Просмотр исходного кода

feat(skills): import proving-it-works-with-a-movie from its standalone repo

Brings the proving-it-works-with-a-movie skill (demo/screencast/proof-video
recording, plus the check-movie timeline gate that catches frozen pictures,
narration drift, and dropped words) into superpowers core, along with its
supporting docs, scripts, and shell regression tests.

Source: prime-radiant-inc/proving-it-works (MIT, same copyright holder),
skills/proving-it-works-with-a-movie/ at time of import. The five scripts
(narrate, make-subtitles, assemble, burn-subtitles, check-movie) are
self-contained uv --script files with inline PEP 723 dependency
declarations, so they port with no new project-level dependency wiring.

Test paths were adjusted one directory level to match superpowers'
tests/<skill-name>/ layout (the standalone repo kept tests/ as a
top-level sibling of skills/).

Adds a Verification entry to README's Skills Library list.

Goal: fold this into 6.4 and retire the standalone repo.
Ada Sen 3 недель назад
Родитель
Сommit
f6617db151

+ 3 - 0
README.md

@@ -295,6 +295,9 @@ Superpowers is built by [Jesse Vincent](https://blog.fsck.com) and the rest of t
 - **systematic-debugging** - 4-phase root cause process (includes root-cause-tracing, defense-in-depth, condition-based-waiting techniques)
 - **verification-before-completion** - Ensure it's actually fixed
 
+**Verification**
+- **proving-it-works-with-a-movie** - Record a demo, screencast, or proof video of software actually running, and catch the silent defects (frozen picture, narration over a dead screen, dropped words) before handing it over
+
 **Collaboration** 
 - **brainstorming** - Socratic design refinement
 - **writing-plans** - Detailed implementation plans

+ 90 - 0
skills/proving-it-works-with-a-movie/SKILL.md

@@ -0,0 +1,90 @@
+---
+name: proving-it-works-with-a-movie
+description: Use when asked for a demo, screencast, tutorial, walkthrough, or proof video of software actually running, when a reviewer needs to see a feature work rather than take your word for it, or when handing over any video artifact of app behavior
+---
+
+# Proving It Works With a Movie
+
+## Overview
+
+A movie is evidence. Every way it fails is silent: no crash, no red text,
+just an artifact that looks fine to whoever made it and is obviously broken
+to the first person who watches it.
+
+**Core principle: you have not made a movie until you have looked at the
+movie.** Not the frames going in. The finished file coming out.
+
+## Pick the route
+
+| What you have to show | Route |
+|---|---|
+| Interaction happening: typing, clicking, a list updating live | Browser-driven motion → recording-motion.md |
+| A CLI, a TUI, an install, a test run, an agent working | Terminal → recording-a-terminal.md |
+| A sequence of real states, motion optional | Composited stills → rendering-stills.md |
+| OS capture blocked (wallpaper-only frames), or the thing to prove is a *run*, not a UI | Reel rendered from the run's own log → rendering-from-a-log.md |
+
+Stills are a legitimate movie. Reach for motion only when the *motion* is
+the claim; it costs several times more to build and is where sync defects
+live.
+
+**Never** mock, stage, or reenact. If a beat can't be shown for real
+(no credentials, no data, a 40-minute job), cut it and say why. A movie
+that quietly fakes one beat is worthless as evidence for any beat.
+
+## The gate — every route, before you hand anything over
+
+```bash
+# $SKILL_DIR is this skill's own directory - the "Base directory for this
+# skill" path printed when it loads. Installed as a plugin that is
+# $CLAUDE_PLUGIN_ROOT/skills/proving-it-works-with-a-movie
+"$SKILL_DIR/scripts/narrate"        scenes.yaml narration/   # voice, gated
+"$SKILL_DIR/scripts/assemble"       scenes.yaml silent-cut.mp4
+"$SKILL_DIR/scripts/make-subtitles" narration/manifest.json movie.srt \
+                                    --offsets-json segments/offsets.json
+"$SKILL_DIR/scripts/burn-subtitles" silent-cut.mp4 movie.srt movie.mp4
+"$SKILL_DIR/scripts/check-movie"    movie.mp4      # nonzero exit: do not ship
+```
+
+It samples picture and sound on one timeline and fails the movie when the
+action is crammed into the first seconds while narration keeps talking, when
+the picture never changes, when the audio is silent, or when a narrated
+movie has no subtitles (or subtitles that quit before the narration does). It samples the
+picture at 1 Hz, so any beat that must register — a flash, a blank frame, a
+transition — has to be held longer than a second. Then:
+
+1. **Open the contact sheet it wrote and actually look at it.** Identical
+   tiles mean a frozen movie. Unreadable text means your viewport is wrong.
+2. **If narrated: transcribe the rendered audio and diff it against your
+   script.** Not the TTS engine's claim about what it said — the audio in
+   the finished file. See narrating.md.
+3. Fix, regenerate, re-run. Never patch the report instead of the movie.
+
+## The silent failures
+
+| What you get | Why it happens |
+|---|---|
+| Narrator talks over a picture that stopped moving | Sleeps guessed against narration nobody measured |
+| A word missing from the narration | Local TTS drops out-of-vocabulary terms with no error |
+| "Sure, here it is:" spoken aloud | Chat-model TTS ad-libs; it is not a TTS endpoint |
+| Clicks that appear to happen by themselves | Automation draws no cursor |
+| Wallpaper, or a blank window | OS screen-recording permission denied; capture "succeeds" |
+| A scene missing, error naming a truncated file | `ffmpeg` ate the loop's stdin (`-nostdin`) |
+| Your real data mutated | You recorded against the live tree; the movie writes |
+| Nothing visibly happens, because nothing visibly *should* | The claim is "state survived" — film the event, not the effect (recording-motion.md) |
+| A muted viewer gets nothing | Narration without subtitles. `narrate` + `make-subtitles` produce them; burn them in |
+
+## Red flags — stop
+
+- "The frames looked right" → frames are not a timeline. Run the checker.
+- "ffprobe says 27 seconds" → duration is not content.
+- "The TTS returned 200" → generation is not delivery. Transcribe it.
+- "I'll note the glitch in the handover" → regenerate it instead.
+- "Close enough to demo" → you are about to hand a reviewer a frozen movie.
+- "No API key, so no narration" → `narrate` falls back to a local voice.
+- "I'll add subtitles later" → later is after someone watched it muted.
+
+## Keep the pipeline
+
+Scene list, narration text, and build scripts are **committed files**, not
+scratch. Scratch directories get cleaned mid-production and a movie you
+can't rebuild is a movie you can't fix. See assembling.md.

+ 104 - 0
skills/proving-it-works-with-a-movie/assembling.md

@@ -0,0 +1,104 @@
+# Assembling
+
+Turning clips, stills, and narration into one file — and the ffmpeg traps
+that cost the most time.
+
+## The segment rule
+
+Per scene, the segment lasts **max(narration, visuals)**. Whichever is
+shorter gets padded:
+
+- video short → freeze the last frame (`tpad=stop_mode=clone`)
+- audio short → pad with silence (`apad`)
+
+```bash
+ffmpeg -nostdin -y -v error -i clip.mp4 -i narration.wav \
+  -filter_complex "[0:v]tpad=stop_mode=clone:stop_duration=${PAD}[v];[1:a]apad[a]" \
+  -map "[v]" -map "[a]" -t "$DUR" -r 30 -pix_fmt yuv420p \
+  -c:v libx264 -preset medium -c:a aac -ar 44100 -ac 2 segment.mp4
+```
+
+Then concat the segments (`-f concat -safe 0 -c copy`). Uniform codec
+parameters across segments are what make the stream-copy concat valid.
+
+**A long freeze-frame tail is a smell, not a fix.** If a scene's narration
+runs 20 seconds past its visuals, the scene is wrong: give the camera
+something to do, or cut the words.
+
+## `-nostdin` on every ffmpeg call inside a loop
+
+ffmpeg reads stdin by default and will eat the loop's input.
+
+```bash
+while IFS= read -r scene; do
+  ffmpeg -nostdin ...          # without this, ffmpeg swallows the rest of the list
+done < scenes.txt
+```
+
+Symptom when you forget: scenes silently skipped, and an error naming a
+*truncated* identifier (`val-landing` for `eval-landing`) because ffmpeg
+consumed part of the next line. It reads like a corrupt input file.
+
+## Title and caption cards: render HTML, screenshot it
+
+Do not fight `drawtext`. It is the fragile part of ffmpeg — under macOS
+sandbox `textfile=` fails outright ("Either text, a valid file, a timecode
+or text source must be provided") even with absolute paths. Write the card
+as HTML, screenshot it in the browser you already have open, and treat it as
+an image. You get real fonts, CSS layout, and markup accents for free.
+
+Name cards so a lexical glob orders them: `card-00` (title), `card-01..NN`
+(scenes), `card-99` (end).
+
+```bash
+ffmpeg -nostdin -y -v error -framerate 1/3 -pattern_type glob -i 'card-*.png' \
+  -r 30 -pix_fmt yuv420p out.mp4      # 1/3 = each card holds 3s
+```
+
+## Burn the subtitles in
+
+Subtitles are on by default; the checker fails a narrated movie without
+them. Burn them into the picture so they survive being dropped into Slack,
+a PR comment, or a phone — and keep the `.srt` beside the movie as the
+sidecar the checker reads (and as the searchable transcript).
+
+```bash
+scripts/make-subtitles narration/manifest.json movie.srt
+scripts/burn-subtitles silent-cut.mp4 movie.srt movie.mp4
+```
+
+Burn them at the *end*, over the assembled cut, so cue timings line up with
+the final timeline rather than per-segment offsets.
+
+Two traps the script exists to absorb:
+
+- **Burning needs libass, and many ffmpeg builds lack it.** Homebrew's
+  default macOS ffmpeg has no `subtitles` filter at all; Debian's has it.
+  `burn-subtitles` checks, and falls back to an embedded soft track with a
+  loud note rather than pretending it burned anything.
+- **ffmpeg 8 removed positional filter options.** `subtitles=movie.srt`
+  parses on 5.x and fails on 8.x with "No option name near". Write
+  `subtitles=filename=movie.srt`, which works on both.
+
+`Fontsize` is in points against the video height — check it on the contact
+sheet, because a size that reads fine at 2560px wide is unreadable when the
+movie is watched in a 400px-wide PR preview.
+
+## Verify the encode, then verify the content
+
+```bash
+ffprobe -v error -show_entries format=duration,size \
+  -show_entries stream=codec_name,width,height -of default=noprint_wrappers=1 out.mp4
+```
+
+`ffprobe` proves the container is real. It says nothing about whether the
+movie is watchable — that is `check-movie` plus your own eyes on the contact
+sheet.
+
+## Keep the pipeline out of scratch
+
+Scene list, narration text, recorder, narrate and assemble scripts belong in
+the repo. Scratch directories are cleaned by the OS between sessions; losing
+the assembler mid-production means reconstructing it from prose before you
+can re-cut a single scene. Ask before committing large media; the *pipeline*
+is small and always worth committing.

+ 90 - 0
skills/proving-it-works-with-a-movie/narrating.md

@@ -0,0 +1,90 @@
+# Narrating
+
+Narration is where the most embarrassing silent failures live: the movie
+looks perfect and says the wrong words.
+
+## Choosing a voice
+
+Listen to a sample of your actual sentences — including product names and
+jargon — before you render anything with it. A voice that mangles the one
+word your movie is about is worse than no narration.
+
+| Engine | Watch for |
+|---|---|
+| OS built-ins (`say`) | Free and instant; reliably sounds robotic. Fine for a scratch timing pass, not for delivery. |
+| Cloud TTS endpoints (e.g. `/v1/audio/speech`) | Deterministic: reads exactly what you send. The safe default. |
+| Chat models with audio output | Best prosody, but they are *chat models*: they ad-lib preambles ("Sure, here it is:"). Usable only with a verbatim gate. |
+| Local neural TTS, Piper | The default when no key is present: free, offline after a one-time voice download, runs on macOS and Linux. It **mispronounces** unusual names rather than dropping them (our jargon came back as "Smevel's", all 14 words intact) — the opposite of the failure below, and the safer one. |
+| Local neural TTS, Kokoro | Free and offline, but drops out-of-vocabulary words **silently**, with a zero exit code. "Every eval on the shelf" became "every on the shelf" with no error at all. |
+
+## Use the script
+
+`scripts/narrate scenes.yaml narration/` renders one clip per scene and
+picks its engine automatically: a cloud voice when a key is there, Piper
+when there isn't. It writes `manifest.json` with the exact text and the
+*measured* duration of every clip — which is what make-subtitles and the
+assembly step both consume, so nothing downstream has to guess timings.
+
+Force the choice with `--engine openai|openai-chat|piper`. `openai-chat`
+buys the best prosody and pays for it with ad-libs, so it is gated below.
+
+## The gate runs even without a key
+
+`narrate` listens back to every clip it renders and compares what it hears
+against the script. With a key it can use a cloud transcriber; without one
+it uses a local ASR (faster-whisper) in its own environment. The gate is not
+something you only get when you're online.
+
+What it measures is **missing or invented content**, not exact words, and
+that distinction is load-bearing. A small ASR mangles unusual names — ours
+came back as "Mevil studio" and "Yvel" — so exact matching cries wolf on
+good clips. Worse, a genuinely *dropped* word scores as more similar than
+two mispronounced ones, so a strict ratio would pass the real defect and
+fail the harmless one. The gate therefore flags a large length change or a
+run of consecutive words that went missing: a skipped sentence, an ad-libbed
+preamble, a clip that came out empty.
+
+It will not catch a single dropped word in a jargon-heavy line. For those,
+listen to one clip yourself when you pick the voice.
+
+Editing a line re-renders it: `narrate` records the text each clip was made
+from, and a clip whose script has changed is regenerated rather than reused.
+
+## The verbatim gate — required
+
+Never trust the generator's own account of what it produced. Verify the
+audio that is actually in the file:
+
+```bash
+# transcribe the RENDERED audio, then diff against the source script
+ffmpeg -nostdin -v error -i movie.mp4 -map 0:a -ac 1 -ar 16000 narration.wav
+# send narration.wav to a transcription API, then compare word sequences
+```
+
+A word-sequence diff (lowercase, strip punctuation) catches dropped jargon,
+ad-libbed preambles, and whole missing sentences. If the engine returns its
+own transcript, diff that too — it is a cheap early signal — but the
+rendered audio is the artifact that ships, so it is the one that counts.
+
+When drift is found: regenerate that block and re-verify. Retrying once
+clears chat-model preambles almost every time.
+
+## Measure durations; never guess them
+
+The single most common defect in a narrated movie is motion paced against
+narration that nobody timed. Write the script, render the audio, `ffprobe`
+each clip, *then* build video to those measured lengths.
+
+```bash
+ffprobe -v error -show_entries format=duration -of csv=p=0 narration/scene-03.wav
+```
+
+Word-count estimates (~2.5 words/sec) are for planning the script only.
+Real delivery runs long and varies per block.
+
+## Pronunciation of product names
+
+Check the sample for your own jargon before committing to a voice. If a good
+voice mangles one term, spell it phonetically **in the TTS input only**
+("S M evals"), never in the script file a human reads. Keep that
+substitution in the narrate step so the source text stays clean.

+ 90 - 0
skills/proving-it-works-with-a-movie/recording-a-terminal.md

@@ -0,0 +1,90 @@
+# Recording a terminal
+
+CLIs, TUIs, installs, test runs, agents at work — a large share of what is
+worth proving happens in a terminal, and none of it is visible to a browser
+recorder or an OS screen capture you probably can't get permission for.
+
+The technique: serve the terminal over HTTP with **ttyd**, attach it to a
+**tmux** session, screenshot the page from a browser, and drive the session
+with `tmux send-keys` from outside. Real characters from a real shell, in a
+window you fully control. `examples/film-terminal.py` is a working
+implementation of everything below.
+
+```bash
+# inside the machine/container being filmed
+tmux new-session -d -s demo -x 125 -y 34
+ttyd -p 7681 -t fontSize=17 -t 'fontFamily=DejaVu Sans Mono,monospace' \
+     -t 'theme={"background":"#101014","foreground":"#e8e6e1"}' \
+     tmux attach -t demo
+
+# from outside: drive it
+tmux send-keys -t demo 'claude plugin install proving-it-works' Enter
+docker exec CONTAINER tmux send-keys -t demo 'ls -la' Enter   # containerised
+```
+
+Size the tmux session to the browser viewport you will screenshot
+(roughly `width/10` columns by `height/22` rows at 17px) or the capture
+shows a window cropped to a different geometry than the shell believes it
+has.
+
+## Headless Chrome renders the terminal blank without software GL
+
+ttyd draws the terminal into a `<canvas>`. Headless Chrome with no GPU
+paints that canvas empty — the screenshot is a black rectangle with a
+status bar, and nothing warns you. It cost 73 blank frames to notice.
+
+```
+--use-gl=angle --use-angle=swiftshader --enable-unsafe-swiftshader
+```
+
+A related trap: setting `Emulation.setDeviceMetricsOverride` mid-session
+resizes the canvas without triggering a redraw, blanking it again. Set the
+scale at launch (`--force-device-scale-factor=2`) instead.
+
+**Preflight before every take.** Print something known, screenshot once, and
+count lit pixels; abort if the frame is empty. Filming a whole sequence and
+discovering afterwards that all of it is black is the failure this prevents:
+
+```python
+lit = sum(1 for v in frame.convert("L").getdata() if v > 90) / npixels
+if lit < 0.002:
+    raise SystemExit("terminal renders blank - check software GL flags")
+```
+
+## Never type into a program that is still running
+
+`tmux send-keys` puts characters into whatever owns the pane. If a command
+is still working, your keystrokes land in *its* stdin and appear as echoed
+text — the movie shows commands that never ran. Wait for the shell:
+
+```python
+def wait_for_shell(session):
+    while tmux(f"display-message -p -t {session} '#{{pane_current_command}}'") \
+            .strip() not in ("bash", "sh", "zsh"):
+        time.sleep(2)
+```
+
+This matters most for the interesting shots: an agent working, a build, a
+test suite. Those are exactly the commands that outlast your `sleep`.
+
+## Long work does not belong inside one take
+
+An agent run or a build takes minutes. Film the command being issued, stop
+the take, wait for the shell to come back, then film the result as a new
+take, and let the cut carry the gap with a card that says how long it took.
+Same rule as recording-motion.md: the work is real, the tedium is not.
+
+## Playing a movie inside the terminal
+
+`mpv --vo=tct movie.mp4` renders video as coloured terminal cells. It genuinely
+proves a file plays where it was made, and it looks like what it is: blocky.
+For a demo where the viewer should actually *see* the movie, cut to the movie
+itself as a segment (`kind: movie` in assemble) rather than filming a terminal
+playing it.
+
+## Glyphs
+
+Terminal fonts routinely lack the check marks and box drawing that CLIs
+emit; a missing glyph renders as a placeholder box and makes real output
+look broken. `fonts-dejavu-core` plus `-t 'fontFamily=DejaVu Sans Mono'`
+covers most of it. Check the preflight screenshot before a long session.

+ 118 - 0
skills/proving-it-works-with-a-movie/recording-motion.md

@@ -0,0 +1,118 @@
+# Recording motion from a live app
+
+For when the interaction itself is the claim. Drive a real browser against a
+real running instance; every pixel is the product.
+
+## Record against a copy, always
+
+A demo movie *writes*: it creates records, saves edits, fires jobs. Copy the
+data tree to a scratch suite and serve that. Never point the recorder at the
+tree you care about, and never at a production instance.
+
+## Two capture styles
+
+**Native video capture** (Playwright `record_video_dir`, Chrome DevTools
+screencast) gives you a continuous clip for free. Playwright needs its own
+bundled encoder — `playwright install ffmpeg` — separate from system ffmpeg.
+Good when you want one continuous take.
+
+**Deliberate frame capture** (screenshot per beat, encode at a chosen rate)
+costs more code and buys per-beat control over pacing, which is what you
+need when narration has to line up. This is the right default for a narrated
+tutorial.
+
+## Draw a cursor or the app appears haunted
+
+Browser automation moves an invisible pointer: a click looks like the UI
+changing by itself, which is exactly what a skeptical reviewer discounts.
+Inject a cursor overlay on every page and animate it to each target before
+clicking, with a press pulse on mousedown.
+
+```js
+// injected via addInitScript / Page.addScriptToEvaluateOnNewDocument
+const ring = document.createElement("div");
+ring.style.cssText = "position:fixed;width:20px;height:20px;border:3px solid " +
+  "rgba(255,64,129,.9);border-radius:50%;pointer-events:none;z-index:2147483647;" +
+  "transform:translate(-50%,-50%);transition:transform .08s";
+document.addEventListener("DOMContentLoaded", () => document.body.appendChild(ring));
+document.addEventListener("mousemove", e => {
+  ring.style.left = e.clientX + "px"; ring.style.top = e.clientY + "px";
+}, true);
+document.addEventListener("mousedown",
+  () => ring.style.transform = "translate(-50%,-50%) scale(.6)", true);
+```
+
+Type at human pace too (~55ms/char, longer after punctuation). Instant text
+insertion reads as a scripted fake even when it isn't.
+
+## Describe scenes as data, not code
+
+Put the movie in a scene list — id, narration, ordered actions — and keep the
+recorder generic. You will re-record individual scenes many times; editing a
+YAML entry beats editing a script every time. Verbs worth having:
+`goto`, `wait_for`, `click`, `type`, `append` (caret to end, then type),
+`select`, `pause`.
+
+Check your scene list against the recorder's actual verbs *before* a long
+pass. A verb the recorder doesn't implement fails at record time, after
+you've spent the wall clock.
+
+## Only type into empty fields
+
+Automation appends at whatever caret exists. To edit existing text you need
+an explicit caret move (`ControlOrMeta+ArrowDown` to end, then type).
+Anything else silently produces mangled input on camera.
+
+## When the correct behavior is invisible
+
+Some claims are proven by *nothing changing*: state survives a reload,
+a retry is idempotent, a cache returns the same answer. Filmed naively, the
+before and after frames are pixel-identical and the movie shows nothing at
+all — a viewer cannot tell the reload happened, and the mechanical gate will
+correctly report a picture that stopped moving.
+
+Stage a visible marker of **the event**, not the effect: navigate to
+`about:blank` and back rather than reloading in place, so there is a real
+teardown and a genuinely blank beat on camera, then the restored state.
+Same for a restart — show the process dying.
+
+Hold that marker beat for **more than one second**. `check-movie` samples the
+picture at 1 Hz; a 600ms blank falls between two samples and is invisible to
+the gate even though it is real. Anything you want the checker (or a viewer)
+to register needs ~1.3s or more.
+
+## Screenshot-based capture: navigation orphans an in-flight capture
+
+Driving CDP directly, a `Page.captureScreenshot` issued as a navigation
+begins never gets a reply — not slowly, *never*. A capture loop that awaits
+it hangs until whatever global timeout you have expires.
+
+Race every capture against a short timeout (~700ms) and skip the frame:
+
+```js
+const shot = await Promise.race([
+  send("Page.captureScreenshot", { format: "png" }),
+  new Promise(r => setTimeout(() => r(null), 700)),
+]);
+if (shot) writeFrame(shot.data);   // dropped frames are fine; a hung loop is not
+```
+
+## Slow real work does not fit inside a scene
+
+A genuine multi-minute operation (a model generating, a build, a deploy)
+cannot be waited out inside a recording pass — and if the recorder owns the
+server, shutting it down at end-of-pass kills the job mid-flight and leaves
+half-written artifacts.
+
+Split into passes: record up to the trigger, let the pass end, produce the
+artifact off-camera with the normal CLI, then record the pass that opens the
+finished result. The movie is honest — the work really happened — and no
+scene depends on a job outliving the process that started it.
+
+## App-specific gotchas worth checking before a pass
+
+- **Auth in the URL**: apps that read a token from `?k=` on first load and
+  scrub it need the token on the *first* navigation of each fresh context
+  only; tagging every navigation forces reloads and breaks hash routing.
+- **Typed fields with parsers**: a value like `Yes`/`No`/`On`/`Off` in a
+  YAML-backed form field saves as a boolean and can crash the app on camera.

+ 90 - 0
skills/proving-it-works-with-a-movie/rendering-from-a-log.md

@@ -0,0 +1,90 @@
+# Rendering a reel from the run's own log
+
+For when there are no pixels to capture — OS screen recording is blocked, or
+the thing to prove is a *run* (a test suite, a deploy, a job) rather than a
+UI. Render an auditable reel from the real run's log instead of fighting the
+OS for a picture.
+
+Adapted from `recording-a-proof-movie.md` in obra/superpowers PR #1931.
+
+## First: try real capture, and refuse to fake it
+
+```bash
+ffmpeg -f avfoundation -list_devices true -i ""      # probe devices
+
+ffmpeg -y -hide_banner -f avfoundation -framerate 15 -capture_cursor 1 \
+  -t 2 -i '<screen-index>:none' -vf scale=1280:-2 -pix_fmt yuv420p /tmp/cap-check.mp4
+ffmpeg -y -hide_banner -i /tmp/cap-check.mp4 -frames:v 1 /tmp/cap-check.png
+```
+
+Look at that PNG. If it is wallpaper with no app window, Screen Recording
+permission is denied for this process and capture will "succeed" while
+recording nothing. **Do not ship it.** Say plainly that the OS blocked
+capture and switch to the reel below — that pivot is the honest outcome, not
+a fallback to apologize for. (`screencapture -x` has the same limitation;
+`screencapture -x -l <windowID>` can still grab one window if you can
+resolve its CoreGraphics id.)
+
+## Make the real run the evidence source
+
+Wrap the actual command so its log carries machine-checkable markers. Use
+`bash`, not `zsh` — zsh's read-only `$status` injects a spurious error after
+a passing run and pollutes the evidence.
+
+```bash
+bash -o pipefail -c '
+  printf "RUN_KIND=<name>\n";
+  printf "STARTED_AT="; date -u +%Y-%m-%dT%H:%M:%SZ;
+  <the real command>;
+  rc=$?;
+  printf "FINISHED_AT="; date -u +%Y-%m-%dT%H:%M:%SZ;
+  printf "EXIT_STATUS=%s\n" "$rc"; exit "$rc"
+' 2>&1 | tee evidence/run.log
+```
+
+Keep each producer plus its `tee` under one `pipefail` owner, or a failing
+command's status is lost and a failed run renders as a successful movie.
+
+If the run touches a remote host or shared session, snapshot that state
+identically before and after and diff them; equal snapshots prove the run
+left no residue.
+
+## Draw frames from the log
+
+Render title / exact command / result / before-after diff / evidence-bundle
+panels as images and stream them into one ffmpeg pipe. Keep it in a saved,
+re-runnable `generate_reel.py`, not a one-shot heredoc.
+
+```python
+cmd = ["ffmpeg", "-y", "-hide_banner", "-f", "rawvideo", "-pix_fmt", "rgb24",
+       "-s", f"{W}x{H}", "-r", str(FPS), "-i", "-", "-an", "-c:v", "libx264",
+       "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p", "out.mp4"]
+proc = subprocess.Popen(cmd, stdin=subprocess.PIPE)
+for nframes, render in scenes:                     # render(t) -> PIL RGB image
+    for i in range(nframes):
+        proc.stdin.write(render(i / max(1, nframes - 1)).tobytes())
+proc.stdin.close()
+if proc.wait() != 0:
+    raise SystemExit("ffmpeg failed")
+```
+
+## Hash the bundle
+
+The reel is *derived from* the log and snapshots; they ship next to it, not
+instead of it.
+
+```bash
+shasum -a 256 out.mp4 contact-sheet.png run.log > SHA256SUMS
+shasum -a 256 -c SHA256SUMS
+```
+
+Fix anything the movie renders — a timestamp, a log line, a stale selector —
+and you regenerate the movie and re-hash. A hash that no longer matches the
+log is a lie.
+
+## Gate it
+
+`"$SKILL_DIR/scripts/check-movie" reel.mp4 --no-expect-audio` if the reel is
+silent (`$SKILL_DIR` = this skill's own directory; see SKILL.md). Then open
+the contact sheet and confirm the panels are legible at full size: a reel
+nobody can read proves nothing.

+ 50 - 0
skills/proving-it-works-with-a-movie/rendering-stills.md

@@ -0,0 +1,50 @@
+# Composited stills
+
+The cheap route, and the right one whenever the *sequence of states* is the
+claim and motion is decoration. Real screenshots of the running product,
+captioned, held long enough to read.
+
+Adapted from `rendering-a-demo-movie.md` in obra/superpowers PR #1931.
+
+## 1. Capture real scene frames
+
+Fix the viewport first so every frame composes identically. Per beat:
+navigate or drive the app into the state, screenshot to `frame-NN.png`, and
+**read the PNG back** to confirm you got the state you meant. One deliberate
+screenshot per beat; no fps.
+
+The read-back is not optional. It is what catches a shot taken mid-scroll,
+mid-animation, or before a fetch resolved — the defect that otherwise ships.
+
+## 2. Sequence the screenshots as they are
+
+Do not composite caption bars onto the stills. Subtitles carry the words
+now (assembling.md), so a caption strip burned into each frame duplicates
+them, competes with them, and has to be re-rendered every time you reword a
+sentence. The screenshot is the evidence; leave it alone.
+
+Name the shots so a lexical glob orders them — `shot-01.png` … `shot-NN.png`
+— and let the assembly step hold each one for its narration.
+
+A title and an end card are still worth having, and those genuinely are
+compositing: render them as HTML and screenshot them rather than fighting
+ffmpeg `drawtext` (see assembling.md). Name them `shot-00` and `shot-99` so
+the same glob picks them up in the right place.
+
+## 3. Hold each shot for its narration
+
+If the movie is narrated, each shot's duration is its narration clip's
+measured length (plus a short beat), not a fixed interval. This is what
+keeps a stills movie in sync by construction — the picture advances exactly
+when the sentence about it ends.
+
+Unnarrated, `-framerate 1/3` (3s per shot) is a reasonable default; anything
+faster than ~2.5s is unreadable.
+
+## 4. Gate it
+
+Run `"$SKILL_DIR/scripts/check-movie"` (see SKILL.md for the path), open the
+contact sheet, and look. A stills movie earns a
+frozen-tail warning when its final card outlasts its last narration by a
+lot — that usually means the closing card is doing too much work, or the
+last scene should have been two.

+ 204 - 0
skills/proving-it-works-with-a-movie/scripts/assemble

@@ -0,0 +1,204 @@
+#!/usr/bin/env -S uv run --quiet --script
+# /// script
+# requires-python = ">=3.10"
+# dependencies = ["pyyaml"]
+# ///
+"""Assemble scenes into one movie, each segment held to max(narration, visuals).
+
+Reads the same scenes file narrate does, so the narration you rendered and
+the picture you recorded stay in step by construction: a segment lasts as
+long as whichever of its two halves is longer, and the short one is padded
+(video freezes its last frame, audio pads with silence).
+
+It also writes segments/offsets.json — where each scene starts in the final
+cut — which make-subtitles consumes. Hand-computing those offsets is the
+step that silently breaks every time you insert or reorder a scene.
+
+Scene kinds:
+  card    title/caption rendered as HTML and screenshotted (needs a browser)
+  image   a still you already have (a contact sheet, a diagram)
+  frames  a directory of PNGs, played at `rate` fps
+  movie   an existing movie, played as itself with its own audio
+
+Usage:
+  assemble SCENES.yaml OUT.mp4 [--narration DIR] [--work DIR] [--browser PATH]
+"""
+
+import argparse
+import json
+import os
+import shutil
+import subprocess
+import sys
+import urllib.parse
+from pathlib import Path
+
+import yaml
+
+BROWSERS = [
+    "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
+    "/Applications/Chromium.app/Contents/MacOS/Chromium",
+    "chromium", "chromium-browser", "google-chrome", "google-chrome-stable",
+]
+
+CARD_HTML = """<!doctype html><meta charset="utf-8">
+<style>
+ html,body{{margin:0;width:{w}px;height:{h}px;background:{bg};color:#e8e6e1;
+  font-family:-apple-system,"Helvetica Neue",Helvetica,Arial,sans-serif;overflow:hidden}}
+ .w{{height:100%;display:flex;flex-direction:column;align-items:center;
+  justify-content:center;gap:{gap}px;text-align:center;padding:0 8%}}
+ h1{{margin:0;font-size:{title}px;font-weight:650;letter-spacing:-.02em;
+  font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:#f2f2f5}}
+ p{{margin:0;font-size:{sub}px;color:#9a9aa6;line-height:1.35}}
+</style><div class="w"><h1>{TITLE}</h1><p>{SUB}</p></div>
+"""
+
+
+def die(msg):
+    print(f"assemble: {msg}", file=sys.stderr)
+    sys.exit(1)
+
+
+def run(cmd):
+    r = subprocess.run(cmd, capture_output=True, text=True)
+    if r.returncode != 0:
+        die(f"{' '.join(map(str, cmd))}\n{r.stderr.strip()[:500]}")
+    return r
+
+
+def dur(path):
+    r = run(["ffprobe", "-v", "error", "-show_entries", "format=duration",
+             "-of", "csv=p=0", str(path)])
+    return float(r.stdout.strip())
+
+
+def find_browser(explicit):
+    for cand in ([explicit] if explicit else []) + BROWSERS:
+        if not cand:
+            continue
+        if os.path.sep in cand and Path(cand).exists():
+            return cand
+        found = shutil.which(cand)
+        if found:
+            return found
+    return None
+
+
+def make_card(scene, png, w, h, browser):
+    if not browser:
+        die("a `card` scene needs a browser (Chrome/Chromium) to render text; "
+            "pass --browser, or use an `image` scene you rendered yourself")
+    html = CARD_HTML.format(
+        w=w, h=h, bg=scene.get("background", "#101014"),
+        gap=max(16, h // 44), title=scene.get("title_size", max(28, h // 14)),
+        sub=scene.get("subtitle_size", max(16, h // 32)),
+        TITLE=scene.get("title", ""), SUB=scene.get("subtitle", ""))
+    tmp = png.with_suffix(".html")
+    tmp.write_text(html)
+    run([browser, "--headless=new", "--disable-gpu", "--hide-scrollbars",
+         f"--screenshot={png}", f"--window-size={w},{h}",
+         "--force-device-scale-factor=1", "file://" + urllib.parse.quote(str(tmp))])
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument("scenes", type=Path)
+    ap.add_argument("out", type=Path)
+    ap.add_argument("--narration", type=Path, default=None)
+    ap.add_argument("--work", type=Path, default=None)
+    ap.add_argument("--browser", default=None)
+    args = ap.parse_args()
+
+    for tool in ("ffmpeg", "ffprobe"):
+        if not shutil.which(tool):
+            die(f"{tool} not on PATH")
+
+    doc = yaml.safe_load(args.scenes.read_text())
+    base = args.scenes.parent
+    res = doc.get("resolution", {}) or {}
+    W, H = int(res.get("width", 1920)), int(res.get("height", 1080))
+    FPS = int(doc.get("fps", 30))
+    narration = args.narration or (base / "narration")
+    work = args.work or (base / "segments")
+    work.mkdir(parents=True, exist_ok=True)
+    browser = find_browser(args.browser)
+
+    fit = (f"scale={W}:{H}:force_original_aspect_ratio=decrease,"
+           f"pad={W}:{H}:(ow-iw)/2:(oh-ih)/2:color=#101014,setsar=1")
+
+    offsets, clock, concat_lines = {}, 0.0, []
+    for sc in doc["scenes"]:
+        sid = sc["id"]
+        kind = sc.get("kind", "frames")
+        seg = work / f"{sid}.mp4"
+        nar = narration / f"{sid}.wav"
+        nard = dur(nar) if nar.exists() else 0.0
+
+        if kind == "movie":
+            src = base / sc["src"]
+            target = dur(src)
+            inner_h = int(sc.get("height", int(H * 0.82)))
+            run(["ffmpeg", "-nostdin", "-y", "-v", "error", "-i", str(src),
+                 "-vf", f"scale=-2:{inner_h},pad={W}:{H}:(ow-iw)/2:(oh-ih)/2:"
+                        f"color=#101014,setsar=1",
+                 "-af", f"volume={sc.get('gain_db', 0)}dB,apad",
+                 "-r", str(FPS), "-t", f"{target:.3f}",
+                 "-c:v", "libx264", "-preset", "medium", "-pix_fmt", "yuv420p",
+                 "-c:a", "aac", "-ar", "44100", "-ac", "2", str(seg)])
+        else:
+            if kind == "frames":
+                src = base / sc["src"]
+                n = len(list(Path(src).glob("*.png")))
+                if not n:
+                    die(f"scene {sid}: no PNGs in {src}")
+                rate = float(sc.get("rate", FPS))
+                vis = n / rate
+                target = max(nard, vis)
+                vin = ["-framerate", str(rate), "-pattern_type", "glob",
+                       "-i", str(Path(src) / "*.png")]
+                # freeze the last frame when narration outlasts the action
+                vf = fit + f",tpad=stop_mode=clone:stop_duration={max(0.0, target - vis):.3f}"
+            else:
+                if kind == "card":
+                    img = work / f"card-{sid}.png"
+                    make_card(sc, img, W, H, browser)
+                elif kind == "image":
+                    img = base / sc["src"]
+                    if not img.exists():
+                        die(f"scene {sid}: no such image {img}")
+                else:
+                    die(f"scene {sid}: unknown kind {kind!r}")
+                target = max(nard, float(sc.get("duration", 3)))
+                vin = ["-loop", "1", "-i", str(img)]
+                vf = fit
+
+            ain = (["-i", str(nar)] if nar.exists()
+                   else ["-f", "lavfi", "-i", "anullsrc=r=44100:cl=stereo"])
+            run(["ffmpeg", "-nostdin", "-y", "-v", "error", *vin, *ain,
+                 "-vf", vf, "-af", "apad", "-r", str(FPS), "-t", f"{target:.3f}",
+                 "-map", "0:v:0", "-map", "1:a:0",
+                 "-c:v", "libx264", "-preset", "medium", "-pix_fmt", "yuv420p",
+                 "-c:a", "aac", "-ar", "44100", "-ac", "2", str(seg)])
+
+        actual = dur(seg)
+        # only scenes that speak get a subtitle offset; a movie played as
+        # itself carries its own subtitles already
+        if nar.exists() and kind != "movie":
+            offsets[sid] = round(clock, 3)
+        clock += actual
+        concat_lines.append(f"file '{seg.resolve()}'")
+        print(f"{sid}: {actual:.1f}s{' (own audio)' if kind == 'movie' else ''}")
+
+    listing = work / "concat.txt"
+    listing.write_text("\n".join(concat_lines) + "\n")
+    run(["ffmpeg", "-nostdin", "-y", "-v", "error", "-f", "concat", "-safe", "0",
+         "-i", str(listing), "-c", "copy", str(args.out)])
+    (work / "offsets.json").write_text(json.dumps(offsets, indent=2))
+    print(f"\nassembled {args.out} ({dur(args.out):.1f}s)")
+    print(f"scene offsets -> {work / 'offsets.json'} "
+          f"(feed to make-subtitles --offsets-json)")
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 99 - 0
skills/proving-it-works-with-a-movie/scripts/burn-subtitles

@@ -0,0 +1,99 @@
+#!/usr/bin/env -S uv run --quiet --script
+# /// script
+# requires-python = ">=3.10"
+# ///
+"""Put subtitles on a movie, by whichever route this ffmpeg supports.
+
+Burning them into the picture is what you want: subtitles survive Slack,
+PR previews, phones, and anything that plays video without a subtitle UI.
+That needs an ffmpeg built with libass, which many are not — Homebrew's
+default macOS build has no `subtitles` filter at all, while Debian's does.
+Rather than emit a command that works on half of machines, this checks and
+falls back to an embedded soft-subtitle track, telling you which you got.
+
+Usage:
+  burn-subtitles IN.mp4 SUBS.srt OUT.mp4 [--font NAME] [--size N]
+                                         [--soft] [--margin PX]
+"""
+
+import argparse
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+
+def has_libass():
+    out = subprocess.run(["ffmpeg", "-hide_banner", "-filters"],
+                         capture_output=True, text=True)
+    return any(line.split()[1:2] == ["subtitles"]
+               for line in out.stdout.splitlines() if line.strip())
+
+
+def run(cmd):
+    r = subprocess.run(cmd, capture_output=True, text=True)
+    if r.returncode != 0:
+        print(" ".join(map(str, cmd)), file=sys.stderr)
+        print(r.stderr.strip()[:600], file=sys.stderr)
+    return r.returncode == 0
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument("movie", type=Path)
+    ap.add_argument("subs", type=Path)
+    ap.add_argument("out", type=Path)
+    ap.add_argument("--font", default="DejaVu Sans")
+    ap.add_argument("--size", type=int, default=16)
+    ap.add_argument("--margin", type=int, default=30)
+    ap.add_argument("--soft", action="store_true",
+                    help="embed a soft track even if burning is available")
+    args = ap.parse_args()
+
+    if not shutil.which("ffmpeg"):
+        sys.exit("ffmpeg not on PATH")
+    for f in (args.movie, args.subs):
+        if not f.exists():
+            sys.exit(f"no such file: {f}")
+
+    if not args.soft and has_libass():
+        # ffmpeg 8 dropped positional filter options, so name it explicitly:
+        # `subtitles=movie.srt` parses on 5.x and fails on 8.x, but
+        # `subtitles=filename=movie.srt` works on both
+        # BorderStyle=3 draws a filled box behind the text. Outline-only
+        # subtitles are legible over a dark terminal and marginal over a
+        # white app screenshot; a demo movie cuts between both.
+        style = (f"FontName={args.font},Fontsize={args.size},"
+                 f"BorderStyle=3,Outline=1,Shadow=0,MarginV={args.margin},"
+                 f"PrimaryColour=&H00FFFFFF&,OutlineColour=&HB0101014&,"
+                 f"BackColour=&HB0101014&")
+        # run from the subtitle's directory: the filter treats ':' and '\' in
+        # paths as its own syntax, and quoting around that is a losing game
+        ok = run(["ffmpeg", "-nostdin", "-y", "-v", "error",
+                  "-i", str(args.movie.resolve()),
+                  "-vf", f"subtitles=filename={args.subs.name}:"
+                         f"force_style='{style}'",
+                  "-c:a", "copy", "-c:v", "libx264", "-preset", "medium",
+                  "-pix_fmt", "yuv420p", str(args.out.resolve())])
+        if ok:
+            print(f"burned into the picture -> {args.out}")
+            return 0
+        print("burn failed; falling back to a soft track", file=sys.stderr)
+
+    ok = run(["ffmpeg", "-nostdin", "-y", "-v", "error",
+              "-i", str(args.movie), "-i", str(args.subs),
+              "-c", "copy", "-c:s", "mov_text",
+              "-metadata:s:s:0", "language=eng", str(args.out)])
+    if not ok:
+        return 1
+    print(f"embedded a soft subtitle track -> {args.out}")
+    if not args.soft:
+        print("NOTE: this ffmpeg has no libass, so the subtitles are a track a "
+              "player must choose to show, not pixels. Anything that autoplays "
+              "without subtitle UI (Slack, PR previews) will show none. Install "
+              "an ffmpeg with libass to burn them in.")
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 261 - 0
skills/proving-it-works-with-a-movie/scripts/check-movie

@@ -0,0 +1,261 @@
+#!/usr/bin/env -S uv run --quiet --script
+# /// script
+# requires-python = ">=3.10"
+# dependencies = ["pillow"]
+# ///
+"""Mechanical gate for a proof/demo movie: catches the silent defects that
+per-frame inspection structurally cannot see.
+
+A movie can pass every frame check and still be unwatchable, because the
+defects live *between* frames: action crammed into the first seconds, a
+narrator talking over a picture that died, a silent audio track. This
+samples the picture and the sound on the same timeline and compares them.
+
+Thresholds are heuristics tuned against real good and bad movies. They
+catch the egregious cases; they cannot tell you a movie is *right*. That is
+what the contact sheet is for, and you have to actually look at it.
+
+Known blind spot: the picture is sampled at 1 Hz, so a visual beat shorter
+than a second (a flash, a blank frame during a reload) falls between samples
+and reads as "no change". Hold anything that matters for >1s.
+
+Usage:
+  check-movie MOVIE [--out DIR] [--no-expect-audio]
+                    [--no-expect-subtitles] [--subs FILE] [--json]
+"""
+
+import argparse
+import array
+import json
+import math
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+from PIL import Image
+
+THUMB_W = 320          # sampling width; the metric is a pixel fraction, so scale-free
+PIXEL_DELTA = 8        # per-pixel grey delta that counts as "this pixel moved"
+CHANGE_FRAC = 0.002    # >0.2% of pixels moved => the picture reached a new state
+SPEECH_DB = -45.0      # windowed RMS above this counts as "someone is talking"
+EARLY_ACTION = 0.40    # last change before this fraction of runtime => front-loaded
+TAIL_TALK_S = 5.0      # ...and this many seconds of narration after it => broken
+WARN_TAIL_S = 15.0     # frozen tail worth mentioning even when it passes
+WARN_GAP_S = 30.0      # hold this long mid-movie and a viewer wonders if it froze
+
+
+def die(msg):
+    print(f"FAIL  {msg}")
+    sys.exit(2)
+
+
+def grey(path):
+    with Image.open(path) as im:
+        return list(im.convert("L").tobytes())
+
+
+def sample_picture(movie, workdir):
+    """Per-second: fraction of pixels that moved since the previous second."""
+    frames = workdir / "samples"
+    frames.mkdir(parents=True, exist_ok=True)
+    for old in frames.glob("*.png"):
+        old.unlink()
+    out = subprocess.run(
+        ["ffmpeg", "-nostdin", "-v", "error", "-i", str(movie),
+         "-vf", f"fps=1,scale={THUMB_W}:-1", "-f", "image2", str(frames / "s%05d.png")],
+        capture_output=True, text=True)
+    if out.returncode != 0:
+        die(f"frame sampling failed: {out.stderr.strip()[:200]}")
+    paths = sorted(frames.glob("s*.png"))
+    if not paths:
+        die("no video frames could be sampled")
+    fracs, prev = [], None
+    for p in paths:
+        px = grey(p)
+        if prev is not None:
+            n = min(len(px), len(prev))
+            moved = sum(1 for i in range(n) if abs(px[i] - prev[i]) > PIXEL_DELTA)
+            fracs.append(moved / n)
+        prev = px
+    return paths, fracs
+
+
+def sample_sound(movie, has_audio):
+    """Per-second RMS in dBFS."""
+    if not has_audio:
+        return []
+    out = subprocess.run(
+        ["ffmpeg", "-nostdin", "-v", "error", "-i", str(movie),
+         "-map", "0:a:0", "-ac", "1", "-ar", "8000", "-f", "s16le", "-"],
+        capture_output=True)
+    if out.returncode != 0 or not out.stdout:
+        die(f"audio decode failed: {out.stderr.decode()[:200]}")
+    pcm = array.array("h")
+    pcm.frombytes(out.stdout[: len(out.stdout) // 2 * 2])
+    levels = []
+    for start in range(0, len(pcm), 8000):
+        chunk = pcm[start:start + 8000]
+        if not chunk:
+            break
+        rms = math.sqrt(sum(float(s) * s for s in chunk) / len(chunk))
+        levels.append(20 * math.log10(rms / 32768.0) if rms > 0 else -120.0)
+    return levels
+
+
+def contact_sheet(paths, out_path, count=12):
+    picks = paths if len(paths) <= count else [
+        paths[round(i * (len(paths) - 1) / (count - 1))] for i in range(count)]
+    thumbs = [Image.open(p).convert("RGB") for p in picks]
+    w, h = thumbs[0].size
+    # pick a column count that fills the grid exactly where possible: an
+    # empty cell reads as a black *frame*, which is a defect signal, and a
+    # sheet that lies about the movie defeats the point of the sheet
+    n = len(thumbs)
+    cols = next((c for c in (4, 3, 5, 2) if n % c == 0), min(4, n))
+    rows = math.ceil(n / cols)
+    sheet = Image.new("RGB", (cols * w, rows * h), (48, 48, 52))
+    for i, t in enumerate(thumbs):
+        sheet.paste(t, ((i % cols) * w, (i // cols) * h))
+    sheet.save(out_path)
+    return [paths.index(p) for p in picks]
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument("movie", type=Path)
+    ap.add_argument("--out", type=Path, default=None)
+    ap.add_argument("--no-expect-audio", dest="expect_audio",
+                    action="store_false", default=True)
+    ap.add_argument("--no-expect-subtitles", dest="expect_subs",
+                    action="store_false", default=True)
+    ap.add_argument("--subs", type=Path, default=None,
+                    help="sidecar .srt (default: MOVIE.srt beside the movie)")
+    ap.add_argument("--json", action="store_true")
+    args = ap.parse_args()
+
+    if not args.movie.exists():
+        die(f"no such movie: {args.movie}")
+    for tool in ("ffmpeg", "ffprobe"):
+        if not shutil.which(tool):
+            die(f"{tool} not on PATH")
+
+    workdir = args.out or args.movie.parent / f"{args.movie.stem}-check"
+    workdir.mkdir(parents=True, exist_ok=True)
+
+    meta = subprocess.run(
+        ["ffprobe", "-v", "error", "-print_format", "json",
+         "-show_format", "-show_streams", str(args.movie)],
+        capture_output=True, text=True)
+    if meta.returncode != 0:
+        die(f"ffprobe failed: {meta.stderr.strip()[:200]}")
+    info = json.loads(meta.stdout)
+    vs = [s for s in info["streams"] if s["codec_type"] == "video"]
+    as_ = [s for s in info["streams"] if s["codec_type"] == "audio"]
+    if not vs:
+        die("no video stream")
+    duration = float(info["format"].get("duration", 0))
+
+    paths, fracs = sample_picture(args.movie, workdir)
+    levels = sample_sound(args.movie, bool(as_))
+    changes = [i for i, f in enumerate(fracs) if f > CHANGE_FRAC]
+    talking = [i for i, lv in enumerate(levels) if lv >= SPEECH_DB]
+    span = len(fracs) or 1
+    last_change = changes[-1] if changes else None
+    last_talk = talking[-1] if talking else None
+
+    print(f"container  {vs[0]['codec_name']} {vs[0]['width']}x{vs[0]['height']}, "
+          f"{duration:.1f}s, audio={'yes' if as_ else 'no'}")
+    print(f"picture    reaches a new state in {len(changes)} of {span} seconds"
+          + (f"; last at {last_change}s" if last_change is not None else ""))
+    if levels:
+        print(f"sound      audible in {len(talking)} of {len(levels)} seconds"
+              + (f"; last at {last_talk}s" if last_talk is not None else ""))
+
+    failures, warnings = [], []
+    if duration < 1:
+        failures.append(f"duration is {duration:.2f}s - that is not a movie")
+    if args.expect_audio and not as_:
+        failures.append("expected narration but there is no audio stream")
+    if levels and not talking:
+        failures.append("the audio track is silent end to end")
+
+    # a narrated movie with no subtitles fails for everyone watching it muted
+    if as_ and args.expect_subs:
+        srt = args.subs or args.movie.with_suffix(".srt")
+        embedded = any(s["codec_type"] == "subtitle" for s in info["streams"])
+        if srt.exists():
+            last = 0.0
+            for line in srt.read_text(errors="replace").splitlines():
+                if "-->" in line:
+                    end = line.split("-->")[1].strip().split()[0]
+                    hh, mm, rest = end.split(":")
+                    ss, _, ms = rest.partition(",")
+                    last = max(last, int(hh) * 3600 + int(mm) * 60 + int(ss)
+                               + int(ms or 0) / 1000)
+            # compare against where the narration ends, not the runtime: a
+            # silent end card is normal and must not read as missing subtitles
+            speech_end = float(last_talk + 1) if last_talk is not None else duration
+            print(f"subtitles   {srt.name}, last cue ends at {last:.1f}s "
+                  f"(narration ends {speech_end:.0f}s)")
+            if last < speech_end - 3.0:
+                failures.append(
+                    f"subtitles stop at {last:.0f}s but the narration runs to "
+                    f"{speech_end:.0f}s - {speech_end - last:.0f}s of speech "
+                    f"has no subtitles")
+        elif embedded:
+            print("subtitles   embedded subtitle stream present")
+        else:
+            failures.append(
+                f"narrated, but no subtitles: expected {srt.name} beside the "
+                f"movie (or an embedded track). Run make-subtitles and burn "
+                f"them in; pass --no-expect-subtitles only for a movie nobody "
+                f"will ever watch muted.")
+    if not changes:
+        failures.append("the picture never reaches a new state - this is a still, "
+                        "not a movie")
+    else:
+        tail_talk = (last_talk - last_change) if last_talk is not None else 0
+        frozen_frac = (span - last_change) / span
+        if last_change < EARLY_ACTION * span and tail_talk > TAIL_TALK_S:
+            failures.append(
+                f"every visible change happens in the first {last_change}s "
+                f"({100*last_change/span:.0f}% of runtime), then the picture is "
+                f"frozen for {span - last_change}s while narration keeps talking "
+                f"for {tail_talk:.0f}s of it. The demo is over before the "
+                f"explanation starts: pace the action to the narration.")
+        elif tail_talk > WARN_TAIL_S:
+            warnings.append(f"{tail_talk:.0f}s of narration after the last visible "
+                            f"change ({100*frozen_frac:.0f}% of runtime frozen)")
+        gaps = [changes[i + 1] - changes[i] for i in range(len(changes) - 1)]
+        if gaps and max(gaps) > WARN_GAP_S:
+            warnings.append(f"{max(gaps)}s with no visible change mid-movie - "
+                            f"intentional hold, or did something hang?")
+
+    sheet = workdir / "contact-sheet.png"
+    idxs = contact_sheet(paths, sheet)
+    print(f"sheet      {sheet}")
+    print(f"           sampled at {', '.join(str(i) + 's' for i in idxs)}")
+
+    for w in warnings:
+        print(f"WARN       {w}")
+    for f in failures:
+        print(f"FAIL       {f}")
+
+    if args.json:
+        (workdir / "check.json").write_text(json.dumps(
+            {"duration": duration, "change_seconds": changes,
+             "talk_seconds": talking, "failures": failures,
+             "warnings": warnings}, indent=2))
+
+    if failures:
+        print("\nNOT SHIPPABLE. Fix, regenerate, re-run.")
+        return 1
+    print("\nMechanical checks pass. NOW OPEN THE CONTACT SHEET AND LOOK AT IT: "
+          "this script cannot see wrong content, unreadable text, a missing "
+          "cursor, or narration that says something the picture contradicts.")
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 125 - 0
skills/proving-it-works-with-a-movie/scripts/make-subtitles

@@ -0,0 +1,125 @@
+#!/usr/bin/env -S uv run --quiet --script
+# /// script
+# requires-python = ">=3.10"
+# ///
+"""Build an SRT from narrate's manifest, timed to the measured clips.
+
+Subtitles are not decoration. A movie gets watched muted - in a PR, on a
+phone, in an open-plan office, by someone who is deaf - and an unsubtitled
+narrated movie simply doesn't communicate to those viewers. They also make
+the movie searchable and let a reviewer check what was said without
+listening.
+
+Cue timing is proportional to character count within each scene's measured
+audio, which tracks speech closely enough for reading. If you need
+word-exact timing, transcribe the rendered audio with a word-timestamp API
+and use those offsets instead.
+
+Usage:
+  make-subtitles MANIFEST.json OUT.srt [--offsets SCENE=SECONDS ...]
+                                       [--max-chars N] [--max-secs S]
+"""
+
+import argparse
+import json
+import sys
+from pathlib import Path
+
+MAX_CHARS = 84          # two comfortable lines
+MAX_SECS = 5.5
+MIN_SECS = 1.0
+
+
+def cue_chunks(text, max_chars):
+    """Split into cue-sized pieces on sentence, then clause, then word."""
+    words, chunks, cur = text.split(), [], ""
+    for w in words:
+        candidate = f"{cur} {w}".strip()
+        if len(candidate) > max_chars and cur:
+            chunks.append(cur)
+            cur = w
+        else:
+            cur = candidate
+            if cur.endswith((".", "!", "?")) and len(cur) > max_chars * 0.45:
+                chunks.append(cur)
+                cur = ""
+    if cur:
+        chunks.append(cur)
+    return chunks or [text]
+
+
+def wrap(line, width=42):
+    words, out, cur = line.split(), [], ""
+    for w in words:
+        if len(f"{cur} {w}".strip()) > width and cur:
+            out.append(cur)
+            cur = w
+        else:
+            cur = f"{cur} {w}".strip()
+    if cur:
+        out.append(cur)
+    return "\n".join(out[:2]) if len(out) <= 2 else "\n".join(
+        [" ".join(out[:len(out) // 2]), " ".join(out[len(out) // 2:])])
+
+
+def ts(seconds):
+    ms = int(round(seconds * 1000))
+    h, ms = divmod(ms, 3600000)
+    m, ms = divmod(ms, 60000)
+    s, ms = divmod(ms, 1000)
+    return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument("manifest", type=Path)
+    ap.add_argument("out", type=Path)
+    ap.add_argument("--offsets", nargs="*", default=[],
+                    help="SCENE=SECONDS start overrides; without these, scenes "
+                         "are assumed to run back to back in manifest order")
+    ap.add_argument("--offsets-json", type=Path, default=None,
+                    help="segments/offsets.json from assemble - the reliable "
+                         "way to time cues against the finished cut")
+    ap.add_argument("--max-chars", type=int, default=MAX_CHARS)
+    ap.add_argument("--max-secs", type=float, default=MAX_SECS)
+    args = ap.parse_args()
+
+    manifest = json.loads(args.manifest.read_text())
+    overrides = {}
+    if args.offsets_json:
+        overrides.update({k: float(v) for k, v in
+                          json.loads(args.offsets_json.read_text()).items()})
+    for spec in args.offsets:
+        k, _, v = spec.partition("=")
+        overrides[k] = float(v)
+
+    # a scene with no offset and no place in the cut would silently land at
+    # the wrong time; skip it rather than mistime it
+    if overrides:
+        manifest = [e for e in manifest if e["id"] in overrides]
+    cues, clock = [], 0.0
+    for entry in manifest:
+        start = overrides.get(entry["id"], clock)
+        dur = float(entry["duration"])
+        chunks = cue_chunks(entry["text"], args.max_chars)
+        total_chars = sum(len(c) for c in chunks) or 1
+        t = start
+        for chunk in chunks:
+            share = dur * (len(chunk) / total_chars)
+            share = max(MIN_SECS, min(share, args.max_secs))
+            cues.append((t, min(t + share, start + dur), wrap(chunk)))
+            t += share
+        clock = start + dur
+
+    lines = []
+    for i, (a, b, text) in enumerate(cues, 1):
+        if b <= a:
+            b = a + MIN_SECS
+        lines += [str(i), f"{ts(a)} --> {ts(b)}", text, ""]
+    args.out.write_text("\n".join(lines))
+    print(f"{len(cues)} cues, ends at {ts(cues[-1][1])} -> {args.out}")
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 284 - 0
skills/proving-it-works-with-a-movie/scripts/narrate

@@ -0,0 +1,284 @@
+#!/usr/bin/env -S uv run --quiet --script
+# /// script
+# requires-python = ">=3.10"
+# dependencies = ["pyyaml", "piper-tts"]
+# ///
+"""Render one narration clip per scene, and prove it says what you wrote.
+
+Engine selection is automatic: a cloud voice when a key is available, a
+local neural voice (Piper) when there isn't one. The local path needs no
+key, no network after the first voice download, and runs on macOS and
+Linux alike - so a container with no secrets in it can still narrate.
+
+Input is a scenes file: a YAML list of scenes, each with `id` and
+`narration`. Output is OUTDIR/<id>.wav plus OUTDIR/manifest.json carrying
+the exact text and measured duration of each clip, which is what
+make-subtitles and the assembly step both read.
+
+Usage:
+  narrate SCENES.yaml OUTDIR [--engine auto|openai|openai-chat|piper]
+                             [--voice NAME] [--force]
+"""
+
+import argparse
+import base64
+import difflib
+import json
+import os
+import re
+import subprocess
+import sys
+import urllib.request
+import wave
+from pathlib import Path
+
+import yaml
+
+OPENAI_TTS_MODEL = "gpt-4o-mini-tts"      # deterministic: reads what you send
+OPENAI_CHAT_MODEL = "gpt-audio-1.5"       # better prosody, will ad-lib; gated
+PIPER_VOICE = "en_US-lessac-medium"
+
+
+def die(msg):
+    print(f"narrate: {msg}", file=sys.stderr)
+    sys.exit(1)
+
+
+def openai_key():
+    key = os.environ.get("OPENAI_API_KEY")
+    if key:
+        return key.strip()
+    try:
+        out = subprocess.run(["llm", "keys", "get", "openai"],
+                             capture_output=True, text=True, timeout=15)
+        if out.returncode == 0 and out.stdout.strip():
+            return out.stdout.strip()
+    except Exception:  # noqa: BLE001 - llm not installed is a normal outcome
+        pass
+    return None
+
+
+def norm(s):
+    return re.sub(r"[^a-z0-9 ]+", "", s.lower()).split()
+
+
+ASR_SNIPPET = """
+import sys
+from faster_whisper import WhisperModel
+m = WhisperModel(sys.argv[2], device="cpu", compute_type="int8")
+segs, _ = m.transcribe(sys.argv[1])
+print(" ".join(s.text.strip() for s in segs))
+"""
+
+
+def transcribe_local(wav, model="base.en"):
+    """Transcribe with a local ASR, in its own uv env so narrate stays light.
+    Returns None when faster-whisper isn't available."""
+    try:
+        out = subprocess.run(
+            ["uv", "run", "--quiet", "--with", "faster-whisper", "python3",
+             "-c", ASR_SNIPPET, str(wav), model],
+            capture_output=True, text=True, timeout=900)
+    except Exception:  # noqa: BLE001 - no uv, no network: gate simply unavailable
+        return None
+    return out.stdout.strip() if out.returncode == 0 and out.stdout.strip() else None
+
+
+def structural_drift(text, heard):
+    """How far a transcript diverges from the script, ignoring the noise an
+    ASR always makes.
+
+    Exact word-matching is the wrong tool here: a small model mangles
+    unusual names ("smevals" -> "Mevil"), and - worse - a *dropped* word
+    scores as more similar than two mispronounced ones. What is detectable,
+    and what actually matters, is missing or invented CONTENT: a sentence
+    the voice skipped, or a preamble it invented. Returns
+    (length_delta_fraction, longest_run_of_missing_or_changed_words).
+    """
+    want, got = norm(text), norm(heard)
+    delta = abs(len(got) - len(want)) / max(1, len(want))
+    ops = difflib.SequenceMatcher(a=want, b=got).get_opcodes()
+    worst = max((i2 - i1 for tag, i1, i2, _, _ in ops if tag in ("delete", "replace")),
+                default=0)
+    return delta, worst
+
+
+def post(url, key, body, want_json=True):
+    req = urllib.request.Request(
+        url, data=json.dumps(body).encode(),
+        headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"})
+    with urllib.request.urlopen(req, timeout=180) as r:
+        return json.load(r) if want_json else r.read()
+
+
+def say_openai(key, text, out_wav, voice):
+    data = post("https://api.openai.com/v1/audio/speech", key,
+                {"model": OPENAI_TTS_MODEL, "voice": voice or "nova",
+                 "input": text, "response_format": "wav"}, want_json=False)
+    out_wav.write_bytes(data)
+    return None                       # deterministic engine: nothing to gate
+
+
+def say_openai_chat(key, text, out_wav, voice):
+    doc = post("https://api.openai.com/v1/chat/completions", key, {
+        "model": OPENAI_CHAT_MODEL,
+        "modalities": ["text", "audio"],
+        "audio": {"voice": voice or "nova", "format": "wav"},
+        "messages": [{"role": "user", "content":
+                      "Read this narration aloud, warm and clear, verbatim, "
+                      "and say nothing else:\n\n" + text}],
+    })
+    audio = doc["choices"][0]["message"]["audio"]
+    out_wav.write_bytes(base64.b64decode(audio["data"]))
+    return audio.get("transcript", "")
+
+
+def say_piper(text, out_wav, voice):
+    from piper import PiperVoice
+    from piper.download_voices import download_voice
+    home = Path(os.environ.get("PIPER_VOICE_DIR",
+                               Path.home() / ".cache" / "piper-voices"))
+    home.mkdir(parents=True, exist_ok=True)
+    name = voice or PIPER_VOICE
+    onnx = home / f"{name}.onnx"
+    if not onnx.exists():
+        print(f"  downloading local voice {name} (one time)…")
+        download_voice(name, home)
+    v = PiperVoice.load(str(onnx))
+    with wave.open(str(out_wav), "wb") as w:
+        v.synthesize_wav(text, w)
+    return None
+
+
+def duration(path):
+    out = subprocess.run(
+        ["ffprobe", "-v", "error", "-show_entries", "format=duration",
+         "-of", "csv=p=0", str(path)], capture_output=True, text=True)
+    return round(float(out.stdout.strip()), 3)
+
+
+def main():
+    if len(sys.argv) == 4 and sys.argv[1] == "--drift-check":
+        script = Path(sys.argv[2]).read_text()
+        heard = Path(sys.argv[3]).read_text()
+        delta, worst = structural_drift(script, heard)
+        bad = delta > 0.15 or worst >= 4
+        print(f"length change {delta:.0%}, worst run {worst} -> "
+              f"{'MISMATCH' if bad else 'ok'}")
+        return 1 if bad else 0
+
+    ap = argparse.ArgumentParser()
+    ap.add_argument("scenes", type=Path)
+    ap.add_argument("outdir", type=Path)
+    ap.add_argument("--engine", default="auto",
+                    choices=["auto", "openai", "openai-chat", "piper"])
+    ap.add_argument("--voice", default=None)
+    ap.add_argument("--force", action="store_true")
+    ap.add_argument("--verify", default="auto", choices=["auto", "on", "off"],
+                    help="listen back to each clip with a local ASR and flag "
+                         "missing or invented content (default: on when the "
+                         "engine can't tell you what it said)")
+    ap.add_argument("--asr-model", default="base.en")
+    args = ap.parse_args()
+
+    doc = yaml.safe_load(args.scenes.read_text())
+    scenes = [s for s in doc.get("scenes", []) if (s.get("narration") or "").strip()]
+    if not scenes:
+        die("no scenes with narration")
+
+    key = openai_key()
+    engine = args.engine
+    if engine == "auto":
+        engine = "openai" if key else "piper"
+    if engine.startswith("openai") and not key:
+        die("no OPENAI_API_KEY (and `llm keys get openai` found nothing). "
+            "Use --engine piper for a local voice.")
+    print(f"engine: {engine}" + ("" if key or engine == "piper" else ""))
+
+    # a deterministic cloud endpoint reads exactly what you send it, so the
+    # ear-check is optional there; anything else gets listened to by default
+    verify = args.verify == "on" or (args.verify == "auto" and engine != "openai")
+
+    args.outdir.mkdir(parents=True, exist_ok=True)
+    # what the cached clips were rendered FROM: editing a line and keeping
+    # its old audio is a silent lie, and the movie will contradict itself
+    prior = {}
+    prior_path = args.outdir / "manifest.json"
+    if prior_path.exists():
+        try:
+            prior = {e["id"]: e.get("text", "") for e in
+                     json.loads(prior_path.read_text())}
+        except Exception:  # noqa: BLE001 - a corrupt manifest just means no cache
+            prior = {}
+    manifest, failures = [], []
+
+    for sc in scenes:
+        sid = sc["id"]
+        text = " ".join((sc["narration"] or "").split())
+        wav = args.outdir / f"{sid}.wav"
+        if wav.exists() and not args.force and prior.get(sid) == text:
+            print(f"{sid}: cached")
+        elif wav.exists() and not args.force and sid in prior:
+            print(f"{sid}: text changed since this clip was rendered - redoing")
+            args.force = True
+        else:
+            for attempt in (1, 2):
+                if engine == "openai":
+                    claimed = say_openai(key, text, wav, args.voice)
+                elif engine == "openai-chat":
+                    claimed = say_openai_chat(key, text, wav, args.voice)
+                else:
+                    claimed = say_piper(text, wav, args.voice)
+
+                # a chat model reports what it said: hold it to that exactly,
+                # because "Sure, here it is:" is the failure it introduces
+                if claimed is not None:
+                    want, got = norm(text), norm(claimed)
+                    drift = abs(len(want) - len(got)) + sum(
+                        1 for a, b in zip(want, got) if a != b)
+                    if drift > max(2, len(want) // 25):
+                        print(f"{sid}: engine ad-libbed (attempt {attempt}, "
+                              f"drift {drift})")
+                        continue
+
+                # every engine: listen back. An ASR mangles unusual names, so
+                # only missing or invented CONTENT counts as a failure here.
+                if verify:
+                    heard = transcribe_local(wav, args.asr_model)
+                    if heard is None:
+                        print(f"{sid}: ok (no local ASR available - gate skipped)")
+                        break
+                    delta, worst = structural_drift(text, heard)
+                    if delta > 0.15 or worst >= 4:
+                        print(f"{sid}: what came out does not match the script "
+                              f"(attempt {attempt}: {delta:.0%} length change, "
+                              f"{worst} words in a row wrong)")
+                        print(f"       heard: {heard[:120]}")
+                        continue
+                    print(f"{sid}: ok (verified by ear: {delta:.0%} length "
+                          f"change, worst run {worst})")
+                    break
+                print(f"{sid}: ok")
+                break
+            else:
+                failures.append(sid)
+        manifest.append({"id": sid, "text": text, "wav": wav.name,
+                         "duration": duration(wav)})
+
+    (args.outdir / "manifest.json").write_text(json.dumps(manifest, indent=2))
+    total = sum(m["duration"] for m in manifest)
+    print(f"\n{len(manifest)} clips, {total:.1f}s total -> {args.outdir}/manifest.json")
+    if engine == "piper":
+        print("local voice: it mispronounces unusual names rather than dropping "
+              "them - listen to one clip before you commit to a voice.")
+    if verify:
+        print("the ear-check catches missing or invented sentences, not "
+              "pronunciation: an ASR mangles jargon too.")
+    if failures:
+        print(f"FAILED verbatim delivery: {failures}", file=sys.stderr)
+        return 1
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 88 - 0
tests/proving-it-works-with-a-movie/test-assemble.sh

@@ -0,0 +1,88 @@
+#!/usr/bin/env bash
+# Regression tests for scripts/assemble and scripts/make-subtitles.
+#
+# The property that matters: a segment lasts max(narration, visuals), and
+# the scene offsets written for the subtitler match where scenes actually
+# start in the finished cut. Hand-computed offsets silently mistime every
+# cue after an inserted scene, which is why assemble emits them.
+#
+# Usage: tests/proving-it-works-with-a-movie/test-assemble.sh
+set -uo pipefail
+
+HERE="$(cd "$(dirname "$0")" && pwd)"
+SCRIPTS="$HERE/../../skills/proving-it-works-with-a-movie/scripts"
+WORK="$(mktemp -d)"
+trap 'rm -rf "$WORK"' EXIT
+pass=0; fail=0
+
+for tool in ffmpeg ffprobe uv; do
+  command -v "$tool" >/dev/null || { echo "SKIP: $tool not on PATH"; exit 0; }
+done
+
+ok() { echo "ok    $1"; pass=$((pass + 1)); }
+no() { echo "FAIL  $1"; fail=$((fail + 1)); }
+dur() { ffprobe -v error -show_entries format=duration -of csv=p=0 "$1"; }
+about() {  # about <actual> <expected> <tolerance>
+  awk -v a="$1" -v b="$2" -v t="$3" 'BEGIN{exit !(a-b<t && b-a<t)}'
+}
+
+mkdir -p "$WORK/shots"
+for i in 1 2 3 4; do
+  ffmpeg -nostdin -y -v error -f lavfi \
+    -i "color=c=0x${i}0${i}0${i}0:size=320x180:d=0.1" -frames:v 1 \
+    "$WORK/shots/s0$i.png"
+done
+# a 6-second narration stand-in, so the 4s of pictures must be padded to it
+mkdir -p "$WORK/narration"
+ffmpeg -nostdin -y -v error -f lavfi -i "sine=frequency=300:duration=6" \
+  "$WORK/narration/body.wav"
+python3 - "$WORK" <<'PY'
+import json, sys, subprocess
+w = sys.argv[1]
+d = float(subprocess.run(["ffprobe","-v","error","-show_entries","format=duration",
+    "-of","csv=p=0",f"{w}/narration/body.wav"],capture_output=True,text=True).stdout)
+json.dump([{"id":"body","text":"one two three four five six seven eight nine ten",
+            "wav":"body.wav","duration":d}], open(f"{w}/narration/manifest.json","w"))
+PY
+
+cat > "$WORK/scenes.yaml" <<'YAML'
+resolution: { width: 640, height: 360 }
+fps: 30
+scenes:
+  - id: opener
+    kind: image
+    src: shots/s01.png
+    duration: 2
+  - id: body
+    kind: frames
+    src: shots
+    rate: 1.0
+YAML
+
+out="$WORK/out.mp4"
+if "$SCRIPTS/assemble" "$WORK/scenes.yaml" "$out" >"$WORK/log" 2>&1; then
+  ok "assemble runs"
+else
+  no "assemble runs"; sed 's/^/      /' "$WORK/log"
+fi
+
+# opener 2s + body max(4s pictures, 6s narration) = 8s
+total="$(dur "$out")"
+if about "$total" 8 0.4; then ok "segment = max(narration, visuals)"
+else no "segment = max(narration, visuals): got ${total}s, wanted ~8"; fi
+
+offset="$(python3 -c "import json;print(json.load(open('$WORK/segments/offsets.json'))['body'])" 2>/dev/null)"
+if about "${offset:-0}" 2 0.3; then ok "offsets.json places the narrated scene"
+else no "offsets.json places the narrated scene: got ${offset:-none}, wanted ~2"; fi
+
+if "$SCRIPTS/make-subtitles" "$WORK/narration/manifest.json" "$WORK/out.srt" \
+     --offsets-json "$WORK/segments/offsets.json" >/dev/null 2>&1 \
+   && grep -q "00:00:0[2-9]" "$WORK/out.srt"; then
+  ok "cues start at the scene's real offset, not zero"
+else
+  no "cues start at the scene's real offset, not zero"
+fi
+
+echo
+echo "$pass passed, $fail failed"
+[ "$fail" -eq 0 ]

+ 105 - 0
tests/proving-it-works-with-a-movie/test-check-movie.sh

@@ -0,0 +1,105 @@
+#!/usr/bin/env bash
+# Regression tests for scripts/check-movie.
+#
+# Synthesizes movies with known defects using ffmpeg's lavfi sources - no
+# fixtures committed, nothing downloaded - and asserts the checker's verdict
+# on each. The front-loaded case reproduces the real failure this skill
+# exists to prevent: a movie whose action finishes in the first seconds
+# while narration keeps talking over a frozen picture.
+#
+# Usage: tests/proving-it-works-with-a-movie/test-check-movie.sh
+set -uo pipefail
+
+HERE="$(cd "$(dirname "$0")" && pwd)"
+CHECKER="$HERE/../../skills/proving-it-works-with-a-movie/scripts/check-movie"
+WORK="$(mktemp -d)"
+trap 'rm -rf "$WORK"' EXIT
+
+pass=0
+fail=0
+
+for tool in ffmpeg ffprobe uv; do
+  command -v "$tool" >/dev/null || { echo "SKIP: $tool not on PATH"; exit 0; }
+done
+[ -x "$CHECKER" ] || { echo "FAIL: $CHECKER is not executable"; exit 1; }
+
+# --- fixtures -------------------------------------------------------------
+# action for 2s, then a frozen picture for 20s, narration (tone) throughout
+ffmpeg -nostdin -y -v error \
+  -f lavfi -i "testsrc2=size=320x240:rate=10:d=2" \
+  -f lavfi -i "color=c=navy:size=320x240:rate=10:d=20" \
+  -f lavfi -i "sine=frequency=300:duration=22" \
+  -filter_complex "[0:v][1:v]concat=n=2:v=1:a=0[v]" \
+  -map "[v]" -map 2:a -c:v libx264 -pix_fmt yuv420p -c:a aac -shortest \
+  "$WORK/front-loaded.mp4"
+
+# picture changing throughout, narration throughout
+ffmpeg -nostdin -y -v error \
+  -f lavfi -i "testsrc2=size=320x240:rate=10:d=22" \
+  -f lavfi -i "sine=frequency=300:duration=22" \
+  -c:v libx264 -pix_fmt yuv420p -c:a aac -shortest "$WORK/paced.mp4"
+
+# one static frame for the whole runtime, narration throughout
+ffmpeg -nostdin -y -v error \
+  -f lavfi -i "color=c=navy:size=320x240:rate=10:d=12" \
+  -f lavfi -i "sine=frequency=300:duration=12" \
+  -c:v libx264 -pix_fmt yuv420p -c:a aac -shortest "$WORK/still.mp4"
+
+# motion, but no audio track at all
+ffmpeg -nostdin -y -v error \
+  -f lavfi -i "testsrc2=size=320x240:rate=10:d=12" \
+  -c:v libx264 -pix_fmt yuv420p "$WORK/silent.mp4"
+
+# subtitles: one covering the whole runtime, one that gives up early
+cat > "$WORK/paced.srt" <<'SRT'
+1
+00:00:00,000 --> 00:00:07,000
+A narrated movie needs subtitles:
+plenty of people watch muted.
+
+2
+00:00:07,000 --> 00:00:14,000
+The checker treats their absence
+as a defect, not a nicety.
+
+3
+00:00:14,000 --> 00:00:21,500
+And it notices when they stop
+before the narration does.
+SRT
+head -8 "$WORK/paced.srt" > "$WORK/short.srt"
+cp "$WORK/paced.mp4" "$WORK/short.mp4"
+
+# --- assertions -----------------------------------------------------------
+check() {  # check <label> <expected-exit> <must-contain> <movie> [extra args...]
+  local label="$1" want="$2" needle="$3" movie="$4"; shift 4
+  local out rc
+  out="$("$CHECKER" "$movie" --out "$WORK/$(basename "$movie" .mp4)-check" "$@" 2>&1)"
+  rc=$?
+  if [ "$rc" -ne "$want" ]; then
+    echo "FAIL  $label: exit $rc, wanted $want"
+    echo "$out" | sed 's/^/      /'
+    fail=$((fail + 1)); return
+  fi
+  if ! printf '%s' "$out" | grep -qi -- "$needle"; then
+    echo "FAIL  $label: output missing '$needle'"
+    echo "$out" | sed 's/^/      /'
+    fail=$((fail + 1)); return
+  fi
+  echo "ok    $label"
+  pass=$((pass + 1))
+}
+
+check "front-loaded action is rejected"       1 "every visible change happens in the first" "$WORK/front-loaded.mp4"
+check "paced + subtitles is accepted"         0 "Mechanical checks pass"                    "$WORK/paced.mp4"
+check "narrated without subtitles rejected"   1 "no subtitles"                              "$WORK/paced.mp4" --subs "$WORK/nope.srt"
+check "subtitles that stop early rejected"    1 "subtitles stop at"                         "$WORK/short.mp4"
+check "subtitle check is opt-outable"         0 "Mechanical checks pass"                    "$WORK/paced.mp4" --subs "$WORK/nope.srt" --no-expect-subtitles
+check "a still with audio is rejected"        1 "never reaches a new state"                 "$WORK/still.mp4"
+check "missing narration is rejected"         1 "no audio stream"                           "$WORK/silent.mp4"
+check "silent movie passes when unnarrated"   0 "Mechanical checks pass"                    "$WORK/silent.mp4" --no-expect-audio
+check "a contact sheet is always written"     0 "contact-sheet.png"                         "$WORK/paced.mp4"
+
+echo
+echo "$pass passed, $fail failed"
+[ "$fail" -eq 0 ]

+ 54 - 0
tests/proving-it-works-with-a-movie/test-narrate.sh

@@ -0,0 +1,54 @@
+#!/usr/bin/env bash
+# Regression tests for the narration gate's drift rule.
+#
+# The rule has to survive a real asymmetry: an ASR mangles unusual names, so
+# exact word-matching produces false alarms — and, worse, a *dropped* word
+# scores as more similar than two mispronounced ones. So the gate measures
+# missing/invented CONTENT, and these tests pin that distinction.
+#
+# Usage: tests/proving-it-works-with-a-movie/test-narrate.sh
+set -uo pipefail
+
+HERE="$(cd "$(dirname "$0")" && pwd)"
+NARRATE="$HERE/../../skills/proving-it-works-with-a-movie/scripts/narrate"
+WORK="$(mktemp -d)"
+trap 'rm -rf "$WORK"' EXIT
+pass=0; fail=0
+
+command -v uv >/dev/null || { echo "SKIP: uv not on PATH"; exit 0; }
+
+drift() {  # drift <label> <expect-exit> <script> <heard>
+  local label="$1" want="$2"
+  printf '%s' "$3" > "$WORK/script.txt"
+  printf '%s' "$4" > "$WORK/heard.txt"
+  local out rc
+  out="$("$NARRATE" --drift-check "$WORK/script.txt" "$WORK/heard.txt" 2>&1)"; rc=$?
+  if [ "$rc" -eq "$want" ]; then
+    echo "ok    $label  ($out)"; pass=$((pass + 1))
+  else
+    echo "FAIL  $label: exit $rc wanted $want ($out)"; fail=$((fail + 1))
+  fi
+}
+
+SCRIPT="This is smevals studio. Every eval on the shelf is a folder of tasks and graders."
+
+# the ASR mangles jargon on a perfectly good clip - must NOT fail
+drift "mispronounced jargon passes" 0 "$SCRIPT" \
+  "This is Mevil studio. Every Yvel on the shelf is a folder of tasks and graders."
+
+# identical - must pass
+drift "exact transcript passes" 0 "$SCRIPT" "$SCRIPT"
+
+# the voice skipped a whole clause - must fail
+drift "a dropped clause fails" 1 "$SCRIPT" "This is smevals studio."
+
+# a chat model prepended a preamble - must fail
+drift "an invented preamble fails" 1 "$SCRIPT" \
+  "Sure, here it is, happy to help with that. This is smevals studio. Every eval on the shelf is a folder of tasks and graders."
+
+# a silent/garbage clip - must fail
+drift "an empty clip fails" 1 "$SCRIPT" "you"
+
+echo
+echo "$pass passed, $fail failed"
+[ "$fail" -eq 0 ]