Skip to content

Commit f8d9f51

Browse files
committed
fix(cli): restore hyperframes capture <url>; move video download to --video flag
PR heygen-com#1447 added `capture video` as a citty subCommand. citty's runCommand (node_modules/.bun/citty@0.2.2/.../dist/index.mjs:209-227) treats any non-flag positional as a subcommand-name attempt and throws E_UNKNOWN_COMMAND when it doesn't match — there's no fallback to the parent's positional args, so `hyperframes capture https://vercel.com` died with "Unknown command https://vercel.com". Per James's suggestion, surface video-download as `capture --video <project>` (a mode flag) instead of a subcommand. Citty has no issue with a positional URL coexisting with flags. `video.ts` now exports `runVideoMode()` instead of a `defineCommand` default export. - `hyperframes capture <url>` works again - `hyperframes capture --video <project> --index N` downloads video - `hyperframes capture --video <project> --list` lists manifest - `hyperframes capture --video <project> --video-url <url>` downloads by URL
1 parent 69aa595 commit f8d9f51

2 files changed

Lines changed: 135 additions & 135 deletions

File tree

packages/cli/src/commands/capture.ts

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ export const examples: Example[] = [
88
["JSON output for AI agents", "hyperframes capture https://example.com --json"],
99
[
1010
"Pull a video from the captured manifest by index",
11-
"hyperframes capture video ./linear-video --index 0",
11+
"hyperframes capture --video ./linear-video --index 0",
12+
],
13+
[
14+
"List videos referenced in the captured manifest",
15+
"hyperframes capture --video ./linear-video --list",
1216
],
1317
];
1418

