Explorar el Código

Merge pull request #2214 from ada-sen/import/proving-it-works-skill

feat(skills): import proving-it-works-with-a-movie skill
Drew Ritter hace 10 horas
padre
commit
dd53fe0b57
Se han modificado 35 ficheros con 6741 adiciones y 0 borrados
  1. 3 0
      README.md
  2. 105 0
      docs/superpowers/plans/2026-09-11-movie-committee-repairs.md
  3. 60 0
      docs/superpowers/plans/2026-09-11-movie-review-fixes.md
  4. 72 0
      docs/superpowers/specs/2026-09-09-proof-movie-windows-completion-design.md
  5. 103 0
      skills/proving-it-works-with-a-movie/SKILL.md
  6. 172 0
      skills/proving-it-works-with-a-movie/assembling.md
  7. 632 0
      skills/proving-it-works-with-a-movie/examples/film-terminal.py
  8. 120 0
      skills/proving-it-works-with-a-movie/narrating.md
  9. 232 0
      skills/proving-it-works-with-a-movie/recording-a-terminal.md
  10. 143 0
      skills/proving-it-works-with-a-movie/recording-motion.md
  11. 92 0
      skills/proving-it-works-with-a-movie/rendering-from-a-log.md
  12. 50 0
      skills/proving-it-works-with-a-movie/rendering-stills.md
  13. 237 0
      skills/proving-it-works-with-a-movie/scripts/assemble
  14. 129 0
      skills/proving-it-works-with-a-movie/scripts/browser_tools.py
  15. 109 0
      skills/proving-it-works-with-a-movie/scripts/burn-subtitles
  16. 293 0
      skills/proving-it-works-with-a-movie/scripts/check-movie
  17. 171 0
      skills/proving-it-works-with-a-movie/scripts/make-subtitles
  18. 29 0
      skills/proving-it-works-with-a-movie/scripts/media_paths.py
  19. 422 0
      skills/proving-it-works-with-a-movie/scripts/narrate
  20. 41 0
      skills/proving-it-works-with-a-movie/scripts/narration_contract.py
  21. 29 0
      tests/proving-it-works-with-a-movie/README.md
  22. 299 0
      tests/proving-it-works-with-a-movie/fixtures.py
  23. 41 0
      tests/proving-it-works-with-a-movie/fixtures/terminal_app.py
  24. 64 0
      tests/proving-it-works-with-a-movie/run-tests.py
  25. 409 0
      tests/proving-it-works-with-a-movie/test_assembly.py
  26. 73 0
      tests/proving-it-works-with-a-movie/test_browser.py
  27. 112 0
      tests/proving-it-works-with-a-movie/test_browser_contract.py
  28. 194 0
      tests/proving-it-works-with-a-movie/test_checker.py
  29. 351 0
      tests/proving-it-works-with-a-movie/test_narration.py
  30. 546 0
      tests/proving-it-works-with-a-movie/test_narration_contract.py
  31. 104 0
      tests/proving-it-works-with-a-movie/test_paths.py
  32. 538 0
      tests/proving-it-works-with-a-movie/test_recorder_contract.py
  33. 290 0
      tests/proving-it-works-with-a-movie/test_subtitle_contract.py
  34. 162 0
      tests/proving-it-works-with-a-movie/test_subtitles.py
  35. 314 0
      tests/proving-it-works-with-a-movie/test_terminal.py

+ 3 - 0
README.md

