Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "3.0.0",
"version": "3.1.0",
"author": {
"name": "Brian (BMad) Madison"
},
Expand Down
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,35 @@

All notable changes to BMad Manticore are documented here. Dates are ISO (YYYY-MM-DD).

## 3.1.0 - Unreleased

Reviewing a cut no longer means waiting for a render. Everything here came out of one real 20-minute 4K project whose gate-2 preview took 22 minutes to encode — twice, because the first one failed validation at the finish line.

### You do not render a file to review a cut any more

- New `edl_to_ffconcat.py` writes the EDL as a **virtual timeline** — an ffconcat playlist (and optionally an mpv EDL) that plays the cut with no encode at all: `ffplay -f concat -safe 0 -i cut/preview.ffconcat`. On a 379-segment 16-minute cut the timeline resolves in 33ms against 22 minutes for the equivalent render, and was verified pixel-identical (PSNR inf) to source ground truth at four points including random seeks. It carries video and audio together, so A/V sync is reviewable — which an audio-only preview cannot show.
- mc-cut now builds the virtual timeline at gate 2 and does **not** render a preview file. Rendering a full preview before the creator has approved a single call was the most expensive habit the stage had. A file gets rendered when something actually needs a file: a composited preview once overlays exist, something to share, or the gate-4 final.
- `render_preview.py --proxy-only` builds the proxy and exits, so the timeline can be built without a render.

### Preview proxies are all-intra, and that is a correctness fix

- `build_proxy_command` now encodes proxies all-intra (`-g 1 -keyint_min 1 -sc_threshold 0`). This is not a speed tweak: the concat demuxer can only cut on keyframes, so against a long-GOP proxy the virtual timeline silently plays the WRONG frames while still reporting the exact right duration. Measured at an 8.333s GOP against segments averaging 2.51s (163 of 379 under 2s), every segment began up to 8.3s early — a wrong cut that passes every check except a frame comparison. `edl_to_ffconcat.py` refuses a non-intra source unless you pass `--allow-long-gop`.
- Proxy paths carry the encode family (`<stem>-720p-intra.mp4`) and sidecars now record a recipe, so a proxy built by an older version can never be reused as if it were intra. A sidecar with no recipe line is stale by definition.
- The cost is disk: roughly 10x a long-GOP proxy (698MB vs 65MB for 20 minutes at 720p), built once per source and reused by every later preview and render.

### Two EDL bugs that only appear on long cuts

- **EDL boundaries must be quantized to the source frame grid.** They come from the audio map at 0.1s granularity and from word timestamps, so they are sub-frame by default; ffmpeg rounds each `trim` to the nearest frame and the error accumulates. A 379-segment cut rendered 954.766s against an expected 952.460s and the output gate correctly refused to publish it — the cut was fine, only the arithmetic disagreed. Past roughly 150 segments this happens every time. Now a cutting rule, with the tail-clamp for boundaries that round past `source_duration`.
- **A trailing silence must be truncated, not tightened.** Tightening the final silence at both ends like an interior gap leaves a junk segment scraped off the absolute end of the file, past the last detected silence and past `source_duration` once frame-aligned. Now a cutting rule: end the final segment on a real tail inside the verified silence.

### Applying approved cuts invalidates everything downstream, loudly

- New section in mc-cut spelling out that the moment `cut/edl.json` is rewritten, every derived artifact is stale — the virtual timeline, the FCPXML, the edited transcript, the preview, the boundary frames, and **every beat anchor in `beats/beats.md`**. Nothing on disk announces this: a stale FCPXML imports cleanly and a stale beat table renders overlays that land off their phrase, discovered after the graphics are paid for. The regeneration order is now explicit, `verify_edl.py` runs first and its non-zero exit stops the rest, and when a beat table already exists the anchor check must re-run and be reported before graphics continue.

### Not shipped, and why

- A concat-demuxer **stream copy** off the intra proxy renders the same frames ~470x faster (2.8s vs 22min, 340x realtime). It is not the preview path: it emits duplicate DTS wherever a segment runs only a frame or two, and the file then fails `render_preview.py`'s own decode validation — 125 errors on that cut, and 3 with every timestamp remedy applied (`+genpts`, `avoid_negative_ts`, `video_track_timescale`, `fps_mode passthrough`, `muxdelay 0`), never zero, from just 2 sub-0.1s segments. Publishing it would mean loosening a correctness gate to buy a speed number. `composite_core.build_streamcopy_command` keeps it, tested and documented, for a caller that wants a scratch file and accepts the caveat. The speed moved to the virtual timeline instead, which is both faster and correct.

## 3.0.0 - Unreleased

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, delivery polish (loudness, captions, OBS alpha), and a cut stage rebuilt from the ground up after its first real project. 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.
Expand Down
47 changes: 45 additions & 2 deletions skills/mc-cut/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,21 @@ uv run {skill-root}/scripts/edited_transcript.py transcript/words.json --edl cut