@@ -17,14 +21,11 @@ export default defineCommand({
1721
name: "capture",
1822
description: "Capture a website as editable HyperFrames components",
1923
},
20-
subCommands: {
21-
video: () => import("./capture/video.js").then((m) => m.default),
22-
},
2324
args: {
2425
url: {
2526
type: "positional",
26-
description: "Website URL to capture",
27-
required: true,
27+
description: "Website URL to capture (omit when using --video)",
28+
required: false,
2829
},
2930
output: {
3031
type: "string",
@@ -49,12 +50,44 @@ export default defineCommand({
4950
description: "Output JSON (for AI agents / programmatic use)",
5051
default: false,
5152
},
53+
video: {
54+
type: "string",
55+
description:
56+
"Switch to video-download mode: path to a captured project directory whose video-manifest.json should be read. Pair with --index, --video-url, or --list.",
57+
},
58+
index: {
59+
type: "string",
60+
description: "(--video mode) Manifest entry index to download (0-based)",
61+
},
62+
"video-url": {
63+
type: "string",
64+
description: "(--video mode) Exact video URL to download (must match a manifest entry)",
65+
},
66+
list: {
67+
type: "boolean",
68+
description: "(--video mode) List manifest entries and exit",
69+
default: false,
70+
},
5271
},
5372
async run({ args }) {
54-
const url = args.url as string;
73+
if (args.video) {
74+
const { runVideoMode } = await import("./capture/video.js");
75+
await runVideoMode({
76+
project: args.video as string,
77+
index: (args.index as string | undefined) ?? null,
78+
url: (args["video-url"] as string | undefined) ?? null,
79+
list: args.list as boolean,
80+
});
81+
return;
82+
}
5583

56-
// citty fires parent's run AFTER routing to a subcommand; skip when args.url is a subcommand name.
57-
if (url === "video") return;
84+
const url = args.url as string | undefined;
85+
if (!url) {
86+
console.error(
87+
"Missing URL. Pass a website URL, or use --video <project> for video download.",
88+
);
89+
process.exit(1);
90+
}
5891

5992
try {
6093
new URL(url);

packages/cli/src/commands/capture/video.ts

Lines changed: 93 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,7 @@
1-
import { defineCommand } from "citty";
21
import { createWriteStream, existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
32
import { resolve, join, basename } from "node:path";
43
import { c } from "../../ui/colors.js";
54
import { safeFetch } from "../../capture/assetDownloader.js";
6-
import type { Example } from "../_examples.js";
7-
8-
export const examples: Example[] = [
9-
[
10-
"Download the hero video (index 0) from a captured project's manifest",
11-
"capture video ./my-project --index 0",
12-
],
13-
[
14-
"Download a specific video by exact URL",
15-
"capture video ./my-project --url https://cdn.example.com/hero.mp4",
16-
],
17-
["List entries in the manifest without downloading", "capture video ./my-project --list"],
18-
];
195

206
const MAX_VIDEO_BYTES = 250 * 1024 * 1024;
217
const VIDEO_CONTENT_TYPE_RE = /^(video\/|application\/(mp4|octet-stream|x-mpegurl))/i;
@@ -185,127 +171,108 @@ export function pickManifestEntry(
185171
};
186172
}
187173

188-
export default defineCommand({
189-
meta: {
190-
name: "video",
191-
description:
192-
"Download a video referenced in capture/extracted/video-manifest.json (on-demand; the capture pipeline only writes the manifest + preview PNGs)",
193-
},
194-
args: {
195-
project: {
196-
type: "positional",
197-
description: "Path to the captured project directory",
198-
required: true,
199-
},
200-
index: {
201-
type: "string",
202-
description: "Manifest entry index to download (0-based)",
203-
},
204-
url: {
205-
type: "string",
206-
description: "Exact video URL to download (must match a manifest entry)",
207-
},
208-
list: {
209-
type: "boolean",
210-
description: "List manifest entries (index, dimensions, heading) and exit",
211-
},
212-
},
213-
// fallow-ignore-next-line complexity
214-
async run({ args }) {
215-
const projectDir = resolve(String(args.project));
216-
// standalone capture writes `<dir>/extracted/…`; W2H project nests under `<dir>/capture/extracted/…`.
217-
const directPath = join(projectDir, "extracted", "video-manifest.json");
218-
const w2hPath = join(projectDir, "capture", "extracted", "video-manifest.json");
219-
const manifestPath = existsSync(directPath) ? directPath : w2hPath;
220-
const isW2hLayout = manifestPath === w2hPath;
221-
if (!existsSync(manifestPath)) {
222-
console.error(
223-
`${c.error("✗")} no video-manifest.json at ${directPath} or ${w2hPath}\n` +
224-
` Was this directory produced by \`hyperframes capture\`?`,
225-
);
226-
process.exitCode = 1;
227-
return;
228-
}
229-
let manifest: ManifestEntry[];
230-
try {
231-
manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
232-
} catch (e) {
233-
console.error(`${c.error("✗")} video-manifest.json is malformed: ${(e as Error).message}`);
234-
process.exitCode = 1;
174+
export interface VideoModeArgs {
175+
project: string;
176+
index?: string | null;
177+
url?: string | null;
178+
list?: boolean;
179+
}
180+
181+
// fallow-ignore-next-line complexity
182+
export async function runVideoMode(args: VideoModeArgs): Promise<void> {
183+
const projectDir = resolve(args.project);
184+
// standalone capture writes `<dir>/extracted/…`; W2H project nests under `<dir>/capture/extracted/…`.
185+
const directPath = join(projectDir, "extracted", "video-manifest.json");
186+
const w2hPath = join(projectDir, "capture", "extracted", "video-manifest.json");
187+
const manifestPath = existsSync(directPath) ? directPath : w2hPath;
188+
const isW2hLayout = manifestPath === w2hPath;
189+
if (!existsSync(manifestPath)) {
190+
console.error(
191+
`${c.error("✗")} no video-manifest.json at ${directPath} or ${w2hPath}\n` +
192+
` Was this directory produced by \`hyperframes capture\`?`,
193+
);
194+
process.exitCode = 1;
195+
return;
196+
}
197+
let manifest: ManifestEntry[];
198+
try {
199+
manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
200+
} catch (e) {
201+
console.error(`${c.error("✗")} video-manifest.json is malformed: ${(e as Error).message}`);
202+
process.exitCode = 1;
203+
return;
204+
}
205+
206+
if (args.list) {
207+
if (manifest.length === 0) {
208+
console.log(c.dim("(manifest is empty — no <video> elements on the captured page)"));
235209
return;
236210
}
237-
238-
if (args.list) {
239-
if (manifest.length === 0) {
240-
console.log(c.dim("(manifest is empty — no <video> elements on the captured page)"));
241-
return;
242-
}
211+
console.log(
212+
`${manifest.length} video entr${manifest.length === 1 ? "y" : "ies"} in ${manifestPath}:`,
213+
);
214+
for (const e of manifest) {
243215
console.log(
244-
`${manifest.length} video entr${manifest.length === 1 ? "y" : "ies"} in ${manifestPath}:`,
216+
` ${c.bold(`[${e.index}]`)} ${e.filename}${e.width}×${e.height}` +
217+
(e.heading ? `\n heading: "${e.heading}"` : "") +
218+
`\n url: ${e.url}`,
245219
);
246-
for (const e of manifest) {
247-
console.log(
248-
` ${c.bold(`[${e.index}]`)} ${e.filename}${e.width}×${e.height}` +
249-
(e.heading ? `\n heading: "${e.heading}"` : "") +
250-
`\n url: ${e.url}`,
251-
);
252-
}
253-
return;
254220
}
221+
return;
222+
}
255223

256-
const pick = pickManifestEntry(manifest, args);
257-
if (!pick.ok) {
258-
console.error(
259-
`${c.error("✗")} ${pick.message}` +
260-
(pick.code === "no-match-url" ? `\n Run with --list to see what's available.` : ""),
261-
);
262-
process.exitCode = 1;
263-
return;
264-
}
265-
const entry = pick.entry;
224+
const pick = pickManifestEntry(manifest, args);
225+
if (!pick.ok) {
226+
console.error(
227+
`${c.error("✗")} ${pick.message}` +
228+
(pick.code === "no-match-url" ? `\n Run with --list to see what's available.` : ""),
229+
);
230+
process.exitCode = 1;
231+
return;
232+
}
233+
const entry = pick.entry;
266234

267-
const collisions = findFilenameCollision(manifest, entry);
268-
if (collisions.length > 0) {
269-
console.error(
270-
`${c.error("✗")} filename "${safeFilename(entry.filename || basename(entry.url))}" ` +
271-
`collides with manifest entr${collisions.length === 1 ? "y" : "ies"} ` +
272-
`${collisions.map((co) => `[${co.index}]`).join(", ")}. ` +
273-
`Refusing to download — the on-disk file's bytes would not match the requested entry.`,
274-
);
275-
process.exitCode = 1;
276-
return;
277-
}
235+
const collisions = findFilenameCollision(manifest, entry);
236+
if (collisions.length > 0) {
237+
console.error(
238+
`${c.error("✗")} filename "${safeFilename(entry.filename || basename(entry.url))}" ` +
239+
`collides with manifest entr${collisions.length === 1 ? "y" : "ies"} ` +
240+
`${collisions.map((co) => `[${co.index}]`).join(", ")}. ` +
241+
`Refusing to download — the on-disk file's bytes would not match the requested entry.`,
242+
);
243+
process.exitCode = 1;
244+
return;
245+
}
278246

279-
const outDir = isW2hLayout
280-
? join(projectDir, "capture", "assets", "videos")
281-
: join(projectDir, "assets", "videos");
282-
mkdirSync(outDir, { recursive: true });
283-
const fname = safeFilename(entry.filename || basename(entry.url));
284-
const outPath = join(outDir, fname);
285-
const relPath = isW2hLayout ? `capture/assets/videos/${fname}` : `assets/videos/${fname}`;
247+
const outDir = isW2hLayout
248+
? join(projectDir, "capture", "assets", "videos")
249+
: join(projectDir, "assets", "videos");
250+
mkdirSync(outDir, { recursive: true });
251+
const fname = safeFilename(entry.filename || basename(entry.url));
252+
const outPath = join(outDir, fname);
253+
const relPath = isW2hLayout ? `capture/assets/videos/${fname}` : `assets/videos/${fname}`;
286254

255+
console.log(
256+
`${c.accent("▸")} downloading [${entry.index}] ${entry.filename} (${entry.width}×${entry.height})`,
257+
);
258+
console.log(` from: ${entry.url}`);
259+
try {
260+
const bytes = await streamToFile(entry.url, outPath);
261+
const sizeKb = Math.round(bytes / 1024);
262+
const sizeStr = sizeKb > 1024 ? `${(sizeKb / 1024).toFixed(1)}MB` : `${sizeKb}KB`;
263+
console.log(`${c.success("◇")} wrote ${relPath} (${sizeStr})`);
264+
const snippetId = `video-${entry.index}`;
287265
console.log(
288-
`${c.accent("▸")} downloading [${entry.index}] ${entry.filename} (${entry.width}×${entry.height})`,
266+
` Reference it from a beat composition as:\n` +
267+
` <video id="${snippetId}" src="${relPath}" data-start="0" data-duration="${entry.width === entry.height ? 5 : 4}" data-track-index="0" autoplay muted loop></video>`,
289268
);
290-
console.log(` from: ${entry.url}`);
291-
try {
292-
const bytes = await streamToFile(entry.url, outPath);
293-
const sizeKb = Math.round(bytes / 1024);
294-
const sizeStr = sizeKb > 1024 ? `${(sizeKb / 1024).toFixed(1)}MB` : `${sizeKb}KB`;
295-
console.log(`${c.success("◇")} wrote ${relPath} (${sizeStr})`);
296-
const snippetId = `video-${entry.index}`;
297-
console.log(
298-
` Reference it from a beat composition as:\n` +
299-
` <video id="${snippetId}" src="${relPath}" data-start="0" data-duration="${entry.width === entry.height ? 5 : 4}" data-track-index="0" autoplay muted loop></video>`,
300-
);
301-
} catch (e) {
302-
if ((e as NodeJS.ErrnoException).code === "EEXIST") {
303-
console.log(`${c.warn("⚠")} already downloaded: ${relPath} (skipping)`);
304-
console.log(` Delete the file and re-run to refetch.`);
305-
return;
306-
}
307-
console.error(`${c.error("✗")} download failed: ${(e as Error).message}`);
308-
process.exitCode = 1;
269+
} catch (e) {
270+
if ((e as NodeJS.ErrnoException).code === "EEXIST") {
271+
console.log(`${c.warn("⚠")} already downloaded: ${relPath} (skipping)`);
272+
console.log(` Delete the file and re-run to refetch.`);
273+
return;
309274
}
310-
},
311-
});
275+
console.error(`${c.error("✗")} download failed: ${(e as Error).message}`);
276+
process.exitCode = 1;
277+
}
278+
}

0 commit comments

Comments
 (0)