@@ -303,6 +303,9 @@ Superpowers is built by [Jesse Vincent](https://blog.fsck.com) and the rest of t
 - **verification-before-completion** - Ensure it's actually fixed
 - **verification-before-completion** - Ensure it's actually fixed
 - **diagnosing-superpowers** - Work out what went wrong in a session, with evidence; export a scrubbed bundle or file an issue
 - **diagnosing-superpowers** - Work out what went wrong in a session, with evidence; export a scrubbed bundle or file an issue
 
 
+**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** 
 **Collaboration** 
 - **brainstorming** - Socratic design refinement
 - **brainstorming** - Socratic design refinement
 - **writing-plans** - Detailed implementation plans
 - **writing-plans** - Detailed implementation plans

+ 105 - 0
docs/superpowers/plans/2026-09-11-movie-committee-repairs.md

@@ -0,0 +1,105 @@
+# Movie committee repairs implementation plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Repair the full-PR committee's concrete failure cases in #2214, including the narration producer/consumer gap that survived narrow reviews.
+
+**Architecture:** Keep the five tools, adjacent helpers, recorder, and existing artifact formats. Acceptance must be withdrawn before accepted bytes can change, and assembly must enforce current scene intent against that acceptance. Keep timing inside measured intervals and resource cleanup around acquisition.
+
+**Tech Stack:** Python 3.10+, unittest, existing uv dependencies, Bash and JavaScript recipe examples.
+
+**Spec:** `docs/superpowers/specs/2026-09-09-proof-movie-windows-completion-design.md`, interpreted with Drew's current instructions and the committee findings recorded under `.superpowers/review/pr2214/committee/`.
+
+## Global Constraints
+
+- Keep the existing five tool CLIs, scene kinds, narration manifest, offsets, SRT files, and checker behavior. Preserve macOS/Linux and native Windows operation.
+- Keep existing dependencies; no process framework, language-segmentation dependency, compatibility layer, or new evaluation harness.
+- Drew personally watches the movies for final acceptance. Do not generate or inspect media, run real movie checkers, synthesis, ASR, FFmpeg, browsers, ttyd, or image/frame inspection. Test real Python decisions using text/byte sentinels and mocked external boundaries only.
+- Preserve failed evidence and do not claim mock-based checks establish live media acceptance.
+- Keep `auto/on/off`, drift thresholds, authoritative browser selection, partial manual offsets, intentional silence, and source-movie own-audio behavior.
+- Work only in `/Users/drewritter/.paseo/worktrees/2mmrq9t5/pr2214-narration-cache`; do not modify the parent checkout, merge, or push from workers.
+- Tests exercise meaningful behavior and structured inputs, never large command/script/HTML string matches. No whole media test suites; select only verified safe classes.
+- Implementers do not spawn subagents. Follow TDD, self-review, commit explicit files with a detailed message, and report RED/GREEN commands and evidence.
+
+### Task 1: Make narration acceptance survive reruns and govern assembly
+
+**Files:** `scripts/narrate`, `scripts/assemble`, `scripts/media_paths.py`, `scripts/check-movie`, optional small adjacent narration-contract helper; `test_narration.py`, `test_assembly.py`, and new safe contract test module under `tests/proving-it-works-with-a-movie/`. All script paths are under `skills/proving-it-works-with-a-movie/`.
+
+**Interfaces:** Preserve manifest fields `id`, `text`, `wav`, `duration`, `synthesis`. Normalize scene text for identity using `" ".join(text.split())`, without dropping Unicode or punctuation. Resolve each manifest WAV relative to the narration directory. Transcript comparison is a separate, tolerant operation. Existing movie scenes retain their own source audio and omit narration offsets.
+
+- [x] Add failing tests before code: accepted two-scene render, forced rejection of first scene, exception in second, normal retry never reuses rejected bytes; strict unavailable verification withdraws acceptance; unchanged accepted settings still cache. Inject failures in synthesis and duration and assert published entries cannot point at rejected replacements. Preserve failed bytes as evidence.
+- [x] Publish manifest atomically (`temporary.write_text(..., encoding="utf-8"); temporary.replace(manifest_path)`) and withdraw acceptance before overwriting any referenced WAV. Publish each accepted scene only after transcript/ASR gates and duration succeed. Preserve the existing bounded attempts and no-narration CLI behavior. Do not silently create a fallback acceptance record.
+- [x] Add unsupported-comparison tests for unrelated Chinese/Japanese (including short and mixed-script text), symbols-only scripts, multiline text, accented Latin, and spaced Cyrillic. Use Unicode casefold/normalization and whitespace-preserving tokenization. Explicitly detect scripts requiring segmentation; do not infer support from average word length or introduce CJK character thresholds. Unsupported comparisons report unavailable/nonzero under `--drift-check` and `--verify on`; `auto` warns/allows unavailable ASR, `off` bypasses ASR. The mandatory chat transcript gate never accepts unsupported comparison, including existing cached chat clips under off/auto. Preserve supported-word thresholds and empty/missing speech rejection.
+- [x] Preflight required ffprobe before synthesis, with a meaningful missing-tool test. Keep existing mocked tests portable by mocking this boundary.
+- [x] Add safe assembly tests: removed narration ignores leftover WAV and creates no offset; required narration with missing manifest/entry/WAV or changed text fails before encoding; accepted referenced WAV is selected even when named differently; movie scenes retain own audio and no narration offset. Validate all narration requirements before encoding the first segment. Update existing media fixture declarations/manifests to reflect the contract without running those media suites.
+- [x] Correct movie segment inputs: probe audio-stream presence, supply anullsrc only for silent source movies, explicitly map selected source video/audio, and fit both width and requested inner height before padding. Test input selection and computed geometry with mocked probes/encoder, including 2560x1080 into 1920x1080. No real encoding.
+- [x] Correct percent-bearing sequence paths in assembly and checker: escape only literal directory percent signs (or use controlled cwd plus fixed basename), retaining the intended `%08d`/numbering placeholder. Test the path helper/selected input-output target with byte sentinels; never sample media.
+- [x] Run safe narration and new assembly/path contract tests; retain existing cache/drift regressions. Self-review and commit.
+
+### Task 2: Keep every subtitle inside its scene and select the supplied track
+
+**Files:** `skills/proving-it-works-with-a-movie/scripts/make-subtitles`, `scripts/burn-subtitles`, `scripts/check-movie` (SRT text parser only); `tests/proving-it-works-with-a-movie/test_subtitles.py` or a separate safe subtitle contract module, safe checker policy/parser regressions, and `run-tests.py`.
+
+**Interfaces:** Manifest schema stays unchanged. Assembly offsets select membership and start times; manual offsets retime selected scenes without reintroducing omitted scenes. Task 1 makes assembly offsets refer only to accepted narration.
+
+- [x] Add failing timing tests by parsing emitted SRT: five chunks in 0.5 seconds followed immediately by another scene; one chunk in 12 seconds; mixed-length chunks; submillisecond/invalid duration handling; empty cut; nonzero manual offsets. Assert every word survives, ordered positive millisecond cue intervals stay within the measured scene, the final cue covers the narration end within rounding, and reported end agrees with emitted cues.
+- [x] Allocate proportional durations over the entire scene. Readability limits can guide splitting/allocation but cannot overflow or truncate the measured interval. Coalesce chunks if there are fewer representable milliseconds than chunks; reject unrepresentable/nonpositive durations clearly rather than emit invalid cues. Remove the serialization fallback that extends collapsed cues by one second.
+- [x] Preserve assembly membership selection as the manifest/offset intersection, including unknown offset keys producing no cues, and partial manual offsets. Enforce accepted membership in Task 1's assembly handoff tests rather than changing this existing subtitle CLI contract.
+- [x] Add one safe producer/consumer regression that invokes narrate, assemble, and make-subtitles sequentially using their real files and mocked synthesis/probing/encoding. Remove an opening scene's narration on rerun: its old WAV survives as evidence, assembly omits that audio/offset, and the remaining scene's caption starts at its measured assembly offset. Assert emitted manifest/offset/SRT data and chosen inputs, not full commands.
+- [x] Add safe replacement-track tests for explicit soft mode and hard-burn fallback by inspecting selected stream maps, including an input already containing subtitles and a video without audio. Explicitly select `0:v:0`, optional source audio, and `1:s:0` from the supplied SRT. Preserve truthful fallback diagnostics.
+- [x] Repair `subtitle_end` to read cue timing lines rather than any caption text containing an arrow. Root reproduced a valid cue with `Follow source --> destination.` raising ValueError. Add text-only parser tests for literal arrows, fake timestamps in caption text, actual malformed cue timing, empty subtitles, and multiple cues; keep the checker's existing last-cue policy unchanged.
+- [x] Register a `contracts` test-runner suite for `test_*contract*.py`, so the new safe regression files run through the normal entrypoint and are included in `all`. Keep only mocked/text-based tests in those modules. Verify by executing `--suite contracts`, never `--suite all` in this pass.
+- [x] Run only safe subtitle classes (existing offset/path tests and BOM/fallback cases plus new contract tests). Self-review and commit.
+
+### Task 3: Own recorder resources from acquisition through shutdown
+
+**Files:** `skills/proving-it-works-with-a-movie/examples/film-terminal.py`, `tests/proving-it-works-with-a-movie/test_terminal.py` or a separate safe recorder contract module.
+
+**Interfaces:** Preserve `serve/run/key/watch/close`, prompt markers, session files, exit 1 on failure and exit 2 only for a live unfinished command, 5 fps timing, and 1600x900 viewport. Preserve logs; only remove the recorder's own profile and readiness state.
+
+- [x] Add startup-failure tests with fake Popen handles and no processes: first/second log open, ttyd launch, browser launch, session metadata write, terminal log open, later connection failure. Verify each acquired process is cleaned, handles close, failed startup cannot remain ready, and profile cleanup is owned. Register resources immediately under an encompassing try/finally (or ExitStack); no general process framework.
+- [x] Resolve session directory before child arguments, cwd, and profile construction. Test a relative path against the actual child argv/cwd relationship using fake processes.
+- [x] Make serve the cleanup owner: close writes the existing stop request and waits with one overall 30-second deadline for completed cleanup, instead of killing historical numeric PIDs. Honor stop requests inside startup pump/retry loops too. Retire PID metadata atomically only after confirmed process and profile cleanup and remove stale ready state; test repeated close after cleanup kills nothing and retains logs. A wait timeout, unreadable/missing metadata, unavailable serve, or failed profile removal is not successful cleanup and returns nonzero. The deadline accommodates an active bounded CDP call, both five-second child waits, and profile release. Avoid creating a new cross-platform PID-identity protocol.
+- [x] Add no-record observation tests for connection/session loss using fake CDP and log text. Poll connection/liveness while waiting; a dead session is failure, never `running`. A live timed-out command still returns 2. Do not consume prompt status incorrectly or lose completed native exit status.
+- [x] Test timing using a fake clock, capture callback returning byte tokens, and mocked writes: a final capture crossing a one-second endpoint must fill exactly the bounded 5 fps slots; also cover hold endpoint, normal completion and mid-capture stalls. No real image/frame inspection or integration tests.
+- [x] Fix ST-terminated OSC stripping and test both BEL/ST markers in actual visible-text parsing. Emit ASCII-escaped stdout JSON (or explicit UTF-8) and test a CP1252 stream with René and λ; keep UTF-8 JSON files.
+- [x] Register real-session test cleanup immediately after Popen so failed setUp cannot leak it; do not run SessionTests in this pass. Retain the tests' real owned-process assertions when shutdown clears PID metadata: snapshot owned IDs for assertions while the session is live rather than iterating an empty completed list. Run safe prompt, serve-argument, and new recorder contract tests; self-review and commit.
+
+### Task 4: Make shipped recipes preserve failures and measured timing
+
+**Files:** `skills/proving-it-works-with-a-movie/SKILL.md`, `assembling.md`, `rendering-from-a-log.md`, `recording-motion.md`, `recording-a-terminal.md`, `narrating.md`, `tests/proving-it-works-with-a-movie/README.md`; focused documentation test evidence under ignored review/SDD directories.
+
+**Interfaces:** Documentation describes Tasks 1-3's existing formats and corrected behavior. Existing evidence standards and Windows shell recipes remain intact. This is a focused reference correction, not a broad skill-policy rewrite.
+
+- [x] Use writing-skills. Preserve the before-change fresh-reader reference trial and execute the original recipes with fake producer commands and log fixtures to record failures. No real media or OS recording.
+- [x] Put `set -euo pipefail` in the primary Unix pipeline's executing Bash scope, retaining all five commands and offsets. In the assembling subtitle example pass `--offsets-json segments/offsets.json` and stop if subtitle production fails. Explain current scene plus accepted manifest, relative WAV reference, and movie own-audio behavior in one paragraph.
+- [x] Put the producer-to-tee logging pipeline under the shell that owns pipefail, preserving the real producer exit status and printed STARTED/FINISHED/EXIT_STATUS markers. Execute the recipe with failing and passing fake producers; assert shell status and log content without matching a large rendered command.
+- [x] Restore the cursor transform on mouseup. Execute the documented JavaScript with a minimal fake DOM/event dispatcher and assert repeated press/release state changes; no browser or pixels.
+- [x] State that session directories, like take directories, must be new or empty on retries. Explain that close requests cleanup from serve, waits up to 30 seconds, and reports failure when the owner is unavailable or cleanup fails; hard-killing serve can leave its children and stale readiness, so these signals cannot establish a live owner. Document unsupported transcript verification and existing retry behavior accurately without adding a user approval gate.
+- [x] Document the new `--suite contracts` entrypoint as safe mocked/text checks, distinct from existing media/session suites and live acceptance.
+- [x] Fresh-reader candidate trials use the same bounded scenarios as baseline, then execute supplied commands with fake boundaries. Preserve both failures and successes; do not call this full skill evaluation. Self-review and commit documentation plus concise results in this plan.
+
+Task 4 executable snippet result: the preserved baseline returned success after
+an assembly failure and a failed logged producer, emitted subtitles at zero
+instead of the measured two-second offset, and left the cursor pressed after
+mouseup. After the focused guide edits, the fake-boundary harness preserves
+assembly exit 41 and logger exit 23, stops later producers, retains prior
+outputs, runs all five stages on success, emits the subtitle interval at
+`00:00:02,000`, and restores the cursor on two releases. The safe `contracts`
+entrypoint passes 66 tests. Two independent fresh-reader candidate trials also
+passed the bounded command checks recorded below; these checks are not full
+skill evaluation or live movie acceptance.
+
+## Final verification and review
+
+Run the safe accumulated contract selection once after all code changes. Have an independent reviewer read the full accumulated PR from `fd02874aa5c55ba3c2bca431253b48e0e4c8be5a` through the final head, including docs/spec and tests, and resolve concrete remaining findings. Push only to Ada's `import/proving-it-works-skill` branch after passing review, verify #2214's remote head, and reply to the four current external threads with exact evidence. Do not merge. Drew's viewing remains final acceptance.
+
+## Consolidated verification results
+
+- Narration/assembly, subtitle, recorder, and guide tasks each passed independent spec and quality review after their recorded fix rounds.
+- At `b206e0cb`, the normal `--suite contracts` entrypoint passed 66 mocked/text tests and the selected existing portable regressions passed 45 tests: 111 safe tests total. No media/session suites ran.
+- Executing the original guide snippets with fake producers reproduced lost failure statuses, missing measured subtitle offsets, continued burning after subtitle failure, and a cursor that stayed pressed. The corrected snippets passed failure and success cases while preserving prior output evidence.
+- Two independent fresh readers used the candidate guides. Their supplied Bash commands passed the bounded failed-rebuild, producer-status, evidence-preservation, and measured-caption-offset checks with fake tool boundaries. These are focused reference trials, not a full skill evaluation or native workflow acceptance.
+- The before-change fresh reader independently supplied fail-fast/offset corrections but also deleted prior outputs. No before/after agent success-rate improvement is claimed.
+- Logs, rejected-attempt evidence, reports, and review packages remain in the worktree's ignored review/SDD directories. The parent checkout remains untouched.
+- Final whole-PR review and normal push to the existing Ada-fork PR head follow these results. Drew's viewing remains the final acceptance decision; this pass does not merge the PR.

+ 60 - 0
docs/superpowers/plans/2026-09-11-movie-review-fixes.md

@@ -0,0 +1,60 @@
+# Movie review fixes implementation plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
+
+**Goal:** Repair the reproduced findings on #2214/#2275 while retaining the existing movie workflow.
+
+**Architecture:** Keep the recorder and five media tools. Refuse reused take directories before sending input; publish only accepted narration in the manifest. Correct the Windows recipes and missing-capability test handling.
+
+**Tech Stack:** Python, unittest, uv, ttyd, Chromium, PowerShell and Git Bash.
+
+**Spec:** `docs/superpowers/specs/2026-09-09-proof-movie-windows-completion-design.md`
+
+## Global constraints
+
+- Native Windows 11 x64: PowerShell 5.1, PowerShell 7, Git Bash; invoking and recorded shells match.
+- Preserve the Unix recipe, existing CLI interfaces, media formats, and verification policy.
+- Drew approved the necessary third-party dependencies on 2026-09-11: "those deps are normal and fine". Remove only the unused `websockets` test dependency.
+- Drew personally watches the movies for final acceptance. Do not run movie checkers, audio transcription, or image inspection for this repair pass. Mock external media boundaries in code-contract tests.
+- Preserve old takes and failed audio as evidence. Do not delete user output directories.
+- No new framework, backwards compatibility layer, merge, or external review comment.
+
+### Task 1: Repair reusable output and missing-capability contracts
+
+**Files:**
+- Modify `skills/proving-it-works-with-a-movie/examples/film-terminal.py` and `scripts/narrate`.
+- Modify `tests/proving-it-works-with-a-movie/test_terminal.py`, `test_narration.py`, `test_subtitles.py`, and `run-tests.py`.
+
+**Interfaces:** Existing recorder `film`, `run`, `key`, `watch`, and `main`; existing narration manifest; existing unittest runner.
+
+- [x] Add regressions that execute real cache/filesystem behavior with fake external synthesis/capture. Two rejected `openai-chat --verify off` invocations must both return 1 and regenerate, with failed scenes absent from the manifest; an accepted scene still caches. A cached clip rejected by strict ASR must be removed from the manifest. No actual ASR or media inspection.
+- [x] Add a frame-directory regression with existing PNGs plus a sentinel file: recording must fail before capture and preserve every byte. Test CLI refusal before connecting or typing, for run/key/watch. Keep empty/new directories valid.
+- [x] Run focused tests to observe the known failures.
+- [x] Implement the smallest guards. Before manifest append: `if sid in failures: continue`. Share a small nonempty-directory guard between the CLI preflight and direct `film()` path; use `any(out.iterdir())` only after checking existence. Report a clear error instructing the caller to use a new take directory. Guard before run/key side effects, not only inside `film`.
+- [x] Mock `module.shutil.which` in subtitle unit tests. In the real subtitle integration test call `fixtures.missing_executables("uv", "ffmpeg")` and skip before invoking `has_libass` if missing. Preserve strict runner rejection of skipped capabilities.
+- [x] Remove only `websockets` from the runner's inline dependency list.
+- [x] Run narration and recorder unit tests. Run subtitle tests with an isolated PATH containing uv and no FFmpeg; ordinary mode must pass with a capability skip, strict mode must fail because of that skip. Do not run media integration tests with FFmpeg available.
+- [x] Self-review and commit only owned files. Report commands, results, commits, and any concerns.
+
+### Task 2: Make Windows recipes executable from a fresh directory
+
+**Files:** Modify `skills/proving-it-works-with-a-movie/recording-a-terminal.md`; record local before/after instruction evidence separately from shipped guidance.
+
+**Interfaces:** Existing serve/run/key/watch/close CLI, readiness JSON, exit codes 0/1/2.
+
+- [x] Preserve current docs as the baseline. Have fresh readers identify and exercise setup and invocation for each shell without consulting recorder source. Record outcomes against the unchanged recipe. The actual PowerShell recipe creates the cwd indirectly and passes; the missing-cwd review finding is not reproduced.
+- [x] Add `[System.IO.Directory]::CreateDirectory($work) | Out-Null` before PowerShell serve. Use literal-path checks for readiness because the sample directory contains brackets.
+- [x] Replace the Git Bash shorthand with a complete Bash block: `skill=$(cygpath -m /c/path/to/skills/proving-it-works-with-a-movie)`, `work=$(cygpath -m "$HOME/movie O'Brien λ & [take]")`, `mkdir -p "$work"`, `film="$skill/examples/film-terminal.py"`, then `uv run --script "$film" ...` with `--shell gitbash`. Explain the harness-owned background serve lifetime and readiness before commands.
+- [x] Explain that each take needs an empty/new directory; return code 2 means the command remains active and should continue through key/watch. Include cleanup and keep the Unix section untouched.
+- [x] Use fresh readers for corrected-doc trials on native Windows. Verify shell startup, cwd, persistent state, status handling, and close; do not grade media. Record exact commands, docs revision, and limitations. Cover PowerShell 5.1 and 7 plus Git Bash without a cross-product matrix.
+- [x] Commit the corrected recipe. Review the complete repair diff against the reproduced comments; keep final media acceptance with Drew.
+
+## Results
+
+- Code repair: `4d4ede29`; 10 narration tests, 8 recorder unit tests, and 4 subtitle tests passed. The FFmpeg-dependent integration test was skipped; strict mode rejected that skip.
+- Fresh-reader baseline: Git Bash failed on the copied PowerShell call operator; PowerShell 7 passed the nested session/cwd recipe.
+- Corrected recipes passed on native Windows PowerShell 5.1, PowerShell 7, and Git Bash: echo, persistent state, interactive key, long-command watch, and close. PowerShell quoted-command arguments were checked in both versions.
+- An initial PowerShell 5.1 candidate exposed native argument quote loss. Final examples use Read-Host/Start-Sleep, with verified version-specific quoting guidance. PowerShell can retain a true success flag after a parse error; the guidance now states that limitation.
+- These are bounded instruction trials, not proof of automatic skill discovery or a full adversarial evaluation of the imported skill. Drew retains movie acceptance.
+- The worker accidentally ran one integration test that checked temporary test frames before restricting subsequent execution to unit tests. Drew was informed; that run is excluded from acceptance evidence.
+- Detailed local reports, command logs, and preserved failures are under `.superpowers/sdd/2026-09-11-movie-review-fixes/`.

+ 72 - 0
docs/superpowers/specs/2026-09-09-proof-movie-windows-completion-design.md

@@ -0,0 +1,72 @@
+# Finish Windows support for the movie skill
+
+## Goal and limits
+
+An agent on native Windows can use the existing movie skill from **PowerShell 5.1, PowerShell 7, or Git Bash** to capture real software, generate local narration, assemble the movie, add subtitles, and apply the existing movie verification gate.
+
+- Both the invoking shell and the recorded shell must work. The acceptance rows pair each invoking shell with the same recorded shell; a nine-combination shell matrix is unnecessary.
+- PowerShell does not require Git Bash. Native Windows does not require WSL, tmux, Docker, administrator rights, a cloud key, or changes to machine settings.
+- Reuse native Python/uv, FFmpeg/ffprobe, Chromium-family browsers, ttyd, and the existing local voice/transcription dependencies. First use may download prerequisites and models; document that setup separately from recording.
+- Keep the existing five tool CLIs, scene kinds, narration manifest, offsets, SRT files, and checker behavior. Preserve the existing Unix terminal recipe and macOS/Linux behavior.
+- Validated on native Windows 11 x64. Support for other Windows releases or architectures is not inferred.
+
+This is a Windows port. It does not deliver a general process-management framework, a cross-platform recorder rewrite, a new eval harness, or a new OS/architecture certification matrix. Existing Linux, Mac, WSL, Rosetta, and offline-isolation setup may be reused when useful; extending that setup is not a deliverable.
+
+## 1. Finish the existing media tools
+
+| Area | Required resulting behavior |
+| --- | --- |
+| Invocation | All five extensionless scripts work with `uv run --script <path> ...` from each Windows shell. Documentation uses each shell's own quoting and environment syntax. Existing Unix shebang execution remains usable. |
+| Browser and cards | Discover installed Chrome or Edge in standard Windows user/machine locations and on PATH; keep existing Mac/Linux discovery. An explicit `--browser` is authoritative: an unusable value reports an error. Render local HTML using `Path.resolve().as_uri()`, an owned temporary browser profile, and a bounded timeout. Verify the screenshot exists. Release the browser processes launched by this render on success/failure without touching the user's browser. |
+| Subtitle paths | For hard subtitles, copy the SRT to a safe fixed basename in a temporary directory and run FFmpeg there, using absolute movie/output paths. This avoids interpreting drive letters, apostrophes, and backslashes as filter syntax. Preserve explicit soft-subtitle mode and the existing no-libass fallback, with accurate diagnostics. A burn failure must not be reported as missing libass. |
+| Text | Read/write YAML, JSON, HTML, SRT, and transcription text with explicit UTF-8, accepting UTF-8 BOM where shell-generated input requires it. CRLF input is valid. Unicode paths/content must survive native Windows defaults, including PowerShell 5.1. Machine-readable helper results must not depend on the console code page. |
+| Local transcription | Replace the failing nested `python3` launch with a Windows-compatible uv-managed Python invocation isolated from the project being filmed. Return transcript data separately from library stdout diagnostics. Under `--verify on`, transcribe both newly generated and reused cached WAVs: an unchanged manifest or a prior `--verify off` run does not establish verification. Missing, malformed, or failed transcription is nonzero under `on`; `auto` may report verification unavailable, and `off` remains explicit. Preserve existing drift thresholds. |
+
+The narration change addresses observed failures: native Windows could synthesize audio but silently skip requested transcription, and a native-library stdout warning could be mistaken for transcript text. Do not change the checker's general acceptance policy as part of this fix.
+
+## 2. Provide a usable Windows terminal example
+
+Windows has no tmux, so `examples/film-terminal.py` stands in for it, keeping
+the Unix route's shape: ttyd serves the shell, a headless Chrome or Edge page
+renders it, screenshots are the frames. `serve` starts both, keeps them alive
+in a harness-owned background task, and appends the raw terminal output to a
+log. `run`, `key`, `watch`, and `close` are one-shot CDP calls against that
+browser, so the shell, its variables, and its cwd persist across separate
+tool calls with no daemon protocol.
+
+- The installed prompt reports a counter, the shell's success flag, the last
+  native exit code, and the cwd through the window title, which the picture
+  never shows. `run` types a command, waits for the next prompt, and prints
+  that status as JSON; it exits 1 when the command failed and 2 when it is
+  still running after `--seconds`.
+- Capture is bounded `Page.captureScreenshot` at 5 fps in a fixed 1600×900
+  viewport. Frames sit on the 0.2-second grid and a slow screenshot repeats
+  the previous frame, so every `--record` directory is a `kind: frames` scene
+  at `rate: 5` for the assembler.
+- `serve` refuses a blank canvas at readiness and exits nonzero if the
+  browser or ttyd connection drops. `close` kills ttyd, the browser, and
+  their descendants (`taskkill /T` on Windows) and removes the browser
+  profile.
+- The script does not check which OS it runs on, which is how its session
+  tests run on macOS too; the Unix route stays the tmux recipe.
+
+## 3. Update only the Windows-facing guidance
+
+Update the existing skill/route documents where Unix-only commands block Windows use. Keep their existing evidence standards and terminology.
+
+- Provide complete PowerShell and Git Bash command sequences for the five tools and terminal example. Record how PowerShell 5.1 writes UTF-8 files and how both shells preserve producer exit status when logging.
+- Explain the distinct choices of invoking shell and recorded shell, foreground lifetime, take boundaries, fixed viewport, and cleanup.
+- Include a Windows FFmpeg `gdigrab` desktop preflight alongside the existing macOS recipe, using a scratch output directory. Verify it in the available ordinary-user interactive desktop session. Document unavailable/locked-desktop capture honestly: a log reel proves a run, not uncaptured GUI behavior.
+- Retain browser-driven motion, stills, existing movie segments, and log reels as existing routes through the same media tools. No new scene language or browser automation framework is needed.
+- Correct the current dangling terminal-example reference so Unix and Windows instructions point to what actually exists.
+
+## 4. Validation
+
+Each of PowerShell 5.1, PowerShell 7, and Git Bash on Windows 11 x64 produced
+a complete narrated, hard-subtitled movie from a title card, a still, real
+browser clicks, two terminal takes across separate tool calls, and a source
+movie segment, using local Piper narration verified by local transcription,
+with the checker passing. The recorder's session tests pass on Windows 11 for
+all three shells and on macOS against a real ttyd; the media suites were also
+run on Linux during development. Full-desktop `gdigrab` capture returned only
+wallpaper on the test host; window-title capture worked.

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

@@ -0,0 +1,103 @@
+---
+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
+
+On native Windows, use the complete PowerShell or Git Bash sequence in
+assembling.md and the native example in recording-a-terminal.md. Invoke all
+five tools with `uv run --script`; Windows does not execute their Unix shebangs.
+
+The Unix sequence:
+
+```bash
+set -euo pipefail
+# $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/ --verify on
+"$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
+```
+
+For each current narrated non-movie scene, downstream tools accept only a
+manifest entry whose text matches after collapsing whitespace while preserving
+case and punctuation, and whose WAV path is relative to the narration directory.
+`kind: movie` scenes retain their source audio and receive no narration offset,
+even if the scene contains a `narration` field.
+
+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.

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

@@ -0,0 +1,172 @@
+# 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
+set -euo pipefail
+scripts/make-subtitles narration/manifest.json movie.srt \
+  --offsets-json segments/offsets.json
+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.
+
+## Native Windows: the five tools
+
+Use native `uv`, FFmpeg and ffprobe on the test process's PATH. Hard subtitles
+require FFmpeg's `subtitles` filter (libass). Install Chrome or Edge for cards.
+The tools' Python environments are managed by uv and need Python 3.10+.
+First use can
+download Python, script dependencies, the local Piper voice, and the local
+transcription model. Do that setup before recording. No cloud key is required.
+PowerShell needs neither Git Bash nor WSL, tmux, Docker, or administrator rights.
+
+Keep the whole skill directory together: the scripts import their adjacent
+helpers. Set `skill` to the skill's loaded base directory, and write a scene
+file in `work`. Scene kinds remain `card`, `image`, `frames`, and `movie`;
+`kind: movie` retains the source clip's own audio. Other scenes can have
+`narration`. Use the measured assembly offsets for subtitles.
+
+PowerShell 5.1 and 7 (each native exit code is checked before continuing):
+
+```powershell
+$skill = 'C:/path/to/skills/proving-it-works-with-a-movie'
+$work = "$HOME/movie O'Brien λ & [take]"
+[IO.Directory]::CreateDirectory($work) | Out-Null
+& uv run --script "$skill/scripts/narrate" "$work/scenes.yaml" "$work/narration" --engine piper --verify on
+if ($LASTEXITCODE -ne 0) { throw 'narrate failed' }
+& uv run --script "$skill/scripts/assemble" "$work/scenes.yaml" "$work/cut.mp4" --narration "$work/narration" --work "$work/assembly work"
+if ($LASTEXITCODE -ne 0) { throw 'assemble failed' }
+& uv run --script "$skill/scripts/make-subtitles" "$work/narration/manifest.json" "$work/movie.srt" --offsets-json "$work/assembly work/offsets.json"
+if ($LASTEXITCODE -ne 0) { throw 'make-subtitles failed' }
+& uv run --script "$skill/scripts/burn-subtitles" "$work/cut.mp4" "$work/movie.srt" "$work/movie.mp4"
+if ($LASTEXITCODE -ne 0) { throw 'burn-subtitles failed' }
+& uv run --script "$skill/scripts/check-movie" "$work/movie.mp4" --out "$work/evidence" --json
+if ($LASTEXITCODE -ne 0) { throw 'check-movie failed' }
+```
+
+Git Bash: convert paths to native Windows form before passing them to native
+uv/Python/FFmpeg. In particular, Python can interpret `/c/...` as `C:\c\...`.
+Keep each path quoted; an apostrophe is literal inside Bash double quotes.
+
+```bash
+set -euo pipefail
+skill=$(cygpath -m '/c/path/to/skills/proving-it-works-with-a-movie')
+work=$(cygpath -m "$HOME/movie O'Brien λ & [take]")
+mkdir -p "$work"
+uv run --script "$skill/scripts/narrate" "$work/scenes.yaml" "$work/narration" --engine piper --verify on
+uv run --script "$skill/scripts/assemble" "$work/scenes.yaml" "$work/cut.mp4" --narration "$work/narration" --work "$work/assembly work"
+uv run --script "$skill/scripts/make-subtitles" "$work/narration/manifest.json" "$work/movie.srt" --offsets-json "$work/assembly work/offsets.json"
+uv run --script "$skill/scripts/burn-subtitles" "$work/cut.mp4" "$work/movie.srt" "$work/movie.mp4"
+uv run --script "$skill/scripts/check-movie" "$work/movie.mp4" --out "$work/evidence" --json
+```
+
+Keep `movie.srt` beside `movie.mp4`: the checker discovers that basename.
+Inspect the finished contact sheet and hard captions, then transcribe the
+rendered audio as described in narrating.md. A successful soft-subtitle
+fallback is not proof that captions were burned into the picture.
+
+PowerShell 5.1's `Out-File` defaults to UTF-16. For scene YAML/JSON, request
+JSON, HTML, and SRT, write UTF-8 explicitly:
+
+```powershell
+[IO.File]::WriteAllText($path, $json, [Text.UTF8Encoding]::new($false))
+```
+
+Use `-LiteralPath` for PowerShell file operations on paths containing brackets.
+BOM-bearing UTF-8 and CRLF scene input are supported; a console's displayed
+encoding is not a reliable way to check the bytes in a JSON file.

+ 632 - 0
skills/proving-it-works-with-a-movie/examples/film-terminal.py

@@ -0,0 +1,632 @@
+#!/usr/bin/env -S uv run --script
+# /// script
+# requires-python = ">=3.10"
+# dependencies = ["websocket-client==1.9.0", "pillow"]
+# ///
+"""Film a shell on native Windows, where there is no tmux.
+
+ttyd serves the shell over HTTP and a headless Chrome or Edge page renders
+it: the same picture the Unix route in recording-a-terminal.md gets. `serve`
+stands in for tmux: it holds the only ttyd client open so the shell survives
+between tool calls, and appends the raw terminal output to
+SESSION/terminal.log. Every other verb is one short CDP call against that
+browser.
+
+  serve SESSION --shell powershell51|powershell7|gitbash [--cwd DIR]
+        hold the session open; run it in a background task
+  run   SESSION 'command' [--record OUT] [--seconds 60] [--hold 1.5]
+        type the command, film until the prompt returns, print its status
+  key   SESSION Enter|Escape|Tab|Ctrl-C|ArrowDown|q [--record OUT]
+        press one key
+  watch SESSION --record OUT [--seconds 30]
+        film without typing: a TUI after a key, or the tail of long work
+  close SESSION
+        kill ttyd, the browser, and everything they started
+
+The prompt `serve` installs reports each command's status through the
+window title, which the picture never shows, so `run` can print it.
+"""
+import argparse
+import base64
+import io
+import json
+import os
+import re
+import shutil
+import socket
+import subprocess
+import sys
+import time
+import urllib.request
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
+from browser_tools import find_browser, kill_process_tree  # noqa: E402
+
+FPS = 5
+WIDTH, HEIGHT = 1600, 900
+# Title set by the installed prompt: MOVIE;<count>;<ok>;<native exit>;<cwd>
+MARKER = re.compile(rb"\x1b\][012];MOVIE;(\d+);([01]);(-?\d*);([^\x07\x1b]*)(?:\x07|\x1b\\)")
+SHELLS = {
+    "powershell51": (["-NoLogo", "-NoProfile", "-NoExit"], "powershell"),
+    "powershell7": (["-NoLogo", "-NoProfile", "-NoExit"], "pwsh"),
+    "gitbash": (["--noprofile", "--norc", "-i"], "bash"),
+    "bash": (["--noprofile", "--norc", "-i"], "bash"),
+}
+PROMPTS = {
+    "powershell": r'''$global:MovieN = 0
+function global:prompt {
+    $ok = $?; $native = $global:LASTEXITCODE; $global:MovieN++
+    $Host.UI.RawUI.WindowTitle = "MOVIE;$global:MovieN;$([int]$ok);$native;$PWD"
+    "PS $PWD> "
+}
+Clear-Host''',
+    "bash": r'''MOVIE_N=0
+movie_prompt() { local s=$?; MOVIE_N=$((MOVIE_N + 1)); printf '\033]0;MOVIE;%s;%s;%s;%s\a' "$MOVIE_N" "$((s == 0))" "$s" "$PWD"; }
+PROMPT_COMMAND=movie_prompt
+PS1='\w \$ '
+clear''',
+}
+KEYS = {
+    "Enter": dict(key="Enter", code="Enter", windowsVirtualKeyCode=13, text="\r"),
+    "Escape": dict(key="Escape", code="Escape", windowsVirtualKeyCode=27),
+    "Tab": dict(key="Tab", code="Tab", windowsVirtualKeyCode=9, text="\t"),
+    "ArrowLeft": dict(key="ArrowLeft", code="ArrowLeft", windowsVirtualKeyCode=37),
+    "ArrowUp": dict(key="ArrowUp", code="ArrowUp", windowsVirtualKeyCode=38),
+    "ArrowRight": dict(key="ArrowRight", code="ArrowRight", windowsVirtualKeyCode=39),
+    "ArrowDown": dict(key="ArrowDown", code="ArrowDown", windowsVirtualKeyCode=40),
+    "Ctrl-C": dict(key="c", code="KeyC", windowsVirtualKeyCode=67, modifiers=2),
+}
+
+
+def shell_family(kind):
+    return "bash" if kind in ("bash", "gitbash") else "powershell"
+
+
+def shell_argv(kind, explicit=None):
+    flags, default = SHELLS[kind]
+    exe = explicit
+    if not exe and kind == "gitbash":
+        # PATH may hold WSL's bash.exe; only Git's is a native Windows shell.
+        for root in (os.environ.get("ProgramFiles"), os.environ.get("ProgramW6432")):
+            if root and (Path(root) / "Git/bin/bash.exe").is_file():
+                exe = str(Path(root) / "Git/bin/bash.exe")
+    exe = exe or shutil.which(default)
+    if not exe:
+        raise SystemExit(f"cannot find the {kind} executable; pass --shell-exe")
+    return [str(Path(exe).resolve()), *flags]
+
+
+def prompt_script(kind, cwd):
+    """Enter the cwd (ttyd's own -w is unreliable), install the status prompt, clear."""
+    if shell_family(kind) == "bash":
+        quoted = "'" + str(cwd).replace("\\", "/").replace("'", "'\\''") + "'"
+        return f"cd -- {quoted}\n" + PROMPTS["bash"]
+    quoted = "'" + str(cwd).replace("'", "''") + "'"
+    return f"Set-Location -LiteralPath {quoted}\n" + PROMPTS["powershell"]
+
+
+def prompt_command(kind, cwd):
+    """One typed line that runs prompt_script without any quoting hazards."""
+    encoded = base64.b64encode(prompt_script(kind, cwd).encode("utf-8")).decode()
+    if shell_family(kind) == "bash":
+        return f'eval "$(printf %s {encoded} | base64 -d)"'
+    return (". ([scriptblock]::Create([Text.Encoding]::UTF8.GetString("
+            f"[Convert]::FromBase64String('{encoded}'))))")
+
+
+VISIBLE = re.compile(rb"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\[[0-?]*[ -/]*[@-~]|\r")
+
+
+def at_prompt(log):
+    """True when the visible text so far ends in a shell prompt (`$` or `>`)."""
+    return VISIBLE.sub(b"", log).rstrip(b" \t\n").endswith((b"$", b">"))
+
+
+def prompts(log):
+    """Every status the installed prompt has reported in these bytes, oldest first."""
+    return [dict(n=int(m[1]), ok=m[2] == b"1", exit_code=int(m[3]) if m[3] else None,
+                 cwd=m[4].decode("utf-8", "replace")) for m in MARKER.finditer(log)]
+
+
+def key_params(key):
+    if key in KEYS:
+        return dict(KEYS[key])
+    if len(key) == 1 and key.isprintable():
+        return dict(key=key, text=key)
+    raise SystemExit(f"unknown key {key!r}: use one character or one of {', '.join(KEYS)}")
+
+
+def require_empty_take(out):
+    if out.exists() and any(out.iterdir()):
+        raise SystemExit(f"{out} is not empty: use a new take directory")
+
+
+def film(out, seconds, hold, capture, finished, clock=time.monotonic, sleep=time.sleep):
+    """Write PNG frames on the FPS grid until `finished()` plus `hold` seconds,
+    or `seconds` in all. A slow capture repeats the previous frame, so the
+    directory plays back at exactly FPS. Returns the frame count."""
+    require_empty_take(out)
+    out.mkdir(parents=True, exist_ok=True)
+    start, index, last, stop = clock(), -1, None, None
+    while True:
+        now = clock()
+        if stop is None and finished():
+            stop = now + hold
+        endpoint = min(start + seconds, stop) if stop is not None else start + seconds
+        if now >= endpoint:
+            # A capture may cross the endpoint. Fill only grid slots before
+            # that endpoint; tolerate floating-point noise at exact FPS ticks.
+            while last is not None and (index + 1) / FPS < endpoint - start - 1e-9:
+                index += 1
+                (out / f"f{index:05d}.png").write_bytes(last)
+            return index + 1
+        slot = int((now - start) * FPS)
+        if slot > index:
+            png = capture()
+            for missed in range(index + 1, slot):
+                (out / f"f{missed:05d}.png").write_bytes(last or png)
+            (out / f"f{slot:05d}.png").write_bytes(png)
+            index, last = slot, png
+        sleep(0.02)
+
+
+class CDP:
+    def __init__(self, url):
+        import websocket
+
+        self.ws = websocket.create_connection(url, timeout=5, suppress_origin=True)
+        self.count, self.on_event, self.before_call = 0, None, None
+
+    def recv(self, timeout):
+        import websocket
+
+        self.ws.settimeout(timeout)
+        try:
+            raw = self.ws.recv()
+        except websocket.WebSocketTimeoutException:
+            return None
+        if not raw:
+            raise ConnectionError("browser connection closed")
+        message = json.loads(raw)
+        if "id" not in message and self.on_event:
+            self.on_event(message)
+        return message
+
+    def call(self, method, params=None, timeout=10):
+        if self.before_call:
+            self.before_call()
+        self.count += 1
+        self.ws.send(json.dumps({"id": self.count, "method": method, "params": params or {}}))
+        deadline = time.monotonic() + timeout
+        while time.monotonic() < deadline:
+            message = self.recv(0.05)
+            if message and message.get("id") == self.count:
+                if "error" in message:
+                    raise RuntimeError(f"{method}: {message['error']}")
+                return message.get("result", {})
+        raise TimeoutError(f"{method} took longer than {timeout:g}s")
+
+
+def free_port():
+    with socket.socket() as sock:
+        sock.bind(("127.0.0.1", 0))
+        return sock.getsockname()[1]
+
+
+def page_url(debug_port):
+    with urllib.request.urlopen(f"http://127.0.0.1:{debug_port}/json/list", timeout=2) as response:
+        pages = json.load(response)
+    return next(page["webSocketDebuggerUrl"] for page in pages if page["type"] == "page")
+
+
+def connect(session):
+    return CDP(page_url(session["debug_port"]))
+
+
+def type_text(cdp, text):
+    cdp.call("Runtime.evaluate", {"expression": "document.querySelector('.xterm-helper-textarea').focus()"})
+    cdp.call("Input.insertText", {"text": text})
+
+
+def press(cdp, key):
+    params = key_params(key)
+    cdp.call("Input.dispatchKeyEvent", dict(type="keyDown", **params))
+    cdp.call("Input.dispatchKeyEvent", dict(type="keyUp", **{k: v for k, v in params.items() if k != "text"}))
+
+
+def screenshot(cdp):
+    return base64.b64decode(cdp.call("Page.captureScreenshot", {"format": "png"}, timeout=5)["data"])
+
+
+def tail(path, size=262144):
+    with path.open("rb") as handle:
+        handle.seek(max(0, handle.seek(0, os.SEEK_END) - size))
+        return handle.read()
+
+
+def read_json(path):
+    return json.loads(path.read_text(encoding="utf-8"))
+
+
+def write_json(path, value):
+    path.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8")
+
+
+def load_session(directory):
+    if not (directory / "ready.json").is_file():
+        raise SystemExit(f"{directory} has no ready.json: is `serve` running there?")
+    return read_json(directory / "session.json")
+
+
+def lit_fraction(png):
+    from PIL import Image
+
+    image = Image.open(io.BytesIO(png)).convert("L")
+    return sum(image.histogram()[91:]) / (image.width * image.height)
+
+
+def serve(args):
+    directory = args.session.resolve()
+    if directory.exists() and any(directory.iterdir()):
+        raise SystemExit(f"{directory} is not empty: use a new session directory")
+    directory.mkdir(parents=True, exist_ok=True)
+    cwd = Path(args.cwd or os.getcwd()).resolve()
+    if not cwd.is_dir():
+        raise SystemExit(f"--cwd is not a directory: {cwd}")
+    shell = shell_argv(args.shell, args.shell_exe)
+    ttyd = args.ttyd or shutil.which("ttyd")
+    if not ttyd:
+        raise SystemExit("ttyd is not on PATH; pass --ttyd")
+    browser = find_browser(args.browser)
+    if not browser:
+        raise SystemExit("no Chrome or Edge found; pass --browser")
+    port, debug_port = free_port(), free_port()
+    unix = os.name != "nt"
+    # ttyd 1.7 on Windows needs -w but decodes it in the ANSI code page, so
+    # pass "." and let the shell inherit this process's Unicode cwd; the
+    # installed prompt script then cds there explicitly and reports back.
+    ttyd_argv = [ttyd, "-i", "127.0.0.1", "-p", str(port), "-W", "-m", "1", "-w", ".",
+                 "-t", "fontSize=17", *shell]
+    # Software GL: without it a GPU-less session paints the xterm canvas empty.
+    browser_argv = [browser, "--headless=new", "--no-first-run", "--no-default-browser-check",
+                    "--use-gl=angle", "--use-angle=swiftshader", "--enable-unsafe-swiftshader",
+                    "--disable-background-networking", "--remote-debugging-address=127.0.0.1",
+                    f"--remote-debugging-port={debug_port}", f"--user-data-dir={directory / 'profile'}",
+                    f"--window-size={WIDTH},{HEIGHT}", "--hide-scrollbars", "about:blank"]
+    logs, processes = [], []
+    session = dict(shell=args.shell, cwd=str(cwd), terminal_url=f"http://127.0.0.1:{port}/",
+                   debug_port=debug_port, pids=[])
+    output, cdp = None, None
+    state = {"closed": False}
+
+    class StopRequested(Exception):
+        pass
+
+    def check_active():
+        if (directory / "stop").exists():
+            raise StopRequested()
+        if state["closed"] or any(process.poll() is not None for process in processes):
+            raise ConnectionError("the terminal session closed")
+
+    def on_event(event):
+        if event["method"] == "Network.webSocketFrameReceived":
+            frame = event["params"]["response"]
+            raw = base64.b64decode(frame["payloadData"]) if frame["opcode"] == 2 else frame["payloadData"].encode()
+            if raw[:1] == b"0":  # ttyd frame type 0 is terminal output
+                output.write(raw[1:])
+                output.flush()
+        elif event["method"] == "Network.webSocketClosed":
+            state["closed"] = True
+
+    def pump_until(condition, timeout, failure):
+        deadline = time.monotonic() + timeout
+        while time.monotonic() < deadline:
+            check_active()
+            cdp.recv(0.05)
+            check_active()
+            if condition():
+                return
+        (directory / "timeout.png").write_bytes(screenshot(cdp))
+        raise TimeoutError(f"{failure}; terminal output so far: {tail(directory / 'terminal.log')[-300:]!r}")
+
+    def pump_while_output_flows(quiet):
+        # A shell's banner and first prompt can trickle out; type only once
+        # it has been silent for `quiet` seconds.
+        deadline, seen = time.monotonic() + quiet, output.tell()
+        while time.monotonic() < deadline:
+            check_active()
+            cdp.recv(0.05)
+            check_active()
+            if output.tell() != seen:
+                deadline, seen = time.monotonic() + quiet, output.tell()
+
+    code = 1
+    try:
+        for name in ("ttyd.log", "browser.log"):
+            logs.append((directory / name).open("ab"))
+        processes.append(subprocess.Popen(
+            ttyd_argv, cwd=cwd, stdin=subprocess.DEVNULL, stdout=logs[0],
+            stderr=subprocess.STDOUT, start_new_session=unix))
+        processes.append(subprocess.Popen(
+            browser_argv, cwd=directory, stdin=subprocess.DEVNULL, stdout=logs[1],
+            stderr=subprocess.STDOUT, start_new_session=unix))
+        session["pids"] = [process.pid for process in processes]
+        write_json(directory / "session.json", session)
+        output = (directory / "terminal.log").open("ab")
+        deadline = time.monotonic() + 20
+        while True:
+            check_active()
+            try:
+                cdp = CDP(page_url(debug_port))
+                break
+            except (OSError, StopIteration) as error:
+                if time.monotonic() > deadline:
+                    raise TimeoutError(f"browser did not start: {error}") from None
+                time.sleep(0.1)
+        cdp.on_event = on_event
+        cdp.before_call = check_active
+        cdp.call("Network.enable")  # before navigation, or the terminal socket is never reported
+        cdp.call("Page.enable")
+        cdp.call("Emulation.setDeviceMetricsOverride",
+                 {"width": WIDTH, "height": HEIGHT, "deviceScaleFactor": 1, "mobile": False})
+        cdp.call("Page.navigate", {"url": session["terminal_url"]})
+        # Type only once the shell is reading input: its own prompt is on
+        # screen and nothing more has arrived for a moment.
+        pump_until(lambda: at_prompt(tail(directory / "terminal.log")), 20, "the shell never showed a prompt")
+        pump_while_output_flows(0.5)
+        # Git Bash under ConPTY can lose the first keystroke of a session.
+        # Spend it on a bare Enter, which only repaints the prompt.
+        press(cdp, "Enter")
+        pump_while_output_flows(0.5)
+        type_text(cdp, prompt_command(args.shell, cwd))
+        press(cdp, "Enter")
+        pump_until(lambda: prompts(tail(directory / "terminal.log")), 15,
+                   "the shell never showed the installed prompt")
+        prompt = prompts(tail(directory / "terminal.log"))[-1]
+        if shell_family(args.shell) == "powershell":
+            entered = os.path.normcase(os.path.normpath(prompt["cwd"])) == os.path.normcase(str(cwd))
+        else:  # Git Bash reports /c/... paths, so compare the leaf directory
+            entered = Path(prompt["cwd"]).name == cwd.name
+        if not entered:
+            raise RuntimeError(f"the shell is in {prompt['cwd']!r}, not {str(cwd)!r}")
+
+        def typed(text):  # type one line and wait for the prompt after it
+            before = prompts(tail(directory / "terminal.log"))[-1]["n"]
+            type_text(cdp, text)
+            press(cdp, "Enter")
+            pump_until(lambda: prompts(tail(directory / "terminal.log"))[-1]["n"] > before, 15,
+                       f"no prompt after typing {text[:24]!r}")
+
+        # Preflight as the Unix route does: print something dense, refuse a blank canvas.
+        typed("echo '" + "#" * 120 + "'")
+        time.sleep(0.3)
+        png = screenshot(cdp)
+        (directory / "ready.png").write_bytes(png)
+        lit = lit_fraction(png)
+        if lit < 0.002:
+            raise RuntimeError(f"the terminal renders blank ({lit:.4%} lit pixels); see ready.png")
+        typed("clear")
+        time.sleep(0.3)
+        check_active()
+        prompt = prompts(tail(directory / "terminal.log"))[-1]
+        write_json(directory / "ready.json", dict(session, prompt=prompt, lit=round(lit, 4)))
+        print(json.dumps({"ready": True, "session": str(directory), "cwd": prompt["cwd"]}), flush=True)
+        while not (directory / "stop").exists():
+            cdp.recv(0.2)
+            check_active()
+        code = 0
+    except (KeyboardInterrupt, StopRequested):
+        code = 0
+    except Exception as error:  # noqa: BLE001 - report, then clean up below
+        print(f"serve: {error}", file=sys.stderr)
+        code = 1
+    finally:
+        cleaned = True
+        try:
+            (directory / "ready.json").unlink(missing_ok=True)
+        except OSError as error:
+            print(f"serve cleanup: {error}", file=sys.stderr)
+            cleaned = False
+        if cdp is not None:
+            try:
+                cdp.ws.close()
+            except Exception as error:
+                print(f"serve cleanup: {error}", file=sys.stderr)
+                cleaned = False
+        for process in processes:
+            try:
+                # Only the owner acts on handles it acquired, never stored PIDs.
+                if process.poll() is not None:
+                    print(f"serve cleanup: child {process.pid} exited before tree cleanup; "
+                          "descendant cleanup cannot be confirmed", file=sys.stderr)
+                    cleaned = False
+                    continue
+                kill_process_tree(process.pid)
+                process.wait(timeout=5)
+            except (OSError, subprocess.TimeoutExpired) as error:
+                print(f"serve cleanup: {error}", file=sys.stderr)
+                cleaned = False
+        for handle in ([output] if output is not None else []) + logs:
+            try:
+                handle.close()
+            except OSError as error:
+                print(f"serve cleanup: {error}", file=sys.stderr)
+                cleaned = False
+        deadline = time.monotonic() + 5
+        while True:
+            try:
+                shutil.rmtree(directory / "profile")
+                break
+            except FileNotFoundError:
+                break
+            except OSError as error:
+                if time.monotonic() >= deadline:
+                    print(f"serve cleanup: {error}", file=sys.stderr)
+                    cleaned = False
+                    break
+                time.sleep(0.1)
+        if cleaned:
+            try:
+                # Readers see either active ownership or completed cleanup.
+                completed = directory / "session.json.tmp"
+                write_json(completed, dict(session, pids=[], closed=True))
+                completed.replace(directory / "session.json")
+            except OSError as error:
+                print(f"serve cleanup: {error}", file=sys.stderr)
+                cleaned = False
+        if not cleaned:
+            code = 1
+    return code
+
+
+def observe(args, cdp, n0):
+    """Film or wait until the prompt after `n0` appears; print the status."""
+    log = args.session / "terminal.log"
+
+    def latest():
+        return next((p for p in reversed(prompts(tail(log))) if p["n"] > n0), None)
+
+    def poll():
+        prompt = latest()
+        if prompt:
+            return prompt
+        try:
+            cdp.recv(0.05)
+        except Exception:
+            # The owner can log the final native status just before disconnect.
+            prompt = latest()
+            if prompt:
+                return prompt
+            raise
+        prompt = latest()
+        if prompt:
+            return prompt
+        if not (args.session / "ready.json").is_file():
+            raise ConnectionError("the terminal session is no longer ready")
+        return None
+
+    frames = 0
+    try:
+        if args.record:
+            frames = film(args.record, args.seconds, args.hold, lambda: screenshot(cdp), lambda: poll() is not None)
+        else:
+            deadline = time.monotonic() + args.seconds
+            while poll() is None and time.monotonic() < deadline:
+                time.sleep(0.05)
+        prompt = poll()
+        result = {"outcome": "completed" if prompt else "running"}
+    except Exception as error:
+        result = {"outcome": "failed", "error": str(error)}
+        try:
+            prompt = latest()
+        except OSError:
+            prompt = None
+    if prompt:
+        result.update(ok=prompt["ok"], exit_code=prompt["exit_code"], cwd=prompt["cwd"])
+    if args.record and result["outcome"] != "failed":
+        result["frames"] = frames
+        result["scene"] = {"kind": "frames", "src": str(args.record.resolve()), "rate": FPS}
+        write_json(args.record / "take.json", result)
+    print(json.dumps(result))
+    if result["outcome"] == "failed":
+        return 1
+    return 2 if not prompt else 0 if prompt["ok"] else 1
+
+
+def last_prompt_number(directory):
+    reported = prompts(tail(directory / "terminal.log"))
+    return reported[-1]["n"] if reported else 0
+
+
+def run(args):
+    session = load_session(args.session)
+    n0 = last_prompt_number(args.session)
+    cdp = connect(session)
+    type_text(cdp, args.command)
+    press(cdp, "Enter")
+    write_json(args.session / "mark.json", {"n": n0})
+    return observe(args, cdp, n0)
+
+
+def key(args):
+    session = load_session(args.session)
+    n0 = last_prompt_number(args.session)
+    cdp = connect(session)
+    cdp.call("Runtime.evaluate", {"expression": "document.querySelector('.xterm-helper-textarea').focus()"})
+    press(cdp, args.key)
+    write_json(args.session / "mark.json", {"n": n0})
+    return observe(args, cdp, n0)
+
+
+def watch(args):
+    session = load_session(args.session)
+    # Wait for the prompt after the last run/key, even if it already returned.
+    mark = args.session / "mark.json"
+    n0 = read_json(mark)["n"] if mark.exists() else last_prompt_number(args.session)
+    return observe(args, connect(session), n0)
+
+
+def close(args):
+    deadline = time.monotonic() + 30
+    try:
+        session = read_json(args.session / "session.json")
+        (args.session / "stop").write_text("", encoding="utf-8")
+        while True:
+            if (session.get("closed") is True and session.get("pids") == []
+                    and not (args.session / "ready.json").exists()
+                    and not (args.session / "profile").exists()):
+                print(json.dumps({"closed": True}))
+                return 0
+            remaining = deadline - time.monotonic()
+            if remaining <= 0:
+                raise TimeoutError("serve did not confirm cleanup within 30 seconds")
+            time.sleep(min(0.1, remaining))
+            session = read_json(args.session / "session.json")
+    except (OSError, ValueError, AttributeError) as error:
+        print(f"close: {error}", file=sys.stderr)
+        return 1
+
+
+def main():
+    for stream in (sys.stdout, sys.stderr):
+        if hasattr(stream, "reconfigure"):
+            stream.reconfigure(errors="backslashreplace")
+    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+    verbs = parser.add_subparsers(dest="verb", required=True)
+
+    def filming(sub, seconds):
+        sub.add_argument("--record", type=Path, help="write PNG frames here at 5 fps")
+        sub.add_argument("--seconds", type=float, default=seconds, help="give up waiting after this long")
+        sub.add_argument("--hold", type=float, default=1.5, help="keep filming this long after the prompt returns")
+
+    sub = verbs.add_parser("serve")
+    sub.add_argument("session", type=Path)
+    sub.add_argument("--shell", choices=list(SHELLS), required=True)
+    sub.add_argument("--cwd", type=Path)
+    sub.add_argument("--shell-exe")
+    sub.add_argument("--ttyd")
+    sub.add_argument("--browser")
+    sub = verbs.add_parser("run")
+    sub.add_argument("session", type=Path)
+    sub.add_argument("command")
+    filming(sub, 60)
+    sub = verbs.add_parser("key")
+    sub.add_argument("session", type=Path)
+    sub.add_argument("key")
+    filming(sub, 30)
+    sub = verbs.add_parser("watch")
+    sub.add_argument("session", type=Path)
+    filming(sub, 30)
+    sub = verbs.add_parser("close")
+    sub.add_argument("session", type=Path)
+    args = parser.parse_args()
+    if args.verb == "watch" and not args.record:
+        parser.error("watch needs --record")
+    if getattr(args, "record", None):
+        require_empty_take(args.record)
+    return {"serve": serve, "run": run, "key": key, "watch": watch, "close": close}[args.verb](args)
+
+
+if __name__ == "__main__":
+    sys.exit(main())

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

@@ -0,0 +1,120 @@
+# 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/ --verify on` 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 --verify on` transcribes every clip, including cached clips, with
+local faster-whisper in its own environment and compares the result against
+the script. It needs no API key. Missing or failed transcription is a failure;
+the first run needs network access to download dependencies and the ASR model.
+
+| Mode | Local transcription behavior |
+|---|---|
+| `--verify on` | Required for every engine. Unavailable ASR or detected drift returns nonzero and excludes the failed clip from the manifest. Use this for the gated workflow above. |
+| `--verify auto` (CLI default) | Tries ASR for Piper and `openai-chat`; reports unavailable ASR but allows the clip. Skips ASR for `openai`. Detected drift still fails. |
+| `--verify off` | Skips ASR. |
+
+For `openai-chat`, word-comparison support is mandatory in every ASR mode,
+including cache reuse. During synthesis its returned transcript must contain
+speech and pass that comparison; an unsupported script also withdraws cached
+acceptance before reuse. `--verify off` bypasses only local ASR. The returned
+transcript still does not prove what the WAV contains.
+
+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.
+
+`narrate` records each clip's text, engine, voice, and synthesis model.
+Changing any of these re-renders the clip. Clips without recorded synthesis
+settings also re-render; an unchanged clip can be reused and still receives
+any requested ASR verification.
+
+A newly synthesized clip gets at most two attempts when synthesis or a
+supported transcript/ASR comparison fails. A cached clip is checked once; if
+rejected, its manifest acceptance is withdrawn and a later invocation can
+synthesize a replacement. Failed candidate files remain as evidence rather
+than being deleted to make a retry appear clean.
+
+## 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.
+
+## Native Windows local voice
+
+Use `--engine piper --verify on` with the commands in assembling.md. The
+first run downloads the Piper voice and the transcription model; later runs
+reuse those caches. `--verify on` transcribes every clip, including cached
+WAVs from an earlier run, and treats an unavailable transcriber as a failure
+rather than a pass. Afterwards, transcribe the finished movie's audio and
+compare each narrated interval with its script. A `kind: movie` segment
+keeps its own sound and is checked against its source, not a script.

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

@@ -0,0 +1,232 @@
+# 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.
+
+## Native Windows: `examples/film-terminal.py` stands in for tmux
+
+Windows has no tmux, so the example script holds the session instead. `serve`
+starts ttyd on the shell you name and a headless Chrome or Edge page showing
+it, keeps both alive, and appends the raw terminal output to
+`SESSION/terminal.log`. Every other verb is one short call against that
+browser. Run `serve` in a background task your harness keeps alive, the way
+the visual companion server runs; a one-shot shell that kills its children
+on return ends the session.
+
+Use a new or empty `SESSION` directory for every session, including retries,
+just as every take directory must be new or empty. `serve` refuses a nonempty
+session directory before launching anything, preserving the prior session's
+logs and evidence.
+
+It needs uv, ttyd, and Chrome or Edge on PATH, or `--ttyd` and `--browser`.
+`--shell powershell51|powershell7|gitbash` picks the filmed shell; the shell
+you type these commands into is a separate choice. Replace the sample
+commands below with the software you are proving.
+
+### PowerShell
+
+Run this block in a background terminal/task your harness keeps alive.
+For PowerShell 5.1 use `--shell powershell51`; for PowerShell 7 use
+`--shell powershell7`.
+
+```powershell
+$skill = 'C:/path/to/skills/proving-it-works-with-a-movie'
+$work = "$HOME/movie O'Brien λ & [take]"
+$film = "$skill/examples/film-terminal.py"
+[System.IO.Directory]::CreateDirectory($work) | Out-Null
+& uv run --script $film serve "$work/session" --shell powershell7 --cwd $work
+```
+
+In a second terminal/task, define the same paths and wait for readiness.
+Repeat these three variable definitions in each tool call if your harness
+starts a fresh shell for every call. `-LiteralPath` keeps the brackets in
+the sample directory name from being interpreted as wildcards.
+
+```powershell
+$skill = 'C:/path/to/skills/proving-it-works-with-a-movie'
+$work = "$HOME/movie O'Brien λ & [take]"
+$film = "$skill/examples/film-terminal.py"
+$deadline = (Get-Date).AddSeconds(60)
+while (-not (Test-Path -LiteralPath "$work/session/ready.json")) {
+    if ((Get-Date) -gt $deadline) { throw 'Recorder not ready; inspect the serve task output.' }
+    Start-Sleep -Milliseconds 200
+}
+& uv run --script $film run "$work/session" 'echo hello' --record "$work/take-one"
+& uv run --script $film run "$work/session" 'Read-Host' --record "$work/take-two" --seconds 2
+# Exit 2 means Read-Host is still waiting. Press Enter to finish it in a new take:
+& uv run --script $film key "$work/session" Enter --record "$work/take-three"
+# A long command can continue across calls; exit 2 here is expected too:
+& uv run --script $film run "$work/session" 'Start-Sleep -Seconds 5' --record "$work/take-four" --seconds 1
+& uv run --script $film watch "$work/session" --record "$work/take-five" --seconds 10
+& uv run --script $film close "$work/session"
+```
+
+Read `$LASTEXITCODE` immediately after each invocation. Stop on exit 1;
+exit 2 is expected while the interactive command is waiting for input.
+Always call `close` when finished, including after a failed command.
+
+When invoking from PowerShell 5.1, escape embedded double quotes with a
+backslash before passing a command to native uv: use the command argument
+`'python -c \"print(123)\"'`. PowerShell 7 preserves the quotes in
+`'python -c "print(123)"'` directly. This depends on the invoking shell,
+regardless of which shell you record.
+
+### Git Bash
+
+Convert paths passed to native uv/Python with `cygpath -m`. Run this block
+in a background terminal/task your harness keeps alive:
+
+```bash
+skill=$(cygpath -m /c/path/to/skills/proving-it-works-with-a-movie)
+work=$(cygpath -m "$HOME/movie O'Brien λ & [take]")
+film="$skill/examples/film-terminal.py"
+mkdir -p "$work"
+uv run --script "$film" serve "$work/session" --shell gitbash --cwd "$work"
+```
+
+In a second terminal/task, use the same paths. Repeat the three variable
+definitions in each tool call if it starts a fresh shell. Keep `set -e`
+off for these interactive calls so expected exit 2 does not end the script.
+
+```bash
+skill=$(cygpath -m /c/path/to/skills/proving-it-works-with-a-movie)
+work=$(cygpath -m "$HOME/movie O'Brien λ & [take]")
+film="$skill/examples/film-terminal.py"
+deadline=$((SECONDS + 60))
+until [[ -f "$work/session/ready.json" ]]; do
+    if (( SECONDS >= deadline )); then
+        printf '%s\n' 'Recorder not ready; inspect the serve task output.' >&2
+        exit 1
+    fi
+    sleep 0.2
+done
+uv run --script "$film" run "$work/session" 'echo hello' --record "$work/take-one"
+uv run --script "$film" run "$work/session" 'read -r answer' --record "$work/take-two" --seconds 2
+# Exit 2 means read is still waiting. Press Enter to finish it in a new take:
+uv run --script "$film" key "$work/session" Enter --record "$work/take-three"
+# A long command can continue across calls; exit 2 here is expected too:
+uv run --script "$film" run "$work/session" 'sleep 5' --record "$work/take-four" --seconds 1
+uv run --script "$film" watch "$work/session" --record "$work/take-five" --seconds 10
+uv run --script "$film" close "$work/session"
+```
+
+Read `$?` immediately after each invocation. Stop on exit 1; exit 2 means
+the command remains active. Always call `close` when finished, including
+after a failed command. The original `serve` task exits after `close`.
+
+`run` types the command and films at 5 fps into `--record` until the prompt
+comes back, holds 1.5 s so the result stays readable, and prints the status
+as JSON: `ok` is the shell's own success flag and `exit_code` the last native
+program's exit code, which PowerShell keeps from an earlier program when the
+command was a cmdlet. PowerShell can also leave its success flag true after
+a parse error, so check the terminal output when a command returns without
+doing the expected work. The recorder exits 1 when the shell reports failure
+and 2 when it is still running after `--seconds`. `key` presses one key and `watch` films
+without typing; both wait for the prompt the same way. Every `--record`
+directory is a `kind: frames` scene at `rate: 5`; a slow screenshot repeats
+the previous frame so the timing stays honest. Use a new or empty directory
+for every take, including retakes. A nonempty `--record` directory is refused
+before input is sent, preserving the earlier take.
+
+Long work spans takes exactly as on Unix: film the command being issued with
+a short `--seconds`, do other things, then `watch` the result as a new take.
+The shell, its variables and its cwd persist across calls until `close`.
+`close` asks the running `serve` command to clean up its owned browser, ttyd,
+shell descendants, readiness file, and profile, then waits up to 30 seconds
+for confirmation. It returns failure when `serve` is unavailable, descendant
+cleanup cannot be confirmed, or profile cleanup fails; it never kills numeric
+PIDs copied from old session metadata. Hard-killing `serve` can leave children
+and stale readiness behind, so a surviving browser or `ready.json` cannot by
+itself establish that a live owner remains.
+
+The viewport is fixed at 1600×900 with a 17 px font. Look at
+`SESSION/ready.png` before filming; `serve` refuses a blank canvas, the same
+preflight as below.
+
+## Unix: tmux and ttyd
+
+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. The commands below are the Unix recipe.
+`examples/film-terminal.py` implements the separate native Windows route.
+
+```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.

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

@@ -0,0 +1,143 @@
+# 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);
+document.addEventListener("mouseup",
+  () => ring.style.transform = "translate(-50%,-50%)", 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.
+
+## Native Windows desktop capture
+
+FFmpeg's `gdigrab` captures one window by its exact title, or the whole
+desktop with `-i desktop`. From an ordinary-user interactive desktop:
+
+```powershell
+$check = "$HOME/movie capture check"
+[IO.Directory]::CreateDirectory($check) | Out-Null
+$arguments = @('-nostdin','-y','-f','gdigrab','-framerate','5','-i',
+    'title=Your application window title','-t','2',"$check/window-check.mp4")
+& ffmpeg @arguments
+if ($LASTEXITCODE -ne 0) { throw 'Window capture unavailable' }
+$arguments = @('-nostdin','-y','-i',"$check/window-check.mp4",
+    '-frames:v','1',"$check/window-check.png")
+& ffmpeg @arguments
+```
+
+Look at the PNG. A zero exit with wallpaper or a blank window is not a
+capture; only visible application pixels are. On the host this was tested
+on, the window-title form captured the app and `-i desktop` returned only
+wallpaper. If neither shows the app, use the browser route and say what
+remains unproven.

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

@@ -0,0 +1,92 @@
+# 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.

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

@@ -0,0 +1,237 @@
+#!/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 tempfile
+from pathlib import Path
+
+import yaml
+from browser_tools import find_browser, render_card
+from media_paths import ffconcat_entry, sequence_pattern, stage_frames
+from narration_contract import accepted_narration
+
+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, *, cwd=None):
+    r = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True,
+                       encoding="utf-8", errors="replace")
+    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 has_audio_stream(path):
+    result = run(["ffprobe", "-v", "error", "-show_streams", "-of", "json", str(path)])
+    try:
+        streams = json.loads(result.stdout).get("streams", [])
+    except json.JSONDecodeError:
+        die(f"ffprobe returned invalid stream data for {path}")
+    return any(stream.get("codec_type") == "audio" for stream in streams)
+
+
+def movie_geometry(width, height, inner_height):
+    return {"scale": (width, inner_height), "pad": (width, height)}
+
+
+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, encoding="utf-8")
+    try:
+        render_card(tmp, png, browser=browser, width=w, height=h)
+    finally:
+        tmp.unlink(missing_ok=True)
+
+
+def main():
+    for stream in (sys.stdout, sys.stderr):
+        if hasattr(stream, "reconfigure"):
+            stream.reconfigure(errors="backslashreplace")
+    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(encoding="utf-8-sig"))
+    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")
+    try:
+        accepted = accepted_narration(narration, doc["scenes"])
+    except ValueError as error:
+        die(str(error))
+    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 = accepted.get(sid)
+        nard = dur(nar) if nar is not None else 0.0
+
+        if kind == "movie":
+            src = base / sc["src"]
+            target = dur(src)
+            inner_h = int(sc.get("height", int(H * 0.82)))
+            geometry = movie_geometry(W, H, inner_h)
+            source_audio = has_audio_stream(src)
+            ain = ([] if source_audio
+                   else ["-f", "lavfi", "-i", "anullsrc=r=44100:cl=stereo"])
+            audio_map = "0:a:0" if source_audio else "1:a:0"
+            run(["ffmpeg", "-nostdin", "-y", "-v", "error", "-i", str(src), *ain,
+                 "-vf", f"scale={geometry['scale'][0]}:{geometry['scale'][1]}:"
+                        f"force_original_aspect_ratio=decrease,"
+                        f"pad={geometry['pad'][0]}:{geometry['pad'][1]}:(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}",
+                 "-map", "0:v:0", "-map", audio_map,
+                 "-c:v", "libx264", "-preset", "medium", "-pix_fmt", "yuv420p",
+                 "-c:a", "aac", "-ar", "44100", "-ac", "2", str(seg)])
+        else:
+            frame_staging = None
+            if kind == "frames":
+                src = base / sc["src"]
+                rate = float(sc.get("rate", FPS))
+                frame_staging = tempfile.TemporaryDirectory(
+                    prefix=f"frames-{sid}-", dir=work
+                )
+                try:
+                    staged = stage_frames(
+                        src, Path(frame_staging.name) / "sequence"
+                    )
+                except ValueError as error:
+                    frame_staging.cleanup()
+                    die(f"scene {sid}: {error}")
+                except BaseException:
+                    frame_staging.cleanup()
+                    raise
+                n = len(staged)
+                vis = n / rate
+                target = max(nard, vis)
+                vin = ["-framerate", str(rate), "-start_number", "0",
+                       "-i", sequence_pattern(staged[0].parent, "frame-%08d.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 is not None
+                   else ["-f", "lavfi", "-i", "anullsrc=r=44100:cl=stereo"])
+            try:
+                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)])
+            finally:
+                if frame_staging is not None:
+                    frame_staging.cleanup()
+
+        actual = dur(seg)
+        # only scenes that speak get a subtitle offset; a movie played as
+        # itself carries its own subtitles already
+        if nar is not None and kind != "movie":
+            offsets[sid] = round(clock, 3)
+        clock += actual
+        concat_lines.append(ffconcat_entry(seg))
+        print(f"{sid}: {actual:.1f}s{' (own audio)' if kind == 'movie' else ''}")
+
+    listing = work / "concat.txt"
+    listing.write_text("".join(concat_lines), encoding="utf-8")
+    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), encoding="utf-8"
+    )
+    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())

+ 129 - 0
skills/proving-it-works-with-a-movie/scripts/browser_tools.py

@@ -0,0 +1,129 @@
+"""Headless browser discovery, bounded card screenshots, and process-tree cleanup."""
+
+from __future__ import annotations
+
+import os
+import shutil
+import signal
+import subprocess
+import sys
+import tempfile
+import time
+from pathlib import Path
+
+
+UNIX_BROWSERS = [
+    "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
+    "/Applications/Chromium.app/Contents/MacOS/Chromium",
+    "chromium", "chromium-browser", "google-chrome", "google-chrome-stable",
+]
+
+
+def _windows_browsers() -> list[Path]:
+    locations = []
+    for variable in ("LOCALAPPDATA", "PROGRAMFILES", "PROGRAMFILES(X86)", "PROGRAMW6432"):
+        value = os.environ.get(variable)
+        if value:
+            root = Path(value)
+            locations.extend([
+                root / "Google/Chrome/Application/chrome.exe",
+                root / "Microsoft/Edge/Application/msedge.exe",
+            ])
+    return locations
+
+
+def _resolve(candidate: str | Path) -> str | None:
+    path = Path(candidate).expanduser()
+    if path.is_file() and (sys.platform == "win32" or os.access(path, os.X_OK)):
+        return str(path.resolve())
+    return shutil.which(str(candidate))
+
+
+def find_browser(explicit: str | None) -> str | None:
+    """Return a usable Chrome-family executable, honoring explicit values."""
+    if explicit:
+        found = _resolve(explicit)
+        if found:
+            return found
+        raise FileNotFoundError(f"explicit browser is not usable: {explicit}")
+    candidates: list[str | Path] = (
+        _windows_browsers() + ["chrome.exe", "msedge.exe"]
+        if sys.platform == "win32" else UNIX_BROWSERS
+    )
+    for candidate in candidates:
+        found = _resolve(candidate)
+        if found:
+            return found
+    return None
+
+
+def _descendants(pid: int) -> list[int]:
+    pids, index = [pid], 0
+    while index < len(pids):
+        listed = subprocess.run(["pgrep", "-P", str(pids[index])], capture_output=True, text=True).stdout
+        pids.extend(int(child) for child in listed.split())
+        index += 1
+    return pids
+
+
+def kill_process_tree(pid: int) -> None:
+    """Kill a process this tool started and everything it spawned. A browser
+    or ttyd leaves helpers behind otherwise, and on Unix a pty child starts
+    its own session, so a process group is not enough."""
+    if sys.platform == "win32":
+        result = subprocess.run(["taskkill", "/T", "/F", "/PID", str(pid)], capture_output=True)
+        if result.returncode != 0:
+            detail = result.stderr.decode(errors="replace").strip()
+            raise OSError(f"taskkill failed for child {pid} (status {result.returncode}): {detail}")
+        return
+    for victim in reversed(_descendants(pid)):
+        try:
+            os.kill(victim, signal.SIGKILL)
+        except ProcessLookupError:
+            pass
+
+
+def render_card(html: Path, png: Path, *, browser: str, width: int,
+                height: int, timeout: float = 20) -> None:
+    """Render one local HTML page and release every process it launched."""
+    html = Path(html).resolve()
+    png = Path(png).resolve()
+    if not html.is_file():
+        raise FileNotFoundError(f"card HTML does not exist: {html}")
+    png.parent.mkdir(parents=True, exist_ok=True)
+    png.unlink(missing_ok=True)
+    with tempfile.TemporaryDirectory(prefix="movie-browser-") as profile:
+        profile_path = Path(profile)
+        log = profile_path / "browser.log"
+        argv = [
+            str(Path(browser).resolve()) if Path(browser).is_file() else browser,
+            "--headless=new", "--disable-gpu", "--hide-scrollbars",
+            "--no-first-run", "--no-default-browser-check",
+            f"--user-data-dir={profile_path}", f"--screenshot={png}",
+            f"--window-size={width},{height}", "--force-device-scale-factor=1",
+            html.as_uri(),
+        ]
+        with log.open("wb") as output:
+            process = subprocess.Popen(argv, cwd=profile_path, stdin=subprocess.DEVNULL,
+                                       stdout=output, stderr=subprocess.STDOUT,
+                                       start_new_session=sys.platform != "win32")
+        try:
+            deadline = time.monotonic() + timeout
+            while time.monotonic() < deadline:
+                # A fresh profile may keep background services alive after
+                # taking the screenshot. A complete PNG is the render result.
+                if png.is_file():
+                    data = png.read_bytes()
+                    if data.startswith(b"\x89PNG\r\n\x1a\n") and data.endswith(b"IEND\xaeB`\x82"):
+                        return
+                elif process.poll() is not None:
+                    detail = log.read_text(encoding="utf-8", errors="replace")[-1000:]
+                    raise RuntimeError(f"Browser exited with status {process.returncode} "
+                                       f"without a complete PNG: {detail}")
+                time.sleep(0.05)
+            raise TimeoutError(f"Browser exceeded {timeout:g}s")
+        finally:
+            # One-shot screenshot commands may exit normally once output is ready.
+            if process.poll() is None:
+                kill_process_tree(process.pid)
+            process.wait(timeout=5)

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

@@ -0,0 +1,109 @@
+#!/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
+import tempfile
+from pathlib import Path
+
+
+def has_libass():
+    out = subprocess.run(["ffmpeg", "-hide_banner", "-filters"],
+                         capture_output=True, text=True, encoding="utf-8", errors="replace")
+    return any(line.split()[1:2] == ["subtitles"]
+               for line in out.stdout.splitlines() if line.strip())
+
+
+def run(cmd, *, cwd=None):
+    r = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, encoding="utf-8", errors="replace")
+    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():
+    for stream in (sys.stdout, sys.stderr):
+        if hasattr(stream, "reconfigure"):
+            stream.reconfigure(errors="backslashreplace")
+    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}")
+
+    args.movie, args.subs, args.out = (path.resolve() for path in
+                                       (args.movie, args.subs, args.out))
+    libass = has_libass() if not args.soft else False
+    if not args.soft and 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&")
+        # Only the safe basename enters filter syntax; media paths stay absolute.
+        with tempfile.TemporaryDirectory(prefix="movie-subtitles-") as temporary:
+            directory = Path(temporary)
+            shutil.copyfile(args.subs, directory / "captions.srt")
+            ok = run(["ffmpeg", "-nostdin", "-y", "-v", "error",
+                      "-i", str(args.movie),
+                      "-vf", "subtitles=filename=captions.srt:"
+                             f"force_style='{style}'",
+                      "-c:a", "copy", "-c:v", "libx264", "-preset", "medium",
+                      "-pix_fmt", "yuv420p", str(args.out)], cwd=directory)
+        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),
+              "-map", "0:v:0", "-map", "0:a?", "-map", "1:s:0",
+              "-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 and not libass:
+        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())

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