Both clean and source timecodes come from here; never convert between them by hand. Run the editorial pass on that transcript per `{skill-root}/references/editorial-pass.md`, writing `cut/editorial-review.md` from `{skill-root}/assets/editorial-review-template.md`. Nothing it recommends is auto-applied. RE-RECORD items are the one exception to "the cut applies the calls": there is no pickup re-entry path, so they hand over as a shoot list and the cut proceeds without them.

Write `cut/cutplan.md` carrying both tiers, each call with its timestamp and the quoted words. Routine silence trims group into one line. Always itemize section re-reads, bloopers and every content-tier recommendation, whatever their size. Set `approvals.cutplan = "pending"`, present it, and STOP for gate 2.
Write `cut/cutplan.md` carrying both tiers, each call with its timestamp and the quoted words. Routine silence trims group into one line. Always itemize section re-reads, bloopers and every content-tier recommendation, whatever their size.

Then build the VIRTUAL TIMELINE and give the creator that to watch. Do NOT render a file for gate 2.

```
uv run {skill-root}/scripts/edl_to_ffconcat.py cut/edl.json -o cut/preview.ffconcat --source renders/proxy/<source>-<height>p-intra.mp4
```

They watch it with no render at all:

```
ffplay -f concat -safe 0 -i cut/preview.ffconcat
```

Set `approvals.cutplan = "pending"`, present it, and STOP for gate 2.

## Apply the approved calls

Expand All @@ -95,9 +109,36 @@ uv run {skill-root}/scripts/snap_spans.py cut/approved-spans.json --audio-map cu

Back up the prior EDL to `cut/edl.pre-editorial.json`, rewrite `cut/edl.json` from the snapped spans, re-run `verify_edl.py`, and append the APPLIED section to `cut/editorial-review.md`.

## THE EDL CHANGED, SO EVERYTHING DERIVED FROM IT IS NOW A LIE

The moment `cut/edl.json` is rewritten, every artifact built from the previous one is stale: the virtual timeline, the FCPXML, the edited transcript, the preview, the boundary frames, and — once the beats stage has run — **every beat anchor in `beats/beats.md`**. Nothing on disk announces this. A stale FCPXML imports cleanly. A stale beat table renders overlays that land off their phrase, and that is discovered after the graphics are paid for.

So regenerate the whole derived set together, in this order, every single time the EDL changes:

```
uv run {skill-root}/scripts/verify_edl.py cut/edl.json --audio-map cut/audio-map.json --words transcript/words.json -o cut/edl-check.json
uv run {skill-root}/scripts/edited_transcript.py transcript/words.json --edl cut/edl.json -o cut/edited-transcript.md -j cut/edited-words.json
uv run {skill-root}/scripts/edl_to_ffconcat.py cut/edl.json -o cut/preview.ffconcat --source <intra proxy>
uv run {skill-root}/scripts/edl_to_fcpxml.py cut/edl.json -o cut/rough.fcpxml
```

`verify_edl.py` runs FIRST and its non-zero exit stops the rest: never regenerate derived artifacts from an EDL that has not re-passed.

**If `beats/beats.md` exists, the beat table is now suspect.** Beat times are derived from the EDL, so a cut applied after the beats stage moves every anchor downstream of it. Re-run mc-beats' anchor check and report the result to the creator before any graphics work continues:

```
uv run {skill-root}/../mc-beats/scripts/verify_anchors.py beats/beats.md --edl cut/edl.json --words transcript/words.json -o beats/anchor-check.json
```

A non-zero exit means the beat table must go back to mc-beats before mc-graphics runs again. Say so plainly rather than letting the graphics stage build against moved anchors.

## Deliver

After approval, and again after every later re-approval that changes the cut: render the preview, export the timeline, and regenerate every other derived artifact together. `{skill-root}/references/rendering.md` carries the commands, the config wiring and the staleness check. Inspect the boundary frames for what they can see, black frames and straddles, up to 3 retries per cut. They see less than they appear to: on the corrupted project every frame looked clean while the cut underneath was built on the hole.
**Do not render a file to review a cut.** The virtual timeline above is what the creator watches at gate 2 and after every re-approval: it costs nothing, it is frame-exact, and it carries video AND audio so A/V sync is reviewable. Rendering a full preview before a single call has been approved is the single most expensive habit this stage ever had — on a 379-segment 16-minute cut it was 22 minutes, paid before the creator had said yes to anything.

Render a FILE only when something actually needs a file: a composited preview once overlays exist, something to upload or share, or the gate-4 final. `{skill-root}/references/rendering.md` carries the commands, the config wiring and the staleness check.

Whenever a file does get rendered, inspect the boundary frames for what they can see, black frames and straddles, up to 3 retries per cut. They see less than they appear to: on the corrupted project every frame looked clean while the cut underneath was built on the hole.

