fix(cli): restore hyperframes capture <url>; move video download to --video flag

PR #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
This commit is contained in:
ukimsanov
2026-06-15 16:03:27 -07:00
parent 69aa595f38
commit f8d9f51245
2 changed files with 144 additions and 144 deletions
+42 -9
View File
@@ -8,7 +8,11 @@ export const examples: Example[] = [
["JSON output for AI agents", "hyperframes capture https://example.com --json"],
[
"Pull a video from the captured manifest by index",
"hyperframes capture video ./linear-video --index 0",
"hyperframes capture --video ./linear-video --index 0",
],
[
"List videos referenced in the captured manifest",
"hyperframes capture --video ./linear-video --list",
],
];
@@ -17,14 +21,11 @@ export default defineCommand({
name: "capture",
description: "Capture a website as editable HyperFrames components",
},
subCommands: {
video: () => import("./capture/video.js").then((m) => m.default),
},
args: {
url: {
type: "positional",
description: "Website URL to capture",
required: true,
description: "Website URL to capture (omit when using --video)",
required: false,
},
output: {
type: "string",
@@ -49,12 +50,44 @@ export default defineCommand({
description: "Output JSON (for AI agents / programmatic use)",
default: false,
},
video: {
type: "string",
description:
"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.",
},
index: {
type: "string",
description: "(--video mode) Manifest entry index to download (0-based)",
},
"video-url": {
type: "string",
description: "(--video mode) Exact video URL to download (must match a manifest entry)",
},
list: {
type: "boolean",
description: "(--video mode) List manifest entries and exit",
default: false,
},
},
async run({ args }) {
const url = args.url as string;
if (args.video) {
const { runVideoMode } = await import("./capture/video.js");
await runVideoMode({
project: args.video as string,
index: (args.index as string | undefined) ?? null,
url: (args["video-url"] as string | undefined) ?? null,
list: args.list as boolean,
});
return;
}
// citty fires parent's run AFTER routing to a subcommand; skip when args.url is a subcommand name.
if (url === "video") return;
const url = args.url as string | undefined;
if (!url) {
console.error(
"Missing URL. Pass a website URL, or use --video <project> for video download.",
);
process.exit(1);
}
try {
new URL(url);
+102 -135
View File
@@ -1,21 +1,7 @@
import { defineCommand } from "citty";
import { createWriteStream, existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
import { resolve, join, basename } from "node:path";
import { c } from "../../ui/colors.js";
import { safeFetch } from "../../capture/assetDownloader.js";
import type { Example } from "../_examples.js";
export const examples: Example[] = [
[
"Download the hero video (index 0) from a captured project's manifest",
"capture video ./my-project --index 0",
],
[
"Download a specific video by exact URL",
"capture video ./my-project --url https://cdn.example.com/hero.mp4",
],
["List entries in the manifest without downloading", "capture video ./my-project --list"],
];
const MAX_VIDEO_BYTES = 250 * 1024 * 1024;
const VIDEO_CONTENT_TYPE_RE = /^(video\/|application\/(mp4|octet-stream|x-mpegurl))/i;
@@ -185,127 +171,108 @@ export function pickManifestEntry(
};
}
export default defineCommand({
meta: {
name: "video",
description:
"Download a video referenced in capture/extracted/video-manifest.json (on-demand; the capture pipeline only writes the manifest + preview PNGs)",
},
args: {
project: {
type: "positional",
description: "Path to the captured project directory",
required: true,
},
index: {
type: "string",
description: "Manifest entry index to download (0-based)",
},
url: {
type: "string",
description: "Exact video URL to download (must match a manifest entry)",
},
list: {
type: "boolean",
description: "List manifest entries (index, dimensions, heading) and exit",
},
},
// fallow-ignore-next-line complexity
async run({ args }) {
const projectDir = resolve(String(args.project));
// standalone capture writes `<dir>/extracted/…`; W2H project nests under `<dir>/capture/extracted/…`.
const directPath = join(projectDir, "extracted", "video-manifest.json");
const w2hPath = join(projectDir, "capture", "extracted", "video-manifest.json");
const manifestPath = existsSync(directPath) ? directPath : w2hPath;
const isW2hLayout = manifestPath === w2hPath;
if (!existsSync(manifestPath)) {
console.error(
`${c.error("✗")} no video-manifest.json at ${directPath} or ${w2hPath}\n` +
` Was this directory produced by \`hyperframes capture\`?`,
);
process.exitCode = 1;
return;
}
let manifest: ManifestEntry[];
try {
manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
} catch (e) {
console.error(`${c.error("✗")} video-manifest.json is malformed: ${(e as Error).message}`);
process.exitCode = 1;
return;
}
export interface VideoModeArgs {
project: string;
index?: string | null;
url?: string | null;
list?: boolean;
}
if (args.list) {
if (manifest.length === 0) {
console.log(c.dim("(manifest is empty — no <video> elements on the captured page)"));
return;
}
console.log(
`${manifest.length} video entr${manifest.length === 1 ? "y" : "ies"} in ${manifestPath}:`,
);
for (const e of manifest) {
console.log(
` ${c.bold(`[${e.index}]`)} ${e.filename} ${e.width}×${e.height}` +
(e.heading ? `\n heading: "${e.heading}"` : "") +
`\n url: ${e.url}`,
);
}
return;
}
const pick = pickManifestEntry(manifest, args);
if (!pick.ok) {
console.error(
`${c.error("✗")} ${pick.message}` +
(pick.code === "no-match-url" ? `\n Run with --list to see what's available.` : ""),
);
process.exitCode = 1;
return;
}
const entry = pick.entry;
const collisions = findFilenameCollision(manifest, entry);
if (collisions.length > 0) {
console.error(
`${c.error("✗")} filename "${safeFilename(entry.filename || basename(entry.url))}" ` +
`collides with manifest entr${collisions.length === 1 ? "y" : "ies"} ` +
`${collisions.map((co) => `[${co.index}]`).join(", ")}. ` +
`Refusing to download — the on-disk file's bytes would not match the requested entry.`,
);
process.exitCode = 1;
return;
}
const outDir = isW2hLayout
? join(projectDir, "capture", "assets", "videos")
: join(projectDir, "assets", "videos");
mkdirSync(outDir, { recursive: true });
const fname = safeFilename(entry.filename || basename(entry.url));
const outPath = join(outDir, fname);
const relPath = isW2hLayout ? `capture/assets/videos/${fname}` : `assets/videos/${fname}`;
console.log(
`${c.accent("▸")} downloading [${entry.index}] ${entry.filename} (${entry.width}×${entry.height})`,
// fallow-ignore-next-line complexity
export async function runVideoMode(args: VideoModeArgs): Promise<void> {
const projectDir = resolve(args.project);
// standalone capture writes `<dir>/extracted/…`; W2H project nests under `<dir>/capture/extracted/…`.
const directPath = join(projectDir, "extracted", "video-manifest.json");
const w2hPath = join(projectDir, "capture", "extracted", "video-manifest.json");
const manifestPath = existsSync(directPath) ? directPath : w2hPath;
const isW2hLayout = manifestPath === w2hPath;
if (!existsSync(manifestPath)) {
console.error(
`${c.error("✗")} no video-manifest.json at ${directPath} or ${w2hPath}\n` +
` Was this directory produced by \`hyperframes capture\`?`,
);
console.log(` from: ${entry.url}`);
try {
const bytes = await streamToFile(entry.url, outPath);
const sizeKb = Math.round(bytes / 1024);
const sizeStr = sizeKb > 1024 ? `${(sizeKb / 1024).toFixed(1)}MB` : `${sizeKb}KB`;
console.log(`${c.success("◇")} wrote ${relPath} (${sizeStr})`);
const snippetId = `video-${entry.index}`;
console.log(
` Reference it from a beat composition as:\n` +
` <video id="${snippetId}" src="${relPath}" data-start="0" data-duration="${entry.width === entry.height ? 5 : 4}" data-track-index="0" autoplay muted loop></video>`,
);
} catch (e) {
if ((e as NodeJS.ErrnoException).code === "EEXIST") {
console.log(`${c.warn("⚠")} already downloaded: ${relPath} (skipping)`);
console.log(` Delete the file and re-run to refetch.`);
return;
}
console.error(`${c.error("✗")} download failed: ${(e as Error).message}`);
process.exitCode = 1;
process.exitCode = 1;
return;
}
let manifest: ManifestEntry[];
try {
manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
} catch (e) {
console.error(`${c.error("✗")} video-manifest.json is malformed: ${(e as Error).message}`);
process.exitCode = 1;
return;
}
if (args.list) {
if (manifest.length === 0) {
console.log(c.dim("(manifest is empty — no <video> elements on the captured page)"));
return;
}
},
});
console.log(
`${manifest.length} video entr${manifest.length === 1 ? "y" : "ies"} in ${manifestPath}:`,
);
for (const e of manifest) {
console.log(
` ${c.bold(`[${e.index}]`)} ${e.filename}${e.width}×${e.height}` +
(e.heading ? `\n heading: "${e.heading}"` : "") +
`\n url: ${e.url}`,
);
}
return;
}
const pick = pickManifestEntry(manifest, args);
if (!pick.ok) {
console.error(
`${c.error("✗")} ${pick.message}` +
(pick.code === "no-match-url" ? `\n Run with --list to see what's available.` : ""),
);
process.exitCode = 1;
return;
}
const entry = pick.entry;
const collisions = findFilenameCollision(manifest, entry);
if (collisions.length > 0) {
console.error(
`${c.error("✗")} filename "${safeFilename(entry.filename || basename(entry.url))}" ` +
`collides with manifest entr${collisions.length === 1 ? "y" : "ies"} ` +
`${collisions.map((co) => `[${co.index}]`).join(", ")}. ` +
`Refusing to download — the on-disk file's bytes would not match the requested entry.`,
);
process.exitCode = 1;
return;
}
const outDir = isW2hLayout
? join(projectDir, "capture", "assets", "videos")
: join(projectDir, "assets", "videos");
mkdirSync(outDir, { recursive: true });
const fname = safeFilename(entry.filename || basename(entry.url));
const outPath = join(outDir, fname);
const relPath = isW2hLayout ? `capture/assets/videos/${fname}` : `assets/videos/${fname}`;
console.log(
`${c.accent("▸")} downloading [${entry.index}] ${entry.filename} (${entry.width}×${entry.height})`,
);
console.log(` from: ${entry.url}`);
try {
const bytes = await streamToFile(entry.url, outPath);
const sizeKb = Math.round(bytes / 1024);
const sizeStr = sizeKb > 1024 ? `${(sizeKb / 1024).toFixed(1)}MB` : `${sizeKb}KB`;
console.log(`${c.success("◇")} wrote ${relPath} (${sizeStr})`);
const snippetId = `video-${entry.index}`;
console.log(
` Reference it from a beat composition as:\n` +
` <video id="${snippetId}" src="${relPath}" data-start="0" data-duration="${entry.width === entry.height ? 5 : 4}" data-track-index="0" autoplay muted loop></video>`,
);
} catch (e) {
if ((e as NodeJS.ErrnoException).code === "EEXIST") {
console.log(`${c.warn("⚠")} already downloaded: ${relPath} (skipping)`);
console.log(` Delete the file and re-run to refetch.`);
return;
}
console.error(`${c.error("✗")} download failed: ${(e as Error).message}`);
process.exitCode = 1;
}
}