diff --git a/skills-manifest.json b/skills-manifest.json index 34af99408..737c286b1 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -6,32 +6,32 @@ "files": 144 }, "faceless-explainer": { - "hash": "a87a402ac73f3166", - "files": 20 + "hash": "ff47c02598dc7947", + "files": 22 }, "figma": { "hash": "0e6e96f5a76ff824", "files": 2 }, "general-video": { - "hash": "1194ac1ea4e0c90d", - "files": 1 + "hash": "94fd399a0dd0b6ce", + "files": 4 }, "hyperframes": { - "hash": "de6908d7047b8702", - "files": 6 + "hash": "ab268ec1eca15ae9", + "files": 17 }, "hyperframes-animation": { - "hash": "78f333feba8bd836", - "files": 102 + "hash": "7b3cd6bf281b1141", + "files": 104 }, "hyperframes-cli": { - "hash": "10526c9dca9ae054", + "hash": "b1a0725560016894", "files": 11 }, "hyperframes-core": { - "hash": "fa201c126c87fd89", - "files": 17 + "hash": "23f64febbc5cae37", + "files": 19 }, "hyperframes-creative": { "hash": "b9e2cbfa49e6ed6c", @@ -46,24 +46,24 @@ "files": 10 }, "media-use": { - "hash": "5399c0f111ff5619", - "files": 139 + "hash": "b10751149c9ea1da", + "files": 145 }, "motion-graphics": { - "hash": "cc93e3d220a0ebf5", + "hash": "da65c1864debfe11", "files": 23 }, "music-to-video": { - "hash": "5bba048c425edcfa", + "hash": "562656e2a2f3a193", "files": 132 }, "pr-to-video": { - "hash": "6bc0d09a63097455", + "hash": "d37f9f5411c34089", "files": 29 }, "product-launch-video": { - "hash": "277fe2829b8da022", - "files": 24 + "hash": "10e0b80f7040ad1e", + "files": 26 }, "remotion-to-hyperframes": { "hash": "c96bb2f0af9e1143", diff --git a/skills/faceless-explainer/SKILL.md b/skills/faceless-explainer/SKILL.md index 8078bc6f9..d8392265e 100644 --- a/skills/faceless-explainer/SKILL.md +++ b/skills/faceless-explainer/SKILL.md @@ -23,7 +23,7 @@ Workflow: Step 0 setup → `hyperframes.json`; Step 1 brief → `capture/extract Goal: Enter with a confirmed brief, create the HyperFrames project, and make the brief durable. -**The brief is confirmed by the intent layer, not by questions asked here.** Opening rule, in order: **(1)** `BRIEF.md` exists → read it and ask nothing — the brief is settled, and its `flow`/`storyboard` derive the mode (brief contract § 1). **(2)** No `BRIEF.md` but the project exists (`hyperframes.json` / `STORYBOARD.md` on disk) → resume from the storyboard's frontmatter and the recorded preferences; never re-interrogate a half-built project. **(3)** Neither — a fresh creation request that arrived here directly → read `/hyperframes` and run its intent layer (§ 4): it checks recipes and remembered defaults, conducts this route's questions (`../hyperframes/references/route-briefs.md`), and hands back the locked brief. Edit requests skip all of this — go do the edit. +**The brief is confirmed by the intent layer, not by questions asked here.** Opening rule, in order: **(1)** `BRIEF.md` exists → read it and ask nothing — the brief is settled, and its `flow`/`storyboard` derive the mode (brief contract § 1). **(2)** No `BRIEF.md` but the project exists (`hyperframes.json` / `STORYBOARD.md` on disk) → resume from the storyboard's frontmatter and the recorded preferences; never re-interrogate a half-built project. **(3)** Neither — a fresh creation request that arrived here directly → read `/hyperframes` and run its intent layer (`references/intent-interview.md`): it checks recipes and remembered defaults, conducts this route's questions (`../hyperframes/references/routes/faceless-explainer.md`), and hands back the locked brief. Edit requests skip all of this — go do the edit. Initialize only if `hyperframes.json` is missing. Name `` from the topic in kebab-case, such as `compound-interest-explained`; never use workspace name or timestamp. @@ -38,7 +38,7 @@ After init, let `` be `videos/` and run every subsequent - **Collaborative:** wait for the user to sign in or explicitly choose `offline` / `go`. - **Autonomous:** state the status and continue through the available local engines. -Do not silently omit a required capability when no offline provider exists; surface the blocker. Do not fold this decision into another question or write keys into a per-repo `.env`. Auth ownership and offline fallbacks: `/media-use` § Providers. +Do not silently omit a required capability when no offline provider exists; surface the blocker. Do not fold this decision into another question or write keys into a per-repo `.env`. Auth ownership and offline fallbacks: `/media-use` `references/setup-providers.md` § Providers. **Gate:** `hyperframes.json` and `BRIEF.md` exist; the preference-backed answers were recorded (brief contract § 2); sign-in status was shown (signed in, or continuing offline). @@ -83,7 +83,7 @@ A faceless explainer usually has **no brand colors/fonts** (`tokens.json` colors Goal: Turn the text into an approved frame-by-frame teaching plan. -Read `../hyperframes-creative/references/story-spine.md` (hook language, value-before-evidence, storyboard-as-proposal), `references/story-design.md`, `../hyperframes-animation/blueprints-index.md`, `../hyperframes-core/references/storyboard-format.md`, and `../hyperframes-core/references/script-format.md`. Use them to write `STORYBOARD.md` and, when narration is needed, `SCRIPT.md`. +Read `../hyperframes-creative/references/story-spine.md` (hook language, value-before-evidence, storyboard-as-proposal), `references/story-design.md`, `../hyperframes-animation/blueprints-index.md`, `../hyperframes-core/references/storyboard-format.md`, and `../hyperframes-core/references/script-format.md`. Use them to write `STORYBOARD.md` and, when narration is needed, `SCRIPT.md`. Set the frontmatter `duration:` from the brief's `length` — a rough expectation; assembly reports where the cut lands against it. Use `story-design.md` for the explainer structure (concept / how-to / listicle / story), hook strategy, clarity techniques, emotional beats, the type-enum mapping, and `VO_MODE`. The video's sequence comes from **narrative design, not the input text's paragraph order** — reorder, merge, omit, compress. As a **soft guide**, consult the role→blueprint menu in `../hyperframes-animation/blueprints-index.md`: for each beat, write the voiceover in the shape its candidate blueprint implies and tag that candidate `blueprint:` id when one fits. Teaching truth still decides which beats exist — never force a beat to fit a blueprint, and never invent a beat just because a proven shape is available. Faceless visuals are invented downstream, so frames do **not** carry an asset inventory: leave `asset_candidates` empty unless the user supplied a real `public/` image. Use the exact required fields from the storyboard and script references. @@ -143,11 +143,15 @@ Wait for Step 3.1 audio to finish if audio was started. Then sync durations and Duration sync is mechanical: real voice duration wins; silent frames keep estimates; never hand-edit synced durations. -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. +Before dispatch, read `../hyperframes-core/references/subagent-dispatch.md`. Build the per-frame packets and the worker role payload: -Each worker context must include `PROJECT_DIR`, `frame_id`, whether the frame has a **confirmed sketch** on disk, 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 confirmed sketch when one exists (keep its layout — frame-worker § When a confirmed sketch exists), the local rule recipe (`../hyperframes-animation/rules/.md`) for each cited motion, and the frame's blueprint template (`../hyperframes-animation/blueprints/.md`). Each worker writes only `compositions/frames/NN-*.html`. Workers must never edit `STORYBOARD.md`. +`node /scripts/frame-packets.mjs --project "$PROJECT_DIR" --storyboard "$PROJECT_DIR/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`.) +The builder writes one bounded packet per frame under `.hyperframes/frame-packets/` (the frame's exact storyboard block + the blueprint body + every cited rule recipe, inlined) and `_role.md` (`../hyperframes-core/references/frame-worker-core.md` + this skill's `sub-agents/frame-worker.md`, concatenated verbatim — the complete worker role). Dispatch one sub-agent per frame, in parallel if possible; otherwise run workers in waves. Each worker gets exactly one frame: its prompt carries `_role.md` and that frame's packet — paste both in full, or hand the two file paths for the worker to read first (equivalent; the worker starts from exactly those two documents either way) — plus a dispatch context with `PROJECT_DIR`, `frame_id`, whether the frame has a **confirmed sketch** on disk (the worker dresses that layout rather than redrawing it — frame-worker core § When a confirmed sketch exists), canvas size, and caption status + keep-out band if captions are enabled. + +Workers read only their packet and `frame.md`; they never open `STORYBOARD.md` or the skill documents (the packet inlines what was selected upstream). 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: `../hyperframes-core/references/frame-worker-core.md`.) As each worker returns, the orchestrator marks that frame as `animated` in `STORYBOARD.md`. @@ -224,5 +228,6 @@ The reusable, domain-agnostic shot shapes live in `../hyperframes-animation/blue | `[references/motion-language.md](references/motion-language.md)` | Step 4: the motion vocabulary + the motion doctrine. | | `[references/cut-catalog.md](references/cut-catalog.md)` | Step 4-5: the cut catalog (worker builds within-frame seams). | | `[../hyperframes-animation/rules-index.md](../hyperframes-animation/rules-index.md)` + `[../hyperframes-animation/rules/](../hyperframes-animation/rules/)` | Step 5: local rule recipe bodies for the cited motions. | -| `[sub-agents/frame-worker.md](sub-agents/frame-worker.md)` | Step 5: dispatch per-frame workers. | +| `[../hyperframes-core/references/frame-worker-core.md](../hyperframes-core/references/frame-worker-core.md)` | Step 5: the shared worker contract (packet builder prepends it to the delta). | +| `[sub-agents/frame-worker.md](sub-agents/frame-worker.md)` | Step 5: the workflow's frame-worker delta. | | `[../hyperframes-core/references/subagent-dispatch.md](../hyperframes-core/references/subagent-dispatch.md)` | Step 5: dispatch sub-agents safely. | diff --git a/skills/faceless-explainer/scripts/assemble-index.mjs b/skills/faceless-explainer/scripts/assemble-index.mjs index 1bdfca1d1..b51ee0421 100644 --- a/skills/faceless-explainer/scripts/assemble-index.mjs +++ b/skills/faceless-explainer/scripts/assemble-index.mjs @@ -318,6 +318,33 @@ for (const m of mounted) { acc += m.durationSeconds; } const TOTAL = r3(acc); + +// ---------- duration expectation (advisory) ---------- +// Frontmatter `duration:` carries the brief's rough length expectation +// (storyboard-format.md § Frontmatter). Never blocks the build: report where +// the cut lands, and flag a large gap so the agent judges whether the drift +// serves the piece. +let durationNote = ""; +const rawTarget = manifest.globals.extra?.duration; +if (rawTarget != null && String(rawTarget).trim() !== "") { + const targetMatch = String(rawTarget).match(/(\d+(?:\.\d+)?)/); + const target = targetMatch ? parseFloat(targetMatch[1]) : NaN; + if (!Number.isFinite(target) || target <= 0) { + anomalies.push( + `frontmatter duration "${rawTarget}" is not parseable (e.g. "22s") — skipped the expectation check`, + ); + } else { + const diff = r3(TOTAL - target); + durationNote = ` (expected ~${target}s, ${diff >= 0 ? "+" : ""}${diff}s)`; + const pct = Math.abs((diff / target) * 100); + if (pct > 10) { + anomalies.push( + `total ${TOTAL}s lands ${Math.round(pct)}% ${diff > 0 ? "over" : "under"} the brief's ~${target}s expectation — ` + + `judge whether the drift serves the piece (pacing, narration fit); re-pace, or update \`duration:\` if the new length is intended`, + ); + } + } +} const startOfFrameNumber = new Map(); for (const m of mounted) if (m.frame.number != null) startOfFrameNumber.set(m.frame.number, m); @@ -562,7 +589,7 @@ console.log(` bgm (track 11): ${bgmEmitted ? "yes" + bgmNote : "no"}`); console.log(` captions (track 2): ${captionsEmitted ? "yes" : "no"}`); console.log(` sfx (track 20+): ${sfxEmitted}`); console.log(` assets staged: ${staged}/${wanted.size}`); -console.log(` total duration: ${TOTAL}s`); +console.log(` total duration: ${TOTAL}s${durationNote}`); if (repairs.length) { console.log(`\nrepaired (frame files updated in place):`); for (const rp of repairs) console.log(` - ${rp}`); diff --git a/skills/faceless-explainer/scripts/frame-packets.mjs b/skills/faceless-explainer/scripts/frame-packets.mjs new file mode 100644 index 000000000..63dfddfdc --- /dev/null +++ b/skills/faceless-explainer/scripts/frame-packets.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node + +// Thin wrapper over the shared packet builder in hyperframes-core — this file only +// pins the paths that are specific to this workflow skill. The logic (frame +// splitting, rule citation, packet bounds, `_role.md` assembly) has one owner: +// ../../hyperframes-core/scripts/lib/frame-packets-core.mjs + +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import * as core from "../../hyperframes-core/scripts/lib/frame-packets-core.mjs"; + +const SKILL_DIR = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const CONFIG = { + animationDir: resolve(SKILL_DIR, "../hyperframes-animation"), + corePath: resolve(SKILL_DIR, "../hyperframes-core/references/frame-worker-core.md"), + deltaPath: resolve(SKILL_DIR, "sub-agents/frame-worker.md"), +}; + +export function buildRolePayload({ outDir }) { + return core.buildRolePayload({ ...CONFIG, outDir }); +} + +export function buildFramePackets(options) { + return core.buildFramePackets({ ...CONFIG, ...options }); +} + +if (core.isMainModule(import.meta.url)) core.runCli({ buildFramePackets, buildRolePayload }); diff --git a/skills/faceless-explainer/scripts/frame-packets.test.mjs b/skills/faceless-explainer/scripts/frame-packets.test.mjs new file mode 100644 index 000000000..56101b730 --- /dev/null +++ b/skills/faceless-explainer/scripts/frame-packets.test.mjs @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; + +import { buildFramePackets } from "./frame-packets.mjs"; + +function write(path, contents) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, contents); +} + +test("packets inline the blueprint body and the Scene-cited rule recipes", () => { + const project = mkdtempSync(join(tmpdir(), "fev-packets-")); + write(join(project, "frame.md"), "# tokens\n"); + write( + join(project, "STORYBOARD.md"), + `---\nformat: 1920x1080\n---\n\n## Frame 1 — Hook\n\n- duration: 3s\n- src: compositions/frames/01-hook.html\n- blueprint: dataviz-countup\n- scene: hero stat punches in\n\nScene 1 (0.0–1.5s): the stat enters via spring-pop-entrance, then counting-dynamic-scale runs the tally.\n\n## Frame 2 — Freeform\n\n- duration: 4s\n- src: compositions/frames/02-freeform.html\n- blueprint: compose\n\nScene 1 (0.0–4.0s): a quiet hold, no named motion.\n`, + ); + + const result = buildFramePackets({ projectDir: project }); + assert.equal(result.length, 2); + + const hook = readFileSync(result[0].path, "utf8"); + assert.match(hook, /## Selected blueprint: dataviz-countup/); + assert.match(hook, /## Selected motion rule: spring-pop-entrance/); + assert.match(hook, /## Selected motion rule: counting-dynamic-scale/); + assert.match(hook, /RULES_DIR: /); + + const freeform = readFileSync(result[1].path, "utf8"); + assert.doesNotMatch(freeform, /## Selected blueprint/); + assert.doesNotMatch(freeform, /## Selected motion rule/); +}); + +test("_role.md is the core contract + this workflow's delta, verbatim", () => { + const project = mkdtempSync(join(tmpdir(), "fev-role-")); + write(join(project, "frame.md"), "# tokens\n"); + write( + join(project, "STORYBOARD.md"), + `---\nformat: 1920x1080\n---\n\n## Frame 1 — Hook\n\n- duration: 3s\n- src: compositions/frames/01-hook.html\n`, + ); + + buildFramePackets({ projectDir: project }); + const rolePath = join(project, ".hyperframes", "frame-packets", "_role.md"); + assert.ok(existsSync(rolePath)); + const role = readFileSync(rolePath, "utf8"); + assert.match(role, /# Frame worker — core contract/); + assert.match(role, /# Frame worker — faceless-explainer delta/); +}); + +test("packet validation is atomic and leaves no partial output on overflow", () => { + const project = mkdtempSync(join(tmpdir(), "fev-atomic-")); + const outDir = join(project, ".hyperframes", "frame-packets"); + write(join(project, "frame.md"), "# tokens\n"); + write( + join(project, "STORYBOARD.md"), + `---\nformat: 1920x1080\n---\n\n## Frame 1 — Big\n\n- duration: 3s\n- src: compositions/frames/01-big.html\n\n${"padding line\n".repeat(300)}`, + ); + + assert.throws( + () => buildFramePackets({ projectDir: project, outDir, maxPacketBytes: 2_000 }), + /limit 2000/, + ); + assert.equal(existsSync(outDir), false); +}); diff --git a/skills/faceless-explainer/sub-agents/frame-worker.md b/skills/faceless-explainer/sub-agents/frame-worker.md index 263fe0078..00fa222e6 100644 --- a/skills/faceless-explainer/sub-agents/frame-worker.md +++ b/skills/faceless-explainer/sub-agents/frame-worker.md @@ -1,79 +1,12 @@ -# Frame worker — faceless-explainer per-frame composition author +# Frame worker — faceless-explainer delta -> You build **one** frame's composition HTML and nothing else. You run N-up, one frame each — siblings build the others. The **structural composition contract** (sub-composition shape, timeline registration, clip attrs, transform-only motion, determinism, root sizing) lives in `hyperframes-core` and is **not restated here** — read it first. This file carries only what's specific to a faceless-explainer frame. Tempted to add a generic GSAP / timeline rule here? Wrong home — it belongs in `hyperframes-core`. +> The shared law is the core contract above (the packet builder prepends `../hyperframes-core/references/frame-worker-core.md` to this file as `_role.md`) — read the two as one role. This file carries only what's specific to a faceless-explainer frame; you run N-up, **one frame each** — your dispatch carries exactly one packet. Tempted to add a generic GSAP / timeline rule here? Wrong home — it belongs in the core contract or `hyperframes-core`. -**INPUT** — your dispatch context provides: +## Your `focal:` / `roles:` — invented elements -- `PROJECT_DIR` — the project root; all paths are relative to it. -- `frame_id` — e.g. `03-compounds`. Use it **verbatim** as the composition id, the `window.__timelines` key, and the file name (`compositions/frames/03-compounds.html`) — that path **is** the frame's `src` in `STORYBOARD.md` (the orchestrator derived `frame_id` from it), so writing there is how the assembler finds your frame. -- Your **`## Frame N` block** in `STORYBOARD.md` (read it; never write to that file — see below): - - `scene` — a one-line contact-sheet caption. **Design intent, never visible DOM text.** - - `voiceover` — the narration line. **Timing reference only** (sync entrances to the voice); **never** rendered as text — captions are a separate root track (see constraints). - - `duration` — your render length in seconds. **Fixed upstream; never change it or tween to fill a different length.** - - `transition_in` — informational. The injector stamps it at the root; **you do not author transitions.** - - 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 **domain-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 **invented** element is the hero. - - `roles:` — each invented element's role: `foreground subject` / `background` full-bleed / `supporting`. Because the explainer is **faceless, these are elements you design** (a hero word, a diagram node, a chart series, a coined-term card), not captured assets. The **only** real media is a user-supplied image, when present: `public/ — description` (a **`[video]`** tag marks a `.mp4` clip the user provided). - - `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.) -- `../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). +- `focal:` — which **invented** element is the hero. +- `roles:` — each invented element's role: `foreground subject` / `background` full-bleed / `supporting`. Because the explainer is **faceless, these are elements you design** (a hero word, a diagram node, a chart series, a coined-term card), not captured assets. The **only** real media is a user-supplied image, when present: `public/ — description` (a **`[video]`** tag marks a `.mp4` clip the user provided). -**Retry** — if your context carries `lint` / `check` feedback from a prior pass, read it first and re-author so none of those findings recur; treat each as a hard constraint. +## Designing each element (faceless-explainer constraint) -**OUTPUT** — `compositions/frames/.html`, one self-contained sub-composition. Writing it (past the self-check below) is your **terminal action** — you do not edit `STORYBOARD.md`, mint audio, assemble the index, run the CLI, or report back. The orchestrator picks up the file and marks the frame's `status`. - -## When a confirmed sketch exists - -In collaborative runs the orchestrator wireframes the board first, so your target file may already exist as the frame's **user-confirmed wireframe** — your dispatch says whether it does (a file found on a retry is your own prior output, not a sketch). Read it first and **keep its composition**: the placement, hierarchy, and copy were approved — don't move or drop them. Everything else is yours to finish: the full `frame.md` treatment (the sketch is deliberately unstyled), the finished invented visuals where the sketch used plain blocks, and the motion — map each Scene onto a timeline phase, reveal each piece on its `voiceover` cue with `fromTo` entrances, adding DOM only where a phase needs it. The frame's landed state must still read as the approved wireframe, fully dressed. - -## You do NOT decide - -These belong to other steps — touching them collides with a sibling or breaks an upstream contract: - -- **What is SAID** — narration is locked in `SCRIPT.md` / the `voiceover` line. You only show; you never write or restate narration text. -- **Duration** — fixed from real voice timing. Build your entrance to land within it; don't stretch or trim it. -- **Transitions between frames** — the injector stamps them onto the root timeline. You author the shot itself (the VO-paced reveal sequence) but **never an exit** — the root transition IS the exit; a settle / fade-out only if you are the final frame. -- **Audio** (narration / BGM / SFX) — assembled at the root by the orchestrator. **No `