Files
hyperframes/packages/cli/src/commands/capture/video.ts
T
WaterrrForeverandClaude Opus 4.8 a4303137cb fix: storyboard-angle review follow-ups (M1 bg-on-clip, B3 slideshow, parser guard, CLI fixes) (#1791)
* fix(skills): storyboard review — bg-on-clip rule, slideshow output, parser parity guard

Addresses the storyboard-angle review (jrusso1020):

- M1 (invisible text): frame-worker.md (x3) + SKILL.md Step 5 (x3) now require a
  frame's full-bleed background on a class=clip layer, never the #root /
  data-composition-id element (the root is clip-gated to its scene window, so a
  background on it is not a dependable ground and dark text can land on the black
  host body). The assembler already paints frame.md's canvas onto index #root as
  the base ground; the per-frame clip rides on top.
- B3 (slideshow truncates to slide 1): slideshow/SKILL.md gains an Output section
  (decks render via 'present'; 'render index.html' captures only the first
  composition; linear main-line MP4 export is deferred).
- Parser drift: vendoredParity.test.ts guards the three vendored storyboard.mjs
  copies (byte-identical + parse-parity with @hyperframes/core).
- skills-manifest.json regenerated for the edited SKILL.md files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): storyboard review — lint, validate help, snapshot, inspect, capture, render

Addresses the CLI findings from the storyboard-angle review (jrusso1020):

- lint (@hyperframes/lint): accept vendor-prefixed system-font keywords
  -apple-system / BlinkMacSystemFont so a system stack with a generic fallback no
  longer trips font_family_without_font_face (+ test).
- help: list 'validate' under Project in 'hyperframes --help' (was runnable but
  undocumented).
- snapshot: honor -o/--output (the flag did not exist; output was hardcoded to
  snapshots/). The dir is resolved once and threaded through capture + contact
  sheet + Gemini.
- snapshot: split font status into loaded / error / unused with a one-line
  summary; only a real 'error' is reported as FAILED (an unrequested @font-face
  is 'unused', not a contradiction with 'loaded').
- inspect: suppress text_occluded across a scene-to-scene crossfade (occluder in
  a different data-composition-id mount while a scene is mid-fade); a same-scene
  or two-settled-scenes overlap still flags.
- inspect: suppress content_overlap between in-flow siblings governed by the same
  flex/grid container (tight stacks / number lockups are layout slop).
- capture: record source resolution (videoWidth/Height) in video-manifest.json
  alongside the DOM display box; consumers size off the source dims.
- render: warn when the target carries a slideshow island (render captures only
  the first scene, so the MP4 is truncated to slide 1; use 'present').

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:58:00 +08:00

284 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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";
const MAX_VIDEO_BYTES = 250 * 1024 * 1024;
const VIDEO_CONTENT_TYPE_RE = /^(video\/|application\/(mp4|octet-stream|x-mpegurl))/i;
// fallow-ignore-next-line complexity
async function streamToFile(url: string, destPath: string): Promise<number> {
// safeFetch re-validates redirect hops; bare redirect:"follow" leaks to private hosts.
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}`,
);
}
if (!r.body) throw new Error(`empty response body for ${url}`);
// `flags: "wx"` = exclusive-create; throws EEXIST if destPath exists. Stream chunks
// and abort mid-transfer if cumulative bytes exceed the cap so a hostile CDN can't
// OOM the process by lying about content-length.
const file = createWriteStream(destPath, { flags: "wx" });
// Single shared error promise: avoids re-attaching `error` listeners per chunk (MaxListeners warning).
let streamError: Error | null = null;
const streamErrored = new Promise<never>((_, reject) => {
file.once("error", (e) => {
streamError = e;
reject(e);
});
});
let bytes = 0;
try {
await Promise.race([
streamErrored,
new Promise<void>((resolveOpen) => file.once("open", () => resolveOpen())),
]);
for await (const chunk of r.body as unknown as AsyncIterable<Uint8Array>) {
if (streamError) throw streamError;
bytes += chunk.byteLength;
if (bytes > MAX_VIDEO_BYTES) {
throw new Error(
`video exceeded ${Math.round(MAX_VIDEO_BYTES / 1024 / 1024)}MB cap mid-stream for ${url}`,
);
}
// lgtm[js/http-to-file-access] — manifest-vetted URL, content-type whitelist, 250MB cap with mid-stream abort, SSRF-safe fetch
if (!file.write(chunk)) {
await Promise.race([
streamErrored,
new Promise<void>((resolveDrain) => file.once("drain", () => resolveDrain())),
]);
}
}
await new Promise<void>((resolveEnd, rejectEnd) => {
file.end((err?: Error | null) => (err ? rejectEnd(err) : resolveEnd()));
});
return bytes;
} catch (e) {
file.destroy();
// EEXIST means destPath ALREADY existed before we wrote anything — leave it alone.
// Any other error means we created a partial file that the caller should not see.
if ((e as NodeJS.ErrnoException).code !== "EEXIST") {
try {
unlinkSync(destPath);
} catch {
/* partial file may not exist */
}
}
throw e;
}
}
export function safeFilename(name: string): string {
let decoded = name;
try {
decoded = decodeURIComponent(name);
} catch {
/* malformed percent-encoding */
}
return decoded.replace(/[^A-Za-z0-9._-]+/g, "_");
}
export { VIDEO_CONTENT_TYPE_RE, MAX_VIDEO_BYTES };
export interface ManifestEntry {
index: number;
url: string;
filename: string;
width: number;
height: number;
/** Intrinsic clip resolution (videoWidth/Height). Optional — absent in
* manifests written before source dims were recorded. Size off these, not
* the display box (width/height). */
sourceWidth?: number;
sourceHeight?: 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;
};
// Two manifest entries can produce the same safeFilename (URL-encoded variants of the same name
// collapse after decode). The wx exclusive-create + EEXIST handler would silently misreport the
// second as "already downloaded" while serving the first's bytes. Fail loudly instead.
export function findFilenameCollision(
manifest: ManifestEntry[],
selected: ManifestEntry,
): ManifestEntry[] {
const selectedName = safeFilename(selected.filename || basename(selected.url));
return manifest.filter(
(e) =>
e.index !== selected.index && safeFilename(e.filename || basename(e.url)) === selectedName,
);
}
// Looks up by `entry.index`, not array offset — captureVideoManifest leaves gaps when previews fail.
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 interface VideoModeArgs {
project: string;
index?: string | null;
url?: string | null;
list?: boolean;
}
// 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\`?`,
);
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.sourceWidth || e.width}×${e.sourceHeight || 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.sourceWidth || entry.width}×${entry.sourceHeight || 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.sourceWidth || entry.width) === (entry.sourceHeight || 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;
}
}