From 2aadf450e718497ac4b0edb7286cf4c8f3da06bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 11 Jul 2026 01:55:19 -0400 Subject: [PATCH] fix(product-launch): hoist approved frame videos during assembly (#2226) * fix(product-launch): keep media out of frame subcompositions * fix(product-launch): hoist approved frame videos at assembly * fix(product-launch): format and refresh media contract * test(product-launch): harden approved video hoist * chore: refresh product-launch skill manifest * fix(product-launch): validate and sanitize hoisted video attrs * fix(product-launch): allowlist hoisted video attributes --- skills-manifest.json | 4 +- .../scripts/assemble-index.mjs | 104 +++++++++++++++++- .../scripts/media-contract.test.mjs | 94 ++++++++++++++++ .../sub-agents/frame-worker.md | 4 +- 4 files changed, 197 insertions(+), 9 deletions(-) create mode 100644 skills/product-launch-video/scripts/media-contract.test.mjs diff --git a/skills-manifest.json b/skills-manifest.json index 90856496d..9321089cb 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -62,8 +62,8 @@ "files": 22 }, "product-launch-video": { - "hash": "158398dbfc4ab652", - "files": 20 + "hash": "cb30ad95ba8863c7", + "files": 21 }, "remotion-to-hyperframes": { "hash": "aa599f027db4b994", diff --git a/skills/product-launch-video/scripts/assemble-index.mjs b/skills/product-launch-video/scripts/assemble-index.mjs index 96a55b38f..b89bc9164 100644 --- a/skills/product-launch-video/scripts/assemble-index.mjs +++ b/skills/product-launch-video/scripts/assemble-index.mjs @@ -38,8 +38,8 @@ // `lint` failures surface HERE instead of after assembly + a wasted render): // ① AUTO-REPAIR — a sub-comp root missing data-width/data-height: inject the canvas // dims (the renderer needs them on the cloned root; else lint root_missing_dimensions). -// ② HARD FAIL — `, + ); + + 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"); + const frame = readFileSync(framePath, "utf8"); + assert.match(index, /data-start="0\.25"/); + assert.match(index, /data-duration="1\.5"/); + assert.match(index, /data-track-index="1007"/); + assert.match(index, /src="https:\/\/cdn\.example\/clip\.mp4"/); + assert.match(index, /poster="poster\.png"/); + assert.match(index, /preload="auto"/); + assert.match(index, /\smuted(?:\s|>)/); + assert.match(index, /\splaysinline(?:\s|>)/); + assert.match(index, /\sloop(?:\s|>)/); + assert.doesNotMatch(index, /onerror=/i); + assert.doesNotMatch(index, /srcdoc=/i); + assert.doesNotMatch(index, /nonce=/i); + assert.doesNotMatch(index, /style=/i); + assert.doesNotMatch(frame, / { + const project = mkdtempSync(join(tmpdir(), "hf-frame-video-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"), + `
`, + ); + const result = spawnSync( + process.execPath, + [join(skillDir, "scripts", "assemble-index.mjs"), "--hyperframes", project], + { encoding: "utf8" }, + ); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /must declare quoted data-start/i); +}); + +test("does not hoist declarations hidden in comments or scripts", () => { + const project = mkdtempSync(join(tmpdir(), "hf-frame-video-hidden-")); + 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"), + `
`, + ); + const result = spawnSync( + process.execPath, + [join(skillDir, "scripts", "assemble-index.mjs"), "--hyperframes", project], + { encoding: "utf8" }, + ); + assert.equal(result.status, 0, result.stderr); + assert.doesNotMatch(readFileSync(join(project, "index.html"), "utf8"), /data-track-index="1001"/); +}); diff --git a/skills/product-launch-video/sub-agents/frame-worker.md b/skills/product-launch-video/sub-agents/frame-worker.md index 6dad8fb66..9761ea219 100644 --- a/skills/product-launch-video/sub-agents/frame-worker.md +++ b/skills/product-launch-video/sub-agents/frame-worker.md @@ -14,7 +14,7 @@ - the **time-coded shot sequence** — your build spec. A sequence of Scene lines (`Scene 1 (0.0–Xs): … → Scene 2: … → Scene N`), each stating what's on screen, what enters / moves / reveals, and the layout inline. Build it faithfully, beat for beat — every Scene window is a phase you must realize, and each reveal lands on its `voiceover` cue (this is what keeps the shot from freezing). - `blueprint:` — an id (or the literal `compose`). The id points to `../hyperframes-animation/blueprints/.md`: the **product-agnostic shot template** this frame instantiates — the overall shape + its signature move. Read it for the shape; `compose` means there's no template, sequence the shot from the Scene lines directly. - `focal:` — which candidate is the hero. - - `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 clip). + - `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. - `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.) @@ -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/.md` and reproduce its mechanics — **never name-guess** (a guess loses the signature move). The **`blueprint:` template** (`../hyperframes-animation/blueprints/.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. **A `[video]` candidate (`.mp4`) is a real motion clip — usually the strongest hero for a motion/demo product.** Render it as a **muted** `