@@ -0,0 +1,293 @@
+#!/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 re
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+from PIL import Image
+from media_paths import sequence_pattern
+
+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",
+         sequence_pattern(frames, "s%05d.png")],
+        capture_output=True, text=True, encoding="utf-8", errors="replace")
+    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 subtitle_end(text):
+    """Return the last SRT cue's end time, or None when there are no cues."""
+    ends = []
+    timestamp = r"(\d{2,}):([0-5]\d):([0-5]\d),(\d{3})"
+    for block in re.split(r"\n\s*\n", text.strip()):
+        if not block:
+            continue
+        lines = block.splitlines()
+        if len(lines) < 2 or not lines[0].strip().isdigit():
+            raise ValueError("malformed SRT cue index or missing timing line")
+        timing = re.fullmatch(rf"{timestamp}\s+-->\s+{timestamp}", lines[1].strip())
+        if timing is None:
+            raise ValueError(f"malformed SRT cue timing: {lines[1]}")
+        hh, mm, ss, ms = map(int, timing.groups()[4:])
+        ends.append(hh * 3600 + mm * 60 + ss + ms / 1000)
+    return max(ends, default=None)
+
+
+def main():
+    for stream in (sys.stdout, sys.stderr):
+        if hasattr(stream, "reconfigure"):
+            stream.reconfigure(errors="backslashreplace")
+    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, encoding="utf-8", errors="replace")
+    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 args.expect_audio and 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 talking and args.expect_subs:
+        srt = args.subs or args.movie.with_suffix(".srt")
+        embedded = any(s["codec_type"] == "subtitle" for s in info["streams"])
+        subtitle_text, source = None, srt.name
+        if srt.exists():
+            subtitle_text = srt.read_text(encoding="utf-8-sig", errors="replace")
+        elif embedded:
+            extracted = subprocess.run(
+                ["ffmpeg", "-nostdin", "-v", "error", "-i", str(args.movie),
+                 "-map", "0:s:0", "-f", "srt", "-"],
+                capture_output=True, text=True, encoding="utf-8", errors="replace")
+            if extracted.returncode != 0:
+                die(f"embedded subtitle extraction failed: {extracted.stderr.strip()[:200]}")
+            subtitle_text, source = extracted.stdout, "embedded"
+        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 subtitle_text is not None:
+            try:
+                last = subtitle_end(subtitle_text)
+            except (ValueError, IndexError):
+                die(f"invalid subtitle timing in {source}")
+            # 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 is None:
+                failures.append(f"{source}: subtitles contain no cues")
+            else:
+                print(f"subtitles   {source}, 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")
+    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), encoding="utf-8")
+
+    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())

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

