mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +00:00
* 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>
284 lines
10 KiB
TypeScript
284 lines
10 KiB
TypeScript
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;
|
||
}
|
||
}
|