From 91197b4266ef768cbef679f03dff9d5aeb098001 Mon Sep 17 00:00:00 2001 From: Brian Madison Date: Wed, 22 Jul 2026 18:53:39 -0500 Subject: [PATCH 1/8] Remove Remotion; HyperFrames is the sole motion-graphics engine (1.1.0) Remotion is dropped and HyperFrames absorbs its jobs (dual-render brand stinger, shorts karaoke captions, plain HTML/SVG comps). Two reasons: its license is free only up to 3 people, which shipped a licensing obligation to every creator at a 4+ person company installing this distributed module; and its React authoring model bought nothing in a frame-deterministic renderer, where state is a function of frame index. - Delete skills/mc-graphics/engines/remotion.md; rationale now lives in engines/hyperframes.md ("Why this engine and not Remotion"). - HyperFrames encodes alpha to both ProRes 4444 MOV and VP9 yuva420p WebM, so the OBS-plus-editor dual target is unchanged. - engine_stingers/engine_overlays profile keys read hyperframes everywhere. - Version policy reversed: install hyperframes@latest at workspace init and record the resolved version; never ship a pin from this repo. - PIPELINE.md tolerance rule maps 0.x beat rows carrying engine: remotion to hyperframes, so in-flight projects do not break. - Sweep README, user-guide, under-the-hood HTML, TODO, module-help.csv, check_deps.py, customize.toml, stack-windows.md, and test fixtures. - Bump to 1.1.0; CHANGELOG heading updated. OGraf unaffected. --- .claude-plugin/marketplace.json | 2 +- CHANGELOG.md | 6 +++++- README.md | 6 +++--- TODO.md | 4 ++-- docs/manny-under-the-hood.html | 7 +++---- docs/user-guide.md | 2 +- skills/mc-agent/references/skills-map.md | 2 +- skills/mc-beats/SKILL.md | 2 +- .../mc-cut/scripts/tests/test-render_final.py | 2 +- skills/mc-graphics/SKILL.md | 4 ++-- .../mc-graphics/engines/design-prompting.md | 18 ++++++++-------- skills/mc-graphics/engines/hyperframes.md | 21 +++++++++++++++++-- skills/mc-graphics/engines/remotion.md | 17 --------------- skills/mc-ograf/SKILL.md | 2 +- skills/mc-pipeline/PIPELINE.md | 10 ++++----- .../mc-setup/assets/formats/course-lesson.md | 4 ++-- .../assets/formats/livestream-pack.md | 4 ++-- .../assets/formats/screen-tutorial.md | 2 +- skills/mc-setup/assets/formats/short.md | 4 ++-- .../mc-setup/assets/formats/talking-head.md | 4 ++-- .../assets/formats/voiceover-explainer.md | 4 ++-- skills/mc-setup/customize.toml | 2 +- skills/mc-setup/references/stack-windows.md | 2 +- skills/mc-setup/scripts/check_deps.py | 2 +- .../tests/test-merge_profile_frontmatter.py | 4 ++-- skills/mc-stream-pack/SKILL.md | 2 +- skills/module-help.csv | 2 +- 27 files changed, 72 insertions(+), 69 deletions(-) delete mode 100644 skills/mc-graphics/engines/remotion.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 29898a6..5e03a68 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -19,7 +19,7 @@ "name": "bmad-manticore", "source": "./", "description": "AI video production pipeline: brain dump to a rough cut sitting in your editor, in your own words, with approval gates at every taste decision.", - "version": "1.0.1", + "version": "1.1.0", "author": { "name": "Brian (BMad) Madison" }, diff --git a/CHANGELOG.md b/CHANGELOG.md index 240ba72..44b0ef9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to BMad Manticore are documented here. Dates are ISO (YYYY-MM-DD). -## Unreleased +## 1.1.0 - Unreleased ### Cross-platform: Windows, Linux, and Intel Mac lanes (code-complete, pending real-hardware validation) @@ -12,6 +12,10 @@ All notable changes to BMad Manticore are documented here. Dates are ISO (YYYY-M - mc-audio portability: the audio-lab venv interpreter resolves per OS (.venv/bin/python vs .venv\Scripts\python.exe), Windows machines with an NVIDIA GPU install torch from the PyTorch cu126 index (roughly 2.5 to 3 GB extra, surfaced in the consent message and the `--dry-run` torch field), and MusicGen/AudioLDM2 pick cuda, then mps, then cpu. macOS behavior is unchanged. - Small portability fixes: edl_to_fcpxml.py emits valid Windows file URIs (file:///C:/... and UNC shares) via Path.as_uri() with byte-identical POSIX output; farm_asset.py resolves registered tools with a PATH lookup (Windows npm .cmd/.exe shims launch by bare name), documents POSIX quoting for headless templates on every OS, and refuses to pass arguments containing cmd.exe metacharacters (embedded double quotes, % ^ & | < >) to a .cmd/.bat shim, failing loudly with a re-register hint instead of letting cmd.exe corrupt or expand them; verify_ograf.py prints per-OS manual verification steps and, from a human terminal, serves the package and opens preview.html in the default browser itself. transcribe.py, edl_to_fcpxml.py, and render_final.py read and write their JSON, FCPXML, and concat-list artifacts with explicit UTF-8 so non-ASCII transcripts and paths survive on Windows locale codecs (cp1252). +### Changed + +- Remotion is removed; HyperFrames is the module's single motion-graphics engine. Two reasons: Remotion's license is free only for companies of up to 3 people and Manticore is a distributed module, so shipping it handed every creator at a 4+ person company a licensing obligation they never opted into; and its remaining justification ("anything React-stateful") bought nothing in a frame-deterministic renderer, where state is a function of frame index. Every job Remotion held (the dual-render brand stinger, shorts karaoke captions, plain HTML/SVG comps) is a paused GSAP timeline in HyperFrames, which encodes alpha to both ProRes 4444 MOV and VP9 yuva420p WebM, so the OBS-plus-editor dual target is unchanged. The `engine_stingers`/`engine_overlays` profile keys now read `hyperframes` where they read `remotion`, `skills/mc-graphics/engines/remotion.md` is deleted, and the rationale lives in `skills/mc-graphics/engines/hyperframes.md`. Beat tables from 0.x carrying `engine: remotion` are read as `hyperframes` under the PIPELINE.md tolerance rule, so in-flight projects do not break. OGraf is unaffected; it solves a different problem (Resolve-editable and SPX-GC live). + ### Added - Final renders are loudness-normalized by default to -14 LUFS (two-pass ffmpeg loudnorm, TP -1.5, LRA 11; audio-only second pass with the video stream copied), configurable via `[render]` loudness-target and disable-able via `[render]` loudnorm = false or `--no-loudnorm`. Preview renders are never normalized. Silent audio skips the pass with a warning instead of failing. diff --git a/README.md b/README.md index c377fd4..f7875b7 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ my-studio/ <- install here, run everything from here exemplars/, headshots/ formats/ <- your editable format profiles (learnings accumulate here) projects/ <- one folder per video, fully self-contained - engines/ <- HyperFrames / Remotion workspaces + engines/ <- HyperFrames / OGraf workspaces ``` Then say "talk to Manny". Manny the Manticore (mc-agent) is the studio's director and front door: he detects that the studio is not set up yet and walks you through mc-setup's onboarding interview (identity, editor, render consent, video style, brand, headshots, voice bible, tools), turns your first idea into a project, routes existing footage into a footage-first project, and drives every stage from there. You never have to know which skill does what. @@ -89,7 +89,7 @@ Manticore orchestrates tools; it does not replace them. The defaults are local a | parakeet-mlx / onnx-asr | Word-level cutting transcripts with verbatim fillers (the "um"s are exactly what gets cut); parakeet-mlx on Apple Silicon, onnx-asr running the same parakeet-tdt-0.6b-v3 weights on Windows, Linux, and Intel Mac | Free, runs locally, no API key | | Kokoro-82M (kokoro-onnx) | TTS narration and two-host dialogue for the mc-audio lane (stock voices, no cloning) | Free, local, faster than realtime on CPU | | MusicGen-small + AudioLDM2 | Instrumental music beds and SFX, farmed locally by mc-audio | Free, local, ungated models | -| HyperFrames and Remotion | Motion graphics engines for overlay beats, stingers, and karaoke captions | Free (Remotion is free for companies up to 3 people) | +| HyperFrames | Motion graphics engine for overlay beats, stingers, and karaoke captions | Free, local, Apache 2.0, no commercial-use threshold | | OGraf + SPX-GC / OBS | Broadcast graphics that stay editable in DaVinci Resolve 21+ and click-to-trigger live in OBS | Free | | yt-dlp | Pulls your back-catalog transcripts to build your voice bible | Free | | Grok CLI (xAI), opt-in | Imagine stills and image-to-video b-roll clips with native audio, plus X/Twitter research and posting, from the terminal | Covered by a SuperGrok / X Premium+ subscription; a metered xAI API lane exists only as an explicit opt-in | @@ -129,7 +129,7 @@ Seven ship by default: talking-head, screen-tutorial (real UI only, generated b- | mc-script | Weave the script from your words; lint; craft QA | | mc-cut | Word-level transcript, cut plan with taste calls (gate 2), edl.json, preview render every iteration, timeline export, the offered final render | | mc-beats | The graphics beat table anchored to spoken words, under creativity mandates and your density tier, with a CTA placement pass (gate 3) | -| mc-graphics | Execute beats in HyperFrames / Remotion / HTML / design-prompting; frame-verified alpha overlays | +| mc-graphics | Execute beats in HyperFrames / HTML / design-prompting; frame-verified alpha overlays | | mc-ograf | Editable broadcast graphics (DaVinci Resolve 21+ and OBS/SPX-GC) | | mc-assets | Farm b-roll stills/clips via your registered CLI tools (metered APIs opt-in), under generative-editing safety rules | | mc-audio | Farm sound, local-first: TTS narration and two-host dialogue (Kokoro-82M), instrumental beds (MusicGen-small), SFX (AudioLDM2); paid lanes opt-in | diff --git a/TODO.md b/TODO.md index 8ca38e4..c4f2525 100644 --- a/TODO.md +++ b/TODO.md @@ -7,7 +7,7 @@ State as of 2026-07-07, the 1.0.0 release. Read AGENTS.md first (module conventi - Per-episode stream packs and the Ecamm lane (the named 1.0.x fast-follow): mc-stream-pack gains a pre-show per-episode pack lane (topic popups, CTAs, lower thirds mined from the episode plan before the show, delivered as switchable scenes) with the two-tier asset rule (evergreen chrome once into series `common/`, topic graphics per episode). The `[live]` tool key (obs, ecamm, other) already ships and is interviewed at setup; the OBS lane keeps HTML browser sources and WebM stingers; the Ecamm/other lane delivers baked PNG / ProRes 4444 alpha scene stills and loops, a ProRes stinger, a countdown safe-zone spec with a --guides render, and a tool-specific HANDOFF.md. Ecamm Live is macOS-only. Scheduled-livestream packaging (mc-package live-event mode, two-asset thumbnail rule) rides along. - farm_asset.py metered API lane (xAI Imagine REST image ~$0.02 and video ~$0.05/s submit/poll/download; Veo 3.1 via the Gemini API as the escalation lane). Registered CLI tools are the only implemented farming lane in 1.0; the API lane ships opt-in only, never as a default. - resolve_import.py: push the exported timeline into a running DaVinci Resolve. External scripting requires Resolve Studio; free-edition users will run it from inside Resolve via the Fusion Scripts menu (the per-OS install paths are already documented in the mc-setup stack references and mc-ograf's resolve-workflow reference). The mc-cut offer stays gated on the script's implemented status. Native scripting remains the documented path; no MCP dependency. -- HyperFrames engine workspace initialization at a pinned version on the first real graphics run (upstream is pre-1.0 and moves fast; v0.7.26 as of 2026-07-03). +- HyperFrames engine workspace initialization on the first real graphics run: install `hyperframes@latest` at that moment and record the resolved version in the workspace package.json (upstream is pre-1.0 and moves fast, so never ship a pin from this repo). ## 1.x roadmap @@ -51,7 +51,7 @@ The cross-platform local lane landed (onnx-asr running the same parakeet-tdt-0.6 ### Shorts karaoke captions -- Word-level karaoke caption system for the short format, built on Remotion, driven by the same word timestamps the cut lane already produces. +- Word-level karaoke caption system for the short format, built on HyperFrames, driven by the same word timestamps the cut lane already produces. ### Decks and whiteboards diff --git a/docs/manny-under-the-hood.html b/docs/manny-under-the-hood.html index bc63a77..db4411d 100644 --- a/docs/manny-under-the-hood.html +++ b/docs/manny-under-the-hood.html @@ -285,7 +285,7 @@

The tools and models behind each stage

Motion engines

working
-

HyperFrames and Remotion for overlay beats and stingers, OGraf for editable broadcast graphics. Themed through tokens.json.

+

HyperFrames for overlay beats, stingers, and karaoke captions, OGraf for editable broadcast graphics. Themed through tokens.json.

Look development

working
@@ -532,14 +532,13 @@

The beat table is the contract

b1"latency"00:12.40stat-cardhyperframesnull b2"pipeline"00:31.08diagramhtmlnull - b3"subscribe"01:47.902ctaremotionnull + b3"subscribe"01:47.902ctahyperframesnull b4"the result"02:20.15lower-thirdografshot-07
-

HyperFrames

Default per-video overlay beats. Registry checked before authoring; exports ProRes 4444 alpha.

-

Remotion

Stingers, transitions, React-stateful graphics, shorts karaoke captions. Dual render for OBS and the editor.

+

HyperFrames

Overlay beats, stingers, and shorts karaoke captions. Registry checked before authoring; exports ProRes 4444 alpha, plus a VP9 alpha WebM dual render for OBS.

OGraf

Editable broadcast graphics, only where the target supports it (Resolve 21+, or OBS live). Everyone else gets baked alpha.

HTML lane

Author from scratch, rendered to exact pixels by Playwright, then frame-verified over a checkerboard for alpha.