@@ -0,0 +1,171 @@
+#!/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 math
+import sys
+from pathlib import Path
+
+MAX_CHARS = 84          # two comfortable lines
+MAX_SECS = 5.5
+
+
+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 scene_cues(text, start, duration, max_chars, max_secs):
+    """Allocate positive millisecond cues across the complete measured scene."""
+    end = start + duration
+    if (not all(math.isfinite(value) for value in (start, duration, end))
+            or start < 0 or duration <= 0):
+        raise ValueError("start must be finite and nonnegative; "
+                         "duration must be finite and positive")
+    start_ms, end_ms = round(start * 1000), round(end * 1000)
+    available = end_ms - start_ms
+    if available <= 0:
+        raise ValueError("scene has no representable millisecond subtitle interval")
+
+    # Readability guides word-boundary splitting, never cuts off the scene tail.
+    char_limit = max(1, min(max_chars,
+                            int(len(text) * min(1.0, max_secs / duration))))
+    chunks = cue_chunks(text, char_limit)
+    if len(chunks) > available:
+        chunks = [" ".join(chunks[i * len(chunks) // available:
+                                  (i + 1) * len(chunks) // available])
+                  for i in range(available)]
+    while True:
+        total_chars = sum(len(chunk) for chunk in chunks) or 1
+        elapsed_chars, previous = 0, start_ms
+        cues, split = [], None
+        for i, chunk in enumerate(chunks):
+            elapsed_chars += len(chunk)
+            remaining = len(chunks) - i - 1
+            boundary = start_ms + round(available * elapsed_chars / total_chars)
+            # Reserve one millisecond per remaining cue, even for very uneven text.
+            boundary = min(end_ms - remaining, max(previous + 1, boundary))
+            if not remaining:
+                boundary = end_ms
+            if (split is None and boundary - previous > max_secs * 1000
+                    and len(chunk.split()) > 1):
+                split = i
+            cues.append((previous / 1000, boundary / 1000, wrap(chunk)))
+            previous = boundary
+        if split is None or len(chunks) == available:
+            return cues
+        # Splitting removes a space from the allocation weights, so remeasure all
+        # cues until every splittable chunk fits or milliseconds limit the count.
+        words = chunks[split].split()
+        midpoint = len(words) // 2
+        chunks[split:split + 1] = [" ".join(words[:midpoint]),
+                                   " ".join(words[midpoint:])]
+
+
+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():
+    for stream in (sys.stdout, sys.stderr):
+        if hasattr(stream, "reconfigure"):
+            stream.reconfigure(errors="backslashreplace")
+    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; other scenes run "
+                         "back to back in manifest order")
+    ap.add_argument("--offsets-json", type=Path, default=None,
+                    help="segments/offsets.json from assemble - selects scenes "
+                         "in the finished cut and sets their start times")
+    ap.add_argument("--max-chars", type=int, default=MAX_CHARS)
+    ap.add_argument("--max-secs", type=float, default=MAX_SECS)
+    args = ap.parse_args()
+    if args.max_chars <= 0 or not math.isfinite(args.max_secs) or args.max_secs <= 0:
+        ap.error("--max-chars and --max-secs must be finite and positive")
+
+    manifest = json.loads(args.manifest.read_text(encoding="utf-8-sig"))
+    overrides = {}
+    if args.offsets_json:
+        overrides.update({k: float(v) for k, v in
+                          json.loads(args.offsets_json.read_text(encoding="utf-8-sig")).items()})
+        # Assembly offsets identify the cut's scenes; manual offsets only retime them.
+        manifest = [e for e in manifest if e["id"] in overrides]
+    for spec in args.offsets:
+        k, _, v = spec.partition("=")
+        overrides[k] = float(v)
+
+    cues, clock = [], 0.0
+    for entry in manifest:
+        start = overrides.get(entry["id"], clock)
+        try:
+            dur = float(entry["duration"])
+            cues.extend(scene_cues(entry["text"], start, dur,
+                                   args.max_chars, args.max_secs))
+        except (ValueError, TypeError, OverflowError) as error:
+            ap.error(f"scene {entry['id']}: {error}")
+        clock = start + dur
+
+    lines = []
+    for i, (a, b, text) in enumerate(cues, 1):
+        lines += [str(i), f"{ts(a)} --> {ts(b)}", text, ""]
+    args.out.write_text("\n".join(lines), encoding="utf-8")
+    end = cues[-1][1] if cues else 0.0
+    print(f"{len(cues)} cues, ends at {ts(end)} -> {args.out}")
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 29 - 0
skills/proving-it-works-with-a-movie/scripts/media_paths.py

@@ -0,0 +1,29 @@
+"""Portable media path preparation for the movie assembly tools."""
+
+import shutil
+from pathlib import Path
+
+
+def stage_frames(source: Path, destination: Path) -> list[Path]:
+    files = sorted(source.glob("*.png"))
+    if not files:
+        raise ValueError(f"no PNG frames in {source}")
+    destination.mkdir(parents=True, exist_ok=False)
+    result = []
+    for index, frame in enumerate(files):
+        target = destination / f"frame-{index:08d}.png"
+        shutil.copyfile(frame, target)
+        result.append(target)
+    return result
+
+
+def ffconcat_entry(path: Path) -> str:
+    value = path.resolve().as_posix()
+    if "\n" in value or "\r" in value:
+        raise ValueError("FFconcat paths cannot contain line breaks")
+    return "file '" + value.replace("'", "'\\''") + "'\n"
+
+
+def sequence_pattern(directory: Path, filename_pattern: str) -> str:
+    """Keep FFmpeg's frame placeholder while escaping literal directory percent signs."""
+    return directory.as_posix().replace("%", "%%") + "/" + filename_pattern

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

@@ -0,0 +1,422 @@
+#!/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 shutil
+import subprocess
+import sys
+import tempfile
+import unicodedata
+import urllib.request
+import uuid
+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
+
+
+SEGMENTATION_SCRIPTS = (
+    (0x3040, 0x30FF),  # Hiragana and Katakana
+    (0x3400, 0x4DBF),  # CJK Extension A
+    (0x4E00, 0x9FFF),  # CJK Unified Ideographs
+    (0xF900, 0xFAFF),  # CJK Compatibility Ideographs
+    (0x20000, 0x323AF),  # CJK Unified Ideograph extensions B through I
+    (0x2F800, 0x2FA1F),  # CJK Compatibility Ideographs Supplement
+    (0x0E00, 0x0E7F),  # Thai
+)
+
+
+def norm(s):
+    """Return comparable words without changing narration/cache identity."""
+    words = []
+    for token in unicodedata.normalize("NFKC", s).casefold().split():
+        word = "".join(
+            char for char in token
+            if unicodedata.category(char)[0] in {"L", "N", "M"}
+        )
+        if word:
+            words.append(word)
+    return words
+
+
+def requires_segmentation(s):
+    return any(start <= ord(char) <= end
+               for char in unicodedata.normalize("NFKC", s)
+               for start, end in SEGMENTATION_SCRIPTS)
+
+
+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])
+from pathlib import Path
+import json
+Path(sys.argv[3]).write_text(json.dumps({"text": " ".join(s.text.strip() for s in segs)}), encoding="utf-8")
+"""
+
+
+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:
+        with tempfile.TemporaryDirectory(prefix="movie-asr-") as temporary:
+            result_path = Path(temporary) / "transcript.json"
+            out = subprocess.run(
+                ["uv", "run", "--no-config", "--no-project", "--isolated",
+                 "--python", sys.executable, "--with", "faster-whisper", "python",
+                 "-c", ASR_SNIPPET, str(wav.resolve()), model, str(result_path)],
+                cwd=temporary, capture_output=True, text=True,
+                encoding="utf-8", errors="replace", timeout=900)
+            for diagnostic in (out.stdout, out.stderr):
+                if diagnostic.strip():
+                    print(f"local ASR: {diagnostic.strip()[-600:]}", file=sys.stderr)
+            if out.returncode != 0:
+                print(f"local ASR exited with status {out.returncode}", file=sys.stderr)
+                return None
+            data = json.loads(result_path.read_text(encoding="utf-8"))
+            text = data.get("text") if isinstance(data, dict) else None
+            return text.strip() if isinstance(text, str) else None
+    except (OSError, ValueError, subprocess.SubprocessError) as error:
+        print(f"local ASR unavailable: {error}", file=sys.stderr)
+        return 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_added_or_changed_words).
+    """
+    if requires_segmentation(text) or requires_segmentation(heard):
+        return None
+    want, got = norm(text), norm(heard)
+    if not want:
+        return None
+    if not got:
+        return 1.0, len(want)
+    delta = abs(len(got) - len(want)) / max(1, len(want))
+    ops = difflib.SequenceMatcher(a=want, b=got).get_opcodes()
+    worst = max((max(i2 - i1, j2 - j1) for tag, i1, i2, j1, j2 in ops if tag != "equal"),
+                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,
+                 "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, "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
+    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, encoding="utf-8", errors="replace")
+    return round(float(out.stdout.strip()), 3)
+
+
+def accepted_wav(outdir, entry):
+    wav = entry.get("wav") if isinstance(entry, dict) else None
+    if not isinstance(wav, str) or not wav:
+        return None
+    candidate = Path(wav)
+    if candidate.is_absolute():
+        return None
+    return outdir / candidate
+
+
+def write_manifest(manifest_path, entries):
+    with tempfile.NamedTemporaryFile(dir=manifest_path.parent, delete=False) as handle:
+        temporary = Path(handle.name)
+    try:
+        temporary.write_text(json.dumps(entries, indent=2), encoding="utf-8")
+        temporary.replace(manifest_path)
+    finally:
+        temporary.unlink(missing_ok=True)
+
+
+def main():
+    for stream in (sys.stdout, sys.stderr):
+        if hasattr(stream, "reconfigure"):
+            stream.reconfigure(errors="backslashreplace")
+    if len(sys.argv) == 4 and sys.argv[1] == "--drift-check":
+        script = Path(sys.argv[2]).read_text(encoding="utf-8-sig")
+        heard = Path(sys.argv[3]).read_text(encoding="utf-8-sig")
+        drift = structural_drift(script, heard)
+        if drift is None:
+            print("comparison unavailable -> MISMATCH")
+            return 1
+        delta, worst = drift
+        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="local ASR: on requires verification; auto tries it "
+                         "for piper/openai-chat but allows unavailable ASR; "
+                         "off skips ASR (default: auto)")
+    ap.add_argument("--asr-model", default="base.en")
+    args = ap.parse_args()
+
+    doc = yaml.safe_load(args.scenes.read_text(encoding="utf-8-sig"))
+    scenes = [s for s in doc.get("scenes", []) if (s.get("narration") or "").strip()]
+    if not scenes:
+        die("no scenes with narration")
+    if not shutil.which("ffprobe"):
+        die("ffprobe not on PATH (narration durations cannot be measured)")
+
+    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 ""))
+    voice = args.voice or (PIPER_VOICE if engine == "piper" else "nova")
+    synthesis = {"engine": engine, "voice": voice, "model": {
+        "openai": OPENAI_TTS_MODEL,
+        "openai-chat": OPENAI_CHAT_MODEL,
+        "piper": voice,
+    }[engine]}
+
+    # 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)
+    # Cache only clips rendered from the requested text and synthesis settings.
+    prior = {}
+    prior_path = args.outdir / "manifest.json"
+    if prior_path.exists():
+        try:
+            prior = {e["id"]: e for e in
+                     json.loads(prior_path.read_text(encoding="utf-8-sig"))}
+        except Exception:  # noqa: BLE001 - a corrupt manifest just means no cache
+            prior = {}
+    current_ids = {sc["id"] for sc in scenes}
+    manifest = {sid: entry for sid, entry in prior.items() if sid in current_ids}
+    write_manifest(prior_path, list(manifest.values()))
+    failures = []
+
+    def withdraw(sid):
+        if sid in manifest:
+            del manifest[sid]
+        write_manifest(prior_path, list(manifest.values()))
+
+    def accept(entry):
+        manifest[entry["id"]] = entry
+        write_manifest(prior_path, list(manifest.values()))
+
+    for sc in scenes:
+        sid = sc["id"]
+        text = " ".join((sc["narration"] or "").split())
+        previous = prior.get(sid, {})
+        previous_wav = accepted_wav(args.outdir, previous)
+        cached = (previous_wav is not None and previous_wav.exists() and not args.force
+                  and previous.get("text") == text
+                  and previous.get("synthesis") == synthesis)
+        if cached:
+            print(f"{sid}: cached")
+        elif previous_wav is not None and previous_wav.exists() and not args.force and sid in prior:
+            print(f"{sid}: text or synthesis settings changed - redoing")
+        if engine == "openai-chat" and structural_drift(text, text) is None:
+            print(f"{sid}: chat transcript comparison unavailable", file=sys.stderr)
+            withdraw(sid)
+            failures.append(sid)
+            continue
+        withdraw(sid)
+        accepted = False
+        for attempt in ((1,) if cached else (1, 2)):
+            claimed = None
+            candidate = previous_wav if cached else args.outdir / (
+                f".{sid}.attempt-{uuid.uuid4().hex}.wav"
+            )
+            if not cached:
+                try:
+                    if engine == "openai":
+                        claimed = say_openai(key, text, candidate, voice)
+                    elif engine == "openai-chat":
+                        claimed = say_openai_chat(key, text, candidate, voice)
+                    else:
+                        claimed = say_piper(text, candidate, voice)
+                except Exception as error:  # rejected bytes remain as evidence
+                    print(f"{sid}: synthesis failed (attempt {attempt}: {error})", file=sys.stderr)
+                    continue
+
+            # Preserve the engine transcript gate and the ASR drift thresholds.
+            if engine == "openai-chat" and not cached:
+                if not isinstance(claimed, str) or not norm(claimed):
+                    print(f"{sid}: chat transcript contains no speech", file=sys.stderr)
+                    continue
+                drift_result = structural_drift(text, claimed)
+                if drift_result is None:
+                    print(f"{sid}: chat transcript comparison unavailable", file=sys.stderr)
+                    break
+                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}, drift {drift})")
+                    continue
+            if verify:
+                heard = transcribe_local(candidate, args.asr_model)
+                if heard is None:
+                    if args.verify == "on":
+                        print(f"{sid}: required verification unavailable", file=sys.stderr)
+                        break
+                    else:
+                        print(f"{sid}: verification unavailable (no local ASR)")
+                else:
+                    drift_result = structural_drift(text, heard)
+                    if drift_result is None:
+                        if args.verify == "on":
+                            print(f"{sid}: required verification unavailable", file=sys.stderr)
+                            break
+                        print(f"{sid}: verification unavailable (unsupported script)")
+                    else:
+                        delta, worst = drift_result
+                        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})")
+            if not verify:
+                print(f"{sid}: ok")
+            try:
+                clip_duration = duration(candidate)
+            except Exception as error:  # unaccepted bytes remain as evidence
+                print(f"{sid}: duration failed ({error})", file=sys.stderr)
+                break
+            if not cached:
+                candidate.replace(args.outdir / f"{sid}.wav")
+                candidate = args.outdir / f"{sid}.wav"
+            wav_name = previous["wav"] if cached else candidate.name
+            accept({"id": sid, "text": text, "wav": wav_name,
+                    "duration": clip_duration, "synthesis": synthesis})
+            accepted = True
+            break
+        if not accepted:
+            withdraw(sid)
+            failures.append(sid)
+
+    entries = list(manifest.values())
+    total = sum(m["duration"] for m in entries)
+    print(f"\n{len(entries)} 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())

+ 41 - 0
skills/proving-it-works-with-a-movie/scripts/narration_contract.py

@@ -0,0 +1,41 @@
+"""Accepted narration shared by rendering and assembly."""
+
+import json
+from pathlib import Path
+
+
+def normalized_text(text):
+    return " ".join((text or "").split())
+
+
+def accepted_narration(narration_dir, scenes):
+    """Return accepted WAVs for narrated non-movie scenes, or explain the gap."""
+    required = [scene for scene in scenes
+                if scene.get("kind", "frames") != "movie"
+                and normalized_text(scene.get("narration"))]
+    if not required:
+        return {}
+    manifest_path = narration_dir / "manifest.json"
+    if not manifest_path.is_file():
+        raise ValueError(f"missing narration manifest: {manifest_path}")
+    try:
+        entries = json.loads(manifest_path.read_text(encoding="utf-8-sig"))
+    except (OSError, ValueError, json.JSONDecodeError) as error:
+        raise ValueError(f"invalid narration manifest: {error}") from error
+    by_id = {entry.get("id"): entry for entry in entries if isinstance(entry, dict)}
+    accepted = {}
+    for scene in required:
+        sid = scene["id"]
+        entry = by_id.get(sid)
+        if entry is None:
+            raise ValueError(f"scene {sid}: missing accepted narration entry")
+        if entry.get("text") != normalized_text(scene.get("narration")):
+            raise ValueError(f"scene {sid}: accepted narration text changed")
+        wav_name = entry.get("wav")
+        if not isinstance(wav_name, str) or not wav_name or Path(wav_name).is_absolute():
+            raise ValueError(f"scene {sid}: invalid accepted narration WAV")
+        wav = narration_dir / wav_name
+        if not wav.is_file():
+            raise ValueError(f"scene {sid}: missing accepted narration WAV: {wav}")
+        accepted[sid] = wav
+    return accepted

+ 29 - 0
tests/proving-it-works-with-a-movie/README.md

@@ -0,0 +1,29 @@
+# Portable movie regressions
+
+Run a suite from the repository root:
+
+```sh
+uv run --script tests/proving-it-works-with-a-movie/run-tests.py --suite assembly
+uv run --script tests/proving-it-works-with-a-movie/run-tests.py --suite checker
+uv run --script tests/proving-it-works-with-a-movie/run-tests.py --suite contracts
+uv run --script tests/proving-it-works-with-a-movie/run-tests.py --suite narration
+uv run --script tests/proving-it-works-with-a-movie/run-tests.py --suite all
+```
+
+By default, unavailable external capabilities are reported as skips. Add
+`--require-capabilities` when the selected environment is required to provide
+them; any skip then makes the run fail.
+
+The assembly sine wave is only a synthetic timing fixture. The narration drift
+inputs exercise text comparison only. Neither is speech/ASR acceptance.
+
+The `contracts` suite is the safe entrypoint for mocked process/media boundaries
+and text fixtures. It does not run the existing media/session suites, inspect a
+finished movie, or replace live acceptance and the required human viewing gate.
+
+The `terminal` suite starts a real ttyd session and skips where ttyd or a
+Chrome-family browser is missing. It films `bash` by default on Unix and
+`powershell51` on Windows; set `MOVIE_TEST_SHELL` to `powershell51`,
+`powershell7`, `gitbash`, or `bash`, and `MOVIE_TEST_SHELL_EXE`,
+`MOVIE_TEST_TTYD`, or `MOVIE_TEST_BROWSER` when those executables are not on
+PATH.

+ 299 - 0
tests/proving-it-works-with-a-movie/fixtures.py

@@ -0,0 +1,299 @@
+"""Portable fixtures for the imported movie regression suites."""
+
+import json
+import importlib.util
+import importlib.machinery
+import shutil
+import subprocess
+import types
+import sys
+from pathlib import Path
+
+
+TIMEOUT_SECONDS = 900
+
+
+def missing_executables(*names: str) -> list[str]:
+    """Return executable names that cannot be resolved on PATH."""
+    return [name for name in names if shutil.which(name) is None]
+
+
+def output_text(result: subprocess.CompletedProcess[bytes]) -> str:
+    """Decode a captured command's combined output as UTF-8 evidence."""
+    return (result.stdout + result.stderr).decode("utf-8", errors="replace")
+
+
+def run_tool(
+    name: str,
+    args: list[str],
+    *,
+    cwd: Path,
+    env: dict[str, str] | None = None,
+) -> subprocess.CompletedProcess[bytes]:
+    """Invoke one extensionless movie tool through its PEP 723 environment."""
+    uv = shutil.which("uv")
+    if uv is None:
+        raise RuntimeError("uv is required for movie tool tests")
+    script = (
+        Path(__file__).resolve().parents[2]
+        / "skills/proving-it-works-with-a-movie/scripts"
+        / name
+    )
+    return subprocess.run(
+        [uv, "run", "--script", str(script), *args],
+        cwd=cwd,
+        env=env,
+        capture_output=True,
+        timeout=TIMEOUT_SECONDS,
+    )
+
+
+def load_script(name: str) -> types.ModuleType:
+    """Load an extensionless movie tool as a test module."""
+    script = (
+        Path(__file__).resolve().parents[2]
+        / "skills/proving-it-works-with-a-movie/scripts"
+        / name
+    )
+    if not script.exists():
+        script = script.with_suffix(".py")
+    sys.path.insert(0, str(script.parent))
+    loader = importlib.machinery.SourceFileLoader(f"movie_tool_{name}", str(script))
+    spec = importlib.util.spec_from_loader(loader.name, loader)
+    if spec is None or spec.loader is None:
+        raise ImportError(f"cannot load movie tool {name!r}")
+    module = importlib.util.module_from_spec(spec)
+    try:
+        spec.loader.exec_module(module)
+    finally:
+        sys.path.pop(0)
+    return module
+
+
+def duration(path: Path) -> float:
+    """Measure a media file's container duration with ffprobe."""
+    ffprobe = shutil.which("ffprobe")
+    if ffprobe is None:
+        raise RuntimeError("ffprobe is required for movie tool tests")
+    result = subprocess.run(
+        [
+            ffprobe,
+            "-v",
+            "error",
+            "-show_entries",
+            "format=duration",
+            "-of",
+            "csv=p=0",
+            str(path),
+        ],
+        capture_output=True,
+        timeout=TIMEOUT_SECONDS,
+    )
+    if result.returncode != 0:
+        raise RuntimeError(f"ffprobe failed for {path}: {output_text(result)}")
+    return float(result.stdout.decode("utf-8").strip())
+
+
+def _run_ffmpeg(args: list[str], *, cwd: Path) -> None:
+    ffmpeg = shutil.which("ffmpeg")
+    if ffmpeg is None:
+        raise RuntimeError("ffmpeg is required for movie tool tests")
+    result = subprocess.run(
+        [ffmpeg, "-nostdin", "-y", "-v", "error", *args],
+        cwd=cwd,
+        capture_output=True,
+        timeout=TIMEOUT_SECONDS,
+    )
+    if result.returncode != 0:
+        raise RuntimeError(f"ffmpeg fixture generation failed: {output_text(result)}")
+
+
+def assembly_fixture(work: Path) -> Path:
+    """Write the imported image/frames/synthetic-timing-audio assembly case."""
+    shots = work / "shots"
+    shots.mkdir(parents=True)
+    for index in range(1, 5):
+        _run_ffmpeg(
+            [
+                "-f",
+                "lavfi",
+                "-i",
+                f"color=c=0x{index}0{index}0{index}0:size=320x180:d=0.1",
+                "-frames:v",
+                "1",
+                str(shots / f"s0{index}.png"),
+            ],
+            cwd=work,
+        )
+
+    narration = work / "narration"
+    narration.mkdir()
+    wav = narration / "body.wav"
+    _run_ffmpeg(
+        [
+            "-f",
+            "lavfi",
+            "-i",
+            "sine=frequency=300:duration=6",
+            str(wav),
+        ],
+        cwd=work,
+    )
+    manifest = [
+        {
+            "id": "body",
+            "text": "one two three four five six seven eight nine ten",
+            "wav": "body.wav",
+            "duration": duration(wav),
+        }
+    ]
+    (narration / "manifest.json").write_text(
+        json.dumps(manifest), encoding="utf-8"
+    )
+
+    scenes = work / "scenes.yaml"
+    scenes.write_text(
+        """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
+    narration: one two three four five six seven eight nine ten
+""",
+        encoding="utf-8",
+    )
+    return scenes
+
+
+def checker_fixture(work: Path) -> dict[str, Path]:
+    """Create the four imported checker movies and their subtitle sidecars."""
+    front_loaded = work / "front-loaded.mp4"
+    _run_ffmpeg(
+        [
+            "-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",
+            str(front_loaded),
+        ],
+        cwd=work,
+    )
+
+    paced = work / "paced.mp4"
+    _run_ffmpeg(
+        [
+            "-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",
+            str(paced),
+        ],
+        cwd=work,
+    )
+
+    still = work / "still.mp4"
+    _run_ffmpeg(
+        [
+            "-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",
+            str(still),
+        ],
+        cwd=work,
+    )
+
+    silent = work / "silent.mp4"
+    _run_ffmpeg(
+        [
+            "-f",
+            "lavfi",
+            "-i",
+            "testsrc2=size=320x240:rate=10:d=12",
+            "-c:v",
+            "libx264",
+            "-pix_fmt",
+            "yuv420p",
+            str(silent),
+        ],
+        cwd=work,
+    )
+
+    subtitles = """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.
+"""
+    (work / "paced.srt").write_text(subtitles, encoding="utf-8")
+    (work / "short.srt").write_text(
+        "\n".join(subtitles.splitlines()[:8]) + "\n", encoding="utf-8"
+    )
+    short = work / "short.mp4"
+    shutil.copyfile(paced, short)
+
+    return {
+        "front-loaded": front_loaded,
+        "paced": paced,
+        "still": still,
+        "silent": silent,
+        "short": short,
+    }

+ 41 - 0
tests/proving-it-works-with-a-movie/fixtures/terminal_app.py

@@ -0,0 +1,41 @@
+"""TUI fixture: three timed colour states, then wait for `q`.
+`tree DIR` instead leaves a three-deep process tree running for cleanup tests."""
+import json, os, subprocess, sys, time
+from pathlib import Path
+
+
+def getch():
+    if os.name == "nt":
+        import msvcrt
+        return msvcrt.getwch()
+    import termios, tty
+    fd = sys.stdin.fileno()
+    old = termios.tcgetattr(fd)
+    try:
+        tty.setcbreak(fd)
+        return sys.stdin.read(1)
+    finally:
+        termios.tcsetattr(fd, termios.TCSADRAIN, old)
+
+
+if len(sys.argv) > 1 and sys.argv[1] == "tree":
+    directory = Path(sys.argv[2])
+    directory.mkdir(exist_ok=True)
+    level = int(sys.argv[3]) if len(sys.argv) > 3 else 0
+    if level < 2:
+        subprocess.Popen([sys.executable, __file__, "tree", str(directory), str(level + 1)])
+    (directory / f"{level}.json").write_text(json.dumps(dict(pid=os.getpid(), level=level)))
+    while True:
+        time.sleep(1)
+
+print("WRAPPING OUTPUT " + ("0123456789 wrap proof " * 60), flush=True)
+time.sleep(1.4)
+states = []
+for label, color in [("STATE ONE RED", 41), ("STATE TWO GREEN", 42), ("STATE THREE BLUE", 44)]:
+    print("\x1b[2J\x1b[H" + f"\x1b[{color}m" + (label + " " * 60 + "\n") * 12 + "\x1b[0m", end="", flush=True)
+    states.append(dict(label=label, time=time.monotonic()))
+    Path("states.json").write_text(json.dumps(states))
+    time.sleep(1.5)
+while getch() != "q":
+    pass
+print("\nAUTOMATIC GATE COMPLETE", flush=True)

+ 64 - 0
tests/proving-it-works-with-a-movie/run-tests.py

@@ -0,0 +1,64 @@
+#!/usr/bin/env -S uv run --script
+# /// script
+# requires-python = ">=3.10"
+# dependencies = ["pyyaml", "pillow", "websocket-client==1.9.0"]
+# ///
+"""Run the proving-it-works-with-a-movie regression suites portably."""
+
+import argparse
+import sys
+import unittest
+from pathlib import Path
+
+
+IMPLEMENTED_SUITES = {
+    "assembly": "test_assembly.py",
+    "browser": "test_browser.py",
+    "checker": "test_checker.py",
+    "contracts": "test_*contract*.py",
+    "narration": "test_narration.py",
+    "paths": "test_paths.py",
+    "subtitles": "test_subtitles.py",
+    "terminal": "test_terminal.py",
+}
+def parse_args() -> argparse.Namespace:
+    parser = argparse.ArgumentParser()
+    parser.add_argument(
+        "--suite",
+        required=True,
+        choices=[*IMPLEMENTED_SUITES, "all"],
+    )
+    parser.add_argument("--require-capabilities", action="store_true")
+    return parser.parse_args()
+
+
+def main() -> int:
+    args = parse_args()
+    patterns = (
+        list(IMPLEMENTED_SUITES.values())
+        if args.suite == "all"
+        else [IMPLEMENTED_SUITES[args.suite]]
+    )
+    test_directory = Path(__file__).resolve().parent
+    loader = unittest.TestLoader()
+    suite = unittest.TestSuite(
+        loader.discover(
+            str(test_directory),
+            pattern=pattern,
+            top_level_dir=str(test_directory),
+        )
+        for pattern in patterns
+    )
+    result = unittest.TextTestRunner(verbosity=2).run(suite)
+    if args.require_capabilities and result.skipped:
+        print(
+            f"required capabilities unavailable: {len(result.skipped)} "
+            "selected test(s) skipped",
+            file=sys.stderr,
+        )
+        return 1
+    return 0 if result.wasSuccessful() else 1
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 409 - 0
tests/proving-it-works-with-a-movie/test_assembly.py

@@ -0,0 +1,409 @@
+import json
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+import fixtures
+from PIL import Image
+
+
+def run_ffmpeg(args: list[str], *, cwd: Path) -> subprocess.CompletedProcess[bytes]:
+    ffmpeg = shutil.which("ffmpeg")
+    if ffmpeg is None:
+        raise RuntimeError("ffmpeg is required for assembly fixtures")
+    result = subprocess.run(
+        [ffmpeg, "-nostdin", "-y", "-v", "error", *args],
+        cwd=cwd,
+        capture_output=True,
+        timeout=fixtures.TIMEOUT_SECONDS,
+    )
+    if result.returncode != 0:
+        raise RuntimeError(
+            f"ffmpeg fixture generation failed: {fixtures.output_text(result)}"
+        )
+    return result
+
+
+def make_tone(path: Path, duration: float, frequency: int, *, cwd: Path) -> None:
+    run_ffmpeg(
+        [
+            "-f",
+            "lavfi",
+            "-i",
+            f"sine=frequency={frequency}:sample_rate=8000:duration={duration}",
+            "-c:a",
+            "pcm_s16le",
+            str(path),
+        ],
+        cwd=cwd,
+    )
+
+
+def make_movie(path: Path, duration: float, frequency: int, *, cwd: Path) -> None:
+    run_ffmpeg(
+        [
+            "-f",
+            "lavfi",
+            "-i",
+            f"color=c=green:size=160x90:rate=10:duration={duration}",
+            "-f",
+            "lavfi",
+            "-i",
+            f"sine=frequency={frequency}:sample_rate=8000:duration={duration}",
+            "-c:v",
+            "libx264",
+            "-pix_fmt",
+            "yuv420p",
+            "-c:a",
+            "aac",
+            "-shortest",
+            str(path),
+        ],
+        cwd=cwd,
+    )
+
+
+def decoded_pixel(path: Path, timestamp: float, *, cwd: Path) -> tuple[int, int, int]:
+    result = run_ffmpeg(
+        [
+            "-ss",
+            str(timestamp),
+            "-i",
+            str(path),
+            "-frames:v",
+            "1",
+            "-vf",
+            "scale=1:1",
+            "-f",
+            "rawvideo",
+            "-pix_fmt",
+            "rgb24",
+            "-",
+        ],
+        cwd=cwd,
+    )
+    if len(result.stdout) < 3:
+        raise AssertionError(f"no decoded pixel from {path}")
+    return tuple(result.stdout[:3])
+
+
+def decoded_frequency(path: Path, *, cwd: Path) -> float:
+    sample_rate = 8000
+    result = run_ffmpeg(
+        [
+            "-ss",
+            "0.1",
+            "-t",
+            "0.5",
+            "-i",
+            str(path),
+            "-map",
+            "0:a:0",
+            "-f",
+            "s16le",
+            "-acodec",
+            "pcm_s16le",
+            "-ac",
+            "1",
+            "-ar",
+            str(sample_rate),
+            "-",
+        ],
+        cwd=cwd,
+    )
+    samples = [
+        int.from_bytes(result.stdout[index:index + 2], "little", signed=True)
+        for index in range(0, len(result.stdout) - 1, 2)
+    ]
+    nonzero = [sample for sample in samples if sample]
+    crossings = sum(
+        (left < 0 <= right) or (left > 0 >= right)
+        for left, right in zip(nonzero, nonzero[1:])
+    )
+    seconds = len(samples) / sample_rate
+    return crossings / (2 * seconds)
+
+
+def available_browser() -> str | None:
+    return fixtures.load_script("browser_tools").find_browser(os.environ.get("MOVIE_BROWSER"))
+
+
+class AssemblyRegression(unittest.TestCase):
+    def test_narration_padding_and_offsets(self):
+        missing = fixtures.missing_executables("uv", "ffmpeg", "ffprobe")
+        if missing:
+            self.skipTest(f"required executable(s) not on PATH: {', '.join(missing)}")
+
+        with tempfile.TemporaryDirectory() as directory:
+            work = Path(directory)
+            scenes = fixtures.assembly_fixture(work)
+            result = fixtures.run_tool(
+                "assemble", [str(scenes), str(work / "out.mp4")], cwd=work
+            )
+            self.assertEqual(result.returncode, 0, fixtures.output_text(result))
+            self.assertAlmostEqual(
+                fixtures.duration(work / "out.mp4"), 8, delta=0.4
+            )
+            offsets = json.loads(
+                (work / "segments/offsets.json").read_text(encoding="utf-8")
+            )
+            self.assertAlmostEqual(offsets["body"], 2, delta=0.3)
+
+            subtitles = fixtures.run_tool(
+                "make-subtitles",
+                [
+                    str(work / "narration/manifest.json"),
+                    str(work / "out.srt"),
+                    "--offsets-json",
+                    str(work / "segments/offsets.json"),
+                ],
+                cwd=work,
+            )
+            self.assertEqual(
+                subtitles.returncode, 0, fixtures.output_text(subtitles)
+            )
+            srt = (work / "out.srt").read_text(encoding="utf-8")
+            timing_line = next(line for line in srt.splitlines() if "-->" in line)
+            start = timing_line.partition("-->")[0].strip()
+            hours, minutes, seconds_millis = start.split(":")
+            seconds, millis = seconds_millis.split(",")
+            first_cue_start = (
+                int(hours) * 3600
+                + int(minutes) * 60
+                + int(seconds)
+                + int(millis) / 1000
+            )
+            self.assertAlmostEqual(
+                first_cue_start,
+                offsets["body"],
+                delta=0.001,
+            )
+
+    def test_image_frames_and_movie_paths_timing_order_and_cleanup(self):
+        missing = fixtures.missing_executables("uv", "ffmpeg", "ffprobe")
+        if missing:
+            self.skipTest(f"required executable(s) not on PATH: {', '.join(missing)}")
+
+        with tempfile.TemporaryDirectory() as directory:
+            root = Path(directory)
+            launch = root / "launch"
+            launch.mkdir()
+            project = root / "movie O'Brien λ & [take]"
+            assets = project / "assets"
+            frames = assets / "frames"
+            subtitles = assets / "subtitles" / "nested"
+            narration = project / "narration"
+            frames.mkdir(parents=True)
+            subtitles.mkdir(parents=True)
+            narration.mkdir()
+
+            still = assets / "still image.png"
+            Image.new("RGB", (160, 90), (230, 230, 230)).save(still)
+            Image.new("RGB", (160, 90), (255, 0, 0)).save(frames / "a.png")
+            Image.new("RGB", (160, 90), (0, 0, 255)).save(frames / "b.png")
+            source_pngs = {
+                path: path.read_bytes() for path in [still, *sorted(frames.glob("*.png"))]
+            }
+
+            source_movie = assets / "source movie.mp4"
+            make_movie(source_movie, 0.8, 440, cwd=launch)
+            make_tone(narration / "image.wav", 0.8, 660, cwd=launch)
+            make_tone(narration / "frames.wav", 1.4, 770, cwd=launch)
+            make_tone(narration / "movie.wav", 1.6, 880, cwd=launch)
+            (narration / "manifest.json").write_text(json.dumps([
+                {"id": "image", "text": "Image narration", "wav": "image.wav", "duration": 0.8},
+                {"id": "frames", "text": "Frames narration", "wav": "frames.wav", "duration": 1.4},
+            ]), encoding="utf-8")
+
+            nested_srt = subtitles / "captions.srt"
+            nested_srt.write_bytes(
+                b"1\r\n00:00:00,000 --> 00:00:00,500\r\nPortable paths\r\n"
+            )
+            scenes = project / "scenes.yaml"
+            yaml_text = f"""resolution: {{ width: 160, height: 90 }}
+fps: 10
+scenes:
+  - id: image
+    kind: image
+    src: assets/still image.png
+    duration: 0.3
+    narration: Image narration
+  - id: frames
+    kind: frames
+    src: {frames.resolve().as_posix()}
+    rate: 2.0
+    narration: Frames narration
+  - id: movie
+    kind: movie
+    src: assets/source movie.mp4
+"""
+            scenes.write_bytes(yaml_text.replace("\n", "\r\n").encode("utf-8"))
+
+            work = root / "generated outside launch"
+            output = project / "assembled output.mp4"
+            result = fixtures.run_tool(
+                "assemble",
+                [
+                    os.path.relpath(scenes, launch),
+                    os.path.relpath(output, launch),
+                    "--narration",
+                    str(narration.resolve()),
+                    "--work",
+                    str(work.resolve()),
+                ],
+                cwd=launch,
+            )
+            self.assertEqual(result.returncode, 0, fixtures.output_text(result))
+
+            image_duration = fixtures.duration(work / "image.mp4")
+            frames_duration = fixtures.duration(work / "frames.mp4")
+            movie_duration = fixtures.duration(work / "movie.mp4")
+            self.assertAlmostEqual(
+                image_duration,
+                max(fixtures.duration(narration / "image.wav"), 0.3),
+                delta=0.25,
+            )
+            self.assertAlmostEqual(
+                frames_duration,
+                max(fixtures.duration(narration / "frames.wav"), 1.0),
+                delta=0.25,
+            )
+            self.assertAlmostEqual(
+                movie_duration,
+                fixtures.duration(source_movie),
+                delta=0.25,
+            )
+            self.assertLess(movie_duration, fixtures.duration(narration / "movie.wav") - 0.4)
+            self.assertAlmostEqual(
+                decoded_frequency(work / "movie.mp4", cwd=launch),
+                440,
+                delta=15,
+            )
+
+            offsets = json.loads(
+                (work / "offsets.json").read_text(encoding="utf-8")
+            )
+            self.assertAlmostEqual(offsets["image"], 0.0, delta=0.001)
+            self.assertAlmostEqual(offsets["frames"], image_duration, delta=0.001)
+            self.assertNotIn("movie", offsets)
+
+            first = decoded_pixel(work / "frames.mp4", 0.2, cwd=launch)
+            second = decoded_pixel(work / "frames.mp4", 0.7, cwd=launch)
+            self.assertGreater(first[0], first[2] + 100)
+            self.assertGreater(second[2], second[0] + 100)
+            self.assertEqual(
+                {path: path.read_bytes() for path in source_pngs},
+                source_pngs,
+            )
+            self.assertEqual(list(work.glob("frames-frames-*")), [])
+
+            subtitled = project / "subtitled output.mp4"
+            subtitle_result = fixtures.run_tool(
+                "burn-subtitles",
+                [
+                    str(output.resolve()),
+                    os.path.relpath(nested_srt, launch),
+                    str(subtitled.resolve()),
+                    "--soft",
+                ],
+                cwd=launch,
+            )
+            self.assertEqual(
+                subtitle_result.returncode,
+                0,
+                fixtures.output_text(subtitle_result),
+            )
+            self.assertTrue(subtitled.is_file())
+
+    def test_card_uses_longer_narration_duration(self):
+        missing = fixtures.missing_executables("uv", "ffmpeg", "ffprobe")
+        if missing:
+            self.skipTest(f"required executable(s) not on PATH: {', '.join(missing)}")
+        browser = available_browser()
+        if browser is None:
+            self.skipTest("required browser unavailable for card assembly")
+
+        with tempfile.TemporaryDirectory() as directory:
+            root = Path(directory)
+            narration = root / "narration"
+            narration.mkdir()
+            make_tone(narration / "card.wav", 0.8, 550, cwd=root)
+            (narration / "manifest.json").write_text(json.dumps([
+                {"id": "card", "text": "Card narration", "wav": "card.wav", "duration": 0.8}
+            ]), encoding="utf-8")
+            scenes = root / "scenes.yaml"
+            scenes.write_bytes(
+                b"\xef\xbb\xbfresolution: { width: 160, height: 90 }\r\n"
+                b"fps: 10\r\n"
+                b"scenes:\r\n"
+                b"  - id: card\r\n"
+                b"    kind: card\r\n"
+                b"    title: Portable\r\n"
+                b"    subtitle: paths\r\n"
+                b"    duration: 0.3\r\n"
+                b"    narration: Card narration\r\n"
+            )
+            work = root / "work"
+            result = fixtures.run_tool(
+                "assemble",
+                [
+                    str(scenes),
+                    str(root / "out.mp4"),
+                    "--work",
+                    str(work),
+                    "--browser",
+                    browser,
+                ],
+                cwd=root,
+            )
+            self.assertEqual(result.returncode, 0, fixtures.output_text(result))
+            self.assertAlmostEqual(
+                fixtures.duration(work / "card.mp4"),
+                max(fixtures.duration(narration / "card.wav"), 0.3),
+                delta=0.25,
+            )
+            offsets = json.loads(
+                (work / "offsets.json").read_text(encoding="utf-8")
+            )
+            self.assertAlmostEqual(offsets["card"], 0.0, delta=0.001)
+
+    def test_frame_sources_survive_failed_assembly_cleanup(self):
+        missing = fixtures.missing_executables("uv", "ffmpeg", "ffprobe")
+        if missing:
+            self.skipTest(f"required executable(s) not on PATH: {', '.join(missing)}")
+
+        with tempfile.TemporaryDirectory() as directory:
+            root = Path(directory)
+            source = root / "movie O'Brien λ & [take]" / "frames"
+            source.mkdir(parents=True)
+            bad_frame = source / "a.png"
+            bad_frame.write_bytes(b"not a PNG")
+            scenes = root / "scenes.yaml"
+            scenes.write_text(
+                f"""resolution: {{ width: 160, height: 90 }}
+fps: 10
+scenes:
+  - id: broken
+    kind: frames
+    src: {source.resolve().as_posix()}
+    rate: 1.0
+""",
+                encoding="utf-8",
+            )
+            work = root / "work"
+            result = fixtures.run_tool(
+                "assemble",
+                [str(scenes), str(root / "out.mp4"), "--work", str(work)],
+                cwd=root,
+            )
+            self.assertNotEqual(result.returncode, 0)
+            self.assertEqual(bad_frame.read_bytes(), b"not a PNG")
+            self.assertEqual(list(work.glob("frames-broken-*")), [])
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 73 - 0
tests/proving-it-works-with-a-movie/test_browser.py

@@ -0,0 +1,73 @@
+import os
+import subprocess
+import sys
+import tempfile
+import time
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+from PIL import Image
+import fixtures
+
+
+class BrowserToolsRegression(unittest.TestCase):
+    def test_explicit_unusable_browser_fails_authoritatively(self):
+        module = fixtures.load_script("browser_tools")
+        with self.assertRaises(FileNotFoundError):
+            module.find_browser("this-browser-does-not-exist")
+        if os.name != "nt":
+            with tempfile.NamedTemporaryFile() as file:
+                with self.assertRaises(FileNotFoundError):
+                    module.find_browser(file.name)
+
+    def test_windows_chrome_edge_discovery(self):
+        module = fixtures.load_script("browser_tools")
+        with tempfile.TemporaryDirectory() as directory:
+            root = Path(directory)
+            for relative in ("Google/Chrome/Application/chrome.exe", "Microsoft/Edge/Application/msedge.exe"):
+                browser = root / relative
+                browser.parent.mkdir(parents=True)
+                browser.touch()
+                with patch.object(module.sys, "platform", "win32"), patch.dict(os.environ, {"LOCALAPPDATA": str(root)}, clear=True), patch.object(module.shutil, "which", return_value=None):
+                    self.assertEqual(module.find_browser(None), str(browser.resolve()))
+                browser.unlink()
+            with patch.object(module.sys, "platform", "win32"), patch.dict(os.environ, {}, clear=True), patch.object(module.shutil, "which", side_effect=lambda name: "C:/Edge/msedge.exe" if name == "msedge.exe" else None):
+                self.assertEqual(module.find_browser(None), "C:/Edge/msedge.exe")
+
+    def browser(self, module):
+        browser = module.find_browser(os.environ.get("MOVIE_BROWSER"))
+        if not browser:
+            self.skipTest("Chrome/Edge is required")
+        return browser
+
+    def test_render_card_special_path_is_a_real_png(self):
+        module = fixtures.load_script("browser_tools")
+        browser = self.browser(module)
+        with tempfile.TemporaryDirectory() as directory:
+            work = Path(directory) / "special path O'Brien λ # %"
+            work.mkdir()
+            html, png = work / "card page.html", work / "card.png"
+            html.write_text("<meta charset='utf-8'><style>body{margin:0;background:rgb(255,0,0)}</style><p>λ</p>", encoding="utf-8")
+            module.render_card(html, png, browser=browser, width=640, height=360)
+            with Image.open(png) as image:
+                self.assertEqual(image.size, (640, 360))
+                self.assertEqual(image.convert("RGB").getpixel((500, 200)), (255, 0, 0))
+
+    def test_render_card_timeout_preserves_unrelated_process(self):
+        module = fixtures.load_script("browser_tools")
+        browser = self.browser(module)
+        sentinel = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"])
+        try:
+            with tempfile.TemporaryDirectory() as directory:
+                work = Path(directory)
+                html = work / "card.html"
+                html.write_text("<p>timeout</p>", encoding="utf-8")
+                start = time.monotonic()
+                with self.assertRaises(TimeoutError):
+                    module.render_card(html, work / "card.png", browser=browser, width=640, height=360, timeout=0)
+                self.assertLess(time.monotonic() - start, 10)
+                self.assertIsNone(sentinel.poll())
+        finally:
+            sentinel.terminate()
+            sentinel.wait(timeout=10)

+ 112 - 0
tests/proving-it-works-with-a-movie/test_browser_contract.py

@@ -0,0 +1,112 @@
+"""Browser cleanup decisions with fake processes and a completed-output token."""
+import contextlib
+import os
+import subprocess
+import tempfile
+import unittest
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import patch
+
+import fixtures
+
+
+class CompletedOutput:
+    """Stand in for the completed-output observation without creating an image."""
+    def startswith(self, prefix):
+        return True
+
+    def endswith(self, suffix):
+        return True
+
+
+@contextlib.contextmanager
+def rendering(*, exited=False, taskkill_status=0, wait_timeout=False, locked_profile=False):
+    module = fixtures.load_script("browser_tools")
+    with tempfile.TemporaryDirectory() as temp, contextlib.ExitStack() as stack:
+        html, png = Path(temp).resolve() / "card.html", Path(temp).resolve() / "card.png"
+        html.write_text("<p>card</p>")
+        process = SimpleNamespace(pid=1100, returncode=0 if exited else None)
+        process.poll = lambda: process.returncode
+        calls, profiles = [], []
+        original_is_file, original_unlink = Path.is_file, os.unlink
+
+        def popen(argv, **kwargs):
+            profiles.append(Path(kwargs["cwd"]))
+            return process
+
+        def taskkill(argv, **kwargs):
+            calls.append(argv)
+            if taskkill_status == 0 and not wait_timeout:
+                process.returncode = -9
+            return subprocess.CompletedProcess(argv, taskkill_status, b"", b"termination failed")
+
+        def wait(timeout):
+            if process.returncode is None:
+                raise subprocess.TimeoutExpired("fake browser", timeout)
+            return process.returncode
+
+        def unlink(path, *args, **kwargs):
+            if locked_profile and Path(path).name == "browser.log":
+                raise PermissionError("locked browser profile")
+            return original_unlink(path, *args, **kwargs)
+
+        process.wait = wait
+        for obj, name, value in (
+            (module.sys, "platform", "win32"),
+            (module.subprocess, "Popen", popen),
+            (module.subprocess, "run", taskkill),
+            (Path, "is_file", lambda path: True if path == png else original_is_file(path)),
+            (Path, "read_bytes", lambda path: CompletedOutput()),
+            (os, "unlink", unlink),
+        ):
+            stack.enter_context(patch.object(obj, name, value))
+        try:
+            yield SimpleNamespace(render=lambda: module.render_card(html, png, browser="fake-browser", width=640, height=360),
+                                  process=process, calls=calls, profiles=profiles)
+        finally:
+            stack.close()
+            for profile in profiles:
+                if profile.exists():
+                    module.shutil.rmtree(profile)
+
+
+class BrowserCleanupContract(unittest.TestCase):
+    def test_windows_tree_termination_failure_reaches_caller_as_oserror(self):
+        module = fixtures.load_script("browser_tools")
+        with patch.object(module.sys, "platform", "win32"), \
+             patch.object(module.subprocess, "run", return_value=subprocess.CompletedProcess([], 1, b"", b"access denied")):
+            with self.assertRaises(OSError):
+                module.kill_process_tree(1100)
+
+    def test_completed_output_does_not_hide_tree_termination_failure(self):
+        with rendering(taskkill_status=1) as rig:
+            with self.assertRaises(OSError):
+                rig.render()
+
+    def test_completed_output_does_not_hide_owned_child_wait_timeout(self):
+        with rendering(wait_timeout=True) as rig:
+            with self.assertRaises(subprocess.TimeoutExpired):
+                rig.render()
+
+    def test_completed_output_does_not_hide_locked_profile(self):
+        with rendering(locked_profile=True) as rig:
+            with self.assertRaises(PermissionError):
+                rig.render()
+
+    def test_normally_exited_completed_card_succeeds_without_numeric_pid_cleanup(self):
+        with rendering(exited=True, taskkill_status=1) as rig:
+            self.assertIsNone(rig.render())
+            self.assertEqual(rig.calls, [])
+            self.assertFalse(rig.profiles[0].exists())
+
+    def test_completed_card_releases_live_browser_and_profile(self):
+        with rendering() as rig:
+            self.assertIsNone(rig.render())
+            self.assertEqual(rig.process.poll(), -9)
+            self.assertEqual(len(rig.calls), 1)
+            self.assertFalse(rig.profiles[0].exists())
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 194 - 0
tests/proving-it-works-with-a-movie/test_checker.py

@@ -0,0 +1,194 @@
+import io
+import json
+import subprocess
+import sys
+import tempfile
+from contextlib import redirect_stdout
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+from PIL import Image
+
+import fixtures
+
+
+class CheckerPolicyRegression(unittest.TestCase):
+    def check(self, expected_exit, *options, audio=True, levels=None,
+              embedded=None, sidecar=None, extraction_exit=0):
+        module = fixtures.load_script("check-movie")
+        with tempfile.TemporaryDirectory() as directory:
+            root = Path(directory)
+            movie = root / "movie.mp4"
+            movie.write_bytes(b"metadata fixture only")
+            if sidecar is not None:
+                movie.with_suffix(".srt").write_text(sidecar, encoding="utf-8-sig")
+            streams = [{"index": 0, "codec_type": "video", "codec_name": "h264",
+                        "width": 640, "height": 360}]
+            if audio:
+                streams.append({"index": 1, "codec_type": "audio", "codec_name": "aac"})
+            if embedded is not None:
+                streams.append({"index": 2, "codec_type": "subtitle", "codec_name": "mov_text"})
+            levels = levels if levels is not None else [-20.0] * 20
+            info = {"format": {"duration": str(len(levels))}, "streams": streams}
+
+            def media_command(cmd, **kwargs):
+                if cmd[0] == "ffprobe":
+                    return subprocess.CompletedProcess(cmd, 0, json.dumps(info), "")
+                if cmd[0] == "ffmpeg" and embedded is not None:
+                    self.assertEqual(cmd[cmd.index("-i") + 1], str(movie))
+                    self.assertEqual(cmd[cmd.index("-map") + 1], "0:s:0")
+                    self.assertEqual(cmd[cmd.index("-f") + 1], "srt")
+                    return subprocess.CompletedProcess(cmd, extraction_exit, embedded,
+                                                       "subtitle decode failed" if extraction_exit else "")
+                raise AssertionError(f"unexpected media command: {cmd}")
+
+            output = root / "report"
+            argv = ["check-movie", str(movie), "--out", str(output), "--json", *options]
+            stdout = io.StringIO()
+            with patch.object(sys, "argv", argv), \
+                 patch.object(module.shutil, "which", return_value="test-tool"), \
+                 patch.object(module.subprocess, "run", side_effect=media_command), \
+                 patch.object(module, "sample_picture", return_value=([], [0.1] * (len(levels) - 1))), \
+                 patch.object(module, "sample_sound", return_value=levels if audio else []), \
+                 patch.object(module, "contact_sheet", return_value=[]), \
+                 redirect_stdout(stdout):
+                try:
+                    code = module.main()
+                except SystemExit as error:
+                    code = error.code
+            self.assertEqual(code, expected_exit, stdout.getvalue())
+            report = output / "check.json"
+            failures = json.loads(report.read_text(encoding="utf-8"))["failures"] if report.exists() else []
+            return failures, stdout.getvalue()
+
+    def test_silent_encoded_track_passes_when_audio_is_not_expected(self):
+        failures, _ = self.check(0, "--no-expect-audio", levels=[-120.0] * 20)
+        self.assertEqual(failures, [])
+
+    def test_absent_audio_passes_when_audio_is_not_expected(self):
+        failures, _ = self.check(0, "--no-expect-audio", audio=False)
+        self.assertEqual(failures, [])
+
+    def test_silent_encoded_track_fails_when_audio_is_expected(self):
+        failures, _ = self.check(1, levels=[-120.0] * 20)
+        self.assertTrue(any("silent" in failure for failure in failures))
+
+    def test_audio_opt_out_still_requires_captions_for_audible_speech(self):
+        failures, _ = self.check(1, "--no-expect-audio")
+        self.assertTrue(any("no subtitles" in failure for failure in failures))
+
+    def test_subtitle_opt_out_allows_audible_speech_without_captions(self):
+        failures, _ = self.check(0, "--no-expect-audio", "--no-expect-subtitles")
+        self.assertEqual(failures, [])
+
+    def test_empty_embedded_track_fails_even_for_short_narration(self):
+        for seconds in (2, 20):
+            with self.subTest(seconds=seconds):
+                failures, _ = self.check(1, embedded="", levels=[-20.0] * seconds)
+                self.assertTrue(any("subtitle" in failure for failure in failures))
+
+    def test_sidecar_and_embedded_cues_must_reach_the_end_of_speech(self):
+        for source in ("sidecar", "embedded"):
+            for end, expected_exit in (("06,000", 1), ("10,000", 0)):
+                with self.subTest(source=source, end=end):
+                    subtitles = f"1\n00:00:00,000 --> 00:00:{end}\nUnicode λ caption\n"
+                    failures, _ = self.check(expected_exit, levels=[-20.0] * 10 + [-120.0] * 10,
+                                             **{source: subtitles})
+                    self.assertEqual(bool(failures), bool(expected_exit))
+
+    def test_embedded_extraction_failure_is_not_accepted(self):
+        _, diagnostics = self.check(2, embedded="", extraction_exit=1)
+        self.assertIn("subtitle", diagnostics)
+
+    def test_malformed_embedded_cue_is_a_reported_failure(self):
+        _, diagnostics = self.check(2, embedded="1\n00:00:00,000 --> invalid\ncaption\n")
+        self.assertIn("subtitle", diagnostics)
+
+
+class CheckerRegression(unittest.TestCase):
+    @classmethod
+    def setUpClass(cls) -> None:
+        missing = fixtures.missing_executables("uv", "ffmpeg", "ffprobe")
+        if missing:
+            raise unittest.SkipTest(
+                f"required executable(s) not on PATH: {', '.join(missing)}"
+            )
+        cls._temporary_directory = tempfile.TemporaryDirectory()
+        cls.addClassCleanup(cls._temporary_directory.cleanup)
+        cls.work = Path(cls._temporary_directory.name)
+        cls.movies = fixtures.checker_fixture(cls.work)
+
+    def check(
+        self, expected_exit: int, needle: str, movie: Path, *extra_args: str
+    ) -> Path:
+        output_directory = self.work / f"{movie.stem}-check"
+        result = fixtures.run_tool(
+            "check-movie",
+            [str(movie), "--out", str(output_directory), *extra_args],
+            cwd=self.work,
+        )
+        output = fixtures.output_text(result)
+        self.assertEqual(result.returncode, expected_exit, output)
+        self.assertIn(needle.casefold(), output.casefold())
+        return output_directory
+
+    def test_front_loaded_action_is_rejected(self):
+        self.check(
+            1,
+            "every visible change happens in the first",
+            self.movies["front-loaded"],
+        )
+
+    def test_paced_with_subtitles_is_accepted(self):
+        self.check(0, "Mechanical checks pass", self.movies["paced"])
+
+    def test_narrated_without_subtitles_is_rejected(self):
+        self.check(
+            1,
+            "no subtitles",
+            self.movies["paced"],
+            "--subs",
+            str(self.work / "nope.srt"),
+        )
+
+    def test_subtitles_that_stop_early_are_rejected(self):
+        self.check(1, "subtitles stop at", self.movies["short"])
+
+    def test_subtitle_check_is_opt_outable(self):
+        self.check(
+            0,
+            "Mechanical checks pass",
+            self.movies["paced"],
+            "--subs",
+            str(self.work / "nope.srt"),
+            "--no-expect-subtitles",
+        )
+
+    def test_still_with_audio_is_rejected(self):
+        self.check(1, "never reaches a new state", self.movies["still"])
+
+    def test_missing_narration_is_rejected(self):
+        self.check(1, "no audio stream", self.movies["silent"])
+
+    def test_silent_movie_passes_when_unnarrated(self):
+        self.check(
+            0,
+            "Mechanical checks pass",
+            self.movies["silent"],
+            "--no-expect-audio",
+        )
+
+    def test_contact_sheet_is_always_written(self):
+        output_directory = self.check(
+            0, "contact-sheet.png", self.movies["paced"]
+        )
+        sheet = output_directory / "contact-sheet.png"
+        self.assertTrue(sheet.is_file(), f"missing contact sheet: {sheet}")
+        with Image.open(sheet) as image:
+            image.load()
+            self.assertEqual(image.format, "PNG")
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 351 - 0
tests/proving-it-works-with-a-movie/test_narration.py

@@ -0,0 +1,351 @@
+import io
+import json
+import sys
+import tempfile
+from contextlib import redirect_stderr, redirect_stdout
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+import fixtures
+
+
+SCRIPT = (
+    "This is smevals studio. Every eval on the shelf is a folder of tasks and "
+    "graders."
+)
+
+
+class NarrationDriftRegression(unittest.TestCase):
+    def drift(self, expected_exit: int, heard: str, script: str = SCRIPT) -> None:
+        missing = fixtures.missing_executables("uv")
+        if missing:
+            self.skipTest(
+                f"required executable(s) not on PATH: {', '.join(missing)}"
+            )
+        with tempfile.TemporaryDirectory() as directory:
+            work = Path(directory)
+            script_path = work / "script.txt"
+            heard_path = work / "heard.txt"
+            script_path.write_text(script, encoding="utf-8")
+            heard_path.write_text(heard, encoding="utf-8")
+            result = fixtures.run_tool(
+                "narrate",
+                ["--drift-check", str(script_path), str(heard_path)],
+                cwd=work,
+            )
+            self.assertEqual(
+                result.returncode, expected_exit, fixtures.output_text(result)
+            )
+
+    def test_mispronounced_jargon_passes(self):
+        self.drift(
+            0,
+            "This is Mevil studio. Every Yvel on the shelf is a folder of tasks "
+            "and graders.",
+        )
+
+    def test_exact_transcript_passes(self):
+        self.drift(0, SCRIPT)
+
+    def test_dropped_clause_fails(self):
+        self.drift(1, "This is smevals studio.")
+
+    def test_invented_preamble_fails(self):
+        self.drift(
+            1,
+            "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.",
+        )
+
+    def test_empty_clip_fails(self):
+        self.drift(1, "you")
+
+    def test_inserted_runs_fail_even_when_total_length_is_close(self):
+        words = [f"word{i}" for i in range(50)]
+        for position in (0, 25, 50):
+            with self.subTest(position=position):
+                heard = words[:position] + "Before we begin please listen".split() + words[position:]
+                self.drift(1, " ".join(heard), " ".join(words))
+
+    def test_expanded_replacement_counts_the_added_words(self):
+        words = [f"word{i}" for i in range(50)]
+        heard = words[:25] + "Before we begin please listen".split() + words[26:]
+        self.drift(1, " ".join(heard), " ".join(words))
+
+    def test_short_insertions_keep_the_existing_tolerance(self):
+        words = [f"word{i}" for i in range(50)]
+        self.drift(0, "Please listen closely " + " ".join(words), " ".join(words))
+
+    def test_cached_audio_requires_requested_verification(self):
+        import json
+        import sys
+        module = fixtures.load_script("narrate")
+        with tempfile.TemporaryDirectory() as tmp:
+            work = Path(tmp)
+            output = work / "voice"
+            output.mkdir()
+            (output / "clip.wav").write_bytes(b"cached audio fixture")
+            (output / "manifest.json").write_text(json.dumps([
+                {"id": "clip", "text": "Read this sentence.", "wav": "clip.wav",
+                 "duration": 1.0, "synthesis": {"engine": "piper",
+                 "voice": module.PIPER_VOICE, "model": module.PIPER_VOICE}}
+            ]), encoding="utf-8")
+            scenes = work / "scenes.yaml"
+            scenes.write_text(json.dumps({"scenes": [
+                {"id": "clip", "narration": "Read this sentence."}
+            ]}), encoding="utf-8")
+            argv = ["narrate", str(scenes), str(output),
+                    "--engine", "piper", "--verify", "on"]
+            stdout, stderr = io.StringIO(), io.StringIO()
+            with patch.object(sys, "argv", argv), \
+                 patch.object(module.shutil, "which", return_value="ffprobe"), \
+                 patch.object(module, "openai_key", return_value=None), \
+                 patch.object(module, "duration", return_value=1.0), \
+                 patch.object(module, "say_piper", side_effect=AssertionError("expected cached clip")), \
+                 patch.object(module, "transcribe_local", return_value=None), \
+                 redirect_stdout(stdout), redirect_stderr(stderr):
+                self.assertNotEqual(module.main(), 0)
+            self.assertIn("clip: required verification unavailable", stderr.getvalue())
+            self.assertIn("FAILED verbatim delivery: ['clip']", stderr.getvalue())
+            self.assertEqual(
+                json.loads((output / "manifest.json").read_text(encoding="utf-8")),
+                [],
+            )
+            self.assertEqual((output / "clip.wav").read_bytes(), b"cached audio fixture")
+
+    def test_rejected_chat_audio_is_never_cached_but_accepted_audio_is(self):
+        import json
+        import sys
+        module = fixtures.load_script("narrate")
+        with tempfile.TemporaryDirectory() as directory:
+            root = Path(directory)
+            scenes = root / "scenes.yaml"
+            scenes.write_text(json.dumps({"scenes": [
+                {"id": "accepted", "narration": "Read this sentence exactly."},
+                {"id": "rejected", "narration": "Keep this evidence out of the manifest."},
+            ]}), encoding="utf-8")
+            output = root / "voice"
+            calls = []
+
+            def synthesize(key, text, wav, voice):
+                calls.append(text)
+                wav.write_bytes(f"render {len(calls)}".encode())
+                if text.startswith("Keep"):
+                    return "Unrelated invented preamble with entirely different words here."
+                return text
+
+            argv = ["narrate", str(scenes), str(output), "--engine", "openai-chat",
+                    "--verify", "off"]
+            rejected_renders = []
+            with patch.object(sys, "argv", argv), \
+                 patch.object(module.shutil, "which", return_value="ffprobe"), \
+                 patch.object(module, "openai_key", return_value="test-key"), \
+                 patch.object(module, "say_openai_chat", side_effect=synthesize), \
+                 patch.object(module, "duration", return_value=1.0):
+                for _ in range(2):
+                    with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
+                        self.assertEqual(module.main(), 1)
+                    manifest = json.loads(
+                        (output / "manifest.json").read_text(encoding="utf-8")
+                    )
+                    self.assertEqual([entry["id"] for entry in manifest], ["accepted"])
+                    rejected_renders.append(sorted(
+                        path.read_bytes() for path in output.glob(".rejected.attempt-*.wav")
+                    ))
+
+            self.assertEqual(calls.count("Read this sentence exactly."), 1)
+            self.assertEqual(calls.count("Keep this evidence out of the manifest."), 4)
+            self.assertLess(len(rejected_renders[0]), len(rejected_renders[1]))
+
+class TranscriptionProtocolRegression(unittest.TestCase):
+    def test_owned_json_is_used_instead_of_library_stdout(self):
+        import json
+        import subprocess
+        import sys
+        import io
+        module = fixtures.load_script("narrate")
+        def child(argv, **kwargs):
+            self.assertIn("--isolated", argv)
+            self.assertIn("--no-project", argv)
+            self.assertIn("--no-config", argv)
+            self.assertEqual(argv[argv.index("--python") + 1], sys.executable)
+            self.assertNotEqual(Path(kwargs["cwd"]), Path.cwd())
+            Path(argv[-1]).write_text(json.dumps({"text": "Correct λ transcript"}), encoding="utf-8")
+            return subprocess.CompletedProcess(argv, 0, "native library warning", "diagnostic")
+        diagnostics = io.StringIO()
+        with patch.object(module.subprocess, "run", side_effect=child), redirect_stderr(diagnostics):
+            self.assertEqual(module.transcribe_local(Path("clip.wav")), "Correct λ transcript")
+        self.assertIn("native library warning", diagnostics.getvalue())
+        self.assertIn("diagnostic", diagnostics.getvalue())
+
+    def test_failed_absent_and_malformed_child_results_are_unavailable(self):
+        import subprocess
+        module = fixtures.load_script("narrate")
+        for payload, code in ((None, 0), ("garbage", 0), ('{"text": 7}', 0), ('{"text": ""}', 0), ('{"text": "words"}', 1)):
+            with self.subTest(payload=payload, code=code):
+                def child(argv, **kwargs):
+                    if payload is not None and "--isolated" in argv:
+                        Path(argv[-1]).write_text(payload, encoding="utf-8")
+                    return subprocess.CompletedProcess(argv, code, "misleading stdout", "error")
+                diagnostics = io.StringIO()
+                with patch.object(module.subprocess, "run", side_effect=child), \
+                     redirect_stderr(diagnostics):
+                    self.assertEqual(
+                        module.transcribe_local(Path("clip.wav")),
+                        "" if payload == '{"text": ""}' and code == 0 else None,
+                    )
+                self.assertIn("local ASR", diagnostics.getvalue())
+
+    def test_fresh_and_off_then_on_clips_require_asr(self):
+        import json
+        import sys
+        module = fixtures.load_script("narrate")
+        for cached in (False, True):
+            with self.subTest(cached=cached), tempfile.TemporaryDirectory() as directory:
+                root = Path(directory)
+                scenes = root / "scenes.yaml"
+                scenes.write_text(json.dumps({"scenes": [{"id": "clip", "narration": "Read this sentence."}]}), encoding="utf-8-sig")
+                output = root / "voice"
+                def synthesize(text, wav, voice):
+                    wav.write_bytes(b"branch policy fixture")
+                argv = ["narrate", str(scenes), str(output), "--engine", "piper", "--verify"]
+                with patch.object(module.shutil, "which", return_value="ffprobe"), patch.object(module, "openai_key", return_value=None), patch.object(module, "say_piper", side_effect=synthesize), patch.object(module, "duration", return_value=1.0), patch.object(module, "transcribe_local", return_value=None):
+                    if cached:
+                        with patch.object(sys, "argv", [*argv, "off"]), \
+                             redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
+                            self.assertEqual(module.main(), 0)
+                    stdout, stderr = io.StringIO(), io.StringIO()
+                    with patch.object(sys, "argv", [*argv, "on"]), \
+                         redirect_stdout(stdout), redirect_stderr(stderr):
+                        self.assertNotEqual(module.main(), 0)
+                    self.assertIn("clip: required verification unavailable", stderr.getvalue())
+
+
+class NarrationCacheRegression(unittest.TestCase):
+    def setUp(self):
+        self.module = fixtures.load_script("narrate")
+        directory = tempfile.TemporaryDirectory()
+        self.addCleanup(directory.cleanup)
+        self.root = Path(directory.name)
+        self.scenes = self.root / "scenes.yaml"
+        self.text = "Read this sentence exactly."
+        self.write_scenes(self.text)
+        self.output = self.root / "voice"
+        self.renders = []
+
+        which = patch.object(self.module.shutil, "which", return_value="ffprobe")
+        which.start()
+        self.addCleanup(which.stop)
+
+        def synthesize(*args):
+            text, wav, voice = args[-3:]
+            self.renders.append((text, voice))
+            wav.write_bytes(f"render {len(self.renders)}".encode())
+            return text
+
+        for name, options in (
+            ("openai_key", {"return_value": "test-key"}),
+            ("duration", {"return_value": 1.0}),
+            ("transcribe_local", {"return_value": None}),
+            ("say_piper", {"side_effect": synthesize}),
+            ("say_openai", {"side_effect": synthesize}),
+            ("say_openai_chat", {"side_effect": synthesize}),
+        ):
+            mocked = patch.object(self.module, name, **options)
+            mocked.start()
+            self.addCleanup(mocked.stop)
+
+    def write_scenes(self, text):
+        self.scenes.write_text(json.dumps({"scenes": [
+            {"id": "clip", "narration": text}
+        ]}), encoding="utf-8")
+
+    def narrate(self, *options, verify="off", expected_exit=0):
+        argv = ["narrate", str(self.scenes), str(self.output),
+                "--verify", verify, *options]
+        stdout, stderr = io.StringIO(), io.StringIO()
+        with patch.object(sys, "argv", argv), \
+             redirect_stdout(stdout), redirect_stderr(stderr):
+            self.assertEqual(self.module.main(), expected_exit, stderr.getvalue())
+        return json.loads((self.output / "manifest.json").read_text(encoding="utf-8"))
+
+    def test_engine_and_voice_changes_rerender(self):
+        for index, options in enumerate((
+            ("--engine", "piper", "--voice", "voice-a"),
+            ("--engine", "piper", "--voice", "voice-b"),
+            ("--engine", "openai", "--voice", "voice-b"),
+            ("--engine", "openai-chat", "--voice", "voice-b"),
+        ), 1):
+            with self.subTest(options=options):
+                self.narrate(*options)
+                self.assertEqual(len(self.renders), index)
+                self.assertEqual((self.output / "clip.wav").read_bytes(),
+                                 f"render {index}".encode())
+
+    def test_cloud_model_changes_rerender(self):
+        for engine, constant in (("openai", "OPENAI_TTS_MODEL"),
+                                 ("openai-chat", "OPENAI_CHAT_MODEL")):
+            with self.subTest(engine=engine):
+                self.narrate("--engine", engine)
+                count = len(self.renders)
+                with patch.object(self.module, constant, "another-model"):
+                    self.narrate("--engine", engine)
+                self.assertEqual(len(self.renders), count + 1)
+
+    def test_implicit_and_explicit_defaults_share_cache(self):
+        for engine, voice in (("piper", self.module.PIPER_VOICE),
+                              ("openai", "nova"), ("openai-chat", "nova")):
+            with self.subTest(engine=engine):
+                self.narrate("--engine", engine)
+                count = len(self.renders)
+                self.narrate("--engine", engine, "--voice", voice)
+                self.assertEqual(len(self.renders), count)
+        self.narrate("--engine", "openai")
+        count = len(self.renders)
+        self.narrate()
+        self.assertEqual(len(self.renders), count)
+        with patch.object(self.module, "openai_key", return_value=None):
+            self.narrate()
+        self.assertEqual(len(self.renders), count + 1)
+
+    def test_cache_without_synthesis_settings_rerenders(self):
+        manifest = self.narrate()
+        manifest[0].pop("synthesis", None)
+        (self.output / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
+        self.narrate()
+        self.assertEqual(len(self.renders), 2)
+
+    def test_changed_text_force_and_missing_wav_rerender(self):
+        self.narrate()
+        self.write_scenes("Read a different sentence exactly.")
+        manifest = self.narrate()
+        self.assertEqual(manifest[0]["text"], "Read a different sentence exactly.")
+        self.assertEqual(len(self.renders), 2)
+        self.narrate("--force")
+        self.assertEqual(len(self.renders), 3)
+        (self.output / "clip.wav").unlink()
+        self.narrate()
+        self.assertEqual(len(self.renders), 4)
+
+    def test_cached_clip_is_reverified_with_requested_asr_model(self):
+        self.narrate()
+        with patch.object(self.module, "transcribe_local", return_value=self.text) as asr:
+            self.narrate("--asr-model", "small.en", verify="on")
+        asr.assert_called_once_with(self.output / "clip.wav", "small.en")
+        self.assertEqual(len(self.renders), 1)
+
+    def test_unavailable_asr_respects_each_verification_mode(self):
+        for engine in ("piper", "openai", "openai-chat"):
+            for mode in ("auto", "on", "off"):
+                with self.subTest(engine=engine, mode=mode):
+                    self.module.transcribe_local.reset_mock()
+                    manifest = self.narrate("--engine", engine, verify=mode,
+                                            expected_exit=1 if mode == "on" else 0)
+                    self.assertEqual(bool(manifest), mode != "on")
+                    self.assertEqual(self.module.transcribe_local.call_count,
+                                     int(mode == "on" or (mode == "auto" and engine != "openai")))
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 546 - 0
tests/proving-it-works-with-a-movie/test_narration_contract.py

@@ -0,0 +1,546 @@
+import base64
+import io
+import json
+import subprocess
+import sys
+import tempfile
+import unittest
+from contextlib import redirect_stderr, redirect_stdout
+from pathlib import Path
+from unittest.mock import patch
+
+import fixtures
+
+
+class ChatResponseContract(unittest.TestCase):
+    def test_malformed_transcripts_cannot_publish_candidates_in_any_asr_mode(self):
+        cases = ({}, {"transcript": None}, {"transcript": 42},
+                 {"transcript": ["Two", "words"]}, {"transcript": {}},
+                 {"transcript": False}, {"transcript": ""},
+                 {"transcript": "   "}, {"transcript": "...!?"})
+        for response in cases:
+            for mode in ("off", "auto", "on"):
+                with self.subTest(response=response, mode=mode), tempfile.TemporaryDirectory() as temp:
+                    module = fixtures.load_script("narrate")
+                    root = Path(temp)
+                    scenes, output = root / "scenes.json", root / "narration"
+                    scenes.write_text(json.dumps({"scenes": [{"id": "clip", "narration": "Two words"}]}))
+                    sentinels = []
+
+                    def post(*args, **kwargs):
+                        sentinel = f"NOT MEDIA: response {len(sentinels) + 1}".encode()
+                        sentinels.append(sentinel)
+                        audio = dict(response, data=base64.b64encode(sentinel).decode())
+                        return {"choices": [{"message": {"audio": audio}}]}
+
+                    argv = ["narrate", str(scenes), str(output), "--engine", "openai-chat", "--verify", mode]
+                    with patch.object(sys, "argv", argv), \
+                         patch.object(module.shutil, "which", return_value="fake-ffprobe"), \
+                         patch.object(module, "openai_key", return_value="fake-key"), \
+                         patch.object(module, "post", post), \
+                         patch.object(module, "duration", return_value=1.25), \
+                         patch.object(module, "transcribe_local", side_effect=AssertionError("invalid transcript reached ASR")), \
+                         redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
+                        try:
+                            result = module.main()
+                        except Exception as error:
+                            result = error
+                    self.assertEqual(result, 1)
+                    self.assertEqual(json.loads((output / "manifest.json").read_text()), [])
+                    self.assertEqual(len(sentinels), 2)
+                    self.assertEqual({path.read_bytes() for path in output.glob(".clip.attempt-*.wav")}, set(sentinels))
+                    self.assertFalse((output / "clip.wav").exists())
+
+    def test_valid_chat_and_cached_reuse_obey_independent_asr_modes(self):
+        for mode in ("off", "auto", "on"):
+            for heard in (None, "Two words"):
+                with self.subTest(mode=mode, heard=heard), tempfile.TemporaryDirectory() as temp:
+                    module = fixtures.load_script("narrate")
+                    root = Path(temp)
+                    scenes, output = root / "scenes.json", root / "narration"
+                    scenes.write_text(json.dumps({"scenes": [{"id": "clip", "narration": "Two words"}]}))
+                    sentinel = b"NOT MEDIA: accepted response"
+                    response = {"choices": [{"message": {"audio": {
+                        "data": base64.b64encode(sentinel).decode(), "transcript": "Two words",
+                    }}}]}
+                    argv = ["narrate", str(scenes), str(output), "--engine", "openai-chat", "--verify", "off"]
+                    with patch.object(sys, "argv", argv), \
+                         patch.object(module.shutil, "which", return_value="fake-ffprobe"), \
+                         patch.object(module, "openai_key", return_value="fake-key"), \
+                         patch.object(module, "post", return_value=response) as post, \
+                         patch.object(module, "duration", return_value=1.25), \
+                         patch.object(module, "transcribe_local", return_value=heard) as asr, \
+                         redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
+                        self.assertEqual(module.main(), 0)
+                        argv[-1] = mode
+                        expected = 1 if mode == "on" and heard is None else 0
+                        self.assertEqual(module.main(), expected)
+                    self.assertEqual(post.call_count, 1, "accepted cache must not synthesize again")
+                    self.assertEqual(asr.call_count, int(mode != "off"))
+                    self.assertEqual(bool(json.loads((output / "manifest.json").read_text())), expected == 0)
+                    self.assertEqual((output / "clip.wav").read_bytes(), sentinel)
+
+
+class NarrationPublicationContract(unittest.TestCase):
+    def setUp(self):
+        self.module = fixtures.load_script("narrate")
+        self.directory = tempfile.TemporaryDirectory()
+        self.addCleanup(self.directory.cleanup)
+        self.root = Path(self.directory.name)
+        self.scenes = self.root / "scenes.yaml"
+        self.output = self.root / "narration"
+
+    def run_narrate(self, scenes, synthesize, *, verify="off", expected=0, engine="piper",
+                    extra_options=(), transcript=None, asr_calls=None):
+        self.scenes.write_text(json.dumps({"scenes": scenes}), encoding="utf-8")
+        argv = ["narrate", str(self.scenes), str(self.output), "--engine", engine,
+                "--verify", verify, *extra_options]
+        stderr = io.StringIO()
+        def transcribe(wav, model):
+            if asr_calls is not None:
+                asr_calls.append((wav, model))
+            return transcript
+        with patch.object(sys, "argv", argv), \
+             patch.object(self.module.shutil, "which", return_value="ffprobe"), \
+             patch.object(self.module, "openai_key", return_value="key" if engine.startswith("openai") else None), \
+             patch.object(self.module, "say_piper", side_effect=synthesize), \
+             patch.object(self.module, "say_openai_chat",
+                          side_effect=lambda key, text, wav, voice: synthesize(text, wav, voice)), \
+             patch.object(self.module, "duration", return_value=1.25), \
+             patch.object(self.module, "transcribe_local", side_effect=transcribe), \
+             redirect_stdout(io.StringIO()), redirect_stderr(stderr):
+            self.assertEqual(self.module.main(), expected, stderr.getvalue())
+        manifest = self.output / "manifest.json"
+        return json.loads(manifest.read_text(encoding="utf-8")) if manifest.exists() else []
+
+    def test_acceptance_is_withdrawn_before_replacing_accepted_bytes(self):
+        old_wav = self.output / "first.wav"
+        self.output.mkdir()
+        old_wav.write_bytes(b"accepted bytes")
+        synthesis = {"engine": "piper", "voice": self.module.PIPER_VOICE,
+                     "model": self.module.PIPER_VOICE}
+        (self.output / "manifest.json").write_text(json.dumps([{
+            "id": "first", "text": "Old words", "wav": "first.wav",
+            "duration": 1.0, "synthesis": synthesis,
+        }]), encoding="utf-8")
+
+        def synthesize(text, wav, voice):
+            published = json.loads((self.output / "manifest.json").read_text())
+            self.assertNotIn("first", [entry["id"] for entry in published])
+            wav.write_bytes(b"replacement bytes")
+
+        manifest = self.run_narrate(
+            [{"id": "first", "narration": "New words"}], synthesize
+        )
+
+        self.assertEqual(manifest[0]["text"], "New words")
+        self.assertEqual(old_wav.read_bytes(), b"replacement bytes")
+
+    def test_rejected_and_exceptional_takes_are_not_accepted_on_a_rerun(self):
+        attempts = []
+
+        def synthesize(text, wav, voice):
+            attempts.append((text, wav))
+            wav.write_bytes(f"{text}:{len(attempts)}".encode())
+            if text == "Reject this":
+                return "invented words that do not match this script at all"
+            if text == "Raise here":
+                raise RuntimeError("synthesis interrupted")
+
+        scenes = [{"id": "reject", "narration": "Reject this"},
+                  {"id": "raise", "narration": "Raise here"}]
+        def accepted(text, wav, voice):
+            wav.write_bytes(b"accepted")
+            return text
+
+        self.run_narrate(scenes, accepted, engine="openai-chat")
+        first = self.run_narrate(scenes, synthesize, engine="openai-chat", expected=1, extra_options=("--force",))
+        attempts_after_rejection = len(attempts)
+        second = self.run_narrate(scenes, synthesize, engine="openai-chat", expected=1)
+
+        self.assertEqual(first, [])
+        self.assertEqual(second, [])
+        self.assertGreater(len(attempts), attempts_after_rejection)
+        rejected_paths = [path for text, path in attempts if text == "Reject this"]
+        self.assertEqual(len({path.name for path in rejected_paths}), len(rejected_paths))
+        self.assertTrue(all(path.exists() for path in rejected_paths))
+        self.assertEqual(len({path.read_bytes() for path in rejected_paths}), len(rejected_paths))
+
+    def test_duration_failure_leaves_only_prior_accepted_scenes_published(self):
+        scenes = [{"id": "accepted", "narration": "Accepted words"},
+                  {"id": "broken", "narration": "Broken words"}]
+
+        def synthesize(text, wav, voice):
+            wav.write_bytes(text.encode())
+
+        self.scenes.write_text(json.dumps({"scenes": scenes}), encoding="utf-8")
+        argv = ["narrate", str(self.scenes), str(self.output), "--engine", "piper",
+                "--verify", "off"]
+        durations = iter((1.0, RuntimeError("ffprobe failed")))
+        with patch.object(sys, "argv", argv), \
+             patch.object(self.module.shutil, "which", return_value="ffprobe"), \
+             patch.object(self.module, "openai_key", return_value=None), \
+             patch.object(self.module, "say_piper", side_effect=synthesize), \
+             patch.object(self.module, "duration", side_effect=durations), \
+             redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
+            self.assertEqual(self.module.main(), 1)
+
+        manifest = json.loads((self.output / "manifest.json").read_text())
+        self.assertEqual([entry["id"] for entry in manifest], ["accepted"])
+
+    def test_duration_failures_keep_distinct_attempt_bytes_across_reruns(self):
+        scenes = [{"id": "clip", "narration": "Measure this clip"}]
+
+        def synthesize(text, wav, voice):
+            wav.write_bytes(f"attempt {len(list(self.output.glob('.clip.attempt-*.wav'))) + 1}".encode())
+
+        for expected_attempts in (1, 2):
+            self.scenes.write_text(json.dumps({"scenes": scenes}), encoding="utf-8")
+            argv = ["narrate", str(self.scenes), str(self.output), "--engine", "piper", "--verify", "off"]
+            with patch.object(sys, "argv", argv), \
+                 patch.object(self.module.shutil, "which", return_value="ffprobe"), \
+                 patch.object(self.module, "openai_key", return_value=None), \
+                 patch.object(self.module, "say_piper", side_effect=synthesize), \
+                 patch.object(self.module, "duration", side_effect=RuntimeError("ffprobe failed")), \
+                 redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
+                self.assertEqual(self.module.main(), 1)
+            attempts = sorted(self.output.glob(".clip.attempt-*.wav"))
+            self.assertEqual(len(attempts), expected_attempts)
+            self.assertEqual(len({path.read_bytes() for path in attempts}), expected_attempts)
+            self.assertFalse((self.output / "clip.wav").exists())
+
+    def test_two_accepted_scenes_are_published_together(self):
+        def synthesize(text, wav, voice):
+            wav.write_bytes(text.encode())
+
+        manifest = self.run_narrate([
+            {"id": "first", "narration": "First accepted scene"},
+            {"id": "second", "narration": "Second accepted scene"},
+        ], synthesize)
+        self.assertEqual([entry["id"] for entry in manifest], ["first", "second"])
+
+    def test_strict_unavailable_verification_withdraws_cached_acceptance(self):
+        def synthesize(text, wav, voice):
+            wav.write_bytes(b"accepted")
+
+        self.run_narrate([{"id": "clip", "narration": "Read these words"}], synthesize)
+        manifest = self.run_narrate(
+            [{"id": "clip", "narration": "Read these words"}], synthesize,
+            verify="on", expected=1,
+        )
+        self.assertEqual(manifest, [])
+
+    def test_unsupported_asr_comparison_obeys_auto_on_and_off_modes(self):
+        def synthesize(text, wav, voice):
+            wav.write_bytes(b"clip")
+
+        scenes = [{"id": "clip", "narration": "\u77ed\u6587"}]
+        for mode, expected in (("auto", 0), ("on", 1), ("off", 0)):
+            with self.subTest(mode=mode):
+                asr_calls = []
+                manifest = self.run_narrate(scenes, synthesize, verify=mode,
+                                             expected=expected, transcript="\u77ed\u6587",
+                                             asr_calls=asr_calls)
+                self.assertEqual(bool(manifest), expected == 0)
+                self.assertEqual(bool(asr_calls), mode != "off")
+
+    def test_missing_ffprobe_stops_before_synthesis(self):
+        self.scenes.write_text(json.dumps({"scenes": [{"id": "clip", "narration": "Words"}]}),
+                              encoding="utf-8")
+        with patch.object(sys, "argv", ["narrate", str(self.scenes), str(self.output)]), \
+             patch.object(self.module.shutil, "which", return_value=None), \
+             patch.object(self.module, "say_piper", side_effect=AssertionError("synthesized")), \
+             redirect_stderr(io.StringIO()):
+            with self.assertRaises(SystemExit):
+                self.module.main()
+
+    def test_fresh_unsupported_chat_transcript_is_rejected_before_synthesis(self):
+        called = []
+
+        def synthesize(*args):
+            called.append(args)
+
+        manifest = self.run_narrate(
+            [{"id": "clip", "narration": "\U00020000\U00020001"}], synthesize,
+            engine="openai-chat", expected=1,
+        )
+        self.assertEqual(manifest, [])
+        self.assertEqual(called, [])
+
+    def test_cached_unsupported_chat_transcript_withdraws_acceptance_without_asr(self):
+        self.output.mkdir()
+        (self.output / "clip.wav").write_bytes(b"cached bytes")
+        synthesis = {"engine": "openai-chat", "voice": "nova",
+                     "model": self.module.OPENAI_CHAT_MODEL}
+        (self.output / "manifest.json").write_text(json.dumps([{
+            "id": "clip", "text": "\u77ed\u6587", "wav": "clip.wav", "duration": 1.0,
+            "synthesis": synthesis,
+        }]), encoding="utf-8")
+        self.scenes.write_text(json.dumps({"scenes": [
+            {"id": "clip", "narration": "\u77ed\u6587"}
+        ]}), encoding="utf-8")
+        for mode in ("off", "auto"):
+            with self.subTest(mode=mode):
+                (self.output / "manifest.json").write_text(json.dumps([{
+                    "id": "clip", "text": "\u77ed\u6587", "wav": "clip.wav", "duration": 1.0,
+                    "synthesis": synthesis,
+                }]), encoding="utf-8")
+                argv = ["narrate", str(self.scenes), str(self.output), "--engine", "openai-chat",
+                        "--verify", mode]
+                with patch.object(sys, "argv", argv), \
+                     patch.object(self.module.shutil, "which", return_value="ffprobe"), \
+                     patch.object(self.module, "openai_key", return_value="key"), \
+                     patch.object(self.module, "say_openai_chat", side_effect=AssertionError("cached")), \
+                     patch.object(self.module, "transcribe_local", side_effect=AssertionError("ASR")), \
+                     redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
+                    self.assertEqual(self.module.main(), 1)
+                self.assertEqual(json.loads((self.output / "manifest.json").read_text()), [])
+
+    def test_empty_chat_or_asr_speech_is_rejected(self):
+        def empty_chat_synthesis(text, wav, voice):
+            wav.write_bytes(b"audio")
+            return ""
+
+        empty_chat = self.run_narrate(
+            [{"id": "clip", "narration": "Two words"}],
+            empty_chat_synthesis, engine="openai-chat", expected=1,
+        )
+        self.assertEqual(empty_chat, [])
+
+        def synthesize(text, wav, voice):
+            wav.write_bytes(b"audio")
+
+        self.scenes.write_text(json.dumps({"scenes": [{"id": "clip", "narration": "Two words"}]}),
+                              encoding="utf-8")
+        argv = ["narrate", str(self.scenes), str(self.output), "--engine", "piper", "--verify", "auto"]
+        with patch.object(sys, "argv", argv), \
+             patch.object(self.module.shutil, "which", return_value="ffprobe"), \
+             patch.object(self.module, "openai_key", return_value=None), \
+             patch.object(self.module, "say_piper", side_effect=synthesize), \
+             patch.object(self.module, "transcribe_local", return_value=""), \
+             patch.object(self.module, "duration", return_value=1.0), \
+             redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
+            self.assertEqual(self.module.main(), 1)
+        self.assertEqual(json.loads((self.output / "manifest.json").read_text()), [])
+
+    def test_cached_nested_wav_keeps_its_manifest_path_after_reverification(self):
+        nested = self.output / "takes" / "clip.wav"
+        nested.parent.mkdir(parents=True)
+        nested.write_bytes(b"accepted")
+        synthesis = {"engine": "piper", "voice": self.module.PIPER_VOICE,
+                     "model": self.module.PIPER_VOICE}
+        (self.output / "manifest.json").write_text(json.dumps([{
+            "id": "clip", "text": "Nested clip", "wav": "takes/clip.wav", "duration": 1.0,
+            "synthesis": synthesis,
+        }]), encoding="utf-8")
+        manifest = self.run_narrate(
+            [{"id": "clip", "narration": "Nested clip"}],
+            lambda *args: (_ for _ in ()).throw(AssertionError("cached")), verify="on",
+            transcript="Nested clip",
+        )
+        self.assertEqual(manifest[0]["wav"], "takes/clip.wav")
+
+    def test_cached_strict_verification_withdraws_before_interrupt(self):
+        def synthesize(text, wav, voice):
+            wav.write_bytes(b"accepted")
+
+        self.run_narrate([{"id": "clip", "narration": "Interrupt safely"}], synthesize)
+        self.scenes.write_text(json.dumps({"scenes": [{"id": "clip", "narration": "Interrupt safely"}]}),
+                              encoding="utf-8")
+        argv = ["narrate", str(self.scenes), str(self.output), "--engine", "piper", "--verify", "on"]
+        with patch.object(sys, "argv", argv), \
+             patch.object(self.module.shutil, "which", return_value="ffprobe"), \
+             patch.object(self.module, "openai_key", return_value=None), \
+             patch.object(self.module, "say_piper", side_effect=AssertionError("cached")), \
+             patch.object(self.module, "transcribe_local", side_effect=KeyboardInterrupt), \
+             redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
+            with self.assertRaises(KeyboardInterrupt):
+                self.module.main()
+        self.assertEqual(json.loads((self.output / "manifest.json").read_text()), [])
+
+
+class NarrationComparisonContract(unittest.TestCase):
+    def setUp(self):
+        self.module = fixtures.load_script("narrate")
+
+    def test_comparison_marks_scripts_requiring_segmentation_or_no_words_unavailable(self):
+        for script, heard in (
+            ("你好世界", "こんにちは世界"),
+            ("短文", "短文"),
+            ("mixed 日本語 words", "mixed 日本語 words"),
+            ("\U00020000\U00020001", "\U00020002\U00020003"),
+            ("*** !!!", "*** !!!"),
+        ):
+            with self.subTest(script=script):
+                self.assertIsNone(self.module.structural_drift(script, heard))
+
+    def test_comparison_accepts_multiline_accented_latin_and_spaced_cyrillic(self):
+        for script, heard in (
+            ("Caf\u00e9\nna\u00efve", "CAF\u00c9 na\u00efve"),
+            ("\u041f\u0440\u0438\u0432\u0435\u0442 \u043c\u0438\u0440", "\u043f\u0440\u0438\u0432\u0435\u0442 \u043c\u0438\u0440"),
+            ("\uc548\ub155 \uc138\uacc4", "\uc548\ub155 \uc138\uacc4"),
+        ):
+            with self.subTest(script=script):
+                self.assertEqual(self.module.structural_drift(script, heard), (0.0, 0))
+
+    def test_drift_check_reports_unavailable_comparison_as_nonzero(self):
+        with tempfile.TemporaryDirectory() as directory:
+            root = Path(directory)
+            script = root / "script.txt"
+            heard = root / "heard.txt"
+            script.write_text("\u77ed\u6587", encoding="utf-8")
+            heard.write_text("\u77ed\u6587", encoding="utf-8")
+            output = io.StringIO()
+            with patch.object(sys, "argv", ["narrate", "--drift-check", str(script), str(heard)]), \
+                 redirect_stdout(output):
+                self.assertEqual(self.module.main(), 1)
+            self.assertIn("comparison unavailable", output.getvalue())
+
+
+class AssemblyNarrationContract(unittest.TestCase):
+    def setUp(self):
+        self.module = fixtures.load_script("assemble")
+        self.directory = tempfile.TemporaryDirectory()
+        self.addCleanup(self.directory.cleanup)
+        self.root = Path(self.directory.name)
+        self.scenes = self.root / "scenes.yaml"
+        self.narration = self.root / "narration"
+        self.narration.mkdir()
+
+    def assemble(self, scenes, *, manifest=None, run_side_effect=None):
+        self.scenes.write_text(json.dumps({"scenes": scenes}), encoding="utf-8")
+        if manifest is not None:
+            (self.narration / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
+        calls = []
+
+        def run(command, **kwargs):
+            calls.append(command)
+            if run_side_effect is not None:
+                return run_side_effect(command)
+            return subprocess.CompletedProcess(command, 0, "1.0", "")
+
+        argv = ["assemble", str(self.scenes), str(self.root / "out.mp4"),
+                "--narration", str(self.narration), "--work", str(self.root / "work")]
+        with patch.object(sys, "argv", argv), \
+             patch.object(self.module.shutil, "which", return_value="tool"), \
+             patch.object(self.module, "find_browser", return_value=None), \
+             patch.object(self.module, "run", side_effect=run), \
+             redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
+            return self.module.main(), calls
+
+    def test_required_narration_contract_fails_before_encoding(self):
+        with self.assertRaises(SystemExit):
+            self.assemble([{"id": "spoken", "kind": "image", "src": "still.png",
+                            "narration": "Expected words"}],
+                          run_side_effect=lambda command: (_ for _ in ()).throw(
+                              AssertionError("encoding started")
+                          ))
+
+    def test_missing_entry_wav_or_matching_text_fails_before_encoding(self):
+        (self.root / "still.png").write_bytes(b"image sentinel")
+        cases = (
+            ([], "missing entry"),
+            ([{"id": "spoken", "text": "Expected words", "wav": "missing.wav"}], "missing WAV"),
+            ([{"id": "spoken", "text": "Changed words", "wav": "accepted.wav"}], "changed text"),
+        )
+        for manifest, label in cases:
+            with self.subTest(label=label):
+                (self.narration / "manifest.json").unlink(missing_ok=True)
+                with self.assertRaises(SystemExit):
+                    self.assemble([{"id": "spoken", "kind": "image", "src": "still.png",
+                                    "narration": "Expected words"}], manifest=manifest,
+                                  run_side_effect=lambda command: (_ for _ in ()).throw(
+                                      AssertionError("encoding started")
+                                  ))
+
+    def test_manifest_wav_name_is_authoritative_and_removed_narration_ignores_leftover_wav(self):
+        selected = self.narration / "accepted-name.wav"
+        selected.write_bytes(b"accepted")
+        leftover = self.narration / "silent.wav"
+        leftover.write_bytes(b"leftover")
+        (self.root / "still.png").write_bytes(b"image sentinel")
+        manifest = [{"id": "spoken", "text": "Expected words",
+                     "wav": selected.name, "duration": 1.0, "synthesis": {}}]
+        status, calls = self.assemble([
+            {"id": "spoken", "kind": "image", "src": "still.png", "narration": "Expected  words"},
+            {"id": "silent", "kind": "image", "src": "still.png"},
+        ], manifest=manifest)
+        self.assertEqual(status, 0)
+        encoded = [call for call in calls if call and call[0] == "ffmpeg"]
+        self.assertTrue(any(str(selected) in call for call in encoded))
+        self.assertFalse(any(str(leftover) in call for call in encoded))
+        offsets = json.loads((self.root / "work" / "offsets.json").read_text())
+        self.assertEqual(set(offsets), {"spoken"})
+
+    def test_2560x1080_movie_uses_source_audio_and_silent_source_gets_anullsrc(self):
+        source = self.root / "wide-2560x1080.mp4"
+        source.write_bytes(b"source")
+        status, calls = self.assemble(
+            [{"id": "movie", "kind": "movie", "src": source.name,
+              "narration": "Ignore this", "height": 800}],
+            run_side_effect=lambda command: subprocess.CompletedProcess(
+                command, 0,
+                json.dumps({"streams": [{"codec_type": "video", "width": 2560,
+                                           "height": 1080}]}), ""
+            ) if "-show_streams" in command else subprocess.CompletedProcess(command, 0, "1.0", ""),
+        )
+        self.assertEqual(status, 0)
+        movie_encode = next(call for call in calls if call and call[0] == "ffmpeg")
+        self.assertIn("anullsrc=r=44100:cl=stereo", movie_encode)
+        self.assertIn("0:v:0", movie_encode)
+        self.assertIn("1:a:0", movie_encode)
+        self.assertIn("scale=1920:800:force_original_aspect_ratio=decrease",
+                      movie_encode[movie_encode.index("-vf") + 1])
+        offsets = json.loads((self.root / "work" / "offsets.json").read_text())
+        self.assertEqual(offsets, {})
+
+    def test_movie_with_audio_maps_its_source_audio(self):
+        source = self.root / "source-with-audio.mp4"
+        source.write_bytes(b"source")
+        status, calls = self.assemble(
+            [{"id": "movie", "kind": "movie", "src": source.name}],
+            run_side_effect=lambda command: subprocess.CompletedProcess(
+                command, 0,
+                json.dumps({"streams": [{"codec_type": "video"}, {"codec_type": "audio"}]}), ""
+            ) if "-show_streams" in command else subprocess.CompletedProcess(command, 0, "1.0", ""),
+        )
+        self.assertEqual(status, 0)
+        movie_encode = next(call for call in calls if call and call[0] == "ffmpeg")
+        self.assertNotIn("anullsrc=r=44100:cl=stereo", movie_encode)
+        self.assertEqual(movie_encode[movie_encode.index("-map") + 1], "0:v:0")
+        self.assertEqual(movie_encode[movie_encode.index("-map", movie_encode.index("-map") + 1) + 1], "0:a:0")
+
+    def test_movie_geometry_fits_width_and_requested_inner_height_before_padding(self):
+        self.assertEqual(self.module.movie_geometry(1920, 1080, 800), {
+            "scale": (1920, 800),
+            "pad": (1920, 1080),
+        })
+
+
+class PercentPathContract(unittest.TestCase):
+    def test_sequence_pattern_escapes_only_directory_percents(self):
+        module = fixtures.load_script("media_paths")
+        pattern = module.sequence_pattern(Path("folder%name") / "frames", "frame-%08d.png")
+        self.assertEqual(pattern, "folder%%name/frames/frame-%08d.png")
+
+    def test_checker_sampling_escapes_only_output_directory_percents(self):
+        module = fixtures.load_script("check-movie")
+        with tempfile.TemporaryDirectory() as directory:
+            work = Path(directory) / "proof%take"
+            work.mkdir()
+            command = []
+
+            def run(argv, **kwargs):
+                command.extend(argv)
+                return subprocess.CompletedProcess(argv, 0, "", "")
+
+            with patch.object(module.subprocess, "run", side_effect=run), redirect_stdout(io.StringIO()):
+                with self.assertRaises(SystemExit):
+                    module.sample_picture(Path("movie.mp4"), work)
+            output = command[-1]
+            self.assertIn("proof%%take", output)
+            self.assertTrue(output.endswith("s%05d.png"))
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 104 - 0
tests/proving-it-works-with-a-movie/test_paths.py

@@ -0,0 +1,104 @@
+import shutil
+import subprocess
+import sys
+import tempfile
+import unittest
+import wave
+from pathlib import Path
+
+
+SCRIPTS = (
+    Path(__file__).resolve().parents[2]
+    / "skills/proving-it-works-with-a-movie/scripts"
+)
+sys.path.insert(0, str(SCRIPTS))
+
+import media_paths
+
+
+class MediaPathRegression(unittest.TestCase):
+    def test_frame_staging_uses_ordinary_ordered_files(self):
+        with tempfile.TemporaryDirectory() as directory:
+            root = Path(directory)
+            source = root / "movie O'Brien λ & [take]"
+            source.mkdir()
+            (source / "b.png").write_bytes(b"second")
+            (source / "a.png").write_bytes(b"first")
+
+            staged = media_paths.stage_frames(source, root / "staged")
+
+            self.assertEqual(
+                [path.read_bytes() for path in staged],
+                [b"first", b"second"],
+            )
+            self.assertTrue(all(not path.is_symlink() for path in staged))
+            self.assertEqual(len(list(source.iterdir())), 2)
+
+    def test_frame_staging_rejects_an_empty_source(self):
+        with tempfile.TemporaryDirectory() as directory:
+            root = Path(directory)
+            source = root / "empty"
+            source.mkdir()
+
+            with self.assertRaisesRegex(ValueError, "no PNG frames"):
+                media_paths.stage_frames(source, root / "staged")
+
+            self.assertFalse((root / "staged").exists())
+
+    def test_ffconcat_entry_is_accepted_by_ffmpeg(self):
+        ffmpeg = shutil.which("ffmpeg")
+        if ffmpeg is None:
+            self.skipTest("required executable not on PATH: ffmpeg")
+
+        with tempfile.TemporaryDirectory() as directory:
+            root = Path(directory) / "movie O'Brien λ & [take]"
+            nested = root / "subtitles" / "nested"
+            nested.mkdir(parents=True)
+            audio = nested / "tone.wav"
+            with wave.open(str(audio), "wb") as output:
+                output.setnchannels(1)
+                output.setsampwidth(2)
+                output.setframerate(8000)
+                output.writeframes(b"\x00\x00" * 800)
+
+            if sys.platform == "win32":
+                self.assertRegex(str(audio.resolve()), r"^[A-Za-z]:\\")
+
+            listing = root / "concat.txt"
+            listing.write_text(
+                "ffconcat version 1.0\n" + media_paths.ffconcat_entry(audio),
+                encoding="utf-8",
+            )
+            result = subprocess.run(
+                [
+                    ffmpeg,
+                    "-nostdin",
+                    "-v",
+                    "error",
+                    "-f",
+                    "concat",
+                    "-safe",
+                    "0",
+                    "-i",
+                    str(listing),
+                    "-f",
+                    "null",
+                    "-",
+                ],
+                cwd=Path(directory),
+                capture_output=True,
+                timeout=30,
+            )
+            self.assertEqual(
+                result.returncode,
+                0,
+                (result.stdout + result.stderr).decode("utf-8", errors="replace"),
+            )
+
+    def test_ffconcat_entry_rejects_line_breaks(self):
+        with self.assertRaisesRegex(ValueError, "cannot contain line breaks"):
+            media_paths.ffconcat_entry(Path("bad\nname.wav"))
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 538 - 0
tests/proving-it-works-with-a-movie/test_recorder_contract.py

@@ -0,0 +1,538 @@
+"""Recorder decisions with fake processes/CDP and byte-token captures only."""
+import contextlib
+import importlib.util
+import io
+import json
+import subprocess
+import tempfile
+import unittest
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import patch
+
+import fixtures
+
+SCRIPT = Path(__file__).resolve().parents[2] / "skills/proving-it-works-with-a-movie/examples/film-terminal.py"
+
+
+def recorder():
+    spec = importlib.util.spec_from_file_location("recorder_contract", SCRIPT)
+    module = importlib.util.module_from_spec(spec)
+    spec.loader.exec_module(module)
+    return module
+
+
+class Clock:
+    def __init__(self):
+        self.now = 0.0
+
+    def monotonic(self):
+        return self.now
+
+    def sleep(self, seconds):
+        self.now += seconds
+
+
+class Process:
+    def __init__(self, pid):
+        self.pid = pid
+        self.returncode = None
+
+    def poll(self):
+        return self.returncode
+
+    def wait(self, timeout):
+        if self.returncode is None:
+            raise subprocess.TimeoutExpired("fake child", timeout)
+        return self.returncode
+
+
+@contextlib.contextmanager
+def serving(failure=None, stop_at=None, relative=False):
+    module, clock = recorder(), Clock()
+    with tempfile.TemporaryDirectory() as temp, contextlib.ExitStack() as stack:
+        root = Path(temp)
+        directory = root / "session"
+        args = SimpleNamespace(session=directory, cwd=root, shell="bash", shell_exe=None,
+                               ttyd="fake-ttyd", browser="fake-browser")
+        if relative:
+            import os
+            args.session = Path(os.path.relpath(directory))
+        handles, children, launches, tokens = [], [], [], []
+        original_open, original_write = Path.open, module.write_json
+        stopped = False
+
+        def open_file(path, *positional, **kwargs):
+            if positional == ("ab",):
+                if failure == path.name:
+                    raise OSError("injected " + path.name)
+                handle = original_open(path, *positional, **kwargs)
+                handles.append(handle)
+                return handle
+            return original_open(path, *positional, **kwargs)
+
+        def popen(argv, **kwargs):
+            index = len(children)
+            if failure == ("ttyd launch" if index == 0 else "browser launch"):
+                raise OSError("injected launch")
+            child = Process(1100 + index)
+            children.append(child)
+            launches.append((argv, kwargs))
+            if index == 1:
+                (directory / "profile").mkdir()
+            return child
+
+        def kill(pid):
+            for child in children:
+                if child.pid == pid:
+                    if child.poll() is not None:
+                        raise AssertionError("cannot use an exited leader's PID")
+                    if failure != "child wait":
+                        child.returncode = -9
+
+        def write_json(path, value):
+            if failure == "session metadata" and path.name == "session.json":
+                raise OSError("injected metadata failure")
+            original_write(path, value)
+
+        def stop(phase):
+            nonlocal stopped
+            if stop_at == phase and not stopped:
+                (directory / "stop").write_text("")
+                stopped = True
+
+        class FakeCDP:
+            def __init__(self, url):
+                if failure == "connection":
+                    raise RuntimeError("injected connection failure")
+                self.n = 0
+                self.on_event = None
+                self.ws = SimpleNamespace(close=lambda: None)
+
+            def recv(self, timeout):
+                clock.sleep(timeout)
+                if (directory / "ready.json").exists():
+                    stop("ready")
+                    if failure == "exited leader":
+                        children[0].returncode = 0
+                    if failure == "later connection":
+                        raise ConnectionError("injected later disconnect")
+                else:
+                    stop("pump" if self.n == 0 else "quiet")
+                return None
+
+            def call(self, method, params=None, **kwargs):
+                if getattr(self, "before_call", None):
+                    self.before_call()
+                if stop_at == "call":
+                    stop("call")
+                    clock.sleep(10)
+                if method == "Input.dispatchKeyEvent" and params["type"] == "keyDown":
+                    self.n += 1
+                return {}
+
+        cdp = None
+
+        def connect(url):
+            nonlocal cdp
+            cdp = FakeCDP(url)
+            return cdp
+
+        def page_url(port):
+            stop("retry")
+            if stop_at == "retry":
+                raise OSError("not listening yet")
+            return "fake-url"
+
+        def tail(path):
+            if cdp and cdp.n:
+                return f"\x1b]0;MOVIE;{cdp.n};1;0;{root}\x07$ ".encode()
+            return b"$ "
+
+        for obj, name, value in (
+            (module, "shell_argv", lambda *a: ["fake-bash"]),
+            (module, "find_browser", lambda *a: "fake-browser"),
+            (module, "free_port", lambda: 1234),
+            (module.subprocess, "Popen", popen),
+            (module, "kill_process_tree", kill),
+            (module, "page_url", page_url),
+            (module, "CDP", connect),
+            (module, "write_json", write_json),
+            (module, "tail", tail),
+            (module, "screenshot", lambda *a: b"capture-token"),
+            (module, "lit_fraction", lambda *a: 0.01),
+            (module.time, "monotonic", clock.monotonic),
+            (module.time, "sleep", clock.sleep),
+            (Path, "open", open_file),
+            (Path, "write_bytes", lambda path, token: tokens.append((path.name, token))),
+        ):
+            stack.enter_context(patch.object(obj, name, value))
+        if failure == "profile removal":
+            stack.enter_context(patch.object(module.shutil, "rmtree", side_effect=PermissionError("locked")))
+        stack.enter_context(contextlib.redirect_stdout(io.StringIO()))
+        stack.enter_context(contextlib.redirect_stderr(io.StringIO()))
+        try:
+            yield SimpleNamespace(module=module, args=args, directory=directory, children=children,
+                                  handles=handles, launches=launches, clock=clock)
+        finally:
+            # Release the test's log handles even when ownership assertions fail.
+            for handle in handles:
+                handle.close()
+
+
+class ServeLifecycleTests(unittest.TestCase):
+    def test_tree_cleanup_failure_keeps_failure_after_leader_exits_and_cleans_other_resources(self):
+        with serving(stop_at="ready") as rig:
+            browser = fixtures.load_script("browser_tools")
+            terminated = []
+
+            def taskkill(argv, **kwargs):
+                pid = int(argv[-1])
+                terminated.append(pid)
+                child = next(child for child in rig.children if child.pid == pid)
+                child.returncode = 0 if pid == 1100 else -9
+                return subprocess.CompletedProcess(argv, 1 if pid == 1100 else 0, b"", b"tree termination failed")
+
+            with patch.object(rig.module, "kill_process_tree", browser.kill_process_tree), \
+                 patch.object(browser.sys, "platform", "win32"), \
+                 patch.object(browser.subprocess, "run", taskkill):
+                self.assertEqual(rig.module.serve(rig.args), 1)
+            session = rig.module.read_json(rig.directory / "session.json")
+            self.assertEqual(session["pids"], [1100, 1101])
+            self.assertFalse(session.get("closed", False))
+            self.assertEqual(terminated, [1100, 1101])
+            self.assertTrue(all(child.poll() is not None for child in rig.children))
+            self.assertTrue(all(handle.closed for handle in rig.handles))
+            self.assertFalse((rig.directory / "profile").exists())
+            self.assertFalse((rig.directory / "ready.json").exists())
+            self.assertTrue(all((rig.directory / name).exists()
+                                for name in ("ttyd.log", "browser.log", "terminal.log")))
+
+    def test_every_acquisition_failure_releases_owned_resources(self):
+        for failure in ("ttyd.log", "browser.log", "ttyd launch", "browser launch",
+                        "session metadata", "terminal.log", "connection", "later connection"):
+            with self.subTest(failure=failure), serving(failure) as rig:
+                try:
+                    code = rig.module.serve(rig.args)
+                except Exception as error:
+                    code = error
+                self.assertEqual(code, 1)
+                self.assertTrue(all(p.poll() is not None for p in rig.children))
+                self.assertTrue(all(h.closed for h in rig.handles))
+                self.assertFalse((rig.directory / "profile").exists())
+                self.assertFalse((rig.directory / "ready.json").exists())
+                for name in ("ttyd.log", "browser.log", "terminal.log"):
+                    if any(Path(h.name).name == name for h in rig.handles):
+                        self.assertTrue((rig.directory / name).exists(), "logs are evidence")
+
+    def test_relative_session_path_matches_browser_profile_and_cwd(self):
+        with serving("connection", relative=True) as rig:
+            self.assertEqual(rig.module.serve(rig.args), 1)
+            argv, kwargs = rig.launches[1]
+            profile_arg = next(a.split("=", 1)[1] for a in argv if a.startswith("--user-data-dir="))
+            self.assertEqual(Path(kwargs["cwd"]) / profile_arg, (rig.directory / "profile").resolve())
+            self.assertTrue(Path(kwargs["cwd"]).is_absolute())
+
+    def test_stop_requests_interrupt_startup_and_finish_owned_cleanup(self):
+        for phase in ("retry", "pump", "quiet", "ready"):
+            with self.subTest(phase=phase), serving(stop_at=phase) as rig:
+                self.assertEqual(rig.module.serve(rig.args), 0)
+                self.assertLess(rig.clock.now, 5)
+                self.assertTrue(all(p.poll() is not None for p in rig.children))
+                self.assertTrue(all(h.closed for h in rig.handles))
+                session = rig.module.read_json(rig.directory / "session.json")
+                self.assertEqual(session["pids"], [])
+                self.assertTrue(session["closed"])
+                self.assertFalse((rig.directory / "ready.json").exists())
+                self.assertFalse((rig.directory / "profile").exists())
+
+    def test_stop_during_one_startup_call_prevents_another_bounded_call(self):
+        with serving(stop_at="call") as rig:
+            self.assertEqual(rig.module.serve(rig.args), 0)
+            self.assertLessEqual(rig.clock.now, 10)
+            self.assertTrue(rig.module.read_json(rig.directory / "session.json")["closed"])
+
+    def test_pid_retirement_is_atomic_and_follows_resource_cleanup(self):
+        with serving(stop_at="ready") as rig:
+            replace = Path.replace
+            observed = []
+
+            def publish(path, target):
+                session = rig.module.read_json(target)
+                observed.append(session["pids"])
+                self.assertEqual(session["pids"], [1100, 1101])
+                self.assertTrue(all(p.poll() is not None for p in rig.children))
+                self.assertTrue(all(h.closed for h in rig.handles))
+                self.assertFalse((rig.directory / "profile").exists())
+                self.assertFalse((rig.directory / "ready.json").exists())
+                return replace(path, target)
+
+            with patch.object(Path, "replace", publish):
+                self.assertEqual(rig.module.serve(rig.args), 0)
+            self.assertEqual(observed, [[1100, 1101]])
+            self.assertEqual(rig.module.read_json(rig.directory / "session.json")["pids"], [])
+
+    def test_failed_cleanup_does_not_retire_owned_ids_or_claim_closed(self):
+        for failure in ("child wait", "profile removal"):
+            with self.subTest(failure=failure), serving(failure, stop_at="ready") as rig:
+                self.assertEqual(rig.module.serve(rig.args), 1)
+                session = rig.module.read_json(rig.directory / "session.json")
+                self.assertEqual(session["pids"], [1100, 1101])
+                self.assertFalse(session.get("closed", False))
+                self.assertFalse((rig.directory / "ready.json").exists())
+
+    def test_exited_leader_cannot_confirm_descendant_cleanup(self):
+        with serving("exited leader", stop_at="ready") as rig:
+            self.assertEqual(rig.module.serve(rig.args), 1)
+            session = rig.module.read_json(rig.directory / "session.json")
+            self.assertEqual(session["pids"], [1100, 1101])
+            self.assertFalse(session.get("closed", False))
+            self.assertEqual(rig.children[0].returncode, 0)
+            self.assertEqual(rig.children[1].returncode, -9)
+            self.assertTrue(all(h.closed for h in rig.handles))
+            self.assertFalse((rig.directory / "ready.json").exists())
+
+
+class CloseContractTests(unittest.TestCase):
+    def test_close_waits_for_owner_cleanup_and_repeated_close_never_kills(self):
+        module, clock = recorder(), Clock()
+        with tempfile.TemporaryDirectory() as temp:
+            directory = Path(temp)
+            module.write_json(directory / "session.json", {"pids": [987654]})
+            (directory / "ready.json").write_text("{}")
+            (directory / "profile").mkdir()
+            (directory / "terminal.log").write_text("retain evidence")
+
+            def sleep(seconds):
+                clock.sleep(seconds)
+                self.assertTrue((directory / "stop").exists())
+                if clock.now >= 12:
+                    (directory / "profile").rmdir()
+                    (directory / "ready.json").unlink()
+                    module.write_json(directory / "session.json", {"pids": [], "closed": True})
+
+            with patch.object(module, "kill_process_tree", side_effect=AssertionError("historical PID kill")), \
+                 patch.object(module.time, "monotonic", clock.monotonic), \
+                 patch.object(module.time, "sleep", sleep), contextlib.redirect_stdout(io.StringIO()):
+                self.assertEqual(module.close(SimpleNamespace(session=directory)), 0)
+                self.assertGreaterEqual(clock.now, 12)
+                self.assertEqual(module.close(SimpleNamespace(session=directory)), 0)
+            self.assertEqual((directory / "terminal.log").read_text(), "retain evidence")
+
+    def test_incomplete_or_unavailable_cleanup_is_not_success(self):
+        for state in ("missing", "unreadable", "unavailable owner", "profile remains", "ready remains"):
+            with self.subTest(state=state), tempfile.TemporaryDirectory() as temp:
+                module, clock, directory = recorder(), Clock(), Path(temp)
+                if state != "missing":
+                    module.write_json(directory / "session.json", {"pids": [987654]})
+                if state == "unreadable":
+                    (directory / "session.json").write_text("invalid json")
+                if state in ("profile remains", "ready remains"):
+                    module.write_json(directory / "session.json", {"pids": [], "closed": True})
+                    if state == "profile remains":
+                        (directory / "profile").mkdir()
+                    else:
+                        (directory / "ready.json").write_text("{}")
+                with patch.object(module, "kill_process_tree", lambda pid: None), \
+                     patch.object(module.time, "monotonic", clock.monotonic), \
+                     patch.object(module.time, "sleep", clock.sleep), \
+                     contextlib.redirect_stdout(io.StringIO()) as out, \
+                     contextlib.redirect_stderr(io.StringIO()):
+                    try:
+                        code = module.close(SimpleNamespace(session=directory))
+                    except Exception as error:
+                        code = error
+                self.assertEqual(code, 1)
+                self.assertNotIn('"closed": true', out.getvalue())
+                self.assertLessEqual(clock.now, 30.1)
+                if state == "unavailable owner":
+                    self.assertGreaterEqual(clock.now, 30)
+
+
+class CDPCallTests(unittest.TestCase):
+    def test_stop_during_response_prevents_the_next_call_from_being_sent(self):
+        module, sent, stopped = recorder(), [], []
+
+        def recv():
+            stopped.append(True)
+            return json.dumps({"id": len(sent), "result": {}})
+
+        ws = SimpleNamespace(settimeout=lambda timeout: None, recv=recv,
+                             send=lambda data: sent.append(json.loads(data)["method"]))
+        websocket = SimpleNamespace(create_connection=lambda *a, **kw: ws,
+                                    WebSocketTimeoutException=TimeoutError)
+        def check_active():
+            if stopped:
+                raise InterruptedError("stop requested")
+
+        with patch.dict("sys.modules", websocket=websocket):
+            cdp = module.CDP("fake-url")
+            cdp.before_call = check_active
+            self.assertEqual(cdp.call("Network.enable"), {})
+            with self.assertRaises(InterruptedError):
+                cdp.call("Page.enable")
+        self.assertEqual(sent, ["Network.enable"])
+
+
+class ObservationTests(unittest.TestCase):
+    def observe(self, action, completed=False, seconds=0.2, cp1252=False):
+        module, clock = recorder(), Clock()
+        with tempfile.TemporaryDirectory() as temp:
+            directory = Path(temp)
+            log = directory / "terminal.log"
+            initial = b"\x1b]0;MOVIE;1;1;0;/work\x07"
+            final = "\x1b]0;MOVIE;2;0;7;C:/René/λ\x1b\\".encode()
+            log.write_bytes(initial + (final if completed else b""))
+            (directory / "ready.json").write_text("{}")
+            module.write_json(directory / "session.json", {"pids": [1100, 1101]})
+            calls = []
+
+            def recv(timeout):
+                calls.append(timeout)
+                clock.sleep(timeout)
+                if action == "disconnect":
+                    raise ConnectionError("browser connection closed")
+                if action == "session loss":
+                    (directory / "ready.json").unlink(missing_ok=True)
+                if action == "prompt arrives":
+                    log.write_bytes(initial + final)
+                if action == "prompt then disconnect":
+                    log.write_bytes(initial + final)
+                    raise ConnectionError("browser connection closed")
+                return None
+
+            cdp = SimpleNamespace(recv=recv)
+            args = SimpleNamespace(session=directory, record=None, seconds=seconds, hold=0)
+            raw = io.BytesIO()
+            out = io.TextIOWrapper(raw, encoding="cp1252" if cp1252 else "utf-8")
+            with patch.object(module.time, "monotonic", clock.monotonic), \
+                 patch.object(module.time, "sleep", clock.sleep), contextlib.redirect_stdout(out):
+                code = module.observe(args, cdp, 1)
+            out.flush()
+            result = json.loads(raw.getvalue().decode("ascii" if cp1252 else "utf-8"))
+            return code, result, calls
+
+    def test_disconnected_browser_is_failure_without_recording(self):
+        code, result, calls = self.observe("disconnect")
+        self.assertEqual(code, 1)
+        self.assertEqual(result["outcome"], "failed")
+        self.assertTrue(calls)
+
+    def test_lost_terminal_session_is_failure_even_with_live_browser(self):
+        code, result, calls = self.observe("session loss")
+        self.assertEqual(code, 1)
+        self.assertEqual(result["outcome"], "failed")
+        self.assertTrue(calls)
+
+    def test_only_live_unfinished_command_returns_two(self):
+        for seconds in (0, 0.2):
+            with self.subTest(seconds=seconds):
+                code, result, calls = self.observe("alive", seconds=seconds)
+                self.assertEqual((code, result), (2, {"outcome": "running"}))
+                self.assertTrue(calls, "even an expired observation must establish liveness")
+
+    def test_new_prompt_retains_native_failure_status(self):
+        for action in ("prompt arrives", "prompt then disconnect"):
+            with self.subTest(action=action):
+                code, result, _ = self.observe(action)
+                self.assertEqual(code, 1)
+                self.assertEqual(result, {"outcome": "completed", "ok": False,
+                                          "exit_code": 7, "cwd": "C:/René/λ"})
+
+    def test_completed_prompt_is_not_consumed_by_later_disconnect(self):
+        code, result, _ = self.observe("disconnect", completed=True)
+        self.assertEqual((code, result["outcome"], result["exit_code"]), (1, "completed", 7))
+
+    def test_stdout_json_round_trips_non_ascii_paths_on_cp1252(self):
+        try:
+            code, result, _ = self.observe("alive", completed=True, cp1252=True)
+        except UnicodeError as error:
+            self.fail(f"stdout JSON was not portable: {error}")
+        self.assertEqual((code, result["cwd"]), (1, "C:/René/λ"))
+
+    def test_capture_disconnect_during_hold_preserves_completed_command_status(self):
+        for ok, exit_code in ((True, 0), (False, 7)):
+            with self.subTest(ok=ok), tempfile.TemporaryDirectory() as temp:
+                module, clock, writes = recorder(), Clock(), []
+                marker = f"\x1b]0;MOVIE;2;{int(ok)};{exit_code};C:/René/λ\x07".encode()
+                args = SimpleNamespace(session=Path(temp), record=Path(temp) / "take",
+                                       seconds=10, hold=0.6)
+                real_film = module.film
+
+                def film(*args):
+                    return real_film(*args, clock=clock.monotonic, sleep=clock.sleep)
+
+                def capture(cdp):
+                    if clock.now >= 0.2:
+                        raise ConnectionError("capture connection closed")
+                    return b"capture-token"
+
+                with patch.object(module, "film", film), \
+                     patch.object(module, "screenshot", capture), \
+                     patch.object(module, "tail", lambda path: marker), \
+                     patch.object(Path, "write_bytes", lambda path, token: writes.append(token)), \
+                     patch.object(module, "write_json", side_effect=AssertionError("failed take publication")), \
+                     contextlib.redirect_stdout(io.StringIO()) as out:
+                    code = module.observe(args, SimpleNamespace(), 1)
+                self.assertEqual(code, 1)
+                self.assertEqual(json.loads(out.getvalue()), {
+                    "outcome": "failed", "error": "capture connection closed",
+                    "ok": ok, "exit_code": exit_code, "cwd": "C:/René/λ",
+                })
+                self.assertEqual(writes, [b"capture-token"])
+
+
+class VisibleTextTests(unittest.TestCase):
+    def test_visible_prompt_survives_bel_and_st_title_markers(self):
+        module = recorder()
+        for terminator in (b"\x07", b"\x1b\\"):
+            with self.subTest(terminator=terminator):
+                log = b"\x1b[32m/work $ \x1b]0;MOVIE;2;1;0;/work" + terminator
+                self.assertTrue(module.at_prompt(log))
+                self.assertFalse(module.at_prompt(log + b"busy"))
+
+
+class CaptureTimingTests(unittest.TestCase):
+    def film(self, durations, seconds=1, hold=0, complete_at=None):
+        module, clock, writes, shots = recorder(), Clock(), [], []
+        def capture():
+            token = bytes([len(shots) + 1])
+            clock.sleep(durations[len(shots)] if len(shots) < len(durations) else 0)
+            shots.append(token)
+            return token
+        with tempfile.TemporaryDirectory() as temp, \
+             patch.object(Path, "write_bytes", lambda path, token: writes.append((path.name, token))):
+            frames = module.film(Path(temp), seconds, hold, capture,
+                                 lambda: complete_at is not None and clock.now >= complete_at,
+                                 clock.monotonic, clock.sleep)
+        self.assertEqual(len(writes), frames)
+        return frames, writes
+
+    def test_capture_crossing_hard_endpoint_fills_exactly_five_slots(self):
+        frames, writes = self.film([1.2])
+        self.assertEqual(frames, 5)
+        self.assertEqual(writes, [("f00000.png", b"\x01"), ("f00001.png", b"\x01"),
+                                  ("f00002.png", b"\x01"), ("f00003.png", b"\x01"),
+                                  ("f00004.png", b"\x01")])
+
+    def test_capture_crossing_hold_endpoint_is_bounded(self):
+        frames, writes = self.film([1.2], seconds=10, hold=0.6, complete_at=0)
+        self.assertEqual(frames, 3)
+        self.assertEqual([name for name, _ in writes], ["f00000.png", "f00001.png", "f00002.png"])
+
+    def test_mid_capture_stall_repeats_previous_token_without_missing_slots(self):
+        frames, writes = self.film([0.01, 0.5], seconds=1)
+        self.assertEqual(frames, 5)
+        self.assertEqual([token for _, token in writes], [b"\x01", b"\x02", b"\x02", b"\x03", b"\x04"])
+
+    def test_completion_and_hold_keep_the_normal_grid(self):
+        frames, _ = self.film([], seconds=10, hold=0.4, complete_at=1)
+        self.assertEqual(frames, 7)
+
+    def test_completion_without_hold_and_zero_duration_do_not_add_slots(self):
+        self.assertEqual(self.film([], seconds=10, complete_at=0)[0], 0)
+        self.assertEqual(self.film([], seconds=0)[0], 0)
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 290 - 0
tests/proving-it-works-with-a-movie/test_subtitle_contract.py

@@ -0,0 +1,290 @@
+"""Text and mocked-boundary contracts; this module never processes media."""
+
+import io
+import json
+import subprocess
+import sys
+import tempfile
+import unittest
+from contextlib import redirect_stderr, redirect_stdout
+from pathlib import Path
+from unittest.mock import patch
+
+import fixtures
+
+
+def milliseconds(timestamp):
+    hours, minutes, rest = timestamp.split(":")
+    seconds, millis = rest.split(",")
+    return ((int(hours) * 60 + int(minutes)) * 60 + int(seconds)) * 1000 + int(millis)
+
+
+def read_cues(path):
+    cues = []
+    for block in path.read_text(encoding="utf-8").strip().split("\n\n"):
+        if block:
+            _, timing, text = block.split("\n", 2)
+            start, end = map(milliseconds, timing.split(" --> "))
+            cues.append((start, end, " ".join(text.split())))
+    return cues
+
+
+class SubtitleTimingContract(unittest.TestCase):
+    def setUp(self):
+        self.module = fixtures.load_script("make-subtitles")
+        temporary = tempfile.TemporaryDirectory()
+        self.addCleanup(temporary.cleanup)
+        self.root = Path(temporary.name)
+        self.output = self.root / "captions.srt"
+
+    def subtitles(self, entries, *options):
+        manifest = self.root / "manifest.json"
+        manifest.write_text(json.dumps(entries), encoding="utf-8")
+        stdout = io.StringIO()
+        with patch.object(sys, "argv", ["make-subtitles", str(manifest), str(self.output), *options]), \
+             redirect_stdout(stdout):
+            self.assertEqual(self.module.main(), 0)
+        return read_cues(self.output), stdout.getvalue()
+
+    def assert_scene(self, cues, start, end, text):
+        self.assertTrue(cues)
+        self.assertEqual(cues[0][0], start)
+        self.assertEqual(cues[-1][1], end)
+        previous = start
+        for a, b, _ in cues:
+            self.assertEqual(a, previous)
+            self.assertLess(a, b)
+            self.assertLessEqual(b, end)
+            previous = b
+        self.assertEqual(" ".join(cue[2] for cue in cues).split(), text.split())
+
+    def test_five_chunks_fit_half_second_before_next_scene(self):
+        text = "one two six ten red"
+        cues, report = self.subtitles([
+            {"id": "short", "duration": 0.5, "text": text},
+            {"id": "next", "duration": 1, "text": "next"},
+        ], "--max-chars", "3")
+        self.assert_scene(cues[:-1], 0, 500, text)
+        self.assertEqual(cues[-1], (500, 1500, "next"))
+        self.assertIn("ends at 00:00:01,500", report)
+
+    def test_one_word_covers_twelve_second_scene(self):
+        cues, report = self.subtitles([{"id": "held", "duration": 12, "text": "Held"}])
+        self.assert_scene(cues, 0, 12000, "Held")
+        self.assertIn("ends at 00:00:12,000", report)
+
+    def test_mixed_chunks_get_proportional_time(self):
+        cues, _ = self.subtitles([{"id": "mix", "duration": 1, "text": "a bbbbbbbbb"}], "--max-chars", "9")
+        self.assertEqual(cues, [(0, 100, "a"), (100, 1000, "bbbbbbbbb")])
+
+    def test_max_seconds_guides_splitting_without_losing_tail_or_words(self):
+        text = "one two six ten red cat dog fox"
+        cues, _ = self.subtitles([{"id": "long", "duration": 12, "text": text}], "--max-secs", "3")
+        self.assert_scene(cues, 0, 12000, text)
+        self.assertGreater(len(cues), 1)
+        self.assertTrue(all(b - a <= 3000 for a, b, _ in cues))
+
+    def test_max_seconds_refines_unequal_chunks_against_allocated_time(self):
+        text = "ab cde f ghi"
+        cues, report = self.subtitles([
+            {"id": "unequal", "duration": 6, "text": text},
+        ], "--max-secs", "3")
+        self.assert_scene(cues, 0, 6000, text)
+        self.assertTrue(all(b - a <= 3000 for a, b, _ in cues), cues)
+        self.assertIn("ends at 00:00:06,000", report)
+
+    def test_chunks_coalesce_to_fit_representable_milliseconds(self):
+        text = "one two six ten red"
+        cues, report = self.subtitles([{"id": "tiny", "duration": 0.002, "text": text}], "--max-chars", "3")
+        self.assert_scene(cues, 0, 2, text)
+        self.assertLessEqual(len(cues), 2)
+        self.assertIn("ends at 00:00:00,002", report)
+
+    def test_unrepresentable_max_seconds_preserves_positive_cues_and_all_words(self):
+        text = "one two six ten red"
+        cues, _ = self.subtitles([
+            {"id": "tiny", "duration": 0.002, "text": text},
+        ], "--max-secs", "0.0001")
+        self.assert_scene(cues, 0, 2, text)
+        self.assertLessEqual(len(cues), 2)
+
+    def test_submillisecond_scene_can_use_its_rounded_interval(self):
+        cues, _ = self.subtitles([{"id": "tiny", "duration": 0.0008, "text": "one two"}])
+        self.assert_scene(cues, 0, 1, "one two")
+
+    def test_invalid_or_unrepresentable_duration_fails_before_writing_srt(self):
+        for duration in (0, -1, 0.0001, float("nan"), float("inf"), "invalid"):
+            with self.subTest(duration=duration), redirect_stderr(io.StringIO()):
+                with self.assertRaises(SystemExit) as caught:
+                    self.subtitles([{"id": "invalid", "duration": duration, "text": "words"}])
+                self.assertNotEqual(caught.exception.code, 0)
+                self.assertFalse(self.output.exists())
+
+    def test_invalid_readability_limits_fail_clearly(self):
+        for option, value in (("--max-chars", "0"), ("--max-secs", "0"),
+                              ("--max-secs", "nan"), ("--max-secs", "inf")):
+            with self.subTest(option=option, value=value), redirect_stderr(io.StringIO()):
+                with self.assertRaises(SystemExit) as caught:
+                    self.subtitles([{"id": "scene", "duration": 1, "text": "words"}], option, value)
+                self.assertNotEqual(caught.exception.code, 0)
+
+    def test_manual_offset_uses_rounded_scene_boundaries(self):
+        cues, report = self.subtitles([{"id": "shifted", "duration": 0.5004, "text": "one two six"}], "--offsets", "shifted=2.1254", "--max-chars", "3")
+        self.assert_scene(cues, 2125, 2626, "one two six")
+        self.assertIn("ends at 00:00:02,626", report)
+
+    def test_empty_cut_and_unknown_offset_keys_do_not_introduce_cues(self):
+        for mapping in ({}, {"unknown": 2}):
+            with self.subTest(mapping=mapping):
+                offsets = self.root / "offsets.json"
+                offsets.write_text(json.dumps(mapping), encoding="utf-8")
+                cues, report = self.subtitles([{"id": "excluded", "duration": 1, "text": "excluded"}], "--offsets-json", str(offsets), "--offsets", "excluded=5")
+                self.assertEqual(cues, [])
+                self.assertIn("0 cues, ends at 00:00:00,000", report)
+
+
+class SubtitleTrackContract(unittest.TestCase):
+    def test_supplied_track_replaces_existing_subtitles_with_optional_audio(self):
+        module = fixtures.load_script("burn-subtitles")
+        for soft in (True, False):
+            for source_audio in (True, False):
+                with self.subTest(soft=soft, source_audio=source_audio), tempfile.TemporaryDirectory() as directory:
+                    root = Path(directory)
+                    movie, subs = root / "movie.mp4", root / "new.srt"
+                    movie.write_bytes(b"source token")
+                    subs.write_text("1\n00:00:00,000 --> 00:00:01,000\nNew caption\n", encoding="utf-8")
+                    selections = []
+                    source = {"v:0": "source video", "s:0": "old caption"}
+                    if source_audio:
+                        source["a:0"] = "source audio"
+                    inputs = [source, {"s:0": "new caption"}]
+
+                    def encode(command, **kwargs):
+                        if "-vf" in command:
+                            return False
+                        maps = [command[i + 1] for i, arg in enumerate(command) if arg == "-map"]
+                        self.assertEqual(maps, ["0:v:0", "0:a?", "1:s:0"])
+                        for spec in maps:
+                            index, kind = spec.rstrip("?").split(":", 1)
+                            selected = [value for stream, value in inputs[int(index)].items() if stream == kind or stream.startswith(kind + ":")]
+                            if not spec.endswith("?"):
+                                self.assertTrue(selected)
+                            selections.extend(selected)
+                        return True
+
+                    stdout, stderr = io.StringIO(), io.StringIO()
+                    argv = ["burn-subtitles", str(movie), str(subs), str(root / "out.mp4")]
+                    if soft:
+                        argv.append("--soft")
+                    with patch.object(sys, "argv", argv), \
+                         patch.object(module.shutil, "which", return_value="mock-ffmpeg"), \
+                         patch.object(module, "has_libass", return_value=True), \
+                         patch.object(module, "run", side_effect=encode), \
+                         redirect_stdout(stdout), \
+                         redirect_stderr(stderr):
+                        self.assertEqual(module.main(), 0)
+                    self.assertEqual(selections, ["source video", *(["source audio"] if source_audio else []), "new caption"])
+                    self.assertNotIn("no libass", stdout.getvalue())
+                    self.assertEqual("burn failed" in stderr.getvalue(), not soft)
+
+
+class SubtitleParserContract(unittest.TestCase):
+    def setUp(self):
+        self.module = fixtures.load_script("check-movie")
+
+    def test_literal_arrow_in_caption_is_not_a_timing_line(self):
+        self.assertEqual(self.module.subtitle_end("1\n00:00:00,000 --> 00:00:01,250\nFollow source --> destination.\n"), 1.25)
+
+    def test_timestamp_shaped_caption_cannot_extend_coverage(self):
+        text = "1\n00:00:00,000 --> 00:00:01,250\n00:00:00,000 --> 00:59:00,000\n"
+        self.assertEqual(self.module.subtitle_end(text), 1.25)
+
+    def test_malformed_actual_timing_is_rejected(self):
+        for timing in ("00:00:00,000 --> invalid", "not a timing line", "00:00:00,000 --> 00:99:00,000"):
+            with self.subTest(timing=timing), self.assertRaises(ValueError):
+                self.module.subtitle_end(f"1\n{timing}\ncaption\n")
+
+    def test_empty_subtitles_have_no_end(self):
+        self.assertIsNone(self.module.subtitle_end("\n  \n"))
+
+    def test_multiple_cues_keep_existing_latest_end_policy(self):
+        text = "1\n00:00:00,000 --> 00:00:10,250\nFirst\n\n2\n00:00:05,000 --> 00:00:06,000\nSecond\n"
+        self.assertEqual(self.module.subtitle_end(text), 10.25)
+
+
+class SubtitleHandoffContract(unittest.TestCase):
+    def test_rerun_removed_opening_narration_keeps_evidence_and_retimes_remaining_caption(self):
+        narrate = fixtures.load_script("narrate")
+        assemble = fixtures.load_script("assemble")
+        subtitles = fixtures.load_script("make-subtitles")
+        with tempfile.TemporaryDirectory() as directory:
+            root = Path(directory)
+            scenes_file, narration = root / "scenes.yaml", root / "narration"
+            work, movie, srt = root / "segments", root / "movie.mp4", root / "movie.srt"
+            (root / "still.png").write_bytes(b"still token")
+            scenes = [
+                {"id": "opening", "kind": "image", "src": "still.png", "duration": 2, "narration": "Opening words"},
+                {"id": "body", "kind": "image", "src": "still.png", "duration": 12, "narration": "Body"},
+            ]
+            encoded_inputs = []
+
+            def synthesize(text, wav, voice):
+                wav.write_bytes(text.encode("utf-8"))
+
+            def measure(path):
+                if path.name.startswith(".opening") or path.name == "opening.wav":
+                    return 1.0
+                return 12.0
+
+            def media_command(command, **kwargs):
+                if command[0] == "ffprobe":
+                    path = Path(command[-1])
+                    seconds = {"opening.mp4": 2.375, "body.mp4": 12.0, "movie.mp4": 14.375}.get(path.name)
+                    if seconds is None:
+                        seconds = measure(path)
+                    return subprocess.CompletedProcess(command, 0, str(seconds), "")
+                self.assertEqual(command[0], "ffmpeg")
+                encoded_inputs.append([command[i + 1] for i, item in enumerate(command) if item == "-i"])
+                Path(command[-1]).write_bytes(b"encoded token")
+                return subprocess.CompletedProcess(command, 0, "", "")
+
+            for rerun in (False, True):
+                if rerun:
+                    del scenes[0]["narration"]
+                scenes_file.write_text(json.dumps({"scenes": scenes}), encoding="utf-8")
+                with patch.object(sys, "argv", ["narrate", str(scenes_file), str(narration), "--engine", "piper", "--verify", "off"]), \
+                     patch.object(narrate.shutil, "which", return_value="mock-tool"), \
+                     patch.object(narrate, "openai_key", return_value=None), \
+                     patch.object(narrate, "say_piper", side_effect=synthesize), \
+                     patch.object(narrate, "duration", side_effect=measure), \
+                     redirect_stdout(io.StringIO()), \
+                     redirect_stderr(io.StringIO()):
+                    self.assertEqual(narrate.main(), 0)
+                manifest = json.loads((narration / "manifest.json").read_text(encoding="utf-8"))
+                self.assertEqual([entry["id"] for entry in manifest], ["body"] if rerun else ["opening", "body"])
+                self.assertEqual(manifest[-1]["text"], "Body")
+                self.assertEqual(manifest[-1]["duration"], 12)
+                self.assertEqual(manifest[-1]["wav"], "body.wav")
+                self.assertEqual((narration / "opening.wav").read_bytes(), b"Opening words")
+                encoded_inputs.clear()
+                with patch.object(sys, "argv", ["assemble", str(scenes_file), str(movie), "--narration", str(narration), "--work", str(work)]), \
+                     patch.object(assemble.shutil, "which", return_value="mock-tool"), \
+                     patch.object(assemble, "find_browser", return_value=None), \
+                     patch.object(assemble, "run", side_effect=media_command), \
+                     redirect_stdout(io.StringIO()):
+                    self.assertEqual(assemble.main(), 0)
+                offsets = json.loads((work / "offsets.json").read_text(encoding="utf-8"))
+                self.assertEqual(offsets, {"body": 2.375} if rerun else {"opening": 0.0, "body": 2.375})
+                self.assertEqual(str(narration / "opening.wav") in encoded_inputs[0], not rerun)
+                self.assertIn(str(narration / "body.wav"), encoded_inputs[1])
+                with patch.object(sys, "argv", ["make-subtitles", str(narration / "manifest.json"), str(srt), "--offsets-json", str(work / "offsets.json")]), \
+                     redirect_stdout(io.StringIO()):
+                    self.assertEqual(subtitles.main(), 0)
+                expected = [(2375, 14375, "Body")]
+                if not rerun:
+                    expected.insert(0, (0, 1000, "Opening words"))
+                self.assertEqual(read_cues(srt), expected)
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 162 - 0
tests/proving-it-works-with-a-movie/test_subtitles.py

@@ -0,0 +1,162 @@
+import tempfile
+import json
+import sys
+import io
+from contextlib import redirect_stderr, redirect_stdout
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+import fixtures
+
+
+class SubtitlePathRegression(unittest.TestCase):
+    def test_hard_burn_runs_from_safe_directory_with_absolute_media(self):
+        module = fixtures.load_script("burn-subtitles")
+        with tempfile.TemporaryDirectory() as directory:
+            root = Path(directory) / "movie O'Brien λ"
+            root.mkdir()
+            movie, subs, output = root / "in.mp4", root / "nested" / "captions.srt", root / "out.mp4"
+            subs.parent.mkdir()
+            movie.write_bytes(b"movie")
+            subs.write_bytes("\ufeff1\r\n00:00:00,000 --> 00:00:01,000\r\nλ\r\n".encode("utf-8"))
+            calls = []
+
+            def fake_run(cmd, *, cwd=None):
+                calls.append((cmd, cwd))
+                if cwd is not None:
+                    self.assertEqual((cwd / "captions.srt").read_bytes(), subs.read_bytes())
+                return True
+
+            stdout, stderr = io.StringIO(), io.StringIO()
+            with patch.object(sys, "argv", ["burn-subtitles", str(movie), str(subs), str(output)]), patch.object(module.shutil, "which", return_value="ffmpeg"), patch.object(module, "has_libass", return_value=True), patch.object(module, "run", side_effect=fake_run), redirect_stdout(stdout), redirect_stderr(stderr):
+                self.assertEqual(module.main(), 0)
+            self.assertIn("burned into the picture", stdout.getvalue())
+            command, cwd = calls[0]
+            self.assertEqual(cwd.name.startswith("movie-subtitles-"), True)
+            self.assertIn(str(movie.resolve()), command)
+            self.assertIn(str(output.resolve()), command)
+            self.assertTrue(any(value.startswith("subtitles=filename=captions.srt") for value in command))
+            self.assertFalse(cwd.exists())
+
+    def test_burn_failure_is_reported_separately_from_missing_libass(self):
+        module = fixtures.load_script("burn-subtitles")
+        with tempfile.TemporaryDirectory() as directory:
+            root = Path(directory)
+            movie, subs, output = root / "in.mp4", root / "captions.srt", root / "out.mp4"
+            movie.write_bytes(b"movie")
+            subs.write_text("1\n00:00:00,000 --> 00:00:01,000\ncaption\n", encoding="utf-8")
+            stdout, stderr = io.StringIO(), io.StringIO()
+            with patch.object(sys, "argv", ["burn-subtitles", str(movie), str(subs), str(output)]), patch.object(module.shutil, "which", return_value="ffmpeg"), patch.object(module, "has_libass", return_value=True), patch.object(module, "run", return_value=False), redirect_stdout(stdout), redirect_stderr(stderr):
+                self.assertEqual(module.main(), 1)
+            self.assertIn("burn failed", stderr.getvalue())
+
+class SubtitleOffsetRegression(unittest.TestCase):
+    def subtitles(self, *manual, offsets=None):
+        module = fixtures.load_script("make-subtitles")
+        with tempfile.TemporaryDirectory() as directory:
+            root = Path(directory)
+            manifest, output = root / "manifest.json", root / "captions.srt"
+            manifest.write_text(json.dumps([
+                {"id": scene, "text": scene, "duration": 1.0}
+                for scene in ("intro", "body", "end")
+            ]), encoding="utf-8")
+            argv = ["make-subtitles", str(manifest), str(output)]
+            if offsets is not None:
+                path = root / "offsets.json"
+                path.write_text(json.dumps(offsets), encoding="utf-8")
+                argv += ["--offsets-json", str(path)]
+            if manual:
+                argv += ["--offsets", *manual]
+            with patch.object(sys, "argv", argv), redirect_stdout(io.StringIO()):
+                self.assertEqual(module.main(), 0)
+            cues = []
+            for block in output.read_text(encoding="utf-8").strip().split("\n\n"):
+                if not block:
+                    continue
+                _, timing, text = block.split("\n", 2)
+                times = []
+                for timestamp in timing.split(" --> "):
+                    h, m, s = timestamp.replace(",", ".").split(":")
+                    times.append(int(h) * 3600 + int(m) * 60 + float(s))
+                cues.append((*times, text))
+            return cues
+
+    def test_default_scenes_run_back_to_back(self):
+        self.assertEqual(self.subtitles(),
+                         [(0, 1, "intro"), (1, 2, "body"), (2, 3, "end")])
+
+    def test_partial_manual_offsets_preserve_other_scenes(self):
+        self.assertEqual(self.subtitles("intro=2"),
+                         [(2, 3, "intro"), (3, 4, "body"), (4, 5, "end")])
+        self.assertEqual(self.subtitles("body=4"),
+                         [(0, 1, "intro"), (4, 5, "body"), (5, 6, "end")])
+
+    def test_assembly_offsets_select_scenes_in_the_cut(self):
+        self.assertEqual(self.subtitles(offsets={"intro": 2, "end": 8}),
+                         [(2, 3, "intro"), (8, 9, "end")])
+
+    def test_manual_offsets_change_timing_without_changing_cut_membership(self):
+        self.assertEqual(self.subtitles("intro=3", "body=5", offsets={"intro": 2, "end": 8}),
+                         [(3, 4, "intro"), (8, 9, "end")])
+
+    def test_cut_without_narrated_scenes_has_no_cues(self):
+        for offsets in ({}, {"silent": 2}):
+            with self.subTest(offsets=offsets):
+                self.assertEqual(self.subtitles(offsets=offsets), [])
+
+
+class SubtitleIntegrationRegression(unittest.TestCase):
+    def test_bom_manifest_and_offsets_write_utf8_under_legacy_console(self):
+        import json
+        import os
+        with tempfile.TemporaryDirectory() as directory:
+            root = Path(directory)
+            manifest, offsets, out = root / "manifest.json", root / "offsets.json", root / "λ.srt"
+            manifest.write_bytes(('\ufeff' + json.dumps([{"id": "clip", "text": "Unicode λ café", "duration": 1}], ensure_ascii=False) + '\r\n').encode('utf-8'))
+            offsets.write_text('{"clip": 2}', encoding="utf-8-sig")
+            env = dict(os.environ, PYTHONIOENCODING="cp1252", PYTHONUTF8="0")
+            result = fixtures.run_tool("make-subtitles", [str(manifest), str(out), "--offsets-json", str(offsets)], cwd=root, env=env)
+            self.assertEqual(result.returncode, 0, fixtures.output_text(result))
+            text = out.read_text(encoding="utf-8")
+            self.assertIn("Unicode λ café", text)
+            self.assertIn("00:00:02,000 --> 00:00:03,000", text)
+            self.assertFalse(out.read_bytes().startswith(b'\xef\xbb\xbf'))
+
+    def test_hard_subtitles_are_pixels_in_nested_special_path(self):
+        import subprocess
+        missing = fixtures.missing_executables("uv", "ffmpeg")
+        if missing:
+            self.skipTest(f"required executable(s) not on PATH: {', '.join(missing)}")
+        module = fixtures.load_script("burn-subtitles")
+        if not module.has_libass():
+            self.skipTest("libass FFmpeg is required for hard subtitle pixels")
+        with tempfile.TemporaryDirectory() as directory:
+            root = Path(directory) / "O'Brien λ # %"
+            root.mkdir()
+            movie, subs, output = root / "in.mp4", root / "nested" / "O'Brien.srt", root / "out.mp4"
+            subs.parent.mkdir()
+            subs.write_bytes("\ufeff1\r\n00:00:00,000 --> 00:00:01,000\r\nVisible caption\r\n".encode("utf-8"))
+            fixtures._run_ffmpeg(["-f", "lavfi", "-i", "color=c=black:s=640x360:d=1", "-c:v", "libx264", str(movie)], cwd=root)
+            result = fixtures.run_tool("burn-subtitles", [str(movie), str(subs), str(output)], cwd=Path(directory))
+            self.assertEqual(result.returncode, 0, fixtures.output_text(result))
+            self.assertIn("burned into the picture", fixtures.output_text(result))
+            frame = subprocess.run(["ffmpeg", "-v", "error", "-ss", "0.5", "-i", str(output), "-frames:v", "1", "-pix_fmt", "gray", "-f", "rawvideo", "-"], capture_output=True, check=True).stdout
+            self.assertGreater(sum(value > 180 for value in frame), 50)
+
+    def test_burn_failure_fallback_does_not_claim_missing_libass(self):
+        module = fixtures.load_script("burn-subtitles")
+        for libass in (True, False):
+            with self.subTest(libass=libass), tempfile.TemporaryDirectory() as directory:
+                root = Path(directory)
+                movie, subs = root / "in.mp4", root / "in.srt"
+                movie.touch(); subs.touch()
+                stdout, stderr = io.StringIO(), io.StringIO()
+                with patch.object(sys, "argv", ["burn-subtitles", str(movie), str(subs), str(root / "out.mp4")]), patch.object(module.shutil, "which", return_value="ffmpeg"), patch.object(module, "has_libass", return_value=libass), patch.object(module, "run", side_effect=[False, True] if libass else [True]), redirect_stdout(stdout), redirect_stderr(stderr):
+                    self.assertEqual(module.main(), 0)
+                self.assertEqual("no libass" in stdout.getvalue(), not libass)
+                self.assertEqual("burn failed" in stderr.getvalue(), libass)
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 314 - 0
tests/proving-it-works-with-a-movie/test_terminal.py

@@ -0,0 +1,314 @@
+"""The terminal recorder: prompt parsing and the frame grid anywhere; a real
+ttyd session wherever ttyd and a Chrome-family browser exist."""
+import importlib.util
+import json
+import os
+import shlex
+import shutil
+import subprocess
+import sys
+import tempfile
+import time
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+import fixtures
+
+SCRIPT = Path(__file__).resolve().parents[2] / "skills/proving-it-works-with-a-movie/examples/film-terminal.py"
+FIXTURE = Path(__file__).resolve().with_name("fixtures") / "terminal_app.py"
+TTYD = os.environ.get("MOVIE_TEST_TTYD") or shutil.which("ttyd")
+BROWSER = fixtures.load_script("browser_tools").find_browser(os.environ.get("MOVIE_TEST_BROWSER"))
+SHELL = os.environ.get("MOVIE_TEST_SHELL") or ("powershell51" if os.name == "nt" else "bash")
+BASH = SHELL in ("bash", "gitbash")
+
+
+def recorder():
+    spec = importlib.util.spec_from_file_location("film_terminal", SCRIPT)
+    module = importlib.util.module_from_spec(spec)
+    spec.loader.exec_module(module)
+    return module
+
+
+def gone(pid, timeout=5):
+    deadline = time.monotonic() + timeout
+    while time.monotonic() < deadline:
+        if os.name == "nt":
+            listed = subprocess.run(["tasklist", "/FI", f"PID eq {pid}", "/NH"],
+                                    capture_output=True, text=True).stdout
+            if str(pid) not in listed:
+                return True
+        else:
+            try:
+                os.kill(pid, 0)
+            except ProcessLookupError:
+                return True
+        time.sleep(0.1)
+    return False
+
+
+class PromptTests(unittest.TestCase):
+    def test_prompts_parse_both_terminators_and_paths_with_semicolons(self):
+        module = recorder()
+        log = (b"noise\x1b]0;MOVIE;1;1;;C:\\a;b\x07\x1b[0m"
+               b"\x1b]2;MOVIE;2;0;7;/c/x\x1b\\tail"
+               b"\x1b]0;MOVIE;3;1;0;/home/me\x07")
+        self.assertEqual(module.prompts(log), [
+            dict(n=1, ok=True, exit_code=None, cwd="C:\\a;b"),
+            dict(n=2, ok=False, exit_code=7, cwd="/c/x"),
+            dict(n=3, ok=True, exit_code=0, cwd="/home/me"),
+        ])
+        self.assertEqual(module.prompts(b"\x1b]0;something else\x07"), [])
+
+    def test_prompt_install_is_one_typed_line_per_shell(self):
+        module = recorder()
+        cwd = Path("C:/Users/x/movie O'Brien λ")
+        for kind in module.SHELLS:
+            line = module.prompt_command(kind, cwd)
+            self.assertEqual(len(line.splitlines()), 1, kind)
+            self.assertNotIn("MOVIE;", line, "the marker text must not be echoed by the install line")
+            self.assertIn("Brien λ", module.prompt_script(kind, cwd), "the script enters the cwd")
+
+    def test_keys_are_named_or_single_characters(self):
+        module = recorder()
+        self.assertEqual(module.key_params("Ctrl-C")["modifiers"], 2)
+        self.assertEqual(module.key_params("Enter")["text"], "\r")
+        self.assertEqual(module.key_params("q"), dict(key="q", text="q"))
+        with self.assertRaises(SystemExit):
+            module.key_params("Bogus")
+
+
+class FilmGridTests(unittest.TestCase):
+    def test_filming_refuses_a_nonempty_take_without_changing_its_contents(self):
+        module = recorder()
+        with tempfile.TemporaryDirectory() as directory:
+            out = Path(directory) / "take"
+            out.mkdir()
+            (out / "f00000.png").write_bytes(b"old frame")
+            (out / "notes.txt").write_bytes(b"sentinel evidence")
+            before = {path.name: path.read_bytes() for path in out.iterdir()}
+            captures = []
+
+            with self.assertRaisesRegex(SystemExit, "not empty.*new take directory"):
+                module.film(out, seconds=1, hold=0,
+                            capture=lambda: captures.append(True) or b"new frame",
+                            finished=lambda: True)
+
+            self.assertEqual(captures, [])
+            self.assertEqual(
+                {path.name: path.read_bytes() for path in out.iterdir()}, before
+            )
+
+    def test_a_slow_capture_repeats_the_previous_frame_and_filming_holds_after_the_prompt(self):
+        module = recorder()
+        clock = {"now": 0.0}
+        shots = []
+
+        def capture():
+            shots.append(len(shots) + 1)
+            clock["now"] += 0.5 if len(shots) == 2 else 0.01  # the second screenshot stalls
+            return bytes([len(shots)])
+
+        with tempfile.TemporaryDirectory() as directory:
+            out = Path(directory) / "take"
+            frames = module.film(out, seconds=10, hold=0.4, capture=capture,
+                                 finished=lambda: clock["now"] >= 1.0,
+                                 clock=lambda: clock["now"],
+                                 sleep=lambda s: clock.__setitem__("now", clock["now"] + s))
+            files = sorted(out.glob("f*.png"))
+            self.assertEqual([f.name for f in files], [f"f{i:05d}.png" for i in range(frames)])
+            self.assertEqual(files[2].read_bytes(), files[1].read_bytes(), "missed slot repeats the last frame")
+            self.assertNotEqual(files[3].read_bytes(), files[2].read_bytes())
+            self.assertEqual(frames, 7, "1.0 s to the prompt plus 0.4 s hold at 5 fps")
+
+    def test_filming_stops_at_the_deadline_while_the_command_runs(self):
+        module = recorder()
+        clock = {"now": 0.0}
+        with tempfile.TemporaryDirectory() as directory:
+            frames = module.film(Path(directory), seconds=1.0, hold=5, capture=lambda: b"png",
+                                 finished=lambda: False, clock=lambda: clock["now"],
+                                 sleep=lambda s: clock.__setitem__("now", clock["now"] + s))
+            self.assertEqual(frames, 5)
+
+
+class ServeArgumentTests(unittest.TestCase):
+    def test_serve_refuses_a_missing_cwd_before_launching_anything(self):
+        with tempfile.TemporaryDirectory() as directory:
+            result = subprocess.run([sys.executable, str(SCRIPT), "serve", str(Path(directory) / "session"),
+                                     "--shell", "bash", "--cwd", str(Path(directory) / "missing")],
+                                    capture_output=True, text=True, timeout=60)
+            self.assertEqual(result.returncode, 1)
+            self.assertIn("--cwd is not a directory", result.stderr)
+            self.assertFalse((Path(directory) / "session" / "session.json").exists())
+
+    def test_recording_verbs_refuse_a_nonempty_take_before_session_side_effects(self):
+        module = recorder()
+        with tempfile.TemporaryDirectory() as directory:
+            root = Path(directory)
+            session = root / "session"
+            session.mkdir()
+            (session / "ready.json").write_text("{}", encoding="utf-8")
+            (session / "session.json").write_text(
+                json.dumps({"debug_port": 1}), encoding="utf-8"
+            )
+            (session / "terminal.log").write_bytes(
+                b"\x1b]0;MOVIE;1;1;0;/tmp\x07"
+            )
+            for verb, positional in (("run", ["echo hello"]),
+                                     ("key", ["Enter"]),
+                                     ("watch", [])):
+                with self.subTest(verb=verb):
+                    take = root / verb
+                    take.mkdir()
+                    (take / "f00000.png").write_bytes(b"old frame")
+                    (take / "sentinel.txt").write_bytes(b"keep me")
+                    before = {path.name: path.read_bytes() for path in take.iterdir()}
+                    argv = ["film-terminal", verb, str(session), *positional,
+                            "--record", str(take)]
+                    with patch.object(sys, "argv", argv), \
+                         patch.object(module, "connect",
+                                      side_effect=AssertionError("connected")), \
+                         patch.object(module, "type_text",
+                                      side_effect=AssertionError("typed")), \
+                         patch.object(module, "press",
+                                      side_effect=AssertionError("pressed")), \
+                         self.assertRaisesRegex(SystemExit,
+                                                "not empty.*new take directory"):
+                        module.main()
+                    self.assertEqual(
+                        {path.name: path.read_bytes() for path in take.iterdir()}, before
+                    )
+
+
+@unittest.skipUnless(TTYD and BROWSER, "ttyd and a Chrome-family browser are required")
+class SessionTests(unittest.TestCase):
+    def setUp(self):
+        self.tmp = tempfile.TemporaryDirectory(prefix="movie-terminal-", ignore_cleanup_errors=True)
+        self.addCleanup(self.tmp.cleanup)
+        self.work = Path(self.tmp.name) / "movie O'Brien λ"
+        self.work.mkdir()
+        self.session = Path(self.tmp.name) / "session"
+        self.log = (Path(self.tmp.name) / "serve.log").open("wb")
+        self.addCleanup(self.log.close)
+        argv = [sys.executable, str(SCRIPT), "serve", str(self.session), "--shell", SHELL,
+                "--cwd", str(self.work), "--ttyd", TTYD, "--browser", BROWSER]
+        if os.environ.get("MOVIE_TEST_SHELL_EXE"):
+            argv += ["--shell-exe", os.environ["MOVIE_TEST_SHELL_EXE"]]
+        self.owned_pids = []
+        self.serve = subprocess.Popen(argv, stdout=self.log, stderr=subprocess.STDOUT)
+        self.addCleanup(self.close_session)
+        deadline = time.monotonic() + 45
+        while not (self.session / "ready.json").exists() and self.serve.poll() is None \
+                and time.monotonic() < deadline:
+            time.sleep(0.1)
+        if (self.session / "session.json").exists():
+            self.owned_pids = json.loads((self.session / "session.json").read_text(encoding="utf-8"))["pids"]
+        if not (self.session / "ready.json").exists():
+            report = "".join(f"--- {name}\n" + path.read_text(errors="replace") if path.exists() else ""
+                             for name, path in (("serve.log", Path(self.tmp.name) / "serve.log"),
+                                                ("ttyd.log", self.session / "ttyd.log"),
+                                                ("browser.log", self.session / "browser.log")))
+            self.fail(report)
+
+    def close_session(self):
+        if self.serve.poll() is None:
+            # A failed setup may not have session metadata yet, but the
+            # owner still needs its stop request before we wait for cleanup.
+            self.session.mkdir(parents=True, exist_ok=True)
+            (self.session / "stop").write_text("", encoding="utf-8")
+            try:
+                self.serve.wait(35)
+            except subprocess.TimeoutExpired:
+                self.serve.kill()
+                self.serve.wait()
+        for pid in self.owned_pids:
+            self.assertTrue(gone(pid), f"pid {pid} survived close")
+
+    def cli(self, *args, timeout=120):
+        result = subprocess.run([sys.executable, str(SCRIPT), *args], capture_output=True, timeout=timeout)
+        return result.returncode, result.stdout.decode("utf-8", "replace"), result.stderr.decode("utf-8", "replace")
+
+    def run_command(self, command, *extra):
+        code, out, err = self.cli("run", str(self.session), command, *extra)
+        self.assertTrue(out.strip(), err)
+        return code, json.loads(out.strip().splitlines()[-1])
+
+    def quoted(self, *words):
+        if BASH:
+            return " ".join(shlex.quote(w.replace("\\", "/")) for w in words)
+        return "& " + " ".join("'" + w.replace("'", "''") + "'" for w in words)
+
+    def native(self, code):
+        return self.quoted(sys.executable) + f' -c "{code}"'
+
+    def test_commands_report_status_and_the_shell_persists_between_calls(self):
+        code, result = self.run_command("echo hello")
+        self.assertEqual((code, result["outcome"], result["ok"]), (0, "completed", True), result)
+        self.assertTrue(result["cwd"].endswith("movie O'Brien λ"), result["cwd"])
+        if BASH:
+            set_value, check_value, failing = "MOVIE_VALUE=kept", 'test "$MOVIE_VALUE" = kept', "false"
+        else:
+            set_value = "$global:MovieValue = 'kept'"
+            check_value = "if ($global:MovieValue -ne 'kept') { throw 'lost' }"
+            failing = "Get-Item 'Z:\\nowhere'"
+        self.assertEqual(self.run_command(set_value)[0], 0)
+        self.assertEqual(self.run_command(check_value)[0], 0, "state must survive separate calls")
+        code, result = self.run_command(failing)
+        self.assertEqual((code, result["ok"]), (1, False), result)
+        code, result = self.run_command(self.native("import sys; sys.exit(7)"))
+        self.assertEqual((code, result["ok"], result["exit_code"]), (1, False, 7), result)
+
+    def test_a_tui_is_filmed_across_two_takes_and_a_long_command_across_calls(self):
+        from PIL import Image
+
+        take_one, take_two, take_three = (self.work / name for name in ("take-one", "take-two", "take-three"))
+        code, result = self.run_command(self.quoted(sys.executable, str(FIXTURE)),
+                                        "--record", str(take_one), "--seconds", "7")
+        self.assertEqual((code, result["outcome"]), (2, "running"), result)
+        frames = sorted(take_one.glob("f*.png"))
+        self.assertGreaterEqual(len(frames), 30, "7 s at 5 fps")
+        seen = []
+        for frame in frames:
+            with Image.open(frame) as image:
+                r, g, b = image.convert("RGB").getpixel((300, 120))
+            color = ("red" if r > 150 and g < 100 and b < 100 else
+                     "green" if g > 120 and r < 100 and b < 140 else
+                     "blue" if b > 150 and r < 100 and g < 140 else None)
+            if color and (not seen or seen[-1] != color):
+                seen.append(color)
+        self.assertEqual(seen, ["red", "green", "blue"], "the three TUI states, in order, in the automatic frames")
+        code, out, err = self.cli("key", str(self.session), "q", "--record", str(take_two), "--seconds", "10")
+        result = json.loads(out.strip().splitlines()[-1])
+        self.assertEqual((code, result["outcome"], result["ok"]), (0, "completed", True), (result, err))
+        self.assertGreaterEqual(result["frames"], 7, "the exit plus the 1.5 s hold")
+        self.assertEqual(len(json.loads((self.work / "states.json").read_text())), 3)
+
+        code, result = self.run_command(self.native("import time; time.sleep(3)"), "--seconds", "1")
+        self.assertEqual(result["outcome"], "running")
+        code, out, err = self.cli("watch", str(self.session), "--record", str(take_three), "--seconds", "15")
+        result = json.loads(out.strip().splitlines()[-1])
+        self.assertEqual((code, result["outcome"], result["ok"]), (0, "completed", True), (result, err))
+        self.assertGreaterEqual(result["frames"], 10, "about 2 s of waiting plus the hold")
+        self.assertEqual(result["scene"], {"kind": "frames", "src": str(take_three.resolve()), "rate": 5})
+
+    def test_close_kills_the_shell_tree_and_spares_unrelated_processes(self):
+        sentinel = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(120)"])
+        self.addCleanup(sentinel.kill)
+        tree = self.work / "tree"
+        code, result = self.run_command(self.quoted(sys.executable, str(FIXTURE), "tree", str(tree)), "--seconds", "2")
+        self.assertEqual(result["outcome"], "running")
+        deadline = time.monotonic() + 20
+        while len(list(tree.glob("*.json"))) < 3 and time.monotonic() < deadline:
+            time.sleep(0.1)
+        pids = [json.loads(path.read_text())["pid"] for path in tree.glob("*.json")]
+        self.assertEqual(len(pids), 3)
+        code, out, err = self.cli("close", str(self.session))
+        self.assertEqual(code, 0, err)
+        self.assertEqual(self.serve.wait(15), 0)
+        for pid in pids:
+            self.assertTrue(gone(pid), f"descendant {pid} survived close")
+        self.assertIsNone(sentinel.poll(), "an unrelated process must survive")
+
+
+if __name__ == "__main__":
+    unittest.main()