Skip to content

Commit 7d741e2

Browse files
authored
fix(video): improve local YouTube workflow (#74)
1 parent fa0355e commit 7d741e2

20 files changed

Lines changed: 1079 additions & 179 deletions

File tree

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,10 @@ verifying the source, routing formats safely, covering animations, and reporting
8787

8888
### Media
8989

90-
| Skill | Type | Purpose |
91-
| ------------------------------------------------------------------------ | --------- | ------------------------------------------------- |
92-
| [`download-youtube-video`](skills/media/download-youtube-video/SKILL.md) | Focused | Download one public video as an exact local file |
93-
| [`summarize-youtube`](skills/media/summarize-youtube/SKILL.md) | Composite | Summarize spoken and visual evidence from YouTube |
90+
| Skill | Type | Purpose |
91+
| ------------------------------------------------------------------------ | --------- | ---------------------------------------------------- |
92+
| [`download-youtube-video`](skills/media/download-youtube-video/SKILL.md) | Focused | Download one accessible video as an exact local file |
93+
| [`summarize-youtube`](skills/media/summarize-youtube/SKILL.md) | Composite | Summarize spoken and visual evidence from YouTube |
9494

9595
## Install
9696

apps/video-tools-cli/src/arguments.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ export interface PrepareArguments {
22
command: "prepare";
33
inputPath: string;
44
artifactsDirectory?: string;
5+
expectedSha256?: string;
6+
only?: "audio" | "frames";
57
frameCount?: number;
8+
timestampsSeconds?: number[];
69
maxFrames?: number;
710
timeoutMs?: number;
811
containerImage?: string;
@@ -22,7 +25,10 @@ export function parseArguments(argv: readonly string[]): CliArguments {
2225
}
2326

2427
let artifactsDirectory: string | undefined;
28+
let expectedSha256: string | undefined;
29+
let only: "audio" | "frames" | undefined;
2530
let frameCount: number | undefined;
31+
const timestampsSeconds: number[] = [];
2632
let maxFrames: number | undefined;
2733
let timeoutMs: number | undefined;
2834
let containerImage: string | undefined;
@@ -34,12 +40,21 @@ export function parseArguments(argv: readonly string[]): CliArguments {
3440
case "--artifacts-dir":
3541
artifactsDirectory = value;
3642
break;
43+
case "--expected-sha256":
44+
expectedSha256 = sha256(value, option);
45+
break;
46+
case "--only":
47+
only = mediaLane(value);
48+
break;
3749
case "--max-frames":
3850
maxFrames = positiveInteger(value, option);
3951
break;
4052
case "--frame-count":
4153
frameCount = positiveInteger(value, option);
4254
break;
55+
case "--frame-time":
56+
timestampsSeconds.push(nonNegativeNumber(value, option));
57+
break;
4358
case "--timeout-ms":
4459
timeoutMs = positiveInteger(value, option);
4560
break;
@@ -51,12 +66,24 @@ export function parseArguments(argv: readonly string[]): CliArguments {
5166
}
5267
index += 1;
5368
}
69+
if (frameCount !== undefined && timestampsSeconds.length > 0) {
70+
throw new Error("--frame-count and --frame-time cannot be used together");
71+
}
72+
if (
73+
only === "audio" &&
74+
(frameCount !== undefined || timestampsSeconds.length > 0 || maxFrames !== undefined)
75+
) {
76+
throw new Error("frame options cannot be used with --only audio");
77+
}
5478

5579
return {
5680
command: "prepare",
5781
inputPath,
5882
...(artifactsDirectory === undefined ? {} : { artifactsDirectory }),
83+
...(expectedSha256 === undefined ? {} : { expectedSha256 }),
84+
...(only === undefined ? {} : { only }),
5985
...(frameCount === undefined ? {} : { frameCount }),
86+
...(timestampsSeconds.length === 0 ? {} : { timestampsSeconds }),
6087
...(maxFrames === undefined ? {} : { maxFrames }),
6188
...(timeoutMs === undefined ? {} : { timeoutMs }),
6289
...(containerImage === undefined ? {} : { containerImage }),
@@ -78,3 +105,22 @@ function positiveInteger(value: string, option: string): number {
78105
throw new Error(`${option} requires a positive integer`);
79106
return parsed;
80107
}
108+
109+
function nonNegativeNumber(value: string, option: string): number {
110+
if (value.trim() === "") throw new Error(`${option} requires a non-negative number`);
111+
const parsed = Number(value);
112+
if (!Number.isFinite(parsed) || parsed < 0)
113+
throw new Error(`${option} requires a non-negative number`);
114+
return parsed;
115+
}
116+
117+
function sha256(value: string, option: string): string {
118+
if (!/^[a-f0-9]{64}$/i.test(value))
119+
throw new Error(`${option} requires 64 hexadecimal characters`);
120+
return value.toLowerCase();
121+
}
122+
123+
function mediaLane(value: string): "audio" | "frames" {
124+
if (value !== "audio" && value !== "frames") throw new Error("--only requires audio or frames");
125+
return value;
126+
}

apps/video-tools-cli/src/cli.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,15 @@ import { parseArguments } from "./arguments.ts";
33
import { isComplete, prepareVideo } from "./prepare-video.ts";
44

55
const usage = `Usage:
6-
video-tools prepare <video-path> [--artifacts-dir <directory>] [--frame-count <count>] [--max-frames <count>] [--timeout-ms <milliseconds>] [--container-image <name@sha256:digest>]
6+
video-tools prepare <video-path> [--artifacts-dir <directory>] [--expected-sha256 <sha256>] [--only <audio|frames>] [--frame-count <count> | --frame-time <seconds>...] [--max-frames <count>] [--timeout-ms <milliseconds>] [--container-image <name@sha256:digest>]
77
88
When --artifacts-dir is omitted, the command creates an isolated temporary directory and reports
99
its location in the JSON result. Temporary derivatives are retained for the caller to inspect.
1010
11+
Repeat --frame-time to extract exact moments. Do not combine it with --frame-count.
12+
Use --only when the other media lane is not needed.
13+
Pass --expected-sha256 to prove a later pass reads the same source bytes.
14+
1115
--timeout-ms bounds each tool invocation, not the total run. A preparation that runs several
1216
stages may therefore exceed it in aggregate.
1317

apps/video-tools-cli/src/prepare-video.ts

Lines changed: 51 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,21 @@ export async function prepareVideo(
7676
audio: null,
7777
inputChanged: null,
7878
};
79+
if (args.expectedSha256 !== undefined && args.expectedSha256 !== identity.sha256) {
80+
return {
81+
...empty,
82+
capability: null,
83+
capabilityGap: null,
84+
inputChanged: {
85+
outcome: "input-changed",
86+
inputPath,
87+
message:
88+
"the original file no longer matches the expected SHA-256, so no derivatives were created",
89+
initialSha256: args.expectedSha256,
90+
finalSha256: identity.sha256,
91+
},
92+
};
93+
}
7994

8095
let exec = options.hostExec ?? execWithBun;
8196
let image: ContainerIdentity | undefined;
@@ -131,29 +146,37 @@ export async function prepareVideo(
131146
if (probe.outcome !== "ok") return { ...empty, capability, capabilityGap: null, probe };
132147

133148
const written: string[] = [];
134-
const frames = await extractFrames({
135-
inputPath,
136-
parentSha256: identity.sha256,
137-
artifactsDirectory,
138-
durationSeconds: probe.probe.durationSeconds,
139-
...(args.frameCount === undefined ? {} : { frameCount: args.frameCount }),
140-
cwd,
141-
exec,
142-
...(args.maxFrames === undefined ? {} : { maxFrames: args.maxFrames }),
143-
...(args.timeoutMs === undefined ? {} : { timeoutMs: args.timeoutMs }),
144-
});
145-
if (frames.outcome === "ok") written.push(...frames.frames.map((frame) => frame.derivative.path));
146-
const audio = probe.probe.hasAudioStream
147-
? await extractAudio({
148-
inputPath,
149-
parentSha256: identity.sha256,
150-
artifactsDirectory,
151-
probe: probe.probe,
152-
cwd,
153-
exec,
154-
...(args.timeoutMs === undefined ? {} : { timeoutMs: args.timeoutMs }),
155-
})
156-
: null;
149+
const frames =
150+
args.only === "audio"
151+
? null
152+
: await extractFrames({
153+
inputPath,
154+
parentSha256: identity.sha256,
155+
artifactsDirectory,
156+
durationSeconds: probe.probe.durationSeconds,
157+
...(args.frameCount === undefined ? {} : { frameCount: args.frameCount }),
158+
...(args.timestampsSeconds === undefined
159+
? {}
160+
: { timestampsSeconds: args.timestampsSeconds }),
161+
cwd,
162+
exec,
163+
...(args.maxFrames === undefined ? {} : { maxFrames: args.maxFrames }),
164+
...(args.timeoutMs === undefined ? {} : { timeoutMs: args.timeoutMs }),
165+
});
166+
if (frames?.outcome === "ok")
167+
written.push(...frames.frames.map((frame) => frame.derivative.path));
168+
const audio =
169+
args.only !== "frames" && probe.probe.hasAudioStream
170+
? await extractAudio({
171+
inputPath,
172+
parentSha256: identity.sha256,
173+
artifactsDirectory,
174+
probe: probe.probe,
175+
cwd,
176+
exec,
177+
...(args.timeoutMs === undefined ? {} : { timeoutMs: args.timeoutMs }),
178+
})
179+
: null;
157180
if (audio?.outcome === "ok") written.push(audio.derivative.path);
158181

159182
const inputChanged = await detectInputChange({ identity, writtenPaths: written });
@@ -173,9 +196,11 @@ export async function prepareVideo(
173196

174197
export function isComplete(result: PrepareVideoResult): boolean {
175198
if (result.capabilityGap !== null || result.inputChanged !== null) return false;
176-
if (result.probe?.outcome !== "ok" || result.frames?.outcome !== "ok") return false;
177-
if (!result.probe.probe.hasAudioStream) return true;
178-
if (result.audio?.outcome !== "ok") return false;
199+
if (result.probe?.outcome !== "ok") return false;
200+
if (result.frames !== null && result.frames.outcome !== "ok") return false;
201+
if (result.audio !== null && result.audio.outcome !== "ok") return false;
202+
if (result.frames === null && result.audio === null) return !result.probe.probe.hasAudioStream;
203+
if (result.audio === null) return true;
179204
// A source with several audio streams is not fully read after one of them. Reporting complete
180205
// coverage here is the same overclaim the frame lane already refuses to make.
181206
return result.audio.selection.omittedStreamIndexes.length === 0;

apps/video-tools-cli/tests/arguments.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,62 @@ describe("parseArguments", () => {
3737
inputPath: "fixture.mp4",
3838
});
3939
});
40+
41+
it("parses an audio-only preparation", () => {
42+
expect(
43+
parseArguments([
44+
"prepare",
45+
"fixture.mp4",
46+
"--expected-sha256",
47+
digest.toUpperCase(),
48+
"--only",
49+
"audio",
50+
]),
51+
).toEqual({
52+
command: "prepare",
53+
inputPath: "fixture.mp4",
54+
expectedSha256: digest,
55+
only: "audio",
56+
});
57+
});
58+
59+
it("parses exact frame times", () => {
60+
expect(
61+
parseArguments([
62+
"prepare",
63+
"fixture.mp4",
64+
"--only",
65+
"frames",
66+
"--frame-time",
67+
"0",
68+
"--frame-time",
69+
"12.5",
70+
]),
71+
).toEqual({
72+
command: "prepare",
73+
inputPath: "fixture.mp4",
74+
only: "frames",
75+
timestampsSeconds: [0, 12.5],
76+
});
77+
});
78+
79+
it("refuses ambiguous and invalid frame times", () => {
80+
expect(() =>
81+
parseArguments(["prepare", "fixture.mp4", "--frame-count", "3", "--frame-time", "1"]),
82+
).toThrow("cannot be used together");
83+
expect(() => parseArguments(["prepare", "fixture.mp4", "--frame-time", "-1"])).toThrow(
84+
"non-negative number",
85+
);
86+
expect(() => parseArguments(["prepare", "fixture.mp4", "--frame-time", " "])).toThrow(
87+
"non-negative number",
88+
);
89+
expect(() =>
90+
parseArguments(["prepare", "fixture.mp4", "--only", "audio", "--frame-time", "1"]),
91+
).toThrow("frame options");
92+
expect(() =>
93+
parseArguments(["prepare", "fixture.mp4", "--expected-sha256", "not-a-hash"]),
94+
).toThrow("64 hexadecimal characters");
95+
});
4096
});
4197

4298
describe("parsePinnedImage", () => {

apps/video-tools-cli/tests/prepare-video.test.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,28 @@ function missing(command: string): Error {
4444
}
4545

4646
describe("prepareVideo capability reporting", () => {
47+
it("extracts frames at exact times", async () => {
48+
const directory = await temporaryDirectory();
49+
const inputPath = join(directory, "clip.mp4");
50+
await writeFile(inputPath, "video bytes");
51+
52+
const result = await prepareVideo(
53+
{
54+
command: "prepare",
55+
inputPath,
56+
artifactsDirectory: join(directory, "artifacts"),
57+
only: "frames",
58+
timestampsSeconds: [3, 1],
59+
},
60+
{ cwd: directory, hostExec: workingExec() },
61+
);
62+
63+
if (result.frames?.outcome !== "ok") throw new Error("expected prepared frames");
64+
expect(result.frames.sampling.timestampsSeconds).toEqual([1, 3]);
65+
expect(result.audio).toBeNull();
66+
expect(isComplete(result)).toBe(true);
67+
});
68+
4769
it("creates and reports a temporary artifacts directory when the caller omits one", async () => {
4870
const directory = await temporaryDirectory();
4971
const inputPath = join(directory, "clip.mp4");
@@ -217,7 +239,12 @@ describe("prepareVideo audio coverage", () => {
217239
});
218240

219241
const result = await prepareVideo(
220-
{ command: "prepare", inputPath, artifactsDirectory: join(directory, "artifacts") },
242+
{
243+
command: "prepare",
244+
inputPath,
245+
artifactsDirectory: join(directory, "artifacts"),
246+
only: "audio",
247+
},
221248
{
222249
cwd: directory,
223250
hostExec: async (request) => {
@@ -233,12 +260,54 @@ describe("prepareVideo audio coverage", () => {
233260
);
234261

235262
if (result.audio?.outcome !== "ok") throw new Error("expected an extracted audio lane");
263+
expect(result.frames).toBeNull();
236264
expect(result.audio.selection.omittedStreamIndexes).toEqual([]);
237265
expect(isComplete(result)).toBe(true);
238266
});
239267
});
240268

241269
describe("prepareVideo source identity", () => {
270+
it("stops before tooling when the source changes between passes", async () => {
271+
const directory = await temporaryDirectory();
272+
const inputPath = join(directory, "clip.mp4");
273+
const artifactsDirectory = join(directory, "artifacts");
274+
await writeFile(inputPath, "first video bytes");
275+
const first = await prepareVideo(
276+
{ command: "prepare", inputPath, artifactsDirectory, only: "audio" },
277+
{ cwd: directory, hostExec: workingExec() },
278+
);
279+
await writeFile(inputPath, "different video bytes");
280+
let called = false;
281+
282+
const second = await prepareVideo(
283+
{
284+
command: "prepare",
285+
inputPath,
286+
artifactsDirectory,
287+
expectedSha256: first.file.sha256,
288+
only: "frames",
289+
timestampsSeconds: [1],
290+
},
291+
{
292+
cwd: directory,
293+
hostExec: async () => {
294+
called = true;
295+
return { exitCode: 0, stdout: "", stderr: "" };
296+
},
297+
},
298+
);
299+
300+
expect(second.inputChanged).toMatchObject({
301+
outcome: "input-changed",
302+
initialSha256: first.file.sha256,
303+
finalSha256: second.file.sha256,
304+
});
305+
expect(second.frames).toBeNull();
306+
expect(second.audio).toBeNull();
307+
expect(called).toBe(false);
308+
expect(isComplete(second)).toBe(false);
309+
});
310+
242311
it("discards every derivative and reports input-changed when the original moved underneath it", async () => {
243312
const directory = await temporaryDirectory();
244313
const inputPath = join(directory, "clip.mp4");

0 commit comments

Comments
 (0)