mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-14 05:13:47 +00:00
feat(cli): capture-video on-demand fetcher from capture manifest
Adds `hyperframes capture-video <project>` which downloads a single
video from the capture's video-manifest.json on demand. The capture
pipeline writes the manifest + preview PNGs but deliberately skips
the mp4s — a site with 12+ feature videos would balloon the capture
from ~5 MB to hundreds of MB. This command pulls one entry at a
time, addressed by --index N (matched against the manifest entry's
own `index` field, not array offset — the manifest can have gaps).
• SSRF-safe via `safeFetch` from capture/assetDownloader (rejects
private/metadata hosts, re-validates redirects).
• Content-type whitelist: `video/*` + a small set of common
`application/*` variants. Anything else (HTML error pages,
JSON, tracking pixels) aborts cleanly.
• 250 MB hard cap on Content-Length AND body size.
• Filename sanitization: percent-decoded then anything outside
[A-Za-z0-9._-] stripped.
• Race-free write: `flag: "wx"` atomic exclusive-create with
EEXIST handled as 'already downloaded' (no upstream existsSync
precheck — eliminates the TOCTOU pattern).
• Dual-layout aware: checks `<dir>/extracted/` (standalone
capture) and `<dir>/capture/extracted/` (W2H project layout).
• Suggested embed snippet includes `id="video-${entry.index}"`
so the output passes the producer's media discovery / lint.
Registered in cli.ts + help.ts.
This commit is contained in:
@@ -135,6 +135,7 @@ const subCommands = {
|
||||
validate: () => import("./commands/validate.js").then((m) => m.default),
|
||||
snapshot: () => import("./commands/snapshot.js").then((m) => m.default),
|
||||
capture: () => import("./commands/capture.js").then((m) => m.default),
|
||||
"capture-video": () => import("./commands/capture-video.js").then((m) => m.default),
|
||||
lambda: () => import("./commands/lambda.js").then((m) => m.default),
|
||||
cloudrun: () => import("./commands/cloudrun.js").then((m) => m.default),
|
||||
cloud: () => import("./commands/cloud.js").then((m) => m.default),
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
MAX_VIDEO_BYTES,
|
||||
VIDEO_CONTENT_TYPE_RE,
|
||||
pickManifestEntry,
|
||||
safeFilename,
|
||||
type ManifestEntry,
|
||||
} from "./capture-video.js";
|
||||
|
||||
const ENTRY = (index: number, partial: Partial<ManifestEntry> = {}): ManifestEntry => ({
|
||||
index,
|
||||
url: `https://cdn.example.com/video-${index}.mp4`,
|
||||
filename: `video-${index}.mp4`,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
heading: "",
|
||||
caption: "",
|
||||
ariaLabel: "",
|
||||
preview: `assets/videos/previews/video-${index}-preview.png`,
|
||||
...partial,
|
||||
});
|
||||
|
||||
describe("safeFilename", () => {
|
||||
it("decodes percent-encoded chars", () => {
|
||||
expect(safeFilename("Frame-2147227325%20(1).mp4")).toBe("Frame-2147227325_1_.mp4");
|
||||
});
|
||||
|
||||
it("strips characters outside [A-Za-z0-9._-]", () => {
|
||||
expect(safeFilename("video with spaces & symbols!.mp4")).toBe("video_with_spaces_symbols_.mp4");
|
||||
});
|
||||
|
||||
it("preserves the extension and version markers", () => {
|
||||
expect(safeFilename("hero.mp4")).toBe("hero.mp4");
|
||||
expect(safeFilename("hero-v2.webm")).toBe("hero-v2.webm");
|
||||
});
|
||||
|
||||
it("falls back when decodeURIComponent throws on a malformed sequence", () => {
|
||||
// `%E0%A4%A` is a truncated UTF-8 multibyte sequence and throws URIError.
|
||||
// We should keep the raw input rather than crashing.
|
||||
expect(safeFilename("Bad%E0%A4%A.mp4")).toBe("Bad_E0_A4_A.mp4");
|
||||
});
|
||||
|
||||
it("collapses runs of disallowed characters into a single underscore", () => {
|
||||
expect(safeFilename("a b___c")).toBe("a_b___c");
|
||||
});
|
||||
});
|
||||
|
||||
describe("VIDEO_CONTENT_TYPE_RE", () => {
|
||||
it("matches common video content-types", () => {
|
||||
expect(VIDEO_CONTENT_TYPE_RE.test("video/mp4")).toBe(true);
|
||||
expect(VIDEO_CONTENT_TYPE_RE.test("video/webm")).toBe(true);
|
||||
expect(VIDEO_CONTENT_TYPE_RE.test("video/quicktime")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches application/* containers that CDNs commonly use", () => {
|
||||
expect(VIDEO_CONTENT_TYPE_RE.test("application/mp4")).toBe(true);
|
||||
expect(VIDEO_CONTENT_TYPE_RE.test("application/octet-stream")).toBe(true);
|
||||
expect(VIDEO_CONTENT_TYPE_RE.test("application/x-mpegURL")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects HTML / JSON error pages that pretend to be videos", () => {
|
||||
expect(VIDEO_CONTENT_TYPE_RE.test("text/html")).toBe(false);
|
||||
expect(VIDEO_CONTENT_TYPE_RE.test("application/json")).toBe(false);
|
||||
expect(VIDEO_CONTENT_TYPE_RE.test("image/png")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MAX_VIDEO_BYTES", () => {
|
||||
it("is 250 MB", () => {
|
||||
expect(MAX_VIDEO_BYTES).toBe(250 * 1024 * 1024);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickManifestEntry", () => {
|
||||
it("returns no-selector when neither --index nor --url is given", () => {
|
||||
const r = pickManifestEntry([ENTRY(0)], {});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.code).toBe("no-selector");
|
||||
});
|
||||
|
||||
it("looks up by the entry's `index` field, NOT array offset (manifest gaps)", () => {
|
||||
// Captures with failed-preview entries skip the push, so `index` can have
|
||||
// gaps. A user running `--index 3` after seeing `[3] hero` in --list
|
||||
// should get the entry whose `index === 3`, even if it's not array[3].
|
||||
const manifest = [ENTRY(0), ENTRY(2), ENTRY(3)]; // index 1 missing
|
||||
const r = pickManifestEntry(manifest, { index: 3 });
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.entry.url).toBe("https://cdn.example.com/video-3.mp4");
|
||||
});
|
||||
|
||||
it("rejects a request for an index that's not in the manifest", () => {
|
||||
const manifest = [ENTRY(0), ENTRY(2)]; // index 1 missing
|
||||
const r = pickManifestEntry(manifest, { index: 1 });
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.code).toBe("no-match-index");
|
||||
expect(r.message).toContain("index=1");
|
||||
expect(r.message).toContain("available: 0, 2");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a negative or non-integer index up front", () => {
|
||||
expect(pickManifestEntry([ENTRY(0)], { index: -1 }).ok).toBe(false);
|
||||
expect(pickManifestEntry([ENTRY(0)], { index: 1.5 }).ok).toBe(false);
|
||||
expect(pickManifestEntry([ENTRY(0)], { index: "abc" }).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts numeric-string indices (citty parses positional args as strings)", () => {
|
||||
const r = pickManifestEntry([ENTRY(0), ENTRY(1)], { index: "1" });
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.entry.index).toBe(1);
|
||||
});
|
||||
|
||||
it("looks up by exact URL match", () => {
|
||||
const manifest = [ENTRY(0), ENTRY(1)];
|
||||
const r = pickManifestEntry(manifest, { url: "https://cdn.example.com/video-1.mp4" });
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.entry.index).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects a URL that doesn't appear in the manifest", () => {
|
||||
const manifest = [ENTRY(0)];
|
||||
const r = pickManifestEntry(manifest, { url: "https://other.com/missing.mp4" });
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.code).toBe("no-match-url");
|
||||
expect(r.message).toContain("missing.mp4");
|
||||
}
|
||||
});
|
||||
|
||||
it("when both --index and --url are passed, --index wins (CLI's declared priority)", () => {
|
||||
const manifest = [ENTRY(0), ENTRY(1)];
|
||||
const r = pickManifestEntry(manifest, {
|
||||
index: 1,
|
||||
url: "https://cdn.example.com/video-0.mp4",
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.entry.index).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,289 @@
|
||||
import { defineCommand } from "citty";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } 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"],
|
||||
];
|
||||
|
||||
/**
|
||||
* The capture pipeline writes capture/extracted/video-manifest.json listing
|
||||
* every <video> element on the source page (URL, dimensions, heading, caption,
|
||||
* preview PNG) but deliberately does NOT download the mp4s — sites with
|
||||
* dozens of feature videos would balloon the capture size to hundreds of MB.
|
||||
*
|
||||
* This command lets agents pull just the ONE video a beat needs (e.g.
|
||||
* heygen.com's "Orb" hero animation at index 0) on demand. The downloaded
|
||||
* file lands at capture/assets/videos/<filename-from-manifest> so beat
|
||||
* compositions can reference it as `capture/assets/videos/<filename>` —
|
||||
* same pattern as the captured SVGs and rasters.
|
||||
*/
|
||||
|
||||
/** Max video size (250 MB) — a long social/marketing reel rarely exceeds 50 MB
|
||||
* but allow headroom for higher-quality masters. Anything above this is almost
|
||||
* certainly a misconfigured manifest URL pointing at the wrong asset.
|
||||
*/
|
||||
const MAX_VIDEO_BYTES = 250 * 1024 * 1024;
|
||||
|
||||
/** Whitelist of content-types we accept. Anything else (XML/HTML error pages,
|
||||
* tracking pixels, JSON, etc.) means the URL didn't actually point at a video.
|
||||
*/
|
||||
const VIDEO_CONTENT_TYPE_RE = /^(video\/|application\/(mp4|octet-stream|x-mpegurl))/i;
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
async function fetchToBuffer(url: string): Promise<Buffer> {
|
||||
// safeFetch enforces SSRF protection (rejects private/metadata hosts AND
|
||||
// re-validates on every redirect hop — a bare redirect:"follow" only checks
|
||||
// the initial URL, so a public URL can 30x to 169.254.169.254 etc.).
|
||||
const r = await safeFetch(url, {
|
||||
signal: AbortSignal.timeout(120_000),
|
||||
headers: { "User-Agent": "HyperFrames/1.0" },
|
||||
});
|
||||
if (!r) {
|
||||
throw new Error(
|
||||
`fetch blocked or failed (private/metadata host, redirect chain, or network error): ${url}`,
|
||||
);
|
||||
}
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status} ${r.statusText} for ${url}`);
|
||||
const ct = r.headers.get("content-type") || "";
|
||||
if (!VIDEO_CONTENT_TYPE_RE.test(ct)) {
|
||||
throw new Error(
|
||||
`unexpected content-type "${ct}" for ${url} — expected video/*. The URL probably doesn't point at a real video file.`,
|
||||
);
|
||||
}
|
||||
const cl = r.headers.get("content-length");
|
||||
if (cl && Number(cl) > MAX_VIDEO_BYTES) {
|
||||
throw new Error(
|
||||
`video too large (${Math.round(Number(cl) / 1024 / 1024)}MB > ${Math.round(MAX_VIDEO_BYTES / 1024 / 1024)}MB cap) for ${url}`,
|
||||
);
|
||||
}
|
||||
const ab = await r.arrayBuffer();
|
||||
if (ab.byteLength > MAX_VIDEO_BYTES) {
|
||||
throw new Error(
|
||||
`video body exceeds ${Math.round(MAX_VIDEO_BYTES / 1024 / 1024)}MB cap (got ${Math.round(ab.byteLength / 1024 / 1024)}MB) for ${url}`,
|
||||
);
|
||||
}
|
||||
return Buffer.from(ab);
|
||||
}
|
||||
|
||||
export function safeFilename(name: string): string {
|
||||
// Manifest filenames sometimes carry URL-encoded chars (e.g.
|
||||
// "Frame-2147227325%20(1).mp4"). Decode and replace anything that
|
||||
// could be hostile on disk while keeping the extension intact.
|
||||
let decoded = name;
|
||||
try {
|
||||
decoded = decodeURIComponent(name);
|
||||
} catch {
|
||||
/* keep raw */
|
||||
}
|
||||
return decoded.replace(/[^A-Za-z0-9._-]+/g, "_");
|
||||
}
|
||||
|
||||
/** Exported for tests — matches the content types a real video URL returns. */
|
||||
export { VIDEO_CONTENT_TYPE_RE, MAX_VIDEO_BYTES };
|
||||
|
||||
export interface ManifestEntry {
|
||||
index: number;
|
||||
url: string;
|
||||
filename: string;
|
||||
width: number;
|
||||
height: number;
|
||||
heading: string;
|
||||
caption: string;
|
||||
ariaLabel: string;
|
||||
preview: string;
|
||||
}
|
||||
|
||||
export type PickResult =
|
||||
| { ok: true; entry: ManifestEntry }
|
||||
| {
|
||||
ok: false;
|
||||
code: "no-selector" | "bad-index" | "no-match-index" | "no-match-url";
|
||||
message: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pick a manifest entry from `--index N` or `--url URL`. Looks up by the
|
||||
* entry's own `index` field (NOT array offset — `captureVideoManifest` can
|
||||
* skip videos whose preview screenshot fails, leaving gaps in `index`).
|
||||
*/
|
||||
export function pickManifestEntry(
|
||||
manifest: ManifestEntry[],
|
||||
args: { index?: string | number | null; url?: string | null },
|
||||
): PickResult {
|
||||
if (args.index != null) {
|
||||
const i = Number(args.index);
|
||||
if (!Number.isInteger(i) || i < 0) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "bad-index",
|
||||
message: `--index ${args.index} must be a non-negative integer`,
|
||||
};
|
||||
}
|
||||
const found = manifest.find((e) => e.index === i);
|
||||
if (!found) {
|
||||
const available = manifest.map((e) => e.index).join(", ");
|
||||
return {
|
||||
ok: false,
|
||||
code: "no-match-index",
|
||||
message: `no manifest entry with index=${i} (available: ${available || "none"})`,
|
||||
};
|
||||
}
|
||||
return { ok: true, entry: found };
|
||||
}
|
||||
if (args.url != null) {
|
||||
const found = manifest.find((e) => e.url === args.url);
|
||||
if (!found) {
|
||||
return { ok: false, code: "no-match-url", message: `no manifest entry with url=${args.url}` };
|
||||
}
|
||||
return { ok: true, entry: found };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
code: "no-selector",
|
||||
message: "specify --index <N> or --url <URL> (or --list to see what's in the manifest)",
|
||||
};
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
meta: {
|
||||
name: "capture-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",
|
||||
},
|
||||
},
|
||||
// CLI orchestrator: arg parsing + manifest layout detection + lookup + download
|
||||
// + snippet output + error paths. Cyclomatic count is intrinsic to the surface,
|
||||
// not accidental complexity.
|
||||
// fallow-ignore-next-line complexity
|
||||
async run({ args }) {
|
||||
const projectDir = resolve(String(args.project));
|
||||
// `hyperframes capture <url> --output <dir>` writes <dir>/extracted/...
|
||||
// A W2H project nests its capture output under <project>/capture/extracted/... instead.
|
||||
// Accept both so this command works on either layout without the user juggling subdirs.
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// Match the layout detected from where the manifest was found.
|
||||
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}`;
|
||||
|
||||
// No upstream existsSync precheck — that's a TOCTOU race source by
|
||||
// construction (CodeQL js/file-system-race). The atomic exclusive
|
||||
// create below is the single source of truth for "already downloaded?"
|
||||
// and "create new file" — there is no gap for another process to win.
|
||||
console.log(
|
||||
`${c.accent("▸")} downloading [${entry.index}] ${entry.filename} (${entry.width}×${entry.height})`,
|
||||
);
|
||||
console.log(` from: ${entry.url}`);
|
||||
try {
|
||||
const buf = await fetchToBuffer(entry.url);
|
||||
// `flag: "wx"` = exclusive create: throws EEXIST if outPath exists.
|
||||
// Race-free check-and-create in one syscall.
|
||||
try {
|
||||
writeFileSync(outPath, buf, { flag: "wx" });
|
||||
} catch (writeErr) {
|
||||
if ((writeErr as NodeJS.ErrnoException).code === "EEXIST") {
|
||||
console.log(`${c.warn("⚠")} already downloaded: ${relPath} (skipping)`);
|
||||
console.log(` Delete the file and re-run to refetch.`);
|
||||
return;
|
||||
}
|
||||
throw writeErr;
|
||||
}
|
||||
const sizeKb = Math.round(buf.length / 1024);
|
||||
const sizeStr = sizeKb > 1024 ? `${(sizeKb / 1024).toFixed(1)}MB` : `${sizeKb}KB`;
|
||||
console.log(`${c.success("◇")} wrote ${relPath} (${sizeStr})`);
|
||||
// `id` is required by the media lint rule (`media_missing_id`) and the
|
||||
// producer's media discovery; without one the video renders frozen.
|
||||
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) {
|
||||
console.error(`${c.error("✗")} download failed: ${(e as Error).message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -22,6 +22,7 @@ const GROUPS: Group[] = [
|
||||
["init", "Scaffold a new composition project"],
|
||||
["add", "Install a block or component from the registry"],
|
||||
["capture", "Capture a website for video production"],
|
||||
["capture-video", "Download a video referenced in a capture manifest"],
|
||||
["catalog", "Browse and install blocks and components"],
|
||||
["preview", "Start the studio for previewing compositions"],
|
||||
["publish", "Upload a project and get a stable public URL"],
|
||||
|
||||
Reference in New Issue
Block a user