mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(product-launch): consolidate media brand and audio contracts (#2408)
* fix(product-launch): preserve hoisted media offsets * fix(product-launch): preserve brand font and accent roles * fix(product-launch): honor TTS provider selection * fix(product-launch): preserve approved video geometry * fix(skills): enforce media geometry and font classification * fix(skills): align secondary brand accents
This commit is contained in:
@@ -163,10 +163,9 @@ let brandFontWeights = []; // weights the brand text font actually ships (tokens
|
||||
let brandColorStats = []; // rich per-color usage stats (areaBg / interactiveBg / textCount …)
|
||||
// Icon/glyph fonts capture surfaces as "fonts" — they are never the brand text face
|
||||
// (webflow-icons, Font Awesome, icomoon …) and must not become display/body or contribute weights.
|
||||
const isIconFont = (name) =>
|
||||
/(?:^|[\s_-])icons?(?:[\s_-]|$)|icomoon|font\s*-?awesome|glyphicons?|material\s*icons|feather\s*icons/i.test(
|
||||
String(name),
|
||||
);
|
||||
const ICON_FONT_PATTERN =
|
||||
/(?:^|[\s_-])icons?(?:[\s_-]|$)|icomoon|font\s*-?awesome|glyphicons?|material\s*icons|feather\s*icons|(?:icon|glyph).*font|font.*(?:icon|glyph)|^vidaxlfont$/i;
|
||||
const isIconFont = (name) => ICON_FONT_PATTERN.test(String(name));
|
||||
if (existsSync(tokensPath)) {
|
||||
try {
|
||||
const t = JSON.parse(readFileSync(tokensPath, "utf8"));
|
||||
|
||||
@@ -127,15 +127,7 @@ export function brandRolesFromStats(stats, colorsInOrder) {
|
||||
.find((s) => Math.abs((lum(s.hex) ?? 0) - cl) > 64)?.hex ??
|
||||
(cl > 128 ? "#000000" : "#FFFFFF");
|
||||
const accent2 =
|
||||
v
|
||||
.filter(
|
||||
(s) =>
|
||||
![canvas, ink, accent].includes(s.hex) &&
|
||||
(s.interactiveBg || 0) > 0 &&
|
||||
chroma(s.hex) > 40 &&
|
||||
!UA_DEFAULT_COLORS.has(s.hex.toUpperCase()),
|
||||
)
|
||||
.sort((a, b) => (b.interactiveBg || 0) - (a.interactiveBg || 0))[0]?.hex ?? accent;
|
||||
pickAccent(v, colorsInOrder ?? v.map((s) => s.hex), [canvas, ink, accent]) ?? accent;
|
||||
return { ink, canvas, accent, accent2 };
|
||||
}
|
||||
|
||||
|
||||
@@ -163,10 +163,9 @@ let brandFontWeights = []; // weights the brand text font actually ships (tokens
|
||||
let brandColorStats = []; // rich per-color usage stats (areaBg / interactiveBg / textCount …)
|
||||
// Icon/glyph fonts capture surfaces as "fonts" — they are never the brand text face
|
||||
// (webflow-icons, Font Awesome, icomoon …) and must not become display/body or contribute weights.
|
||||
const isIconFont = (name) =>
|
||||
/(?:^|[\s_-])icons?(?:[\s_-]|$)|icomoon|font\s*-?awesome|glyphicons?|material\s*icons|feather\s*icons/i.test(
|
||||
String(name),
|
||||
);
|
||||
const ICON_FONT_PATTERN =
|
||||
/(?:^|[\s_-])icons?(?:[\s_-]|$)|icomoon|font\s*-?awesome|glyphicons?|material\s*icons|feather\s*icons|(?:icon|glyph).*font|font.*(?:icon|glyph)|^vidaxlfont$/i;
|
||||
const isIconFont = (name) => ICON_FONT_PATTERN.test(String(name));
|
||||
if (existsSync(tokensPath)) {
|
||||
try {
|
||||
const t = JSON.parse(readFileSync(tokensPath, "utf8"));
|
||||
|
||||
@@ -127,15 +127,7 @@ export function brandRolesFromStats(stats, colorsInOrder) {
|
||||
.find((s) => Math.abs((lum(s.hex) ?? 0) - cl) > 64)?.hex ??
|
||||
(cl > 128 ? "#000000" : "#FFFFFF");
|
||||
const accent2 =
|
||||
v
|
||||
.filter(
|
||||
(s) =>
|
||||
![canvas, ink, accent].includes(s.hex) &&
|
||||
(s.interactiveBg || 0) > 0 &&
|
||||
chroma(s.hex) > 40 &&
|
||||
!UA_DEFAULT_COLORS.has(s.hex.toUpperCase()),
|
||||
)
|
||||
.sort((a, b) => (b.interactiveBg || 0) - (a.interactiveBg || 0))[0]?.hex ?? accent;
|
||||
pickAccent(v, colorsInOrder ?? v.map((s) => s.hex), [canvas, ink, accent]) ?? accent;
|
||||
return { ink, canvas, accent, accent2 };
|
||||
}
|
||||
|
||||
|
||||
@@ -106,9 +106,9 @@ Goal: Generate narration, word timings, music, and audio metadata from the appro
|
||||
|
||||
Start audio after Step 3 approval. Run it in the background, then continue to Step 4.
|
||||
|
||||
**Choose the narration voice from the user's ask before invoking.** If the request named a voice, gender, or tone, pick a matching voice id and pass it with `--voice <id>`. The pipeline default is otherwise **Marcia (female)** on HeyGen / `am_michael` on Kokoro — so a request like "a male voice" is silently ignored unless you pass the flag. Voice ids are provider-specific; resolve against whichever provider Step 0's sign-in status selected: **HeyGen** (signed in) via `node ../media-use/audio/scripts/heygen-tts.mjs --list` (or `GET /v3/voices?engine=starfish`); **Kokoro** (offline) via the voice table in `../media-use/audio/references/tts.md` (prefixes `am_`/`bm_` male, `af_`/`bf_` female). Omit `--voice` only when the user expressed no preference.
|
||||
**Choose the narration provider and voice from the user's ask before invoking.** Pass the provider selected in Step 0 with `--provider <provider>` (or set `HF_TTS_PROVIDER`). If the request named a voice, gender, or tone, pick a matching voice id and pass it with `--voice <id>`. The pipeline default is otherwise **Marcia (female)** on HeyGen / `am_michael` on Kokoro — so a request like "a male voice" is silently ignored unless you pass the flag. Voice ids are provider-specific; resolve against whichever provider Step 0's sign-in status selected: **HeyGen** (signed in) via `node ../media-use/audio/scripts/heygen-tts.mjs --list` (or `GET /v3/voices?engine=starfish`); **Kokoro** (offline) via the voice table in `../media-use/audio/references/tts.md` (prefixes `am_`/`bm_` male, `af_`/`bf_` female). Omit `--voice` only when the user expressed no preference.
|
||||
|
||||
`node <SKILL_DIR>/scripts/audio.mjs --script ./SCRIPT.md --storyboard ./STORYBOARD.md --hyperframes . --out ./audio_meta.json --voice <voice-id> &`
|
||||
`node <SKILL_DIR>/scripts/audio.mjs --script ./SCRIPT.md --storyboard ./STORYBOARD.md --hyperframes . --out ./audio_meta.json --provider <provider> --voice <voice-id> &`
|
||||
|
||||
The audio script handles narration, word timings, BGM lookup from HeyGen's music library, and timing metadata. BGM mood comes from the storyboard's `music:` field. This uses the HeyGen Audio API for retrieval, not generation, and uses the same `~/.heygen` credential as TTS. For provider details, read `../media-use/audio/references/tts.md`.
|
||||
|
||||
|
||||
@@ -177,7 +177,7 @@ function escapeHtmlAttr(value) {
|
||||
|
||||
function approvedVideoAttrs(attrs) {
|
||||
const forwarded = [];
|
||||
for (const name of ["id", "src", "poster", "preload", "aria-label"]) {
|
||||
for (const name of ["id", "src", "poster", "preload", "aria-label", "data-media-start"]) {
|
||||
const value = attrValueFrom(attrs, name);
|
||||
if (value !== null) forwarded.push(`${name}="${escapeHtmlAttr(value)}"`);
|
||||
}
|
||||
@@ -187,6 +187,42 @@ function approvedVideoAttrs(attrs) {
|
||||
return forwarded.join(" ");
|
||||
}
|
||||
|
||||
function approvedVideoLayout(attrs) {
|
||||
const names = ["x", "y", "width", "height"];
|
||||
const raw = Object.fromEntries(
|
||||
names.map((name) => [name, attrValueFrom(attrs, `data-frame-video-${name}`)]),
|
||||
);
|
||||
const rawFit = attrValueFrom(attrs, "data-frame-video-fit");
|
||||
const values = Object.fromEntries(names.map((name) => [name, Number(raw[name])]));
|
||||
if (
|
||||
names.some(
|
||||
(name) => raw[name] === null || raw[name].trim() === "" || !Number.isFinite(values[name]),
|
||||
) ||
|
||||
values.width <= 0 ||
|
||||
values.height <= 0
|
||||
) {
|
||||
return {
|
||||
style: null,
|
||||
error:
|
||||
"approved frame video layout data-frame-video-x/y/width/height must all be finite numeric values, with positive width and height",
|
||||
};
|
||||
}
|
||||
|
||||
const fit = rawFit ?? "cover";
|
||||
if (!["cover", "contain", "fill", "none", "scale-down"].includes(fit)) {
|
||||
return {
|
||||
style: null,
|
||||
error:
|
||||
'approved frame video layout data-frame-video-fit must be "cover", "contain", "fill", "none", or "scale-down"',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
style: `position:absolute;left:${values.x}px;top:${values.y}px;width:${values.width}px;height:${values.height}px;object-fit:${fit}`,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
function hoistApprovedVideos(html, label) {
|
||||
const videos = [];
|
||||
const errors = [];
|
||||
@@ -221,7 +257,19 @@ function hoistApprovedVideos(html, label) {
|
||||
);
|
||||
return full;
|
||||
}
|
||||
videos.push({ attrs: approvedVideoAttrs(attrs), inner, start, duration, track });
|
||||
const layout = approvedVideoLayout(attrs);
|
||||
if (layout.error) {
|
||||
errors.push(`${label}: ${layout.error}`);
|
||||
return full;
|
||||
}
|
||||
videos.push({
|
||||
attrs: approvedVideoAttrs(attrs),
|
||||
inner,
|
||||
start,
|
||||
duration,
|
||||
track,
|
||||
layoutStyle: layout.style,
|
||||
});
|
||||
return "<!-- approved frame video hoisted by assemble-index -->";
|
||||
});
|
||||
return { html: repaired, videos, errors };
|
||||
@@ -458,6 +506,7 @@ for (const [frameIndex, m] of mounted.entries()) {
|
||||
body.push(
|
||||
` <video${id} ${video.attrs}`,
|
||||
` class="clip"`,
|
||||
...(video.layoutStyle ? [` style="${video.layoutStyle}"`] : []),
|
||||
` data-start="${globalStart}"`,
|
||||
` data-duration="${r3(video.duration)}"`,
|
||||
` data-track-index="${track}"`,
|
||||
|
||||
@@ -124,6 +124,7 @@ function runGenerate(argv) {
|
||||
const scriptPath = resolve(flag(argv, "script", join(hyperframesDir, "SCRIPT.md")));
|
||||
const outPath = resolve(flag(argv, "out", join(hyperframesDir, "audio_meta.json")));
|
||||
const userVoice = flag(argv, "voice", null);
|
||||
const provider = flag(argv, "provider", process.env.HF_TTS_PROVIDER || "auto");
|
||||
const speed = Number(flag(argv, "speed", "1.0")) || 1.0;
|
||||
|
||||
if (!existsSync(storyboardPath)) die(`STORYBOARD.md not found at ${storyboardPath}`);
|
||||
@@ -142,7 +143,7 @@ function runGenerate(argv) {
|
||||
// strict here (no wait-bgm step downstream).
|
||||
const query = (g.extra && g.extra.music) || g.message || g.arc || "calm cinematic underscore";
|
||||
const request = {
|
||||
provider: "auto",
|
||||
provider,
|
||||
speed,
|
||||
lines,
|
||||
bgm: { mode: "retrieve", query, blob: g.message || "", arc: g.arc || "" },
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import test from "node:test";
|
||||
|
||||
const script = new URL("./audio.mjs", import.meta.url).pathname;
|
||||
|
||||
function runAudio({ args = [], env = {} } = {}) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "product-launch-audio-"));
|
||||
const engine = join(dir, "engine.mjs");
|
||||
writeFileSync(join(dir, "STORYBOARD.md"), "message: Test\n");
|
||||
writeFileSync(
|
||||
engine,
|
||||
`import { readFileSync, writeFileSync } from "node:fs";
|
||||
const argv = process.argv.slice(2);
|
||||
const flag = (name) => argv[argv.indexOf(name) + 1];
|
||||
const request = JSON.parse(readFileSync(flag("--request"), "utf8"));
|
||||
writeFileSync(new URL("request.json", import.meta.url), JSON.stringify(request));
|
||||
writeFileSync(flag("--out"), JSON.stringify({ voices: [], bgm: null, sfx: [] }));
|
||||
`,
|
||||
);
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[script, "--hyperframes", dir, "--storyboard", join(dir, "STORYBOARD.md"), ...args],
|
||||
{ encoding: "utf8", env: { ...process.env, HF_MEDIA_ENGINE: engine, ...env } },
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
return JSON.parse(readFileSync(join(dir, "request.json"), "utf8"));
|
||||
}
|
||||
|
||||
test("passes --provider to the shared audio engine", () => {
|
||||
assert.equal(runAudio({ args: ["--provider", "kokoro"] }).provider, "kokoro");
|
||||
});
|
||||
|
||||
test("uses HF_TTS_PROVIDER when --provider is omitted", () => {
|
||||
assert.equal(runAudio({ env: { HF_TTS_PROVIDER: "elevenlabs" } }).provider, "elevenlabs");
|
||||
});
|
||||
|
||||
test("--provider takes precedence over HF_TTS_PROVIDER", () => {
|
||||
assert.equal(
|
||||
runAudio({ args: ["--provider", "kokoro"], env: { HF_TTS_PROVIDER: "elevenlabs" } }).provider,
|
||||
"kokoro",
|
||||
);
|
||||
});
|
||||
@@ -32,6 +32,7 @@ import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
brandRolesFromStats,
|
||||
chroma,
|
||||
isIconFont,
|
||||
lum,
|
||||
parseColors,
|
||||
parseFonts,
|
||||
@@ -163,10 +164,6 @@ let brandFontWeights = []; // weights the brand text font actually ships (tokens
|
||||
let brandColorStats = []; // rich per-color usage stats (areaBg / interactiveBg / textCount …)
|
||||
// Icon/glyph fonts capture surfaces as "fonts" — they are never the brand text face
|
||||
// (webflow-icons, Font Awesome, icomoon …) and must not become display/body or contribute weights.
|
||||
const isIconFont = (name) =>
|
||||
/(?:^|[\s_-])icons?(?:[\s_-]|$)|icomoon|font\s*-?awesome|glyphicons?|material\s*icons|feather\s*icons/i.test(
|
||||
String(name),
|
||||
);
|
||||
if (existsSync(tokensPath)) {
|
||||
try {
|
||||
const t = JSON.parse(readFileSync(tokensPath, "utf8"));
|
||||
|
||||
@@ -48,6 +48,13 @@ export const UA_DEFAULT_COLORS = new Set(
|
||||
["#0000EE", "#0000FF", "#0000CC", "#1A0DAB", "#551A8B", "#EE0000"].map((c) => c.toUpperCase()),
|
||||
);
|
||||
|
||||
export const ICON_FONT_PATTERN =
|
||||
/(?:^|[\s_-])icons?(?:[\s_-]|$)|icomoon|font\s*-?awesome|glyphicons?|material\s*icons|feather\s*icons|(?:icon|glyph).*font|font.*(?:icon|glyph)|^vidaxlfont$/i;
|
||||
|
||||
export function isIconFont(name) {
|
||||
return ICON_FONT_PATTERN.test(String(name));
|
||||
}
|
||||
|
||||
// Semantic STATUS roles (green "positive", red "negative"/"error", amber "warning" …). Their HUE
|
||||
// carries the meaning, so they are never a brand ACCENT — a status red is frequently the most
|
||||
// chromatic color in a palette (e.g. #dc2626 chroma 182 beats a deep-blue accent #1E40AF chroma
|
||||
@@ -127,15 +134,7 @@ export function brandRolesFromStats(stats, colorsInOrder) {
|
||||
.find((s) => Math.abs((lum(s.hex) ?? 0) - cl) > 64)?.hex ??
|
||||
(cl > 128 ? "#000000" : "#FFFFFF");
|
||||
const accent2 =
|
||||
v
|
||||
.filter(
|
||||
(s) =>
|
||||
![canvas, ink, accent].includes(s.hex) &&
|
||||
(s.interactiveBg || 0) > 0 &&
|
||||
chroma(s.hex) > 40 &&
|
||||
!UA_DEFAULT_COLORS.has(s.hex.toUpperCase()),
|
||||
)
|
||||
.sort((a, b) => (b.interactiveBg || 0) - (a.interactiveBg || 0))[0]?.hex ?? accent;
|
||||
pickAccent(v, colorsInOrder ?? v.map((s) => s.hex), [canvas, ink, accent]) ?? accent;
|
||||
return { ink, canvas, accent, accent2 };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { brandRolesFromStats, ICON_FONT_PATTERN, isIconFont } from "./tokens.mjs";
|
||||
import { brandRolesFromStats as facelessBrandRolesFromStats } from "../../../faceless-explainer/scripts/lib/tokens.mjs";
|
||||
import { brandRolesFromStats as prBrandRolesFromStats } from "../../../pr-to-video/scripts/lib/tokens.mjs";
|
||||
|
||||
const scriptsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||
|
||||
test("recognizes brand-specific icon font names", () => {
|
||||
assert.equal(isIconFont("vidaXLfont"), true);
|
||||
assert.equal(isIconFont("BrandGlyphFont"), true);
|
||||
assert.equal(isIconFont("Poppins"), false);
|
||||
assert.equal(isIconFont("HelveticaFont"), false);
|
||||
assert.equal(isIconFont("Airbnb Cereal Font"), false);
|
||||
assert.equal(isIconFont("SF Pro Text Font"), false);
|
||||
assert.equal(isIconFont("Uber Move Font"), false);
|
||||
assert.equal(isIconFont("Circular Std font"), false);
|
||||
assert.equal(isIconFont("brand-font"), false);
|
||||
});
|
||||
|
||||
test("keeps sibling skill icon-font classifiers aligned", () => {
|
||||
for (const skill of ["faceless-explainer", "pr-to-video"]) {
|
||||
const source = readFileSync(join(scriptsDir, skill, "scripts", "build-frame.mjs"), "utf8");
|
||||
assert.match(
|
||||
source,
|
||||
new RegExp(String.raw`ICON_FONT_PATTERN\s*=\s*${escapeRegExp(ICON_FONT_PATTERN)}`),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
test("preserves a prominent second accent used outside interactive backgrounds", () => {
|
||||
const colors = ["#FFFFFF", "#2D1238", "#F3E62B", "#111111"];
|
||||
const stats = [
|
||||
{ hex: "#FFFFFF", areaBg: 1000, maxArea: 1000 },
|
||||
{ hex: "#2D1238", textCount: 4, interactiveBg: 3 },
|
||||
{ hex: "#F3E62B", textCount: 3, interactiveBg: 0 },
|
||||
{ hex: "#111111", textCount: 20 },
|
||||
];
|
||||
|
||||
assert.deepEqual(brandRolesFromStats(stats, colors), {
|
||||
canvas: "#FFFFFF",
|
||||
ink: "#111111",
|
||||
accent: "#F3E62B",
|
||||
accent2: "#2D1238",
|
||||
});
|
||||
|
||||
for (const sibling of [facelessBrandRolesFromStats, prBrandRolesFromStats]) {
|
||||
assert.deepEqual(sibling(stats, colors), brandRolesFromStats(stats, colors));
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -26,7 +26,7 @@ test("assemble hoists an approved timed frame video to the host root", () => {
|
||||
);
|
||||
writeFileSync(
|
||||
framePath,
|
||||
`<html><body><div id="root" data-composition-id="frame-1" data-width="1920" data-height="1080"><video data-frame-video="approved" src="https://cdn.example/clip.mp4" poster="poster.png" preload="auto" muted playsinline loop style="background:url(https://evil.example/x)" nonce="unsafe" onerror="alert(1)" srcdoc="<script>alert(2)</script>" data-start="0.25" data-duration="1.5" data-track-index="7"></video></div><script>window.__timelines = {}; window.__timelines["frame-1"] = gsap.timeline();</script></body></html>`,
|
||||
`<html><body><div id="root" data-composition-id="frame-1" data-width="1920" data-height="1080"><video data-frame-video="approved" data-frame-video-x="0" data-frame-video-y="0" data-frame-video-width="1920" data-frame-video-height="1080" src="https://cdn.example/clip.mp4" poster="poster.png" preload="auto" muted playsinline loop style="background:url(https://evil.example/x)" nonce="unsafe" onerror="alert(1)" srcdoc="<script>alert(2)</script>" data-start="0.25" data-duration="1.5" data-media-start="12.75" data-track-index="7"></video></div><script>window.__timelines = {}; window.__timelines["frame-1"] = gsap.timeline();</script></body></html>`,
|
||||
);
|
||||
|
||||
const result = spawnSync(
|
||||
@@ -39,6 +39,7 @@ test("assemble hoists an approved timed frame video to the host root", () => {
|
||||
const frame = readFileSync(framePath, "utf8");
|
||||
assert.match(index, /data-start="0\.25"/);
|
||||
assert.match(index, /data-duration="1\.5"/);
|
||||
assert.match(index, /data-media-start="12\.75"/);
|
||||
assert.match(index, /data-track-index="1007"/);
|
||||
assert.match(index, /src="https:\/\/cdn\.example\/clip\.mp4"/);
|
||||
assert.match(index, /poster="poster\.png"/);
|
||||
@@ -49,10 +50,115 @@ test("assemble hoists an approved timed frame video to the host root", () => {
|
||||
assert.doesNotMatch(index, /onerror=/i);
|
||||
assert.doesNotMatch(index, /srcdoc=/i);
|
||||
assert.doesNotMatch(index, /nonce=/i);
|
||||
assert.doesNotMatch(index, /style=/i);
|
||||
assert.match(
|
||||
index,
|
||||
/style="position:absolute;left:0px;top:0px;width:1920px;height:1080px;object-fit:cover"/,
|
||||
);
|
||||
assert.doesNotMatch(frame, /<video\b/i);
|
||||
});
|
||||
|
||||
test("assemble preserves approved-video geometry through sanitized host CSS", () => {
|
||||
const project = mkdtempSync(join(tmpdir(), "hf-frame-video-layout-"));
|
||||
mkdirSync(join(project, "compositions"));
|
||||
const framePath = join(project, "compositions", "frame-1.html");
|
||||
writeFileSync(
|
||||
join(project, "STORYBOARD.md"),
|
||||
"---\nformat: 16:9\n---\n\n## Frame 1\n- status: built\n- duration: 2s\n- src: compositions/frame-1.html\n",
|
||||
);
|
||||
writeFileSync(
|
||||
framePath,
|
||||
`<html><body><div id="root" data-composition-id="frame-1" data-width="1920" data-height="1080"><video class="clip approved-demo" data-frame-video="approved" data-frame-video-x="120" data-frame-video-y="240" data-frame-video-width="960" data-frame-video-height="540" data-frame-video-fit="cover" src="clip.mp4" style="position:fixed;background:url(https://evil.example/x)" nonce="unsafe" onerror="alert(1)" srcdoc="<script>alert(2)</script>" data-start="0" data-duration="1" data-track-index="7"></video></div><script>window.__timelines = {}; window.__timelines["frame-1"] = gsap.timeline();</script></body></html>`,
|
||||
);
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[join(skillDir, "scripts", "assemble-index.mjs"), "--hyperframes", project],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const index = readFileSync(join(project, "index.html"), "utf8");
|
||||
assert.match(
|
||||
index,
|
||||
/style="position:absolute;left:120px;top:240px;width:960px;height:540px;object-fit:cover"/,
|
||||
);
|
||||
assert.doesNotMatch(index, /approved-demo/);
|
||||
assert.doesNotMatch(index, /evil\.example/);
|
||||
assert.doesNotMatch(index, /position:fixed/);
|
||||
assert.doesNotMatch(index, /data-frame-video-(?:x|y|width|height|fit)=/);
|
||||
assert.doesNotMatch(index, /onerror=|srcdoc=|nonce=/i);
|
||||
assert.doesNotMatch(readFileSync(framePath, "utf8"), /<video\b/i);
|
||||
});
|
||||
|
||||
test("rejects partial or unsafe approved-video layout geometry", () => {
|
||||
const project = mkdtempSync(join(tmpdir(), "hf-frame-video-layout-invalid-"));
|
||||
mkdirSync(join(project, "compositions"));
|
||||
writeFileSync(
|
||||
join(project, "STORYBOARD.md"),
|
||||
"---\nformat: 16:9\n---\n\n## Frame 1\n- status: built\n- duration: 2s\n- src: compositions/frame-1.html\n",
|
||||
);
|
||||
writeFileSync(
|
||||
join(project, "compositions", "frame-1.html"),
|
||||
`<html><body><div id="root" data-composition-id="frame-1" data-width="1920" data-height="1080"><video data-frame-video="approved" data-frame-video-x="0" data-frame-video-y="0" data-frame-video-width="calc(100% + 1px)" data-frame-video-height="1080" data-frame-video-fit="cover" src="clip.mp4" data-start="0" data-duration="1" data-track-index="0"></video></div><script>window.__timelines = {}; window.__timelines["frame-1"] = gsap.timeline();</script></body></html>`,
|
||||
);
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[join(skillDir, "scripts", "assemble-index.mjs"), "--hyperframes", project],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /approved frame video layout.*finite numeric/i);
|
||||
assert.equal(existsSync(join(project, "index.html")), false);
|
||||
});
|
||||
|
||||
test("rejects an approved video without mandatory layout geometry", () => {
|
||||
const project = mkdtempSync(join(tmpdir(), "hf-frame-video-layout-missing-"));
|
||||
mkdirSync(join(project, "compositions"));
|
||||
writeFileSync(
|
||||
join(project, "STORYBOARD.md"),
|
||||
"---\nformat: 16:9\n---\n\n## Frame 1\n- status: built\n- duration: 2s\n- src: compositions/frame-1.html\n",
|
||||
);
|
||||
writeFileSync(
|
||||
join(project, "compositions", "frame-1.html"),
|
||||
`<html><body><div id="root" data-composition-id="frame-1" data-width="1920" data-height="1080"><video data-frame-video="approved" src="clip.mp4" data-start="0" data-duration="1" data-track-index="0"></video></div><script>window.__timelines = {}; window.__timelines["frame-1"] = gsap.timeline();</script></body></html>`,
|
||||
);
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[join(skillDir, "scripts", "assemble-index.mjs"), "--hyperframes", project],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /approved frame video layout.*finite numeric/i);
|
||||
assert.equal(existsSync(join(project, "index.html")), false);
|
||||
});
|
||||
|
||||
test("rejects empty approved-video layout coordinates", () => {
|
||||
const project = mkdtempSync(join(tmpdir(), "hf-frame-video-layout-empty-"));
|
||||
mkdirSync(join(project, "compositions"));
|
||||
writeFileSync(
|
||||
join(project, "STORYBOARD.md"),
|
||||
"---\nformat: 16:9\n---\n\n## Frame 1\n- status: built\n- duration: 2s\n- src: compositions/frame-1.html\n",
|
||||
);
|
||||
writeFileSync(
|
||||
join(project, "compositions", "frame-1.html"),
|
||||
`<html><body><div id="root" data-composition-id="frame-1" data-width="1920" data-height="1080"><video data-frame-video="approved" data-frame-video-x="" data-frame-video-y="0" data-frame-video-width="1920" data-frame-video-height="1080" src="clip.mp4" data-start="0" data-duration="1" data-track-index="0"></video></div><script>window.__timelines = {}; window.__timelines["frame-1"] = gsap.timeline();</script></body></html>`,
|
||||
);
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[join(skillDir, "scripts", "assemble-index.mjs"), "--hyperframes", project],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /approved frame video layout.*finite numeric/i);
|
||||
assert.equal(existsSync(join(project, "index.html")), false);
|
||||
});
|
||||
|
||||
test("rejects an approved video with missing admission timing", () => {
|
||||
const project = mkdtempSync(join(tmpdir(), "hf-frame-video-missing-"));
|
||||
mkdirSync(join(project, "compositions"));
|
||||
|
||||
@@ -46,7 +46,7 @@ Generic seek-safety + structure live in `hyperframes-core` (read it; not restate
|
||||
- **Visible text is short motion-graphics copy** — headline / stat / one-word emphasis (`"$83K"`, `"INSTANT"`), never a sentence from the narration. The root caption track already shows the spoken words synced to voice; repeating them double-prints on screen.
|
||||
- **Build the whole shot — reveal across the full `duration`, never front-load.** Dumping the whole canvas in the first ~25% then holding it is exactly what reads as a PowerPoint slide. Instead reveal each piece — a line, a card, a stat, an icon — **as the `voiceover` reaches it**, sequencing reveals across the shot and especially the back ~50%, with the macro camera move running underneath. **Only EXITS are banned** — a non-final frame unmounts mid-frame, so an exit tween truncates and reads as a glitch (the root transition IS the exit); mid-shot reveals are free and seek-safe. The lone exception is a note marked as a deliberate hold / stillness frame: there, an entrance + a quiet settle is right (a held read beats bad motion).
|
||||
- **Implement the shot sequence faithfully — every Scene is a timeline phase.** The Scene lines ARE the build: map each Scene onto a phase of the one timeline, each piece revealing as the `voiceover` reaches it. For each **named motion** in a Scene, open its rule recipe under `RULES_DIR/<id>.md` and reproduce its mechanics — **never name-guess** (a guess loses the signature move). The **`blueprint:` template** (`../hyperframes-animation/blueprints/<id>.md`) gives the overall shape; read it and keep its **signature move** recognizable, then instantiate it with this frame's content / assets / timing. `compose` → no template; sequence the shot straight from the Scene lines. Whichever, never front-load the whole sequence at `t=0` — pace the reveals to the voiceover.
|
||||
- **Place each candidate by its `roles`** (the `focal` is the hero): a `cutout` is a foreground subject — respect the 83% keep-out, lay text around it, not over its face; a `background` is full-bleed and dimmed ~30–50% so foreground content stays legible. Frame files are sub-compositions. Audio remains orchestrator-owned: never author `<audio>` in a frame. An approved `[video]` candidate may be declared as a frame-local `<video data-frame-video="approved" data-start="..." data-duration="..." data-track-index="...">`; `assemble-index.mjs` hoists it to the host root and translates its timing. Do not use this declaration for audio, unapproved URLs, or videos without explicit timing. If no approved video is supplied, use an explicitly supplied static still/key art (`[video-still]` or another image candidate) as `<img>`; do not extract a frame, fabricate a URL, or silently embed an unapproved clip.
|
||||
- **Place each candidate by its `roles`** (the `focal` is the hero): a `cutout` is a foreground subject — respect the 83% keep-out, lay text around it, not over its face; a `background` is full-bleed and dimmed ~30–50% so foreground content stays legible. Frame files are sub-compositions. Audio remains orchestrator-owned: never author `<audio>` in a frame. An approved `[video]` candidate may be declared as a frame-local `<video data-frame-video="approved" data-start="..." data-duration="..." data-track-index="...">`; `assemble-index.mjs` hoists it to the host root and translates its timing. Give every approved video explicit host geometry with numeric `data-frame-video-x`, `data-frame-video-y`, `data-frame-video-width`, and `data-frame-video-height`; optionally set `data-frame-video-fit="cover|contain|fill|none|scale-down"` (default `cover`). The assembler converts only those values to host CSS: frame-local classes and inline styles do not cross the hoist boundary. Do not use this declaration for audio, unapproved URLs, or videos without explicit timing and geometry. If no approved video is supplied, use an explicitly supplied static still/key art (`[video-still]` or another image candidate) as `<img>`; do not extract a frame, fabricate a URL, or silently embed an unapproved clip.
|
||||
|
||||
## Workflow
|
||||
|
||||
|
||||
Reference in New Issue
Block a user