mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +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
@@ -511,6 +511,8 @@ export function generateAssetDescriptions(
|
|||||||
heading?: string;
|
heading?: string;
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
|
sourceWidth?: number;
|
||||||
|
sourceHeight?: number;
|
||||||
}>;
|
}>;
|
||||||
for (const v of manifest) {
|
for (const v of manifest) {
|
||||||
if (!v.localPath) continue; // only describe clips that actually downloaded
|
if (!v.localPath) continue; // only describe clips that actually downloaded
|
||||||
@@ -518,7 +520,9 @@ export function generateAssetDescriptions(
|
|||||||
if (!base) continue;
|
if (!base) continue;
|
||||||
const desc =
|
const desc =
|
||||||
(v.caption || v.heading || "").trim().replace(/\s+/g, " ").slice(0, 140) || "motion clip";
|
(v.caption || v.heading || "").trim().replace(/\s+/g, " ").slice(0, 140) || "motion clip";
|
||||||
const dims = v.width && v.height ? `, ~${v.width}×${v.height}` : "";
|
const dimW = v.sourceWidth || v.width;
|
||||||
|
const dimH = v.sourceHeight || v.height;
|
||||||
|
const dims = dimW && dimH ? `, ~${dimW}×${dimH}` : "";
|
||||||
videoLines.push(`${base} — [video] ${desc}${dims}`);
|
videoLines.push(`${base} — [video] ${desc}${dims}`);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -277,6 +277,8 @@ interface VideoDescriptor {
|
|||||||
src: string;
|
src: string;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
sourceWidth: number;
|
||||||
|
sourceHeight: number;
|
||||||
top: number;
|
top: number;
|
||||||
left: number;
|
left: number;
|
||||||
heading: string;
|
heading: string;
|
||||||
@@ -315,8 +317,14 @@ const VIDEO_SCAN_EXPR = `(() => {
|
|||||||
if (!ariaLabel && wrapper) ariaLabel = wrapper.getAttribute('aria-label') || '';
|
if (!ariaLabel && wrapper) ariaLabel = wrapper.getAttribute('aria-label') || '';
|
||||||
return {
|
return {
|
||||||
src: src,
|
src: src,
|
||||||
|
// width/height are the DOM display box (what the page laid the element out
|
||||||
|
// at); sourceWidth/Height are the clip's intrinsic resolution. Size planners
|
||||||
|
// off the source dims, not the display box (a 1920x1080 clip can display at
|
||||||
|
// 904x613). 0 when metadata has not loaded yet.
|
||||||
width: Math.round(rect.width),
|
width: Math.round(rect.width),
|
||||||
height: Math.round(rect.height),
|
height: Math.round(rect.height),
|
||||||
|
sourceWidth: v.videoWidth || 0,
|
||||||
|
sourceHeight: v.videoHeight || 0,
|
||||||
top: Math.round(rect.top),
|
top: Math.round(rect.top),
|
||||||
left: Math.round(rect.left),
|
left: Math.round(rect.left),
|
||||||
heading: heading,
|
heading: heading,
|
||||||
@@ -418,6 +426,8 @@ export async function captureVideoManifest(
|
|||||||
filename: k,
|
filename: k,
|
||||||
width: 0,
|
width: 0,
|
||||||
height: 0,
|
height: 0,
|
||||||
|
sourceWidth: 0,
|
||||||
|
sourceHeight: 0,
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
heading: "",
|
heading: "",
|
||||||
@@ -441,6 +451,8 @@ export async function captureVideoManifest(
|
|||||||
filename: string;
|
filename: string;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
sourceWidth: number;
|
||||||
|
sourceHeight: number;
|
||||||
heading: string;
|
heading: string;
|
||||||
caption: string;
|
caption: string;
|
||||||
ariaLabel: string;
|
ariaLabel: string;
|
||||||
@@ -508,6 +520,8 @@ export async function captureVideoManifest(
|
|||||||
filename: v.filename,
|
filename: v.filename,
|
||||||
width: v.width,
|
width: v.width,
|
||||||
height: v.height,
|
height: v.height,
|
||||||
|
sourceWidth: v.sourceWidth,
|
||||||
|
sourceHeight: v.sourceHeight,
|
||||||
heading: v.heading,
|
heading: v.heading,
|
||||||
caption: v.caption,
|
caption: v.caption,
|
||||||
ariaLabel: v.ariaLabel,
|
ariaLabel: v.ariaLabel,
|
||||||
|
|||||||
@@ -104,6 +104,11 @@ export interface ManifestEntry {
|
|||||||
filename: string;
|
filename: string;
|
||||||
width: number;
|
width: number;
|
||||||
height: 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;
|
heading: string;
|
||||||
caption: string;
|
caption: string;
|
||||||
ariaLabel: string;
|
ariaLabel: string;
|
||||||
@@ -213,7 +218,7 @@ export async function runVideoMode(args: VideoModeArgs): Promise<void> {
|
|||||||
);
|
);
|
||||||
for (const e of manifest) {
|
for (const e of manifest) {
|
||||||
console.log(
|
console.log(
|
||||||
` ${c.bold(`[${e.index}]`)} ${e.filename} — ${e.width}×${e.height}` +
|
` ${c.bold(`[${e.index}]`)} ${e.filename} — ${e.sourceWidth || e.width}×${e.sourceHeight || e.height}` +
|
||||||
(e.heading ? `\n heading: "${e.heading}"` : "") +
|
(e.heading ? `\n heading: "${e.heading}"` : "") +
|
||||||
`\n url: ${e.url}`,
|
`\n url: ${e.url}`,
|
||||||
);
|
);
|
||||||
@@ -253,7 +258,7 @@ export async function runVideoMode(args: VideoModeArgs): Promise<void> {
|
|||||||
const relPath = isW2hLayout ? `capture/assets/videos/${fname}` : `assets/videos/${fname}`;
|
const relPath = isW2hLayout ? `capture/assets/videos/${fname}` : `assets/videos/${fname}`;
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`${c.accent("▸")} downloading [${entry.index}] ${entry.filename} (${entry.width}×${entry.height})`,
|
`${c.accent("▸")} downloading [${entry.index}] ${entry.filename} (${entry.sourceWidth || entry.width}×${entry.sourceHeight || entry.height})`,
|
||||||
);
|
);
|
||||||
console.log(` from: ${entry.url}`);
|
console.log(` from: ${entry.url}`);
|
||||||
try {
|
try {
|
||||||
@@ -264,7 +269,7 @@ export async function runVideoMode(args: VideoModeArgs): Promise<void> {
|
|||||||
const snippetId = `video-${entry.index}`;
|
const snippetId = `video-${entry.index}`;
|
||||||
console.log(
|
console.log(
|
||||||
` Reference it from a beat composition as:\n` +
|
` 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>`,
|
` <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) {
|
} catch (e) {
|
||||||
if ((e as NodeJS.ErrnoException).code === "EEXIST") {
|
if ((e as NodeJS.ErrnoException).code === "EEXIST") {
|
||||||
|
|||||||
@@ -473,11 +473,37 @@
|
|||||||
return a.contains(b) || b.contains(a);
|
return a.contains(b) || b.contains(a);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isInFlow(element) {
|
||||||
|
const position = getComputedStyle(element).position;
|
||||||
|
return position === "static" || position === "relative" || position === "sticky";
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestFlexGridAncestor(element) {
|
||||||
|
for (let parent = element.parentElement; parent; parent = parent.parentElement) {
|
||||||
|
const display = getComputedStyle(parent).display;
|
||||||
|
if (display.includes("flex") || display.includes("grid")) return parent;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two in-flow text blocks governed by the same flex/grid container are placed
|
||||||
|
// by the layout engine, which reserves space for each — they cannot visually
|
||||||
|
// collide. Any measured text-rect overlap between them is line-box / leading
|
||||||
|
// slop (tight stacks, number lockups, super/subscript units), not a collision.
|
||||||
|
// A real overlap bug needs free positioning (absolute/fixed), which keeps a
|
||||||
|
// different formatting context and is still flagged.
|
||||||
|
function isManagedFlowOverlap(a, b) {
|
||||||
|
if (!isInFlow(a) || !isInFlow(b)) return false;
|
||||||
|
const container = nearestFlexGridAncestor(a);
|
||||||
|
return !!container && container === nearestFlexGridAncestor(b);
|
||||||
|
}
|
||||||
|
|
||||||
// Two solid text blocks whose boxes overlap by more than a fifth of the
|
// Two solid text blocks whose boxes overlap by more than a fifth of the
|
||||||
// smaller block read as a collision — unreadable, and invisible to the
|
// smaller block read as a collision — unreadable, and invisible to the
|
||||||
// overflow checks, which only compare an element against its container.
|
// overflow checks, which only compare an element against its container.
|
||||||
function overlapIssue(a, b, time) {
|
function overlapIssue(a, b, time) {
|
||||||
if (isNested(a.element, b.element)) return null;
|
if (isNested(a.element, b.element)) return null;
|
||||||
|
if (isManagedFlowOverlap(a.element, b.element)) return null;
|
||||||
const area = intersectionArea(a.rect, b.rect);
|
const area = intersectionArea(a.rect, b.rect);
|
||||||
if (area <= Math.min(rectArea(a.rect), rectArea(b.rect)) * 0.2) return null;
|
if (area <= Math.min(rectArea(a.rect), rectArea(b.rect)) * 0.2) return null;
|
||||||
return {
|
return {
|
||||||
@@ -537,13 +563,30 @@
|
|||||||
return !!hit && hit !== element && !element.contains(hit) && !hit.contains(element);
|
return !!hit && hit !== element && !element.contains(hit) && !hit.contains(element);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// During a scene-to-scene crossfade the incoming scene paints over the
|
||||||
|
// outgoing scene's still-visible text at >= 0.6 opacity — and `--at-transitions`
|
||||||
|
// samples exactly that midpoint. That overlap is the transition doing its job,
|
||||||
|
// not an occlusion bug. Detect it: the occluder lives in a DIFFERENT composition
|
||||||
|
// mount ([data-composition-id]) than the text, and at least one of the two scenes
|
||||||
|
// is mid-fade (effective opacity < 1). Two fully-settled scenes overlapping
|
||||||
|
// (both opacity 1) is NOT suppressed — that is a real layering bug.
|
||||||
|
function isCrossSceneTransitionOverlap(textEl, occluder) {
|
||||||
|
const textScene = textEl.closest("[data-composition-id]");
|
||||||
|
const occluderScene = occluder.closest("[data-composition-id]");
|
||||||
|
if (!textScene || !occluderScene || textScene === occluderScene) return false;
|
||||||
|
return Math.min(opacityChain(textScene), opacityChain(occluderScene)) < 0.999;
|
||||||
|
}
|
||||||
|
|
||||||
// The opaque element painted over (x, y), or null when the topmost element
|
// The opaque element painted over (x, y), or null when the topmost element
|
||||||
// there is related to the text or non-opaque.
|
// there is related to the text, non-opaque, or a transient crossfade overlap.
|
||||||
|
// fallow-ignore-next-line complexity
|
||||||
function occluderAt(element, x, y) {
|
function occluderAt(element, x, y) {
|
||||||
if (typeof document.elementFromPoint !== "function") return null;
|
if (typeof document.elementFromPoint !== "function") return null;
|
||||||
const hit = document.elementFromPoint(x, y);
|
const hit = document.elementFromPoint(x, y);
|
||||||
if (!isForeignElement(element, hit)) return null;
|
if (!isForeignElement(element, hit)) return null;
|
||||||
return isOpaqueOccluder(hit) ? hit : null;
|
if (!isOpaqueOccluder(hit)) return null;
|
||||||
|
if (isCrossSceneTransitionOverlap(element, hit)) return null;
|
||||||
|
return hit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sweep a grid across the text box (three rows, not just the mid-line, so
|
// Sweep a grid across the text box (three rows, not just the mid-line, so
|
||||||
|
|||||||
@@ -661,6 +661,30 @@ export default defineCommand({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Slideshow guard ───────────────────────────────────────────────────
|
||||||
|
// A slideshow deck is several top-level scene compositions with no master
|
||||||
|
// root. `render` captures only the FIRST composition, so a deck renders as a
|
||||||
|
// silently truncated MP4 (e.g. slide 1 of a 40s deck). Warn and point at the
|
||||||
|
// deck-native path. Best-effort — never block a render on this probe.
|
||||||
|
if (!quiet) {
|
||||||
|
try {
|
||||||
|
const renderTarget = entryFile ? resolve(project.dir, entryFile) : project.indexPath;
|
||||||
|
const { slideshowIslandRegex } = await import("@hyperframes/core/slideshow");
|
||||||
|
if (slideshowIslandRegex("i").test(readFileSync(renderTarget, "utf8"))) {
|
||||||
|
console.log(
|
||||||
|
c.warn("⚠") +
|
||||||
|
" This composition carries a slideshow island — `render` captures only the first" +
|
||||||
|
" scene, so the MP4 will be truncated to slide 1. Use " +
|
||||||
|
c.accent("hyperframes present") +
|
||||||
|
" for the deck; a linear main-line MP4 export is not yet available.",
|
||||||
|
);
|
||||||
|
console.log("");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* best-effort — a missing/unreadable target surfaces later in the real flow */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Print render plan ─────────────────────────────────────────────────
|
// ── Print render plan ─────────────────────────────────────────────────
|
||||||
if (!quiet && !batchPath) {
|
if (!quiet && !batchPath) {
|
||||||
const workerLabel =
|
const workerLabel =
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { spawn } from "node:child_process";
|
|||||||
import { defineCommand } from "citty";
|
import { defineCommand } from "citty";
|
||||||
import { existsSync, mkdtempSync, readFileSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdtempSync, readFileSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
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 { resolveProject } from "../utils/project.js";
|
||||||
import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js";
|
import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js";
|
||||||
import { serveStaticProjectHtml } from "../utils/staticProjectServer.js";
|
import { serveStaticProjectHtml } from "../utils/staticProjectServer.js";
|
||||||
@@ -94,7 +94,7 @@ export const examples: Example[] = [
|
|||||||
*/
|
*/
|
||||||
async function captureSnapshots(
|
async function captureSnapshots(
|
||||||
projectDir: string,
|
projectDir: string,
|
||||||
opts: { frames?: number; timeout?: number; at?: number[] },
|
opts: { frames?: number; timeout?: number; at?: number[]; outputDir?: string },
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
|
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
|
||||||
const { ensureBrowser } = await import("../browser/manager.js");
|
const { ensureBrowser } = await import("../browser/manager.js");
|
||||||
@@ -176,26 +176,37 @@ async function captureSnapshots(
|
|||||||
// Extra settle time for media and animations to initialize
|
// Extra settle time for media and animations to initialize
|
||||||
await new Promise((r) => setTimeout(r, 1500));
|
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
|
const fontReport = await page
|
||||||
.evaluate(() => {
|
.evaluate(() => {
|
||||||
const loaded: string[] = [];
|
const loaded: string[] = [];
|
||||||
const failed: string[] = [];
|
const errored: string[] = [];
|
||||||
|
const unused: string[] = [];
|
||||||
(document as any).fonts.forEach((f: any) => {
|
(document as any).fonts.forEach((f: any) => {
|
||||||
const entry = `${f.family} (${f.weight} ${f.style})`;
|
const entry = `${f.family} (${f.weight} ${f.style})`;
|
||||||
if (f.status === "loaded") loaded.push(entry);
|
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) {
|
if (
|
||||||
console.log(
|
fontReport.loaded.length > 0 ||
|
||||||
`\n ${c.dim("Fonts loaded:")} ${fontReport.loaded.length > 0 ? fontReport.loaded.join(", ") : "none"}`,
|
fontReport.errored.length > 0 ||
|
||||||
);
|
fontReport.unused.length > 0
|
||||||
if (fontReport.failed.length > 0) {
|
) {
|
||||||
console.log(` ${c.error("Fonts FAILED:")} ${fontReport.failed.join(", ")}`);
|
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]
|
? [duration / 2]
|
||||||
: Array.from({ length: numFrames }, (_, i) => (i / (numFrames - 1)) * duration);
|
: 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 });
|
mkdirSync(snapshotDir, { recursive: true });
|
||||||
try {
|
try {
|
||||||
const { readdirSync } = await import("node:fs");
|
const { readdirSync } = await import("node:fs");
|
||||||
@@ -387,7 +398,8 @@ async function captureSnapshots(
|
|||||||
const framePath = join(snapshotDir, filename);
|
const framePath = join(snapshotDir, filename);
|
||||||
|
|
||||||
await page.screenshot({ path: framePath, type: "png" });
|
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 {
|
} finally {
|
||||||
await chromeBrowser.close();
|
await chromeBrowser.close();
|
||||||
@@ -410,6 +422,11 @@ export default defineCommand({
|
|||||||
description: "Project directory",
|
description: "Project directory",
|
||||||
required: false,
|
required: false,
|
||||||
},
|
},
|
||||||
|
output: {
|
||||||
|
type: "string",
|
||||||
|
alias: "o",
|
||||||
|
description: "Directory to write snapshots into (default: <project>/snapshots)",
|
||||||
|
},
|
||||||
frames: {
|
frames: {
|
||||||
type: "string",
|
type: "string",
|
||||||
description: "Number of evenly-spaced frames to capture (default: 5)",
|
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)}`);
|
console.log(`${c.accent("◆")} Capturing ${label} from ${c.accent(project.name)}`);
|
||||||
|
|
||||||
try {
|
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) {
|
if (paths.length === 0) {
|
||||||
console.log(
|
console.log(
|
||||||
@@ -466,7 +491,9 @@ export default defineCommand({
|
|||||||
process.exit(1);
|
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) {
|
for (const p of paths) {
|
||||||
console.log(` ${p}`);
|
console.log(` ${p}`);
|
||||||
}
|
}
|
||||||
@@ -474,7 +501,6 @@ export default defineCommand({
|
|||||||
// Generate contact sheet for quick AI review
|
// Generate contact sheet for quick AI review
|
||||||
try {
|
try {
|
||||||
const { createSnapshotContactSheet } = await import("../capture/contactSheet.js");
|
const { createSnapshotContactSheet } = await import("../capture/contactSheet.js");
|
||||||
const snapshotDir = join(project.dir, "snapshots");
|
|
||||||
const sheets = await createSnapshotContactSheet(
|
const sheets = await createSnapshotContactSheet(
|
||||||
snapshotDir,
|
snapshotDir,
|
||||||
join(snapshotDir, "contact-sheet.jpg"),
|
join(snapshotDir, "contact-sheet.jpg"),
|
||||||
@@ -501,8 +527,6 @@ export default defineCommand({
|
|||||||
const { GoogleGenAI } = await import("@google/genai");
|
const { GoogleGenAI } = await import("@google/genai");
|
||||||
const ai = new GoogleGenAI({ apiKey: geminiKey });
|
const ai = new GoogleGenAI({ apiKey: geminiKey });
|
||||||
const model = process.env.HYPERFRAMES_GEMINI_MODEL || "gemini-3.1-flash-lite-preview";
|
const model = process.env.HYPERFRAMES_GEMINI_MODEL || "gemini-3.1-flash-lite-preview";
|
||||||
const snapshotDir = join(project.dir, "snapshots");
|
|
||||||
|
|
||||||
const customQuestion =
|
const customQuestion =
|
||||||
describeArg === "true"
|
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."
|
? "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(
|
const results = await Promise.allSettled(
|
||||||
paths.map(async (p) => {
|
paths.map(async (p) => {
|
||||||
const filename = p.replace("snapshots/", "");
|
const filename = basename(p);
|
||||||
const filePath = join(snapshotDir, filename);
|
const filePath = join(snapshotDir, filename);
|
||||||
if (!existsSync(filePath)) return { filename, desc: "file not found" };
|
if (!existsSync(filePath)) return { filename, desc: "file not found" };
|
||||||
const raw = readFileSync(filePath);
|
const raw = readFileSync(filePath);
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ const GROUPS: Group[] = [
|
|||||||
title: "Project",
|
title: "Project",
|
||||||
commands: [
|
commands: [
|
||||||
["lint", "Validate a composition for common mistakes"],
|
["lint", "Validate a composition for common mistakes"],
|
||||||
|
[
|
||||||
|
"validate",
|
||||||
|
"Runtime-validate a composition in headless Chrome (JS errors, missing assets, contrast)",
|
||||||
|
],
|
||||||
["beats", "Detect beats in the music track and write beats/<audio>.json"],
|
["beats", "Detect beats in the music track and write beats/<audio>.json"],
|
||||||
["inspect", "Inspect rendered visual layout across the timeline"],
|
["inspect", "Inspect rendered visual layout across the timeline"],
|
||||||
["snapshot", "Capture key frames as PNG screenshots for visual verification"],
|
["snapshot", "Capture key frames as PNG screenshots for visual verification"],
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { parseStoryboard as coreParse } from "./parseStoryboard.js";
|
||||||
|
|
||||||
|
// Sync guard for the hand-vendored parser.
|
||||||
|
//
|
||||||
|
// Each standalone skill ships a plain-JS copy of this parser at
|
||||||
|
// scripts/lib/storyboard.mjs — skills install via `npx skills add`, where a
|
||||||
|
// script can't reach @hyperframes/core (and the core export is .ts that node
|
||||||
|
// can't load). The copies MUST stay in lockstep with core. This test fails the
|
||||||
|
// moment they drift, so a parser change can't silently leave the skills behind:
|
||||||
|
// 1. every vendored copy is byte-identical to the others, and
|
||||||
|
// 2. a vendored copy parses a representative storyboard identically to core.
|
||||||
|
// If you change the parser, update every path below in the same commit.
|
||||||
|
const VENDORED_RELATIVE = [
|
||||||
|
"../../../../skills/faceless-explainer/scripts/lib/storyboard.mjs",
|
||||||
|
"../../../../skills/pr-to-video/scripts/lib/storyboard.mjs",
|
||||||
|
"../../../../skills/product-launch-video/scripts/lib/storyboard.mjs",
|
||||||
|
];
|
||||||
|
|
||||||
|
const vendoredUrls = VENDORED_RELATIVE.map((rel) => new URL(rel, import.meta.url));
|
||||||
|
|
||||||
|
// Exercises frontmatter (known keys + an unknown global), H2/H3 frame headings,
|
||||||
|
// every recognized meta key + an alias (transition / vo / description), an unknown
|
||||||
|
// status (warning + stash under extra), an unparseable duration (warning), unknown
|
||||||
|
// extra keys, and free-form narrative — the surfaces most likely to drift.
|
||||||
|
const SAMPLE = `---
|
||||||
|
format: 1080x1920
|
||||||
|
message: "Ship it"
|
||||||
|
arc: Hook → Proof → CTA
|
||||||
|
audience: builders
|
||||||
|
campaign: spring
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frame 1 — Hook
|
||||||
|
- duration: 3s
|
||||||
|
- status: animated
|
||||||
|
- transition: cut
|
||||||
|
- vo: "Open cold."
|
||||||
|
- src: compositions/frames/01-hook.html
|
||||||
|
- poster: 2s
|
||||||
|
- effect: shimmer
|
||||||
|
|
||||||
|
Open on the promise.
|
||||||
|
|
||||||
|
### Beat 2 — Detail
|
||||||
|
- duration: four
|
||||||
|
- status: glowing
|
||||||
|
- description: a quiet hold
|
||||||
|
- voiceover: "The payoff."
|
||||||
|
|
||||||
|
Land it.
|
||||||
|
`;
|
||||||
|
|
||||||
|
describe("vendored storyboard parser parity", () => {
|
||||||
|
it("every vendored copy is byte-identical", () => {
|
||||||
|
const bodies = vendoredUrls.map((u) => readFileSync(fileURLToPath(u), "utf8"));
|
||||||
|
for (let i = 1; i < bodies.length; i++) {
|
||||||
|
expect(
|
||||||
|
bodies[i],
|
||||||
|
`${VENDORED_RELATIVE[i]} drifted from ${VENDORED_RELATIVE[0]} — re-vendor it`,
|
||||||
|
).toBe(bodies[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a vendored copy parses identically to core", async () => {
|
||||||
|
const vendored = await import(vendoredUrls[0].href);
|
||||||
|
expect(vendored.parseStoryboard(SAMPLE)).toEqual(coreParse(SAMPLE));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -261,6 +261,16 @@ describe("font rules", () => {
|
|||||||
expect(findings).toHaveLength(0);
|
expect(findings).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not flag vendor-prefixed system-font keywords (-apple-system, BlinkMacSystemFont)", async () => {
|
||||||
|
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||||
|
<style>
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, system-ui, sans-serif; }
|
||||||
|
</style>
|
||||||
|
</div>`;
|
||||||
|
const findings = await findByCode(html, "font_family_without_font_face");
|
||||||
|
expect(findings).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not flag installed registry blocks that declare fonts via Google Fonts", async () => {
|
it("does not flag installed registry blocks that declare fonts via Google Fonts", async () => {
|
||||||
const html =
|
const html =
|
||||||
`<!-- hyperframes-registry-item: my-block -->\n` +
|
`<!-- hyperframes-registry-item: my-block -->\n` +
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ const GENERIC_FAMILIES = new Set([
|
|||||||
"math",
|
"math",
|
||||||
"emoji",
|
"emoji",
|
||||||
"fangsong",
|
"fangsong",
|
||||||
|
// Vendor-prefixed system-font keywords. Like `system-ui`, the engine resolves
|
||||||
|
// these to the OS UI font — they are never installable files and must not be
|
||||||
|
// flagged as a missing @font-face, even when a generic fallback follows them
|
||||||
|
// (e.g. `-apple-system, system-ui, sans-serif`).
|
||||||
|
"-apple-system",
|
||||||
|
"blinkmacsystemfont",
|
||||||
"inherit",
|
"inherit",
|
||||||
"initial",
|
"initial",
|
||||||
"unset",
|
"unset",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"files": 144
|
"files": 144
|
||||||
},
|
},
|
||||||
"faceless-explainer": {
|
"faceless-explainer": {
|
||||||
"hash": "b94e52c77d04c1ea",
|
"hash": "edbe47dd738d14d8",
|
||||||
"files": 17
|
"files": 17
|
||||||
},
|
},
|
||||||
"general-video": {
|
"general-video": {
|
||||||
@@ -54,11 +54,11 @@
|
|||||||
"files": 132
|
"files": 132
|
||||||
},
|
},
|
||||||
"pr-to-video": {
|
"pr-to-video": {
|
||||||
"hash": "85a7577aee41bb82",
|
"hash": "ef4a3aa5a943aeec",
|
||||||
"files": 21
|
"files": 21
|
||||||
},
|
},
|
||||||
"product-launch-video": {
|
"product-launch-video": {
|
||||||
"hash": "5341508d185c091d",
|
"hash": "b7bee220096f2ae2",
|
||||||
"files": 18
|
"files": 18
|
||||||
},
|
},
|
||||||
"remotion-to-hyperframes": {
|
"remotion-to-hyperframes": {
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
"files": 70
|
"files": 70
|
||||||
},
|
},
|
||||||
"slideshow": {
|
"slideshow": {
|
||||||
"hash": "ae8d8f3093ef0c04",
|
"hash": "2f006c93745fcc32",
|
||||||
"files": 2
|
"files": 2
|
||||||
},
|
},
|
||||||
"talking-head-recut": {
|
"talking-head-recut": {
|
||||||
|
|||||||
@@ -128,6 +128,8 @@ Before dispatch, read `sub-agents/frame-worker.md` and `../hyperframes-core/refe
|
|||||||
|
|
||||||
Each worker context must include `PROJECT_DIR`, `frame_id`, canvas size, caption status and keep-out band if captions are enabled, and `RULES_DIR` as the absolute path to this skill's `../hyperframes-animation/rules/`. Each worker reads `frame.md`, its own `## Frame N` block from `STORYBOARD.md`, the local rule recipe (`../hyperframes-animation/rules/<id>.md`) for each cited motion, and the frame's blueprint template (`../hyperframes-animation/blueprints/<id>.md`). Each worker writes only `compositions/frames/NN-*.html`. Workers must never edit `STORYBOARD.md`.
|
Each worker context must include `PROJECT_DIR`, `frame_id`, canvas size, caption status and keep-out band if captions are enabled, and `RULES_DIR` as the absolute path to this skill's `../hyperframes-animation/rules/`. Each worker reads `frame.md`, its own `## Frame N` block from `STORYBOARD.md`, the local rule recipe (`../hyperframes-animation/rules/<id>.md`) for each cited motion, and the frame's blueprint template (`../hyperframes-animation/blueprints/<id>.md`). Each worker writes only `compositions/frames/NN-*.html`. Workers must never edit `STORYBOARD.md`.
|
||||||
|
|
||||||
|
**Full-bleed backgrounds ride on a `class="clip"` layer, never the `#root`.** A frame's ground (color field / gradient / grid) is its own full-duration background clip — a `background` set on the `#root` / `data-composition-id` element is clip-gated to the frame's window and is not a dependable ground, so dark content can land on the black host `body` and render invisible. The video's base ground is painted by the assembler from `frame.md`'s `canvas` color onto the index `#root`. (Full rule + self-check: `sub-agents/frame-worker.md`.)
|
||||||
|
|
||||||
As each worker returns, the orchestrator marks that frame as `animated` in `STORYBOARD.md`.
|
As each worker returns, the orchestrator marks that frame as `animated` in `STORYBOARD.md`.
|
||||||
|
|
||||||
After audio timings exist, build captions in the background and assemble the index:
|
After audio timings exist, build captions in the background and assemble the index:
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ You **can't** meaningfully run `hyperframes lint` / `validate` / `inspect` here:
|
|||||||
- `missing_template_wrapper` / `missing_composition_id` — root is `<template>`-wrapped and carries `data-composition-id="<frame_id>"`.
|
- `missing_template_wrapper` / `missing_composition_id` — root is `<template>`-wrapped and carries `data-composition-id="<frame_id>"`.
|
||||||
- **Template transport** — every `<style>` and `<script>` block, including the GSAP load, lives inside `<template>`.
|
- **Template transport** — every `<style>` and `<script>` block, including the GSAP load, lives inside `<template>`.
|
||||||
- `subcomposition_root_styled_by_class` — **style the frame root via `#root`, never a class on the `data-composition-id` element**: at render a class on the root gets scoped to a descendant selector that can't match it, so the **whole scene renders unstyled** (Studio preview still looks right — trust this rule, not the preview). Descendants use plain selectors.
|
- `subcomposition_root_styled_by_class` — **style the frame root via `#root`, never a class on the `data-composition-id` element**: at render a class on the root gets scoped to a descendant selector that can't match it, so the **whole scene renders unstyled** (Studio preview still looks right — trust this rule, not the preview). Descendants use plain selectors.
|
||||||
|
- **Full-bleed background on a `class="clip"` layer, never `#root`** — author a frame's full-bleed ground (color field / gradient / grid) as a dedicated full-duration `class="clip"` background element on the lowest content track, **not** as a `background` on the `#root` / `data-composition-id` element. At assembly the frame root is clip-gated to its scene window, so a background painted on the root is not a dependable full-frame ground — dark content can end up over the host `body` (black) and render invisible. The video's base ground is painted separately by the assembler from `frame.md`'s `canvas` color onto the index `#root`; your full-bleed clip rides on top of it.
|
||||||
- `clip_missing_data_attrs` — every `class="clip"` element has `data-start` / `data-duration` / `data-track-index`.
|
- `clip_missing_data_attrs` — every `class="clip"` element has `data-start` / `data-duration` / `data-track-index`.
|
||||||
- `timeline_not_paused` / `timeline_not_registered` — one paused timeline, registered at `window.__timelines["<frame_id>"]`.
|
- `timeline_not_paused` / `timeline_not_registered` — one paused timeline, registered at `window.__timelines["<frame_id>"]`.
|
||||||
- `css_transition_used` + repeat / yoyo / non-deterministic logic — none present (the renderer seeks frame-by-frame).
|
- `css_transition_used` + repeat / yoyo / non-deterministic logic — none present (the renderer seeks frame-by-frame).
|
||||||
|
|||||||
@@ -159,6 +159,8 @@ Duration sync is mechanical: real voice duration wins; silent frames keep estima
|
|||||||
|
|
||||||
Before dispatch, read `sub-agents/frame-worker.md` and `../hyperframes-core/references/subagent-dispatch.md`. Dispatch one sub-agent per frame, in parallel if possible; otherwise run workers in waves. Each worker gets exactly one frame. Each worker's context must include `PROJECT_DIR`, `frame_id`, canvas size, caption status and keep-out band if captions are enabled, `RULES_DIR` (absolute path to this skill's `../hyperframes-animation/rules/`), and the absolute path to `references/code-vocabulary.md`. Each worker reads `frame.md`, its own `## Frame N` block from `STORYBOARD.md`, the local rule recipe (`../hyperframes-animation/rules/<id>.md`) for each cited motion, the frame's blueprint template (`../hyperframes-animation/blueprints/<id>.md`), and — for a code beat — `code-vocabulary.md` for the named block's inputs. Each worker writes only `compositions/frames/NN-*.html`; workers never edit `STORYBOARD.md`.
|
Before dispatch, read `sub-agents/frame-worker.md` and `../hyperframes-core/references/subagent-dispatch.md`. Dispatch one sub-agent per frame, in parallel if possible; otherwise run workers in waves. Each worker gets exactly one frame. Each worker's context must include `PROJECT_DIR`, `frame_id`, canvas size, caption status and keep-out band if captions are enabled, `RULES_DIR` (absolute path to this skill's `../hyperframes-animation/rules/`), and the absolute path to `references/code-vocabulary.md`. Each worker reads `frame.md`, its own `## Frame N` block from `STORYBOARD.md`, the local rule recipe (`../hyperframes-animation/rules/<id>.md`) for each cited motion, the frame's blueprint template (`../hyperframes-animation/blueprints/<id>.md`), and — for a code beat — `code-vocabulary.md` for the named block's inputs. Each worker writes only `compositions/frames/NN-*.html`; workers never edit `STORYBOARD.md`.
|
||||||
|
|
||||||
|
**Full-bleed backgrounds ride on a `class="clip"` layer, never the `#root`.** A frame's ground (color field / gradient / grid) is its own full-duration background clip — a `background` set on the `#root` / `data-composition-id` element is clip-gated to the frame's window and is not a dependable ground, so dark content can land on the black host `body` and render invisible. The video's base ground is painted by the assembler from `frame.md`'s `canvas` color onto the index `#root`. (Full rule + self-check: `sub-agents/frame-worker.md`.)
|
||||||
|
|
||||||
As each worker returns, mark that frame `animated` in `STORYBOARD.md`.
|
As each worker returns, mark that frame `animated` in `STORYBOARD.md`.
|
||||||
|
|
||||||
After audio timings exist, build captions in the background and assemble the index:
|
After audio timings exist, build captions in the background and assemble the index:
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ You **can't** meaningfully run `hyperframes lint` / `validate` / `inspect` here:
|
|||||||
- `missing_template_wrapper` / `missing_composition_id` — root is `<template>`-wrapped and carries `data-composition-id="<frame_id>"`.
|
- `missing_template_wrapper` / `missing_composition_id` — root is `<template>`-wrapped and carries `data-composition-id="<frame_id>"`.
|
||||||
- **Template transport** — every `<style>` and `<script>` block, including the GSAP load, lives inside `<template>`.
|
- **Template transport** — every `<style>` and `<script>` block, including the GSAP load, lives inside `<template>`.
|
||||||
- `subcomposition_root_styled_by_class` — **style the frame root via `#root`, never a class on the `data-composition-id` element**: at render a class on the root gets scoped to a descendant selector that can't match it, so the **whole scene renders unstyled** (Studio preview still looks right — trust this rule, not the preview). Descendants use plain selectors.
|
- `subcomposition_root_styled_by_class` — **style the frame root via `#root`, never a class on the `data-composition-id` element**: at render a class on the root gets scoped to a descendant selector that can't match it, so the **whole scene renders unstyled** (Studio preview still looks right — trust this rule, not the preview). Descendants use plain selectors.
|
||||||
|
- **Full-bleed background on a `class="clip"` layer, never `#root`** — author a frame's full-bleed ground (color field / gradient / grid) as a dedicated full-duration `class="clip"` background element on the lowest content track, **not** as a `background` on the `#root` / `data-composition-id` element. At assembly the frame root is clip-gated to its scene window, so a background painted on the root is not a dependable full-frame ground — dark content can end up over the host `body` (black) and render invisible. The video's base ground is painted separately by the assembler from `frame.md`'s `canvas` color onto the index `#root`; your full-bleed clip rides on top of it.
|
||||||
- `clip_missing_data_attrs` — every `class="clip"` element has `data-start` / `data-duration` / `data-track-index`.
|
- `clip_missing_data_attrs` — every `class="clip"` element has `data-start` / `data-duration` / `data-track-index`.
|
||||||
- `timeline_not_paused` / `timeline_not_registered` — one paused timeline, registered at `window.__timelines["<frame_id>"]`.
|
- `timeline_not_paused` / `timeline_not_registered` — one paused timeline, registered at `window.__timelines["<frame_id>"]`.
|
||||||
- `css_transition_used` + repeat / yoyo / non-deterministic logic — none present (the renderer seeks frame-by-frame).
|
- `css_transition_used` + repeat / yoyo / non-deterministic logic — none present (the renderer seeks frame-by-frame).
|
||||||
|
|||||||
@@ -131,6 +131,8 @@ Before dispatch, read `sub-agents/frame-worker.md` and `../hyperframes-core/refe
|
|||||||
|
|
||||||
Each worker context must include `PROJECT_DIR`, `frame_id`, canvas size, caption status and keep-out band if captions are enabled, and `RULES_DIR` as the absolute path to this skill's `../hyperframes-animation/rules/`. Each worker reads `frame.md`, its own `## Frame N` block from `STORYBOARD.md`, the local rule recipe (`../hyperframes-animation/rules/<id>.md`) for each cited motion, and the frame's blueprint template (`../hyperframes-animation/blueprints/<id>.md`). Each worker writes only `compositions/frames/NN-*.html`. Workers must never edit `STORYBOARD.md`.
|
Each worker context must include `PROJECT_DIR`, `frame_id`, canvas size, caption status and keep-out band if captions are enabled, and `RULES_DIR` as the absolute path to this skill's `../hyperframes-animation/rules/`. Each worker reads `frame.md`, its own `## Frame N` block from `STORYBOARD.md`, the local rule recipe (`../hyperframes-animation/rules/<id>.md`) for each cited motion, and the frame's blueprint template (`../hyperframes-animation/blueprints/<id>.md`). Each worker writes only `compositions/frames/NN-*.html`. Workers must never edit `STORYBOARD.md`.
|
||||||
|
|
||||||
|
**Full-bleed backgrounds ride on a `class="clip"` layer, never the `#root`.** A frame's ground (color field / gradient / grid) is its own full-duration background clip — a `background` set on the `#root` / `data-composition-id` element is clip-gated to the frame's window and is not a dependable ground, so dark content can land on the black host `body` and render invisible. The video's base ground is painted by the assembler from `frame.md`'s `canvas` color onto the index `#root`. (Full rule + self-check: `sub-agents/frame-worker.md`.)
|
||||||
|
|
||||||
As each worker returns, the orchestrator marks that frame as `animated` in `STORYBOARD.md`.
|
As each worker returns, the orchestrator marks that frame as `animated` in `STORYBOARD.md`.
|
||||||
|
|
||||||
After audio timings exist, build captions in the background and assemble the index:
|
After audio timings exist, build captions in the background and assemble the index:
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ You **can't** meaningfully run `hyperframes lint` / `validate` / `inspect` here:
|
|||||||
- `missing_template_wrapper` / `missing_composition_id` — root is `<template>`-wrapped and carries `data-composition-id="<frame_id>"`.
|
- `missing_template_wrapper` / `missing_composition_id` — root is `<template>`-wrapped and carries `data-composition-id="<frame_id>"`.
|
||||||
- **Template transport** — every `<style>` and `<script>` block, including the GSAP load, lives inside `<template>`.
|
- **Template transport** — every `<style>` and `<script>` block, including the GSAP load, lives inside `<template>`.
|
||||||
- `subcomposition_root_styled_by_class` — **style the frame root via `#root`, never a class on the `data-composition-id` element**: at render a class on the root gets scoped to a descendant selector that can't match it, so the **whole scene renders unstyled** (Studio preview still looks right — trust this rule, not the preview). Descendants use plain selectors.
|
- `subcomposition_root_styled_by_class` — **style the frame root via `#root`, never a class on the `data-composition-id` element**: at render a class on the root gets scoped to a descendant selector that can't match it, so the **whole scene renders unstyled** (Studio preview still looks right — trust this rule, not the preview). Descendants use plain selectors.
|
||||||
|
- **Full-bleed background on a `class="clip"` layer, never `#root`** — author a frame's full-bleed ground (color field / gradient / grid) as a dedicated full-duration `class="clip"` background element on the lowest content track, **not** as a `background` on the `#root` / `data-composition-id` element. At assembly the frame root is clip-gated to its scene window, so a background painted on the root is not a dependable full-frame ground — dark content can end up over the host `body` (black) and render invisible. The video's base ground is painted separately by the assembler from `frame.md`'s `canvas` color onto the index `#root`; your full-bleed clip rides on top of it.
|
||||||
- `clip_missing_data_attrs` — every `class="clip"` element has `data-start` / `data-duration` / `data-track-index`.
|
- `clip_missing_data_attrs` — every `class="clip"` element has `data-start` / `data-duration` / `data-track-index`.
|
||||||
- `timeline_not_paused` / `timeline_not_registered` — one paused timeline, registered at `window.__timelines["<frame_id>"]`.
|
- `timeline_not_paused` / `timeline_not_registered` — one paused timeline, registered at `window.__timelines["<frame_id>"]`.
|
||||||
- `css_transition_used` + repeat / yoyo / non-deterministic logic — none present (the renderer seeks frame-by-frame).
|
- `css_transition_used` + repeat / yoyo / non-deterministic logic — none present (the renderer seeks frame-by-frame).
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ A HyperFrames slideshow is a normal HyperFrames composition — scenes, clips, G
|
|||||||
|
|
||||||
**Read `/hyperframes-core` first** for the base composition contract (clips, tracks, `data-*` attributes, determinism rules). This skill covers only what is new: the island schema, slide writing rules, fragments, branching, validation, and the wrapping component.
|
**Read `/hyperframes-core` first** for the base composition contract (clips, tracks, `data-*` attributes, determinism rules). This skill covers only what is new: the island schema, slide writing rules, fragments, branching, validation, and the wrapping component.
|
||||||
|
|
||||||
|
## Output — a navigable deck, not a linear MP4
|
||||||
|
|
||||||
|
A slideshow's output is the **running deck**: serve it with `hyperframes present <project-dir>` (or Studio present mode) — the player's `SlideshowController` reads the island and drives navigation, fragments, branching, and presenter mode. See **Presenting and handoff** below.
|
||||||
|
|
||||||
|
**Do not `hyperframes render` a slideshow into a single MP4.** A deck is authored as several top-level scene compositions (one `data-composition-id` per slide) with **no master-root composition** wrapping them, so `render` resolves only the **first** composition and emits a **silently truncated** MP4 (e.g. 6s of a 40-second deck). A linear main-line export (main slides only, branch sequences excluded) is **deferred** — until it ships, the supported outputs are the live `present` deck and per-slide `snapshot` stills. If a user needs a linear MP4 today, surface this limitation rather than pointing `render` at the deck.
|
||||||
|
|
||||||
## Intent confirmation
|
## Intent confirmation
|
||||||
|
|
||||||
If the user explicitly asks for a slideshow, slide show, or HyperFrames slideshow, proceed with this skill.
|
If the user explicitly asks for a slideshow, slide show, or HyperFrames slideshow, proceed with this skill.
|
||||||
|
|||||||
Reference in New Issue
Block a user