mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +00:00
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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
a4eacaec37
commit
a4303137cb
@@ -3,7 +3,7 @@ import { spawn } from "node:child_process";
|
||||
import { defineCommand } from "citty";
|
||||
import { existsSync, mkdtempSync, readFileSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolve, join, relative, isAbsolute } from "node:path";
|
||||
import { resolve, join, relative, isAbsolute, basename } from "node:path";
|
||||
import { resolveProject } from "../utils/project.js";
|
||||
import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js";
|
||||
import { serveStaticProjectHtml } from "../utils/staticProjectServer.js";
|
||||
@@ -94,7 +94,7 @@ export const examples: Example[] = [
|
||||
*/
|
||||
async function captureSnapshots(
|
||||
projectDir: string,
|
||||
opts: { frames?: number; timeout?: number; at?: number[] },
|
||||
opts: { frames?: number; timeout?: number; at?: number[]; outputDir?: string },
|
||||
): Promise<string[]> {
|
||||
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
|
||||
const { ensureBrowser } = await import("../browser/manager.js");
|
||||
@@ -176,26 +176,37 @@ async function captureSnapshots(
|
||||
// Extra settle time for media and animations to initialize
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
|
||||
// Font verification — report which fonts loaded vs fell back
|
||||
// Font verification — split into loaded / errored / unused. Only status
|
||||
// "error" is a real failure; a face still "unloaded"/"loading" after
|
||||
// document.fonts.ready + the settle wait was simply never requested by any
|
||||
// rendered text (an unused @font-face), so it is reported as "unused", not
|
||||
// FAILED — printing it as FAILED alongside "loaded" read as a contradiction.
|
||||
const fontReport = await page
|
||||
.evaluate(() => {
|
||||
const loaded: string[] = [];
|
||||
const failed: string[] = [];
|
||||
const errored: string[] = [];
|
||||
const unused: string[] = [];
|
||||
(document as any).fonts.forEach((f: any) => {
|
||||
const entry = `${f.family} (${f.weight} ${f.style})`;
|
||||
if (f.status === "loaded") loaded.push(entry);
|
||||
else failed.push(entry + ` [${f.status}]`);
|
||||
else if (f.status === "error") errored.push(entry);
|
||||
else unused.push(entry);
|
||||
});
|
||||
return { loaded, failed };
|
||||
return { loaded, errored, unused };
|
||||
})
|
||||
.catch(() => ({ loaded: [] as string[], failed: [] as string[] }));
|
||||
.catch(() => ({ loaded: [] as string[], errored: [] as string[], unused: [] as string[] }));
|
||||
|
||||
if (fontReport.loaded.length > 0 || fontReport.failed.length > 0) {
|
||||
console.log(
|
||||
`\n ${c.dim("Fonts loaded:")} ${fontReport.loaded.length > 0 ? fontReport.loaded.join(", ") : "none"}`,
|
||||
);
|
||||
if (fontReport.failed.length > 0) {
|
||||
console.log(` ${c.error("Fonts FAILED:")} ${fontReport.failed.join(", ")}`);
|
||||
if (
|
||||
fontReport.loaded.length > 0 ||
|
||||
fontReport.errored.length > 0 ||
|
||||
fontReport.unused.length > 0
|
||||
) {
|
||||
const parts = [`${fontReport.loaded.length} loaded`];
|
||||
if (fontReport.errored.length > 0) parts.push(`${fontReport.errored.length} failed`);
|
||||
if (fontReport.unused.length > 0) parts.push(`${fontReport.unused.length} unused`);
|
||||
console.log(`\n ${c.dim("Fonts:")} ${parts.join(", ")}`);
|
||||
if (fontReport.errored.length > 0) {
|
||||
console.log(` ${c.error("Fonts FAILED:")} ${fontReport.errored.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,7 +232,7 @@ async function captureSnapshots(
|
||||
? [duration / 2]
|
||||
: Array.from({ length: numFrames }, (_, i) => (i / (numFrames - 1)) * duration);
|
||||
|
||||
const snapshotDir = join(projectDir, "snapshots");
|
||||
const snapshotDir = opts.outputDir ?? join(projectDir, "snapshots");
|
||||
mkdirSync(snapshotDir, { recursive: true });
|
||||
try {
|
||||
const { readdirSync } = await import("node:fs");
|
||||
@@ -387,7 +398,8 @@ async function captureSnapshots(
|
||||
const framePath = join(snapshotDir, filename);
|
||||
|
||||
await page.screenshot({ path: framePath, type: "png" });
|
||||
savedPaths.push(`snapshots/${filename}`);
|
||||
const rel = relative(projectDir, framePath);
|
||||
savedPaths.push(rel.startsWith("..") || isAbsolute(rel) ? framePath : rel);
|
||||
}
|
||||
} finally {
|
||||
await chromeBrowser.close();
|
||||
@@ -410,6 +422,11 @@ export default defineCommand({
|
||||
description: "Project directory",
|
||||
required: false,
|
||||
},
|
||||
output: {
|
||||
type: "string",
|
||||
alias: "o",
|
||||
description: "Directory to write snapshots into (default: <project>/snapshots)",
|
||||
},
|
||||
frames: {
|
||||
type: "string",
|
||||
description: "Number of evenly-spaced frames to capture (default: 5)",
|
||||
@@ -457,7 +474,15 @@ export default defineCommand({
|
||||
console.log(`${c.accent("◆")} Capturing ${label} from ${c.accent(project.name)}`);
|
||||
|
||||
try {
|
||||
const paths = await captureSnapshots(project.dir, { frames, timeout, at: atTimestamps });
|
||||
const snapshotDir = args.output
|
||||
? resolve(String(args.output))
|
||||
: join(project.dir, "snapshots");
|
||||
const paths = await captureSnapshots(project.dir, {
|
||||
frames,
|
||||
timeout,
|
||||
at: atTimestamps,
|
||||
outputDir: snapshotDir,
|
||||
});
|
||||
|
||||
if (paths.length === 0) {
|
||||
console.log(
|
||||
@@ -466,7 +491,9 @@ export default defineCommand({
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n${c.success("◇")} ${paths.length} snapshots saved to snapshots/`);
|
||||
console.log(
|
||||
`\n${c.success("◇")} ${paths.length} snapshots saved to ${args.output ? snapshotDir : "snapshots/"}`,
|
||||
);
|
||||
for (const p of paths) {
|
||||
console.log(` ${p}`);
|
||||
}
|
||||
@@ -474,7 +501,6 @@ export default defineCommand({
|
||||
// Generate contact sheet for quick AI review
|
||||
try {
|
||||
const { createSnapshotContactSheet } = await import("../capture/contactSheet.js");
|
||||
const snapshotDir = join(project.dir, "snapshots");
|
||||
const sheets = await createSnapshotContactSheet(
|
||||
snapshotDir,
|
||||
join(snapshotDir, "contact-sheet.jpg"),
|
||||
@@ -501,8 +527,6 @@ export default defineCommand({
|
||||
const { GoogleGenAI } = await import("@google/genai");
|
||||
const ai = new GoogleGenAI({ apiKey: geminiKey });
|
||||
const model = process.env.HYPERFRAMES_GEMINI_MODEL || "gemini-3.1-flash-lite-preview";
|
||||
const snapshotDir = join(project.dir, "snapshots");
|
||||
|
||||
const customQuestion =
|
||||
describeArg === "true"
|
||||
? "Describe this video composition frame in 1-2 sentences. Be specific and factual: what elements are visible, what text appears, is the frame blank/black/loading, what is the composition. Flag any obvious problems."
|
||||
@@ -533,7 +557,7 @@ export default defineCommand({
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
paths.map(async (p) => {
|
||||
const filename = p.replace("snapshots/", "");
|
||||
const filename = basename(p);
|
||||
const filePath = join(snapshotDir, filename);
|
||||
if (!existsSync(filePath)) return { filename, desc: "file not found" };
|
||||
const raw = readFileSync(filePath);
|
||||
|
||||
Reference in New Issue
Block a user