diff --git a/skills-manifest.json b/skills-manifest.json index ae2a7e40e..9965b2874 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -6,8 +6,8 @@ "files": 144 }, "faceless-explainer": { - "hash": "e486bec3499bd84d", - "files": 19 + "hash": "a87a402ac73f3166", + "files": 20 }, "figma": { "hash": "0e6e96f5a76ff824", @@ -58,12 +58,12 @@ "files": 132 }, "pr-to-video": { - "hash": "739774abd773a3d3", - "files": 28 + "hash": "6bc0d09a63097455", + "files": 29 }, "product-launch-video": { - "hash": "86fcf6421ab9aa8f", - "files": 23 + "hash": "277fe2829b8da022", + "files": 24 }, "remotion-to-hyperframes": { "hash": "c96bb2f0af9e1143", diff --git a/skills/faceless-explainer/scripts/captions.mjs b/skills/faceless-explainer/scripts/captions.mjs index 433248deb..1203b57c6 100644 --- a/skills/faceless-explainer/scripts/captions.mjs +++ b/skills/faceless-explainer/scripts/captions.mjs @@ -34,9 +34,10 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { parseStoryboard } from "./lib/storyboard.mjs"; import { captionBand, parseFormat } from "./lib/dimensions.mjs"; -import { parseColors, parseFonts, semanticColors, lum } from "./lib/tokens.mjs"; +import { parseColors, parseFonts, semanticColors } from "./lib/tokens.mjs"; const flag = (argv, name, def) => { const i = argv.indexOf(`--${name}`); @@ -276,23 +277,11 @@ function buildFromSkin(skin, groups, total, W, H, tokens, die, faces = "", fonts // overflow:hidden — nothing is clipped); zeroing it would need an airy line-height that // balloons the pill, which is worse. Override only if a brand font genuinely clips. out += "\n"; - // dark-ground caption contrast (auto). The preset caption skins are tuned for a LIGHT - // pill (cream); on a dark brand ground the pill goes near-black, so the skin's faint - // upcoming-word mix and light highlight block turn unreadable. When the resolved caption - // canvas is dark, override the three word states: a brighter muted upcoming color + an - // on-brand ACCENT highlight block with light text. Light grounds are left untouched. - const capCanvas = (tokens.match(/--cap-canvas:\s*(#[0-9a-fA-F]{6})/) || [])[1]; - if (capCanvas && (lum(capCanvas) ?? 255) < 90) { - out += - "\n"; - } return `\n`; } +export { buildFromSkin }; + // @font-face for the brand display/body fonts, matched from the project's font dirs // (staged assets/fonts first, else capture/assets/fonts) by family-name prefix, with // weight parsed from the filename. Paths are relative to compositions/captions.html. @@ -498,11 +487,13 @@ function buildCaptionsHtml(groups, total, W, H) { `; } -const sub = process.argv[2]; -if (sub === "build" || sub === undefined) runBuild(process.argv.slice(sub === "build" ? 3 : 2)); -else { - console.error( - "usage: node captions.mjs build [--storyboard …] [--audio-meta …] [--hyperframes .]", - ); - process.exit(2); +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const sub = process.argv[2]; + if (sub === "build" || sub === undefined) runBuild(process.argv.slice(sub === "build" ? 3 : 2)); + else { + console.error( + "usage: node captions.mjs build [--storyboard …] [--audio-meta …] [--hyperframes .]", + ); + process.exit(2); + } } diff --git a/skills/faceless-explainer/scripts/captions.test.mjs b/skills/faceless-explainer/scripts/captions.test.mjs new file mode 100644 index 000000000..f2724ea42 --- /dev/null +++ b/skills/faceless-explainer/scripts/captions.test.mjs @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import { readdirSync, readFileSync } from "node:fs"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { buildFromSkin } from "./captions.mjs"; + +const presetsDir = fileURLToPath( + new URL("../../hyperframes-creative/frame-presets/", import.meta.url), +); +const skins = readdirSync(presetsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => ({ + name: entry.name, + source: readFileSync( + new URL( + `../../hyperframes-creative/frame-presets/${entry.name}/caption-skin.html`, + import.meta.url, + ), + "utf8", + ), + })) + .filter(({ source }) => source.includes(".caption-word.is-active")); + +for (const canvas of ["#f7f3e8", "#111827"]) { + for (const skin of skins) { + test(`${skin.name} preserves word-state rules on ${canvas}`, () => { + const active = skin.source.match(/\.caption-word\.is-active\s*\{[^}]*\}/s)?.[0]; + const spoken = skin.source.match(/\.caption-word\.is-spoken\s*\{[^}]*\}/s)?.[0]; + assert.ok(active, "skin must define an active-word rule"); + assert.ok(spoken, "skin must define a spoken-word rule"); + + const output = buildFromSkin( + skin.source, + [], + 1, + 1920, + 1080, + `:root { --cap-canvas: ${canvas}; --cap-ink: #111111; --cap-accent: #ffcc00; }`, + (message) => { + throw new Error(message); + }, + ); + + assert.ok(output.includes(active)); + assert.ok(output.includes(spoken)); + assert.equal(output.match(/\.caption-word\.is-active\s*\{/g)?.length, 1); + assert.equal(output.match(/\.caption-word\.is-spoken\s*\{/g)?.length, 1); + }); + } +} diff --git a/skills/pr-to-video/scripts/captions.mjs b/skills/pr-to-video/scripts/captions.mjs index 433248deb..1203b57c6 100644 --- a/skills/pr-to-video/scripts/captions.mjs +++ b/skills/pr-to-video/scripts/captions.mjs @@ -34,9 +34,10 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { parseStoryboard } from "./lib/storyboard.mjs"; import { captionBand, parseFormat } from "./lib/dimensions.mjs"; -import { parseColors, parseFonts, semanticColors, lum } from "./lib/tokens.mjs"; +import { parseColors, parseFonts, semanticColors } from "./lib/tokens.mjs"; const flag = (argv, name, def) => { const i = argv.indexOf(`--${name}`); @@ -276,23 +277,11 @@ function buildFromSkin(skin, groups, total, W, H, tokens, die, faces = "", fonts // overflow:hidden — nothing is clipped); zeroing it would need an airy line-height that // balloons the pill, which is worse. Override only if a brand font genuinely clips. out += "\n"; - // dark-ground caption contrast (auto). The preset caption skins are tuned for a LIGHT - // pill (cream); on a dark brand ground the pill goes near-black, so the skin's faint - // upcoming-word mix and light highlight block turn unreadable. When the resolved caption - // canvas is dark, override the three word states: a brighter muted upcoming color + an - // on-brand ACCENT highlight block with light text. Light grounds are left untouched. - const capCanvas = (tokens.match(/--cap-canvas:\s*(#[0-9a-fA-F]{6})/) || [])[1]; - if (capCanvas && (lum(capCanvas) ?? 255) < 90) { - out += - "\n"; - } return `\n`; } +export { buildFromSkin }; + // @font-face for the brand display/body fonts, matched from the project's font dirs // (staged assets/fonts first, else capture/assets/fonts) by family-name prefix, with // weight parsed from the filename. Paths are relative to compositions/captions.html. @@ -498,11 +487,13 @@ function buildCaptionsHtml(groups, total, W, H) { `; } -const sub = process.argv[2]; -if (sub === "build" || sub === undefined) runBuild(process.argv.slice(sub === "build" ? 3 : 2)); -else { - console.error( - "usage: node captions.mjs build [--storyboard …] [--audio-meta …] [--hyperframes .]", - ); - process.exit(2); +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const sub = process.argv[2]; + if (sub === "build" || sub === undefined) runBuild(process.argv.slice(sub === "build" ? 3 : 2)); + else { + console.error( + "usage: node captions.mjs build [--storyboard …] [--audio-meta …] [--hyperframes .]", + ); + process.exit(2); + } } diff --git a/skills/pr-to-video/scripts/captions.test.mjs b/skills/pr-to-video/scripts/captions.test.mjs new file mode 100644 index 000000000..f2724ea42 --- /dev/null +++ b/skills/pr-to-video/scripts/captions.test.mjs @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import { readdirSync, readFileSync } from "node:fs"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { buildFromSkin } from "./captions.mjs"; + +const presetsDir = fileURLToPath( + new URL("../../hyperframes-creative/frame-presets/", import.meta.url), +); +const skins = readdirSync(presetsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => ({ + name: entry.name, + source: readFileSync( + new URL( + `../../hyperframes-creative/frame-presets/${entry.name}/caption-skin.html`, + import.meta.url, + ), + "utf8", + ), + })) + .filter(({ source }) => source.includes(".caption-word.is-active")); + +for (const canvas of ["#f7f3e8", "#111827"]) { + for (const skin of skins) { + test(`${skin.name} preserves word-state rules on ${canvas}`, () => { + const active = skin.source.match(/\.caption-word\.is-active\s*\{[^}]*\}/s)?.[0]; + const spoken = skin.source.match(/\.caption-word\.is-spoken\s*\{[^}]*\}/s)?.[0]; + assert.ok(active, "skin must define an active-word rule"); + assert.ok(spoken, "skin must define a spoken-word rule"); + + const output = buildFromSkin( + skin.source, + [], + 1, + 1920, + 1080, + `:root { --cap-canvas: ${canvas}; --cap-ink: #111111; --cap-accent: #ffcc00; }`, + (message) => { + throw new Error(message); + }, + ); + + assert.ok(output.includes(active)); + assert.ok(output.includes(spoken)); + assert.equal(output.match(/\.caption-word\.is-active\s*\{/g)?.length, 1); + assert.equal(output.match(/\.caption-word\.is-spoken\s*\{/g)?.length, 1); + }); + } +} diff --git a/skills/product-launch-video/scripts/captions.mjs b/skills/product-launch-video/scripts/captions.mjs index 433248deb..1203b57c6 100644 --- a/skills/product-launch-video/scripts/captions.mjs +++ b/skills/product-launch-video/scripts/captions.mjs @@ -34,9 +34,10 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { parseStoryboard } from "./lib/storyboard.mjs"; import { captionBand, parseFormat } from "./lib/dimensions.mjs"; -import { parseColors, parseFonts, semanticColors, lum } from "./lib/tokens.mjs"; +import { parseColors, parseFonts, semanticColors } from "./lib/tokens.mjs"; const flag = (argv, name, def) => { const i = argv.indexOf(`--${name}`); @@ -276,23 +277,11 @@ function buildFromSkin(skin, groups, total, W, H, tokens, die, faces = "", fonts // overflow:hidden — nothing is clipped); zeroing it would need an airy line-height that // balloons the pill, which is worse. Override only if a brand font genuinely clips. out += "\n"; - // dark-ground caption contrast (auto). The preset caption skins are tuned for a LIGHT - // pill (cream); on a dark brand ground the pill goes near-black, so the skin's faint - // upcoming-word mix and light highlight block turn unreadable. When the resolved caption - // canvas is dark, override the three word states: a brighter muted upcoming color + an - // on-brand ACCENT highlight block with light text. Light grounds are left untouched. - const capCanvas = (tokens.match(/--cap-canvas:\s*(#[0-9a-fA-F]{6})/) || [])[1]; - if (capCanvas && (lum(capCanvas) ?? 255) < 90) { - out += - "\n"; - } return `\n`; } +export { buildFromSkin }; + // @font-face for the brand display/body fonts, matched from the project's font dirs // (staged assets/fonts first, else capture/assets/fonts) by family-name prefix, with // weight parsed from the filename. Paths are relative to compositions/captions.html. @@ -498,11 +487,13 @@ function buildCaptionsHtml(groups, total, W, H) { `; } -const sub = process.argv[2]; -if (sub === "build" || sub === undefined) runBuild(process.argv.slice(sub === "build" ? 3 : 2)); -else { - console.error( - "usage: node captions.mjs build [--storyboard …] [--audio-meta …] [--hyperframes .]", - ); - process.exit(2); +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const sub = process.argv[2]; + if (sub === "build" || sub === undefined) runBuild(process.argv.slice(sub === "build" ? 3 : 2)); + else { + console.error( + "usage: node captions.mjs build [--storyboard …] [--audio-meta …] [--hyperframes .]", + ); + process.exit(2); + } } diff --git a/skills/product-launch-video/scripts/captions.test.mjs b/skills/product-launch-video/scripts/captions.test.mjs new file mode 100644 index 000000000..f2724ea42 --- /dev/null +++ b/skills/product-launch-video/scripts/captions.test.mjs @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import { readdirSync, readFileSync } from "node:fs"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { buildFromSkin } from "./captions.mjs"; + +const presetsDir = fileURLToPath( + new URL("../../hyperframes-creative/frame-presets/", import.meta.url), +); +const skins = readdirSync(presetsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => ({ + name: entry.name, + source: readFileSync( + new URL( + `../../hyperframes-creative/frame-presets/${entry.name}/caption-skin.html`, + import.meta.url, + ), + "utf8", + ), + })) + .filter(({ source }) => source.includes(".caption-word.is-active")); + +for (const canvas of ["#f7f3e8", "#111827"]) { + for (const skin of skins) { + test(`${skin.name} preserves word-state rules on ${canvas}`, () => { + const active = skin.source.match(/\.caption-word\.is-active\s*\{[^}]*\}/s)?.[0]; + const spoken = skin.source.match(/\.caption-word\.is-spoken\s*\{[^}]*\}/s)?.[0]; + assert.ok(active, "skin must define an active-word rule"); + assert.ok(spoken, "skin must define a spoken-word rule"); + + const output = buildFromSkin( + skin.source, + [], + 1, + 1920, + 1080, + `:root { --cap-canvas: ${canvas}; --cap-ink: #111111; --cap-accent: #ffcc00; }`, + (message) => { + throw new Error(message); + }, + ); + + assert.ok(output.includes(active)); + assert.ok(output.includes(spoken)); + assert.equal(output.match(/\.caption-word\.is-active\s*\{/g)?.length, 1); + assert.equal(output.match(/\.caption-word\.is-spoken\s*\{/g)?.length, 1); + }); + } +} diff --git a/skills/product-launch-video/sub-agents/frame-worker.md b/skills/product-launch-video/sub-agents/frame-worker.md index 13e01d6f2..a3b4a9a70 100644 --- a/skills/product-launch-video/sub-agents/frame-worker.md +++ b/skills/product-launch-video/sub-agents/frame-worker.md @@ -17,6 +17,7 @@ - `roles:` — each candidate's role: `cutout` foreground / `background` full-bleed / supporting — plus the real media available (each `public/ — description`; a **`[video]`** tag marks a `.mp4` motion source that cannot be mounted by this sub-composition worker). - `sfx:` — the orchestrator's; you mount no audio. - `frame.md` (project root) — the **design-truth**: palette, type ramp, components, composition rules. The LOOK. Pull every visual token from here. +- **Asset paths are project-root relative everywhere.** This includes HTML attributes and CSS `url(...)` values such as `@font-face src`: use `assets/...` (or the supplied `capture/assets/...` path), never `../` or `../../`. Stage shared fonts before dispatch so every parallel frame resolves the same local files; never fall back to a network `@import`. - `RULES_DIR` — absolute path to this skill's local `../hyperframes-animation/rules/`. The **named motion verbs in the Scene lines** (and the moves the blueprint cites) resolve to rule recipes here: `RULES_DIR/.md` is the mechanics for a motion. (A few rules link an optional runnable demo in the shared `../hyperframes-animation/examples/.html` — open it only when a recipe is unclear.) - `../references/cut-catalog.md` — the **cut catalog** (zoom-through / inverse / cut-the-curve / waterfall). When a Scene seam is a within-scene swap, a scene-to-scene cut, or a text-to-text line change, build it INSIDE your composition per this catalog (Z-scale + blur + opacity, or per-word x-staggers). You never author the between-frame transition — story's `transition_in` + the injector own that. - Canvas `×` and `Captions: ` (+ the keep-out cutoff when enabled). @@ -57,6 +58,7 @@ Generic seek-safety + structure live in `hyperframes-core` (read it; not restate 1. **Read** — `hyperframes-core`'s composition contract (the structural law), then `frame.md` (the look) and your `## Frame N` block (the shot sequence + `blueprint:` / `focal:` / `roles:` / assets). **Then read the blueprint template** `../hyperframes-animation/blueprints/.md` (skip if `compose`) for the shot's shape and signature move, and **open the rule recipe `RULES_DIR/.md` for every named motion** in the Scene lines (plus the shared `../hyperframes-animation/examples/.html` when the recipe is unclear): you reproduce these mechanics, not improvise them. Internalize the self-check codes below before you write — most lethal is **template transport**: every `