Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .agents/skills/youtube-ctx/references/visual.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -29,9 +31,13 @@ Choose no more than 30 timestamps that answer the user's question. Prefer a smal
```bash
node <skill-directory>/scripts/watch.mjs frames \
--workspace <workspace-from-index> \
--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.
Expand Down
37 changes: 27 additions & 10 deletions .agents/skills/youtube-ctx/scripts/watch.mjs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

38 changes: 38 additions & 0 deletions packages/youtube-skills/src/watch/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -142,13 +160,33 @@ 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();

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 <ms,...>');
expect(createFetch).not.toHaveBeenCalled();
});
});
29 changes: 21 additions & 8 deletions packages/youtube-skills/src/watch/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Inspect a YouTube storyboard and transcript, then optionally extract exact times

Usage:
watch.mjs index --video-id <id> [options]
watch.mjs frames --workspace <path> --timestamps <seconds,...> [options]
watch.mjs frames --workspace <path> (--timestamps <seconds,...> | --timestamps-ms <milliseconds,...>) [options]
watch.mjs cleanup --workspace <path> [--pretty]

Shared options:
Expand All @@ -33,7 +33,8 @@ Index options:

Frame options:
--workspace <path> Workspace returned by index
--timestamps <seconds,...> One to 30 decimal timestamps
--timestamps <seconds,...> One to 30 decimal timestamps in seconds
--timestamps-ms <ms,...> One to 30 integer timestamps in milliseconds
--max-width <pixels> Output width cap from 320 to 1920
--ffmpeg-path <path> FFmpeg executable; defaults to FFMPEG_PATH or ffmpeg
`;
Expand Down Expand Up @@ -101,7 +102,7 @@ function parseArguments(argv: string[]): { operation: Operation; flags: Flags }
}
const allowed: Record<Operation, string[]> = {
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)) {
Expand All @@ -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<string> {
Expand Down
10 changes: 10 additions & 0 deletions packages/youtube-skills/src/watch/workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
8 changes: 6 additions & 2 deletions packages/youtube-skills/src/watch/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,8 +189,12 @@ export async function extractFrames(options: ExtractFramesRequest): Promise<Fram
}
if (video.durationSeconds !== undefined) {
const durationMs = video.durationSeconds * 1_000;
if (timestamps.some((timestamp) => 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);
Expand Down
Loading