diff --git a/docs/user-guide.md b/docs/user-guide.md index 34dd439..8fd503b 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -24,7 +24,7 @@ my-studio/ <- install here, run everything from here projects/ <- one folder per video, fully self-contained my-first-video/ another-video/ - engines/ <- HyperFrames / Remotion workspaces + engines/ <- HyperFrames / OGraf workspaces ``` Install: diff --git a/skills/mc-agent/references/skills-map.md b/skills/mc-agent/references/skills-map.md index 2bbfa9d..a728204 100644 --- a/skills/mc-agent/references/skills-map.md +++ b/skills/mc-agent/references/skills-map.md @@ -44,7 +44,7 @@ Riffs graphic and treatment ideas with the creator first, then builds the beat t ### mc-graphics -Executes the approved beat table in HyperFrames / Remotion / HTML / design-prompting; frame-verified ProRes 4444 alpha overlays plus HANDOFF.md. Routes whooshes and beds to mc-audio. Needs gate 3 approved. +Executes the approved beat table in HyperFrames / HTML / design-prompting; frame-verified ProRes 4444 alpha overlays plus HANDOFF.md. Routes whooshes and beds to mc-audio. Needs gate 3 approved. ### mc-assets diff --git a/skills/mc-beats/SKILL.md b/skills/mc-beats/SKILL.md index 214ff9e..555f2ba 100644 --- a/skills/mc-beats/SKILL.md +++ b/skills/mc-beats/SKILL.md @@ -35,7 +35,7 @@ The table follows PIPELINE.md's engine-neutral contract, one row per beat: |---|---|---|---|---|---|---|---|---|---|---| - `type` is one of the format profile's `beat-types` (the frontmatter list is the whole vocabulary for the format). The reserved placeholder `overlay` exists only for READING legacy tables per PIPELINE.md's tolerance rule; mc-beats never writes it. -- `engine` names the rendering engine per the engine policy (e.g. `hyperframes`, `remotion`, `ograf`, `html`). +- `engine` names the rendering engine per the engine policy (e.g. `hyperframes`, `ograf`, `html`). - `asset` is `null` or a farmed-asset id for mc-assets. - mc-beats always writes every column. When revising a legacy 0.x table that lacks the extended columns, apply PIPELINE.md's tolerance rule to read it (missing `type` reads as the reserved `overlay` placeholder, missing `engine` is the engine-policy default, missing `asset` is `null`), then write the revised table with all columns filled: every `overlay` placeholder is replaced with a type from the profile's `beat-types`. diff --git a/skills/mc-cut/scripts/tests/test-render_final.py b/skills/mc-cut/scripts/tests/test-render_final.py index 156654d..1ebb68f 100644 --- a/skills/mc-cut/scripts/tests/test-render_final.py +++ b/skills/mc-cut/scripts/tests/test-render_final.py @@ -56,7 +56,7 @@ def test_format(self): | id | start | dur | end | anchor word | anchor ts | spoken phrase | type | engine | asset | composition | |---|---|---|---|---|---|---|---|---|---|---| | b1 | 0:02 | 3 | 0:05 | alpha | 0:02 | "alpha beta" | overlay | html | null | keyword callout | -| b2 | 12.5 | 2.5 | | gamma | 12.5 | "gamma" | cta | remotion | cta-card | subscribe | +| b2 | 12.5 | 2.5 | | gamma | 12.5 | "gamma" | cta | hyperframes | cta-card | subscribe | | b3 | 30 | | 34 | delta | 30 | "delta" | | | | legacy 0.x row | | bad | oops | 2 | | x | 0 | "x" | overlay | | | broken start | | b4 | 40 | | | x | 40 | "x" | overlay | | | no dur or end | diff --git a/skills/mc-graphics/SKILL.md b/skills/mc-graphics/SKILL.md index 3bee8e2..f33ae5a 100644 --- a/skills/mc-graphics/SKILL.md +++ b/skills/mc-graphics/SKILL.md @@ -1,6 +1,6 @@ --- name: mc-graphics -description: Execute the approved beat table in HyperFrames/Remotion/OGraf/HTML, render, frame-verify, and deliver alpha overlays plus a HANDOFF for the creator's editor. Use at the graphics stage, only after gate 3 (beats) is approved. +description: Execute the approved beat table in HyperFrames/OGraf/HTML, render, frame-verify, and deliver alpha overlays plus a HANDOFF for the creator's editor. Use at the graphics stage, only after gate 3 (beats) is approved. --- # mc-graphics @@ -8,7 +8,7 @@ description: Execute the approved beat table in HyperFrames/Remotion/OGraf/HTML, ## Steps 1. Load the studio config (`uv run {project-root}/_bmad/scripts/resolve_config.py --project-root {project-root} --key modules.manticore`; empty means mc-setup has not run: stop and route the creator there) and this skill's own surface (`uv run {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root}`; run `{workflow.activation_steps_prepend}` now, `{workflow.activation_steps_append}` after this step, and hold `{workflow.persistent_facts}` as standing context). Resolve `paths` values against `{project-root}`. Read `project.json` (confirm `approvals.beats` is a date, stage `graphics`), `beats/beats.md`, `beats/STORYBOARD.md`, `{brand-path}/tokens.json`, `{brand-path}/production-bible.md` (the styling contract beyond tokens.json: overlay aesthetic, motion feel, image-type policy, placement rules), the format profile, and `{skill-root}/engines/.md` for each engine the table names. Beats marked OGraf route through the mc-ograf skill, and ONLY if `[editor] ograf-editable = true`; otherwise build them as baked alpha overlays like everything else (baked alpha works in every editor). -2. Engine workspaces live at `{engines-path}//`; initialize on first use per the engine README (pin versions). +2. Engine workspaces live at `{engines-path}//`; initialize on first use per the engine README, installing the latest published version at that moment and recording what it resolved. 3. Source before authoring: for each beat, check the engine registry/library for a fitting block (`npx hyperframes add`, existing brand-themed blocks in the engine workspace); for simple moves on a finished still (fly-in and fly-out, staged builds), prefer the ffmpeg recipes in `{skill-root}/references/motion-recipes.md`; when nothing fits, author from scratch via the html lane (`{skill-root}/engines/html.md`) or the design-prompting loop (`{skill-root}/engines/design-prompting.md`). Everything themes through tokens.json, no hardcoded colors or fonts. 4. Build per engine in the project's `graphics/` folder. Follow the loop: edit, lint, preview, draft render (CRF 28), single-frame verify, final render. The shipped toolkit does the mechanical parts: `{skill-root}/scripts/html_to_png.py` (exact-size HTML render, separate `--guides` pass, alpha verify) and `{skill-root}/scripts/snug_frame.py` (native-aspect photo framing). Sound: when a composition calls for a whoosh, hit, chime, or bed (the animation-feel conventions in the Production Bible say when), route through the mc-audio service skill (never reach into its folder) and deliver the wav into `graphics/` next to the overlay it belongs to, with its timing noted in HANDOFF.md. 5. Verify every final render with `uv run {skill-root}/scripts/render_verify.py`, passing expectations explicitly: `--pixfmt` per the delivery target, `--expect-dur` from the beat's dur, `--expect-fps` and `--expect-res` from the format profile, or `--meta` pointing at the comp's meta.json render contract carrying the same keys (extracted frames visually checked over checkerboard for alpha). A render without checked frames is not done. diff --git a/skills/mc-graphics/engines/design-prompting.md b/skills/mc-graphics/engines/design-prompting.md index dc8a4da..e9f899f 100644 --- a/skills/mc-graphics/engines/design-prompting.md +++ b/skills/mc-graphics/engines/design-prompting.md @@ -4,11 +4,11 @@ The authoring path when no registry block or brand template fits a beat: hand th ## The core rule -Design surfaces handle look and iteration, but pixels always come from deterministic frame-stepped rendering (Remotion render, or headless-Chrome frame stepping plus ffmpeg to ProRes 4444 alpha), never realtime screen recording. +Design surfaces handle look and iteration, but pixels always come from deterministic frame-stepped rendering (HyperFrames render, or headless-Chrome frame stepping plus ffmpeg to ProRes 4444 alpha), never realtime screen recording. Realtime capture gives no alpha channel, a variable frame rate, and dropped frames. Every deliverable render walks frames deterministically: -- Remotion path: `npx remotion render` with `--codec prores --prores-profile 4444 --pixel-format yuva444p10le` for the editor lane, and a second render with `--codec vp9 --pixel-format yuva420p` for the OBS/live lane. One comp, two renders, never two source comps. +- HyperFrames path: `npx hyperframes render` to ProRes 4444 (yuva444p10le) MOV for the editor lane, and a second render of the same comp to VP9 (yuva420p) WebM for the OBS/live lane. One comp, two renders, never two source comps. - HTML path: a headless-Chrome harness seeks the animation to frame N, screenshots with a transparent background (`omitBackground: true` gives PNG alpha), then `ffmpeg -framerate {fps} -i frame_%05d.png -c:v prores_ks -profile:v 4444 -pix_fmt yuva444p10le overlay.mov`. A design surface's live preview, a hosted review page, and any screen recording are review surfaces only, never deliverables. @@ -22,7 +22,7 @@ Designs authored in a chat or design surface default to wall-clock CSS animation - No unseeded randomness. Identical frame in, identical pixels out (render a frame twice and byte-compare to verify). - Signal readiness: resolve `document.fonts.ready` before the first frame is captured. -For Remotion beats the contract is instead: composition with explicit `durationInFrames` and `fps`, all motion derived from `useCurrentFrame()`, no `Math.random` without Remotion's seeded `random()`. +HyperFrames beats satisfy the same contract natively: the comp is plain HTML/CSS plus a GSAP timeline created with `{ paused: true }` and registered on `window.__timelines`, which the renderer seeks per frame. Declare fps and duration in the comp's render config rather than inferring them. ## The design brief @@ -82,7 +82,7 @@ Sources for the brief: the approved beat row (timing, anchor, composition), the ## The iterate loop 1. Brief: generate the brief file from the beat row, tokens, and the Production Bible. -2. Propose: the design model produces a candidate comp (self-contained HTML honoring the seek contract, or a Remotion comp when React-stateful). +2. Propose: the design model produces a candidate comp (self-contained HTML honoring the seek contract, authored directly as a HyperFrames comp when it is headed for the engine workspace). 3. Render one frame: seek to the anchor frame and render it. Cheap, fast, and catches most misses before any video render. 4. Critique against the bible: check the frame against the Production Bible's aesthetic language, the safe zones, the verbatim text, and alpha over checkerboard. Revise and repeat. 5. Review with the creator: the review surface may be a hosted page (a Claude Artifact is the worked example) showing the animation looping, a scrub slider driving the same `seek(frame)` the renderer will use, a checkerboard toggle to prove alpha, and a composite over a still frame extracted from the actual footage at the beat's start time so safe zones are checked against reality. The creator gives frame-referenced notes ("at 0:00.8 the underline overshoots"); each note feeds the next revision of the local source file, which stays the single source of truth. @@ -93,7 +93,6 @@ Sources for the brief: the approved beat row (timing, anchor, composition), the Once the creator approves the look, it becomes durable engine code rather than a one-off: - HyperFrames: port the comp into a themed block in the HyperFrames workspace, all colors and fonts read from tokens, timing parameterized so the block can be reused at other durations. -- Remotion: promote the comp into the Remotion workspace with `durationInFrames` and `fps` as props and all motion from `useCurrentFrame()`. - OGraf: only when the target supports it (editor lane per `[editor] ograf-editable`, always for the live lane); rebuild the approved look as an OGraf graphic via mc-ograf rather than wrapping the HTML. - Foreign HTML (exported from a design surface) is sanitized before entering a workspace: strip or inline every external reference, replace hardcoded colors and fonts with token references (a grep for hex literals not present in tokens is the lint), retrofit the seek contract, and double-render a frame to verify determinism. - Record promoted blocks in the format profile's Templates section so future beats assemble them instead of redesigning. @@ -186,12 +185,12 @@ to the cut. anchor k; SVG-based so strokes stay crisp at 4K> ``` -### Example 3: cold-open title stinger (Remotion, dual render) +### Example 3: cold-open title stinger (HyperFrames, dual render) Transcript moment: the approved hook line opens the video; the stinger also serves as the live scene transition. ```markdown -# Design brief: b00-title-stinger (engine: Remotion) +# Design brief: b00-title-stinger (engine: HyperFrames) ## Beat @@ -217,8 +216,9 @@ title as it tracks in; everything exits with a fast upward wipe in the last 12 frames so the footage is revealed clean. ## Determinism contract -Remotion composition, durationInFrames=48, fps=30; all motion from -useCurrentFrame() via interpolate/spring; no unseeded randomness. +HyperFrames comp, 48 frames at 30fps; one GSAP timeline created +{ paused: true } and registered on window.__timelines; no unseeded +randomness. ## Deliverables ONE comp, TWO renders: ProRes 4444 (yuva444p10le) MOV for the editor, diff --git a/skills/mc-graphics/engines/hyperframes.md b/skills/mc-graphics/engines/hyperframes.md index 2e95bf8..5dcebfa 100644 --- a/skills/mc-graphics/engines/hyperframes.md +++ b/skills/mc-graphics/engines/hyperframes.md @@ -1,14 +1,31 @@ # Engine: HyperFrames -Default engine for per-video overlay beats. Apache 2.0, fully local, free: `npx hyperframes render` drives HTML/CSS/GSAP frame-by-frame in headless Chrome. Export overlay-only ProRes 4444 MOV with alpha (their docs recommend exactly this for Resolve workflows). The hosted MCP/HeyGen credits are optional convenience; the pipeline never depends on them. +Default engine for per-video overlay beats, stingers, and karaoke captions. Apache 2.0, fully local, free: `npx hyperframes render` drives HTML/CSS/GSAP frame-by-frame in headless Chrome. Export overlay-only ProRes 4444 MOV with alpha (their docs recommend exactly this for Resolve workflows). The hosted MCP/HeyGen credits are optional convenience; the pipeline never depends on them. + +## Why this engine and not Remotion + +Remotion was the module's second engine through 0.x and was removed on 2026-07-22. Two reasons, recorded here so the decision is not relitigated per video: + +- License. Remotion is free only for companies of up to 3 people; past that it needs a paid Company License (per-seat, or per-render with a monthly minimum). Manticore is a distributed module, so shipping Remotion would hand every creator at a 4+ person company a licensing obligation they did not opt into. HyperFrames is Apache 2.0 with no commercial-use threshold. +- React bought nothing. Remotion's remaining justification was "anything React-stateful", but in a frame-deterministic renderer state IS a function of frame index. Every job Remotion held here (the dual-render brand stinger, word-level karaoke captions, plain HTML/SVG comps) is a paused GSAP timeline in HyperFrames, and the beat table drove both engines identically. One engine means one authoring model and one thing for the agent to know. + +Remotion remains the stronger pick for React shops rendering at massive scale. That is not this pipeline. ## Setup -- The engine workspace lives at the creator's `{engines-path}/hyperframes/`, initialized on first graphics run. PIN the version in its package.json (pre-1.0, moves fast; v0.7.26 as of 2026-07-03). Upgrade deliberately, never floating. +- The engine workspace lives at the creator's `{engines-path}/hyperframes/`, initialized on first graphics run. Install the latest published version at that moment (`npm install hyperframes@latest`); never carry a version number in module docs, which only ships a stale pin to every new creator. Upstream is pre-1.0 and moves fast, so record the version the install actually resolved in the workspace's package.json and upgrade deliberately from there rather than floating mid-project. - Pull registry blocks before authoring anything: `npx hyperframes add` (50+ blocks: caption styles, lower thirds, transitions, dataviz). Theme every block through `{brand-path}/tokens.json`. - `@hyperframes/studio` is the timeline GUI; bidirectional sync with the HTML source is real (drag a beat in the GUI, the code updates; hand-edit the code, the GUI hot-reloads). Use it for timing nudges after mc-graphics gets close. +## Jobs + +- Per-video overlay beats: the default lane, delivered as ProRes 4444 alpha MOVs. +- Brand stinger and transitions: ONE composition rendered twice, VP9 yuva420p WebM for OBS and ProRes 4444 MOV for the editor lane. Keep it 1 to 2 seconds (transparent WebM renders slowly). HyperFrames captures each frame as PNG with alpha and encodes through either alpha-capable codec, so the dual target is one comp and two renders. +- Shorts karaoke captions: word-level highlight driven by the transcript's word timestamps, built from a registry caption block themed through tokens.json. + ## Rules - Brand-themed blocks live in the engine workspace and are reused across projects; per-video comps live in each project's `graphics/` folder. +- Dual-render targets share one source of truth; never maintain two stinger comps. +- WebM VP9 alpha is for browser and OBS consumption only. Editors ignore its alpha channel and render transparent areas black, so the editor lane always takes the ProRes 4444 MOV. - A render is not done until `{skill-root}/scripts/render_verify.py` has extracted frames and they have been visually checked. diff --git a/skills/mc-graphics/engines/remotion.md b/skills/mc-graphics/engines/remotion.md deleted file mode 100644 index 23f0805..0000000 --- a/skills/mc-graphics/engines/remotion.md +++ /dev/null @@ -1,17 +0,0 @@ -# Engine: Remotion - -React-based renderer, free for companies of up to 3 people (check the Remotion license above that size). Used for: - -- The brand stinger/transition: ONE composition rendered twice, VP9 yuva420p WebM for OBS stingers and ProRes 4444 MOV for Resolve. Keep it 1 to 2 seconds (transparent WebM renders slowly). -- Shorts karaoke captions: word-level highlight driven by the transcript. -- Anything React-stateful, and plain HTML/SVG/JS comps when that is the natural authoring mode. It is all HTML underneath; the beat table drives Remotion comps identically to HyperFrames. - -## Setup - -- The engine workspace (package.json, comps) lives at the creator's `{engines-path}/remotion/`, initialized on first graphics run; this folder holds the module's shared knowledge and reference comps. -- Everything reads `{brand-path}/tokens.json`; no hardcoded colors or fonts in comps. - -## Rules - -- Dual-render targets share one source of truth; never maintain two stinger comps. -- Verify renders with `{skill-root}/scripts/render_verify.py` (ffprobe pixel format + frame extraction) before calling them done. diff --git a/skills/mc-ograf/SKILL.md b/skills/mc-ograf/SKILL.md index 40b0c67..570da71 100644 --- a/skills/mc-ograf/SKILL.md +++ b/skills/mc-ograf/SKILL.md @@ -13,7 +13,7 @@ The outcome is a FOLDER (`*.ograf.json` manifest + Web Component `.mjs` + assets 1. Load the studio config (`uv run {project-root}/_bmad/scripts/resolve_config.py --project-root {project-root} --key modules.manticore`; empty means mc-setup has not run: stop and route the creator there) and this skill's own surface (`uv run {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root}`; run `{workflow.activation_steps_prepend}` now, `{workflow.activation_steps_append}` after this step, and hold `{workflow.persistent_facts}` as standing context). Resolve `paths` values against `{project-root}`. 2. OGraf is only the right output when the target supports it: - - Editor lane: `[editor] ograf-editable = true` (DaVinci Resolve 21+). If false, STOP and say so; the same graphic should be built as a baked alpha overlay by mc-graphics instead (HyperFrames/Remotion work in every editor). + - Editor lane: `[editor] ograf-editable = true` (DaVinci Resolve 21+). If false, STOP and say so; the same graphic should be built as a baked alpha overlay by mc-graphics instead (baked HyperFrames alpha works in every editor). - Live lane: OBS/SPX-GC stream graphics (mc-stream-pack). Editor-independent; always allowed. ## Design the graphic diff --git a/skills/mc-pipeline/PIPELINE.md b/skills/mc-pipeline/PIPELINE.md index 6dc25a3..b3ff152 100644 --- a/skills/mc-pipeline/PIPELINE.md +++ b/skills/mc-pipeline/PIPELINE.md @@ -88,11 +88,11 @@ If the config exists but a key this stage needs is missing or empty, ask for jus ## Engine policy -- HyperFrames: default for per-video overlay beats. Registry blocks before authoring (`npx hyperframes add`). Export overlay-only ProRes 4444 MOV with alpha. -- Remotion: stingers and transitions (dual render: VP9 alpha WebM for OBS + ProRes 4444 for the editor timeline lane), React-stateful graphics, shorts karaoke captions. Free for companies of up to 3 people; larger teams need a Remotion license. +- HyperFrames: the graphics engine. Per-video overlay beats, stingers and transitions (dual render: VP9 alpha WebM for OBS + ProRes 4444 for the editor timeline lane), and shorts karaoke captions. Registry blocks before authoring (`npx hyperframes add`). Export overlay-only ProRes 4444 MOV with alpha. Apache 2.0, local, no commercial-use threshold. - OGraf (the mc-ograf skill): ONLY when the target supports it. Editor lane requires `[editor] ograf-editable = true` (DaVinci Resolve 21+); the live lane (OBS/SPX-GC via mc-stream-pack) is editor-independent. Everyone else gets baked alpha MOVs, which work in every editor. - Everything is themed through `{brand-path}/tokens.json`. Component sourcing rule: registries and open libraries first, author from scratch only when nothing fits. -- Engine workspaces (the pinned HyperFrames project, the Remotion project) live at `{engines-path}`; mc-setup or the first graphics run initializes them. +- Engine workspaces (the pinned HyperFrames project) live at `{engines-path}`; mc-setup or the first graphics run initializes them. +- Remotion was a second engine through 0.x and was removed on 2026-07-22: its license is free only up to 3 people, and its React authoring model bought nothing in a frame-deterministic renderer. Rationale in `mc-graphics/engines/hyperframes.md`. ## The beat table (engine-neutral graphics contract) @@ -104,9 +104,9 @@ One row per graphic beat, produced by mc-beats, consumed by mc-graphics and mc-a Column rules: - `type` is a beat type from the format profile's `beat-types` frontmatter list (e.g. `lower-third`, `diagram`, `stat-card`, `cta`); the profile is the single type vocabulary for its format. The reserved placeholder `overlay` is legal only when reading legacy tables (tolerance rule below) and is never written. -- `engine` names the engine that renders the beat, per the Engine policy below (e.g. `hyperframes`, `remotion`, `ograf`, `html`). +- `engine` names the engine that renders the beat, per the Engine policy below (e.g. `hyperframes`, `ograf`, `html`). - `asset` is `null` or a farmed-asset id from `assets/manifest.json`; mc-assets farms the listed assets, mc-graphics composes with them. -- Tolerance rule: consumers MUST accept rows missing `type`, `engine`, or `asset` (beat tables written by 0.x projects). Treat a missing `type` as the reserved placeholder `overlay` (informational only; rendering keys off `engine` and `composition`), a missing `engine` as the Engine policy default, and a missing `asset` as `null`. A stage that rewrites the table (mc-beats) replaces every `overlay` placeholder with a type from the profile's `beat-types`. An in-flight 0.x project never breaks on the extended contract. +- Tolerance rule: consumers MUST accept rows missing `type`, `engine`, or `asset` (beat tables written by 0.x projects). Treat a missing `type` as the reserved placeholder `overlay` (informational only; rendering keys off `engine` and `composition`), a missing `engine` as the Engine policy default, an `engine` of `remotion` (written by 0.x projects) as `hyperframes`, and a missing `asset` as `null`. A stage that rewrites the table (mc-beats) replaces every `overlay` placeholder with a type from the profile's `beat-types`. An in-flight 0.x project never breaks on the extended contract. Anchors are measured against the EDITED timeline defined by `cut/edl.json`, not the raw take. diff --git a/skills/mc-setup/assets/formats/course-lesson.md b/skills/mc-setup/assets/formats/course-lesson.md index 6788229..fb1027c 100644 --- a/skills/mc-setup/assets/formats/course-lesson.md +++ b/skills/mc-setup/assets/formats/course-lesson.md @@ -2,7 +2,7 @@ format: course-lesson stages: [new, braindump, outline, script, record, cut, beats, graphics, package, final, retro] engine_overlays: hyperframes -engine_stingers: remotion +engine_stingers: hyperframes generated_broll: banned beat-types: [lower-third, title-card, step-counter, definition-card, checkpoint-slide, list-build, diagram, screenshot-callout, recap-card, cta] density: @@ -27,7 +27,7 @@ Talking-head profile adapted for course/lesson content. The first five lessons a ## Engine defaults -- Same as talking-head: HyperFrames overlays, Remotion stinger. +- Same as talking-head: HyperFrames overlays, HyperFrames stinger. ## Templates diff --git a/skills/mc-setup/assets/formats/livestream-pack.md b/skills/mc-setup/assets/formats/livestream-pack.md index 5068fd3..51c1488 100644 --- a/skills/mc-setup/assets/formats/livestream-pack.md +++ b/skills/mc-setup/assets/formats/livestream-pack.md @@ -2,7 +2,7 @@ format: livestream-pack stages: [new, stream-pack, final, retro] engine_overlays: ograf -engine_stingers: remotion +engine_stingers: hyperframes generated_broll: banned beat-types: [starting-soon-scene, brb-scene, ending-scene, full-overlay, lower-third, topic-card, stinger] density: @@ -25,7 +25,7 @@ Not a video. One run of mc-stream-pack producing a complete OBS asset pack from - Static scenes as self-contained local HTML (starting-soon with countdown, BRB, ending, full overlay). OBS browser sources render local HTML transparent by default; no server. - Scenes are reactive via the `window.obsstudio` JS API (countdown resets on scene activation, lower thirds re-trigger entrance on visibility) with a plain-browser fallback. -- Stinger transition: one Remotion comp rendered twice (VP9 yuva420p WebM for OBS, ProRes 4444 MOV for the editor lane), 1 to 2 seconds. Baked alpha scene and lower-third deliverables list WebM VP9 alpha (libvpx-vp9 yuva420p) for OBS on any platform alongside the ProRes 4444 MOV; render_verify.py can transcode and verify the WebM from the ProRes master in one step. +- Stinger transition: one HyperFrames comp rendered twice (VP9 yuva420p WebM for OBS, ProRes 4444 MOV for the editor lane), 1 to 2 seconds. Baked alpha scene and lower-third deliverables list WebM VP9 alpha (libvpx-vp9 yuva420p) for OBS on any platform alongside the ProRes 4444 MOV; render_verify.py can transcode and verify the WebM from the ProRes master in one step. - vMix note: vMix rejects MP4 stingers and prefers PNG sequences; when the live tool is vMix, deliver a PNG sequence or the ProRes 4444 MOV instead of WebM. Wirecast takes the ProRes 4444 MOV directly. - Lower thirds and topic cards as OGraf (via the mc-ograf skill), standalone-capable and SPX-GC-compatible for click-to-trigger later. diff --git a/skills/mc-setup/assets/formats/screen-tutorial.md b/skills/mc-setup/assets/formats/screen-tutorial.md index bcc52fd..1b74add 100644 --- a/skills/mc-setup/assets/formats/screen-tutorial.md +++ b/skills/mc-setup/assets/formats/screen-tutorial.md @@ -2,7 +2,7 @@ format: screen-tutorial stages: [new, braindump, outline, script, record, cut, beats, graphics, package, final, retro] engine_overlays: hyperframes -engine_stingers: remotion +engine_stingers: hyperframes generated_broll: banned beat-types: [lower-third, title-card, keyword-pop, list-build, diagram, zoom-pan, ui-callout, key-press-chip, step-counter, cta] density: diff --git a/skills/mc-setup/assets/formats/short.md b/skills/mc-setup/assets/formats/short.md index 7ac32dd..b794310 100644 --- a/skills/mc-setup/assets/formats/short.md +++ b/skills/mc-setup/assets/formats/short.md @@ -1,7 +1,7 @@ --- format: short stages: [new, cut, beats, graphics, package, final, retro] -engine_overlays: remotion +engine_overlays: hyperframes engine_stingers: none generated_broll: allowed beat-types: [karaoke-caption, hook-text, keyword-pop, screenshot-callout, cta] @@ -26,7 +26,7 @@ density: ## Engine defaults -- Captions and overlays: the Remotion karaoke caption system, safe-area aware for the vertical UI chrome. +- Captions and overlays: the HyperFrames karaoke caption system, safe-area aware for the vertical UI chrome. - Source: the parent project's `cut/edl.json` and transcript; a short's own edl selects and reorders parent segments. ## Templates diff --git a/skills/mc-setup/assets/formats/talking-head.md b/skills/mc-setup/assets/formats/talking-head.md index 78a1052..d840b9d 100644 --- a/skills/mc-setup/assets/formats/talking-head.md +++ b/skills/mc-setup/assets/formats/talking-head.md @@ -2,7 +2,7 @@ format: talking-head stages: [new, braindump, outline, script, record, cut, beats, graphics, assets, package, final, retro] engine_overlays: hyperframes -engine_stingers: remotion +engine_stingers: hyperframes generated_broll: allowed beat-types: [lower-third, title-card, keyword-pop, quote-card, list-build, stat-card, diagram, screenshot-callout, b-roll, cta] density: @@ -27,7 +27,7 @@ The default for main channel videos. The creator on camera, graphics are overlay ## Engine defaults - Overlays: HyperFrames, registry blocks first, ProRes 4444 alpha export. -- Stinger/transition: the Remotion brand stinger from `{engines-path}/remotion/`. +- Stinger/transition: the HyperFrames brand stinger from `{engines-path}/hyperframes/`. - Generated b-roll: allowed for atmosphere and story beats only, never for UI or text that must be accurate. ## Templates diff --git a/skills/mc-setup/assets/formats/voiceover-explainer.md b/skills/mc-setup/assets/formats/voiceover-explainer.md index 345fa8d..b4c1a6a 100644 --- a/skills/mc-setup/assets/formats/voiceover-explainer.md +++ b/skills/mc-setup/assets/formats/voiceover-explainer.md @@ -2,7 +2,7 @@ format: voiceover-explainer stages: [new, braindump, outline, script, record, cut, beats, graphics, assets, package, final, retro] engine_overlays: hyperframes -engine_stingers: remotion +engine_stingers: hyperframes generated_broll: allowed beat-types: [slide, diagram, list-build, keyword-pop, quote-card, stat-card, farmed-still, farmed-clip, screenshot, cta] density: @@ -27,7 +27,7 @@ Narration status: creator-recorded narration is the default and the honest recom ## Engine defaults -- Diagrams and slides: HyperFrames or plain HTML/SVG comps in Remotion (the creator's call per video; both read `{brand-path}/tokens.json`). +- Diagrams and slides: HyperFrames blocks or plain HTML/SVG comps (the creator's call per video; both read `{brand-path}/tokens.json`). - Farmed stills/clips: per the configured `[assets]` lanes and the `PIPELINE.md` engine policy. No vendor is assumed; if a lane is unset, the assets stage stops and asks. ## Templates diff --git a/skills/mc-setup/customize.toml b/skills/mc-setup/customize.toml index 94b402c..987aefd 100644 --- a/skills/mc-setup/customize.toml +++ b/skills/mc-setup/customize.toml @@ -44,7 +44,7 @@ brand-path = "manticore/brand" formats-path = "manticore/formats" # projects-path holds one folder per video. projects-path = "manticore/projects" -# engines-path holds your HyperFrames/Remotion/OGraf engine workspaces. +# engines-path holds your HyperFrames/OGraf engine workspaces. engines-path = "manticore/engines" [defaults.video] diff --git a/skills/mc-setup/references/stack-windows.md b/skills/mc-setup/references/stack-windows.md index eed3611..0e886e3 100644 --- a/skills/mc-setup/references/stack-windows.md +++ b/skills/mc-setup/references/stack-windows.md @@ -34,7 +34,7 @@ Same as above except: ## Windows notes -- Keep `{engines-path}` short. Deep Node engine workspaces (HyperFrames, Remotion) can trip MAX_PATH; also enable LongPathsEnabled in the registry. +- Keep `{engines-path}` short. Deep Node engine workspaces (HyperFrames) can trip MAX_PATH; also enable LongPathsEnabled in the registry. - Install the gyan.dev full ffmpeg build; it carries libx264, prores_ks, libvpx-vp9, and drawtext with freetype/fontconfig. - Screen recording goes through OBS, not Game Bar (single-app only, no Desktop or Explorer capture). - Registered CLI tools installed via npm run through `.cmd` shims; the pipeline resolves them with PATH lookup before launching. Headless templates use POSIX quoting. diff --git a/skills/mc-setup/scripts/check_deps.py b/skills/mc-setup/scripts/check_deps.py index 9ab200b..708802e 100644 --- a/skills/mc-setup/scripts/check_deps.py +++ b/skills/mc-setup/scripts/check_deps.py @@ -33,7 +33,7 @@ ("uv", True, "runs every pipeline script (installs Python automatically if needed)"), ("ffmpeg", True, "frame extraction, re-mux to constant frame rate, preview renders"), ("ffprobe", True, "frame-rate and pixel-format verification"), - ("node", True, "HyperFrames and Remotion render engines"), + ("node", True, "HyperFrames render engine"), ("npx", True, "hyperframes CLI and registry blocks"), ("git", True, "project history"), ("yt-dlp", False, "pulling your published transcripts for the voice bible"), diff --git a/skills/mc-setup/scripts/tests/test-merge_profile_frontmatter.py b/skills/mc-setup/scripts/tests/test-merge_profile_frontmatter.py index 40bc430..1343e64 100644 --- a/skills/mc-setup/scripts/tests/test-merge_profile_frontmatter.py +++ b/skills/mc-setup/scripts/tests/test-merge_profile_frontmatter.py @@ -35,7 +35,7 @@ STUDIO = """--- format: talking-head stages: [new, braindump, outline, script, record, cut, beats, graphics, assets, package, final, retro] -engine_overlays: remotion +engine_overlays: ograf generated_broll: banned --- @@ -76,7 +76,7 @@ def test_missing_keys_merged_existing_and_body_untouched(self): merged = self.studio.read_text() self.assertIn("beat-types: [popup, diagram, lower-third, stat-card, cta]", merged) self.assertIn('medium: "20-45s"', merged) - self.assertIn("engine_overlays: remotion", merged) # studio value wins + self.assertIn("engine_overlays: ograf", merged) # studio value wins self.assertIn("generated_broll: banned", merged) # studio value wins self.assertNotIn("Shipped prose", merged) self.assertIn("the creator's hard-won learning stays put", merged) diff --git a/skills/mc-stream-pack/SKILL.md b/skills/mc-stream-pack/SKILL.md index 4b59fb3..3953baa 100644 --- a/skills/mc-stream-pack/SKILL.md +++ b/skills/mc-stream-pack/SKILL.md @@ -10,7 +10,7 @@ Brand tokens in, complete pack out. Spec lives in the `livestream-pack` format p ## Steps 1. Load the studio config (`uv run {project-root}/_bmad/scripts/resolve_config.py --project-root {project-root} --key modules.manticore`; empty means mc-setup has not run: stop and route the creator there) and this skill's own surface (`uv run {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root}`; run `{workflow.activation_steps_prepend}` now, `{workflow.activation_steps_append}` after this step, and hold `{workflow.persistent_facts}` as standing context). Resolve `paths` values against `{project-root}`. Read `project.json` (stage `stream-pack`), the `livestream-pack` format profile, `{brand-path}/tokens.json`, and `{brand-path}/production-bible.md` when it exists (the styling contract beyond tokens: overlay and popup aesthetic for scenes and lower thirds, per-series template sections, and the CTA section). The OGraf standards apply to the lower thirds; the mc-ograf skill enforces them in step 2. -2. Build the pack the profile specifies (scene list, reactivity, render formats, and durations come from the profile, not from here): static scenes as self-contained local HTML in `graphics/scenes/`, all styling from tokens.json; the stinger as one Remotion comp in `{engines-path}/remotion/`, rendered to both formats the profile names; lower thirds and topic cards via the mc-ograf skill (never reach into its folder). Baked alpha deliverables headed for OBS browser or stinger use on any platform get a WebM VP9 alpha variant produced and verified in one step: `uv run {skill-root}/scripts/render_verify.py graphics/.mov --transcode-webm graphics/.webm` (checks default to yuva420p; add `--expect-res`/`--expect-fps`/`--expect-dur` from the profile). When `[live] tool` is vmix or other, know the targets: vMix rejects MP4 stingers and prefers PNG sequences, so deliver a PNG sequence (`ffmpeg -i .mov -pix_fmt rgba graphics/-png/%04d.png`) or the ProRes 4444 MOV instead of WebM; Wirecast takes the ProRes 4444 MOV directly. Sound for the pack (the stinger whoosh, a Starting Soon music bed) routes through the mc-audio service skill the same way; deliver the wavs alongside the scenes with OBS wiring noted in HANDOFF.md. This live lane does not require `[editor] ograf-editable`; OBS/SPX-GC is editor-independent. +2. Build the pack the profile specifies (scene list, reactivity, render formats, and durations come from the profile, not from here): static scenes as self-contained local HTML in `graphics/scenes/`, all styling from tokens.json; the stinger as one HyperFrames comp in `{engines-path}/hyperframes/`, rendered to both formats the profile names; lower thirds and topic cards via the mc-ograf skill (never reach into its folder). Baked alpha deliverables headed for OBS browser or stinger use on any platform get a WebM VP9 alpha variant produced and verified in one step: `uv run {skill-root}/scripts/render_verify.py graphics/.mov --transcode-webm graphics/.webm` (checks default to yuva420p; add `--expect-res`/`--expect-fps`/`--expect-dur` from the profile). When `[live] tool` is vmix or other, know the targets: vMix rejects MP4 stingers and prefers PNG sequences, so deliver a PNG sequence (`ffmpeg -i .mov -pix_fmt rgba graphics/-png/%04d.png`) or the ProRes 4444 MOV instead of WebM; Wirecast takes the ProRes 4444 MOV directly. Sound for the pack (the stinger whoosh, a Starting Soon music bed) routes through the mc-audio service skill the same way; deliver the wavs alongside the scenes with OBS wiring noted in HANDOFF.md. This live lane does not require `[editor] ograf-editable`; OBS/SPX-GC is editor-independent. 3. Verify, not vibes: run the profile's verification section. Scene screenshots land in `graphics/_verify/` and every one is visually checked; stinger checks run via `uv run {skill-root}/scripts/render_verify.py`. Stinger and baked-asset WebM variants are verified with `--pixfmt yuva420p`, or produced and verified in one step via `--transcode-webm` as in step 2. 4. Write `graphics/HANDOFF.md`: OBS setup steps per asset (browser source URLs/sizes, stinger transition settings). Update project.json artifacts and advance stage per the profile's stages list (next after `stream-pack`, normally `final`): the creator loads the pack in OBS and approves the look live. diff --git a/skills/module-help.csv b/skills/module-help.csv index 6582dde..041c665 100644 --- a/skills/module-help.csv +++ b/skills/module-help.csv @@ -9,7 +9,7 @@ BMad Manticore,mc-outline,Outline,OL,"3 hooks plus one outline plus the packagin BMad Manticore,mc-script,Script,SC,"Weave the script from the creator's own braindump words, lint against the blacklist, craft QA. The creator records it however they always record.",,,1-write,mc-outline,mc-cut,false,projects-path,*/script.md BMad Manticore,mc-cut,Cut,CT,"Word-level transcript, cut plan with taste calls. GATE 2: hard stop. Every approval produces a preview render and the editor timeline export; the final-quality render is offered at gate 4.",,,2-cut,mc-script,mc-beats,true,projects-path,*/cut/edl.json BMad Manticore,mc-beats,Graphics Beats,BT,"Riff treatment ideas with the creator, then the beat table anchored to spoken words under creativity mandates and the density tier, with the CTA placement pass. GATE 3: hard stop.",,,3-graphics,mc-cut,mc-graphics,false,projects-path,*/beats/beats.md -BMad Manticore,mc-graphics,Build Graphics,GX,"Execute the approved beat table in HyperFrames / Remotion / HTML / design-prompting; frame-verified alpha overlays plus an editor HANDOFF.",,,3-graphics,mc-beats,mc-assets,false,projects-path,*/graphics/* +BMad Manticore,mc-graphics,Build Graphics,GX,"Execute the approved beat table in HyperFrames / HTML / design-prompting; frame-verified alpha overlays plus an editor HANDOFF.",,,3-graphics,mc-beats,mc-assets,false,projects-path,*/graphics/* BMad Manticore,mc-assets,Farm Assets,FA,"Source and farm the stills and b-roll the beat table calls for through registered CLI tools (metered APIs opt-in), real verified imagery first.",,,3-graphics,mc-beats,mc-package,false,projects-path,*/assets/manifest.json BMad Manticore,mc-audio,Farm Sound,AU,"Service skill, no stage or gate: local-first TTS narration and two-host dialogue (Kokoro-82M), instrumental beds (MusicGen-small), SFX (AudioLDM2). Called from graphics, stream packs, and voiceover narration, or directly.",,,anytime,,,false,,*/manifest.json BMad Manticore,mc-ograf,OGraf Graphics,OG,"Service skill: editable broadcast graphics where the target supports them (DaVinci Resolve 21+ editor lane, OBS/SPX-GC live lane). Everyone else gets baked alpha.",,,anytime,,,false,, From 35a03b680e43f1d1b79c0f52c7f0a7ef5941bca9 Mon Sep 17 00:00:00 2001 From: Brian Madison Date: Wed, 22 Jul 2026 22:21:38 -0500 Subject: [PATCH 2/8] Make remotion a permanent alias for hyperframes (safe upgrade) A studio configured before 1.1.0 keeps its own copied format profiles, which may still name `remotion` in engine_overlays/engine_stingers frontmatter. Rather than rewrite creator-owned files, treat `remotion` as an unconditional alias for `hyperframes` wherever an engine is named (beat-table engine value or profile frontmatter, any vintage). Upgrade is just re-running the install; no migration, no touched creator files. - PIPELINE.md: add the compatibility-alias line to the engine policy and broaden the beat-table tolerance rule from "0.x only" to any vintage. - mc-beats step 4: a profile naming remotion is written into the table as hyperframes, never remotion. - CHANGELOG: state the upgrade is re-install + alias, no migration. --- CHANGELOG.md | 2 +- skills/mc-beats/SKILL.md | 2 +- skills/mc-pipeline/PIPELINE.md | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44b0ef9..e37d1c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ All notable changes to BMad Manticore are documented here. Dates are ISO (YYYY-M ### Changed -- Remotion is removed; HyperFrames is the module's single motion-graphics engine. Two reasons: Remotion's license is free only for companies of up to 3 people and Manticore is a distributed module, so shipping it handed every creator at a 4+ person company a licensing obligation they never opted into; and its remaining justification ("anything React-stateful") bought nothing in a frame-deterministic renderer, where state is a function of frame index. Every job Remotion held (the dual-render brand stinger, shorts karaoke captions, plain HTML/SVG comps) is a paused GSAP timeline in HyperFrames, which encodes alpha to both ProRes 4444 MOV and VP9 yuva420p WebM, so the OBS-plus-editor dual target is unchanged. The `engine_stingers`/`engine_overlays` profile keys now read `hyperframes` where they read `remotion`, `skills/mc-graphics/engines/remotion.md` is deleted, and the rationale lives in `skills/mc-graphics/engines/hyperframes.md`. Beat tables from 0.x carrying `engine: remotion` are read as `hyperframes` under the PIPELINE.md tolerance rule, so in-flight projects do not break. OGraf is unaffected; it solves a different problem (Resolve-editable and SPX-GC live). +- Remotion is removed; HyperFrames is the module's single motion-graphics engine. Two reasons: Remotion's license is free only for companies of up to 3 people and Manticore is a distributed module, so shipping it handed every creator at a 4+ person company a licensing obligation they never opted into; and its remaining justification ("anything React-stateful") bought nothing in a frame-deterministic renderer, where state is a function of frame index. Every job Remotion held (the dual-render brand stinger, shorts karaoke captions, plain HTML/SVG comps) is a paused GSAP timeline in HyperFrames, which encodes alpha to both ProRes 4444 MOV and VP9 yuva420p WebM, so the OBS-plus-editor dual target is unchanged. The `engine_stingers`/`engine_overlays` profile keys now read `hyperframes` where they read `remotion`, `skills/mc-graphics/engines/remotion.md` is deleted, and the rationale lives in `skills/mc-graphics/engines/hyperframes.md`. Upgrade is just re-running the install: `remotion` is now a permanent, unconditional alias for `hyperframes` wherever an engine is named (beat-table `engine` value or a format profile's `engine_overlays`/`engine_stingers` frontmatter, any vintage), so a studio configured before 1.1.0 keeps its own copied profiles untouched and every skill reads them as `hyperframes`. No migration, no rewritten creator files. OGraf is unaffected; it solves a different problem (Resolve-editable and SPX-GC live). ### Added diff --git a/skills/mc-beats/SKILL.md b/skills/mc-beats/SKILL.md index 555f2ba..99f5819 100644 --- a/skills/mc-beats/SKILL.md +++ b/skills/mc-beats/SKILL.md @@ -12,7 +12,7 @@ Gate 3. The beat table is the engine-neutral contract between the script and the 1. Load the studio config (`uv run {project-root}/_bmad/scripts/resolve_config.py --project-root {project-root} --key modules.manticore`; empty means mc-setup has not run: stop and route the creator there) and this skill's own surface (`uv run {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root}`; run `{workflow.activation_steps_prepend}` now, `{workflow.activation_steps_append}` after this step, and hold `{workflow.persistent_facts}` as standing context). Resolve `paths` values against `{project-root}`. Read `project.json` (confirm `approvals.cutplan` is a date, stage is `beats`), `script.md`, `cut/edl.json`, `transcript/`, the format profile at `{formats-path}/.md`, `{brand-path}/production-bible.md`, and `{brand-path}/tokens.json`. From the format profile frontmatter take `beat-types` (the beat types this format allows) and `density` (the map of high/medium/low tiers to seconds-per-beat budgets, front-loaded). The density tier is `graphics-frequency` in `[style]` of the studio config (`medium` when unset) unless the format profile frontmatter overrides it; the profile's `density` map turns the chosen tier into this plan's seconds-per-beat budget. 2. Riff with the creator BEFORE writing the table. Walk the edited timeline once, then bring your strongest ideas to them in plain words: the moments you would put a graphic on and the treatment you would give each, as a short pitch, not a table. Ask what they were picturing: anything specific they already imagined for this video, moments they know they want a visual on, references they have been chewing on. A few minutes of riffing here beats a revision cycle at gate 3. Carry every answer into the plan. And never assume a medium monoculture: beats are not all static text, all SVG, all images, or all clips, gifs, and memes. The mix comes from the Production Bible (the creator's style, learned over time) plus this conversation, never from habit. 3. Walk the EDITED timeline (times derive from edl.json, not the raw take). Scan the transcript with the trigger heuristics in `references/density-and-creativity.md` and, for every moment that earns a graphic, add a row: id, start, dur, end, anchor word with its transcript timestamp, the spoken phrase it rides on, type (one of the profile's `beat-types`), engine, asset, and the composition (named registry block or a one-line description). Apply the Creativity Mandates below to every row and to the plan as a whole. -4. Mark each row's engine per the format profile defaults and PIPELINE.md's engine policy; rows needing farmed assets carry the asset id in the `asset` column (they become the mc-assets shopping list), all other rows carry `null`. +4. Mark each row's engine per the format profile defaults and PIPELINE.md's engine policy; a profile that still names `remotion` in its `engine_overlays`/`engine_stingers` frontmatter (a studio configured before 1.1.0) is written into the table as `hyperframes` per the engine policy's compatibility alias, never as `remotion`. Rows needing farmed assets carry the asset id in the `asset` column (they become the mc-assets shopping list), all other rows carry `null`. 5. Run the CTA placement pass per `references/cta-placement.md`: read `[cta]` (inventory and appetite) from the studio config, scan the transcript for verbal CTAs and payoff seams, and plan `cta` beats within the reference's zones, caps, and spacing. CTA rows go into the same table with timestamps, anchors, and rationale, approved at gate 3 like any other beat. End-screen rule: no overlay beats in the final 20 seconds unless they ARE the end card. When the inventory includes a next-video or end-card item, optionally add an end-card beat themed from `{brand-path}/tokens.json`. 6. Write `beats/beats.md` (the table) and `beats/STORYBOARD.md`. Each STORYBOARD.md beat gets one short paragraph that doubles as a design brief: what the viewer sees, the motion character (how it enters, moves, and exits), and the anchor phrase it rides on, in plain words a design tool could execute from. 7. Update `artifacts` in project.json (`"beats": "beats/beats.md"`, `"storyboard": "beats/STORYBOARD.md"`), set `approvals.beats = "pending"`, present the table, and STOP for gate 3. diff --git a/skills/mc-pipeline/PIPELINE.md b/skills/mc-pipeline/PIPELINE.md index b3ff152..154e6e5 100644 --- a/skills/mc-pipeline/PIPELINE.md +++ b/skills/mc-pipeline/PIPELINE.md @@ -93,6 +93,7 @@ If the config exists but a key this stage needs is missing or empty, ask for jus - Everything is themed through `{brand-path}/tokens.json`. Component sourcing rule: registries and open libraries first, author from scratch only when nothing fits. - Engine workspaces (the pinned HyperFrames project) live at `{engines-path}`; mc-setup or the first graphics run initializes them. - Remotion was a second engine through 0.x and was removed on 2026-07-22: its license is free only up to 3 people, and its React authoring model bought nothing in a frame-deterministic renderer. Rationale in `mc-graphics/engines/hyperframes.md`. +- Compatibility alias (unconditional, any vintage): `remotion` is a permanent alias for `hyperframes` wherever an engine is named — a beat-table `engine` value OR a format profile's `engine_overlays`/`engine_stingers` frontmatter. A studio configured before 1.1.0 keeps its own copied profiles that may still say `remotion`; every skill reads that as `hyperframes` and no creator file is rewritten. There is no Remotion engine doc or workspace to route to. ## The beat table (engine-neutral graphics contract) @@ -106,7 +107,7 @@ Column rules: - `type` is a beat type from the format profile's `beat-types` frontmatter list (e.g. `lower-third`, `diagram`, `stat-card`, `cta`); the profile is the single type vocabulary for its format. The reserved placeholder `overlay` is legal only when reading legacy tables (tolerance rule below) and is never written. - `engine` names the engine that renders the beat, per the Engine policy below (e.g. `hyperframes`, `ograf`, `html`). - `asset` is `null` or a farmed-asset id from `assets/manifest.json`; mc-assets farms the listed assets, mc-graphics composes with them. -- Tolerance rule: consumers MUST accept rows missing `type`, `engine`, or `asset` (beat tables written by 0.x projects). Treat a missing `type` as the reserved placeholder `overlay` (informational only; rendering keys off `engine` and `composition`), a missing `engine` as the Engine policy default, an `engine` of `remotion` (written by 0.x projects) as `hyperframes`, and a missing `asset` as `null`. A stage that rewrites the table (mc-beats) replaces every `overlay` placeholder with a type from the profile's `beat-types`. An in-flight 0.x project never breaks on the extended contract. +- Tolerance rule: consumers MUST accept rows missing `type`, `engine`, or `asset` (beat tables written by 0.x projects). Treat a missing `type` as the reserved placeholder `overlay` (informational only; rendering keys off `engine` and `composition`), a missing `engine` as the Engine policy default, an `engine` of `remotion` (from any vintage of table, per the Engine policy's compatibility alias) as `hyperframes`, and a missing `asset` as `null`. A stage that rewrites the table (mc-beats) replaces every `overlay` placeholder with a type from the profile's `beat-types`. An in-flight 0.x project never breaks on the extended contract. Anchors are measured against the EDITED timeline defined by `cut/edl.json`, not the raw take. From 65c9953534bbbee77e03fc0fb4b1fc950daf82c4 Mon Sep 17 00:00:00 2001 From: Brian Madison Date: Wed, 22 Jul 2026 22:54:08 -0500 Subject: [PATCH 3/8] Fix six daily-use bugs in the render lane and skill contracts Render lane (composite_core.py + render_preview/render_final + tests): - Multi-source timelines with mismatched frame sizes/aspect ratios (cam + screencast) no longer fail concat: build_filter_complex takes a target (W,H) and normalizes every segment to one frame (scale fit + pad + setsar). The single-source path is unchanged. The renderer decides target once and threads the same value to every parallel chunk so the lossless concat stays exact. - Audio-less sources (screen recordings) and mismatched sample rates no longer hard-fail: probe_has_audio() drives a shared anullsrc silence input for audio-less sources, and every audio chain ends in aresample=48000, aformat=channel_layouts=stereo. New e2e tests render the real mixed-dim + audio-less case; render tests grow 14->20 and 46->50. Skill contracts: - mc-package live-event mode no longer corrupts state on livestream-pack projects: it advances stage/stages_done only when `package` is the current stage AND in the stages array, mirroring the VOD flow guard. Closes a gate-4 bypass and a stream-pack routing brick. - mc-retro routes stage-behavior fixes to the durable {project-root}/_bmad/custom/.toml layer first (survives module updates, loads on activation); editing installed SKILL.md is last resort. - Default stage order is assets-before-graphics so farmed stills exist before graphics composes with them (PIPELINE.md master list + json example + ingest variant, talking-head/voiceover-explainer/livestream-vod profiles, mc-agent mnemonic, new_project.py hint, and the two stale test fixtures/assertions). - Final-render delivery height reads [video] delivery-resolution, not a nonexistent [render] key, so configured 1080p ships instead of source-native. Full suite green (26 files). --- skills/mc-agent/SKILL.md | 2 +- skills/mc-cut/SKILL.md | 2 +- skills/mc-cut/scripts/composite_core.py | 111 ++++++++++++-- skills/mc-cut/scripts/render_final.py | 19 ++- skills/mc-cut/scripts/render_preview.py | 36 +++-- .../mc-cut/scripts/tests/test-render_final.py | 112 ++++++++++++++ .../scripts/tests/test-render_preview.py | 145 +++++++++++++++++- skills/mc-new/scripts/new_project.py | 2 +- .../mc-new/scripts/tests/test-new_project.py | 6 +- skills/mc-package/SKILL.md | 2 +- skills/mc-pipeline/PIPELINE.md | 14 +- skills/mc-retro/SKILL.md | 2 +- .../mc-setup/assets/formats/livestream-vod.md | 2 +- .../mc-setup/assets/formats/talking-head.md | 2 +- .../assets/formats/voiceover-explainer.md | 2 +- .../tests/test-merge_profile_frontmatter.py | 4 +- 16 files changed, 416 insertions(+), 47 deletions(-) diff --git a/skills/mc-agent/SKILL.md b/skills/mc-agent/SKILL.md index 8554dfd..23a9632 100644 --- a/skills/mc-agent/SKILL.md +++ b/skills/mc-agent/SKILL.md @@ -28,7 +28,7 @@ The full contract lives with mc-pipeline; invoke it for real state and routing. | record | the creator | | | cut | mc-cut | gate 2: cutplan | | beats | mc-beats | gate 3: beats | -| graphics, assets | mc-graphics, mc-assets | | +| assets, graphics | mc-assets, mc-graphics | | | package | mc-package (may start any time after gate 1) | | | stream-pack | mc-stream-pack (livestream-pack lane only) | | | final | the creator, with an offered pipeline render | gate 4: final | diff --git a/skills/mc-cut/SKILL.md b/skills/mc-cut/SKILL.md index a83e49b..9933407 100644 --- a/skills/mc-cut/SKILL.md +++ b/skills/mc-cut/SKILL.md @@ -32,7 +32,7 @@ mc-pipeline routes here as soon as the graphics stage completes (mc-graphics han ## Final render (gate 4) -When the project reaches the final stage, offer the final-quality render from this skill: `uv run {skill-root}/scripts/render_final.py cut/edl.json -o renders/final.mp4 --beats beats/beats.md --graphics-dir graphics/` plus any `{workflow.final_flags}`, with `--codec`, `--crf`, and `--height` per `[render]` in the studio config, `--loudness-target <[render] loudness-target>`, and `--no-loudnorm` appended when `[render] loudnorm` is false. It bakes the same EDL the creator approved with graphics composited from the approved beat table, hardware encode when available (videotoolbox on macOS; on Windows h264_nvenc, then h264_qsv, then h264_amf; on Linux h264_nvenc then h264_vaapi; each candidate validated by a one-frame test encode, libx264 fallback everywhere), segment-parallel rendering, a disk preflight, progress reporting, and boundary-frame checks. The final render is loudness-normalized to the target LUFS with two-pass ffmpeg loudnorm (the fast preview is never normalized). Finishing in the creator's own editor from the always-exported timeline is an equally supported path; either closes gate 4. +When the project reaches the final stage, offer the final-quality render from this skill: `uv run {skill-root}/scripts/render_final.py cut/edl.json -o renders/final.mp4 --beats beats/beats.md --graphics-dir graphics/` plus any `{workflow.final_flags}`, with `--codec` and `--crf` per `[render]` in the studio config, `--height` from the height of `[video]` delivery-resolution, `--loudness-target <[render] loudness-target>`, and `--no-loudnorm` appended when `[render] loudnorm` is false. It bakes the same EDL the creator approved with graphics composited from the approved beat table, hardware encode when available (videotoolbox on macOS; on Windows h264_nvenc, then h264_qsv, then h264_amf; on Linux h264_nvenc then h264_vaapi; each candidate validated by a one-frame test encode, libx264 fallback everywhere), segment-parallel rendering, a disk preflight, progress reporting, and boundary-frame checks. The final render is loudness-normalized to the target LUFS with two-pass ffmpeg loudnorm (the fast preview is never normalized). Finishing in the creator's own editor from the always-exported timeline is an equally supported path; either closes gate 4. ## Dual timecode diff --git a/skills/mc-cut/scripts/composite_core.py b/skills/mc-cut/scripts/composite_core.py index 1b5cad1..db3f678 100644 --- a/skills/mc-cut/scripts/composite_core.py +++ b/skills/mc-cut/scripts/composite_core.py @@ -206,13 +206,32 @@ def resolve_overlays(beats, graphics_dir): def build_filter_complex(edl, source_index, height, overlays=(), overlay_size=None, - hwupload=False): + hwupload=False, target=None, audio_map=None, + silence_index=None): """Build the filter_complex string for the whole timeline. source_index maps each source path to its ffmpeg -i input index. Each - segment is trimmed from its source, PTS-reset, scaled to height when given - (even width, square pixels; height None keeps native size), and given an - in/out afade of fade_ms at its boundaries; all segments then concat. + segment is trimmed from its source, PTS-reset, sized, given an in/out + afade of fade_ms at its boundaries, and audio-normalized; all segments + then concat. + + Sizing: when target=(W,H) is given every segment is normalized to that one + frame (scale to fit with force_original_aspect_ratio=decrease, then pad and + centre to WxH, setsar=1), so sources of different frame sizes or aspect + ratios all become identical WxH inputs and the concat filter accepts them + (mixed cam + screencast). target is set by the caller only when the + timeline draws on more than one distinct source; the single-source fast + path keeps the plain scale=-2:height (even width, square pixels; height + None keeps native size), unchanged. + + Audio: a source with no audio stream (audio_map[source] is False and a + silence_index is supplied) draws silence from the shared anullsrc input at + silence_index instead of a real [idx:a], so screen recordings without audio + do not fail with "Stream specifier :a matches no streams". Every audio + chain ends in aresample=48000,aformat=channel_layouts=stereo so sources + with different sample rates or channel layouts (44.1k cam + 48k screencast) + concat cleanly. audio_map None means every source has audio (the historic + behavior). overlays (optional) are dicts {index, start, dur, image} whose 'index' is the ffmpeg input index of the overlay file; each is composited over the @@ -226,6 +245,7 @@ def build_filter_complex(edl, source_index, height, overlays=(), overlay_size=No up the device (encoder_init_flags). """ fade = edl.get("fade_ms", 30) / 1000.0 + tw, th = target if target else (None, None) parts, vlabels, alabels = [], [], [] for i, seg in enumerate(edl["segments"]): idx = source_index[seg["source"]] @@ -238,21 +258,35 @@ def build_filter_complex(edl, source_index, height, overlays=(), overlay_size=No f"[{idx}:v]trim=start={_fmt(start)}:end={_fmt(end)}," f"setpts=PTS-STARTPTS" ) - if height: - vchain += f",scale=-2:{height}" - vchain += f",setsar=1[{vlab}]" + if target: + # Normalize every segment to one frame so mixed-size sources concat. + vchain += ( + f",scale={tw}:{th}:force_original_aspect_ratio=decrease," + f"pad={tw}:{th}:(ow-iw)/2:(oh-ih)/2,setsar=1[{vlab}]" + ) + elif height: + vchain += f",scale=-2:{height},setsar=1[{vlab}]" + else: + vchain += f",setsar=1[{vlab}]" parts.append(vchain) - afade = ( - f"[{idx}:a]atrim=start={_fmt(start)}:end={_fmt(end)}," + # Audio: real stream, or shared silence for an audio-less source. + if (silence_index is not None and audio_map is not None + and not audio_map.get(seg["source"], True)): + aidx, a_start, a_end = silence_index, 0.0, dur + else: + aidx, a_start, a_end = idx, start, end + achain = ( + f"[{aidx}:a]atrim=start={_fmt(a_start)}:end={_fmt(a_end)}," f"asetpts=PTS-STARTPTS" ) if f > 0: - afade += ( + achain += ( f",afade=t=in:st=0:d={_fmt(f)}" f",afade=t=out:st={_fmt(dur - f)}:d={_fmt(f)}" ) - afade += f"[{alab}]" - parts.append(afade) + achain += ",aresample=48000,aformat=channel_layouts=stereo" + achain += f"[{alab}]" + parts.append(achain) vlabels.append(f"[{vlab}]") alabels.append(f"[{alab}]") n = len(edl["segments"]) @@ -293,7 +327,7 @@ def build_filter_complex(edl, source_index, height, overlays=(), overlay_size=No def build_command(edl, project_dir, output, height, overlays=(), overlay_size=None, encode=None, extra_output_flags=(), - encoder=None): + encoder=None, target=None, audio_map=None): """Assemble (ffmpeg_argv, source_index) for one render invocation. encode replaces the default preview encode args (libx264 crf 28 veryfast @@ -302,6 +336,16 @@ def build_command(edl, project_dir, output, height, overlays=(), inputs are -t capped to the beat's dur too, so decode stops at the enable window. -movflags +faststart is added for .mp4/.mov outputs. + target (optional) is the (W,H) frame every segment is normalized to, for + mixed-size sources; the caller sets it only for multi-source timelines and + passes the SAME value to every chunk so the chunk concat stays exact. + + audio_map (optional) maps each source path to whether it has an audio + stream (probe_has_audio). Any audio-less source is fed synthesized silence + from a single trimmed anullsrc input added after the real sources; the -t + cap on that input keeps the synthetic source from running open-ended. + audio_map None means every source has audio. + encoder (optional) is the encoder name the encode args target; it only matters for encoders that need device setup and hardware frames (vaapi gets -init_hw_device flags and an hwupload filtergraph tail). Software @@ -312,13 +356,22 @@ def build_command(edl, project_dir, output, height, overlays=(), if seg["source"] not in distinct: distinct.append(seg["source"]) source_index = {src: i for i, src in enumerate(distinct)} + need_silence = bool(audio_map) and any( + not audio_map.get(src, True) for src in distinct) argv = ["ffmpeg", "-y", *encoder_init_flags(encoder)] for src in distinct: argv += ["-i", str((project_dir / src).resolve())] + silence_index = None + if need_silence: + silence_index = len(distinct) + total = sum(seg["end"] - seg["start"] for seg in edl["segments"]) + argv += ["-f", "lavfi", "-t", _fmt(total), + "-i", "anullsrc=channel_layout=stereo:sample_rate=48000"] + base = len(distinct) + (1 if need_silence else 0) ovs = [] for k, ov in enumerate(overlays): entry = dict(ov) - entry["index"] = len(distinct) + k + entry["index"] = base + k if entry.get("image"): argv += ["-loop", "1", "-t", _fmt(entry["dur"]), "-i", str(entry["path"])] @@ -328,7 +381,9 @@ def build_command(edl, project_dir, output, height, overlays=(), argv += [ "-filter_complex", build_filter_complex(edl, source_index, height, ovs, overlay_size, - hwupload=encoder_needs_hwupload(encoder)), + hwupload=encoder_needs_hwupload(encoder), + target=target, audio_map=audio_map, + silence_index=silence_index), "-map", "[outv]", "-map", "[outa]", ] argv += list(encode) if encode else list(PREVIEW_ENCODE) @@ -611,6 +666,32 @@ def probe_dims(path): return (int(w), int(h)) if w and h else None +def probe_has_audio(path): + """True if the file has at least one audio stream. + + On a probe failure (missing or unreadable file, or no ffprobe) returns + True, so ffmpeg surfaces the real open error at render time rather than + this wrapper silently synthesizing silence for a file that will fail + anyway. A file that exists but carries no audio stream returns False, + which is the signal for the renderers to feed it synthesized silence.""" + try: + proc = subprocess.run( + ["ffprobe", "-v", "error", "-select_streams", "a:0", + "-show_entries", "stream=index", "-print_format", "json", + str(path)], + capture_output=True, text=True, + ) + except OSError: + return True + if proc.returncode != 0: + return True + try: + streams = json.loads(proc.stdout).get("streams") or [] + except json.JSONDecodeError: + return True + return bool(streams) + + def extract_boundary_frames(output, edl, out_dir): """One still just before and just after each internal cut of the output.""" out_dir.mkdir(parents=True, exist_ok=True) diff --git a/skills/mc-cut/scripts/render_final.py b/skills/mc-cut/scripts/render_final.py index b4020f3..da1740c 100644 --- a/skills/mc-cut/scripts/render_final.py +++ b/skills/mc-cut/scripts/render_final.py @@ -333,6 +333,12 @@ def main(argv=None): print(f"beat row skipped: {reason}", file=sys.stderr) overlays, missing = core.resolve_overlays(beats, args.graphics_dir) + distinct = [] + for seg in edl["segments"]: + if seg["source"] not in distinct: + distinct.append(seg["source"]) + multi = len(distinct) > 1 + # Output dimensions from the first source (needed for overlay scaling, # the bitrate ladder, and the disk estimate). dims = core.probe_dims(project_dir / edl["segments"][0]["source"]) @@ -348,6 +354,13 @@ def main(argv=None): out_w, out_h = core.even(dims[0]), core.even(dims[1]) scale_height = None overlay_size = (out_w, out_h) if overlays else None + # Multi-source timelines normalize every segment (and every chunk) to this + # one frame so the mixed-size concat matches; single-source is unchanged. + target = (out_w, out_h) if multi else None + # Audio-less sources get synthesized silence; probe each distinct source + # once and pass the map to every chunk. + audio_map = {src: core.probe_has_audio(project_dir / src) + for src in distinct} total = sum(core.segment_durations(edl)) encoder = core.pick_encoder(args.codec) @@ -372,7 +385,8 @@ def main(argv=None): cmd, _ = core.build_command(edl, project_dir, output, scale_height, overlays=overlays, overlay_size=overlay_size, encode=enc, extra_output_flags=progress_flags, - encoder=encoder) + encoder=encoder, target=target, + audio_map=audio_map) rcs, tails = run_chunks([cmd], [total], total) if rcs[0] != 0: print("ffmpeg render failed:", file=sys.stderr) @@ -393,7 +407,8 @@ def main(argv=None): overlay_size=overlay_size, encode=enc, extra_output_flags=progress_flags, - encoder=encoder) + encoder=encoder, target=target, + audio_map=audio_map) cmds.append(cmd) files.append(f) durs.append(ch["duration"]) diff --git a/skills/mc-cut/scripts/render_preview.py b/skills/mc-cut/scripts/render_preview.py index 67a2a70..e3e39b7 100644 --- a/skills/mc-cut/scripts/render_preview.py +++ b/skills/mc-cut/scripts/render_preview.py @@ -119,8 +119,13 @@ def main(argv=None): print("edl has no segments", file=sys.stderr) return 2 + distinct = [] + for seg in edl["segments"]: + if seg["source"] not in distinct: + distinct.append(seg["source"]) + multi = len(distinct) > 1 + overlays, missing, skipped = [], [], [] - overlay_size = None if args.beats: try: overlays, missing, skipped = gather_overlays(args.beats, @@ -130,19 +135,32 @@ def main(argv=None): return 1 for reason in skipped: print(f"beat row skipped: {reason}", file=sys.stderr) + + # One target frame is needed to scale overlays and to normalize mixed-size + # sources so the concat inputs match (cam + screencast). + overlay_size = None + target = None + if overlays or multi: + dims = core.probe_dims(project_dir / edl["segments"][0]["source"]) + if dims is None: + print("cannot probe source dimensions", file=sys.stderr) + return 1 + frame = (core.even(dims[0] * args.height / dims[1]), args.height) if overlays: - dims = core.probe_dims(project_dir / edl["segments"][0]["source"]) - if dims is None: - print("cannot probe source dimensions for overlay scaling", - file=sys.stderr) - return 1 - ow = core.even(dims[0] * args.height / dims[1]) - overlay_size = (ow, args.height) + overlay_size = frame + if multi: + target = frame + + # Audio-less sources (a screen recording with no audio) get synthesized + # silence so the filtergraph never references a missing :a stream. + audio_map = {src: core.probe_has_audio(project_dir / src) + for src in distinct} output = Path(args.output) output.parent.mkdir(parents=True, exist_ok=True) cmd, _ = build_command(edl, project_dir, output, args.height, - overlays=overlays, overlay_size=overlay_size) + overlays=overlays, overlay_size=overlay_size, + target=target, audio_map=audio_map) proc = subprocess.run(cmd, capture_output=True, text=True) if proc.returncode != 0: print("ffmpeg render failed:", file=sys.stderr) diff --git a/skills/mc-cut/scripts/tests/test-render_final.py b/skills/mc-cut/scripts/tests/test-render_final.py index 1ebb68f..b0c3758 100644 --- a/skills/mc-cut/scripts/tests/test-render_final.py +++ b/skills/mc-cut/scripts/tests/test-render_final.py @@ -548,6 +548,118 @@ def test_single_chunk_plain_render(self): self.assertTrue(out.is_file()) +class TestMultiSourceCommand(unittest.TestCase): + """Pure command/filtergraph construction for the mixed-source cases that + broke concat: different frame sizes, and a source with no audio.""" + + def edl_two_sources(self): + return { + "source": "raw/cam.mp4", "fade_ms": 30, + "segments": [ + {"source": "raw/cam.mp4", "start": 0.0, "end": 2.0}, + {"source": "raw/screen.mp4", "start": 0.0, "end": 3.0}, + ], + } + + def test_target_normalizes_every_segment(self): + edl = self.edl_two_sources() + idx = {"raw/cam.mp4": 0, "raw/screen.mp4": 1} + fc = core.build_filter_complex(edl, idx, 720, target=(1280, 720)) + self.assertEqual( + fc.count("scale=1280:720:force_original_aspect_ratio=decrease," + "pad=1280:720:(ow-iw)/2:(oh-ih)/2,setsar=1"), 2) + self.assertNotIn("scale=-2:720", fc) + + def test_audioless_source_gets_synthesized_silence(self): + edl = self.edl_two_sources() + audio_map = {"raw/cam.mp4": True, "raw/screen.mp4": False} + cmd, _ = core.build_command(edl, Path("/proj"), "out.mp4", 1080, + target=(1920, 1080), audio_map=audio_map, + encoder="libx264") + joined = " ".join(cmd) + self.assertEqual(joined.count("-f lavfi"), 1) + self.assertIn("anullsrc=channel_layout=stereo:sample_rate=48000", + joined) + fc = cmd[cmd.index("-filter_complex") + 1] + self.assertNotIn("[1:a]", fc) # audio-less source not read + self.assertIn("[2:a]atrim", fc) # silence input drives its audio + self.assertIn("aresample=48000,aformat=channel_layouts=stereo", fc) + + def test_audio_map_none_keeps_real_streams(self): + edl = self.edl_two_sources() + cmd, _ = core.build_command(edl, Path("/proj"), "out.mp4", 1080, + encoder="libx264") + self.assertNotIn("anullsrc", " ".join(cmd)) + fc = cmd[cmd.index("-filter_complex") + 1] + self.assertIn("[1:a]atrim", fc) + + +@unittest.skipUnless(FFMPEG, "ffmpeg/ffprobe not installed") +class TestMixedSourcesEndToEnd(unittest.TestCase): + """Real renders of the cases that raised 'Input link parameters do not + match' and 'Stream specifier :a matches no streams': a 16:9 cam with 44.1k + audio spliced with a 4:3 screen recording that has no audio at all. Both + the frame-size normalization and the silence synthesis are exercised, over + a two-chunk parallel render so the chunk concat is validated too.""" + + @classmethod + def setUpClass(cls): + cls.tmp = tempfile.TemporaryDirectory() + proj = Path(cls.tmp.name) + (proj / "raw").mkdir() + (proj / "cut").mkdir() + # cam: 160x90 (16:9), 44.1k audio + r = subprocess.run( + ["ffmpeg", "-y", + "-f", "lavfi", "-t", "6", "-i", "testsrc2=size=160x90:rate=30", + "-f", "lavfi", "-t", "6", + "-i", "sine=frequency=440:sample_rate=44100", + "-t", "6", "-shortest", + "-c:v", "libx264", "-preset", "ultrafast", "-crf", "30", + "-pix_fmt", "yuv420p", "-c:a", "aac", + str(proj / "raw" / "cam.mp4")], + capture_output=True, text=True) + assert r.returncode == 0, r.stderr + # screen: 160x120 (4:3, different aspect), NO audio stream + r = subprocess.run( + ["ffmpeg", "-y", + "-f", "lavfi", "-t", "6", "-i", "testsrc2=size=160x120:rate=30", + "-t", "6", + "-c:v", "libx264", "-preset", "ultrafast", "-crf", "30", + "-pix_fmt", "yuv420p", + str(proj / "raw" / "screen.mp4")], + capture_output=True, text=True) + assert r.returncode == 0, r.stderr + # cam, then two screen segments (audio-less source, referenced twice) + edl = {"source": "raw/cam.mp4", "fade_ms": 30, "pad_ms": 60, + "segments": [ + {"source": "raw/cam.mp4", "start": 0.5, "end": 2.0}, + {"source": "raw/screen.mp4", "start": 0.5, "end": 2.0}, + {"source": "raw/screen.mp4", "start": 3.0, "end": 4.5}]} + (proj / "cut" / "edl.json").write_text(json.dumps(edl)) + cls.proj = proj + + @classmethod + def tearDownClass(cls): + cls.tmp.cleanup() + + def test_mixed_dimensions_and_audioless_render(self): + out = self.proj / "renders" / "mixed.mp4" + r = run_cli([str(self.proj / "cut" / "edl.json"), "-o", str(out), + "--codec", "libx264", "--crf", "30", "--parallel", "2", + "--no-loudnorm", "--height", "120"]) + self.assertEqual(r.returncode, 0, r.stderr) + summary = json.loads(r.stdout) + self.assertEqual(summary["segments"], 3) + self.assertTrue(out.is_file()) + self.assertAlmostEqual(summary["actual_duration_seconds"], 4.5, + delta=0.5) + # every frame is the one normalized target size + dims = core.probe_dims(self.proj / "raw" / "cam.mp4") + expected_w = core.even(dims[0] * 120 / dims[1]) + self.assertEqual(core.probe_dims(out), (expected_w, 120)) + + @unittest.skipUnless(FFMPEG, "ffmpeg/ffprobe not installed") class TestLoudnormEndToEnd(unittest.TestCase): """A real two-pass loudnorm run: a quiet 7s test tone rendered with the diff --git a/skills/mc-cut/scripts/tests/test-render_preview.py b/skills/mc-cut/scripts/tests/test-render_preview.py index 5e02d15..fb14c03 100644 --- a/skills/mc-cut/scripts/tests/test-render_preview.py +++ b/skills/mc-cut/scripts/tests/test-render_preview.py @@ -11,18 +11,26 @@ the synthesized-fixture integration test in test-render_final.py (same compositing core) and by running the script against a real source.""" import importlib.util +import json +import shutil import subprocess import sys import tempfile import unittest from pathlib import Path -SCRIPT = Path(__file__).resolve().parent.parent / "render_preview.py" +SCRIPTS = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(SCRIPTS)) +SCRIPT = SCRIPTS / "render_preview.py" + +import composite_core as core # noqa: E402 spec = importlib.util.spec_from_file_location("render_preview", SCRIPT) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) +FFMPEG = shutil.which("ffmpeg") and shutil.which("ffprobe") + def canned_edl(): return { @@ -153,6 +161,77 @@ def test_multi_source_inputs_and_encode_flags(self): self.assertEqual(cmd[-1], "/out/p.mp4") +def edl_two_sources(): + # A 16:9 cam and a 4:3 screencast, both used in the timeline. + return { + "source": "raw/cam.mp4", "fade_ms": 30, + "segments": [ + {"source": "raw/cam.mp4", "start": 0.0, "end": 2.0}, + {"source": "raw/screen.mp4", "start": 0.0, "end": 3.0}, + {"source": "raw/screen.mp4", "start": 5.0, "end": 6.0}, + ], + } + + +class TestMultiSourceFrame(unittest.TestCase): + """Mixed-size sources must be normalized to one target frame, or the + concat filter rejects the mismatched inputs (cam 16:9 + screencast 4:3).""" + + def test_target_pads_every_segment_to_one_frame(self): + edl = edl_two_sources() + idx = {"raw/cam.mp4": 0, "raw/screen.mp4": 1} + fc = mod.build_filter_complex(edl, idx, 720, target=(1280, 720)) + # all three segment chains normalize to the SAME frame + self.assertEqual( + fc.count("scale=1280:720:force_original_aspect_ratio=decrease," + "pad=1280:720:(ow-iw)/2:(oh-ih)/2,setsar=1"), 3) + # the old height-only scale (which left widths mismatched) is gone + self.assertNotIn("scale=-2:720", fc) + + def test_single_source_keeps_plain_scale(self): + # single-source behavior is unchanged: plain scale=-2:height, no pad + fc = mod.build_filter_complex(canned_edl(), {"raw/camera-a.mp4": 0}, 720) + self.assertIn("scale=-2:720", fc) + self.assertNotIn("force_original_aspect_ratio", fc) + + +class TestAudioNormalization(unittest.TestCase): + """Every audio chain is resampled to 48k stereo so sources with different + sample rates or channel layouts concat cleanly; audio-less sources draw + synthesized silence instead of a missing :a stream.""" + + def test_every_chain_ends_in_resample_and_layout(self): + fc = mod.build_filter_complex(canned_edl(), {"raw/camera-a.mp4": 0}, 720) + self.assertEqual( + fc.count("aresample=48000,aformat=channel_layouts=stereo"), 3) + + def test_audioless_source_draws_shared_silence(self): + edl = edl_two_sources() + audio_map = {"raw/cam.mp4": True, "raw/screen.mp4": False} + cmd, _ = mod.build_command(edl, Path("/proj"), Path("/out/p.mp4"), 720, + target=(1280, 720), audio_map=audio_map) + joined = " ".join(cmd) + # exactly one synthesized silent input, added after the two real sources + self.assertEqual(joined.count("-f lavfi"), 1) + self.assertIn("anullsrc=channel_layout=stereo:sample_rate=48000", joined) + fc = cmd[cmd.index("-filter_complex") + 1] + # cam (input 0) keeps its real audio + self.assertIn("[0:a]atrim=start=0:end=2", fc) + # screen (input 1) is audio-less: never referenced for audio; both of + # its segments draw from the shared anullsrc at input index 2 + self.assertNotIn("[1:a]", fc) + self.assertEqual(fc.count("[2:a]atrim"), 2) + + def test_no_silence_input_when_every_source_has_audio(self): + edl = edl_two_sources() + audio_map = {"raw/cam.mp4": True, "raw/screen.mp4": True} + cmd, _ = mod.build_command(edl, Path("/proj"), Path("/out/p.mp4"), 720, + target=(1280, 720), audio_map=audio_map) + self.assertNotIn("anullsrc", " ".join(cmd)) + fc = cmd[cmd.index("-filter_complex") + 1] + self.assertIn("[1:a]atrim", fc) + + def run(args): return subprocess.run([sys.executable, str(SCRIPT), *args], capture_output=True, text=True) @@ -171,5 +250,69 @@ def test_empty_segments_exits_2(self): self.assertEqual(r.returncode, 2) +@unittest.skipUnless(FFMPEG, "ffmpeg/ffprobe not installed") +class TestMixedSourcePreviewEndToEnd(unittest.TestCase): + """The maintainer's primary render-first case: a preview composited from a + 16:9 cam (44.1k audio) and a 4:3 screen recording with NO audio. Mixed + frame sizes and the missing audio stream both used to hard-fail at concat; + this renders the actual preview end to end.""" + + @classmethod + def setUpClass(cls): + cls.tmp = tempfile.TemporaryDirectory() + proj = Path(cls.tmp.name) + (proj / "raw").mkdir() + (proj / "cut").mkdir() + # cam: 192x108 (16:9) with 44.1k audio (mismatched rate on purpose) + r = subprocess.run( + ["ffmpeg", "-y", + "-f", "lavfi", "-t", "4", "-i", "testsrc2=size=192x108:rate=30", + "-f", "lavfi", "-t", "4", + "-i", "sine=frequency=440:sample_rate=44100", + "-t", "4", "-shortest", + "-c:v", "libx264", "-preset", "ultrafast", "-crf", "30", + "-pix_fmt", "yuv420p", "-c:a", "aac", + str(proj / "raw" / "cam.mp4")], + capture_output=True, text=True) + assert r.returncode == 0, r.stderr + # screen: 160x120 (4:3, different aspect) with NO audio at all + r = subprocess.run( + ["ffmpeg", "-y", + "-f", "lavfi", "-t", "4", "-i", "testsrc2=size=160x120:rate=30", + "-t", "4", + "-c:v", "libx264", "-preset", "ultrafast", "-crf", "30", + "-pix_fmt", "yuv420p", + str(proj / "raw" / "screen.mp4")], + capture_output=True, text=True) + assert r.returncode == 0, r.stderr + edl = {"source": "raw/cam.mp4", "fade_ms": 30, "pad_ms": 60, + "segments": [ + {"source": "raw/cam.mp4", "start": 0.5, "end": 2.0}, + {"source": "raw/screen.mp4", "start": 0.5, "end": 2.0}, + {"source": "raw/screen.mp4", "start": 2.5, "end": 3.5}]} + (proj / "cut" / "edl.json").write_text(json.dumps(edl)) + cls.proj = proj + + @classmethod + def tearDownClass(cls): + cls.tmp.cleanup() + + def test_preview_renders_mixed_dimensions_and_audioless(self): + out = self.proj / "cut" / "preview.mp4" + r = run([str(self.proj / "cut" / "edl.json"), "-o", str(out), + "--height", "108"]) + self.assertEqual(r.returncode, 0, r.stderr) + summary = json.loads(r.stdout) + self.assertEqual(summary["segments"], 3) + self.assertTrue(out.is_file()) + self.assertAlmostEqual(summary["actual_duration_seconds"], 3.5, + delta=0.5) + # every frame is the one normalized target size, computed from the + # first source at the requested height + dims = core.probe_dims(self.proj / "raw" / "cam.mp4") + expected_w = core.even(dims[0] * 108 / dims[1]) + self.assertEqual(core.probe_dims(out), (expected_w, 108)) + + if __name__ == "__main__": unittest.main() diff --git a/skills/mc-new/scripts/new_project.py b/skills/mc-new/scripts/new_project.py index 9ae67d0..191d994 100644 --- a/skills/mc-new/scripts/new_project.py +++ b/skills/mc-new/scripts/new_project.py @@ -123,7 +123,7 @@ def main() -> None: f"error: --ingest creates a footage-first project, but format {args.fmt!r} " f"includes ideation stages {ideation}; use a footage-first profile whose " "stages go straight to post-production, e.g. " - "stages: [new, cut, beats, graphics, assets, package, final, retro]" + "stages: [new, cut, beats, assets, graphics, package, final, retro]" ) proj = (projects_dir / args.series / args.slug) if args.series else (projects_dir / args.slug) diff --git a/skills/mc-new/scripts/tests/test-new_project.py b/skills/mc-new/scripts/tests/test-new_project.py index 938c2d5..e6dcbf7 100644 --- a/skills/mc-new/scripts/tests/test-new_project.py +++ b/skills/mc-new/scripts/tests/test-new_project.py @@ -15,14 +15,14 @@ SCRIPT = Path(__file__).resolve().parent.parent / "new_project.py" PROFILE = """--- -stages: [new, braindump, outline, script, record, cut, beats, graphics, assets, package, final, retro] +stages: [new, braindump, outline, script, record, cut, beats, assets, graphics, package, final, retro] --- # Talking head """ VOD_PROFILE = """--- -stages: [new, cut, beats, graphics, assets, package, final, retro] +stages: [new, cut, beats, assets, graphics, package, final, retro] --- # Livestream VOD @@ -148,7 +148,7 @@ def test_ingest_registers_source_and_post_stages(self): self._new(slug="vod-episode", fmt="livestream-vod", extra=["--ingest", str(footage)]) state = self._state("vod-episode") self.assertEqual(state["stages"], - ["new", "cut", "beats", "graphics", "assets", "package", "final", "retro"]) + ["new", "cut", "beats", "assets", "graphics", "package", "final", "retro"]) self.assertEqual(state["stage"], "cut") self.assertEqual(state["stages_done"], ["new"]) self.assertEqual(state["sources"], [{ diff --git a/skills/mc-package/SKILL.md b/skills/mc-package/SKILL.md index e1a8f0c..387b606 100644 --- a/skills/mc-package/SKILL.md +++ b/skills/mc-package/SKILL.md @@ -42,7 +42,7 @@ For livestream-pack projects, packaging serves the scheduled broadcast, not a fi 2. Produce one title (locked anchors apply) and one description. This is the live lane, so live framing is correct here (chat asks, the schedule, membership mentions), with the CTA lines and `[owner]` `links` per step 6. 3. Produce ONE scheduled-broadcast thumbnail through the full step 4 flow: face plus a 2 to 4 word hook, programmatic draft, mandatory improvement pass, mandatory 120px verification. 4. The two-asset rule, explicit: the scheduled-broadcast thumbnail competes in browse and search exactly like a VOD thumbnail and gets the full face-plus-hook treatment; it is NEVER a plain brand card. The plain branded card with the countdown safe zone is a different asset with a different job: the in-stream Starting Soon SCENE, produced by the stream-pack stage, not here. Never present one asset for both jobs, and never let the scene card become the broadcast thumbnail. -5. No chapters (nothing is cut). Write `packaging/titles.md`, `packaging/description.md`, and the thumbnail per the folder rules above; update `artifacts`, append `package` to `stages_done`, and set `stage` to the next stage in `stages`. Blessed slots apply once the creator approves the assets. +5. No chapters (nothing is cut). Write `packaging/titles.md`, `packaging/description.md`, and the thumbnail per the folder rules above; update `artifacts`. Touch `stage` and `stages_done` ONLY when `package` is the project's current stage AND appears in its `stages` array, mirroring the VOD flow's step 10 guard; livestream-pack has no `package` stage (its stages are `new`, `stream-pack`, `final`, `retro`), so leave `stage` and `stages_done` untouched and let mc-stream-pack advance the lane on the creator's gate-4 approval. Blessed slots apply once the creator approves the assets. ## The series template contract diff --git a/skills/mc-pipeline/PIPELINE.md b/skills/mc-pipeline/PIPELINE.md index 154e6e5..0aad139 100644 --- a/skills/mc-pipeline/PIPELINE.md +++ b/skills/mc-pipeline/PIPELINE.md @@ -20,15 +20,15 @@ Format profiles select a subset of these stages (see the `stages:` frontmatter o | 3 | outline | mc-outline | gate 1: outline | `outline.md` (hooks + outline + packaging promise) | | 4 | script | mc-script | | `script.md` (lint passed, craft QA passed) | | 5 | record | the creator | | `raw/*` recordings, constant frame rate | -| 6 | cut | mc-cut | gate 2: cutplan | `transcript/words.json` (suffixed `.words.json` when a project has multiple sources), `cut/candidates.json`, `cut/cutplan.md`, `cut/edl.json`, `cut/rough.fcpxml` (per `[editor] timeline-format`; `none` skips), `renders/preview.mp4` (fast low-res preview, re-rendered each iteration; once stage 8 has rendered overlays, the router sends the project back through mc-cut to re-render it with graphics composited) | +| 6 | cut | mc-cut | gate 2: cutplan | `transcript/words.json` (suffixed `.words.json` when a project has multiple sources), `cut/candidates.json`, `cut/cutplan.md`, `cut/edl.json`, `cut/rough.fcpxml` (per `[editor] timeline-format`; `none` skips), `renders/preview.mp4` (fast low-res preview, re-rendered each iteration; once stage 9 has rendered overlays, the router sends the project back through mc-cut to re-render it with graphics composited) | | 7 | beats | mc-beats | gate 3: beats | `beats/beats.md` (the beat table), `beats/STORYBOARD.md` | -| 8 | graphics | mc-graphics | | `graphics/` alpha MOVs + `graphics/HANDOFF.md`; on completion the router routes through mc-cut to re-render `renders/preview.mp4` with the overlays composited | -| 9 | assets | mc-assets | | `assets/` + `assets/manifest.json` | +| 8 | assets | mc-assets | | `assets/` + `assets/manifest.json` | +| 9 | graphics | mc-graphics | | `graphics/` alpha MOVs + `graphics/HANDOFF.md`; on completion the router routes through mc-cut to re-render `renders/preview.mp4` with the overlays composited | | 10 | package | mc-package | | `packaging/titles.md`, `packaging/thumbs/`, `packaging/description.md`, `packaging/chapters.md`, `packaging/captions/` (final.srt, final.vtt, transcript.md, when the cut exists) | -| 11 | final | the creator, with an offered pipeline render | gate 4: final | `renders/final.mp4` (the offered final-quality render: same EDL, graphics composited from the beat table, delivery resolution and codec per `[render]`, loudness-normalized to the `[render]` loudness-target unless loudnorm is off), or the creator's own editor render into `renders/` | +| 11 | final | the creator, with an offered pipeline render | gate 4: final | `renders/final.mp4` (the offered final-quality render: same EDL, graphics composited from the beat table, delivery resolution per `[video]` delivery-resolution and codec per `[render]`, loudness-normalized to the `[render]` loudness-target unless loudnorm is off), or the creator's own editor render into `renders/` | | 12 | retro | mc-retro | | edits to `{formats-path}/.md` learnings + offending skill files | -Stages 8 and 9 may run in parallel once gate 3 is approved. Stage 10 may start any time after gate 1 (the packaging promise exists from the outline). +Stage 8 (assets) runs before stage 9 (graphics) so the farmed stills and clips exist before graphics composes with them; both unlock at gate 3. Stage 10 may start any time after gate 1 (the packaging promise exists from the outline). ## project.json contract @@ -41,7 +41,7 @@ Stages 8 and 9 may run in parallel once gate 3 is approved. Stage 10 may start a "parent": null, "stage": "braindump", "series": null, - "stages": ["new", "braindump", "outline", "script", "record", "cut", "beats", "graphics", "assets", "package", "final", "retro"], + "stages": ["new", "braindump", "outline", "script", "record", "cut", "beats", "assets", "graphics", "package", "final", "retro"], "stages_done": ["new"], "approvals": { "outline": null, @@ -57,7 +57,7 @@ Stages 8 and 9 may run in parallel once gate 3 is approved. Stage 10 may start a Field rules: - `stage` is the stage currently in progress or next to run. When the last stage in `stages` completes (retro), it is set to `done`, the one terminal value not drawn from `stages`. -- `stages` is copied from the format profile at creation; never assume the master list. Footage-first projects (an existing recording, a livestream VOD) use the ingest-first variant written by mc-new's ingest mode: `["new", "cut", "beats", "graphics", "assets", "package", "final", "retro"]`. It skips the ideation stages entirely; the source file is registered in `sources` at creation. +- `stages` is copied from the format profile at creation; never assume the master list. Footage-first projects (an existing recording, a livestream VOD) use the ingest-first variant written by mc-new's ingest mode: `["new", "cut", "beats", "assets", "graphics", "package", "final", "retro"]`. It skips the ideation stages entirely; the source file is registered in `sources` at creation. - `series` (optional, default `null`) names the series this project belongs to, written by mc-new's `--series` mode. A series is a folder under `{projects-path}` holding a `common/` folder for evergreen shared assets and one subfolder per episode project. Stages that read brand templates (mc-package) check `series` to apply per-series packaging templates. - `approvals` values are `null` (not reached), `"pending"` (artifact presented, waiting on the creator), or an ISO date string (approved that day). Only the creator's explicit say-so in conversation moves pending to a date. - `artifacts` maps artifact names to paths as they are produced, e.g. `"edl": "cut/edl.json"`. diff --git a/skills/mc-retro/SKILL.md b/skills/mc-retro/SKILL.md index 0871772..b1826a9 100644 --- a/skills/mc-retro/SKILL.md +++ b/skills/mc-retro/SKILL.md @@ -18,7 +18,7 @@ The compounding mechanism: feedback edits FILES, not just memory. Every note imp - voice/wording miss: `{brand-path}/voice-bible.md` (new rule with the verbatim example) and/or a new pattern in `{brand-path}/blacklist.md`, - visual style miss (graphics density, overlay aesthetic, image-type choice, CTA placement): `{brand-path}/production-bible.md`, in the global section or the matching per-format override section; ISO-dated, one-way ratchet (entries only accumulate; a change of taste gets a new dated entry that supersedes by date, never a deletion), - structural/retention miss: the Learnings section of `{formats-path}/.md` (ISO-dated, newest first), - - a stage doing the wrong thing: that skill's SKILL.md (note: skill edits apply to the installed module and may be overwritten by module updates; prefer profile/brand files when the note fits there; if the harness blocks access to another skill's folder, record the note in the format profile's Learnings instead), + - a stage doing the wrong thing: route the note FIRST to that skill's durable per-skill surface, a `workflow.persistent_facts` entry (or the matching `workflow` key, e.g. one of the `*_flags`) in `{project-root}/_bmad/custom/.toml`, the team-override layer resolve_customization.py loads on every activation and module updates never touch (the same file where mc-setup records mc-cut's `cutplan_flags`); edit it surgically, preserving existing keys. Editing that skill's installed SKILL.md is the last resort, only when the note fits nowhere else: skill edits apply to the installed module and may be overwritten by module updates. If the harness blocks access to another skill's folder, record the note in the format profile's Learnings instead, - a tool being driven wrong: the `notes` field of that tool's `[[tools]]` entry in the studio config (`[modules.manticore]` in `{project-root}/_bmad/custom/config.toml`), - a mechanical failure: an issue note in the relevant engine README or script docstring, - a pipeline gap (a stage could not do its job because the module itself is missing a feature, has a wrong contract, or a broken mechanic): append an entry to the studio improvements log (see Improvements log below), in addition to any local fix above. diff --git a/skills/mc-setup/assets/formats/livestream-vod.md b/skills/mc-setup/assets/formats/livestream-vod.md index b9bdb0a..5f51866 100644 --- a/skills/mc-setup/assets/formats/livestream-vod.md +++ b/skills/mc-setup/assets/formats/livestream-vod.md @@ -1,6 +1,6 @@ --- format: livestream-vod -stages: [new, cut, beats, graphics, assets, package, final, retro] +stages: [new, cut, beats, assets, graphics, package, final, retro] engine_overlays: hyperframes engine_stingers: none generated_broll: allowed-with-verification diff --git a/skills/mc-setup/assets/formats/talking-head.md b/skills/mc-setup/assets/formats/talking-head.md index d840b9d..eab3537 100644 --- a/skills/mc-setup/assets/formats/talking-head.md +++ b/skills/mc-setup/assets/formats/talking-head.md @@ -1,6 +1,6 @@ --- format: talking-head -stages: [new, braindump, outline, script, record, cut, beats, graphics, assets, package, final, retro] +stages: [new, braindump, outline, script, record, cut, beats, assets, graphics, package, final, retro] engine_overlays: hyperframes engine_stingers: hyperframes generated_broll: allowed diff --git a/skills/mc-setup/assets/formats/voiceover-explainer.md b/skills/mc-setup/assets/formats/voiceover-explainer.md index b4c1a6a..7a53ab7 100644 --- a/skills/mc-setup/assets/formats/voiceover-explainer.md +++ b/skills/mc-setup/assets/formats/voiceover-explainer.md @@ -1,6 +1,6 @@ --- format: voiceover-explainer -stages: [new, braindump, outline, script, record, cut, beats, graphics, assets, package, final, retro] +stages: [new, braindump, outline, script, record, cut, beats, assets, graphics, package, final, retro] engine_overlays: hyperframes engine_stingers: hyperframes generated_broll: allowed diff --git a/skills/mc-setup/scripts/tests/test-merge_profile_frontmatter.py b/skills/mc-setup/scripts/tests/test-merge_profile_frontmatter.py index 1343e64..3e5c18d 100644 --- a/skills/mc-setup/scripts/tests/test-merge_profile_frontmatter.py +++ b/skills/mc-setup/scripts/tests/test-merge_profile_frontmatter.py @@ -16,7 +16,7 @@ SHIPPED = """--- format: talking-head -stages: [new, braindump, outline, script, record, cut, beats, graphics, assets, package, final, retro] +stages: [new, braindump, outline, script, record, cut, beats, assets, graphics, package, final, retro] engine_overlays: hyperframes generated_broll: allowed beat-types: [popup, diagram, lower-third, stat-card, cta] @@ -34,7 +34,7 @@ STUDIO = """--- format: talking-head -stages: [new, braindump, outline, script, record, cut, beats, graphics, assets, package, final, retro] +stages: [new, braindump, outline, script, record, cut, beats, assets, graphics, package, final, retro] engine_overlays: ograf generated_broll: banned --- From 4f667641fc2771052307dd2be15d187f892c65f2 Mon Sep 17 00:00:00 2001 From: Brian Madison Date: Wed, 22 Jul 2026 23:42:54 -0500 Subject: [PATCH 4/8] Add incremental content-addressed segment rendering to the final render The final render partitions the timeline into persistent, content-addressed segments under renders/segments/ and re-encodes only the segments whose inputs actually changed, reusing the rest from cache. A tweaked graphic or a re-cut region on a long video re-renders in seconds instead of end to end. composite_core.py: - plan_segments() replaces plan_chunks: greedy left-to-right sticky boundaries snapped to the next safe cut (~600s target), so an edit cannot move an earlier boundary and its cached segment survives. - Content addressing: segment_id (position-independent slice identity), segment_input_hash (EDL slice + source/overlay file digests + render_key), content_digest, ffmpeg_version, load/save_manifest. - build_filter_complex / build_command / encode_args gain a streams param (av | video | audio); the av path is byte-for-byte unchanged. render_final.py: - plan -> hash -> dirty-skip -> render dirty segments video-only via a bounded worker pool (atomic temp->rename) -> rebuild whole-program audio once -> concat video (-c copy) -> mux -> whole-file loudnorm -> write manifest -> GC orphans. Video-only-persist + audio-rebuilt-whole keeps the timeline exact. - New flags: --segment-target-seconds (600), --no-cache. Encoder/ffmpeg/ dimension changes dirty every segment (a mixed-encoder concat is invalid). Tests: 50 -> 60 (TestPlanSegments incl. the boundary-stability property, identity/hashing, manifest round-trip, content-digest, and e2e cache-hit + --no-cache). Docs updated: README, SKILL.md, under-the-hood, CHANGELOG. --- CHANGELOG.md | 1 + README.md | 2 +- docs/manny-under-the-hood.html | 2 +- skills/mc-cut/SKILL.md | 2 +- skills/mc-cut/scripts/composite_core.py | 362 +++++++++++++----- skills/mc-cut/scripts/render_final.py | 360 ++++++++++++----- .../mc-cut/scripts/tests/test-render_final.py | 288 ++++++++++---- 7 files changed, 754 insertions(+), 263 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e37d1c2..0841138 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ All notable changes to BMad Manticore are documented here. Dates are ISO (YYYY-M ### Added +- The final render is incremental. The timeline is partitioned into content-addressed segments (greedy, sticky boundaries snapped to the next safe cut, ~10 min target tunable via `--segment-target-seconds`) persisted video-only under `renders/segments/` alongside a `manifest.json`. Each re-render hashes every segment's real inputs (its EDL slice, the content digest of every source and overlay file it consumes, and the resolved encoder, ffmpeg version, and output dimensions) and re-encodes only the segments that actually changed, reusing the rest from cache; the video segments are then losslessly concatenated (`-c copy`) and the whole-program audio, rebuilt every pass so the timeline stays exact and per-segment AAC seam drift can never accumulate, is muxed in. A tweaked graphic or a re-cut region on a long video re-renders in seconds instead of end to end. Boundaries are stable: because a segment's identity is its content, not its position, an edit leaves earlier segments (and unshifted later ones) cached. Changing the encoder, ffmpeg version, or output dimensions dirties every segment (a mixed-encoder concat is invalid); `--no-cache` forces a full re-render. Dirty segments render through a bounded worker pool. Encoder selection, the hardware ladders, the disk preflight, boundary-frame checks, and the -14 LUFS loudnorm pass are unchanged; the preview render is untouched. - Final renders are loudness-normalized by default to -14 LUFS (two-pass ffmpeg loudnorm, TP -1.5, LRA 11; audio-only second pass with the video stream copied), configurable via `[render]` loudness-target and disable-able via `[render]` loudnorm = false or `--no-loudnorm`. Preview renders are never normalized. Silent audio skips the pass with a warning instead of failing. - mc-package emits uploadable captions and a publishable transcript from the edited timeline: the new stdlib-only captions.py maps each EDL segment's word spans onto output-timeline times (reordered and multi-source edits included) and writes packaging/captions/final.srt, final.vtt, and transcript.md. Caption defaults: 42 chars per line, 2 lines, 1 to 7 s cues, splits at sentence ends, pauses, and cuts. A light filler/stutter cleanup applies to the caption rendition only (`--no-clean` keeps verbatim); transcript/words.json is never modified. - mc-stream-pack produces and verifies the OBS WebM VP9 alpha deliverable from a ProRes 4444 master in one command (render_verify.py `--transcode-webm`, with `--webm-crf`), with clear errors when ffmpeg lacks libvpx-vp9 or the master has no alpha, plus a pixfmt-failure hint carrying the exact re-encode flags. The stream-pack copy of render_verify.py is now a superset of mc-graphics' copy. diff --git a/README.md b/README.md index f7875b7..044677e 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Manticore is an AI video production pipeline for content creators, packaged as a - A script woven from your own recorded brain-dump words under a quote-or-cut contract, linted against a blacklist of LLM tells and a 16-rule craft checklist. It sounds like you because it is you. - A cut plan that itemizes only the calls you might disagree with, each with a timestamp and the quoted words: "trailing 'so' at 42:20, keep or cut?" -- A render at every step: a fast low-res preview after every cut approval, the same preview re-rendered with graphics composited once the graphics stage has rendered the overlays, and a final-quality render offered at the last gate. The editor timeline export, edl.json, cutplan, and overlays are always written too, so jumping into your editor never loses work. +- A render at every step: a fast low-res preview after every cut approval, the same preview re-rendered with graphics composited once the graphics stage has rendered the overlays, and a final-quality render offered at the last gate. The final render is incremental: the timeline is cached as content-addressed segments, so re-rendering after a tweaked graphic or a re-cut region touches only what changed, turning a fix on a long video into a seconds-long render. The editor timeline export, edl.json, cutplan, and overlays are always written too, so jumping into your editor never loses work. - Motion graphics planned under creativity mandates and your visual density tier, styled by your Production Bible, delivered as brand-themed alpha overlays (ProRes 4444, works in every NLE), anchored to the exact words you speak. - CTAs planned like beats: your configured CTA inventory placed at research-backed seams, approved by you in the same gate as every other graphic. - Titles, thumbnails (A/B pairs for series), description, chapters, and uploadable SRT/VTT captions with a publishable transcript of the edited timeline, built around a packaging promise you approved before the script was written. diff --git a/docs/manny-under-the-hood.html b/docs/manny-under-the-hood.html index db4411d..72eac8e 100644 --- a/docs/manny-under-the-hood.html +++ b/docs/manny-under-the-hood.html @@ -277,7 +277,7 @@