Chapters or log notes written against source timecode remap onto the edited timeline with `uv run {skill-root}/scripts/remap_timecode.py cut/edl.json --direction orig-to-clean --chapters <file> -o <out>`, and `--direction clean-to-orig` maps back.

Expand All @@ -114,6 +155,8 @@ Final render: when the project reaches the final stage, offer the final-quality
## Cutting rules (non-negotiable)

- Never cut inside a word. Edges land inside an audio-verified silence, which makes this structural rather than aspirational. Pad 30 to 200 ms.
- **Quantize every EDL boundary to the source frame grid** (`round(t * fps) / fps`) before writing `cut/edl.json`. Boundaries come from the audio map at 0.1s granularity and from word timestamps, so they are sub-frame by default; ffmpeg rounds each `trim` to the nearest frame and the error ACCUMULATES. A 379-segment cut rendered 954.766s against an expected 952.460s and the output gate correctly refused to publish it — the cut was fine, only the arithmetic disagreed. Past roughly 150 segments this happens every time. Max shift from quantizing is half a frame (16.67ms at 30fps), far inside the 200ms silence pad, and `verify_edl.py` re-passes. Clamp any boundary that rounds past `source_duration`.
- **Truncate a TAIL silence, never tighten it.** The trailing silence after the last word is not a gap between two utterances. Tightening it at both ends leaves a junk segment scraped off the absolute end of the file, past the last detected silence and past `source_duration` once frame-aligned. End the final segment on a real tail (roughly 0.5s after the last word), inside the verified silence.
- 30 ms audio fades on every cut boundary (`fade_ms` in the EDL).
- Never shrink or letterbox the source video to make room for graphics; overlays composite over the full frame in safe zones. Nothing enforces this one, and the beats and graphics stages inherit whatever canvas this stage leaves them.

Expand Down
44 changes: 43 additions & 1 deletion skills/mc-cut/references/rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,40 @@ order rather than on any stated rule. So the flags strings are an escape hatch
for what the config does not model, such as `--segment-target-seconds`. Never
put `--height`, `--crf`, or the loudness flags in them.

## Preview
## The default is NOT to render

Reviewing a cut does not need a rendered file. The virtual timeline plays the
EDL directly — no encode, no wait, frame-exact, video and audio together:

```
uv run {skill-root}/scripts/render_preview.py cut/edl.json -o renders/preview.mp4 --proxy-only
uv run {skill-root}/scripts/edl_to_ffconcat.py cut/edl.json -o cut/preview.ffconcat \
--source renders/proxy/<stem>-720p-intra.mp4
ffplay -f concat -safe 0 -i cut/preview.ffconcat
```

`--proxy-only` builds the all-intra proxy and stops. Add `--mpv cut/preview.mpv.edl`
to `edl_to_ffconcat.py` for creators who have mpv, which scrubs better.

Measured on a 379-segment 16-minute cut: the timeline resolves in 33ms against
22 minutes for the equivalent render, and was verified pixel-identical (PSNR
inf) to source ground truth at four points including random seeks.

**The all-intra proxy is a correctness precondition, not an optimisation.** The
concat demuxer can only cut on keyframes. Against a long-GOP proxy (8.333s GOP
vs segments averaging 2.51s) every segment starts up to 8.3s early while the
total duration still matches the EDL exactly — a wrong cut that passes every
check except a frame comparison. `edl_to_ffconcat.py` refuses a non-intra
source unless you pass `--allow-long-gop`.

The proxy costs ~10x the disk of a long-GOP one (698MB vs 65MB for 20 minutes
at 720p) and is built once per source, then reused by every later preview and
render.

## Preview file, when you actually need a file

Only when something needs to be a file: a composited preview once overlays
exist, something to upload or share, or the gate-4 final.

```
uv run {skill-root}/scripts/render_preview.py cut/edl.json \
Expand All @@ -26,6 +59,15 @@ uv run {skill-root}/scripts/render_preview.py cut/edl.json \
Defaults to 720p CRF 28 when `[render]` leaves them unset. Never
loudness-normalized. Check `"validated": true` in the summary.

This path re-encodes. A concat stream copy off the intra proxy is ~470x faster
(2.8s vs 22min measured) and is genuinely the same frames, but it emits
duplicate DTS wherever a segment runs only a frame or two, and the file then
fails this script's own decode validation — 125 errors on that cut, and 3 with
every timestamp remedy applied, never zero, from just 2 sub-0.1s segments.
`composite_core.build_streamcopy_command` keeps it, tested, for a caller that
wants a scratch file and accepts the caveat. It is not the review path because
the virtual timeline is faster AND correct.

Composited, once the graphics stage has rendered overlays into `graphics/`, add:

```
Expand Down
Loading
Loading