diff --git a/.agents/skills/youtube-ctx/references/visual.md b/.agents/skills/youtube-ctx/references/visual.md index b0507e9..0bab693 100644 --- a/.agents/skills/youtube-ctx/references/visual.md +++ b/.agents/skills/youtube-ctx/references/visual.md @@ -16,6 +16,8 @@ Parse the JSON response. Read the available timed transcript and, when present, (firstFrameIndex + row * columns + column) * intervalMs ``` +This formula returns an integer `timestampMs`. Keep storyboard-derived timestamps in milliseconds. When `video.durationSeconds` is available, require each value to be less than `video.durationSeconds * 1000`. + Use `--granularity word` only when word-level timing materially changes the task. After reading the index, answer directly when it provides enough evidence for the requested claims. Storyboards are suited to video structure, scene or slide sequences, locating demonstrations, and rough visual changes. @@ -29,9 +31,13 @@ Choose no more than 30 timestamps that answer the user's question. Prefer a smal ```bash node /scripts/watch.mjs frames \ --workspace \ - --timestamps 30,686,1000 + --timestamps-ms 1000,4500,11000 ``` +Use `--timestamps-ms` for values calculated from the storyboard index. Use `--timestamps` only for timestamps expressed in seconds, including decimals such as `1,4.5,11`. Never pass storyboard millisecond values to `--timestamps`. + +If validation identifies an out-of-range timestamp, check the selected flag and its unit before editing the list. Do not guess which value failed or retry by dropping values speculatively. + Explicitly open every returned `frames[].path` with the image-viewing tool. Keep each image associated with `timestampMs`; a file path alone is not visual context. Reflect `failures` and `meta.warnings` when the answer depends on missing or low-resolution evidence. If exact frames fail with `DEPENDENCY_MISSING`, continue with a qualified index-only answer when the available evidence supports one. Report that FFmpeg must be installed or supplied with `--ffmpeg-path` when the unresolved question requires exact frames. Do not install system software without user authorization. diff --git a/.agents/skills/youtube-ctx/scripts/watch.mjs b/.agents/skills/youtube-ctx/scripts/watch.mjs index c1bd2a3..0dcc895 100755 --- a/.agents/skills/youtube-ctx/scripts/watch.mjs +++ b/.agents/skills/youtube-ctx/scripts/watch.mjs @@ -21720,8 +21720,12 @@ async function extractFrames(options) { } if (video.durationSeconds !== void 0) { const durationMs = video.durationSeconds * 1e3; - if (timestamps2.some((timestamp) => timestamp >= durationMs)) { - throw new YouTubeClientError("INVALID_INPUT", "Every timestamp must be within the video duration."); + const invalidTimestamp = timestamps2.find((timestamp) => timestamp >= durationMs); + if (invalidTimestamp !== void 0) { + throw new YouTubeClientError( + "INVALID_INPUT", + `Timestamp ${invalidTimestamp}ms must be less than the video duration of ${durationMs}ms.` + ); } } const outputDir = resolve3(options.outputDir); @@ -21841,7 +21845,7 @@ Inspect a YouTube storyboard and transcript, then optionally extract exact times Usage: watch.mjs index --video-id [options] - watch.mjs frames --workspace --timestamps [options] + watch.mjs frames --workspace (--timestamps | --timestamps-ms ) [options] watch.mjs cleanup --workspace [--pretty] Shared options: @@ -21855,7 +21859,8 @@ Index options: Frame options: --workspace Workspace returned by index - --timestamps One to 30 decimal timestamps + --timestamps One to 30 decimal timestamps in seconds + --timestamps-ms One to 30 integer timestamps in milliseconds --max-width Output width cap from 320 to 1920 --ffmpeg-path FFmpeg executable; defaults to FFMPEG_PATH or ffmpeg `; @@ -21896,7 +21901,7 @@ function parseArguments(argv) { } const allowed = { index: ["video-id", "lang", "granularity", "proxy", "pretty"], - frames: ["workspace", "timestamps", "max-width", "ffmpeg-path", "proxy", "pretty"], + frames: ["workspace", "timestamps", "timestamps-ms", "max-width", "ffmpeg-path", "proxy", "pretty"], cleanup: ["workspace", "pretty"] }; for (const name of Object.keys(flags)) { @@ -21920,16 +21925,28 @@ function integerFlag(flags, name) { return parsed; } function timestamps(flags) { - const value = stringFlag(flags, "timestamps", true); + const secondsValue = stringFlag(flags, "timestamps"); + const millisecondsValue = stringFlag(flags, "timestamps-ms"); + if (secondsValue === void 0 === (millisecondsValue === void 0)) { + throw new WatchCliInputError("Provide exactly one of --timestamps or --timestamps-ms."); + } + const name = secondsValue === void 0 ? "timestamps-ms" : "timestamps"; + const value = secondsValue ?? millisecondsValue; const parts = value.split(","); if (parts.length < 1 || parts.length > 30) { - throw new WatchCliInputError("--timestamps must contain between 1 and 30 values."); + throw new WatchCliInputError(`--${name} must contain between 1 and 30 values.`); } const result = parts.map((part) => Number(part.trim())); - if (result.some((seconds) => !Number.isFinite(seconds) || seconds < 0)) { - throw new WatchCliInputError("--timestamps must contain non-negative seconds."); + if (secondsValue !== void 0) { + if (result.some((seconds) => !Number.isFinite(seconds) || seconds < 0)) { + throw new WatchCliInputError("--timestamps must contain non-negative seconds."); + } + return [...new Set(result.map((seconds) => Math.round(seconds * 1e3)))]; + } + if (result.some((milliseconds) => !Number.isSafeInteger(milliseconds) || milliseconds < 0)) { + throw new WatchCliInputError("--timestamps-ms must contain non-negative integer milliseconds."); } - return [...new Set(result.map((seconds) => Math.round(seconds * 1e3)))]; + return [...new Set(result)]; } async function createWorkspace() { return await mkdtemp(join3(tmpdir(), WORKSPACE_PREFIX)); diff --git a/packages/youtube-skills/src/watch/cli.test.ts b/packages/youtube-skills/src/watch/cli.test.ts index bdb6f67..0b6a183 100644 --- a/packages/youtube-skills/src/watch/cli.test.ts +++ b/packages/youtube-skills/src/watch/cli.test.ts @@ -95,6 +95,24 @@ describe('youtube-ctx visual CLI', () => { expect(JSON.parse(io.output[0]!).meta.partial).toBe(true); }); + test('accepts storyboard timestamps in milliseconds without rescaling them', async () => { + const io = capture(); + const workspace = await markedWorkspace(); + const extractFrames = vi.fn(async () => ({ + videoId: 'abcdefghijk', frames: [], failures: [], + meta: { partial: false, warnings: [] }, + })); + + const code = await runWatchCli([ + 'frames', '--workspace', workspace, '--timestamps-ms', '1000,4500,11000', + ], io, {}, { ...fetchDependency(), extractFrames }); + + expect(code).toBe(0); + expect(extractFrames).toHaveBeenCalledWith(expect.objectContaining({ + timestampsMs: [1_000, 4_500, 11_000], + })); + }); + test('preserves structured missing-FFmpeg errors', async () => { const io = capture(); const workspace = await markedWorkspace(); @@ -142,6 +160,25 @@ describe('youtube-ctx visual CLI', () => { expect(extractFrames).not.toHaveBeenCalled(); }); + test.each([ + ['neither timestamp flag', []], + ['both timestamp flags', ['--timestamps', '1', '--timestamps-ms', '1000']], + ])('requires exactly one timestamp unit for %s', async (_case, timestampArguments) => { + const workspace = await markedWorkspace(); + const io = capture(); + const extractFrames = vi.fn(); + + const code = await runWatchCli([ + 'frames', '--workspace', workspace, ...timestampArguments, + ], io, {}, { ...fetchDependency(), extractFrames }); + + expect(code).toBe(2); + expect(JSON.parse(io.errors[0]!).error.message).toBe( + 'Provide exactly one of --timestamps or --timestamps-ms.', + ); + expect(extractFrames).not.toHaveBeenCalled(); + }); + test('prints youtube-ctx visual help without constructing a client', async () => { const io = capture(); const createFetch = vi.fn(); @@ -149,6 +186,7 @@ describe('youtube-ctx visual CLI', () => { expect(await runWatchCli(['--help'], io, {}, { createFetch })).toBe(0); expect(io.output.join('')).toContain('youtube-ctx visual'); expect(io.output.join('')).toContain('watch.mjs index'); + expect(io.output.join('')).toContain('--timestamps-ms '); expect(createFetch).not.toHaveBeenCalled(); }); }); diff --git a/packages/youtube-skills/src/watch/cli.ts b/packages/youtube-skills/src/watch/cli.ts index c6f0891..c8faffc 100644 --- a/packages/youtube-skills/src/watch/cli.ts +++ b/packages/youtube-skills/src/watch/cli.ts @@ -19,7 +19,7 @@ Inspect a YouTube storyboard and transcript, then optionally extract exact times Usage: watch.mjs index --video-id [options] - watch.mjs frames --workspace --timestamps [options] + watch.mjs frames --workspace (--timestamps | --timestamps-ms ) [options] watch.mjs cleanup --workspace [--pretty] Shared options: @@ -33,7 +33,8 @@ Index options: Frame options: --workspace Workspace returned by index - --timestamps One to 30 decimal timestamps + --timestamps One to 30 decimal timestamps in seconds + --timestamps-ms One to 30 integer timestamps in milliseconds --max-width Output width cap from 320 to 1920 --ffmpeg-path FFmpeg executable; defaults to FFMPEG_PATH or ffmpeg `; @@ -101,7 +102,7 @@ function parseArguments(argv: string[]): { operation: Operation; flags: Flags } } const allowed: Record = { index: ['video-id', 'lang', 'granularity', 'proxy', 'pretty'], - frames: ['workspace', 'timestamps', 'max-width', 'ffmpeg-path', 'proxy', 'pretty'], + frames: ['workspace', 'timestamps', 'timestamps-ms', 'max-width', 'ffmpeg-path', 'proxy', 'pretty'], cleanup: ['workspace', 'pretty'], }; for (const name of Object.keys(flags)) { @@ -128,16 +129,28 @@ function integerFlag(flags: Flags, name: string): number | undefined { } function timestamps(flags: Flags): number[] { - const value = stringFlag(flags, 'timestamps', true)!; + const secondsValue = stringFlag(flags, 'timestamps'); + const millisecondsValue = stringFlag(flags, 'timestamps-ms'); + if ((secondsValue === undefined) === (millisecondsValue === undefined)) { + throw new WatchCliInputError('Provide exactly one of --timestamps or --timestamps-ms.'); + } + const name = secondsValue === undefined ? 'timestamps-ms' : 'timestamps'; + const value = secondsValue ?? millisecondsValue!; const parts = value.split(','); if (parts.length < 1 || parts.length > 30) { - throw new WatchCliInputError('--timestamps must contain between 1 and 30 values.'); + throw new WatchCliInputError(`--${name} must contain between 1 and 30 values.`); } const result = parts.map((part) => Number(part.trim())); - if (result.some((seconds) => !Number.isFinite(seconds) || seconds < 0)) { - throw new WatchCliInputError('--timestamps must contain non-negative seconds.'); + if (secondsValue !== undefined) { + if (result.some((seconds) => !Number.isFinite(seconds) || seconds < 0)) { + throw new WatchCliInputError('--timestamps must contain non-negative seconds.'); + } + return [...new Set(result.map((seconds) => Math.round(seconds * 1_000)))]; + } + if (result.some((milliseconds) => !Number.isSafeInteger(milliseconds) || milliseconds < 0)) { + throw new WatchCliInputError('--timestamps-ms must contain non-negative integer milliseconds.'); } - return [...new Set(result.map((seconds) => Math.round(seconds * 1_000)))]; + return [...new Set(result)]; } async function createWorkspace(): Promise { diff --git a/packages/youtube-skills/src/watch/workflow.test.ts b/packages/youtube-skills/src/watch/workflow.test.ts index 01013a4..f235055 100644 --- a/packages/youtube-skills/src/watch/workflow.test.ts +++ b/packages/youtube-skills/src/watch/workflow.test.ts @@ -162,4 +162,14 @@ describe('youtube-ctx visual workflow', () => { })).rejects.toMatchObject({ code: 'DEPENDENCY_MISSING' }); expect(mocks.loadMediaCandidateGroup).not.toHaveBeenCalled(); }); + + test('identifies an out-of-range timestamp and the video duration in milliseconds', async () => { + await expect(extractFrames({ + videoId: 'abcdefghijk', outputDir: '/tmp/watch-test', timestampsMs: [59_000, 60_000], + })).rejects.toMatchObject({ + code: 'INVALID_INPUT', + message: 'Timestamp 60000ms must be less than the video duration of 60000ms.', + }); + expect(mocks.resolveFfmpegExecutable).not.toHaveBeenCalled(); + }); }); diff --git a/packages/youtube-skills/src/watch/workflow.ts b/packages/youtube-skills/src/watch/workflow.ts index a594a86..f8756bc 100644 --- a/packages/youtube-skills/src/watch/workflow.ts +++ b/packages/youtube-skills/src/watch/workflow.ts @@ -189,8 +189,12 @@ export async function extractFrames(options: ExtractFramesRequest): Promise timestamp >= durationMs)) { - throw new YouTubeClientError('INVALID_INPUT', 'Every timestamp must be within the video duration.'); + const invalidTimestamp = timestamps.find((timestamp) => timestamp >= durationMs); + if (invalidTimestamp !== undefined) { + throw new YouTubeClientError( + 'INVALID_INPUT', + `Timestamp ${invalidTimestamp}ms must be less than the video duration of ${durationMs}ms.`, + ); } } const outputDir = resolve(options.outputDir);