The tools and models behind each stage

Rendering

working
-

ffmpeg, segment-parallel and disk-preflighted, with videotoolbox hardware encode on macOS; the final render is loudness-normalized to -14 LUFS. The Windows (nvenc, qsv, amf) and Linux (nvenc, vaapi) encoder ladders with libx264 fallback are code-complete, pending real-hardware validation.

+

ffmpeg, incremental and disk-preflighted: the timeline is partitioned into content-addressed segments, so a re-render re-encodes only the segments whose inputs actually changed and reuses the rest — one tweaked graphic on a long video is a seconds-long re-render. Video segments are cached; the audio is rebuilt whole every pass so the timeline stays exact. videotoolbox hardware encode on macOS; the final render is loudness-normalized to -14 LUFS. The Windows (nvenc, qsv, amf) and Linux (nvenc, vaapi) encoder ladders with libx264 fallback are code-complete, pending real-hardware validation.

Graphics render

working
diff --git a/skills/mc-cut/SKILL.md b/skills/mc-cut/SKILL.md index 9933407..a4f7e88 100644 --- a/skills/mc-cut/SKILL.md +++ b/skills/mc-cut/SKILL.md @@ -32,7 +32,7 @@ mc-pipeline routes here as soon as the graphics stage completes (mc-graphics han ## Final render (gate 4) -When the project reaches the final stage, offer the final-quality render from this skill: `uv run {skill-root}/scripts/render_final.py cut/edl.json -o renders/final.mp4 --beats beats/beats.md --graphics-dir graphics/` plus any `{workflow.final_flags}`, with `--codec` and `--crf` per `[render]` in the studio config, `--height` from the height of `[video]` delivery-resolution, `--loudness-target <[render] loudness-target>`, and `--no-loudnorm` appended when `[render] loudnorm` is false. It bakes the same EDL the creator approved with graphics composited from the approved beat table, hardware encode when available (videotoolbox on macOS; on Windows h264_nvenc, then h264_qsv, then h264_amf; on Linux h264_nvenc then h264_vaapi; each candidate validated by a one-frame test encode, libx264 fallback everywhere), segment-parallel rendering, a disk preflight, progress reporting, and boundary-frame checks. The final render is loudness-normalized to the target LUFS with two-pass ffmpeg loudnorm (the fast preview is never normalized). Finishing in the creator's own editor from the always-exported timeline is an equally supported path; either closes gate 4. +When the project reaches the final stage, offer the final-quality render from this skill: `uv run {skill-root}/scripts/render_final.py cut/edl.json -o renders/final.mp4 --beats beats/beats.md --graphics-dir graphics/` plus any `{workflow.final_flags}`, with `--codec` and `--crf` per `[render]` in the studio config, `--height` from the height of `[video]` delivery-resolution, `--loudness-target <[render] loudness-target>`, and `--no-loudnorm` appended when `[render] loudnorm` is false. It bakes the same EDL the creator approved with graphics composited from the approved beat table, hardware encode when available (videotoolbox on macOS; on Windows h264_nvenc, then h264_qsv, then h264_amf; on Linux h264_nvenc then h264_vaapi; each candidate validated by a one-frame test encode, libx264 fallback everywhere), persistent incremental segment rendering (the timeline is partitioned into content-addressed segments under `renders/segments/`; a re-render re-encodes only the segments whose inputs actually changed and reuses the rest, so a single tweaked graphic on a long video is a seconds-long re-render), a disk preflight, progress reporting, and boundary-frame checks. `--segment-target-seconds` (default 600) tunes the segment size; append it via `{workflow.final_flags}` when a project wants coarser or finer segments. The final render is loudness-normalized to the target LUFS with two-pass ffmpeg loudnorm (the fast preview is never normalized). Finishing in the creator's own editor from the always-exported timeline is an equally supported path; either closes gate 4. ## Dual timecode diff --git a/skills/mc-cut/scripts/composite_core.py b/skills/mc-cut/scripts/composite_core.py index db3f678..c8d8f5f 100644 --- a/skills/mc-cut/scripts/composite_core.py +++ b/skills/mc-cut/scripts/composite_core.py @@ -27,6 +27,7 @@ No config discovery: every function takes explicit arguments. Stdlib only. """ +import hashlib import json import platform import shutil @@ -207,9 +208,17 @@ def resolve_overlays(beats, graphics_dir): def build_filter_complex(edl, source_index, height, overlays=(), overlay_size=None, hwupload=False, target=None, audio_map=None, - silence_index=None): + silence_index=None, streams="av"): """Build the filter_complex string for the whole timeline. + streams selects which output streams the graph emits: "av" (default, the + historic behavior, [outv] and [outa]), "video" ([outv] only, the audio + chains and the audio concat omitted), or "audio" ([outa] only, the video + chains and every overlay omitted). The video-only and audio-only forms + back the incremental segment render, which persists video-only segments + and rebuilds the audio whole every render; the "av" form is byte-for-byte + unchanged so the preview and any av caller stay identical. + source_index maps each source path to its ffmpeg -i input index. Each segment is trimmed from its source, PTS-reset, sized, given an in/out afade of fade_ms at its boundaries, and audio-normalized; all segments @@ -244,6 +253,10 @@ def build_filter_complex(edl, source_index, height, overlays=(), overlay_size=No encoders that only take hardware frames (vaapi); the caller must also set up the device (encoder_init_flags). """ + want_v = streams in ("av", "video") + want_a = streams in ("av", "audio") + if not want_v: + overlays = () # overlays are a video-only concern fade = edl.get("fade_ms", 30) / 1000.0 tw, th = target if target else (None, None) parts, vlabels, alabels = [], [], [] @@ -254,51 +267,61 @@ def build_filter_complex(edl, source_index, height, overlays=(), overlay_size=No # Never let the two fades overlap on a very short segment. f = min(fade, dur / 2) if dur > 0 else 0.0 vlab, alab = f"v{i}", f"a{i}" - vchain = ( - f"[{idx}:v]trim=start={_fmt(start)}:end={_fmt(end)}," - f"setpts=PTS-STARTPTS" - ) - if target: - # Normalize every segment to one frame so mixed-size sources concat. - vchain += ( - f",scale={tw}:{th}:force_original_aspect_ratio=decrease," - f"pad={tw}:{th}:(ow-iw)/2:(oh-ih)/2,setsar=1[{vlab}]" + if want_v: + vchain = ( + f"[{idx}:v]trim=start={_fmt(start)}:end={_fmt(end)}," + f"setpts=PTS-STARTPTS" ) - elif height: - vchain += f",scale=-2:{height},setsar=1[{vlab}]" - else: - vchain += f",setsar=1[{vlab}]" - parts.append(vchain) - # Audio: real stream, or shared silence for an audio-less source. - if (silence_index is not None and audio_map is not None - and not audio_map.get(seg["source"], True)): - aidx, a_start, a_end = silence_index, 0.0, dur - else: - aidx, a_start, a_end = idx, start, end - achain = ( - f"[{aidx}:a]atrim=start={_fmt(a_start)}:end={_fmt(a_end)}," - f"asetpts=PTS-STARTPTS" - ) - if f > 0: - achain += ( - f",afade=t=in:st=0:d={_fmt(f)}" - f",afade=t=out:st={_fmt(dur - f)}:d={_fmt(f)}" + if target: + # Normalize every segment to one frame so mixed-size sources concat. + vchain += ( + f",scale={tw}:{th}:force_original_aspect_ratio=decrease," + f"pad={tw}:{th}:(ow-iw)/2:(oh-ih)/2,setsar=1[{vlab}]" + ) + elif height: + vchain += f",scale=-2:{height},setsar=1[{vlab}]" + else: + vchain += f",setsar=1[{vlab}]" + parts.append(vchain) + vlabels.append(f"[{vlab}]") + if want_a: + # Audio: real stream, or shared silence for an audio-less source. + if (silence_index is not None and audio_map is not None + and not audio_map.get(seg["source"], True)): + aidx, a_start, a_end = silence_index, 0.0, dur + else: + aidx, a_start, a_end = idx, start, end + achain = ( + f"[{aidx}:a]atrim=start={_fmt(a_start)}:end={_fmt(a_end)}," + f"asetpts=PTS-STARTPTS" ) - achain += ",aresample=48000,aformat=channel_layouts=stereo" - achain += f"[{alab}]" - parts.append(achain) - vlabels.append(f"[{vlab}]") - alabels.append(f"[{alab}]") + if f > 0: + achain += ( + f",afade=t=in:st=0:d={_fmt(f)}" + f",afade=t=out:st={_fmt(dur - f)}:d={_fmt(f)}" + ) + achain += ",aresample=48000,aformat=channel_layouts=stereo" + achain += f"[{alab}]" + parts.append(achain) + alabels.append(f"[{alab}]") n = len(edl["segments"]) - concat_inputs = "".join(v + a for v, a in zip(vlabels, alabels)) - if not overlays: - if hwupload: - parts.append(f"{concat_inputs}concat=n={n}:v=1:a=1[basev][outa]") - parts.append("[basev]format=nv12,hwupload[outv]") - else: - parts.append(f"{concat_inputs}concat=n={n}:v=1:a=1[outv][outa]") + if not want_v: + # Audio-only: concat the audio chains straight to [outa]. + parts.append(f"{''.join(alabels)}concat=n={n}:v=0:a=1[outa]") + return ";".join(parts) + # A stage after the video concat is needed when overlays composite over it + # or a hardware encoder demands hardware frames. + need_post = bool(overlays) or hwupload + if want_a: + concat_inputs = "".join(v + a for v, a in zip(vlabels, alabels)) + head = f"{concat_inputs}concat=n={n}:v=1:a=1" + parts.append(f"{head}[basev][outa]" if need_post else f"{head}[outv][outa]") + else: + concat_inputs = "".join(vlabels) + head = f"{concat_inputs}concat=n={n}:v=1:a=0" + parts.append(f"{head}[basev]" if need_post else f"{head}[outv]") + if not need_post: return ";".join(parts) - parts.append(f"{concat_inputs}concat=n={n}:v=1:a=1[basev][outa]") prev = "basev" for k, ov in enumerate(overlays): lab = f"ov{k}" @@ -327,9 +350,15 @@ def build_filter_complex(edl, source_index, height, overlays=(), overlay_size=No def build_command(edl, project_dir, output, height, overlays=(), overlay_size=None, encode=None, extra_output_flags=(), - encoder=None, target=None, audio_map=None): + encoder=None, target=None, audio_map=None, streams="av"): """Assemble (ffmpeg_argv, source_index) for one render invocation. + streams selects the output streams (passed through to build_filter_complex + and the -map flags): "av" (default, unchanged), "video" (video-only, no + audio silence input, maps [outv] only), or "audio" (audio-only, no overlay + inputs, maps [outa] only). The video/audio split backs the incremental + segment render (video segments persisted, audio rebuilt whole). + encode replaces the default preview encode args (libx264 crf 28 veryfast + aac). Every looped image overlay input carries an explicit -t duration cap (looped/synthetic sources must never run open-ended); video overlay @@ -351,14 +380,19 @@ def build_command(edl, project_dir, output, height, overlays=(), gets -init_hw_device flags and an hwupload filtergraph tail). Software and videotoolbox/nvenc/qsv/amf encoders need nothing here. """ + want_v = streams in ("av", "video") + want_a = streams in ("av", "audio") + if not want_v: + overlays = () # overlays are a video-only concern distinct = [] for seg in edl["segments"]: if seg["source"] not in distinct: distinct.append(seg["source"]) source_index = {src: i for i, src in enumerate(distinct)} - need_silence = bool(audio_map) and any( + need_silence = want_a and bool(audio_map) and any( not audio_map.get(src, True) for src in distinct) - argv = ["ffmpeg", "-y", *encoder_init_flags(encoder)] + hwupload = want_v and encoder_needs_hwupload(encoder) + argv = ["ffmpeg", "-y", *(encoder_init_flags(encoder) if want_v else [])] for src in distinct: argv += ["-i", str((project_dir / src).resolve())] silence_index = None @@ -381,11 +415,14 @@ def build_command(edl, project_dir, output, height, overlays=(), argv += [ "-filter_complex", build_filter_complex(edl, source_index, height, ovs, overlay_size, - hwupload=encoder_needs_hwupload(encoder), - target=target, audio_map=audio_map, - silence_index=silence_index), - "-map", "[outv]", "-map", "[outa]", + hwupload=hwupload, target=target, + audio_map=audio_map, silence_index=silence_index, + streams=streams), ] + if want_v: + argv += ["-map", "[outv]"] + if want_a: + argv += ["-map", "[outa]"] argv += list(encode) if encode else list(PREVIEW_ENCODE) if str(output).endswith((".mp4", ".mov")): argv += ["-movflags", "+faststart"] @@ -394,62 +431,192 @@ def build_command(edl, project_dir, output, height, overlays=(), return argv, source_index -# --- chunk planning (segment-parallel final render) -------------------------- +# --- segment planning (persistent incremental render) ------------------------ -def plan_chunks(edl, overlays=(), parallel=2): - """Split the EDL into up to `parallel` contiguous chunks for parallel - rendering. Split points are internal cut boundaries that fall strictly - outside every overlay window, nearest to the equal-duration targets, so no - overlay ever spans two chunks and the concat is sample-exact. Returns - chunk dicts {seg_start, seg_end, offset, duration, overlays} where - overlays carry chunk-local start times. +def plan_segments(edl, overlays=(), target_seconds=600.0): + """Partition the EDL into persistent render-segments with STABLE, sticky + boundaries for the incremental render cache. + + Greedy, left to right: accumulate EDL segments until the running duration + since the last cut reaches target_seconds, then close the render-segment + at the next SAFE boundary, a hard cut between two EDL segments that no + overlay window spans (the boundary-safety rule the parallel render already + relied on). A boundary chosen this way depends only on the content BEFORE + it, so an edit later in the timeline cannot move an earlier boundary and + its persisted segment survives; because a render-segment's identity is the + content it contains (not its timeline offset), even the segments after an + edit keep their identity as long as the same EDL segments still group + together. The final render-segment always runs to the end. + + target_seconds is a floor, not an exact size: an unsafe boundary (an + overlay straddles it) is skipped and the segment grows until the next safe + cut. Returns the same dict shape plan_chunks returned {seg_start, seg_end, + offset, duration, overlays}, overlays carrying chunk-local start times, so + the renderer treats a segment exactly like the old parallel chunk. """ durs = segment_durations(edl) - total = sum(durs) n = len(durs) - parallel = max(1, min(parallel, n)) - bounds = boundary_times(edl) + if n == 0: + return [] + bounds = boundary_times(edl) # times of the n-1 internal boundaries - def inside_overlay(t): + def spans_boundary(t): return any(ov["start"] < t < ov["start"] + ov["dur"] for ov in overlays) - valid = [(i, t) for i, t in enumerate(bounds) if not inside_overlay(t)] - cuts = [] - for k in range(1, parallel): - target = total * k / parallel - best = None - for i, t in valid: - if cuts and t <= cuts[-1][1]: - continue - if best is None or abs(t - target) < abs(best[1] - target): - best = (i, t) - if best is not None: - cuts.append(best) - chunks = [] + # Decide at each internal boundary i (between EDL seg i and i+1) whether to + # cut: the first SAFE boundary once the run since the last cut reaches the + # target. An unsafe boundary does not reset the accumulator, so the segment + # keeps growing to the next safe cut. + cut_after = [False] * max(0, n - 1) + running = 0.0 + for i in range(n - 1): + running += durs[i] + if running >= target_seconds and not spans_boundary(bounds[i]): + cut_after[i] = True + running = 0.0 + segments = [] seg_start = 0 offset = 0.0 - for i, _t in cuts + [(n - 1, total)]: - seg_end = i + 1 - dur = sum(durs[seg_start:seg_end]) - chunks.append({ - "seg_start": seg_start, - "seg_end": seg_end, - "offset": round(offset, 6), - "duration": round(dur, 6), - "overlays": [], - }) - seg_start = seg_end - offset += dur + for i in range(n): + if i == n - 1 or cut_after[i]: + seg_end = i + 1 + dur = sum(durs[seg_start:seg_end]) + segments.append({ + "seg_start": seg_start, + "seg_end": seg_end, + "offset": round(offset, 6), + "duration": round(dur, 6), + "overlays": [], + }) + seg_start = seg_end + offset += dur for ov in overlays: - for ch in chunks: - last = ch is chunks[-1] + for ch in segments: + last = ch is segments[-1] if ch["offset"] <= ov["start"] < ch["offset"] + ch["duration"] or last: local = dict(ov) local["start"] = round(ov["start"] - ch["offset"], 6) ch["overlays"].append(local) break - return chunks + return segments + + +# --- content addressing (the incremental render cache identity) -------------- + + +def content_digest(path, cheap=False): + """A content fingerprint for a file, or 'missing' when it is absent. + + cheap=True returns size+mtime_ns, for large source media that is expensive + to hash and rarely changes silently. cheap=False (the default) returns a + truncated sha256 of the bytes, for small overlay and asset files whose + regeneration MUST dirty the segment that consumes them even when the path + is unchanged (a re-rendered graphic keeps its name).""" + p = Path(path) + try: + st = p.stat() + except OSError: + return "missing" + if cheap: + return f"size:{st.st_size}:mtime:{st.st_mtime_ns}" + h = hashlib.sha256() + try: + with open(p, "rb") as fh: + for block in iter(lambda: fh.read(1 << 20), b""): + h.update(block) + except OSError: + return "missing" + return "sha256:" + h.hexdigest()[:32] + + +def ffmpeg_version(): + """The ffmpeg build version token (part of the render identity: a bump can + change encoded output, so it must dirty every cached segment). 'unknown' + when ffmpeg cannot be run or the banner is unparseable.""" + try: + proc = subprocess.run(["ffmpeg", "-version"], + capture_output=True, text=True) + except OSError: + return "unknown" + if proc.returncode != 0 or not proc.stdout: + return "unknown" + parts = proc.stdout.splitlines()[0].split() + # "ffmpeg version 8.1.2 Copyright ..." + return parts[2] if len(parts) >= 3 and parts[0] == "ffmpeg" else "unknown" + + +def segment_identity(edl, seg): + """Position-independent content identity of a render-segment: its EDL + slice as ordered (source, source-relative start, end) plus fade_ms. Never + includes the output-timeline offset, so an upstream edit that only shifts a + segment later in time leaves its identity unchanged.""" + slice_ = edl["segments"][seg["seg_start"]:seg["seg_end"]] + return { + "fade_ms": edl.get("fade_ms", 30), + "segments": [{"source": s["source"], + "start": round(s["start"], 6), + "end": round(s["end"], 6)} for s in slice_], + } + + +def segment_id(edl, seg): + """Stable filesystem-safe id for a render-segment, derived from its content + identity (not its position), so an identical slice keeps the same id and + persisted file across runs. Duplicate slices (the same source span kept + twice) legitimately share one id and one file.""" + raw = json.dumps(segment_identity(edl, seg), sort_keys=True).encode("utf-8") + return "seg-" + hashlib.sha256(raw).hexdigest()[:16] + + +def segment_input_hash(edl, seg, render_key, source_digests, overlay_digests): + """sha256 over the content-bearing, position-independent inputs of one + render-segment, the cache key: its slice identity, the digest of every + source it draws on, each overlay landing inside it (id, chunk-local start, + dur, image flag, and the overlay FILE's digest, so a re-rendered graphic + dirties the segment), and the shared render_key (resolved encoder, ffmpeg + version, output dims, video encode args). Absolute/edited-timeline offsets + are never hashed, so an edit upstream cannot dirty a segment whose own + content is unchanged. + + source_digests maps source path -> digest; overlay_digests maps overlay id + -> digest. render_key is any JSON-serializable dict of shared render state. + """ + seen, slice_sources = set(), [] + for s in edl["segments"][seg["seg_start"]:seg["seg_end"]]: + if s["source"] not in seen: + seen.add(s["source"]) + slice_sources.append([s["source"], + source_digests.get(s["source"], "missing")]) + ovs = sorted( + ({"id": ov.get("id"), + "start": round(ov["start"], 6), + "dur": round(ov["dur"], 6), + "image": bool(ov.get("image")), + "digest": overlay_digests.get(ov.get("id"), "missing")} + for ov in seg["overlays"]), + key=lambda o: (o["start"], str(o["id"]))) + payload = { + "identity": segment_identity(edl, seg), + "sources": sorted(slice_sources), + "overlays": ovs, + "render_key": render_key, + } + raw = json.dumps(payload, sort_keys=True).encode("utf-8") + return "sha256:" + hashlib.sha256(raw).hexdigest() + + +def load_manifest(path): + """The prior segment manifest dict, or None when absent or unreadable.""" + try: + return json.loads(Path(path).read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + + +def save_manifest(path, manifest): + """Write the segment manifest as pretty JSON.""" + Path(path).write_text(json.dumps(manifest, indent=2), encoding="utf-8") # --- encoder selection and disk preflight ------------------------------------ @@ -576,12 +743,19 @@ def bitrate_for(height): return 5000 -def encode_args(encoder, crf=18, height=1080): +AUDIO_ENCODE = ["-c:a", "aac", "-b:a", "192k"] + + +def encode_args(encoder, crf=18, height=1080, streams="av"): """Encode argv fragment for the final render. Hardware encoders take a bitrate from the ladder (no dependable CRF mode across drivers); libx264 takes -crf. -pix_fmt is not forced for nvenc/qsv/amf (each negotiates its own supported format from the yuv420p filtergraph output) nor for - vaapi (it receives hardware frames via the hwupload chain).""" + vaapi (it receives hardware frames via the hwupload chain). + + streams selects the fragment: "av" (default, video + aac audio, unchanged), + "video" (video only, for the persisted video segments), or "audio" (aac + only, for the whole-timeline audio pass).""" if is_hardware_encoder(encoder): v = ["-c:v", encoder, "-b:v", f"{bitrate_for(height)}k"] if encoder.endswith("_videotoolbox"): @@ -591,7 +765,11 @@ def encode_args(encoder, crf=18, height=1080): else: v = ["-c:v", encoder, "-crf", str(crf), "-preset", "medium", "-pix_fmt", "yuv420p"] - return v + ["-c:a", "aac", "-b:a", "192k"] + if streams == "video": + return v + if streams == "audio": + return list(AUDIO_ENCODE) + return v + list(AUDIO_ENCODE) def estimate_output_bytes(duration_s, height, encoder="libx264"): diff --git a/skills/mc-cut/scripts/render_final.py b/skills/mc-cut/scripts/render_final.py index da1740c..edb1244 100644 --- a/skills/mc-cut/scripts/render_final.py +++ b/skills/mc-cut/scripts/render_final.py @@ -9,6 +9,7 @@ uv run {skill-root}/scripts/render_final.py -o renders/final.mp4 \ [--project-dir ] [--beats beats/beats.md --graphics-dir graphics/] \ [--codec auto] [--crf 18] [--height ] [--parallel 2] \ + [--segment-target-seconds 600] [--no-cache] \ [--loudness-target -14] [--no-loudnorm] \ [--boundary-frames ] [--skip-disk-check] [--keep-temp] @@ -19,6 +20,12 @@ render_preview.py, so the composited preview the creator iterated on is exactly what the final bakes. + Incremental: the timeline is partitioned into persistent, content-addressed + render segments under renders/segments/, so a re-render only re-encodes the + segments whose inputs actually changed (a tweaked graphic, a re-cut region) + and reuses the rest from cache. Changing one overlay on a two-hour video is + a seconds-long re-render, not a full one. + Contract: input edl.json {source, fade_ms, pad_ms, segments[]} (seconds against each segment's source). Optional --beats beats/beats.md (the @@ -27,13 +34,27 @@ beat id (.mov ProRes 4444 alpha, or .webm/.mp4/.mkv/.png). Beats without a matching file are listed in the summary as overlays_missing, never fatal. - output an H.264 (or HEVC) mp4 at -o. The timeline is split into up to - --parallel chunks at internal cut boundaries that avoid every - overlay window; each chunk renders in its own ffmpeg process to - an MPEG-TS intermediate beside the output, then the chunks are - losslessly concatenated (concat demuxer, -c copy, - aac_adtstoasc, +faststart). Intermediates are removed unless - --keep-temp. + output an H.264 (or HEVC) mp4 at -o. The timeline is partitioned into + render segments with STABLE, sticky boundaries: greedy left to + right to --segment-target-seconds (default 600 = 10 min), each + snapped to the next safe cut that no overlay spans, so an edit + cannot move an earlier boundary. Each segment is content-addressed + by a hash of its inputs (its EDL slice, the digest of every source + and overlay file it consumes, and the resolved render identity) + and persisted VIDEO-ONLY to renders/segments/.ts. On each + render only the segments whose hash changed (or all of them when + the encoder/ffmpeg/dimensions change, since a mixed-encoder concat + is invalid) are re-encoded, via a bounded pool of --parallel + workers, each to a temp renamed into place only on success; the + rest are reused. The video segments are then losslessly + concatenated (concat demuxer, -c copy, +faststart) and the + whole-program audio, rebuilt fresh every render (video-only + segments never carry audio, so per-segment AAC seam drift cannot + accumulate), is muxed in (-c copy). renders/segments/manifest.json + records the render identity, each segment's hash, and the concat + order; --no-cache re-renders every segment; --keep-temp keeps the + concat/audio intermediates. Persisted segments survive across runs + as the cache; segments orphaned by a boundary shift are removed. encode --codec auto picks the platform's hardware ladder: on macOS h264_videotoolbox when this ffmpeg lists it (bitrate ladder by output height); on Windows the first of h264_nvenc, h264_qsv, @@ -49,7 +70,7 @@ loudnorm two-pass ffmpeg loudnorm on the finished file, final render only (the fast preview never normalizes): pass 1 measures (loudnorm print_format=json over the whole timeline, which is - why it runs after chunk concat, never per chunk), pass 2 + why it runs after the mux, never per segment), pass 2 re-encodes the audio with the measured values (linear mode, TP -1.5, LRA 11, aac 192k 48kHz) while the video stream is copied, then atomically replaces the output. Target is @@ -62,14 +83,15 @@ the render refuses with a clear message (--skip-disk-check overrides). Every looped image overlay input carries an explicit -t duration cap so looped/synthetic sources can never run away. - Progress lines print to stderr (aggregated across chunks, from - ffmpeg -progress). Expected vs actual duration is checked to - 0.5s, and --boundary-frames extracts before/after stills at - every internal cut for the boundary-frame inspection. - summary json.dumps on stdout: segments, chunks, encoder, overlays, - overlays_missing, expected/actual duration, loudnorm (null when - --no-loudnorm; else {target, applied, input_i, output_i, - output_tp}), output path. + Progress lines print to stderr (aggregated across the segment + pool, from ffmpeg -progress). Expected vs actual duration is + checked to 0.5s, and --boundary-frames extracts before/after + stills at every internal cut for the boundary-frame inspection. + summary json.dumps on stdout: segments (EDL segment count), + render_segments, segments_rendered, segments_cached, encoder, + overlays, overlays_missing, expected/actual duration, loudnorm + (null when --no-loudnorm; else {target, applied, input_i, + output_i, output_tp}), boundary_frames, output path. Exit codes: 0 ok, 1 failure, 2 usage. @@ -204,70 +226,101 @@ def run_loudnorm(output, target): "output_tp": stats.get("output_tp")} -def run_chunks(cmds, durations, total): - """Run the chunk ffmpeg commands in parallel, aggregating -progress output - into percent lines on stderr. Returns (return_codes, stderr_tails).""" - procs, readers = [], [] - progress = [0.0] * len(cmds) - tails = [""] * len(cmds) +def run_jobs(cmds, durations, total, workers, label="render_final", + noun="segment"): + """Run the ffmpeg commands with at most `workers` in flight at once, + aggregating -progress output into percent lines on stderr. A bounded pool + (not one process per job) so an incremental render of many dirty segments + never spawns dozens of concurrent ffmpegs. Returns (return_codes, + stderr_tails) aligned to cmds; an empty cmds list returns ([], []).""" + n = len(cmds) + if n == 0: + return [], [] + rcs = [None] * n + tails = [""] * n + progress = [0.0] * n lock = threading.Lock() - - def read_out(i, pipe): - for line in pipe: - info = core.parse_progress(line) - if "seconds" in info: - with lock: - progress[i] = min(info["seconds"], durations[i]) - pipe.close() - - def read_err(i, pipe): - tail = deque(maxlen=60) - for line in pipe: - tail.append(line) - tails[i] = "".join(tail) - pipe.close() - - for i, cmd in enumerate(cmds): - p = subprocess.Popen(cmd, stdout=subprocess.PIPE, - stderr=subprocess.PIPE, text=True) - procs.append(p) - for target, pipe in ((read_out, p.stdout), (read_err, p.stderr)): - t = threading.Thread(target=target, args=(i, pipe), daemon=True) + sem = threading.Semaphore(max(1, workers)) + + def run_one(i): + with sem: + p = subprocess.Popen(cmds[i], stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) + + def read_out(pipe): + for line in pipe: + info = core.parse_progress(line) + if "seconds" in info: + with lock: + progress[i] = min(info["seconds"], durations[i]) + pipe.close() + + t = threading.Thread(target=read_out, args=(p.stdout,), daemon=True) t.start() - readers.append(t) - - print(f"render_final: rendering {len(cmds)} chunk(s), {total:.1f}s of " - "timeline", file=sys.stderr) + tail = deque(maxlen=60) + for line in p.stderr: + tail.append(line) + p.stderr.close() + t.join(timeout=5) + rcs[i] = p.wait() + tails[i] = "".join(tail) + with lock: + progress[i] = durations[i] + + workers_threads = [threading.Thread(target=run_one, args=(i,), daemon=True) + for i in range(n)] + for t in workers_threads: + t.start() + print(f"{label}: rendering {n} {noun}(s), {total:.1f}s " + f"({min(max(1, workers), n)} at a time)", file=sys.stderr) last = -1 - while any(p.poll() is None for p in procs): + while any(t.is_alive() for t in workers_threads): time.sleep(0.5) with lock: done = sum(progress) pct = int(done / total * 100) if total else 0 if pct != last: - print(f"render_final: {pct}% ({done:.1f}/{total:.1f}s, " - f"{len(cmds)} chunk(s))", file=sys.stderr) + print(f"{label}: {pct}% ({done:.1f}/{total:.1f}s, {n} {noun}(s))", + file=sys.stderr) last = pct - for t in readers: - t.join(timeout=5) - return [p.wait() for p in procs], tails + for t in workers_threads: + t.join() + return rcs, tails -def concat_chunks(chunk_files, output): - """Losslessly concatenate the MPEG-TS chunks into the final mp4.""" +def concat_video(segment_files, output): + """Losslessly concatenate the video-only segment files into `output` + (concat demuxer, -c copy). No audio bitstream filter: the segments carry + no audio, the whole-program audio is muxed in afterward.""" list_file = output.parent / f".{output.stem}-concat.txt" lines = [] - for p in chunk_files: - quoted = str(p.resolve()).replace("'", "'\\''") + for p in segment_files: + quoted = str(Path(p).resolve()).replace("'", "'\\''") lines.append(f"file '{quoted}'\n") list_file.write_text("".join(lines), encoding="utf-8") cmd = ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(list_file), - "-c", "copy", "-bsf:a", "aac_adtstoasc", - "-movflags", "+faststart", str(output)] + "-c", "copy", "-movflags", "+faststart", str(output)] proc = subprocess.run(cmd, capture_output=True, text=True) list_file.unlink(missing_ok=True) if proc.returncode != 0: - print("ffmpeg concat failed:", file=sys.stderr) + print("ffmpeg video concat failed:", file=sys.stderr) + print(" ".join(cmd), file=sys.stderr) + print(proc.stderr.strip()[-2000:], file=sys.stderr) + return False + return True + + +def mux_av(video, audio, output): + """Mux the concatenated video-only file and the whole-program audio into + the final container, both stream-copied (no re-encode).""" + cmd = ["ffmpeg", "-y", "-i", str(video), "-i", str(audio), + "-c", "copy", "-map", "0:v:0", "-map", "1:a:0"] + if str(output).endswith((".mp4", ".mov")): + cmd += ["-movflags", "+faststart"] + cmd.append(str(output)) + proc = subprocess.run(cmd, capture_output=True, text=True) + if proc.returncode != 0: + print("ffmpeg mux failed:", file=sys.stderr) print(" ".join(cmd), file=sys.stderr) print(proc.stderr.strip()[-2000:], file=sys.stderr) return False @@ -292,7 +345,15 @@ def main(argv=None): parser.add_argument("--height", type=int, default=None, help="scale output to this height (default: source native)") parser.add_argument("--parallel", type=int, default=2, - help="max parallel render chunks (default 2)") + help="max segment renders in flight at once (worker " + "pool size, default 2)") + parser.add_argument("--segment-target-seconds", type=float, default=600.0, + help="target duration per persisted render segment; " + "boundaries snap to the next safe cut (default " + "600 = 10 min)") + parser.add_argument("--no-cache", action="store_true", + help="ignore any persisted segments and re-render every " + "one (the manifest is still rewritten)") parser.add_argument("--loudness-target", type=float, default=-14.0, help="two-pass loudnorm integrated target in LUFS " "(default -14)") @@ -377,58 +438,122 @@ def main(argv=None): file=sys.stderr) return 1 - enc = core.encode_args(encoder, crf=args.crf, height=out_h) progress_flags = ("-progress", "pipe:1", "-nostats") - chunks = core.plan_chunks(edl, overlays, args.parallel) - - if len(chunks) == 1: - cmd, _ = core.build_command(edl, project_dir, output, scale_height, - overlays=overlays, overlay_size=overlay_size, - encode=enc, extra_output_flags=progress_flags, - encoder=encoder, target=target, - audio_map=audio_map) - rcs, tails = run_chunks([cmd], [total], total) - if rcs[0] != 0: - print("ffmpeg render failed:", file=sys.stderr) - print(" ".join(cmd), file=sys.stderr) - print(tails[0].strip()[-2000:], file=sys.stderr) - return 1 - else: - cmds, files, durs = [], [], [] - for i, ch in enumerate(chunks): + enc_video = core.encode_args(encoder, crf=args.crf, height=out_h, + streams="video") + enc_audio = core.encode_args(encoder, streams="audio") + + # The persistent incremental segment cache lives beside the output. + segments_dir = output.parent / "segments" + segments_dir.mkdir(parents=True, exist_ok=True) + manifest_path = segments_dir / "manifest.json" + + segments = core.plan_segments(edl, overlays, args.segment_target_seconds) + + # Shared render identity: any change here (encoder swap, ffmpeg bump, + # output dimensions, encode args) dirties every segment, since a + # mixed-encoder -c copy concat would produce a broken file. + render_key = { + "encoder": encoder, + "ffmpeg": core.ffmpeg_version(), + "dims": [out_w, out_h], + "encode": enc_video, + } + # Sources are fingerprinted cheaply (size+mtime); overlay files are hashed + # by content so a re-rendered graphic dirties the segment that consumes it. + source_digests = {src: core.content_digest(project_dir / src, cheap=True) + for src in distinct} + overlay_digests = {ov["id"]: core.content_digest(ov["path"]) + for ov in overlays} + + prior = None if args.no_cache else core.load_manifest(manifest_path) + prior_by_id = {e["id"]: e for e in (prior or {}).get("segments", [])} + key_changed = bool(prior) and prior.get("render_key") != render_key + if key_changed: + print("render_final: render settings changed (encoder/ffmpeg/dims/" + "encode); re-rendering every segment", file=sys.stderr) + + # Resolve each render-segment's stable id, content hash, and cache status. + plan = [] + for seg in segments: + sid = core.segment_id(edl, seg) + ih = core.segment_input_hash(edl, seg, render_key, source_digests, + overlay_digests) + seg_file = segments_dir / f"{sid}.ts" + cached = (not key_changed + and prior_by_id.get(sid, {}).get("input_hash") == ih + and seg_file.is_file()) + plan.append({"seg": seg, "id": sid, "hash": ih, + "file": seg_file, "cached": cached}) + + # Render the dirty segments video-only (deduped by id), via the bounded + # worker pool, each to a temp renamed into place only on success. + jobs = {} + for item in plan: + if not item["cached"]: + jobs.setdefault(item["id"], item) + jobs = list(jobs.values()) + if jobs: + cmds, durs, temps = [], [], [] + for item in jobs: + seg = item["seg"] sub = { "source": edl.get("source"), "fade_ms": edl.get("fade_ms", 30), - "segments": edl["segments"][ch["seg_start"]:ch["seg_end"]], + "segments": edl["segments"][seg["seg_start"]:seg["seg_end"]], } - f = output.parent / f".{output.stem}-chunk{i}.ts" - cmd, _ = core.build_command(sub, project_dir, f, scale_height, - overlays=ch["overlays"], - overlay_size=overlay_size, - encode=enc, - extra_output_flags=progress_flags, - encoder=encoder, target=target, - audio_map=audio_map) + tmp = segments_dir / f".{item['id']}.tmp.ts" + cmd, _ = core.build_command( + sub, project_dir, tmp, scale_height, overlays=seg["overlays"], + overlay_size=overlay_size, encode=enc_video, + extra_output_flags=progress_flags, encoder=encoder, + target=target, streams="video") cmds.append(cmd) - files.append(f) - durs.append(ch["duration"]) - rcs, tails = run_chunks(cmds, durs, total) + durs.append(seg["duration"]) + temps.append(tmp) + rcs, tails = run_jobs(cmds, durs, sum(durs), args.parallel) if any(rcs): for i, rc in enumerate(rcs): if rc: - print(f"ffmpeg chunk {i} failed:", file=sys.stderr) + print(f"ffmpeg segment {jobs[i]['id']} failed:", + file=sys.stderr) print(" ".join(cmds[i]), file=sys.stderr) print(tails[i].strip()[-2000:], file=sys.stderr) - if not args.keep_temp: - for f in files: - f.unlink(missing_ok=True) - return 1 - ok = concat_chunks(files, output) - if not args.keep_temp: - for f in files: - f.unlink(missing_ok=True) - if not ok: + for tmp in temps: + tmp.unlink(missing_ok=True) return 1 + for item, tmp in zip(jobs, temps): + tmp.replace(item["file"]) + else: + print("render_final: all segments cached; skipping video re-render", + file=sys.stderr) + + # Rebuild the whole-program audio every render: cheap, and it keeps the + # timeline exact (per-segment AAC seam priming would accumulate drift). + audio_tmp = output.parent / f".{output.stem}-audio.m4a" + acmd, _ = core.build_command( + edl, project_dir, audio_tmp, None, encode=enc_audio, + extra_output_flags=progress_flags, audio_map=audio_map, + streams="audio") + arcs, atails = run_jobs([acmd], [total], total, 1, noun="audio pass") + if arcs[0] != 0: + print("ffmpeg audio render failed:", file=sys.stderr) + print(" ".join(acmd), file=sys.stderr) + print(atails[0].strip()[-2000:], file=sys.stderr) + audio_tmp.unlink(missing_ok=True) + return 1 + + # Losslessly concat the (cached + fresh) video segments, then mux the + # whole-program audio in. Both stream-copied, no re-encode. + video_tmp = output.parent / f".{output.stem}-video.mp4" + ok = concat_video([item["file"] for item in plan], video_tmp) + if ok: + ok = mux_av(video_tmp, audio_tmp, output) + if not args.keep_temp: + video_tmp.unlink(missing_ok=True) + audio_tmp.unlink(missing_ok=True) + if not ok: + return 1 loudnorm = None if not args.no_loudnorm: @@ -443,9 +568,38 @@ def main(argv=None): boundary_count = core.extract_boundary_frames( output, edl, Path(args.boundary_frames)) + # Record the manifest (only on a successful render) and GC segment files + # left orphaned by boundary shifts. One manifest entry per unique id. + seen_ids, manifest_segments = set(), [] + for item in plan: + if item["id"] in seen_ids: + continue + seen_ids.add(item["id"]) + off = item["seg"]["offset"] + manifest_segments.append({ + "id": item["id"], + "input_hash": item["hash"], + "file": f"segments/{item['id']}.ts", + "edl_range": [round(off, 3), round(off + item["seg"]["duration"], 3)], + "duration": round(item["seg"]["duration"], 3), + }) + core.save_manifest(manifest_path, { + "render_key": render_key, + "segment_target_seconds": args.segment_target_seconds, + "loudnorm": loudnorm, + "segments": manifest_segments, + "concat_order": [item["id"] for item in plan], + }) + for stale in segments_dir.glob("seg-*.ts"): + if stale.stem not in seen_ids: + stale.unlink(missing_ok=True) + + rendered = sum(1 for item in plan if not item["cached"]) summary = { "segments": len(edl["segments"]), - "chunks": len(chunks), + "render_segments": len(plan), + "segments_rendered": rendered, + "segments_cached": len(plan) - rendered, "encoder": encoder, "overlays": len(overlays), "overlays_missing": missing, diff --git a/skills/mc-cut/scripts/tests/test-render_final.py b/skills/mc-cut/scripts/tests/test-render_final.py index b0c3758..2f5d958 100644 --- a/skills/mc-cut/scripts/tests/test-render_final.py +++ b/skills/mc-cut/scripts/tests/test-render_final.py @@ -106,43 +106,166 @@ def edl_4x10(): return {"source": "raw/a.mp4", "fade_ms": 30, "segments": segs} -class TestPlanChunks(unittest.TestCase): - def test_even_split_no_overlays(self): - chunks = core.plan_chunks(edl_4x10(), parallel=2) - self.assertEqual(len(chunks), 2) - self.assertEqual(chunks[0]["seg_start"], 0) - self.assertEqual(chunks[0]["seg_end"], 2) - self.assertEqual(chunks[0]["duration"], 20.0) - self.assertEqual(chunks[1]["offset"], 20.0) - self.assertEqual(chunks[1]["duration"], 20.0) - - def test_split_avoids_overlay_windows(self): +def edl_n(n, keep=10.0, gap=10.0): + """n single-source segments of `keep` seconds, `gap` apart in the source.""" + segs = [{"source": "raw/a.mp4", "start": i * (keep + gap), + "end": i * (keep + gap) + keep} for i in range(n)] + return {"source": "raw/a.mp4", "fade_ms": 30, "segments": segs} + + +class TestPlanSegments(unittest.TestCase): + def test_target_floor_greedy_split(self): + # 4x10s, target 25s: accumulate until >=25, cut at next boundary. + segs = core.plan_segments(edl_4x10(), target_seconds=25.0) + self.assertEqual([(s["seg_start"], s["seg_end"]) for s in segs], + [(0, 3), (3, 4)]) + self.assertEqual(segs[0]["duration"], 30.0) + self.assertEqual(segs[1]["offset"], 30.0) + + def test_short_timeline_is_one_segment(self): + # Never reaches the target -> a single render-segment. + segs = core.plan_segments(edl_4x10(), target_seconds=600.0) + self.assertEqual(len(segs), 1) + self.assertEqual(segs[0]["seg_start"], 0) + self.assertEqual(segs[0]["seg_end"], 4) + + def test_boundary_snaps_past_overlay_span(self): + # target 15s would cut after seg1 (boundary at 20s), but an overlay + # spans 20s, so the cut snaps to the next safe boundary (30s). ov = [{"index": 1, "start": 19.0, "dur": 2.0, "image": True, "path": "g/x.png", "id": "x"}] - chunks = core.plan_chunks(edl_4x10(), ov, parallel=2) - self.assertEqual(len(chunks), 2) - # boundary 20 is inside the overlay; 10 and 30 are the valid picks - split = chunks[0]["duration"] - self.assertIn(split, (10.0, 30.0)) - # the overlay lands whole in exactly one chunk, with a local start - owners = [c for c in chunks if c["overlays"]] + segs = core.plan_segments(edl_4x10(), ov, target_seconds=15.0) + self.assertEqual(segs[0]["duration"], 30.0) + owners = [s for s in segs if s["overlays"]] self.assertEqual(len(owners), 1) - local = owners[0]["overlays"][0] - self.assertEqual(local["start"], 19.0 - owners[0]["offset"]) - - def test_parallel_one_and_cap(self): - self.assertEqual(len(core.plan_chunks(edl_4x10(), parallel=1)), 1) - edl = {"segments": [{"source": "s", "start": 0.0, "end": 5.0}]} - self.assertEqual(len(core.plan_chunks(edl, parallel=4)), 1) - - def test_chunks_cover_everything_in_order(self): - chunks = core.plan_chunks(edl_4x10(), parallel=3) - self.assertEqual(chunks[0]["seg_start"], 0) - self.assertEqual(chunks[-1]["seg_end"], 4) - for a, b in zip(chunks, chunks[1:]): + self.assertEqual(owners[0]["overlays"][0]["start"], + 19.0 - owners[0]["offset"]) + + def test_segments_cover_everything_in_order(self): + segs = core.plan_segments(edl_n(6), target_seconds=25.0) + self.assertEqual(segs[0]["seg_start"], 0) + self.assertEqual(segs[-1]["seg_end"], 6) + for a, b in zip(segs, segs[1:]): self.assertEqual(a["seg_end"], b["seg_start"]) self.assertAlmostEqual(a["offset"] + a["duration"], b["offset"]) + def test_empty_edl(self): + self.assertEqual(core.plan_segments({"segments": []}), []) + + def test_earlier_boundaries_survive_a_late_edit(self): + # The stability guarantee: editing a late segment must not move any + # earlier render-segment boundary (so its cache survives). + base = edl_n(9) + before = core.plan_segments(base, target_seconds=25.0) + edited = edl_n(9) + edited["segments"][7]["end"] -= 3.0 # trim a late segment + after = core.plan_segments(edited, target_seconds=25.0) + # every render-segment that ends before the edited index is identical + early_before = [(s["seg_start"], s["seg_end"]) for s in before + if s["seg_end"] <= 7] + early_after = [(s["seg_start"], s["seg_end"]) for s in after + if s["seg_end"] <= 7] + self.assertEqual(early_before, early_after) + self.assertTrue(early_before) # there is at least one such segment + + +class TestSegmentIdentity(unittest.TestCase): + def seg(self, edl, a, b): + return {"seg_start": a, "seg_end": b, "offset": 0.0, + "duration": 0.0, "overlays": []} + + def test_id_is_position_independent(self): + # Same slice content at two different EDL positions -> same id. + e1 = edl_n(4) + e2 = edl_n(6) # segments 2..4 of e2 equal segments 0..2 of a shifted... + # Build an explicit match: slice (0,2) of e1 vs a slice of e2 whose + # (source,start,end) tuples are identical. + s1 = self.seg(e1, 0, 2) + e3 = {"source": "raw/a.mp4", "fade_ms": 30, + "segments": [{"source": "x", "start": 5.0, "end": 6.0}] + + e1["segments"][0:2]} + s3 = self.seg(e3, 1, 3) # same two segments as s1, later position + self.assertEqual(core.segment_id(e1, s1), core.segment_id(e3, s3)) + + def test_id_changes_with_content(self): + e = edl_n(4) + s = self.seg(e, 0, 2) + e2 = edl_n(4) + e2["segments"][1]["end"] += 1.0 + self.assertNotEqual(core.segment_id(e, s), + core.segment_id(e2, self.seg(e2, 0, 2))) + + def test_input_hash_reflects_overlay_file_and_render_key(self): + e = edl_n(4) + seg = {"seg_start": 0, "seg_end": 2, "offset": 0.0, "duration": 20.0, + "overlays": [{"id": "b1", "start": 1.0, "dur": 0.5, + "image": True, "path": "g/b1.png"}]} + rk = {"encoder": "libx264", "ffmpeg": "8.1", "dims": [1920, 1080], + "encode": ["-c:v", "libx264", "-crf", "18"]} + sd = {"raw/a.mp4": "size:1:mtime:1"} + base = core.segment_input_hash(e, seg, rk, sd, {"b1": "digestA"}) + # A re-rendered overlay file (new digest) dirties the segment. + moved = core.segment_input_hash(e, seg, rk, sd, {"b1": "digestB"}) + self.assertNotEqual(base, moved) + # An encoder/render-key change dirties the segment. + rk2 = dict(rk, encoder="h264_videotoolbox") + self.assertNotEqual( + base, core.segment_input_hash(e, seg, rk2, sd, {"b1": "digestA"})) + # A source content change dirties the segment. + self.assertNotEqual( + base, core.segment_input_hash( + e, seg, rk, {"raw/a.mp4": "size:2:mtime:9"}, {"b1": "digestA"})) + # Same inputs -> stable hash. + self.assertEqual( + base, core.segment_input_hash(e, seg, rk, sd, {"b1": "digestA"})) + + def test_input_hash_ignores_timeline_offset(self): + # The same segment content at a different output offset hashes the same + # (offset is derived, never hashed) as long as overlay-local starts and + # slice content are unchanged. + e = edl_n(4) + rk = {"encoder": "libx264", "ffmpeg": "8.1", "dims": [10, 10], + "encode": []} + sd = {"raw/a.mp4": "d"} + a = {"seg_start": 0, "seg_end": 2, "offset": 0.0, "duration": 20.0, + "overlays": []} + b = {"seg_start": 0, "seg_end": 2, "offset": 999.0, "duration": 20.0, + "overlays": []} + self.assertEqual(core.segment_input_hash(e, a, rk, sd, {}), + core.segment_input_hash(e, b, rk, sd, {})) + + +class TestManifestRoundTrip(unittest.TestCase): + def test_save_and_load(self): + with tempfile.TemporaryDirectory() as tmp: + p = Path(tmp) / "manifest.json" + m = {"render_key": {"encoder": "libx264"}, + "segments": [{"id": "seg-abc", "input_hash": "sha256:x"}]} + core.save_manifest(p, m) + self.assertEqual(core.load_manifest(p), m) + + def test_load_missing_or_bad_is_none(self): + with tempfile.TemporaryDirectory() as tmp: + self.assertIsNone(core.load_manifest(Path(tmp) / "nope.json")) + bad = Path(tmp) / "bad.json" + bad.write_text("{not json") + self.assertIsNone(core.load_manifest(bad)) + + +class TestContentDigest(unittest.TestCase): + def test_cheap_vs_content_and_missing(self): + with tempfile.TemporaryDirectory() as tmp: + f = Path(tmp) / "a.bin" + f.write_bytes(b"hello") + cheap = core.content_digest(f, cheap=True) + full = core.content_digest(f, cheap=False) + self.assertTrue(cheap.startswith("size:5:mtime:")) + self.assertTrue(full.startswith("sha256:")) + # content change moves the sha256 digest + f.write_bytes(b"hello world") + self.assertNotEqual(full, core.content_digest(f)) + self.assertEqual(core.content_digest(Path(tmp) / "gone"), "missing") + def never_probe(encoder): raise AssertionError(f"probe must not be called (got {encoder!r})") @@ -465,7 +588,8 @@ def test_non_ascii_edl_reads_under_non_utf8_locale(self): @unittest.skipUnless(FFMPEG, "ffmpeg/ffprobe not installed") class TestEndToEnd(unittest.TestCase): """Synthesized-fixture render: a 4s test source, a two-segment EDL, one - PNG overlay from a beat table, two parallel chunks, boundary frames.""" + PNG overlay from a beat table, the incremental segment cache (fresh render + then cache hit), boundary frames.""" @classmethod def setUpClass(cls): @@ -509,44 +633,77 @@ def setUpClass(cls): def tearDownClass(cls): cls.tmp.cleanup() - def test_parallel_composited_render(self): - out = self.proj / "renders" / "final.mp4" + def render(self, subdir, *extra): + """Run render_final into its own output dir (so each test's segment + cache is isolated) and return the parsed summary + the run result.""" + outdir = self.proj / "renders" / subdir + out = outdir / "final.mp4" r = run_cli([str(self.proj / "cut" / "edl.json"), "-o", str(out), - "--beats", str(self.proj / "beats.md"), - "--graphics-dir", str(self.proj / "graphics"), - "--codec", "libx264", "--crf", "30", "--parallel", "2", - "--boundary-frames", str(self.proj / "renders" / "bf")]) + "--codec", "libx264", "--crf", "30", *extra]) + return r, out, outdir + + def test_incremental_composited_render_then_cache_hit(self): + args = ["--beats", str(self.proj / "beats.md"), + "--graphics-dir", str(self.proj / "graphics"), + "--parallel", "2", "--segment-target-seconds", "0.5"] + # First render: a two-segment timeline, both encoded fresh. + r, out, outdir = self.render("comp", *args, + "--boundary-frames", str(self.proj / "bf")) self.assertEqual(r.returncode, 0, r.stderr) - summary = json.loads(r.stdout) - self.assertEqual(summary["segments"], 2) - self.assertEqual(summary["chunks"], 2) - self.assertEqual(summary["overlays"], 1) - self.assertEqual(summary["overlays_missing"], ["b9"]) + s = json.loads(r.stdout) + self.assertEqual(s["segments"], 2) + self.assertEqual(s["render_segments"], 2) + self.assertEqual(s["segments_rendered"], 2) + self.assertEqual(s["segments_cached"], 0) + self.assertEqual(s["overlays"], 1) + self.assertEqual(s["overlays_missing"], ["b9"]) self.assertTrue(out.is_file()) - self.assertAlmostEqual(summary["actual_duration_seconds"], 2.0, - delta=0.5) - self.assertEqual(summary["boundary_frames"], 2) - # loudnorm ran by default, post-concat, against the default target - self.assertEqual(summary["loudnorm"]["target"], -14.0) - self.assertTrue(summary["loudnorm"]["applied"]) - # progress lines reached stderr + self.assertAlmostEqual(s["actual_duration_seconds"], 2.0, delta=0.5) + self.assertEqual(s["boundary_frames"], 2) + self.assertEqual(s["loudnorm"]["target"], -14.0) + self.assertTrue(s["loudnorm"]["applied"]) self.assertIn("render_final:", r.stderr) - # chunk intermediates and loudnorm temp were cleaned up - self.assertEqual(list((self.proj / "renders").glob("*.ts")), []) - self.assertEqual(list((self.proj / "renders").glob(".*loudnorm*")), []) - - def test_single_chunk_plain_render(self): - out = self.proj / "renders" / "plain.mp4" - r = run_cli([str(self.proj / "cut" / "edl.json"), "-o", str(out), - "--codec", "libx264", "--crf", "30", "--parallel", "1", - "--no-loudnorm"]) + # Segments persisted; the manifest is written; no stray temps. + segs = sorted((outdir / "segments").glob("seg-*.ts")) + self.assertEqual(len(segs), 2) + self.assertTrue((outdir / "segments" / "manifest.json").is_file()) + self.assertEqual(list(outdir.glob("*.ts")), []) # no temp .ts + self.assertEqual(list(outdir.glob(".*")), []) # no dotfile temps + + # Second render, nothing changed: every segment is served from cache. + r2, out2, _ = self.render("comp", *args) + self.assertEqual(r2.returncode, 0, r2.stderr) + s2 = json.loads(r2.stdout) + self.assertEqual(s2["segments_rendered"], 0) + self.assertEqual(s2["segments_cached"], 2) + self.assertAlmostEqual(s2["actual_duration_seconds"], 2.0, delta=0.5) + self.assertIn("all segments cached", r2.stderr) + + def test_single_segment_plain_render(self): + # Default 600s target over a 2s timeline: one render-segment, no cache + # hit, no audio-less handling; plain (no overlays), loudnorm opted out. + r, out, _ = self.render("plain", "--parallel", "1", "--no-loudnorm") self.assertEqual(r.returncode, 0, r.stderr) - summary = json.loads(r.stdout) - self.assertEqual(summary["chunks"], 1) - self.assertEqual(summary["overlays"], 0) - self.assertIsNone(summary["loudnorm"]) # explicit opt-out + s = json.loads(r.stdout) + self.assertEqual(s["render_segments"], 1) + self.assertEqual(s["segments_rendered"], 1) + self.assertEqual(s["overlays"], 0) + self.assertIsNone(s["loudnorm"]) # explicit opt-out self.assertTrue(out.is_file()) + def test_no_cache_forces_full_rerender(self): + args = ["--parallel", "2", "--segment-target-seconds", "0.5", + "--no-loudnorm"] + r, _, _ = self.render("nocache", *args) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertEqual(json.loads(r.stdout)["segments_rendered"], 2) + # Re-render with --no-cache: nothing served from cache. + r2, _, _ = self.render("nocache", *args, "--no-cache") + self.assertEqual(r2.returncode, 0, r2.stderr) + s2 = json.loads(r2.stdout) + self.assertEqual(s2["segments_rendered"], 2) + self.assertEqual(s2["segments_cached"], 0) + class TestMultiSourceCommand(unittest.TestCase): """Pure command/filtergraph construction for the mixed-source cases that @@ -599,8 +756,9 @@ class TestMixedSourcesEndToEnd(unittest.TestCase): """Real renders of the cases that raised 'Input link parameters do not match' and 'Stream specifier :a matches no streams': a 16:9 cam with 44.1k audio spliced with a 4:3 screen recording that has no audio at all. Both - the frame-size normalization and the silence synthesis are exercised, over - a two-chunk parallel render so the chunk concat is validated too.""" + the frame-size normalization (video segment pass) and the silence synthesis + (whole-program audio pass, audio-less source referenced twice) are + exercised end to end through the segment/concat/mux path.""" @classmethod def setUpClass(cls): From e8ec683ce010fd9d81ceccef0e236bdac34c92f4 Mon Sep 17 00:00:00 2001 From: Brian Madison Date: Thu, 23 Jul 2026 07:40:59 -0500 Subject: [PATCH 5/8] Install and favor HyperFrames' Agent Skills; surface its full capability map mc-graphics installs HyperFrames' own Agent Skills on the first graphics run alongside the engine (npx hyperframes skills update, or npx skills add heygen-com/hyperframes --all --full-depth for the whole catalog) and refreshes them on later runs (npx hyperframes init). The agent now authors from HyperFrames' current, self-refreshing knowledge of the engine instead of a hand-written summary that goes stale, and sources from the catalog and skills before authoring anything by hand. engines/hyperframes.md is rewritten as a durable capability map (categories, not a transcribed catalog that would rot pre-1.0): the 100+ block catalog plus footage-facing effects the pipeline did not surface before (color grading with .cube LUTs/grain/vignette, background removal, HTML-in-Canvas WebGL) and its delivery reach (HDR10, 4K, MOV/WebM/GIF/PNG), deferring detail to the installed skills and llms.txt. Local-first is explicit: the hosted conveniences (cloud render credits, Studio, Claude Design, Figma, Lambda/Cloud Run) stay optional and are never a dependency; no HeyGen account is required. check_deps npx descriptor updated; CHANGELOG entry added. --- CHANGELOG.md | 1 + skills/mc-graphics/SKILL.md | 4 +-- skills/mc-graphics/engines/hyperframes.md | 37 +++++++++++++++++------ skills/mc-setup/scripts/check_deps.py | 2 +- 4 files changed, 32 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0841138..de04619 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ All notable changes to BMad Manticore are documented here. Dates are ISO (YYYY-M ### Added - The final render is incremental. The timeline is partitioned into content-addressed segments (greedy, sticky boundaries snapped to the next safe cut, ~10 min target tunable via `--segment-target-seconds`) persisted video-only under `renders/segments/` alongside a `manifest.json`. Each re-render hashes every segment's real inputs (its EDL slice, the content digest of every source and overlay file it consumes, and the resolved encoder, ffmpeg version, and output dimensions) and re-encodes only the segments that actually changed, reusing the rest from cache; the video segments are then losslessly concatenated (`-c copy`) and the whole-program audio, rebuilt every pass so the timeline stays exact and per-segment AAC seam drift can never accumulate, is muxed in. A tweaked graphic or a re-cut region on a long video re-renders in seconds instead of end to end. Boundaries are stable: because a segment's identity is its content, not its position, an edit leaves earlier segments (and unshifted later ones) cached. Changing the encoder, ffmpeg version, or output dimensions dirties every segment (a mixed-encoder concat is invalid); `--no-cache` forces a full re-render. Dirty segments render through a bounded worker pool. Encoder selection, the hardware ladders, the disk preflight, boundary-frame checks, and the -14 LUFS loudnorm pass are unchanged; the preview render is untouched. +- HyperFrames' own Agent Skills are installed and favored. On the first graphics run mc-graphics installs the skills alongside the engine (`npx hyperframes skills update`, or `npx skills add heygen-com/hyperframes --all --full-depth` for the whole catalog) and refreshes them on later runs (`npx hyperframes init`), so the agent authors from HyperFrames' current, self-refreshing knowledge of the engine instead of a hand-written summary that goes stale. The engine reference (`skills/mc-graphics/engines/hyperframes.md`) is rewritten as a durable capability map that names what is reachable and defers the detail to the installed skills and https://hyperframes.heygen.com/llms.txt: the 100+ block catalog (code animations, WebGL shader transitions, caption styles, lower thirds, social cards, data-viz maps, VFX, 3D device mockups) plus footage-facing effects the pipeline did not surface before (color grading with `.cube` LUTs, grain, and vignette; background removal; HTML-in-Canvas WebGL) and its delivery reach (HDR10, 4K, MOV/WebM/GIF/PNG). It all runs on the local CLI and render path; the hosted conveniences (HeyGen cloud-render credits, the Studio web app, Claude Design, Figma import, Lambda/Cloud Run) stay optional and are never a dependency, and no HeyGen account is required. mc-graphics now sources from the catalog and skills before authoring anything by hand. - Final renders are loudness-normalized by default to -14 LUFS (two-pass ffmpeg loudnorm, TP -1.5, LRA 11; audio-only second pass with the video stream copied), configurable via `[render]` loudness-target and disable-able via `[render]` loudnorm = false or `--no-loudnorm`. Preview renders are never normalized. Silent audio skips the pass with a warning instead of failing. - mc-package emits uploadable captions and a publishable transcript from the edited timeline: the new stdlib-only captions.py maps each EDL segment's word spans onto output-timeline times (reordered and multi-source edits included) and writes packaging/captions/final.srt, final.vtt, and transcript.md. Caption defaults: 42 chars per line, 2 lines, 1 to 7 s cues, splits at sentence ends, pauses, and cuts. A light filler/stutter cleanup applies to the caption rendition only (`--no-clean` keeps verbatim); transcript/words.json is never modified. - mc-stream-pack produces and verifies the OBS WebM VP9 alpha deliverable from a ProRes 4444 master in one command (render_verify.py `--transcode-webm`, with `--webm-crf`), with clear errors when ffmpeg lacks libvpx-vp9 or the master has no alpha, plus a pixfmt-failure hint carrying the exact re-encode flags. The stream-pack copy of render_verify.py is now a superset of mc-graphics' copy. diff --git a/skills/mc-graphics/SKILL.md b/skills/mc-graphics/SKILL.md index f33ae5a..84d2229 100644 --- a/skills/mc-graphics/SKILL.md +++ b/skills/mc-graphics/SKILL.md @@ -8,8 +8,8 @@ description: Execute the approved beat table in HyperFrames/OGraf/HTML, render, ## Steps 1. Load the studio config (`uv run {project-root}/_bmad/scripts/resolve_config.py --project-root {project-root} --key modules.manticore`; empty means mc-setup has not run: stop and route the creator there) and this skill's own surface (`uv run {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root}`; run `{workflow.activation_steps_prepend}` now, `{workflow.activation_steps_append}` after this step, and hold `{workflow.persistent_facts}` as standing context). Resolve `paths` values against `{project-root}`. Read `project.json` (confirm `approvals.beats` is a date, stage `graphics`), `beats/beats.md`, `beats/STORYBOARD.md`, `{brand-path}/tokens.json`, `{brand-path}/production-bible.md` (the styling contract beyond tokens.json: overlay aesthetic, motion feel, image-type policy, placement rules), the format profile, and `{skill-root}/engines/.md` for each engine the table names. Beats marked OGraf route through the mc-ograf skill, and ONLY if `[editor] ograf-editable = true`; otherwise build them as baked alpha overlays like everything else (baked alpha works in every editor). -2. Engine workspaces live at `{engines-path}//`; initialize on first use per the engine README, installing the latest published version at that moment and recording what it resolved. -3. Source before authoring: for each beat, check the engine registry/library for a fitting block (`npx hyperframes add`, existing brand-themed blocks in the engine workspace); for simple moves on a finished still (fly-in and fly-out, staged builds), prefer the ffmpeg recipes in `{skill-root}/references/motion-recipes.md`; when nothing fits, author from scratch via the html lane (`{skill-root}/engines/html.md`) or the design-prompting loop (`{skill-root}/engines/design-prompting.md`). Everything themes through tokens.json, no hardcoded colors or fonts. +2. Engine workspaces live at `{engines-path}//`; initialize on first use per the engine README, installing the latest published version at that moment and recording what it resolved. For HyperFrames this also installs and favors its Agent Skills (`npx hyperframes skills update`, or `npx skills add heygen-com/hyperframes --all --full-depth` for the whole catalog) and refreshes them on later runs (`npx hyperframes init`); the skills are the agent's current, self-refreshing knowledge of the engine's authoring patterns and full capability surface, so nothing here transcribes a catalog that would go stale. +3. Source before authoring, always: for each beat, reach first for a fitting HyperFrames block or skill (`npx hyperframes add`, the installed skills, existing brand-themed blocks in the engine workspace) across the whole catalog (captions, transitions, lower thirds, social cards, data viz, VFX, device mockups) and its footage-facing effects (color grading, background removal, HTML-in-Canvas), per `{skill-root}/engines/hyperframes.md`; for simple moves on a finished still (fly-in and fly-out, staged builds), prefer the ffmpeg recipes in `{skill-root}/references/motion-recipes.md`; author from scratch via the html lane (`{skill-root}/engines/html.md`) or the design-prompting loop (`{skill-root}/engines/design-prompting.md`) only when nothing fits. Everything themes through tokens.json, no hardcoded colors or fonts. 4. Build per engine in the project's `graphics/` folder. Follow the loop: edit, lint, preview, draft render (CRF 28), single-frame verify, final render. The shipped toolkit does the mechanical parts: `{skill-root}/scripts/html_to_png.py` (exact-size HTML render, separate `--guides` pass, alpha verify) and `{skill-root}/scripts/snug_frame.py` (native-aspect photo framing). Sound: when a composition calls for a whoosh, hit, chime, or bed (the animation-feel conventions in the Production Bible say when), route through the mc-audio service skill (never reach into its folder) and deliver the wav into `graphics/` next to the overlay it belongs to, with its timing noted in HANDOFF.md. 5. Verify every final render with `uv run {skill-root}/scripts/render_verify.py`, passing expectations explicitly: `--pixfmt` per the delivery target, `--expect-dur` from the beat's dur, `--expect-fps` and `--expect-res` from the format profile, or `--meta` pointing at the comp's meta.json render contract carrying the same keys (extracted frames visually checked over checkerboard for alpha). A render without checked frames is not done. 6. Self-review gate before presenting any batch: zoom-inspect every asset (read every string, check edges and alpha fringes), check each against the Production Bible's aesthetic language, and ask of each one "is this the best you could do?". Fix what fails before the creator sees anything. diff --git a/skills/mc-graphics/engines/hyperframes.md b/skills/mc-graphics/engines/hyperframes.md index 5dcebfa..88e30af 100644 --- a/skills/mc-graphics/engines/hyperframes.md +++ b/skills/mc-graphics/engines/hyperframes.md @@ -1,30 +1,49 @@ # Engine: HyperFrames -Default engine for per-video overlay beats, stingers, and karaoke captions. Apache 2.0, fully local, free: `npx hyperframes render` drives HTML/CSS/GSAP frame-by-frame in headless Chrome. Export overlay-only ProRes 4444 MOV with alpha (their docs recommend exactly this for Resolve workflows). The hosted MCP/HeyGen credits are optional convenience; the pipeline never depends on them. +Default engine for everything the pipeline renders as motion: per-video overlay beats, stingers, karaoke captions, and footage-facing effects. Apache 2.0, fully local, free. `npx hyperframes render` drives HTML/CSS/GSAP frame-by-frame in headless Chrome and exports overlay-only ProRes 4444 MOV with alpha (their docs recommend exactly this for Resolve workflows). Everything the pipeline uses is the local CLI and render path. The hosted conveniences (HeyGen cloud-render credits, the Studio web app, Claude Design, Figma import, AWS Lambda / Cloud Run) are never a dependency, and no HeyGen account is required. -## Why this engine and not Remotion +## The skills are the source of truth (install them, favor them) -Remotion was the module's second engine through 0.x and was removed on 2026-07-22. Two reasons, recorded here so the decision is not relitigated per video: +HyperFrames is pre-1.0 and moves fast, so this file never transcribes its full capability list or pins a version, which would only ship stale knowledge to every creator. Instead the agent loads HyperFrames' own Agent Skills, which teach the current authoring patterns (the `data-*` attributes, GSAP timeline registration, the component vocabulary, every effect) and refresh themselves: -- License. Remotion is free only for companies of up to 3 people; past that it needs a paid Company License (per-seat, or per-render with a monthly minimum). Manticore is a distributed module, so shipping Remotion would hand every creator at a 4+ person company a licensing obligation they did not opt into. HyperFrames is Apache 2.0 with no commercial-use threshold. -- React bought nothing. Remotion's remaining justification was "anything React-stateful", but in a frame-deterministic renderer state IS a function of frame index. Every job Remotion held here (the dual-render brand stinger, word-level karaoke captions, plain HTML/SVG comps) is a paused GSAP timeline in HyperFrames, and the beat table drove both engines identically. One engine means one authoring model and one thing for the agent to know. +- Install on first graphics use, alongside the engine workspace: `npx hyperframes skills update` installs the maintained core set; `npx skills add heygen-com/hyperframes --all --full-depth` loads the whole catalog so the agent knows every capability. Favor them: the installed skills plus the block catalog are the FIRST place to look for any beat, ahead of authoring anything by hand. Record what resolved. +- Refresh, never blindly expand: `npx hyperframes init` refreshes the core set plus whatever is already installed; `npx hyperframes skills check` reports drift and `npx hyperframes skills update` applies it. The harness loads the skills by its own skill resolution; nothing here is specific to one agent. +- The authoritative, always-current capability index is https://hyperframes.heygen.com/llms.txt. When a beat needs something, consult the installed skills and that index rather than this file. -Remotion remains the stronger pick for React shops rendering at massive scale. That is not this pipeline. +## What it can do (reach for these before building from scratch) + +All of the following run locally and free. Names are examples, not the live list (the skills and the index above are canonical); the point is that these categories exist and the agent should reach for them: + +- Block catalog (`npx hyperframes add`, 100+ blocks): code animations (typing, diff, morph, 3D extrude, particle assemble), transitions including WebGL shaders (whip pan, glitch, light leak, iris, vortex, burn), caption styles (karaoke, kinetic slam, neon, gradient, texture-mask), lower thirds, social cards (X, TikTok, Instagram, Reddit, Spotify), data viz (bar and line charts, flowcharts, US/world/choropleth maps), VFX (liquid glass, portal, shatter, news ticker, logo outro), and 3D device showcases (GLTF iPhone/MacBook with live HTML screens). Theme every block through tokens.json. +- Footage-facing media effects (these apply to the video and stills, not only to overlays): color grading with presets, project-local `.cube` LUTs, vignette, grain, blur, and pixelate via a `data-color-grading` attribute; background removal to a transparent overlay; HTML-in-Canvas to run WebGL shaders and 3D geometry over the DOM. These open real pipeline moves without leaving the local renderer: propose a few graded looks over the actual footage and let the creator pick, or cut a subject off its background. +- Delivery reach: renders to ProRes 4444 MOV, VP9 alpha WebM, MP4, GIF, and PNG sequences; HDR10 (BT.2020 PQ or HLG, 10-bit H.265) when the sources are HDR; 4K via the Chrome device scale factor. ## Setup -- The engine workspace lives at the creator's `{engines-path}/hyperframes/`, initialized on first graphics run. Install the latest published version at that moment (`npm install hyperframes@latest`); never carry a version number in module docs, which only ships a stale pin to every new creator. Upstream is pre-1.0 and moves fast, so record the version the install actually resolved in the workspace's package.json and upgrade deliberately from there rather than floating mid-project. -- Pull registry blocks before authoring anything: `npx hyperframes add` (50+ blocks: caption styles, lower thirds, transitions, dataviz). Theme every block through `{brand-path}/tokens.json`. -- `@hyperframes/studio` is the timeline GUI; bidirectional sync with the HTML source is real (drag a beat in the GUI, the code updates; hand-edit the code, the GUI hot-reloads). Use it for timing nudges after mc-graphics gets close. +- The engine workspace lives at the creator's `{engines-path}/hyperframes/`, initialized on the first graphics run: install the latest published version at that moment (`npm install hyperframes@latest`) and install the skills (above). Never carry a version number in module docs. Record the resolved engine version in the workspace package.json and upgrade deliberately from there rather than floating mid-project. +- Pull the blocks a beat needs before authoring: `npx hyperframes add`. Theme every block through `{brand-path}/tokens.json`. +- `@hyperframes/studio` is the local timeline GUI with real bidirectional HTML sync (drag a beat in the GUI, the code updates; hand-edit the code, the GUI hot-reloads). Use it for timing nudges after mc-graphics gets close. The hosted Studio preview and Claude Design are optional and never required. + +## Why this engine and not Remotion + +Remotion is not used. Two reasons, recorded here so the decision is not relitigated per video: + +- License. Remotion is free only for companies of up to 3 people; past that it needs a paid Company License (per-seat, or per-render with a monthly minimum). Manticore is a distributed module, so shipping Remotion would hand every creator at a 4+ person company a licensing obligation they did not opt into. HyperFrames is Apache 2.0 with no commercial-use threshold. +- React bought nothing. Remotion's remaining justification was "anything React-stateful", but in a frame-deterministic renderer state IS a function of frame index. Every job it might hold here (the dual-render brand stinger, word-level karaoke captions, plain HTML/SVG comps) is a paused GSAP timeline in HyperFrames, and the beat table drives it identically. One engine means one authoring model and one thing for the agent to know. + +Remotion remains the stronger pick for React shops rendering at massive scale. That is not this pipeline. ## Jobs - Per-video overlay beats: the default lane, delivered as ProRes 4444 alpha MOVs. - Brand stinger and transitions: ONE composition rendered twice, VP9 yuva420p WebM for OBS and ProRes 4444 MOV for the editor lane. Keep it 1 to 2 seconds (transparent WebM renders slowly). HyperFrames captures each frame as PNG with alpha and encodes through either alpha-capable codec, so the dual target is one comp and two renders. - Shorts karaoke captions: word-level highlight driven by the transcript's word timestamps, built from a registry caption block themed through tokens.json. +- Footage-facing effects: color-graded looks and background removal on the source video and stills, when a beat or the Production Bible calls for them. ## Rules +- Favor the catalog and the installed skills before authoring from scratch; a hand-built comp is the last resort, not the first. +- Footage-facing effects use the local render (`data-color-grading`, background removal, HTML-in-Canvas), never a hosted API or credits. - Brand-themed blocks live in the engine workspace and are reused across projects; per-video comps live in each project's `graphics/` folder. - Dual-render targets share one source of truth; never maintain two stinger comps. - WebM VP9 alpha is for browser and OBS consumption only. Editors ignore its alpha channel and render transparent areas black, so the editor lane always takes the ProRes 4444 MOV. diff --git a/skills/mc-setup/scripts/check_deps.py b/skills/mc-setup/scripts/check_deps.py index 708802e..581ecf2 100644 --- a/skills/mc-setup/scripts/check_deps.py +++ b/skills/mc-setup/scripts/check_deps.py @@ -34,7 +34,7 @@ ("ffmpeg", True, "frame extraction, re-mux to constant frame rate, preview renders"), ("ffprobe", True, "frame-rate and pixel-format verification"), ("node", True, "HyperFrames render engine"), - ("npx", True, "hyperframes CLI and registry blocks"), + ("npx", True, "hyperframes CLI, skills, and catalog blocks"), ("git", True, "project history"), ("yt-dlp", False, "pulling your published transcripts for the voice bible"), ] From d71068ae2bf3ce4ed8644a40b72f46a89c15b1e7 Mon Sep 17 00:00:00 2001 From: Brian Madison Date: Thu, 23 Jul 2026 08:22:26 -0500 Subject: [PATCH 6/8] Install HyperFrames skills at setup so capability knowledge is live from the beats stage mc-setup gains step 2b: with node and npx present, it installs and favors the HyperFrames Agent Skills (npx skills add heygen-com/hyperframes --all --full-depth, or the core set via npx hyperframes skills update), confirm-before-install and idempotent. Wired into the 0.x migration offer list and the closing runnability report, and named in the skill description. Previously the skills installed on the first graphics run; installing them at setup makes the engine's full capability surface (block catalog, color grading, background removal, HTML-in-Canvas, delivery reach) known from the beats/pitch stage onward. mc-graphics now refreshes them each run (npx hyperframes init) and installs them only if setup was skipped. The engine workspace itself still initializes lazily on the first graphics run; only the lightweight skill knowledge lands at setup. Engine reference and CHANGELOG updated to match. --- CHANGELOG.md | 2 +- skills/mc-graphics/SKILL.md | 2 +- skills/mc-graphics/engines/hyperframes.md | 4 ++-- skills/mc-setup/SKILL.md | 14 +++++++++++--- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de04619..a42b14c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ All notable changes to BMad Manticore are documented here. Dates are ISO (YYYY-M ### Added - The final render is incremental. The timeline is partitioned into content-addressed segments (greedy, sticky boundaries snapped to the next safe cut, ~10 min target tunable via `--segment-target-seconds`) persisted video-only under `renders/segments/` alongside a `manifest.json`. Each re-render hashes every segment's real inputs (its EDL slice, the content digest of every source and overlay file it consumes, and the resolved encoder, ffmpeg version, and output dimensions) and re-encodes only the segments that actually changed, reusing the rest from cache; the video segments are then losslessly concatenated (`-c copy`) and the whole-program audio, rebuilt every pass so the timeline stays exact and per-segment AAC seam drift can never accumulate, is muxed in. A tweaked graphic or a re-cut region on a long video re-renders in seconds instead of end to end. Boundaries are stable: because a segment's identity is its content, not its position, an edit leaves earlier segments (and unshifted later ones) cached. Changing the encoder, ffmpeg version, or output dimensions dirties every segment (a mixed-encoder concat is invalid); `--no-cache` forces a full re-render. Dirty segments render through a bounded worker pool. Encoder selection, the hardware ladders, the disk preflight, boundary-frame checks, and the -14 LUFS loudnorm pass are unchanged; the preview render is untouched. -- HyperFrames' own Agent Skills are installed and favored. On the first graphics run mc-graphics installs the skills alongside the engine (`npx hyperframes skills update`, or `npx skills add heygen-com/hyperframes --all --full-depth` for the whole catalog) and refreshes them on later runs (`npx hyperframes init`), so the agent authors from HyperFrames' current, self-refreshing knowledge of the engine instead of a hand-written summary that goes stale. The engine reference (`skills/mc-graphics/engines/hyperframes.md`) is rewritten as a durable capability map that names what is reachable and defers the detail to the installed skills and https://hyperframes.heygen.com/llms.txt: the 100+ block catalog (code animations, WebGL shader transitions, caption styles, lower thirds, social cards, data-viz maps, VFX, 3D device mockups) plus footage-facing effects the pipeline did not surface before (color grading with `.cube` LUTs, grain, and vignette; background removal; HTML-in-Canvas WebGL) and its delivery reach (HDR10, 4K, MOV/WebM/GIF/PNG). It all runs on the local CLI and render path; the hosted conveniences (HeyGen cloud-render credits, the Studio web app, Claude Design, Figma import, Lambda/Cloud Run) stay optional and are never a dependency, and no HeyGen account is required. mc-graphics now sources from the catalog and skills before authoring anything by hand. +- HyperFrames' own Agent Skills are installed and favored. mc-setup installs them (new step 2b, `npx skills add heygen-com/hyperframes --all --full-depth` for the whole catalog, or `npx hyperframes skills update` for the core set) so their knowledge of the engine's full capability surface is live from the beats and graphics stages onward, not just after the first graphics run; mc-graphics refreshes them each run (`npx hyperframes init`) and installs them if setup was skipped. The agent authors from HyperFrames' current, self-refreshing knowledge of the engine instead of a hand-written summary that goes stale. The engine workspace itself still initializes lazily on the first graphics run; only the lightweight skill knowledge lands at setup. The engine reference (`skills/mc-graphics/engines/hyperframes.md`) is rewritten as a durable capability map that names what is reachable and defers the detail to the installed skills and https://hyperframes.heygen.com/llms.txt: the 100+ block catalog (code animations, WebGL shader transitions, caption styles, lower thirds, social cards, data-viz maps, VFX, 3D device mockups) plus footage-facing effects the pipeline did not surface before (color grading with `.cube` LUTs, grain, and vignette; background removal; HTML-in-Canvas WebGL) and its delivery reach (HDR10, 4K, MOV/WebM/GIF/PNG). It all runs on the local CLI and render path; the hosted conveniences (HeyGen cloud-render credits, the Studio web app, Claude Design, Figma import, Lambda/Cloud Run) stay optional and are never a dependency, and no HeyGen account is required. mc-graphics now sources from the catalog and skills before authoring anything by hand. - Final renders are loudness-normalized by default to -14 LUFS (two-pass ffmpeg loudnorm, TP -1.5, LRA 11; audio-only second pass with the video stream copied), configurable via `[render]` loudness-target and disable-able via `[render]` loudnorm = false or `--no-loudnorm`. Preview renders are never normalized. Silent audio skips the pass with a warning instead of failing. - mc-package emits uploadable captions and a publishable transcript from the edited timeline: the new stdlib-only captions.py maps each EDL segment's word spans onto output-timeline times (reordered and multi-source edits included) and writes packaging/captions/final.srt, final.vtt, and transcript.md. Caption defaults: 42 chars per line, 2 lines, 1 to 7 s cues, splits at sentence ends, pauses, and cuts. A light filler/stutter cleanup applies to the caption rendition only (`--no-clean` keeps verbatim); transcript/words.json is never modified. - mc-stream-pack produces and verifies the OBS WebM VP9 alpha deliverable from a ProRes 4444 master in one command (render_verify.py `--transcode-webm`, with `--webm-crf`), with clear errors when ffmpeg lacks libvpx-vp9 or the master has no alpha, plus a pixfmt-failure hint carrying the exact re-encode flags. The stream-pack copy of render_verify.py is now a superset of mc-graphics' copy. diff --git a/skills/mc-graphics/SKILL.md b/skills/mc-graphics/SKILL.md index 84d2229..432a453 100644 --- a/skills/mc-graphics/SKILL.md +++ b/skills/mc-graphics/SKILL.md @@ -8,7 +8,7 @@ description: Execute the approved beat table in HyperFrames/OGraf/HTML, render, ## Steps 1. Load the studio config (`uv run {project-root}/_bmad/scripts/resolve_config.py --project-root {project-root} --key modules.manticore`; empty means mc-setup has not run: stop and route the creator there) and this skill's own surface (`uv run {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root}`; run `{workflow.activation_steps_prepend}` now, `{workflow.activation_steps_append}` after this step, and hold `{workflow.persistent_facts}` as standing context). Resolve `paths` values against `{project-root}`. Read `project.json` (confirm `approvals.beats` is a date, stage `graphics`), `beats/beats.md`, `beats/STORYBOARD.md`, `{brand-path}/tokens.json`, `{brand-path}/production-bible.md` (the styling contract beyond tokens.json: overlay aesthetic, motion feel, image-type policy, placement rules), the format profile, and `{skill-root}/engines/.md` for each engine the table names. Beats marked OGraf route through the mc-ograf skill, and ONLY if `[editor] ograf-editable = true`; otherwise build them as baked alpha overlays like everything else (baked alpha works in every editor). -2. Engine workspaces live at `{engines-path}//`; initialize on first use per the engine README, installing the latest published version at that moment and recording what it resolved. For HyperFrames this also installs and favors its Agent Skills (`npx hyperframes skills update`, or `npx skills add heygen-com/hyperframes --all --full-depth` for the whole catalog) and refreshes them on later runs (`npx hyperframes init`); the skills are the agent's current, self-refreshing knowledge of the engine's authoring patterns and full capability surface, so nothing here transcribes a catalog that would go stale. +2. Engine workspaces live at `{engines-path}//`; initialize on first use per the engine README, installing the latest published version at that moment and recording what it resolved. For HyperFrames, refresh its Agent Skills now (`npx hyperframes init`); mc-setup installs them (step 2b) so their knowledge is live from the beats stage, but install them here if setup was skipped or predates them (`npx skills add heygen-com/hyperframes --all --full-depth`, or `npx hyperframes skills update` for the core set). The skills are the agent's current, self-refreshing knowledge of the engine's authoring patterns and full capability surface, so nothing here transcribes a catalog that would go stale. 3. Source before authoring, always: for each beat, reach first for a fitting HyperFrames block or skill (`npx hyperframes add`, the installed skills, existing brand-themed blocks in the engine workspace) across the whole catalog (captions, transitions, lower thirds, social cards, data viz, VFX, device mockups) and its footage-facing effects (color grading, background removal, HTML-in-Canvas), per `{skill-root}/engines/hyperframes.md`; for simple moves on a finished still (fly-in and fly-out, staged builds), prefer the ffmpeg recipes in `{skill-root}/references/motion-recipes.md`; author from scratch via the html lane (`{skill-root}/engines/html.md`) or the design-prompting loop (`{skill-root}/engines/design-prompting.md`) only when nothing fits. Everything themes through tokens.json, no hardcoded colors or fonts. 4. Build per engine in the project's `graphics/` folder. Follow the loop: edit, lint, preview, draft render (CRF 28), single-frame verify, final render. The shipped toolkit does the mechanical parts: `{skill-root}/scripts/html_to_png.py` (exact-size HTML render, separate `--guides` pass, alpha verify) and `{skill-root}/scripts/snug_frame.py` (native-aspect photo framing). Sound: when a composition calls for a whoosh, hit, chime, or bed (the animation-feel conventions in the Production Bible say when), route through the mc-audio service skill (never reach into its folder) and deliver the wav into `graphics/` next to the overlay it belongs to, with its timing noted in HANDOFF.md. 5. Verify every final render with `uv run {skill-root}/scripts/render_verify.py`, passing expectations explicitly: `--pixfmt` per the delivery target, `--expect-dur` from the beat's dur, `--expect-fps` and `--expect-res` from the format profile, or `--meta` pointing at the comp's meta.json render contract carrying the same keys (extracted frames visually checked over checkerboard for alpha). A render without checked frames is not done. diff --git a/skills/mc-graphics/engines/hyperframes.md b/skills/mc-graphics/engines/hyperframes.md index 88e30af..e9c012c 100644 --- a/skills/mc-graphics/engines/hyperframes.md +++ b/skills/mc-graphics/engines/hyperframes.md @@ -6,8 +6,8 @@ Default engine for everything the pipeline renders as motion: per-video overlay HyperFrames is pre-1.0 and moves fast, so this file never transcribes its full capability list or pins a version, which would only ship stale knowledge to every creator. Instead the agent loads HyperFrames' own Agent Skills, which teach the current authoring patterns (the `data-*` attributes, GSAP timeline registration, the component vocabulary, every effect) and refresh themselves: -- Install on first graphics use, alongside the engine workspace: `npx hyperframes skills update` installs the maintained core set; `npx skills add heygen-com/hyperframes --all --full-depth` loads the whole catalog so the agent knows every capability. Favor them: the installed skills plus the block catalog are the FIRST place to look for any beat, ahead of authoring anything by hand. Record what resolved. -- Refresh, never blindly expand: `npx hyperframes init` refreshes the core set plus whatever is already installed; `npx hyperframes skills check` reports drift and `npx hyperframes skills update` applies it. The harness loads the skills by its own skill resolution; nothing here is specific to one agent. +- Installed at setup (mc-setup step 2b), so the whole capability surface is known from the beats stage onward, not just at graphics time: `npx skills add heygen-com/hyperframes --all --full-depth` loads the whole catalog (or `npx hyperframes skills update` for the maintained core set). Favor them: the installed skills plus the block catalog are the FIRST place to look for any beat, ahead of authoring anything by hand. +- Refreshed on every graphics run, never blindly expanded: `npx hyperframes init` refreshes the core set plus whatever is already installed; `npx hyperframes skills check` reports drift and `npx hyperframes skills update` applies it. If setup was skipped, mc-graphics installs them on first use. The engine WORKSPACE itself still initializes lazily on the first graphics run (below); only the lightweight skill knowledge lands at setup. The harness loads the skills by its own skill resolution; nothing here is specific to one agent. - The authoritative, always-current capability index is https://hyperframes.heygen.com/llms.txt. When a beat needs something, consult the installed skills and that index rather than this file. ## What it can do (reach for these before building from scratch) diff --git a/skills/mc-setup/SKILL.md b/skills/mc-setup/SKILL.md index 3426880..9c2e5a6 100644 --- a/skills/mc-setup/SKILL.md +++ b/skills/mc-setup/SKILL.md @@ -1,6 +1,6 @@ --- name: mc-setup -description: First-run (or any-time) configuration for the Manticore video pipeline. Creates and updates the studio config ([modules.manticore] in {project-root}/_bmad/custom/config.toml), verifies dependencies and platform support, runs the onboarding interview (basics, render consent, video style, CTAs, audio lanes), builds the brand for real (tokens, Production Bible, headshots, guided voice bible), registers the creator's generation CLIs with end-to-end verification, scaffolds .env.example, and migrates 0.x studios. Run when any mc-* skill reports missing config, or to change tools later. +description: First-run (or any-time) configuration for the Manticore video pipeline. Creates and updates the studio config ([modules.manticore] in {project-root}/_bmad/custom/config.toml), verifies dependencies and platform support, installs the HyperFrames graphics skills, runs the onboarding interview (basics, render consent, video style, CTAs, audio lanes), builds the brand for real (tokens, Production Bible, headshots, guided voice bible), registers the creator's generation CLIs with end-to-end verification, scaffolds .env.example, and migrates 0.x studios. Run when any mc-* skill reports missing config, or to change tools later. --- # mc-setup @@ -36,7 +36,7 @@ An existing `[modules.manticore]` that is missing any of the 1.0 tables (`[rende - A pre-1.0 series or thumbnail template at the brand root (for example `thumbnail-template.md`) predates the `{brand-path}/templates/.md` contract: offer to move it there, named for the series it describes, so mc-package finds it. - Run the delta interview: step 3b (render consent), then step 3c (the video style interview), then step 3d (audio lanes). - Scaffold `{brand-path}/production-bible.md` per step 4, seeded from the brand assets that already exist (tokens.json, shipped overlays, exemplars, format-profile learnings) plus the step 3c answers, not from a blank slate. -- Offer, without forcing, the other new builds: headshot collection (step 4), the guided voice bible (step 4b), `.env.example` (step 7). +- Offer, without forcing, the other new builds: the HyperFrames graphics skills (step 2b), headshot collection (step 4), the guided voice bible (step 4b), `.env.example` (step 7). - Leave every other existing value untouched; those are already the creator's answers. Finish with step 8 as usual so the migrated config is verified and the pending gaps are reported. @@ -47,6 +47,14 @@ Bootstrap first: check `uv --version`. If uv is missing, offer to install it; ot Then run `uv run {skill-root}/scripts/check_deps.py`. Report what is missing with the exact install command (brew/apt/winget as fits the platform). Install nothing without the creator confirming each item. The report ends with a platform verdict: detected OS, CPU architecture, and GPU vendor, plus the recommended stack file (`{skill-root}/references/stack-macos.md`, `stack-windows.md`, or `stack-linux.md`) and the platform-specific defaults it implies (transcription lane, torch index, encoder ladder, SVG rasterizer, fonts approach). Read the named stack file now and hold it as context for the rest of setup. If this machine is not Apple Silicon, relay the script's pointer honestly: the parakeet-mlx reference lane will not run here, and the recommended lane is onnx-asr with the same parakeet-tdt-0.6b-v3 weights (implemented; verbatim fillers and word timestamps carry over, CPU or CUDA per the verdict). Carry that honesty into step 3's transcription question. +### 2b. HyperFrames graphics skills + +HyperFrames is the graphics engine, and its Agent Skills carry the agent's current, self-refreshing knowledge of everything it can do (the block catalog, WebGL transitions, color grading, background removal, HTML-in-Canvas, the authoring patterns). Install them now, provided node and npx are present (step 2), so that capability knowledge is live from the beats and graphics stages onward instead of only after the first graphics run. Confirm before installing (it writes skill files into the project); the install is idempotent, a re-run refreshes rather than duplicates: + +- Install and favor the full catalog: `npx skills add heygen-com/hyperframes --all --full-depth`. A creator who wants a lighter footprint can take just the maintained core set instead: `npx hyperframes skills update`. +- Everything here runs on the local CLI; no HeyGen account or credits. The engine WORKSPACE itself (`{engines-path}/hyperframes/`, a multi-GB npm install) still builds lazily on the first graphics run; this step installs only the lightweight skill knowledge so the whole capability surface is known from the start. +- If node or npx is missing, say HyperFrames graphics need them and defer the skills to the first graphics run rather than blocking setup. + ### 3. The basics interview Ask, offering current values (or the `[defaults]` from customize.toml) as defaults: @@ -158,7 +166,7 @@ Write the interview results as the `[modules.manticore]` table (with its sub-tab Close with the honest runnability report: -- Locked behavior: what will actually happen on the first project with these settings. Render-first preview and offered final per `[render]`, the graphics-frequency tier, the CTA inventory, the transcription lane and whether THIS machine can run it, the audio lanes and whether the engine workspace is built yet, the editor timeline format. +- Locked behavior: what will actually happen on the first project with these settings. Render-first preview and offered final per `[render]`, the graphics-frequency tier, the CTA inventory, the transcription lane and whether THIS machine can run it, the audio lanes and whether the engine workspace is built yet, the editor timeline format, and whether the HyperFrames graphics skills are installed (or deferred to the first graphics run). - Lane status: implemented vs planned for every configured lane, straight from the customize.toml comments. Never claim a planned lane works. - Pending gaps, flagged loudly: missing headshots (thumbnails are blocked), unbuilt voice bible, placeholder Production Bible sections, unverified tools, empty asset lanes (mc-assets will stop and ask). - Capability note: check whether the harness has browser automation available; packaging research degrades without it, and the report says so when it is absent. From 09ee2ae073850c194f3cd148e8207bbf0724e395 Mon Sep 17 00:00:00 2001 From: Brian Madison Date: Thu, 23 Jul 2026 16:54:10 -0500 Subject: [PATCH 7/8] Cut 2.0.0: README announcement, impact-focused changelog, version bump - README gains a "New in 2.0" announcement at the top: HyperFrames consolidation and skills integration (the full local toolkit), Remotion retired, incremental render, loudnorm, cross-platform lanes, a changelog callout, and the clean-reinstall upgrade path (remove _bmad and _bmad-output, reinstall, re-onboard; brand/voice/formats survive). - CHANGELOG 1.1.0-Unreleased reworked into 2.0.0, refocused on the big-picture and quality-of-life changes: one engine plus the whole HyperFrames toolkit, faster iteration (incremental render, -14 LUFS finals), cross-platform, packaging and delivery, docs. - Version bumped to 2.0.0 (marketplace.json, README badge); the remotion alias notes in PIPELINE.md and mc-beats now say "before 2.0.0". --- .claude-plugin/marketplace.json | 2 +- CHANGELOG.md | 38 +++++++++++++++++++-------------- README.md | 8 ++++++- skills/mc-beats/SKILL.md | 2 +- skills/mc-pipeline/PIPELINE.md | 2 +- 5 files changed, 32 insertions(+), 20 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 5e03a68..8a2b1fe 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -19,7 +19,7 @@ "name": "bmad-manticore", "source": "./", "description": "AI video production pipeline: brain dump to a rough cut sitting in your editor, in your own words, with approval gates at every taste decision.", - "version": "1.1.0", + "version": "2.0.0", "author": { "name": "Brian (BMad) Madison" }, diff --git a/CHANGELOG.md b/CHANGELOG.md index a42b14c..c850f46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,31 +2,37 @@ All notable changes to BMad Manticore are documented here. Dates are ISO (YYYY-MM-DD). -## 1.1.0 - Unreleased +## 2.0.0 - Unreleased -### Cross-platform: Windows, Linux, and Intel Mac lanes (code-complete, pending real-hardware validation) +The big release: one motion-graphics engine with the full HyperFrames toolkit behind it, cross-platform support, a final render that only re-does what changed, and delivery polish (loudness, captions, OBS alpha). Upgrading from 1.x is a clean reinstall (see README): your brand, voice bible, and format profiles live in your studio folder, not in `_bmad/`, so they survive and onboarding picks them back up. -- Transcription is cross-platform: transcribe.py gains an onnx-asr provider running the same parakeet-tdt-0.6b-v3 weights as an ONNX conversion, so verbatim fillers and 80 ms word timestamps carry over on Windows, Linux, and Intel Macs. New default provider `auto` picks parakeet-mlx on macOS Apple Silicon (byte-identical to the 1.0 reference lane) and onnx-asr everywhere else; dependencies select per platform via PEP 508 markers, CUDA machines escalate with `uv run --with "onnx-asr[gpu,hub]" python ` (the `python` command skips the script's cpu-extra dependency so onnxruntime-gpu never co-installs with onnxruntime, and the script warns when an NVIDIA GPU is visible but CUDA is unavailable), long audio is chunked in 20 s windows with 2 s overlap and merged deterministically with a seam-repair pass so boundary words are never duplicated or dropped, and words.json stays byte-compatible across lanes. Honesty rule: when the ONNX runtime exposes no per-token scores, every confidence reads 1.0 (no signal), never a fabricated number. The `[transcription]` default moved from "parakeet-mlx" to "auto"; whisper.cpp and faster-whisper are no longer the documented fallbacks. -- Install-time platform detection: check_deps.py now detects OS, CPU architecture, and GPU vendor (nvidia-smi, /sys/class/drm PCI ids, wmic/PowerShell fallbacks) and emits a platform verdict, in `--json` as a kebab-case `platform` object (os, arch, apple-silicon, gpu, gpu-detail, recommended{stack-file, transcription, torch-index, encoder-ladder, svg-rasterizer, fonts}) and as a table-mode stack block. Three new stack reference files at skills/mc-setup/references/stack-{macos,windows,linux}.md carry the per-OS defaults, Windows notes (short engines-path, LongPathsEnabled, gyan.dev build, OBS over Game Bar), Linux notes (PipeWire/Wayland capture, noto-color-emoji, the free-Resolve H.264/HEVC/AAC codec caveat), vMix and Wirecast alpha notes, and the per-OS DaVinci Resolve Fusion Scripts paths for the free-edition scripted-import lane. mc-setup reads the recommended stack file during the interview. -- Hardware encoder ladders: the final render and the VFR remux pick per-OS hardware encoders validated by a real one-frame test encode (Windows: h264_nvenc, then h264_qsv, then h264_amf; Linux: h264_nvenc, then h264_vaapi wired end to end with hwupload; libx264 fallback everywhere). The preview render, plain and graphics-composited alike, stays libx264 crf 28 veryfast by design on every OS. macOS videotoolbox behavior is byte-for-byte unchanged. -- mc-audio portability: the audio-lab venv interpreter resolves per OS (.venv/bin/python vs .venv\Scripts\python.exe), Windows machines with an NVIDIA GPU install torch from the PyTorch cu126 index (roughly 2.5 to 3 GB extra, surfaced in the consent message and the `--dry-run` torch field), and MusicGen/AudioLDM2 pick cuda, then mps, then cpu. macOS behavior is unchanged. -- Small portability fixes: edl_to_fcpxml.py emits valid Windows file URIs (file:///C:/... and UNC shares) via Path.as_uri() with byte-identical POSIX output; farm_asset.py resolves registered tools with a PATH lookup (Windows npm .cmd/.exe shims launch by bare name), documents POSIX quoting for headless templates on every OS, and refuses to pass arguments containing cmd.exe metacharacters (embedded double quotes, % ^ & | < >) to a .cmd/.bat shim, failing loudly with a re-register hint instead of letting cmd.exe corrupt or expand them; verify_ograf.py prints per-OS manual verification steps and, from a human terminal, serves the package and opens preview.html in the default browser itself. transcribe.py, edl_to_fcpxml.py, and render_final.py read and write their JSON, FCPXML, and concat-list artifacts with explicit UTF-8 so non-ASCII transcripts and paths survive on Windows locale codecs (cp1252). +### Motion graphics: one engine, the whole HyperFrames toolkit -### Changed +- Consolidated on HyperFrames as the single motion-graphics engine; Remotion is retired. Remotion's license carried a real obligation for teams of four or more, and its React model bought nothing in a frame-deterministic renderer where state is just a function of frame index. Every job it held (brand stinger, karaoke captions, HTML/SVG comps) is a HyperFrames comp, and the OBS-plus-editor dual alpha target is unchanged. Nothing to migrate: `remotion` is a permanent alias for `hyperframes` everywhere an engine is named, so existing studios keep working untouched. +- HyperFrames' own Agent Skills are installed at setup and favored, so the agent knows the engine's full, current capability surface from the start instead of a hand-written summary that goes stale. That surface is large and mostly new to the pipeline: the 100-plus block catalog (code animations, WebGL shader transitions, caption styles, lower thirds, social cards, data-viz maps, VFX, 3D device mockups) plus footage-facing effects the pipeline never surfaced before (color grading with LUTs, grain, and vignette; background removal; HTML-in-Canvas WebGL), and HDR10 and 4K delivery. It all runs locally; no HeyGen account or credits, ever. -- Remotion is removed; HyperFrames is the module's single motion-graphics engine. Two reasons: Remotion's license is free only for companies of up to 3 people and Manticore is a distributed module, so shipping it handed every creator at a 4+ person company a licensing obligation they never opted into; and its remaining justification ("anything React-stateful") bought nothing in a frame-deterministic renderer, where state is a function of frame index. Every job Remotion held (the dual-render brand stinger, shorts karaoke captions, plain HTML/SVG comps) is a paused GSAP timeline in HyperFrames, which encodes alpha to both ProRes 4444 MOV and VP9 yuva420p WebM, so the OBS-plus-editor dual target is unchanged. The `engine_stingers`/`engine_overlays` profile keys now read `hyperframes` where they read `remotion`, `skills/mc-graphics/engines/remotion.md` is deleted, and the rationale lives in `skills/mc-graphics/engines/hyperframes.md`. Upgrade is just re-running the install: `remotion` is now a permanent, unconditional alias for `hyperframes` wherever an engine is named (beat-table `engine` value or a format profile's `engine_overlays`/`engine_stingers` frontmatter, any vintage), so a studio configured before 1.1.0 keeps its own copied profiles untouched and every skill reads them as `hyperframes`. No migration, no rewritten creator files. OGraf is unaffected; it solves a different problem (Resolve-editable and SPX-GC live). +### Faster iteration -### Added +- The final render is incremental. The timeline is cached as content-addressed segments, so a re-render re-encodes only what actually changed (a tweaked graphic, a re-cut region) and reuses the rest. A fix on a long video is a seconds-long render instead of an end-to-end one, and boundaries are stable, so an edit early in the timeline does not invalidate everything after it. `--no-cache` forces a full rebuild. +- Final renders are loudness-normalized to -14 LUFS by default (the YouTube reference), so deliverables land at a consistent, platform-ready level with no manual pass. The preview is never normalized; `[render]` loudness-target and loudnorm are the knobs. -- The final render is incremental. The timeline is partitioned into content-addressed segments (greedy, sticky boundaries snapped to the next safe cut, ~10 min target tunable via `--segment-target-seconds`) persisted video-only under `renders/segments/` alongside a `manifest.json`. Each re-render hashes every segment's real inputs (its EDL slice, the content digest of every source and overlay file it consumes, and the resolved encoder, ffmpeg version, and output dimensions) and re-encodes only the segments that actually changed, reusing the rest from cache; the video segments are then losslessly concatenated (`-c copy`) and the whole-program audio, rebuilt every pass so the timeline stays exact and per-segment AAC seam drift can never accumulate, is muxed in. A tweaked graphic or a re-cut region on a long video re-renders in seconds instead of end to end. Boundaries are stable: because a segment's identity is its content, not its position, an edit leaves earlier segments (and unshifted later ones) cached. Changing the encoder, ffmpeg version, or output dimensions dirties every segment (a mixed-encoder concat is invalid); `--no-cache` forces a full re-render. Dirty segments render through a bounded worker pool. Encoder selection, the hardware ladders, the disk preflight, boundary-frame checks, and the -14 LUFS loudnorm pass are unchanged; the preview render is untouched. -- HyperFrames' own Agent Skills are installed and favored. mc-setup installs them (new step 2b, `npx skills add heygen-com/hyperframes --all --full-depth` for the whole catalog, or `npx hyperframes skills update` for the core set) so their knowledge of the engine's full capability surface is live from the beats and graphics stages onward, not just after the first graphics run; mc-graphics refreshes them each run (`npx hyperframes init`) and installs them if setup was skipped. The agent authors from HyperFrames' current, self-refreshing knowledge of the engine instead of a hand-written summary that goes stale. The engine workspace itself still initializes lazily on the first graphics run; only the lightweight skill knowledge lands at setup. The engine reference (`skills/mc-graphics/engines/hyperframes.md`) is rewritten as a durable capability map that names what is reachable and defers the detail to the installed skills and https://hyperframes.heygen.com/llms.txt: the 100+ block catalog (code animations, WebGL shader transitions, caption styles, lower thirds, social cards, data-viz maps, VFX, 3D device mockups) plus footage-facing effects the pipeline did not surface before (color grading with `.cube` LUTs, grain, and vignette; background removal; HTML-in-Canvas WebGL) and its delivery reach (HDR10, 4K, MOV/WebM/GIF/PNG). It all runs on the local CLI and render path; the hosted conveniences (HeyGen cloud-render credits, the Studio web app, Claude Design, Figma import, Lambda/Cloud Run) stay optional and are never a dependency, and no HeyGen account is required. mc-graphics now sources from the catalog and skills before authoring anything by hand. -- Final renders are loudness-normalized by default to -14 LUFS (two-pass ffmpeg loudnorm, TP -1.5, LRA 11; audio-only second pass with the video stream copied), configurable via `[render]` loudness-target and disable-able via `[render]` loudnorm = false or `--no-loudnorm`. Preview renders are never normalized. Silent audio skips the pass with a warning instead of failing. -- mc-package emits uploadable captions and a publishable transcript from the edited timeline: the new stdlib-only captions.py maps each EDL segment's word spans onto output-timeline times (reordered and multi-source edits included) and writes packaging/captions/final.srt, final.vtt, and transcript.md. Caption defaults: 42 chars per line, 2 lines, 1 to 7 s cues, splits at sentence ends, pauses, and cuts. A light filler/stutter cleanup applies to the caption rendition only (`--no-clean` keeps verbatim); transcript/words.json is never modified. -- mc-stream-pack produces and verifies the OBS WebM VP9 alpha deliverable from a ProRes 4444 master in one command (render_verify.py `--transcode-webm`, with `--webm-crf`), with clear errors when ffmpeg lacks libvpx-vp9 or the master has no alpha, plus a pixfmt-failure hint carrying the exact re-encode flags. The stream-pack copy of render_verify.py is now a superset of mc-graphics' copy. +### Cross-platform: Windows, Linux, and Intel Mac + +Code-complete and unit-tested; still pending validation on real Windows and Linux hardware, so treat the first run as a shakedown and report what you hit. + +- Transcription runs everywhere: the reference parakeet weights via parakeet-mlx on Apple Silicon and onnx-asr (same weights) on Windows, Linux, and Intel Macs, CPU by default with a one-flag CUDA escalation. Verbatim fillers and word timestamps carry over identically. The default lane is now `auto`. +- The final render uses per-OS hardware encoders validated by a real one-frame test encode (nvenc, qsv, amf on Windows; nvenc, vaapi on Linux) with a libx264 fallback everywhere; macOS videotoolbox is unchanged. +- Setup detects your OS, CPU, and GPU and recommends the right stack, with per-OS notes for Windows, Linux, vMix and Wirecast, and the free DaVinci Resolve edition. +- The audio farm, timeline export, and asset farming are portable to Windows and Linux paths, shells, and locales. + +### Packaging and delivery + +- mc-package emits uploadable captions and a publishable transcript from the edited timeline (SRT, VTT, and a cleaned Markdown transcript), mapped onto output-timeline times across reordered and multi-source edits; the source words.json is never modified. +- mc-stream-pack produces and verifies the OBS WebM (VP9 alpha) deliverable from a ProRes 4444 master in one command, with clear errors when ffmpeg lacks libvpx-vp9 or the master has no alpha. ### Documentation -- README platform matrix and the user guide rewritten for the cross-platform reality; the Resolve handoff reference gains the free-edition Fusion Scripts install paths, the Linux free-edition codec caveat, and an opt-in pointer to the community samuelgursky/davinci-resolve-mcp server for Studio users (Manticore itself still requires no MCP server). +- README carries a 2.0 announcement and the upgrade path; the platform matrix and user guide are rewritten for cross-platform reality; the Resolve handoff reference gains free-edition Fusion Scripts paths and an optional Resolve-MCP pointer (Manticore itself needs no MCP server). ## 1.0.1 - 2026-07-07 diff --git a/README.md b/README.md index 044677e..49a6458 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # BMad Manticore -[![Version](https://img.shields.io/badge/version-1.0.1-blue)](.claude-plugin/marketplace.json) +[![Version](https://img.shields.io/badge/version-2.0.0-blue)](.claude-plugin/marketplace.json) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Python Version](https://img.shields.io/badge/python-%3E%3D3.11-blue?logo=python&logoColor=white)](https://www.python.org) [![uv](https://img.shields.io/badge/uv-package%20manager-blueviolet?logo=uv)](https://docs.astral.sh/uv/) @@ -10,6 +10,12 @@ **From brain dump to a rendered, graphics-rich video, in your own words.** +## New in 2.0 + +Manticore now runs entirely on [HyperFrames](https://hyperframes.heygen.com) for motion graphics, with its Agent Skills installed and favored at setup so the agent can reach the whole toolkit: color grading, background removal, WebGL shader transitions, kinetic captions, data-viz, 3D device mockups, HDR and 4K delivery, and a 100-plus block catalog. It all runs locally, with no account or credits. Remotion is retired; its license and React model no longer fit a frame-deterministic pipeline. The final render is now incremental too (a fix on a long video re-renders in seconds, not minutes), finals are loudness-normalized by default, and Windows, Linux, and Intel Mac lanes are code-complete. See the [changelog](CHANGELOG.md) for everything that changed. + +Upgrading from an earlier version: back up anything custom you want to keep, then remove the `_bmad/` and `_bmad-output/` folders from your studio and reinstall (see [Install](#install)). Start your agent and say `hey manny lets get this all set up!`, then follow onboarding. Your brand kit, voice bible, and format profiles live in your studio folder (not in `_bmad/`), so they survive the reinstall: onboarding finds them, and if it does not, point it at them so it can reuse or update them. + Talk through your video idea for twenty minutes, or hand over footage you already have. Get back a script in your own words, a word-level cut plan for your raw footage, a watchable preview render of every iteration, brand-themed motion graphics with CTAs placed where they work, a title/thumbnail package, and an offered final-quality render at the end. The editor timeline export and all cut assets are always produced alongside, so you can move into your own editor at any step. You approve every taste decision along the way. Manticore is an AI video production pipeline for content creators, packaged as an installable [BMad Method](https://docs.bmad-method.org/) module. It is opinionated but flexible: a wrapper and orchestrator around many great tools (your editor, transcription, generation CLIs, motion graphics engines), with strong defaults and nothing locked in. Every external tool is optional, opt-in gravy; the core pipeline needs only uv, ffmpeg, node, and git. diff --git a/skills/mc-beats/SKILL.md b/skills/mc-beats/SKILL.md index 99f5819..e41e331 100644 --- a/skills/mc-beats/SKILL.md +++ b/skills/mc-beats/SKILL.md @@ -12,7 +12,7 @@ Gate 3. The beat table is the engine-neutral contract between the script and the 1. Load the studio config (`uv run {project-root}/_bmad/scripts/resolve_config.py --project-root {project-root} --key modules.manticore`; empty means mc-setup has not run: stop and route the creator there) and this skill's own surface (`uv run {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root}`; run `{workflow.activation_steps_prepend}` now, `{workflow.activation_steps_append}` after this step, and hold `{workflow.persistent_facts}` as standing context). Resolve `paths` values against `{project-root}`. Read `project.json` (confirm `approvals.cutplan` is a date, stage is `beats`), `script.md`, `cut/edl.json`, `transcript/`, the format profile at `{formats-path}/.md`, `{brand-path}/production-bible.md`, and `{brand-path}/tokens.json`. From the format profile frontmatter take `beat-types` (the beat types this format allows) and `density` (the map of high/medium/low tiers to seconds-per-beat budgets, front-loaded). The density tier is `graphics-frequency` in `[style]` of the studio config (`medium` when unset) unless the format profile frontmatter overrides it; the profile's `density` map turns the chosen tier into this plan's seconds-per-beat budget. 2. Riff with the creator BEFORE writing the table. Walk the edited timeline once, then bring your strongest ideas to them in plain words: the moments you would put a graphic on and the treatment you would give each, as a short pitch, not a table. Ask what they were picturing: anything specific they already imagined for this video, moments they know they want a visual on, references they have been chewing on. A few minutes of riffing here beats a revision cycle at gate 3. Carry every answer into the plan. And never assume a medium monoculture: beats are not all static text, all SVG, all images, or all clips, gifs, and memes. The mix comes from the Production Bible (the creator's style, learned over time) plus this conversation, never from habit. 3. Walk the EDITED timeline (times derive from edl.json, not the raw take). Scan the transcript with the trigger heuristics in `references/density-and-creativity.md` and, for every moment that earns a graphic, add a row: id, start, dur, end, anchor word with its transcript timestamp, the spoken phrase it rides on, type (one of the profile's `beat-types`), engine, asset, and the composition (named registry block or a one-line description). Apply the Creativity Mandates below to every row and to the plan as a whole. -4. Mark each row's engine per the format profile defaults and PIPELINE.md's engine policy; a profile that still names `remotion` in its `engine_overlays`/`engine_stingers` frontmatter (a studio configured before 1.1.0) is written into the table as `hyperframes` per the engine policy's compatibility alias, never as `remotion`. Rows needing farmed assets carry the asset id in the `asset` column (they become the mc-assets shopping list), all other rows carry `null`. +4. Mark each row's engine per the format profile defaults and PIPELINE.md's engine policy; a profile that still names `remotion` in its `engine_overlays`/`engine_stingers` frontmatter (a studio configured before 2.0.0) is written into the table as `hyperframes` per the engine policy's compatibility alias, never as `remotion`. Rows needing farmed assets carry the asset id in the `asset` column (they become the mc-assets shopping list), all other rows carry `null`. 5. Run the CTA placement pass per `references/cta-placement.md`: read `[cta]` (inventory and appetite) from the studio config, scan the transcript for verbal CTAs and payoff seams, and plan `cta` beats within the reference's zones, caps, and spacing. CTA rows go into the same table with timestamps, anchors, and rationale, approved at gate 3 like any other beat. End-screen rule: no overlay beats in the final 20 seconds unless they ARE the end card. When the inventory includes a next-video or end-card item, optionally add an end-card beat themed from `{brand-path}/tokens.json`. 6. Write `beats/beats.md` (the table) and `beats/STORYBOARD.md`. Each STORYBOARD.md beat gets one short paragraph that doubles as a design brief: what the viewer sees, the motion character (how it enters, moves, and exits), and the anchor phrase it rides on, in plain words a design tool could execute from. 7. Update `artifacts` in project.json (`"beats": "beats/beats.md"`, `"storyboard": "beats/STORYBOARD.md"`), set `approvals.beats = "pending"`, present the table, and STOP for gate 3. diff --git a/skills/mc-pipeline/PIPELINE.md b/skills/mc-pipeline/PIPELINE.md index 0aad139..4cdddab 100644 --- a/skills/mc-pipeline/PIPELINE.md +++ b/skills/mc-pipeline/PIPELINE.md @@ -93,7 +93,7 @@ If the config exists but a key this stage needs is missing or empty, ask for jus - Everything is themed through `{brand-path}/tokens.json`. Component sourcing rule: registries and open libraries first, author from scratch only when nothing fits. - Engine workspaces (the pinned HyperFrames project) live at `{engines-path}`; mc-setup or the first graphics run initializes them. - Remotion was a second engine through 0.x and was removed on 2026-07-22: its license is free only up to 3 people, and its React authoring model bought nothing in a frame-deterministic renderer. Rationale in `mc-graphics/engines/hyperframes.md`. -- Compatibility alias (unconditional, any vintage): `remotion` is a permanent alias for `hyperframes` wherever an engine is named — a beat-table `engine` value OR a format profile's `engine_overlays`/`engine_stingers` frontmatter. A studio configured before 1.1.0 keeps its own copied profiles that may still say `remotion`; every skill reads that as `hyperframes` and no creator file is rewritten. There is no Remotion engine doc or workspace to route to. +- Compatibility alias (unconditional, any vintage): `remotion` is a permanent alias for `hyperframes` wherever an engine is named — a beat-table `engine` value OR a format profile's `engine_overlays`/`engine_stingers` frontmatter. A studio configured before 2.0.0 keeps its own copied profiles that may still say `remotion`; every skill reads that as `hyperframes` and no creator file is rewritten. There is no Remotion engine doc or workspace to route to. ## The beat table (engine-neutral graphics contract) From 894e82e2626ac668771d3398e0ae6fb0c1cc75e3 Mon Sep 17 00:00:00 2001 From: Brian Madison Date: Thu, 23 Jul 2026 20:21:16 -0500 Subject: [PATCH 8/8] Fix overlay-blind segment cache key; address CodeRabbit doc nits The persisted segment id derived only from the EDL-slice identity, excluding overlays. Two content-identical slices carrying different overlays collided on one segments/.ts and deduped to a single render job, baking the wrong graphics onto one of them. segment_id now folds in overlay placement (id, chunk-local start, dur, image) while still excluding the overlay file digest, so re-rendering a graphic keeps the filename stable (input_hash catches it) and position-independence is preserved. New test covers distinct/shared ids. Also: README Status section brought current to 2.0.0; mc-cut loudnorm note made conditional on [render] loudnorm; removed the landed HyperFrames workspace-init item from TODO. --- README.md | 6 ++--- TODO.md | 1 - skills/mc-cut/SKILL.md | 2 +- skills/mc-cut/scripts/composite_core.py | 27 ++++++++++++++++--- .../mc-cut/scripts/tests/test-render_final.py | 26 ++++++++++++++++++ 5 files changed, 53 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 49a6458..af42b6f 100644 --- a/README.md +++ b/README.md @@ -149,10 +149,10 @@ Taste lives in files (your voice bible, Production Bible, format profiles, brand ## Status -1.0.0 is the first release shaped by real production use. Honest state as of 2026-07-07: +2.0.0 is shaped by real production use. Honest state as of 2026-07-23: -- Proven in production: the full cut lane (parakeet-mlx word-level transcription validated on real footage, cut candidate detection, edl.json, FCPXML export, preview render with boundary-frame verification), Manny as the front door, setup and dependency checking, config resolution, project scaffolding, the OBS stream pack, and the retro loop. -- New in 1.0, implemented and unit-tested, with the least real-project mileage: the render lane (composited preview and the offered final render), the expanded setup interview (render consent, video style, creator-emulation takeaways, headshots, guided voice bible), the Production Bible, creativity mandates and the CTA system, footage-first ingest and the livestream-vod format, series support, graphics render verification, the graphics toolkit (HTML render, snug framing, design-prompting lane), CLI-registry asset farming, and the mc-audio local sound lanes (validated end to end on Apple Silicon 2026-07-07). +- Proven in production: the full cut lane (parakeet-mlx word-level transcription validated on real footage, cut candidate detection, edl.json, FCPXML export, preview render with boundary-frame verification), Manny as the front door, setup and dependency checking, config resolution, project scaffolding, the render lane (composited preview and the offered final render), the expanded setup interview, the Production Bible, creativity mandates and the CTA system, footage-first ingest, series support, the graphics toolkit, CLI-registry asset farming, the OBS stream pack, the mc-audio local sound lanes (validated end to end on Apple Silicon), and the retro loop. +- New in 2.0, implemented and unit-tested, with the least real-project mileage: HyperFrames as the sole motion-graphics engine with its Agent Skills installed at setup, the incremental content-addressed final render, default -14 LUFS loudness normalization, and the cross-platform stack (onnx-asr transcription and the per-OS hardware-encoder ladders on Windows, Linux, and Intel Mac) — code-complete and covered by tests, but treat the first run on non-Apple-Silicon hardware as a shakedown. - The writing lane (braindump, outline, script) is the core promise and is wired end to end with live blacklist linting; it has had the least real-video exercise of the core stages, so treat your first run through it as a shakedown and feed mc-retro afterward. - Planned: Premiere (xmeml) and CMX3600 EDL export lanes, per-episode stream packs with the Ecamm target (the named 1.0.x fast-follow), multitrack recording support, the remaining audio lanes (full songs with vocals, plus the paid opt-in rungs of the audio ladder), and a research/show-prep skill. See [TODO.md](TODO.md) for the full roadmap. diff --git a/TODO.md b/TODO.md index c4f2525..24f01ee 100644 --- a/TODO.md +++ b/TODO.md @@ -7,7 +7,6 @@ State as of 2026-07-07, the 1.0.0 release. Read AGENTS.md first (module conventi - Per-episode stream packs and the Ecamm lane (the named 1.0.x fast-follow): mc-stream-pack gains a pre-show per-episode pack lane (topic popups, CTAs, lower thirds mined from the episode plan before the show, delivered as switchable scenes) with the two-tier asset rule (evergreen chrome once into series `common/`, topic graphics per episode). The `[live]` tool key (obs, ecamm, other) already ships and is interviewed at setup; the OBS lane keeps HTML browser sources and WebM stingers; the Ecamm/other lane delivers baked PNG / ProRes 4444 alpha scene stills and loops, a ProRes stinger, a countdown safe-zone spec with a --guides render, and a tool-specific HANDOFF.md. Ecamm Live is macOS-only. Scheduled-livestream packaging (mc-package live-event mode, two-asset thumbnail rule) rides along. - farm_asset.py metered API lane (xAI Imagine REST image ~$0.02 and video ~$0.05/s submit/poll/download; Veo 3.1 via the Gemini API as the escalation lane). Registered CLI tools are the only implemented farming lane in 1.0; the API lane ships opt-in only, never as a default. - resolve_import.py: push the exported timeline into a running DaVinci Resolve. External scripting requires Resolve Studio; free-edition users will run it from inside Resolve via the Fusion Scripts menu (the per-OS install paths are already documented in the mc-setup stack references and mc-ograf's resolve-workflow reference). The mc-cut offer stays gated on the script's implemented status. Native scripting remains the documented path; no MCP dependency. -- HyperFrames engine workspace initialization on the first real graphics run: install `hyperframes@latest` at that moment and record the resolved version in the workspace package.json (upstream is pre-1.0 and moves fast, so never ship a pin from this repo). ## 1.x roadmap diff --git a/skills/mc-cut/SKILL.md b/skills/mc-cut/SKILL.md index a4f7e88..eccca59 100644 --- a/skills/mc-cut/SKILL.md +++ b/skills/mc-cut/SKILL.md @@ -32,7 +32,7 @@ mc-pipeline routes here as soon as the graphics stage completes (mc-graphics han ## Final render (gate 4) -When the project reaches the final stage, offer the final-quality render from this skill: `uv run {skill-root}/scripts/render_final.py cut/edl.json -o renders/final.mp4 --beats beats/beats.md --graphics-dir graphics/` plus any `{workflow.final_flags}`, with `--codec` and `--crf` per `[render]` in the studio config, `--height` from the height of `[video]` delivery-resolution, `--loudness-target <[render] loudness-target>`, and `--no-loudnorm` appended when `[render] loudnorm` is false. It bakes the same EDL the creator approved with graphics composited from the approved beat table, hardware encode when available (videotoolbox on macOS; on Windows h264_nvenc, then h264_qsv, then h264_amf; on Linux h264_nvenc then h264_vaapi; each candidate validated by a one-frame test encode, libx264 fallback everywhere), persistent incremental segment rendering (the timeline is partitioned into content-addressed segments under `renders/segments/`; a re-render re-encodes only the segments whose inputs actually changed and reuses the rest, so a single tweaked graphic on a long video is a seconds-long re-render), a disk preflight, progress reporting, and boundary-frame checks. `--segment-target-seconds` (default 600) tunes the segment size; append it via `{workflow.final_flags}` when a project wants coarser or finer segments. The final render is loudness-normalized to the target LUFS with two-pass ffmpeg loudnorm (the fast preview is never normalized). Finishing in the creator's own editor from the always-exported timeline is an equally supported path; either closes gate 4. +When the project reaches the final stage, offer the final-quality render from this skill: `uv run {skill-root}/scripts/render_final.py cut/edl.json -o renders/final.mp4 --beats beats/beats.md --graphics-dir graphics/` plus any `{workflow.final_flags}`, with `--codec` and `--crf` per `[render]` in the studio config, `--height` from the height of `[video]` delivery-resolution, `--loudness-target <[render] loudness-target>`, and `--no-loudnorm` appended when `[render] loudnorm` is false. It bakes the same EDL the creator approved with graphics composited from the approved beat table, hardware encode when available (videotoolbox on macOS; on Windows h264_nvenc, then h264_qsv, then h264_amf; on Linux h264_nvenc then h264_vaapi; each candidate validated by a one-frame test encode, libx264 fallback everywhere), persistent incremental segment rendering (the timeline is partitioned into content-addressed segments under `renders/segments/`; a re-render re-encodes only the segments whose inputs actually changed and reuses the rest, so a single tweaked graphic on a long video is a seconds-long re-render), a disk preflight, progress reporting, and boundary-frame checks. `--segment-target-seconds` (default 600) tunes the segment size; append it via `{workflow.final_flags}` when a project wants coarser or finer segments. When `[render] loudnorm` is enabled (the default), the final render is loudness-normalized to the target LUFS with two-pass ffmpeg loudnorm; `--no-loudnorm` turns it off, and the fast preview is never normalized. Finishing in the creator's own editor from the always-exported timeline is an equally supported path; either closes gate 4. ## Dual timecode diff --git a/skills/mc-cut/scripts/composite_core.py b/skills/mc-cut/scripts/composite_core.py index c8d8f5f..e40de7b 100644 --- a/skills/mc-cut/scripts/composite_core.py +++ b/skills/mc-cut/scripts/composite_core.py @@ -560,12 +560,31 @@ def segment_identity(edl, seg): } +def overlay_placement(seg): + """The position-independent overlay layout of a render-segment: for each + overlay landing in it, its id, chunk-local start, dur, and image flag, + sorted. The overlay FILE digest is deliberately excluded (that lives in + segment_input_hash): re-rendering a graphic must dirty the segment's cache + entry but must NOT change its filename, so the persisted .ts is reused.""" + return sorted( + ([ov.get("id"), round(ov["start"], 6), round(ov["dur"], 6), + bool(ov.get("image"))] for ov in seg.get("overlays", [])), + key=lambda o: (o[1], str(o[0])), + ) + + def segment_id(edl, seg): """Stable filesystem-safe id for a render-segment, derived from its content - identity (not its position), so an identical slice keeps the same id and - persisted file across runs. Duplicate slices (the same source span kept - twice) legitimately share one id and one file.""" - raw = json.dumps(segment_identity(edl, seg), sort_keys=True).encode("utf-8") + identity and its overlay layout (never its timeline position), so an + identical slice carrying identical overlays keeps the same id and persisted + file across runs. Two content-identical slices are distinct ids when + DIFFERENT overlays land on them, so each overlay configuration owns its own + persisted .ts and neither is deduped onto the other. Duplicate slices with + the same overlay layout (the same span kept twice, same graphics) legitimately + share one id and one file.""" + key = {"identity": segment_identity(edl, seg), + "overlays": overlay_placement(seg)} + raw = json.dumps(key, sort_keys=True).encode("utf-8") return "seg-" + hashlib.sha256(raw).hexdigest()[:16] diff --git a/skills/mc-cut/scripts/tests/test-render_final.py b/skills/mc-cut/scripts/tests/test-render_final.py index 2f5d958..f2b17f5 100644 --- a/skills/mc-cut/scripts/tests/test-render_final.py +++ b/skills/mc-cut/scripts/tests/test-render_final.py @@ -195,6 +195,32 @@ def test_id_changes_with_content(self): self.assertNotEqual(core.segment_id(e, s), core.segment_id(e2, self.seg(e2, 0, 2))) + def test_id_distinguishes_overlays_on_identical_slices(self): + # Two content-identical slices (same source spans) that carry DIFFERENT + # overlays must get DIFFERENT ids, so neither is deduped onto the other's + # persisted .ts and each overlay configuration is encoded on its own. + e = edl_n(4) + plain = {"seg_start": 0, "seg_end": 2, "offset": 0.0, "duration": 20.0, + "overlays": []} + with_ov = {"seg_start": 0, "seg_end": 2, "offset": 0.0, "duration": 20.0, + "overlays": [{"id": "b1", "start": 1.0, "dur": 0.5, + "image": True, "path": "g/b1.png"}]} + other_ov = {"seg_start": 0, "seg_end": 2, "offset": 0.0, "duration": 20.0, + "overlays": [{"id": "b2", "start": 1.0, "dur": 0.5, + "image": True, "path": "g/b2.png"}]} + self.assertNotEqual(core.segment_id(e, plain), core.segment_id(e, with_ov)) + self.assertNotEqual(core.segment_id(e, with_ov), + core.segment_id(e, other_ov)) + # Identical slice + identical overlay layout -> same id (dedup stands). + same = dict(with_ov, offset=500.0) # different position, same content + self.assertEqual(core.segment_id(e, with_ov), core.segment_id(e, same)) + # The overlay FILE digest is not part of the id: re-rendering the graphic + # keeps the filename stable (input_hash catches the change instead). + redrawn = {"seg_start": 0, "seg_end": 2, "offset": 0.0, "duration": 20.0, + "overlays": [{"id": "b1", "start": 1.0, "dur": 0.5, + "image": True, "path": "g/b1-v2.png"}]} + self.assertEqual(core.segment_id(e, with_ov), core.segment_id(e, redrawn)) + def test_input_hash_reflects_overlay_file_and_render_key(self): e = edl_n(4) seg = {"seg_start": 0, "seg_end": 2, "offset": 0.0, "duration": 20.0,