Curated Formatv0.17.0

Animal Conversations

Send your coding agent a video link or local clip. It prepares the dialogue for your approval, then turns it into a talking Dog-and-Bunny video with the included characters and backgrounds.

By Shaz · Updated September 2026

Before you start

Services & costs

0 media APIs · $0 provider cost

  • InputConversation clipSupported link or local file
    Used forThe complete performance and final soundtrack
    NeedRequired
    CostYour supplied clip
  • ToolsLocal runtimenode · npm · ffmpeg · ffprobe · sharp
    Used forValidation, rendering, audio muxing, and inspection
    NeedIncluded workflow
    CostFree

You provide the clip. The agent extracts the audio and writes the dialogue timing. If a link can’t be downloaded, send the local file instead. Source media stays local and private.

No AI image or video credits needed, and no dedicated GPU. Local transcription uses Python 3.12 and a one-time download of about 486 MB. Your coding agent may have its own fees or usage limits.

Included assets

The cast, backgrounds, and camera grammar.

2 characters · 5 backgrounds · 3 cameras

Complete character poses

Idle · talking · blink

Cat complete colored idle pose

Cat

3 checksum-bound PNG poses

Bunny complete colored idle pose

Bunny

3 checksum-bound PNG poses

Background system

Choose one packaged scene without changing the renderer

Living Room packaged conversation background

Living Room

default supplied conversation room

Three camera angles included

Two-shot · Cat close-up · Bunny close-up

Examples

Finished Conversations.

These examples were made with v0.15.1. The current download is v0.17.0, with multi-platform social distribution, easier setup, and a guided approval-to-export workflow.

The assembly line

Clip → Dialogue draft → Your approval → Render → Review & export

1. Send a clip

Free

Send a supported link or local file. The agent extracts the full soundtrack locally; blocked links need a local file instead.

2. Agent drafts

Free

The agent writes the dialogue, works out the timing, and proposes who says each line. Uncertain words and reactions stay visible.

3. You approve

Free

Check the words, Dog/Bunny assignments, and background. Tell the agent what to change, then approve the complete plan.

Waits for your approval

4. Render

Free

Builds one 1080 × 1920 conversation with the supplied audio.

5. Review & export

Free

Required technical and playback checks must pass before the agent exports the video, review summary, and checksums.

Waits for your review

What the coding agent runs

node runner.mjs doctor
npm ci
npm test
node runner.mjs smoke --run=<fresh-smoke-id>
node runner.mjs setup-intake  # one-time, with your permission
node runner.mjs intake --run=<id> --source=<link-or-local-file>
node runner.mjs status --run=<id> --json
# The agent follows SKILL.md for drafting, your approval, and playback review.
node runner.mjs run --run=<id>

02 · Finished example · v0.15.1

Watch the final conversation.

Open proof report
00:00 / 00:31
Open finished ad

03 · Final evaluation

How your finished video is checked.

Open quality.json
17Required technical checks
0Provider calls at runtime
3Approved camera angles

Evidence stays explicit. The agent proposes the words and characters; you approve them. Transcription never approves casting, and this Repo never claims automatic diarization. The examples are earlier-version proof, not a new v0.17.0 review.

The playback review checks 14 thingsCharacter, speaker, caption, camera, motion, and audio evidence
  1. 01cat and bunny remain complete, colored, recognizable, and correctly framed
  2. 02both characters visibly face inward toward each other in every two-shot
  3. 03two-shot character silhouettes remain clearly separated without touching or overlap
  4. 04the bunny-only camera faces right, matching the supplied reference
  5. 05the approved visible speaker receives varied mouth motion, visible sustained pauses close naturally, and a listener remains still; auditory timing and performance judgments are separately scored under audio-performance-sync
  6. 06three-frame blinks look naturally irregular, favor conversational boundaries, and do not repeatedly synchronize the two characters
  7. 07neutral talking remains vertically still and any jump is brief, intentional, and attached to an explicit emphasis event
  8. 08the user has seen and approved the generated timed role sheet containing every exact time range, spoken line, named nonverbal vocalization, silence, character assignment, caption owner, and overlap; automated transcription, diarization, captions, and cameras may propose but never approve roles
  9. 09mentor, lead, questioner, and foil are episode-specific roles taken only from the user-approved complete script; when local diarization represents distinct real speakers, a stable detected voice never jumps characters, while a user-approved character performance can override an inadequate anonymous cluster map
  10. 10camera cuts follow the supplied three-angle grammar
  11. 11captions progress in readable one-to-three-word chunks, stay in the lower bottom-third lane below character faces and above the episode label without a background panel, and stay associated with the approved speaker; auditory synchronization is separately scored under audio-performance-sync
  12. 12technical checks confirm the original render-audio identity, complete duration, AAC audio stream, and non-silent level; metadata does not establish intelligibility or perceived synchronization
  13. 13intelligibility is reviewed when sound is directly perceptible and otherwise explicitly left unscored with a reason and disclosure
  14. 14direct audiovisual review confirms audible speech and captions remain synchronized, slow sustained sounds do not flap at a fixed rate, and elongated phrases and trailing words stay with the same speaker through the last audible word without swallowing the next character's first audible word; unavailable auditory perception remains explicitly unscored

Required technical and visual playback checks must pass before export. Unavailable sound judgments may be left unscored only where the policy permits, with a reason. Missing required checks still block completion.

04 · Everything included

Repo files

Open any file to read its actual contents.

Download exact Repo
Agent instructionsSKILL.md
---
name: animal-conversations
description: Turn a supported accessible video link or local media file into an explicitly approved Dog/Bunny video using local transcription, the packaged engine, actual playback review, and verified export. The agent authors the dialogue timeline; user-authored timestamps are optional.
---

# Animal Conversations

Read `KIT-MANIFEST.json` first and report its exact `formatVersion`. This is the canonical agent workflow. Use `runner.mjs` as the episode entry point and `runtime/render.mjs` as the only renderer. Never rebuild the engine, weaken a gate, introduce image/video/voice providers, or invent a second workflow.

The blue Dog uses legacy runtime ID `cat`; the pink Bunny uses `bunny`. Either can be mentor, questioner, lead, or foil. Their performance comes from the user's approved complete script—not a fixed archetype, camera, or anonymous voice cluster.

## 1. Check setup and existing progress

Node >=20.9.0 is the initial prerequisite. Run `node runner.mjs doctor` before installation; report missing requirements together without silently repairing global installations. `FFMPEG`, `FFPROBE`, and `PYTHON` select executable paths, not shell commands.

Install kit-local dependencies with `npm ci`, then run `npm test`, the doctor again, and `node runner.mjs smoke --run=<fresh-smoke-id>`. Python and Cargo are unnecessary for rendering prepared episodes from the packaged poses. The smoke proof has synthetic mechanics-only approval; it cannot finalize real content.

For an existing episode, begin with:

```sh
node runner.mjs status --run=<id> --json
```

Follow the actual checkpoint and `nextAction.owner`: the **agent** drafts/diagnoses/reviews, the **user** approves or supplies inaccessible media, and the **operator** resolves unavailable tools/access. Do not turn agent-owned work into a request for user-authored timestamps. Status is read-only; a saved phase label does not prove artifacts remain valid.

## 2. Prepare the clip

Obtain a supported, accessible source link or local media file the user has permission to remix. A background preference is optional; propose a packaged background for approval if needed. No user-authored timestamps are required, though supplied transcripts/timelines may help draft.

Local transcription requires Python **3.12** and explicit one-time setup:

```sh
node runner.mjs setup-intake
node runner.mjs intake --run=<fresh-id> --source=<supported-link-or-absolute-local-file>
```

Before setup, explain that it installs locked dependencies into `.intake-env/` and approximately **486 MB** of pinned `small.en` weights into `.intake-models/`. Defaults: CPU/int8, English word timestamps, beam size 5, no VAD, no previous-text conditioning. Prepared local-file intake then works offline. Ordinary intake never downloads missing dependencies or model files. Do not promise non-English accuracy or perfect emotional/overlapping speech recognition.

If retrieval fails, ask for a downloaded file. Never obtain cookies, credentials, or paid services automatically; not every Instagram/TikTok/YouTube link works. Resume the existing intake after setup or with the supplied fallback:

```sh
node runner.mjs run --run=<id> --source=/absolute/downloaded-video.mp4
```

Original media stays private. `user-audio.wav` is the full-resolution/full-duration soundtrack; the separate 16 kHz mono WAV is only for ASR. Use measured audio duration, not video-frame duration. Transcript times start at decoded-audio zero; evidence records any source-container offset.

## 3. The agent authors the complete draft

At `needs-script-draft`, inspect the source and uncertain `transcript.json`, then author a candidate following `input-contract.json`. Do not ask the user for timing JSON, copy sample timings into real content, or mistake ASR for approval.

For a `speaker=both` draft, include the actual simultaneous-speech basis in `overlapEvidence` and identify `captionSpeaker` when there are captioned words. Use `captionSpeaker=both` only for a synchronized chorus. A draft evidence note does not confirm or approve the review; section 4 still applies.

Import into the **same intake run**, without calling init again:

```sh
node runner.mjs review-script --run=<id> --input=/absolute/draft.json
```

Validation happens before canonical input is replaced. The generated `script-review.html`, `timed-role-sheet.md`, and exact WAV clips show wording, timing, characters, caption ownership, vocalizations, overlap, cameras, emphasis, background, title, episode label, evidence, and uncertainty. The page works through `file://` without a server.

An operator with prepared real audio and a candidate may instead use the advanced fresh-run path: `init --run=<fresh-id> --audio=/absolute/audio.wav --input=/absolute/draft.json`. Both initialization paths reject run collisions.

## 4. Show an honest review before approval

Fill the generated `script-review.json` beat evidence from actual observations: `confirmedSpeaker`, `evidence`, `evidenceNote`, `transcriptionEvidence`, `timingEvidence`, and `uncertainty`. Valid evidence methods are `direct-audio-review`, `local-audio-analysis`, `user-provided-label`, `reference-video`, or `silence`. Do not claim a channel you did not use. Do not manually set approval flags or compute approval hashes.

Only set `diarization.performed`, `detectedVoices`, and stable `voiceCharacterMap` entries when genuine diarization supplies distinct voices. Bundled ASR does **not** perform diarization. One person performing multiple characters is not proof of one voice/one character. Every `both` beat requires `overlapConfirmed: true` and specific simultaneous-speech evidence; it never means uncertainty.

After evidence edits, regenerate **before showing the page**:

```sh
node runner.mjs review-script --run=<id>
```

Show the actual playable page and explain uncertainty plainly. Ask the user to approve the entire displayed plan or describe corrections; the agent handles corrections and timing. Do not hide omitted reactions or turn uncertain words into confident claims.

Only after explicit approval of that displayed review:

```sh
node runner.mjs approve-script --run=<id> --review-id=<displayed-id> --approved-by=<user-name> --note="Actual complete-review confirmation"
node runner.mjs run --run=<id>
```

The runtime computes hashes. Changed review evidence may require a new displayed review ID. Changed audio or creative choices invalidate approval. For changed choices in a progressed run, import with `review-script --input=/absolute/revised.json --new-revision`; previous outputs/receipts are preserved. A different source/audio uses fresh intake, not in-place file replacement. Unchanged review regeneration is idempotent.

Importing changed draft content regenerates all beat evidence. Preserve the previous input/review first, carry forward only observations still valid for unchanged content and audio, then record corrections and regenerate the review. Never copy approval fields, fingerprints, review IDs, or receipts into the new review.

## 5. Render and repair only observed failures

`run` advances eligible deterministic work until completion or a required action. There are **three technical cycles per approved content-and-audio revision, including the first**: render if needed, then inspect. Interrupted cycles resume under the existing attempt ID; read-only diagnosis consumes no attempt.

**Reapproving unchanged content cannot reset that budget.** Approval timestamps and notes do not define a revision. A note claiming a fix is not a repair. Diagnose the actual failure and require a relevant supported correction before retrying. Changed approved choices need review/approval again. Never edit the renderer, weaken policy, delete history, or change correct timing just to pass a gate.

Missing approval/access, unavailable required perception, exhausted retries, or package defects produce a specific blocker and resumable checkpoint—not a completion claim. State what is missing and who must act. Do not retry unchanged failures indefinitely.

## 6. Review the actual video

At `needs-playback-review`, open the current `final.mp4` and perform both named passes in `quality.json`:

1. `uninterrupted-playback`: watch the full video, with audio when directly perceptible; otherwise uninterrupted visual playback plus technical audio checks.
2. `caption-camera-character-inspection`: inspect framing, orientation/separation, mouth/blink/emphasis motion, captions, and cameras against the approved plan.

Read every current `blindReview.criterionRules` entry and its matching criterion text. Author a playback record containing:

- `schemaVersion: 2`, current `mp4Sha256`, **`renderIdentityHash`**, `qualityPolicyHash`, and `rubricVersion`;
- `reviewer`, visual/audio `perception` modes and bases;
- both named `passes` with actual completion and observation notes;
- every criterion exactly once with `id`, `status`, and `note`;
- explicit `disclosures`.

Read current identities from `quality-report.json` and the rubric; runtime verification checks them against the actual media/environment. Do not reuse observations from a different render identity even if its MP4 bytes happen to match.

Only mark assessments performed. A contact sheet, sampled frames, playback counter, or FFprobe result is not by itself uninterrupted playback or proof of perceived sound. Required **direct visual**, evidence, and technical-audio checks must pass. Only policy-permitted **perceptual-audio** criteria may be `unscored` when direct sound perception is unavailable, with a reason and disclosure. Disclosure never excuses a required failure.

```sh
node runner.mjs record-playback-review --run=<id> --input=/absolute/playback-record.json
node runner.mjs run --run=<id>
```

Records are attestations; software cannot authenticate chat, prove perception, or prevent an unrestricted agent editing files. Never manufacture review results to pass validation.

## 7. Verify export and show the video

After current technical/playback evidence passes, `run` finalizes and verifies an export, defaulting to `outputs/<run-id>/`. Use `export --run=<id> --output=/absolute/output-directory` for another destination. Unrelated or changed existing destinations are refused; choose a new folder rather than deleting user files.

The public bundle contains the MP4, local viewing page, sanitized review summary/contact sheet, and checksums. Source/review WAVs are excluded unless the user wants `--include-review-media`. Raw downloader metadata, original source video, credentials, machine paths, and original hash-bound evidence stay private; presentation copies do not rewrite canonical records.

Run `status --run=<id> --json`, then show the actual exported MP4 with review status and permitted limitations. `complete` means the current finalized export verifies—not that the user received or watched it. Missing or changed exported files invalidate completion.

`upgrade-run --run=<old-id> --new-run=<fresh-id>` preserves legacy history but requires a newly displayed expanded review, fresh approval, and new render/quality evidence. Never manufacture upgraded approval or playback receipts.

## 8. Multi-Platform Social Distribution (Optional)

When an episode is rendered and approved, the agent can distribute it across YouTube Shorts, Instagram Reels, TikTok, and X via the packaged `runtime/publish.mjs` CLI or connected Buffer MCP tools:

1. **Author platform-tailored copy in `inputs/distribution.json`:**
   - **YouTube Shorts:** Punchy title (≤100 chars), categoryId (`15` for Pets & Animals), duration ≤60s.
   - **Twitter/X:** Strong hook with viral hashtags (≤280 chars total).
   - **Instagram Reels:** Engaging caption with tags, strictly vertical (9:16).
   - **TikTok:** Engaging caption with trending tags (≤2200 chars).

2. **Probe the release before publishing (`--dry-run`):**
   ```sh
   node runtime/publish.mjs --dry-run inputs/distribution.json <path-to-mp4>
   ```

3. **Obtain explicit user approval:**
   - Present the copy and selected platforms to the user.
   - **Never dispatch live to social media without explicit human sign-off.**

4. **Live dispatch and receipts:**
   - If `BUFFER_API_KEY` is provided, dispatch live via Buffer MCP or CLI.
   - If unconfigured, the runtime validates assets and logs a graceful `unconfigured_environment` receipt.
   - Durable receipts are saved to `<video-path>.distribution.json`.

## Behavioral Guardrails
- **Human in the Loop:** Never publish without explicit confirmation from the user.
- **Probe First:** Always validate media format and character limits before dispatching.
- **Zero Token Leakage:** Never print or store API keys in repo files or receipts.

## Fixed animation and authoring rules

- Start the timeline at zero and finish at measured audio duration. Schema 1 retains 20 ms boundary tolerance; new drafts use exactly shared boundaries. The review exposes discrepancies.
- Each active beat contains words in `caption` **or** a named nonverbal `vocalization`, never both. Silence uses `speaker=none`. Separate audible reactions. Keep delayed questions, internal pauses, elongated delivery, and trailing words with the real speaker without swallowing the next speaker's first word.
- Cameras are only `two-shot`, `cat-close`, or `bunny-close`. Captioned overlap sets `captionSpeaker=cat`, `bunny`, or a genuinely synchronized `both`.
- Neutral talking changes mouths only. Optional `bounceAt` offsets request sparse emphasis: normally one bounce, two only for an intentionally frantic line. No manual blink/lip-sync/viseme cues.
- The official renderer measures the audio envelope, holds slow sounds, closes sustained pauses, suppresses mouth jitter, and supplies independent deterministic blinks. Do not replace those systems.
- Keep a complete line in `caption`; the renderer creates progressive one-to-three-word chunks below faces and above the label. Vocalizations are review/animation evidence, not caption text. Word-aligned caption rendering is not added here.
- Animation uses packaged assets and local computation, with no AI image/video/voice API calls or dedicated GPU requirement. This does not promise free coding-agent access, internet, or hardware.

Use `npm run convert` only for an operator-supplied Harmony project; normal episodes keep the existing PNGs. Read `PACKAGING.md` for release archives and acceptance boundaries. A built ZIP is not fresh-agent or supported-platform certification.
Human setupREADME.md
# Animal Conversations

Give a coding agent a supported, accessible video link or downloaded clip. It drafts the dialogue, shows you a playable plan to approve, then renders, reviews, and delivers a 1080×1920 talking-animal video.

You do **not** need to write timestamps. A transcript/timeline is optional if you already have one. A regular chat window without local file and command access is not enough.

## What's included

The blue Dog and pink Bunny, five backgrounds, three camera angles, progressive captions, audio-driven mouths, deterministic blinks, and sparse emphasis motion. The existing local engine is preserved: no AI image/video/voice API calls and no dedicated GPU required. Your coding agent may have its own plan, limits, or costs.

The Dog's internal ID remains `cat` for compatibility. Neither character has a fixed narrative role; your approved script decides who performs each part.

## First-time setup

Node >=20.9.0 is the first prerequisite. The doctor runs before dependencies are installed and reports missing tools together without changing global installations.

```sh
node runner.mjs doctor
npm ci
npm test
node runner.mjs doctor
node runner.mjs smoke --run=first-smoke
```

Normal rendering needs Node, FFmpeg/FFprobe, and kit-local Sharp—not Python or Cargo. `FFMPEG`, `FFPROBE`, and `PYTHON` optionally select executable paths. `check` is another name for the read-only doctor.

Optional local transcription uses Python 3.12. Explicit setup installs locked dependencies into `.intake-env/` and about 486 MB of pinned `small.en` model files into `.intake-models/`:

```sh
node runner.mjs setup-intake
```

It runs on CPU, is English-first, and processes local files offline after setup. Missing tools or weights return `setup-required`; intake does not silently download them. Setup targets Apple Silicon macOS and Linux x64, including Linux tools inside WSL. Native Windows is not supported. Platform targets are not a substitute for the documented release acceptance results.

## Make an episode

Your coding agent follows [SKILL.md](SKILL.md), the single complete workflow. These are its checkpoints, not technical work you must do:

| Checkpoint | What happens |
| --- | --- |
| Intake | `intake --run=<fresh-id> --source=<link-or-local-file>` preserves the original, extracts full-quality audio, and creates an uncertain transcript. |
| Draft | The agent writes dialogue, character assignments, timing, and creative choices, then imports with `review-script --run=<id> --input=/absolute/draft.json`. |
| Review | Open `agent-runs/<id>/script-review.html`. Hear exact clips, check the full plan and uncertainty, and ask the agent for corrections. No server is needed. |
| Approve | After you approve the displayed version, the agent records its review ID, your name, and your actual confirmation using `approve-script`. |
| Produce | `run --run=<id>` renders and inspects, then stops for actual playback review. |
| Deliver | Once required reviews pass, `run` finalizes and verifies the export, and the agent shows you the video. |

All commands use `node runner.mjs` as their prefix. [SKILL.md](SKILL.md) gives the exact approval/playback arguments, required observation fields, and current media/render/policy identities. For existing work, start with `node runner.mjs status --run=<id> --json`.

If a link is blocked or needs login, provide a downloaded file: `node runner.mjs run --run=<id> --source=/absolute/downloaded-video.mp4`. The kit does not automatically access cookies or credentials, and it cannot retrieve every link.

Transcription is a draft, not proof of who spoke. Overlaps, emotional sounds, and elongated words need particular care. The agent prepares and corrects timing; you approve the content. Use a clip you have permission to remix.

## Changes and honest completion

- Changed words, timing, roles, background, cameras, emphasis, title, or audio invalidate the relevant approval/results. For changed creative choices in a progressed run, `review-script --input=... --new-revision` preserves prior outputs. New source audio uses fresh intake.
- There are **three render/inspection cycles per content-and-audio revision**, including the first. Reapproving unchanged content does not reset the budget. Interrupted work resumes; a note alone cannot unlock an unchanged failed retry.
- Required technical and direct visual review must pass. Only policy-permitted auditory judgments may be explicitly unscored when sound cannot be perceived. Disclosure is not permission to skip required checks.
- `complete` means the current finalized export verifies—not that a file merely rendered, or that you watched it. Missing access/approval, exhausted retries, and required review limitations remain blockers.

An advanced agent with prepared real audio and an input JSON may use `init --run=<fresh-id> --audio=/absolute/audio.wav --input=/absolute/draft.json`. Displayed review and explicit approval still apply. `upgrade-run --run=<old-id> --new-run=<fresh-id>` preserves old work and requires a new expanded review, approval, render, and quality evidence.

## Your files

Private work stays in `agent-runs/<id>/`. Full-quality `user-audio.wav` is separate from the lower-resolution ASR derivative; the derivative never replaces the soundtrack.

The verified public export defaults to `outputs/<id>/`: `final.mp4`, a viewing page, sanitized review evidence, and checksums. Source video, downloader metadata, credentials, and machine paths stay private. Review WAVs are omitted unless requested with `--include-review-media`. Export refuses unrelated existing folders; changed exported files invalidate completion.

The intentionally distributable examples are `goldens/we-listen-dont-judge.mp4` and `examples/i-made-a-mistake/evidence/final.mp4`, with their approved soundtracks. The sine-tone smoke test is only a mechanics proof, never user approval or playback review of a real episode.

## Developer and converter checks

`npm test` covers JavaScript runtime/converter logic and packaged assets. `npm run test:converter` adds Python converter tests and Cargo; these are unnecessary for normal episodes. [PACKAGING.md](PACKAGING.md) describes exact-file release selection and the explicit real-ZIP test profile. Archive integrity does not certify fresh-agent or supported-platform acceptance.

To rebuild a pose from an operator-supplied Harmony rig:

```sh
npm run convert -- --rig=/absolute/path/CAT_LOOP_1 --manifest=cat-frame1 --mouth=2 --eyes=1 --output=/absolute/path/cat.png
```

The optional converter uses Cargo, Node, and Sharp without Harmony. See `converter/RECOVERY.md`. Normal episodes keep the shipped assets and renderer.
Required inputsinput-contract.json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "Animal Conversations episode input",
  "type": "object",
  "additionalProperties": false,
  "required": ["schemaVersion", "title", "episodeLabel", "audioFile", "background", "timeline"],
  "properties": {
    "schemaVersion": {"const": 1},
    "title": {"type": "string", "minLength": 1, "maxLength": 80},
    "episodeLabel": {"type": "string", "minLength": 1, "maxLength": 64},
    "audioFile": {"type": "string", "description": "Run-relative copy of the user-supplied audio file."},
    "background": {"enum": ["living-room", "backyard", "pool", "street-view", "store-atm"]},
    "timeline": {
      "type": "array",
      "minItems": 1,
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": ["start", "end", "speaker", "camera", "caption"],
        "properties": {
          "start": {"type": "number", "minimum": 0},
          "end": {"type": "number", "exclusiveMinimum": 0},
          "speaker": {"enum": ["cat", "bunny", "both", "none"], "description": "Provisional value until the complete written role script is approved. both means simultaneous speech only, never uncertainty or alternating turns."},
          "camera": {"enum": ["two-shot", "cat-close", "bunny-close"]},
          "caption": {"type": "string", "maxLength": 180, "description": "Complete spoken line for this beat. The renderer automatically presents it as progressive one-to-three-word caption cards."},
          "captionSpeaker": {"enum": ["cat", "bunny", "both"], "description": "Required only when speaker=both and caption contains words. Identifies whose words the caption represents while both performances remain active; use both only for a true synchronized chorus."},
          "vocalization": {"type": "string", "minLength": 1, "maxLength": 120, "description": "Named audible nonverbal performance such as an emotional gasp or shriek. It is approval evidence, not displayed caption text. Split it into its own contiguous beat instead of hiding it inside a spoken line."},
          "overlapEvidence": {"type": "string", "minLength": 1, "description": "Required on a speaker=both draft: describe actual evidence of simultaneous speech, not uncertainty or alternating voices. This note is not approval. After explicit complete-review approval, approve-script replaces it with the separately confirmed review evidenceNote."},
          "bounceAt": {
            "type": "array",
            "description": "Optional explicit emphasis events, expressed as strictly increasing second offsets from this beat's start. Omit for neutral talking, which uses mouth motion only.",
            "maxItems": 2,
            "items": {"type": "number", "minimum": 0}
          }
        }
      }
    }
  },
  "timingRules": [
    "schema-1 beat boundaries retain the existing 20 ms tolerance from zero and between consecutive beats; new drafts use exactly shared boundaries, and the playable review exposes any discrepancy before approval",
    "the final beat ends within 0.08 seconds of measured user-audio duration",
    "every active-speaker beat contains exactly one of spoken caption text or a named nonverbal vocalization; words and nonverbal performance are split into separate contiguous beats",
    "each beat stores the complete spoken line; the renderer, not the operator, splits it into progressive one-to-three-word caption cards",
    "a speaker boundary occurs after the current speaker's last audible word and no later than the next speaker's first audible word; elongated phrases, internal dramatic pauses, and trailing words remain one speaker performance when the delivery slows down but never swallow the next character's words",
    "every beat speaker is explicitly confirmed through direct audio, documented local audio analysis, a user label, a checksum-matched documented reference video, or silence, and the user sees and approves the complete written role script before validation",
    "when genuine diarization supplies stable distinct voices, local audio analysis defines each detected voice ID once in voiceCharacterMap and uses detectedVoices on every diarized beat; a detected voice cannot change characters at a caption or dialogue-turn boundary; transcription-only evidence requires no fabricated voice map",
    "speaker=both is valid only for explicitly confirmed simultaneous speech with documented overlap evidence; uncertainty must stop for review, and alternating voices must be split into contiguous single-speaker beats",
    "every captioned speaker=both beat identifies captionSpeaker as cat, bunny, or both; captionSpeaker is omitted from all other beats",
    "init and review-script generate timed-role-sheet.md with every exact time range, active Dog/Bunny performance, caption owner, spoken line or named vocalization, overlap, and review clip; the user approves that full sheet before rendering",
    "complete-script approval covers audio bytes, title, episode label, background, timing, caption, caption ownership, vocalization, camera, speaker assignment, overlap, and bounce emphasis; changes invalidate approval, while cosmetic review-page changes do not",
    "script-review.html works through file:// and plays the complete soundtrack plus exact per-beat WAV clips at the source sample rate and channel count; its displayed review ID binds creative content, audio, evidence, and uncertainty",
    "approve-script requires the displayed review-id, approved-by, and explicit chat-confirmation note; runtime hashes are computed internally; transcription, timing, casting, diarization, and overlap evidence are separate and never self-approve the episode",
    "approved revision identity is based on semantic content and audio bytes; reapproving unchanged content does not reset its three-attempt budget",
    "neutral talking has mouth motion only; vertical bounce occurs only at an explicit bounceAt emphasis event",
    "bounceAt contains no more than two strictly increasing offsets inside its beat"
  ]
}
Final deliverablesoutput-contract.json
{
  "schemaVersion": 2,
  "video": {
    "container": "mp4",
    "videoCodec": "h264",
    "audioCodec": "aac",
    "width": 1080,
    "height": 1920,
    "fps": 24,
    "durationRule": "within 0.08 seconds of the measured user-audio duration"
  },
  "delivery": {
    "finalVideo": "final.mp4",
    "validationReceipt": ".validation.json",
    "scriptApprovalReceipt": ".script-approval.json",
    "timedRoleSheet": "timed-role-sheet.md",
    "renderReport": "render-report.json",
    "qualityReport": "quality-report.json",
    "playbackReview": "playback-review.json",
    "playableScriptReview": "script-review.html",
    "contactSheet": "contact-sheet.png",
    "manifest": "delivery.json",
    "rule": "finalize writes a current hash-bound delivery.json only after episode-scope user approval, current render identity, every required technical gate, and documented current playback review pass. Fixture approval cannot finalize an episode. Finalization alone is not export completion."
  },
  "evidence": ["input.json", "script-review.json", "script-review.html", "timed-role-sheet.md", ".script-approval.json", ".validation.json", "render-report.json", "quality-report.json", "playback-review.json", "contact-sheet.png", "delivery.json"],
  "export": {
    "defaultDirectory": "outputs/<run-id>",
    "requiredFiles": ["final.mp4", "index.html", "checksums.json", "evidence/review-summary.json", "evidence/contact-sheet.png"],
    "optionalReviewMedia": "--include-review-media copies exact WAVs into review-media/source.wav and review-media/beat-NNN.wav; the static export page references only files that are actually present",
    "privateByDefault": ["original source media", "source/review audio", "raw downloader metadata", "source URLs", "credentials", "absolute machine paths", "free-text source assertions and reviewer notes"],
    "evidenceRule": "Canonical evidence stays unchanged in the private run. The exported summary is explicitly a sanitized presentation, not an edited canonical receipt; it lists canonical evidence hashes and omissions.",
    "completionRule": "Complete requires current finalization and a verified export containing every required declared file. Missing, extra, or modified exported files invalidate completion. Export does not assert that the user received or watched the video; the host agent must show the actual result."
  }
}
Services and toolsrequirements.json
{
  "localTools": [
    "node",
    "npm",
    "ffmpeg",
    "ffprobe",
    "sharp"
  ],
  "optionalAssetRebuildTools": [
    "cargo",
    "python3"
  ],
  "providers": [
    {
      "name": "Social Publisher (Buffer MCP or API)",
      "environmentVariables": [
        "BUFFER_API_KEY"
      ],
      "optional": true,
      "purpose": "Simultaneous headless publishing to YouTube Shorts, Instagram Reels, TikTok, and X."
    }
  ],
  "environmentVariables": [
    "FFMPEG",
    "FFPROBE",
    "PYTHON"
  ],
  "notes": [
    "Node >=20.9.0 is the initial prerequisite. Run node runner.mjs doctor before installation; it reports missing tools, pinned Sharp, FFmpeg capabilities, and packaged-asset checksums without changing global installations. Install kit-local dependencies explicitly with npm ci.",
    "FFMPEG, FFPROBE, and PYTHON select executable paths, not shell command strings. Node subprocesses use the running Node executable.",
    "npm test includes JavaScript/runtime/packaged-asset checks without Python or Cargo. Optional converter tests need Python and Cargo; archive verification is an explicit zip/unzip profile documented in PACKAGING.md.",
    "The user provides a supported accessible source link or local media file; user-authored timestamps are optional. The host agent drafts and corrects the complete dialogue from source/transcription evidence.",
    "Optional setup creates a kit-local Python 3.12 environment and verifies about 486 MB of pinned model files. Ordinary intake returns setup-required instead of downloading missing dependencies or weights. Native Windows is deferred.",
    "Keep original source media and full-duration full-resolution render audio separate from the 16 kHz mono ASR derivative. URL failures offer a user-supplied local file; no automatic cookies, credentials, or paid services.",
    "The generated script-review.html and timed-role-sheet.md must be shown to the user before approval. They include every exact time range, spoken line, named nonverbal vocalization, silence, Dog/Bunny assignment, caption owner, and overlap, plus cameras, emphasis, background, title, episode label, exact WAV playback, evidence, and uncertainty. Transcription, timing evidence, casting evidence, and genuine diarization are distinct; automated transcription never approves roles.",
    "speaker=both requires documented simultaneous-speech evidence and explicit caption ownership; uncertainty must stop for review, and alternating voices must be split into single-speaker beats rather than animated as overlap.",
    "At each speaker handoff, retain the current speaker through the last audible word and hand over no later than the next speaker's first audible word; elongated phrases, internal dramatic pauses, and trailing words remain with their performer but must never absorb the next character's words.",
    "Only when genuine diarization identifies distinct stable people, each stable detected voice ID maps to one user-confirmed character throughout the episode. Never infer a character from a fixed mentor, lead, questioner, or foil archetype, or recast at a caption boundary. A single performer playing multiple characters cannot be forced into one real person per character; user-approved performance and honest source evidence remain authoritative.",
    "Explicit approval records the displayed review ID, actual approver, and confirmation. Changed approved choices or audio require fresh approval. Three technical cycles are allowed per content-and-audio revision; unchanged reapproval and explanatory notes do not reset retries.",
    "Required technical checks and direct visual playback review must pass. Permitted unavailable auditory judgments remain explicitly unscored, never silently passed. Review receipts bind the current MP4, render identity, and quality policy; recorded attestations cannot prove perception.",
    "Completion requires a verified export and host-agent handoff showing the actual video. Private source data and original evidence stay in the run; sanitized presentation never rewrites canonical receipts. A modified exported file invalidates completion.",
    "No AI speech, music, image, or video API call occurs in this runtime. Local rendering and CPU transcription need no dedicated GPU; the host coding agent may have its own subscription or usage limits.",
    "The sine-tone smoke proof exercises mechanics only and cannot finalize arbitrary episode content. Cargo is used only for rebuilding the packaged poses or optional converter/release proofs."
  ],
  "optionalIntakeTools": [
    "python3.12"
  ],
  "optionalReleaseTools": [
    "zip",
    "unzip"
  ],
  "localIntake": {
    "setupCommand": "node runner.mjs setup-intake",
    "dependencyLock": "scripts/intake-requirements.lock",
    "modelManifest": "scripts/intake-model.json",
    "language": "en",
    "device": "cpu",
    "computeType": "int8",
    "platformTargets": [
      "macOS ARM64",
      "Linux x64",
      "WSL with Linux binaries"
    ],
    "nativeWindowsSupported": false,
    "execution": "Prepared local-file transcription is offline; supported URL retrieval needs network access."
  }
}
Composition rulescomposition-contract.json
{
  "schemaVersion": 1,
  "fixed": [
    "1080x1920 vertical canvas at 24 fps",
    "one official renderer at runtime/render.mjs",
    "two supplied Toon Boom-derived characters: cat and bunny",
    "the blue dog uses the legacy runtime ID cat and the pink bunny uses bunny; narrative roles are not fixed by the Format and come only from the approved episode script",
    "idle, mouth-open, and blink pose substitutions for each character",
    "exactly three approved camera IDs: two-shot, cat-close, and bunny-close",
    "the bunny faces right in two-shot and bunny-close layouts while the cat faces left in two-shot and cat-close layouts, matching the supplied three-angle reference",
    "two-shot character bounds preserve at least 120 pixels of clear conversational space and never overlap",
    "mouth substitution only for the active speaker; both characters move only when speaker=both carries explicit confirmed simultaneous-speech evidence; captionSpeaker preserves caption ownership during that overlap; uncertainty never animates two mouths, and alternating voices use separate contiguous beats; sustained low-energy pauses hold the active mouth closed without sub-three-frame jitter",
    "every exact time range, spoken line, nonverbal vocal event, silence, character role, caption owner, and overlap is shown in one generated timed-role-sheet.md, explicitly approved, and checksum-bound to the user audio before rendering",
    "when genuine diarization supplies stable distinct voices, documented local audio analysis binds stable detected voice IDs to one character for the complete episode; caption and dialogue-turn boundaries never recast a voice; ASR-only evidence does not pretend to identify speakers",
    "static script-review.html plays full-quality local WAV review audio through file:// and displays title, episode label, background, wording, timing, roles, vocalizations, overlap, cameras, emphasis, and explicit evidence or uncertainty before the user approves its review ID",
    "neutral talking uses mouth substitutions and independent deterministic three-frame blinks without vertical movement; blink cadence is irregular, favors nearby dialogue boundaries, skips offscreen events, and avoids mechanical synchronization",
    "vertical bounce occurs only at an explicit per-beat bounceAt emphasis event, with at most two cues",
    "speaker-colored, outlined captions in a dedicated lower lane inside the bottom third, below character faces and above the episode label, with no background panel and one-to-three-word progression rather than full-sentence cards",
    "persistent episode label near the bottom safe area",
    "user audio is copied locally and muxed into the final video"
  ],
  "replaceable": ["user audio", "timed captions", "named nonverbal vocalizations", "explicit speaker assignment", "caption ownership for confirmed simultaneous performance", "episode-specific narrative roles such as mentor, lead, questioner, or foil", "approved camera selection per beat", "optional key-segment bounce cues", "episode title and label", "one of five packaged backgrounds"],
  "outOfScope": ["voice generation", "automatic speaker identity or casting approval from transcription", "freeform camera coordinates", "character replacement", "timeline editor", "automatic rights clearance"],
  "rendererInvariant": "Preview/proof/export frames are produced only by runtime/render.mjs from the validated complete episode input."
}
Fixed vs customizablecontent-boundary.json
{
  "schemaVersion": 1,
  "packagedMechanics": [
    "Harmony-free Toon Boom pose conversion",
    "complete-layer character compositing and checksum receipts",
    "three-angle conversation renderer",
    "explicit local extraction/transcription setup, agent-authored draft, content-and-audio-bound approval, speaker mouth switching, blinks, reactions, captions, audio muxing, technical and playback review, and verified export"
  ],
  "packagedReplaceableContent": ["five supplied backgrounds", "cat and bunny idle, talking, and blink poses"],
  "episodeInputs": ["supported accessible link or local media the user is authorized to use", "agent-authored complete role script with named nonverbal events and silence; optional user timestamps accepted but never required", "explicit user approval of the whole playable role script", "packaged background ID", "episode label"],
  "providerCalls": [],
  "sourceRigPolicy": "The source Harmony projects are operator inputs to the converter and are not redistributed by this Repo.",
  "audioPolicy": "The Repo never generates or uploads dialogue. It preserves acquired media privately, extracts a full-channel full-rate soundtrack and a separate 16 kHz mono ASR derivative, creates exact local review WAVs, and encodes the soundtrack as AAC in the final MP4. Standalone source/review media is omitted from export by default."
}
Asset manifestassets.json
{
  "schemaVersion": 1,
  "backgrounds": [
    {"id": "living-room", "path": "assets/backgrounds/bg7and8.png", "sha256": "66f20b4e90c39117df84ac80102e1038190a7b1fcf912fc7e03fc1919de10477", "role": "default supplied conversation room"},
    {"id": "backyard", "path": "assets/backgrounds/backyard.png", "sha256": "21800440fd5c6feb845ace4bf94931fda27885c69d6696929f881e9a87c64c24", "role": "supplied outdoor option"},
    {"id": "pool", "path": "assets/backgrounds/pool.png", "sha256": "7dc708d3375e03963e5d8c89f5be1621143b2a8aee73a5e96b10b82c99a5cf88", "role": "supplied pool option"},
    {"id": "street-view", "path": "assets/backgrounds/streetview.png", "sha256": "6c3acf8f1c6c2749053d9ae039fc282f8d8e2c80a4a7395e77e86f762282ecbd", "role": "supplied street option"},
    {"id": "store-atm", "path": "assets/backgrounds/store-atm.png", "sha256": "288c0fc618dbeda8217556d3c53f63e673fd18c29dbb8e1eac0ccc20aeb18cb0", "role": "supplied store option"}
  ],
  "characters": [
    {
      "id": "cat",
      "source": "Harmony CAT_LOOP_1 converted locally by converter/convert_pose.mjs",
      "poses": [
        {"id": "idle", "path": "assets/characters/cat/idle.png", "sha256": "0bfa7329c2febbbbac01c58a518132a984320286e9ee509b71a51fddcff4f995"},
        {"id": "mouth-open", "path": "assets/characters/cat/mouth-open.png", "sha256": "9bf36cb446b2ff40de241d2e9de53f527ef8fb9ef3e6c5b493401d56e3a7550f"},
        {"id": "blink", "path": "assets/characters/cat/blink.png", "sha256": "94fdac22e1facaab07bfae2e161505d276815dfaba53e433e0886635e206e644"}
      ]
    },
    {
      "id": "bunny",
      "source": "Harmony BUN_LOOP_1 converted locally by converter/convert_pose.mjs",
      "poses": [
        {"id": "idle", "path": "assets/characters/bunny/idle.png", "sha256": "3bc06240c29e92049e8e59bcb31e86c533af3b0b753f2b69f0945bef97ac45f6"},
        {"id": "mouth-open", "path": "assets/characters/bunny/mouth-open.png", "sha256": "9a2235bec6f48e5e0d93e01a3c40b3c4037778eeb7b27a27402749291e05e0a4"},
        {"id": "blink", "path": "assets/characters/bunny/blink.png", "sha256": "0ef96eb62f358fbb4f2c375fee31ce532b6f105742a55dc5722c8a520e88b319"}
      ]
    }
  ],
  "provenance": {
    "backgrounds": "supplied by the user for this format",
    "characterSourceRigs": "supplied Toon Boom Harmony projects; source rigs are not redistributed in this kit",
    "converterDecoder": "c-probably-archived20260718/tvg at 9d09471e, MIT; see converter/source/README.md"
  }
}
Quality rubricquality.json
{
  "schemaVersion": 3,
  "rubricVersion": "1.1.0",
  "automatic": {
    "width": 1080,
    "height": 1920,
    "fps": 24,
    "durationToleranceSeconds": 0.08,
    "videoCodec": "h264",
    "audioCodec": "aac",
    "audioRequired": true,
    "minimumMeanVolumeDb": -55,
    "maximumAttempts": 3
  },
  "requiredTechnicalGates": ["width", "height", "fps", "duration", "videoCodec", "audioCodec", "audibleAudio", "approvedCamerasOnly", "completeScriptApproved", "nonverbalEventsApproved", "stableVoiceCastingConfirmed", "simultaneousSpeechConfirmed", "renderMatchesInput", "captionsPresent", "speechActivityAnalyzed", "mouthAnimationAnalyzed", "pauseClosuresStable"],
  "technicalGates": [
    {"id": "dimensions", "threshold": "exactly 1080x1920", "measurementPath": "measured.width,height"},
    {"id": "fps", "threshold": "24 fps +/- 0.01", "measurementPath": "measured.fps"},
    {"id": "duration", "threshold": "user audio duration +/- 0.08 sec", "measurementPath": "measured.durationSeconds"},
    {"id": "codecs", "threshold": "H.264 video and AAC audio", "measurementPath": "measured.videoCodec,audioCodec"},
    {"id": "audibleAudio", "threshold": "mean volume above -55 dB", "measurementPath": "measured.meanVolumeDb"},
    {"id": "cameraGrammar", "threshold": "only two-shot, cat-close, bunny-close", "measurementPath": "measured.camerasUsed"},
    {"id": "completeScriptApproval", "threshold": "the generated timed role sheet covers every exact time range, spoken, nonverbal, silent, and overlapping beat and the approval receipt is bound to current user audio", "measurementPath": "gates.completeScriptApproved,nonverbalEventsApproved"},
    {"id": "stableVoiceCasting", "threshold": "every locally analyzed beat is bound through one stable voice-to-character map", "measurementPath": "gates.stableVoiceCastingConfirmed"},
    {"id": "simultaneousSpeech", "threshold": "every speaker=both beat has explicit confirmed overlap evidence and every captioned overlap has an explicit caption owner", "measurementPath": "gates.simultaneousSpeechConfirmed"},
    {"id": "renderInputParity", "threshold": "render report input hash equals inspected input hash", "measurementPath": "gates.renderMatchesInput"},
    {"id": "captions", "threshold": "at least one captioned beat", "measurementPath": "measured.captionedBeatCount"},
    {"id": "speechActivity", "threshold": "audio-derived activity exists and every interior mouth closure lasts at least three frames", "measurementPath": "gates.speechActivityAnalyzed,pauseClosuresStable"},
    {"id": "mouthCadence", "threshold": "mouth pose changes come from the local audio envelope rather than a fixed frame clock", "measurementPath": "gates.mouthAnimationAnalyzed"}
  ],
  "blindReview": {
    "watchPasses": ["uninterrupted playback with audio when perceptible, otherwise uninterrupted visual playback plus audio technical checks", "caption-camera-character inspection"],
    "requiredPlayback": {"completedPasses": 2, "passIds": ["uninterrupted-playback", "caption-camera-character-inspection"], "perceptionMode": "best-available-with-explicit-disclosure"},
    "criterionRules": [
      {"id": "character-framing", "index": 0, "channel": "visual", "required": true},
      {"id": "two-shot-orientation", "index": 1, "channel": "visual", "required": true},
      {"id": "two-shot-separation", "index": 2, "channel": "visual", "required": true},
      {"id": "bunny-camera-orientation", "index": 3, "channel": "visual", "required": true},
      {"id": "mouth-motion", "index": 4, "channel": "visual", "required": true},
      {"id": "blinks", "index": 5, "channel": "visual", "required": true},
      {"id": "emphasis-motion", "index": 6, "channel": "visual", "required": true},
      {"id": "complete-script-approval", "index": 7, "channel": "evidence", "required": true},
      {"id": "approved-casting", "index": 8, "channel": "evidence", "required": true},
      {"id": "camera-grammar", "index": 9, "channel": "visual", "required": true},
      {"id": "caption-layout", "index": 10, "channel": "visual", "required": true},
      {"id": "audio-technical-integrity", "index": 11, "channel": "technical-audio", "required": true},
      {"id": "audio-intelligibility", "index": 12, "channel": "perceptual-audio", "required": false, "allowUnscored": true},
      {"id": "audio-performance-sync", "index": 13, "channel": "perceptual-audio", "required": false, "allowUnscored": true}
    ],
    "criteria": [
      "cat and bunny remain complete, colored, recognizable, and correctly framed",
      "both characters visibly face inward toward each other in every two-shot",
      "two-shot character silhouettes remain clearly separated without touching or overlap",
      "the bunny-only camera faces right, matching the supplied reference",
      "the approved visible speaker receives varied mouth motion, visible sustained pauses close naturally, and a listener remains still; auditory timing and performance judgments are separately scored under audio-performance-sync",
      "three-frame blinks look naturally irregular, favor conversational boundaries, and do not repeatedly synchronize the two characters",
      "neutral talking remains vertically still and any jump is brief, intentional, and attached to an explicit emphasis event",
      "the user has seen and approved the generated timed role sheet containing every exact time range, spoken line, named nonverbal vocalization, silence, character assignment, caption owner, and overlap; automated transcription, diarization, captions, and cameras may propose but never approve roles",
      "mentor, lead, questioner, and foil are episode-specific roles taken only from the user-approved complete script; when local diarization represents distinct real speakers, a stable detected voice never jumps characters, while a user-approved character performance can override an inadequate anonymous cluster map",
      "camera cuts follow the supplied three-angle grammar",
      "captions progress in readable one-to-three-word chunks, stay in the lower bottom-third lane below character faces and above the episode label without a background panel, and stay associated with the approved speaker; auditory synchronization is separately scored under audio-performance-sync",
      "technical checks confirm the original render-audio identity, complete duration, AAC audio stream, and non-silent level; metadata does not establish intelligibility or perceived synchronization",
      "intelligibility is reviewed when sound is directly perceptible and otherwise explicitly left unscored with a reason and disclosure",
      "direct audiovisual review confirms audible speech and captions remain synchronized, slow sustained sounds do not flap at a fixed rate, and elongated phrases and trailing words stay with the same speaker through the last audible word without swallowing the next character's first audible word; unavailable auditory perception remains explicitly unscored"
    ]
  }
}
Overlap regressionfixtures/regression/overlapping-reassurance/input.json
{
  "schemaVersion": 1,
  "title": "Overlapping Reassurance Regression",
  "episodeLabel": "ANIMAL CONVERSATIONS",
  "audioFile": "user-audio.wav",
  "background": "living-room",
  "timeline": [
    {"start": 0, "end": 0.8, "speaker": "cat", "camera": "cat-close", "caption": "I made a mistake!?"},
    {"start": 0.8, "end": 1.4, "speaker": "both", "captionSpeaker": "bunny", "camera": "two-shot", "caption": "Noooo...", "overlapEvidence": "The bunny reassurance overlaps the dog's continuing emotional reaction."},
    {"start": 1.4, "end": 2, "speaker": "cat", "camera": "cat-close", "caption": "", "vocalization": "Emotional gasp and shriek"},
    {"start": 2, "end": 2.5, "speaker": "both", "captionSpeaker": "bunny", "camera": "two-shot", "caption": "No, no, no!", "overlapEvidence": "The bunny's rapid reassurance overlaps the dog's continuing emotional reaction."},
    {"start": 2.5, "end": 4.2, "speaker": "cat", "camera": "cat-close", "caption": "I'm traaaaash... Gaaaaarbage..."},
    {"start": 4.2, "end": 4.3, "speaker": "none", "camera": "two-shot", "caption": ""},
    {"start": 4.3, "end": 5, "speaker": "bunny", "camera": "bunny-close", "caption": "If you made a mistake—"}
  ]
}
Published proofPROOF-REPORT.md
# Animal Conversations proof report

This document records historical renderer and approved-example evidence through 0.15.1. It does not certify the new 0.16.0 workflow, intake, approval schemas, playback review, export, or supported-platform matrix. Historical instructions below are not the current workflow; follow SKILL.md. New release acceptance is recorded separately.

## Converter proof

The Harmony-free converter generated complete colored cat and bunny idle poses plus mouth-open and blink substitutions from the supplied Toon Boom projects. Converter tests assert the complete 12-layer order, exact palette recovery, source PEG placement, detached bunny fill nodes, expression drawing substitutions, transparent borders, and required opaque interior points. The six packaged character PNGs are checksum-bound in `assets.json`; each has a colocated conversion receipt.

## Character-fill failure and fix

- Observed failure: the bunny's dark-pink head shadow was transparent in every packaged pose. The close-up camera mirrors the pose, so the missing region appeared on the left side of the on-screen head and exposed the background curtain.
- Root cause: the `Head-1.tvg` dark-pink paint seed (`0b6d656fc8edfc85`) resolved from boundary 11 side 0 to no enclosed region. The only previous required opacity point sampled the torso, so conversion incorrectly passed.
- Smallest fix: bind that seed to boundary 11 side 1 in the bunny manifest and regenerate idle, blink, and mouth-open through the official converter. The recovered region contains 596,695 source-render pixels and the full pose's opaque coverage rises from 0.48925 to 0.49675.
- Guardrail: conversion now checks a point inside the repaired head shadow, and tests inspect every cat/dog and bunny pose at all manifest-required head/body/tail points. A bunny-specific assertion also locks the repaired point to RGBA `[213,122,122,255]`.
- Dog/cat inspection: idle, blink, and mouth-open were regenerated against expanded head/body/tail opacity points without changing their existing PNG hashes. High-contrast pose sheets, supplied-reference comparison, and two full-resolution rendered close-ups showed no analogous background leak.
- Render evidence: smoke and both 31.137-second supplied-audio proofs passed. Exact living-room close-ups at 3.0, 11.5, 17.0, and 25.0 seconds were inspected at full resolution; both bunny talking/idle frames have a complete head shadow and both blue dog/cat frames remain fully colored.

## Free smoke proof

- Run: `agent-runs/smoke-proof` (ignored local evidence)
- Input: 4.5-second locally synthesized tone and three timed beats
- Coverage: two-shot, cat-close, bunny-close; `both`, `cat`, and `bunny` speaker modes; generated per-beat review clips and an audio-bound assignment receipt
- Result: pass at 1080x1920, 24 fps, H.264/AAC, audible audio, confirmed speaker assignment, and visible captions
- Purpose: prove the explicit speaker-review gate, all camera layouts, speaker pose switching, muxing, inspection, and contact-sheet generation without a provider call

## Supplied-sample proof

- Run: `agent-runs/sample-living-room` (ignored local evidence)
- Input audio SHA-256: `226ffe78af88c77175c0358d4ab85360eb3eac43b185e29b1a92ff2e58517657`
- Measured input duration: 31.137007 seconds
- Timeline: 15 contiguous beats; all three approved cameras and all four speaker modes. Fourteen spoken beats were explicitly confirmed from the supplied reference video's speaker-colored captions and mouth motion; the silent reaction was confirmed separately.
- Output SHA-256: `10c65799bff76421b2714b8afea84686cec44002ad039638e11b09aee04f7d24`
- Automated result: pass at 1080x1920, 24 fps, H.264/AAC, mean audio -18.5 dB, 14 captioned beats
- Direct visual review: the regenerated contact sheet was inspected at original resolution, the full 31-second render was scanned at one-second intervals, and dense transition and motion sheets were inspected across close-up, two-shot, cued, and neutral-talking intervals. The disputed `No judging`, `We listen`, and `We're just listening` beats use the cat caption color and cat mouth while the bunny remains a listener. The silent 27.9-28.8 reaction uses the reference-matched cat close-up. Staging, bunny-only orientation, neutral vertical stability, and brief cued emphasis motion remain correct.
- Audio evidence: codec, duration, stream presence, and mean level passed automated inspection. No claim of directly hearing or judging intelligibility is made from player controls or metadata.

## Variation proof

- Run: `agent-runs/sample-backyard` (ignored local evidence)
- Variation: identical user audio and timing with the packaged `backyard` background
- Output SHA-256: `0b5a96b9aafa5eb3eff1af0dee8dac4b3121ba9e429d0452486e7aaef61448c5`
- Result: every automated gate passed, including 15-of-15 confirmed speaker beats; direct contact-sheet inspection confirmed the corrected cat assignments and that the replaceable background flows through the same renderer without changing character, caption, or camera behavior

## Speaker-assignment failure and fix

- Observed failure: `No judging. No judging. No judging.` was assigned to the bunny, while `We listen.` and `We're just listening...` were assigned to both characters. The supplied reference identifies all three as cat lines, so the renderer animated the rabbit during cat dialogue.
- Root cause: the renderer correctly obeyed `timeline[].speaker`, but initialization and validation trusted provisional hand-authored values. No step forced the operator to check the actual user-audio beat, and no receipt proved that every assignment was current.
- Smallest robust fix: version 0.2.0 adds an explicit audio-first workflow. `init`/`review-speakers` extract one local WAV per beat; the operator records `confirmedSpeaker` and evidence; `apply-speakers` makes those values authoritative and writes a receipt bound to the user-audio SHA-256 and full speaker timeline. `validate`, `render`, `inspect`, and `finalize` reject missing or stale confirmation, and delivery also requires input-hash parity with the inspected render. Automatic diarization is deliberately not claimed.
- Guardrails: unit coverage rejects incomplete/stale reviews, verifies receipt hashes, and locks the three disputed lines to the cat. The integration proof shows an initialized but unapplied run failing validation with `Speaker assignment is unconfirmed`.
- Evidence: both supplied-audio proofs passed the new `speakerAssignmentConfirmed` inspection gate with 15 reviewed beats. Dense visual sheets show the cat talking throughout the corrected two-shot beats and the bunny remaining idle.

## Reference-aligned staging correction

- Observed failures: renderer version 3 made the two-shot characters oversized and overlapping, and the bunny-only camera retained the converted asset's left-facing orientation instead of the supplied sample's right-facing orientation.
- Root cause: the two-shot scale and positions were not calibrated against the supplied video, the bunny-only layout had no orientation override, and the regression test asserted only a two-shot flip flag rather than rendered spatial bounds.
- Smallest fix: reduce and reposition only the two-shot layouts, keep their inward-facing directions, and mirror the bunny in `bunny-close`. Cat layouts are unchanged.
- Guardrail: the runtime test calculates prepared widths from the packaged character pixels, requires at least 120 pixels between two-shot bounds, requires the cat to remain inside the canvas, and asserts the layout-specific bunny orientation. Composition and quality contracts require separation and the reference-matched bunny-only direction.
- Evidence: the staging correction was introduced in renderer version 4 and remains locked in the current renderer version 14 proofs. All automated gates pass. Original-resolution contact sheets show all three angles, while the one-second full visual scan and exact living-room frames confirm the correction throughout the 31-second output.

## Key-segment motion correction

- Observed failure: speaking characters jumped every six frames throughout every line, and listeners automatically jumped at the start of each beat. A useful emphasis move had become constant background motion.
- Root cause: vertical movement was derived from the generic speaking/listening state rather than from an authored semantic event.
- Smallest robust fix: renderer version 5 removes both automatic paths. Neutral talking changes the mouth pose only. A beat may opt into one or two `bounceAt` offsets, measured from that beat's start; each cue creates one brief 0.36-second half-sine bounce on the active speaker only. The two-shot amplitude is 12 pixels and the close-up amplitude is 18 pixels.
- Blind-agent guardrail: `SKILL.md`, the README, and both input/composition contracts state the same rule: neutral talking uses mouth movement only; jumping must be explicitly attached to an emphasis event. The validator rejects unknown beat fields, more than two cues, unsorted/out-of-beat cues, and cues without an active speaker.
- Regression proof: the runtime suite asserts zero vertical movement across multiple uncued speaking frames, movement and reset around an explicit cue, and no listener movement. The supplied conversation uses sparse cues only on selected punchlines and strong reactions. Dense motion sheets confirmed the cued movement in cat-close, two-shot, and bunny-close views and a completely fixed body baseline through an uncued talking interval; both background contact sheets were inspected at original resolution.

## Reference-style caption correction

- Observed failure: each timed beat displayed its complete caption as one sentence-sized slab, while the supplied Animal Conversations reference advances in short conversational fragments.
- Root cause: the caption painter consumed `timeline[].caption` directly, so the data model's complete spoken line and the on-screen presentation were accidentally treated as the same unit.
- Smallest robust fix: renderer version 7 keeps one complete line per audio-aligned beat, splits that line at sentence boundaries, then emits punctuation-aware one-to-three-word cards across the beat duration. Four-word phrases balance as two plus two instead of leaving a single-word tail. Operators do not pre-split captions or create caption-only beats.
- Guardrail: unit coverage asserts that every generated card contains one to three words, repeated punctuated phrases remain distinct, a four-word line balances into two cards, and the active card advances with the frame. The active caption is included in cached visual state, so a card change always produces a new frame.
- Evidence: both 31.137-second proofs were re-rendered through version 14. Original-resolution contact sheets show short chunks such as `we don't judge!`, `heart to tell`, `No judging.`, `I hoped I`, and `I wound up` while retaining the verified speaker, camera, staging, color, and explicit-emphasis behavior.

## Bottom-third caption presentation correction

- Requested correction: move subtitles from the top safe area into the bottom third and remove their grey background.
- Observed follow-up failure: placing the text exactly at the 1280-pixel bottom-third boundary crossed the characters' faces in two-shot and close-up views. An unchanged output hash on the first retry also exposed stale renderer-cache reuse before delivery.
- Smallest fix: renderer version 14 starts a dedicated caption lane at 1400 pixels on the 1080x1920 canvas—60 pixels above the prior face-safe lane, still below every face and above the episode label—and removes only the subtitle panel. The separate episode-label panel remains unchanged. Advancing the renderer revision invalidates all earlier cached frames.
- Guardrail: unit coverage locks the 1400-pixel lane, rejects the former `x=68` subtitle rectangle and 0.70-opacity fill, and confirms the episode-label rectangle is still present.
- Evidence: the corrected no-hop living-room output SHA-256 is `4d31c0f0c3bcc164d3a6678e0c94be8bc809485e02a20b2c305eea665e4e2fb3`. Both official 31.137-second proofs pass renderer version 14, and original-resolution living-room and backyard contact sheets confirm the outlined captions below the faces with no caption panel across all three cameras.

## Conversational blink correction

- Observed failure: both characters shared one global three-frame blink every 89 frames. That made the timing clock-like, synchronized the characters, blinked them while offscreen, and allowed the mouth cycle to hide scheduled blinks.
- Research basis: human inter-blink intervals are variable rather than periodic; conversation has a higher blink rate than reading; spontaneous blinks cluster at cognitive and speech boundaries; and human/robot conversation studies place communicative blinks at speech starts, completions, and pauses. Three frames at 24 fps equals 125 ms, closely matching measured spontaneous-blink duration.
- Smallest robust fix: renderer version 9 uses two fixed independent cadence patterns with roughly three-to-six-second gaps. A due blink snaps within half a second to a visible dialogue boundary, offscreen events are skipped, and bunny events within five frames of a cat blink are staggered by eight frames or omitted when a visible stagger is impossible. The scheduler is automatic, deterministic, and has no input field or runtime randomness.
- Guardrail: unit coverage locks determinism, independent tracks, boundary preference, three-frame duration, minimum spacing, collision avoidance, and blink priority over the mouth cycle. The render report records exact blink frames for inspection.
- Primary references: [Bentivoglio et al., 1997](https://doi.org/10.1002/mds.870120629) for task-dependent rates; [Borges et al., 2010](https://doi.org/10.1590/S0004-27492010000400005) for log-normal inter-blink timing; [Nakano and Kitazawa, 2010](https://doi.org/10.1007/s00221-010-2387-z) for conversational breakpoints; [Ford et al., 2013](https://doi.org/10.1142/S0219843613500060) for communicative state transitions; [Tatsukawa et al., 2016](https://doi.org/10.1038/srep39718) for controlled breakpoint-timed android blinks; and [Cornelis et al., 2025](https://doi.org/10.1038/s41598-025-04839-y) for measured duration and cognitive-boundary timing.

## Pause-aware mouth correction

- Observed failure: the verified speaker's two-pose mouth alternated every three frames for the entire dialogue beat, including audible gaps between phrases. That was correctly speaker-timed but was not audio-responsive lip sync.
- Smallest robust fix: renderer version 11 uses the existing FFmpeg installation to decode the user audio to 24 kHz mono PCM and measures one RMS level per 24 fps video frame. A clip-relative threshold sits 18 dB below its 90th-percentile strong level, bounded from -55 to -28 dB. Two-frame speech padding protects brief quiet syllables, and any internal closure shorter than three frames is suppressed. The result still uses only `idle` and `mouth-open`; there is no new dependency, model, viseme set, phoneme analysis, transcript alignment, authoring field, or manual threshold.
- Guardrail: unit coverage proves the same speech-activity frames after a 20 dB volume shift, bridges brief quiet dips, closes sustained pauses, rejects sub-three-frame twitches, and requires silence to override an otherwise open speaking pose. Inspection now fails when audio-derived activity is absent or an interior mouth closure is shorter than three frames. The render cache includes the audio checksum and each report records the derived threshold and exact inactive speaking-frame ranges.
- Evidence: the exact version 9 no-hop baseline SHA-256 was `621584da8a7a870f0f50940139471ce9f7893bb32deb117f6fd1243692005355`; the version 11 pause-aware no-hop output is `953daf0d98cc056ebbff05b7bdda4c6cb10c90673fe4c09808a823355154e0ab`. Original-resolution A/B frames at 9.625, 13.500, 27.833, and 29.250 seconds show the baseline mouth open during low-energy gaps and the corrected mouth closed. The accepted sample threshold is -32.8 dB and produces five sustained spoken-beat pause ranges with no interior closure shorter than four frames. Attenuating the real supplied audio by 20 dB moved the threshold to -52.8 dB but changed zero of 748 activity decisions. Both current background proofs pass renderer version 14 with the same audio-derived schedule.

## Rate-responsive two-pose mouth correction

- Observed failure: pause detection was audio-responsive, but every active speech interval still alternated `idle`/`mouth-open` on a fixed three-frame clock. Slow voiceover therefore flapped at the same eight pose changes per second as fast speech.
- Smallest robust fix: renderer version 15 keeps the existing two drawings and derives changes from per-beat audio-envelope percentiles with hysteresis. Slow sustained energy holds one pose; syllabic rises and valleys switch poses; a two-frame minimum pose prevents jitter. There is no phoneme model, viseme set, transcript dependency, threshold input, or provider.
- Guardrail: unit coverage proves a sustained slow sound stays open, a pulsed fast sound produces at least four changes, a 20 dB volume shift produces an identical track, and a true inactive frame closes immediately. Inspection requires the render report's `audio-envelope-hysteresis` receipt.
- Unseen-audio evidence: the corrected 30.023-second render records 391 open frames and 114 audio-derived transitions—about 3.8 changes per second overall instead of the former fixed eight. The exact v15 output SHA-256 is `f766ad2f0d0a6d91a2643f10678ca271817c2f2d3b0cb512b84a8f3b814262d1`.

## Unseen audio-only blind run and intake correction

- Blind input: a previously unseen user MP4 was reduced to only its 30.023356-second PCM audio stream before handoff; the source video frames were never used. Audio SHA-256: `53b2be558855a2ebacb1f3c1336b721b6ae37735ced25547a14cf2c7724344d5`.
- First blind result: a fresh agent received only the v0.7.0 ZIP and WAV. It passed install, tests, check, and smoke, then stopped with zero real render attempts because `init` silently substituted the 31.137007-second sample timing when `--input` was absent and the environment could not directly hear the generated review clips. It correctly refused to fabricate captions or speaker evidence.
- Reusable fix: v0.8.0 makes every real `init` fail closed without an absolute timing JSON. It also accepts `local-audio-analysis` as an explicit evidence channel only when every affected beat carries an `evidenceNote` naming the local ASR/diarization basis and any creative voice-to-character mapping. The kit still ships no transcription model, provider, or second renderer.
- Local analysis basis: checksum-verified Whisper large-v3-turbo word timings were cross-checked against sherpa-onnx pyannote segmentation plus 3D-Speaker two-voice diarization. Detected voice 0 was creatively cast as cat and voice 1 as bunny; short call-and-response beats were cast by dialogue turn. In the first attempt, the ambiguous 13.57–19.30 reaction was incorrectly collapsed to `both`. No cloud/provider call or source-video cue was used.
- Guardrails: tests prove missing real timing fails before a run directory can inherit the sample, reject undocumented local-analysis evidence, and count documented local analysis separately in the audio-bound speaker receipt.
- First rendered proof: `agent-runs/blind-masondenverr-audio` finalized through the official renderer with 14 contiguous beats, all three cameras, all four speaker modes, one explicit reaction bounce, and no neutral-dialogue hopping. Output SHA-256: `f766ad2f0d0a6d91a2643f10678ca271817c2f2d3b0cb512b84a8f3b814262d1`.
- Automated inspection: pass at 1080x1920, 24 fps, H.264/AAC, 30.041667 seconds, mean audio -20.3 dB, 13 captioned beats, 14-of-14 speaker decisions, and current render/input/audio hashes.
- Direct visual review: original-resolution contact and motion sheets plus uninterrupted in-app playback confirmed complete character fills, the right-facing bunny close-up, separated inward-facing two-shots, face-safe progressive captions, deterministic blinks, and exactly one brief reaction bounce. The player completed unmuted, but this agent could not directly perceive sound, so spoken intelligibility remains explicitly unscored.

## Sequential-speaker reaction correction

- Observed failure: immediately after `I made a mistake.`, both characters lip-synced throughout the 13.57–19.30-second reaction even though the saved local diarization showed sequential speaker changes rather than simultaneous voices.
- Root cause: `speaker=both` was an ordinary executable enum value with no required overlap proof. The first audio-only analysis used it as a substitute for uncertainty, so the renderer correctly animated two mouths for an unsupported assignment.
- Smallest robust fix: version 0.9.0 reserves `both` for explicitly confirmed simultaneous speech. Speaker review now requires `overlapConfirmed: true` plus a written `evidenceNote`; `apply-speakers` carries that note into the final beat as `overlapEvidence`; validation and inspection reject any mismatch. Uncertainty must stop for stronger review, while alternating voices must be split into contiguous single-speaker beats.
- Corrected episode: the reaction is split at the saved diarization boundaries into bunny `13.57–15.235`, cat `15.235–16.67`, and bunny `16.67–19.30`, all using the same two-shot. The source video remained unused. The checksum-bound receipt covers 16 beats and records zero confirmed overlap beats.
- Guardrails: runtime tests reject `both` without overlap evidence, reject overlap confirmation on a single speaker, require documented simultaneous-speech review, hash the overlap evidence into the assignment receipt, and exercise the smoke fixture through one explicitly labeled simultaneous beat.
- Evidence: all automatic gates pass, including `simultaneousSpeechConfirmed`; the dense 4-fps reaction sheet shows only one active mouth at a time across both turn boundaries. Corrected output SHA-256: `afc7117d1901f369202f2cd08c2c6f53001f9f9854fed864d63e3a36cf02a361`.

## Stable dog-lead voice correction

- Observed failure: the blind run manually alternated speakers at apparent conversational caption boundaries. Near the end, the dominant dog voice was therefore painted as bunny for `Oh! You better speak up. I almost lost my cool there.` and again for `Why?`, even though saved diarization kept the same voice active.
- Root cause: version 0.9.0 required prose evidence for local analysis but did not machine-bind a detected audio voice to one character. The review notes explicitly admitted that short beats were creatively cast by dialogue turn, contradicting the rule against caption-based speaker inference.
- Audio evidence: saved sherpa-onnx pyannote/3D-Speaker output assigns `speaker_00` continuously from 20.804–25.900 seconds and again from 26.575–29.967 seconds. The user identifies that stable dominant voice as the grounded, wise blue dog. The source video remained unused.
- Smallest robust fix: version 0.10.0 adds one `voiceCharacterMap` to speaker review and a `detectedVoices` list per locally analyzed beat. `apply-speakers` rejects an unmapped voice, a voice recast to another character, or locally claimed overlap without mapped dog and bunny voices. Receipts and inspection record the stable map and exact number of voice-bound beats.
- Superseded casting assumption: version 0.10.0 treated the blue dog as the fixed wise lead and the pink bunny as the fixed questioner/foil. The later approved episode script disproved that assumption: the bunny is the mentor in this episode. Version 0.12.0 therefore keeps only the technical IDs (`cat` for the blue dog and `bunny` for the pink bunny) fixed; narrative roles are episode-specific.
- Corrected proof: `speaker_00 → cat` and `speaker_01 → bunny` cover all 15 analyzed spoken beats; one silent beat remains separate and zero overlap beats are claimed. The corrected camera plan keeps the dog close-up across the continuous ending voice instead of cutting to bunny at caption boundaries.
- Guardrail and historical evidence: a regression test tries to map `voice_0` to cat early and bunny later and must fail. The v0.10.0 proof passed that mechanical constraint, but its claimed ending and reaction roles were later superseded by the user's complete character-performance script below. Historical output SHA-256: `b58823b454e3fdf89eb16837e0372413b54c1324d64502ff40a710ecb15d116b`.

## Complete role-script approval correction

- Observed failure: even after stable anonymous voice casting was added, the unseen episode still assigned several character performances incorrectly. The dog line `I made a mistake!?` was assigned to bunny; the bunny mentor's subtle `Noooo...` immediately afterward was missed; the dog's elongated `I'm traaaaash... Gaaaaarbage...` performance was first omitted and then truncated early; the bunny's rapid `No, no, no!` reassurance was split across characters; and the ending roles were inferred from imperfect clustering instead of the user's intended Dog/Bunny performance.
- Root cause: transcription and diarization drafts were allowed to become executable after per-beat evidence entry without requiring the user to see and approve one complete role script. The kit also encoded a fixed dog-wise-lead/bunny-foil archetype that contradicted this episode's bunny mentor. Those assumptions were too weak for a single performer acting multiple characters, subtle interjections, emotional delivery, and clustering errors. A stable anonymous voice ID is useful evidence only when it genuinely represents one stable speaker; it is not a character or narrative-role oracle.
- Smallest robust fix: version 0.11.0 replaces the render gate with explicit complete-script approval. Every active beat contains exactly one spoken `caption` or named nonverbal `vocalization`; silence is a separate `speaker=none` beat. The user sees every beat and character assignment together before `approve-script` can create the checksum-bound receipt. Words, vocalization, timing, camera, role, or audio changes invalidate that approval. Diarization remains an optional draft aid and the official runtime adds no provider, model, fallback renderer, or lip-sync experiment.
- Episode-role correction: version 0.12.0 removes every active contract/instruction that fixed either character's narrative archetype. Mentor, lead, questioner, and foil are replaceable episode inputs governed only by the approved complete script; technical character IDs and visual assets remain fixed.
- Next-speaker-boundary correction: version 0.13.0 overextended the dog's emotional beat to 16.67 seconds. Version 0.14.0 then overcorrected in the other direction by ending the dog's performance at 15.20 seconds and assigning most of the following emotional delivery to the bunny. User playback review later established that the characters overlap briefly: the bunny reassures while the dog continues reacting, then the dog alone delivers the elongated line. The v0.14.0 timing remains historical failure evidence, not the current role authority.
- Regression guardrails: tests reject missing whole-script approval, omitted or captioned-over nonverbal events, stale approval after a role or vocalization change, undocumented local-analysis claims, unstable detected-voice casting, and unproven simultaneous speech. Validation and inspection separately require the approved spoken, nonverbal, and silent counts to match the current timeline.
- Approved unseen-audio proof: the audio SHA-256 remains `53b2be558855a2ebacb1f3c1336b721b6ae37735ced25547a14cf2c7724344d5`. The v0.15.0 script contains nineteen beats: fifteen spoken, two named dog vocalizations, and two silent. Dog says `I made a mistake!?` at 12.45–13.57; Bunny's subtle `Noooo...` is caption owner during confirmed Dog+Bunny overlap at 13.57–14.52; Dog alone continues the emotional gasp/shriek at 14.52–15.285; Bunny owns rapid `No, no, no!` during a second confirmed overlap at 15.285–15.845; Dog continues the reaction at 15.845–17.445 and alone owns the entire `I'm traaaaash... Gaaaaarbage...` at 17.445–20.725; Bunny begins `If you made a mistake—` at 20.885. It has zero bounce cues. The approval/script hash is `5c1c7bd77f19fcdabace7d1d8c55e70325aa8069b8a7d8fbd9805e21cee47bc3`.
- Timed approval evidence: `init` and `review-script` now generate one `timed-role-sheet.md` listing exact start/end times, active Dog/Bunny performance, caption owner, spoken line or named vocalization, overlap evidence, and per-beat review clip. `approve-script` rewrites it as approved, records hash `8b69c1dbd8d2815a7ce246fb56e9a82cabe86b807b4d05f326d4d7b79dfad573`, and validation rejects a missing or changed sheet.
- Render and inspection evidence: renderer version 16 preserves both active mouths during the two short overlaps while `captionSpeaker=bunny` renders those captions in Bunny pink. Dog-only close-ups own the two emotional vocalizations and the complete elongated line. The finalized proof passes every gate at 1080x1920, 24 fps, H.264/AAC, 30.041667 seconds, and -20.3 dB mean audio. Its output SHA-256 is `189eafcb00e3b9fc553bc4d181a2a3704cea052c368ff9517ce1701c7b2c3701`. Audio intelligibility was not directly scored by this agent; the user's explicit timed role correction is the role authority.

## Parallel-performance approval correction

- Observed failure: ordinary transcription and diarization slightly improved word timing but serialized expressive overlap, mistranscribed the elongated emotional line, and allowed anonymous speaker labels to flip inside one character performance. The prior approved script could look plausible as prose while still assigning several seconds to the wrong character.
- Smallest robust fix: version 0.15.0 keeps `speaker` as the active performance set and adds `captionSpeaker` only to captioned `speaker=both` beats. This separates “whose mouth is active” from “whose words are captioned” without adding a model, provider, viseme system, alternate renderer, or generic track graph.
- Guardrail: `fixtures/regression/overlapping-reassurance/input.json` locks Dog reaction → Bunny-owned reassurance overlap → Dog named reaction → Bunny-owned rapid reassurance overlap → Dog elongated line → Bunny handoff. Runtime tests reject missing or misplaced `captionSpeaker`, prove both mouths remain active, prove Bunny caption color, bind caption ownership into the approval hash, and assert the readable timed sheet. Twenty-nine Node tests plus the Rust decoder test pass.

## Audio-first decision

The reference MP4 container reports 31.251202 seconds, while its extracted AAC stream measures 31.137007 seconds. The proof timeline intentionally ends at the measured audio duration because the reusable format accepts user audio, not source video. Validation rejects a timeline that follows container length instead of audio length by more than 0.08 seconds. Speaker identity is also bound to the extracted user-audio checksum, so replacing the file invalidates the assignment receipt.

## Packaged handoff

`npm run build:kit` produced a stable version 0.15.0 ZIP without `node_modules`, generated runs, Cargo targets, raw runtime audio, standalone script-review clips, or download artifacts. The intentionally distributable approved proof MP4 retains its soundtrack. The archive includes explicit entrypoints for Codex, Claude Code, Cursor, and other coding agents plus a versioned `KIT-MANIFEST.json`. A fresh extraction under `/private/tmp` completed `npm install`, all twenty-nine Node tests, the Rust decoder test, `npm run check`, and the full free smoke command successfully. The packaged smoke generated an approved exact-time role sheet, bound its SHA-256 into the script receipt, kept a synchronized chorus caption explicitly owned by both performers, and passed final inspection through renderer version 16. The package also contains the overlapping-reassurance regression fixture and tests that preserve Bunny caption ownership during brief Dog+Bunny overlap, Dog ownership of the elongated line, and the following Bunny handoff. `zip -X` removes nondeterministic filesystem metadata; two consecutive builds produced the same archive bytes, and the publication test pins their external SHA-256. The final implementation retained one renderer, one runtime, and no Deepgram, Cherry, transcription, diarization, viseme, or provider dependency.

The bounded v0.15 blind audit received only candidate ZIP SHA-256 `cfc7048e4daff70346d1e630045dd41e6f6df8b75d49028ac69a1aba93e5d21e`. From a fresh extraction it resolved version 0.15.0 and passed `npm install`, all twenty-nine Node tests, the Rust decoder test, `npm run check`, and the free smoke workflow. It verified that the regression fixture preserves both confirmed overlaps with Bunny-owned captions, the named Dog gasp/shriek, the complete Dog `I'm traaaaash... Gaaaaarbage...` line, explicit silence, and the Bunny handoff; mutations to caption ownership or those roles invalidated approval. Its archive audit found no generated runs, dependencies, downloads, raw audio, review clips, or secret-like files, and confirmed `sharp` as the only runtime dependency with no provider or environment-variable requirement. The blind agent inspected the smoke contact-sheet pixels and found no clipping or layout issue, but could not hear audio or continuously watch motion, so intelligibility and temporal smoothness remained explicitly unscored. This receipt describes the audited candidate; the publication test pins the deterministic final ZIP rebuilt after recording the receipt.

Version 0.15.1 adds the user-approved corrected proof as a second finished example at `examples/i-made-a-mistake/evidence/final.mp4`, with a representative two-shot poster beside it. The committed MP4 is byte-identical to the finalized renderer-version-16 output SHA-256 `189eafcb00e3b9fc553bc4d181a2a3704cea052c368ff9517ce1701c7b2c3701`; it is 1080×1920 H.264/AAC, 24 fps, and 30.041667 seconds. The example intentionally retains its approved soundtrack and does not change the runtime, renderer, input contract, approval rules, dependency set, or raw-audio exclusion.

An independent blind agent then received only the ZIP and the user-supplied audio, resolved version 0.4.0 from `KIT-MANIFEST.json`, ran every packaged gate, and finalized the backyard proof without source-repository context or provider calls. Its output SHA-256 was `66a7cf1e9d95d6a80ade75097ffc33aae000ee6060285e69f1f94543f09c2876`, exactly matching the controlled variation proof. The agent could not directly perceive audio, so it reused the packaged reference timeline only after the supplied file's SHA-256 and measured duration exactly matched the documented sample; its report makes that limitation explicit. The blind run also exposed and prompted correction of an overbroad receipt label: `method` now says `explicit-per-beat-speaker-confirmation`, while `evidenceCounts` preserves whether each decision came from direct audio, documented local audio analysis, a user label, a reference video, or silence.
Contract testsruntime/tests/runtime.test.mjs
import test from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import sharp from "sharp";
import { validateTimeline } from "../validate.mjs";
import { blinkStateAtFrame, buildBlinkSchedule, buildMouthAnimationTrack, buildSpeechActivityTrack, CAPTION_TOP_Y, captionChunks, captionSvg, captionTextAtFrame, LAYOUTS, visualState } from "../render.mjs";
import { approveScriptReviewDocument, createScriptReviewDocument, reviewedScriptHash, scriptApprovalHash, timedRoleSheetMarkdown } from "../speaker-review.mjs";
import { hashValue, readJson, requireEpisodeInputSource } from "../common.mjs";

test("real episode initialization fails closed without a timing input", () => {
  assert.throws(() => requireEpisodeInputSource(), /every real episode/);
  assert.equal(requireEpisodeInputSource("/tmp/episode.json"), "/tmp/episode.json");
});

const formatRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");

function markCompleteScriptApproved(input, review, basis = "user-confirmed-complete-script") {
  review.approval = {
    approved: true,
    basis,
    approvedBy: basis === "packaged-smoke-fixture" ? "packaged smoke fixture" : "episode user",
    approvalNote: "The complete ordered script, including all roles, words, nonverbal vocalizations, and silence, was explicitly approved.",
    scriptHash: reviewedScriptHash(input, review),
  };
  return review;
}

test("timeline accepts only contiguous approved conversation beats", () => {
  assert.deepEqual(validateTimeline([
    { start: 0, end: 1, speaker: "cat", camera: "cat-close", caption: "Hello" },
    { start: 1, end: 2, speaker: "bunny", camera: "bunny-close", caption: "Hi" },
  ], 2), []);
  assert.match(validateTimeline([
    { start: 0, end: 1, speaker: "cat", camera: "wide", caption: "Hello" },
  ], 1).join(" "), /camera/);
  assert.match(validateTimeline([
    { start: 0, end: 1, speaker: "both", camera: "two-shot", caption: "Together" },
  ], 1).join(" "), /overlapEvidence.*required/);
  assert.deepEqual(validateTimeline([
    { start: 0, end: 1, speaker: "both", captionSpeaker: "bunny", camera: "two-shot", caption: "Together", overlapEvidence: "Two independently identified voices are simultaneous." },
  ], 1), []);
  assert.match(validateTimeline([
    { start: 0, end: 1, speaker: "both", camera: "two-shot", caption: "Together", overlapEvidence: "Two independently identified voices are simultaneous." },
  ], 1).join(" "), /captionSpeaker/);
  assert.match(validateTimeline([
    { start: 0, end: 1, speaker: "cat", captionSpeaker: "cat", camera: "cat-close", caption: "Solo" },
  ], 1).join(" "), /permitted only.*speaker=both/);
  assert.match(validateTimeline([
    { start: 0, end: 1, speaker: "cat", camera: "two-shot", caption: "Solo", overlapEvidence: "uncertain" },
  ], 1).join(" "), /permitted only when speaker=both/);
  assert.deepEqual(validateTimeline([
    { start: 0, end: 1, speaker: "cat", camera: "cat-close", caption: "", vocalization: "Emotional gasp and shriek" },
  ], 1), []);
  assert.match(validateTimeline([
    { start: 0, end: 1, speaker: "cat", camera: "cat-close", caption: "Words", vocalization: "Shriek" },
  ], 1).join(" "), /exactly one of caption or vocalization/);
  assert.match(validateTimeline([
    { start: 0, end: 1, speaker: "none", camera: "two-shot", caption: "", vocalization: "Unknown sound" },
  ], 1).join(" "), /speaker=none cannot contain/);
});

test("only the active speaker receives the talking pose", () => {
  const beat = { start: 0, end: 2, speaker: "cat", camera: "two-shot", caption: "Hello" };
  const open = visualState(beat, 3);
  assert.equal(open.catPose, "mouth-open");
  assert.notEqual(open.bunnyPose, "mouth-open");
});

test("caption ownership stays distinct from simultaneous mouth performance", () => {
  const beat = { start: 0, end: 1, speaker: "both", captionSpeaker: "bunny", camera: "two-shot", caption: "Noooo..." };
  const state = visualState(beat, 3);
  assert.equal(state.catPose, "mouth-open");
  assert.equal(state.bunnyPose, "mouth-open");
  assert.match(captionSvg(beat, beat.caption, "ANIMAL CONVERSATIONS").toString(), /fill="#FF8BCC"/);
});

test("mouth motion closes for real pauses without reacting to brief quiet frames", () => {
  const loud = [-60, -60, -60, -15, -15, -15, -50, -50, -15, -15, -15, -60, -60, -60, -60, -60, -60, -60, -60, -15, -15, -15];
  const quiet = loud.map((level) => level - 20);
  const loudTrack = buildSpeechActivityTrack(loud);
  const quietTrack = buildSpeechActivityTrack(quiet);
  assert.deepEqual(loudTrack.activeFrames, quietTrack.activeFrames, "volume changes should not change the mouth timing");
  assert.ok(loudTrack.activeFrames[6], "a brief quiet dip inside speech should remain active");
  assert.equal(loudTrack.activeFrames[15], false, "the middle of a sustained pause should close the mouth");
  for (let start = 1; start < loudTrack.activeFrames.length - 1;) {
    if (loudTrack.activeFrames[start]) {
      start += 1;
      continue;
    }
    let end = start + 1;
    while (end < loudTrack.activeFrames.length && !loudTrack.activeFrames[end]) end += 1;
    if (end < loudTrack.activeFrames.length) assert.ok(end - start >= 3, "a detected interior pause must never create a one-frame mouth twitch");
    start = end;
  }

  const beat = { start: 0, end: 2, speaker: "cat", camera: "cat-close", caption: "Hello" };
  assert.equal(visualState(beat, 3, { cat: false, bunny: false }, true).catPose, "mouth-open");
  assert.equal(visualState(beat, 3, { cat: false, bunny: false }, false).catPose, "idle");
  assert.notEqual(visualState(beat, 3, { cat: false, bunny: false }, false).bunnyPose, "mouth-open");
});

test("mouth cadence follows the audio envelope instead of a fixed frame clock", () => {
  const slowLevels = Array(16).fill(-18);
  const slowTrack = buildMouthAnimationTrack(slowLevels, slowLevels.map(() => true));
  assert.ok(slowTrack.slice(1).every(Boolean), "a sustained slow vowel should hold the mouth open instead of flapping");

  const fastLevels = [-16, -15, -38, -39, -17, -16, -37, -38, -15, -16, -39, -38];
  const fastTrack = buildMouthAnimationTrack(fastLevels, fastLevels.map(() => true));
  const transitions = fastTrack.slice(1).filter((isOpen, index) => isOpen !== fastTrack[index]).length;
  assert.ok(transitions >= 4, "syllabic energy peaks should produce a visibly faster cadence");
  assert.deepEqual(
    buildMouthAnimationTrack(fastLevels.map((level) => level - 20), fastLevels.map(() => true)),
    fastTrack,
    "cadence should be stable after a volume shift",
  );
  assert.deepEqual(buildMouthAnimationTrack([-16, -16, -16], [true, false, true]), [true, false, true]);
});

test("blinks use independent deterministic tracks and favor dialogue boundaries", () => {
  const timeline = [
    { start: 0, end: 2.25, speaker: "both", camera: "two-shot", caption: "Together" },
    { start: 2.25, end: 6.85, speaker: "cat", camera: "cat-close", caption: "Cat talks" },
    { start: 6.85, end: 8, speaker: "bunny", camera: "bunny-close", caption: "Bunny talks" },
    { start: 8, end: 12, speaker: "both", camera: "two-shot", caption: "Together again" },
  ];
  const schedule = buildBlinkSchedule(timeline, 12 * 24);
  assert.deepEqual(schedule, buildBlinkSchedule(timeline, 12 * 24), "the same input must always produce the same blinks");
  assert.equal(schedule.cat[0], 53, "cat's first due blink should align just before the 2.25s dialogue boundary");
  assert.equal(schedule.bunny[0], 191, "an offscreen bunny blink should be skipped and the next due blink aligned to a visible boundary");
  assert.ok(schedule.cat.every((frame) => !schedule.bunny.some((other) => Math.abs(frame - other) <= 5)), "character blinks should not look mechanically synchronized");
  for (const starts of Object.values(schedule)) {
    for (let index = 1; index < starts.length; index += 1) assert.ok(starts[index] - starts[index - 1] >= 72);
  }

  assert.deepEqual(blinkStateAtFrame(schedule, 53), { cat: true, bunny: false });
  assert.deepEqual(blinkStateAtFrame(schedule, 55), { cat: true, bunny: false });
  assert.deepEqual(blinkStateAtFrame(schedule, 56), { cat: false, bunny: false });
  const speaking = timeline[0];
  assert.equal(visualState(speaking, 53, blinkStateAtFrame(schedule, 53)).catPose, "blink", "a due blink should not be swallowed by the mouth cycle");

  const sharedBoundary = buildBlinkSchedule([
    { start: 0, end: 259 / 24, speaker: "both", camera: "two-shot", caption: "Together" },
    { start: 259 / 24, end: 14, speaker: "both", camera: "two-shot", caption: "Still together" },
  ], 14 * 24);
  assert.ok(sharedBoundary.cat.includes(258), "the cat should use the nearby shared boundary");
  assert.ok(sharedBoundary.bunny.includes(266), "the bunny should stagger instead of blinking with the cat");
});

test("captions progress in readable one-to-three-word chunks instead of full sentences", () => {
  assert.deepEqual(captionChunks("I thought you liked it."), ["I thought you", "liked it."]);
  assert.deepEqual(captionChunks("One two three four"), ["One two", "three four"]);
  assert.deepEqual(captionChunks("No judging. No judging. No judging."), ["No judging.", "No judging.", "No judging."]);
  assert.ok(captionChunks("Whatever. I always thought I'd end up with Liam Hemsworth").every((chunk) => chunk.split(/\s+/).length <= 3));

  const beat = { start: 2, end: 4, speaker: "bunny", camera: "bunny-close", caption: "I thought you liked it." };
  assert.equal(captionTextAtFrame(beat, 48), "I thought you");
  assert.equal(captionTextAtFrame(beat, 72), "liked it.");
  assert.equal(visualState(beat, 72).captionText, "liked it.");
});

test("captions stay in the lower bottom-third lane without a background panel", () => {
  const svg = captionSvg({ speaker: "cat" }, "I have something", "ANIMAL CONVERSATIONS").toString();
  assert.equal(CAPTION_TOP_Y, 1400);
  assert.match(svg, /<text x="540" y="1476"/);
  assert.doesNotMatch(svg, /<rect x="68"/);
  assert.doesNotMatch(svg, /fill-opacity="0\.70"/);
  assert.match(svg, /<rect x="173" y="1764"/, "the separate episode label should remain intact");
});

test("normal speech stays vertically still and bounce cues animate only the speaker", () => {
  const normal = { start: 0, end: 2, speaker: "cat", camera: "cat-close", caption: "Hello" };
  for (const frame of [0, 6, 12, 18, 24, 30]) {
    const state = visualState(normal, frame);
    assert.equal(state.catBob, 0);
    assert.equal(state.bunnyBob, 0);
  }

  const emphasized = { ...normal, bounceAt: [0.25] };
  assert.equal(visualState(emphasized, 6).catBob, 0);
  assert.ok(visualState(emphasized, 10).catBob < 0);
  assert.equal(visualState(emphasized, 10).bunnyBob, 0);
  assert.equal(visualState(emphasized, 16).catBob, 0);
});

test("bounce cues are optional, ordered, inside the beat, and capped at two", () => {
  const beat = { start: 0, end: 1, speaker: "cat", camera: "cat-close", caption: "Hello" };
  assert.deepEqual(validateTimeline([{ ...beat, bounceAt: [0.1, 0.4] }], 1), []);
  assert.match(validateTimeline([{ ...beat, bounceAt: [0.4, 0.1] }], 1).join(" "), /strictly increasing/);
  assert.match(validateTimeline([{ ...beat, bounceAt: [0.1, 0.4, 0.7] }], 1).join(" "), /at most two/);
  assert.match(validateTimeline([{ ...beat, bounceAt: [1] }], 1).join(" "), /inside the beat/);
  assert.match(validateTimeline([{ ...beat, jump: true }], 1).join(" "), /unknown field/);
  assert.match(validateTimeline([{ ...beat, speaker: "none", caption: "", bounceAt: [0.1] }], 1).join(" "), /active speaker/);
});

test("complete script approval is required and binds words, nonverbal events, roles, and timing", () => {
  const input = {
    audioFile: "user-audio.wav",
    timeline: [
      { start: 0, end: 1, speaker: "bunny", camera: "two-shot", caption: "No judging." },
      { start: 1, end: 2, speaker: "cat", camera: "cat-close", caption: "Okay." },
    ],
  };
  const review = createScriptReviewDocument({ input, audioSha256: "audio-hash", generatedAt: "2026-01-01T00:00:00.000Z" });
  assert.equal(review.beats[0].confirmedSpeaker, null);
  assert.match(review.instructions, /timed-role-sheet\.md.*exact time range.*nonverbal vocalization.*caption owner.*user sees and approves the entire timed role sheet/);
  assert.throws(() => approveScriptReviewDocument({ input, review, audioSha256: "audio-hash" }), /complete written role script must be explicitly approved/);
  assert.throws(() => approveScriptReviewDocument({ input, review, audioSha256: "replacement-audio" }), /user audio changed/);
  review.beats[0].confirmedSpeaker = "cat";
  review.beats[0].evidence = "direct-audio-review";
  review.beats[1].confirmedSpeaker = "cat";
  review.beats[1].evidence = "user-provided-label";
  markCompleteScriptApproved(input, review);
  const recastReview = structuredClone(review);
  recastReview.beats[0].confirmedSpeaker = "bunny";
  assert.throws(() => approveScriptReviewDocument({ input, review: recastReview, audioSha256: "audio-hash" }), /script approval is stale/);
  const changedTimeline = structuredClone(input);
  changedTimeline.timeline[0].camera = "cat-close";
  assert.throws(() => approveScriptReviewDocument({ input: changedTimeline, review, audioSha256: "audio-hash" }), /timing, words, caption ownership, vocalizations, or cameras changed/);
  const applied = approveScriptReviewDocument({ input, review, audioSha256: "audio-hash", appliedAt: "2026-01-01T00:01:00.000Z" });
  assert.equal(applied.receipt.method, "explicit-complete-script-approval");
  assert.equal(applied.input.timeline[0].speaker, "cat");
  assert.equal(applied.receipt.scriptHash, scriptApprovalHash(applied.input));
  assert.equal(applied.receipt.timedRoleSheetHash, hashValue(applied.timedRoleSheet));
  assert.equal(applied.receipt.reviewedBeats, 2);
  assert.equal(applied.receipt.nonverbalBeats, 0);
  assert.equal(applied.receipt.approval.basis, "user-confirmed-complete-script");
});

test("nonverbal vocalizations are first-class approved beats and invalidate stale receipts", () => {
  const input = {
    audioFile: "user-audio.wav",
    timeline: [
      { start: 0, end: 1, speaker: "cat", camera: "cat-close", caption: "", vocalization: "Emotional gasp and shriek" },
      { start: 1, end: 2, speaker: "bunny", camera: "bunny-close", caption: "No, no, no!" },
    ],
  };
  const review = createScriptReviewDocument({ input, audioSha256: "audio-hash" });
  review.beats.forEach((beat) => {
    beat.confirmedSpeaker = beat.proposedSpeaker;
    beat.evidence = "user-provided-label";
  });
  markCompleteScriptApproved(input, review);
  const applied = approveScriptReviewDocument({ input, review, audioSha256: "audio-hash" });
  assert.equal(applied.receipt.nonverbalBeats, 1);
  assert.equal(applied.receipt.spokenBeats, 1);
  const revised = structuredClone(applied.input);
  revised.timeline[0].vocalization = "Quiet gasp";
  assert.notEqual(scriptApprovalHash(revised), applied.receipt.scriptHash);
});

test("local audio analysis is explicit evidence only when its basis is documented", () => {
  const input = {
    audioFile: "user-audio.wav",
    timeline: [{ start: 0, end: 1, speaker: "cat", camera: "cat-close", caption: "Hello" }],
  };
  const review = createScriptReviewDocument({ input, audioSha256: "audio-hash" });
  review.beats[0].confirmedSpeaker = "cat";
  review.beats[0].evidence = "local-audio-analysis";
  assert.throws(() => approveScriptReviewDocument({ input, review, audioSha256: "audio-hash" }), /evidenceNote/);
  review.beats[0].evidenceNote = "Local ASR word timings plus two-speaker diarization; speaker 0 was creatively cast as cat.";
  review.voiceCharacterMap.voice_0 = "cat";
  review.beats[0].detectedVoices = ["voice_0"];
  markCompleteScriptApproved(input, review);
  const applied = approveScriptReviewDocument({ input, review, audioSha256: "audio-hash" });
  assert.equal(applied.receipt.evidenceCounts["local-audio-analysis"], 1);
  assert.equal(applied.receipt.voiceBoundBeats, 1);
  assert.equal(applied.receipt.voiceCharacterMap.voice_0, "cat");
});

test("local audio analysis cannot recast one detected voice at a later dialogue turn", () => {
  const input = {
    audioFile: "user-audio.wav",
    timeline: [
      { start: 0, end: 1, speaker: "cat", camera: "cat-close", caption: "First" },
      { start: 1, end: 2, speaker: "bunny", camera: "bunny-close", caption: "Later" },
    ],
  };
  const review = createScriptReviewDocument({ input, audioSha256: "audio-hash" });
  review.voiceCharacterMap.voice_0 = "cat";
  review.beats.forEach((beat) => {
    beat.evidence = "local-audio-analysis";
    beat.evidenceNote = "The same diarized voice_0 continues across this caption boundary.";
    beat.detectedVoices = ["voice_0"];
  });
  review.beats[0].confirmedSpeaker = "cat";
  review.beats[1].confirmedSpeaker = "bunny";
  markCompleteScriptApproved(input, review);
  assert.throws(() => approveScriptReviewDocument({ input, review, audioSha256: "audio-hash" }), /same confirmed character/);
});

test("speaker=both requires explicitly confirmed simultaneous speech and never represents uncertainty", () => {
  const input = {
    audioFile: "user-audio.wav",
    timeline: [{ start: 0, end: 1, speaker: "both", captionSpeaker: "bunny", camera: "two-shot", caption: "Together" }],
  };
  const review = createScriptReviewDocument({ input, audioSha256: "audio-hash" });
  review.voiceCharacterMap.voice_cat = "cat";
  review.voiceCharacterMap.voice_bunny = "bunny";
  review.beats[0].confirmedSpeaker = "both";
  review.beats[0].evidence = "local-audio-analysis";
  review.beats[0].evidenceNote = "Overlap-aware local analysis identifies cat and bunny speaking simultaneously from 0.20–0.72 seconds.";
  review.beats[0].detectedVoices = ["voice_cat", "voice_bunny"];
  markCompleteScriptApproved(input, review);
  assert.throws(() => approveScriptReviewDocument({ input, review, audioSha256: "audio-hash" }), /overlapConfirmed=true/);
  review.beats[0].overlapConfirmed = true;
  const applied = approveScriptReviewDocument({ input, review, audioSha256: "audio-hash" });
  assert.equal(applied.receipt.confirmedOverlapBeats, 1);
  assert.match(applied.input.timeline[0].overlapEvidence, /simultaneously/);
  assert.equal(applied.input.timeline[0].captionSpeaker, "bunny");

  const changedCaptionOwner = structuredClone(input);
  changedCaptionOwner.timeline[0].captionSpeaker = "cat";
  assert.notEqual(scriptApprovalHash(changedCaptionOwner), applied.receipt.scriptHash);

  review.beats[0].confirmedSpeaker = "cat";
  assert.throws(() => approveScriptReviewDocument({ input, review, audioSha256: "audio-hash" }), /only when confirmedSpeaker=both/);
});

test("overlapping-reassurance regression keeps parallel performance and role handoffs explicit", async () => {
  const fixture = await readJson(path.join(formatRoot, "fixtures/regression/overlapping-reassurance/input.json"));
  assert.deepEqual(validateTimeline(fixture.timeline, 5), []);
  assert.deepEqual(fixture.timeline.map((beat) => beat.speaker), ["cat", "both", "cat", "both", "cat", "none", "bunny"]);
  assert.deepEqual(fixture.timeline.filter((beat) => beat.speaker === "both").map((beat) => beat.captionSpeaker), ["bunny", "bunny"]);
  assert.equal(fixture.timeline.find((beat) => beat.caption.includes("traaaaash"))?.speaker, "cat");

  const review = createScriptReviewDocument({ input: fixture, audioSha256: "fixture-audio-hash", generatedAt: "2026-01-01T00:00:00.000Z" });
  review.beats.forEach((beat) => {
    beat.confirmedSpeaker = beat.proposedSpeaker;
    beat.evidence = beat.proposedSpeaker === "none" ? "silence" : "user-provided-label";
    if (beat.proposedSpeaker === "both") {
      beat.overlapConfirmed = true;
      beat.evidenceNote = fixture.timeline[beat.index].overlapEvidence;
    }
  });
  markCompleteScriptApproved(fixture, review);
  review.status = "applied";
  const sheet = timedRoleSheetMarkdown({ input: fixture, review });
  assert.match(sheet, /Status: \*\*APPROVED\*\*/);
  assert.match(sheet, /00:00\.800–00:01\.400 \| Dog \+ Bunny \| Bunny \| “Noooo\.\.\.” \| CONFIRMED/);
  assert.match(sheet, /00:01\.400–00:02\.000 \| Dog \| — \| \[Emotional gasp and shriek\]/);
  assert.match(sheet, /00:02\.500–00:04\.200 \| Dog \| Dog \| “I'm traaaaash\.\.\. Gaaaaarbage\.\.\.”/);
  assert.match(sheet, /00:04\.300–00:05\.000 \| Bunny \| Bunny \| “If you made a mistake—”/);
});

test("the supplied sample assigns the disputed blue-caption lines to the cat", async () => {
  const sample = await readJson(path.join(formatRoot, "fixtures/sample/input.json"));
  const speakersByCaption = new Map(sample.timeline.map((beat) => [beat.caption, beat.speaker]));
  assert.equal(speakersByCaption.get("No judging. No judging. No judging."), "cat");
  assert.equal(speakersByCaption.get("We listen."), "cat");
  assert.equal(speakersByCaption.get("We're just listening..."), "cat");
});

test("quality review requires complete-script approval and honest perception disclosure", async () => {
  const quality = await readJson(path.join(formatRoot, "quality.json"));
  const requirements = await readJson(path.join(formatRoot, "requirements.json"));
  const inputContract = await readJson(path.join(formatRoot, "input-contract.json"));
  const compositionContract = await readJson(path.join(formatRoot, "composition-contract.json"));
  const kitManifest = await readJson(path.join(formatRoot, "KIT-MANIFEST.json"));
  assert.equal(quality.blindReview.requiredPlayback.perceptionMode, "best-available-with-explicit-disclosure");
  const criteria = quality.blindReview.criteria.join(" ");
  assert.match(criteria, /generated timed role sheet.*exact time range.*spoken line.*nonverbal vocalization.*character assignment.*caption owner.*overlap/);
  assert.match(criteria, /automated transcription.*diarization.*never approve roles/);
  assert.match(criteria, /mentor, lead, questioner, and foil.*episode-specific roles.*user-approved complete script/);
  assert.doesNotMatch(criteria, /grounded, wise lead|questioner\/foil/);
  assert.match(criteria, /elongated phrases.*trailing words.*same speaker.*last audible word.*without swallowing.*next character's first audible word/);
  assert.match(criteria, /intelligibility.*otherwise explicitly left unscored/);
  assert.match(requirements.notes.join(" "), /timed-role-sheet\.md.*exact time range.*spoken line.*nonverbal vocalization.*Dog\/Bunny assignment.*caption owner.*overlap/);
  assert.match(requirements.notes.join(" "), /speaker=both.*simultaneous-speech evidence.*uncertainty must stop/);
  assert.match(requirements.notes.join(" "), /stable detected voice ID.*user-confirmed character.*Never infer.*mentor.*lead.*questioner.*foil/);
  assert.doesNotMatch(requirements.notes.join(" "), /grounded, wise lead|questioner\/foil/);
  assert.match(requirements.notes.join(" "), /speaker handoff.*last audible word.*next speaker's first audible word.*elongated phrases.*trailing words.*never absorb.*next character's words/);
  assert.match(inputContract.timingRules.join(" "), /spoken caption text.*named nonverbal vocalization/);
  assert.match(inputContract.timingRules.join(" "), /speaker boundary.*last audible word.*next speaker's first audible word.*elongated phrases.*trailing words.*delivery slows down.*never swallow.*next character's words/);
  assert.match(inputContract.timingRules.join(" "), /timed-role-sheet\.md.*exact time range.*caption owner.*user approves that full sheet/);
  assert.match(inputContract.timingRules.join(" "), /speaker=both.*simultaneous speech.*alternating voices.*single-speaker beats/);
  assert.match(inputContract.timingRules.join(" "), /voiceCharacterMap.*detectedVoices.*cannot change characters/);
  assert.match(compositionContract.fixed.join(" "), /blue dog.*runtime ID cat.*pink bunny.*narrative roles are not fixed.*approved episode script/);
  assert.match(compositionContract.fixed.join(" "), /captionSpeaker.*caption ownership.*timed-role-sheet\.md/);
  assert.doesNotMatch(compositionContract.fixed.join(" "), /grounded, wise lead|questioner\/foil/);
  assert.match(compositionContract.replaceable.join(" "), /episode-specific narrative roles.*mentor.*lead.*questioner.*foil/);
  assert.match(kitManifest.excluded.join(" "), /raw user-supplied runtime audio.*example MP4s retain their distributable soundtracks/);
});

test("format kit build strips nondeterministic ZIP metadata", async () => {
  const buildSource = await readFile(path.join(formatRoot, "build-kit.mjs"), "utf8");
  assert.match(buildSource, /"-X", "-q"/);
  assert.doesNotMatch(buildSource, /"-r"/);
  assert.match(buildSource, /staged\.files\.map/);
});

test("conversation staging preserves inward orientation and a clear two-shot gap", async () => {
  const twoShot = LAYOUTS["two-shot"];
  const bunnyMetadata = await sharp(path.join(formatRoot, "assets/characters/bunny/idle.png")).metadata();
  const catMetadata = await sharp(path.join(formatRoot, "assets/characters/cat/idle.png")).metadata();
  const bunnyWidth = bunnyMetadata.width * twoShot.bunny.height / bunnyMetadata.height;
  const catWidth = catMetadata.width * twoShot.cat.height / catMetadata.height;
  const characterGap = twoShot.cat.left - (twoShot.bunny.left + bunnyWidth);

  assert.equal(twoShot.bunny.mirrorX, true);
  assert.equal(twoShot.cat.mirrorX, undefined);
  assert.equal(LAYOUTS["bunny-close"].bunny.mirrorX, true);
  assert.ok(characterGap >= 120, `expected at least 120px between characters, received ${characterGap}px`);
  assert.ok(twoShot.cat.left + catWidth <= 1080, "cat must remain inside the canvas");
});
Main runnerrunner.mjs
#!/usr/bin/env node

import { mkdir } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { execute, exists, parseArgs, readJson, resolveRunDirectory, sha256, writeJson } from "./runtime/common.mjs";
import { checkDependencies } from "./runtime/doctor.mjs";

const root = path.dirname(fileURLToPath(import.meta.url));
const command = process.argv[2];
const args = parseArgs(process.argv.slice(3));
const commands = ["doctor", "check", "setup-intake", "intake", "init", "review-script", "approve-script", "status", "run", "validate", "render", "inspect", "record-playback-review", "finalize", "export", "upgrade-run", "smoke"];

function flag(name) {
  const value = args[name];
  if (value === undefined || value === false || value === "false") return false;
  if (value === true || value === "true") return true;
  throw new Error(`--${name} must be a flag or true/false.`);
}

async function smoke({ runId, workflow, stateApi }) {
  const runDirectory = resolveRunDirectory(root, runId);
  const { createScriptReview, approveScriptReview } = await import("./runtime/speaker-review.mjs");
  const { canonicalHash, semanticContent } = await import("./runtime/identity.mjs");
  const fixture = await readJson(path.join(root, "fixtures", "smoke", "input.json"));
  if (!await exists(runDirectory)) {
    await mkdir(runDirectory);
    const state = stateApi.newWorkflowState(runId, "mechanics-smoke");
    await stateApi.saveWorkflowState(runDirectory, state);
    await writeJson(path.join(runDirectory, "input.json"), fixture);
    const audio = path.join(runDirectory, fixture.audioFile);
    await execute("ffmpeg", ["-y", "-v", "error", "-f", "lavfi", "-i", `sine=frequency=440:sample_rate=48000:duration=${fixture.timeline.at(-1).end}`, "-c:a", "pcm_s16le", audio], { capture: true });
    state.sourceAudioSha256 = await sha256(audio);
    await stateApi.saveWorkflowState(runDirectory, state);
  }
  const state = await stateApi.readWorkflowState(runDirectory);
  if (state.kind !== "mechanics-smoke") throw new Error("Smoke refuses an existing episode run. Use a separate new run ID; no episode was overwritten.");
  const input = await readJson(path.join(runDirectory, "input.json"));
  if (input.audioFile !== fixture.audioFile || state.sourceAudioSha256 !== await sha256(path.join(runDirectory, fixture.audioFile))) throw new Error("Smoke audio is missing or changed. Preserve this run and choose a fresh smoke ID.");
  const approvalFile = path.join(runDirectory, ".script-approval.json");
  if (await exists(approvalFile)) {
    if ((await readJson(approvalFile)).scope !== "fixture") throw new Error("Smoke only resumes mechanics-only fixture approval.");
  } else {
    if (canonicalHash(semanticContent(input)) !== canonicalHash(semanticContent(fixture))) throw new Error("The unapproved smoke input differs from the packaged fixture; use a fresh smoke ID.");
    let review = await createScriptReview({ root, runDirectory });
    for (const beat of review.beats) {
      beat.confirmedSpeaker = beat.proposedSpeaker;
      beat.evidence = beat.proposedSpeaker === "none" ? "silence" : "user-provided-label";
      beat.evidenceNote = "Fixed synthetic mechanics fixture only; not user approval or perceptual review of an episode.";
      if (beat.proposedSpeaker === "both") {
        beat.overlapConfirmed = true;
        beat.evidenceNote = "The packaged smoke fixture explicitly labels this mechanics-only beat as simultaneous dialogue.";
      }
    }
    await writeJson(path.join(runDirectory, "script-review.json"), review);
    review = await createScriptReview({ root, runDirectory });
    await approveScriptReview({ root, runDirectory, reviewId: review.reviewId, fixtureProof: "smoke" });
  }
  const report = await workflow.technicalCycle({ root, runId });
  return {
    schemaVersion: 2, status: report.status, scope: "mechanics-only", run: runId,
    output: path.join(runDirectory, "final.mp4"),
    limitation: "This is a synthetic runtime/asset proof, not episode approval or playback review. It does not finalize or export an episode.",
  };
}

async function main() {
  if (command === "--help" || command === "help") return { status: "help", commands, usage: "node runner.mjs <command> --run=<id> [--source=<URL-or-file>] [--input=/absolute/draft.json]" };
  if (!commands.includes(command)) throw new Error(`Usage: node runner.mjs <${commands.join("|")}> [--run=id].`);
  // Bootstrap must not import the rendering graph or depend on node_modules.
  if (command === "check" || command === "doctor") return checkDependencies({ root });
  if (command === "setup-intake") {
    const { setupIntake } = await import("./runtime/intake.mjs");
    return setupIntake({ root }); // The dedicated setup lock protects the kit-local environment/cache.
  }
  const workflow = await import("./runtime/workflow.mjs");
  const stateApi = await import("./runtime/workflow-state.mjs");
  const runId = command === "smoke" ? args.run || "smoke-proof" : args.run;
  const runDirectory = resolveRunDirectory(root, runId);
  if (command === "status") return workflow.workflowStatus({ root, runId });
  if (command === "upgrade-run") {
    const newRunId = args["new-run"];
    resolveRunDirectory(root, newRunId);
    if (runId === newRunId) throw new Error("Upgrade requires a different --new-run ID.");
    const [first, second] = [runId, newRunId].sort();
    return stateApi.withRunLock({ root, runId: first }, () => stateApi.withRunLock({ root, runId: second }, () => workflow.upgradeRun({ root, runId, newRunId })));
  }
  return stateApi.withRunLock({ root, runId }, async () => {
    if (command === "smoke") return smoke({ runId, workflow, stateApi });
    if (command === "intake") return workflow.startIntake({ root, runId, source: args.source });
    if (command === "init") return workflow.initializeEpisode({ root, runId, audio: args.audio, input: args.input });
    if (command === "review-script") return workflow.importDraft({ root, runId, input: args.input, newRevision: flag("new-revision") });
    if (command === "approve-script") return workflow.approveEpisode({ root, runId, reviewId: args["review-id"], approvedBy: args["approved-by"], note: args.note });
    if (command === "run") return workflow.runWorkflow({ root, runId, source: args.source, output: args.output, includeReviewMedia: flag("include-review-media") });
    if (command === "render" || command === "inspect") return workflow.technicalCycle({ root, runId, renderOnly: command === "render" });
    if (command === "validate") {
      const { validateRun } = await import("./runtime/validate.mjs");
      return (await validateRun({ root, runDirectory })).receipt;
    }
    if (command === "record-playback-review") {
      if (typeof args.input !== "string" || !args.input) throw new Error("Pass --input=/path/playback-review-record.json containing the current review observations.");
      const { recordPlaybackReview } = await import("./runtime/quality.mjs");
      return recordPlaybackReview({ root, runDirectory, review: await readJson(path.resolve(args.input)) });
    }
    if (command === "finalize") {
      const { finalizeRun } = await import("./runtime/quality.mjs");
      return finalizeRun({ root, runDirectory });
    }
    if (command === "export") return workflow.exportEpisode({ root, runId, output: args.output, includeReviewMedia: flag("include-review-media") });
  });
}

main().then((result) => {
  console.log(JSON.stringify(result, null, 2));
  const failureStates = new Set(["blocked", "fail", "setup-required", "invalid-source", "source-inaccessible", "login-required", "network-error", "missing-audio", "transcription-failed", "attempt-limit", "repair-required", "export-invalid", "input-or-setup-invalid", "intake-evidence-invalid"]);
  if (failureStates.has(result?.status) || failureStates.has(result?.phase)) process.exitCode = 1;
}).catch(async (error) => {
  let checkpoint = null;
  try {
    const runDirectory = resolveRunDirectory(root, args.run || (command === "smoke" ? "smoke-proof" : undefined));
    if (await exists(runDirectory)) checkpoint = { runDirectory, state: path.join(runDirectory, "state.json"), resume: `node runner.mjs status --run=${path.basename(runDirectory)}` };
  } catch { /* A malformed/new run may not have any resumable checkpoint. */ }
  console.error(JSON.stringify({
    schemaVersion: 2, status: "blocked", command: command || null,
    blocker: { code: error.code || "action-required", message: error.message || String(error) },
    checkpoint, nextAction: { owner: /lock|setup|install|attempt limit|No relevant repair/i.test(error.message || "") ? "operator" : "agent", action: checkpoint ? "inspect-status-and-resolve-blocker" : "resolve-blocker-before-starting" },
    completionClaimed: false,
  }, null, 2));
  process.exitCode = 1;
});
Video rendererruntime/render.mjs
import { link, mkdir, readFile, rename, rm } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import path from "node:path";
import sharp from "sharp";
import { execute, exists, hashValue, sha256, writeJson } from "./common.mjs";
import { assertMatchingIdentity, canonicalHash, collectRenderIdentity } from "./identity.mjs";
import { validateRun } from "./validate.mjs";

const WIDTH = 1080;
const HEIGHT = 1920;
const FPS = 24;
const RENDERER_VERSION = 16;
const BOUNCE_SECONDS = 0.36;
const SPEECH_ANALYSIS_SAMPLE_RATE = 24000;
const SPEECH_LEVEL_PERCENTILE = 0.9;
const SPEECH_THRESHOLD_RANGE_DB = [-55, -28];
const SPEECH_THRESHOLD_BELOW_STRONG_DB = 18;
const SPEECH_ACTIVITY_PADDING_FRAMES = 2;
const SPEECH_MINIMUM_PAUSE_FRAMES = 3;
const MOUTH_MINIMUM_POSE_FRAMES = 2;
const BLINK_DURATION_FRAMES = 3;
const BLINK_BOUNDARY_SNAP_FRAMES = 12;
const BLINK_MINIMUM_GAP_FRAMES = 72;
const BLINK_COLLISION_FRAMES = 5;
const BLINK_STAGGER_FRAMES = 8;
export const CAPTION_TOP_Y = 1400;
const BLINK_TRACKS = {
  cat: { firstFrame: 53, gapFrames: [84, 109, 91, 126, 97] },
  bunny: { firstFrame: 79, gapFrames: [103, 88, 117, 96, 132] },
};

export const LAYOUTS = {
  "two-shot": {
    bunny: { height: 560, left: 70, bottom: 1670, mirrorX: true },
    cat: { height: 540, left: 555, bottom: 1670 },
  },
  "cat-close": {
    cat: { height: 1130, left: 1, bottom: 1810 },
  },
  "bunny-close": {
    bunny: { height: 1600, left: 45, bottom: 1810, mirrorX: true },
  },
};

function escapeXml(text) {
  return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
}

function wrapCaption(text, maximum = 27) {
  const words = text.trim().split(/\s+/).filter(Boolean);
  const lines = [];
  let current = "";
  for (const word of words) {
    const candidate = current ? `${current} ${word}` : word;
    if (candidate.length > maximum && current) {
      lines.push(current);
      current = word;
    } else current = candidate;
  }
  if (current) lines.push(current);
  return lines.slice(0, 5);
}

export function captionChunks(text) {
  const words = text.trim().split(/\s+/).filter(Boolean);
  const phrases = [];
  let phrase = [];
  for (const word of words) {
    phrase.push(word);
    if (/[.!?]+["']?$/.test(word)) {
      phrases.push(phrase);
      phrase = [];
    }
  }
  if (phrase.length) phrases.push(phrase);

  const chunks = [];
  for (const wordsInPhrase of phrases) {
    for (let index = 0; index < wordsInPhrase.length;) {
      const remaining = wordsInPhrase.length - index;
      const size = remaining === 4 ? 2 : Math.min(3, remaining);
      chunks.push(wordsInPhrase.slice(index, index + size).join(" "));
      index += size;
    }
  }
  return chunks;
}

export function captionTextAtFrame(beat, frameIndex) {
  const chunks = captionChunks(beat.caption || "");
  if (!chunks.length) return "";
  const durationFrames = Math.max(1, Math.round((beat.end - beat.start) * FPS));
  const localFrame = Math.max(0, frameIndex - Math.round(beat.start * FPS));
  const chunkIndex = Math.min(chunks.length - 1, Math.floor(localFrame * chunks.length / durationFrames));
  return chunks[chunkIndex];
}

function characterIsVisible(character, frameIndex, timeline) {
  const time = frameIndex / FPS;
  const beat = timeline.find((entry, index) => time < entry.end - 0.0001 || index === timeline.length - 1);
  return beat?.camera === "two-shot" || beat?.camera === `${character}-close`;
}

function nearestVisibleBreakpoint(character, targetFrame, timeline) {
  const candidates = timeline.slice(0, -1)
    .map((beat) => Math.max(0, Math.round(beat.end * FPS) - 1))
    .filter((frame) => characterIsVisible(character, frame, timeline))
    .filter((frame) => Math.abs(frame - targetFrame) <= BLINK_BOUNDARY_SNAP_FRAMES)
    .sort((left, right) => Math.abs(left - targetFrame) - Math.abs(right - targetFrame) || left - right);
  return candidates[0];
}

export function buildBlinkSchedule(timeline, frameCount) {
  const schedule = { cat: [], bunny: [] };
  for (const character of ["cat", "bunny"]) {
    const track = BLINK_TRACKS[character];
    let targetFrame = track.firstFrame;
    let gapIndex = 0;
    while (targetFrame + BLINK_DURATION_FRAMES <= frameCount) {
      let startFrame = nearestVisibleBreakpoint(character, targetFrame, timeline) ?? targetFrame;
      if (characterIsVisible(character, startFrame, timeline)) {
        const previous = schedule[character].at(-1);
        if (previous === undefined || startFrame - previous >= BLINK_MINIMUM_GAP_FRAMES) {
          let shouldSchedule = true;
          if (character === "bunny" && schedule.cat.some((catFrame) => Math.abs(catFrame - startFrame) <= BLINK_COLLISION_FRAMES)) {
            const staggered = startFrame + BLINK_STAGGER_FRAMES;
            if (staggered + BLINK_DURATION_FRAMES <= frameCount && characterIsVisible(character, staggered, timeline)) startFrame = staggered;
            else shouldSchedule = false;
          }
          if (shouldSchedule) schedule[character].push(startFrame);
        }
      }
      targetFrame += track.gapFrames[gapIndex % track.gapFrames.length];
      gapIndex += 1;
    }
  }
  return schedule;
}

export function blinkStateAtFrame(schedule, frameIndex) {
  const isBlinking = (character) => schedule[character].some(
    (startFrame) => frameIndex >= startFrame && frameIndex < startFrame + BLINK_DURATION_FRAMES,
  );
  return { cat: isBlinking("cat"), bunny: isBlinking("bunny") };
}

export function buildSpeechActivityTrack(frameLevelsDb) {
  if (!frameLevelsDb.length) return { activeFrames: [], strongLevelDb: -180, thresholdDb: -55 };
  const sorted = [...frameLevelsDb].sort((left, right) => left - right);
  const strongLevelDb = sorted[Math.floor((sorted.length - 1) * SPEECH_LEVEL_PERCENTILE)];
  const thresholdDb = Math.max(
    SPEECH_THRESHOLD_RANGE_DB[0],
    Math.min(SPEECH_THRESHOLD_RANGE_DB[1], strongLevelDb - SPEECH_THRESHOLD_BELOW_STRONG_DB),
  );
  const activeFrames = frameLevelsDb.map((_, frameIndex) => {
    const first = Math.max(0, frameIndex - SPEECH_ACTIVITY_PADDING_FRAMES);
    const last = Math.min(frameLevelsDb.length - 1, frameIndex + SPEECH_ACTIVITY_PADDING_FRAMES);
    for (let candidate = first; candidate <= last; candidate += 1) {
      if (frameLevelsDb[candidate] >= thresholdDb) return true;
    }
    return false;
  });
  for (let start = 0; start < activeFrames.length;) {
    if (activeFrames[start]) {
      start += 1;
      continue;
    }
    let end = start + 1;
    while (end < activeFrames.length && !activeFrames[end]) end += 1;
    if (start > 0 && end < activeFrames.length && end - start < SPEECH_MINIMUM_PAUSE_FRAMES) {
      activeFrames.fill(true, start, end);
    }
    start = end;
  }
  return { activeFrames, strongLevelDb, thresholdDb };
}

export function buildMouthAnimationTrack(frameLevelsDb, activeFrames) {
  if (!frameLevelsDb.length || frameLevelsDb.length !== activeFrames.length) return [];
  const activeLevels = frameLevelsDb.filter((_, index) => activeFrames[index]).sort((left, right) => left - right);
  if (!activeLevels.length) return activeFrames.map(() => false);
  const percentile = (value) => activeLevels[Math.floor((activeLevels.length - 1) * value)];
  const openLevelDb = percentile(0.58);
  const measuredCloseLevelDb = percentile(0.32);
  const closeLevelDb = Math.min(measuredCloseLevelDb, openLevelDb - 3);
  const mouthOpenFrames = [];
  let isOpen = false;
  let framesInPose = MOUTH_MINIMUM_POSE_FRAMES;
  for (let frameIndex = 0; frameIndex < frameLevelsDb.length; frameIndex += 1) {
    if (!activeFrames[frameIndex]) {
      isOpen = false;
      framesInPose = MOUTH_MINIMUM_POSE_FRAMES;
    } else if (framesInPose >= MOUTH_MINIMUM_POSE_FRAMES) {
      if (!isOpen && frameLevelsDb[frameIndex] >= openLevelDb) {
        isOpen = true;
        framesInPose = 0;
      } else if (isOpen && frameLevelsDb[frameIndex] <= closeLevelDb) {
        isOpen = false;
        framesInPose = 0;
      }
    }
    mouthOpenFrames.push(isOpen);
    framesInPose += 1;
  }
  return mouthOpenFrames;
}

function pcmFrameLevels(pcm, frameCount) {
  const samplesPerFrame = SPEECH_ANALYSIS_SAMPLE_RATE / FPS;
  return Array.from({ length: frameCount }, (_, frameIndex) => {
    const firstByte = frameIndex * samplesPerFrame * 2;
    const lastByte = Math.min(pcm.length, firstByte + samplesPerFrame * 2);
    let squared = 0;
    let sampleCount = 0;
    for (let byte = firstByte; byte + 1 < lastByte; byte += 2) {
      const sample = pcm.readInt16LE(byte) / 32768;
      squared += sample * sample;
      sampleCount += 1;
    }
    if (!sampleCount) return -180;
    return 20 * Math.log10(Math.sqrt(squared / sampleCount) + 1e-9);
  });
}

async function analyzeSpeechActivity({ audioFile, cacheDirectory, frameCount }) {
  const pcmFile = path.join(cacheDirectory, "speech-activity.pcm");
  if (!(await exists(pcmFile))) {
    await execute("ffmpeg", [
      "-y", "-v", "error", "-i", audioFile, "-vn", "-ac", "1",
      "-ar", String(SPEECH_ANALYSIS_SAMPLE_RATE), "-f", "s16le", pcmFile,
    ], { capture: true });
  }
  const frameLevelsDb = pcmFrameLevels(await readFile(pcmFile), frameCount);
  return { ...buildSpeechActivityTrack(frameLevelsDb), frameLevelsDb };
}

export function speakingPauseFrameRanges(activeFrames, inactiveSpeakingFrames) {
  const speakingFrames = new Set(inactiveSpeakingFrames);
  const ranges = [];
  for (let start = 0; start < activeFrames.length;) {
    if (activeFrames[start]) {
      start += 1;
      continue;
    }
    let end = start;
    let intersectsSpeech = false;
    while (end < activeFrames.length && !activeFrames[end]) {
      if (speakingFrames.has(end)) intersectsSpeech = true;
      end += 1;
    }
    // Keep the complete audio pause, including its continuation through a
    // speaker=none beat; clipping at that boundary invents short closures.
    if (intersectsSpeech) ranges.push([start, end - 1]);
    start = end;
  }
  return ranges;
}

export function captionSvg(beat, captionText, episodeLabel) {
  const colors = { cat: "#62C8FF", bunny: "#FF8BCC", both: "#FFE477", none: "#FFFFFF" };
  const captionSpeaker = beat.speaker === "both" ? beat.captionSpeaker : beat.speaker;
  const lines = wrapCaption(captionText, 24);
  const fontSize = lines.length > 1 ? 66 : 76;
  const lineHeight = fontSize + 12;
  const tspans = lines.map((line, index) => `<tspan x="540" dy="${index ? lineHeight : 0}">${escapeXml(line)}</tspan>`).join("");
  return Buffer.from(`<svg width="${WIDTH}" height="${HEIGHT}" xmlns="http://www.w3.org/2000/svg">
    <text x="540" y="${CAPTION_TOP_Y + fontSize}" text-anchor="middle" font-family="Arial Rounded MT Bold,Arial,sans-serif" font-size="${fontSize}" font-weight="800" fill="${colors[captionSpeaker]}" stroke="#07101c" stroke-width="12" paint-order="stroke" stroke-linejoin="round">${tspans}</text>
    <rect x="173" y="1764" width="734" height="88" rx="44" fill="#09121f" fill-opacity="0.76"/>
    <text x="540" y="1825" text-anchor="middle" font-family="Arial Rounded MT Bold,Arial,sans-serif" font-size="45" font-weight="800" fill="#FFE477" stroke="#07101c" stroke-width="7" paint-order="stroke">${escapeXml(episodeLabel)}</text>
  </svg>`);
}

export function visualState(beat, frameIndex, blinkState = { cat: false, bunny: false }, mouthOpen = true) {
  const localFrame = Math.max(0, frameIndex - Math.round(beat.start * FPS));
  const isSpeaking = (character) => beat.speaker === character || beat.speaker === "both";
  const pose = (character) => {
    if (blinkState[character]) return "blink";
    if (isSpeaking(character) && mouthOpen) return "mouth-open";
    return "idle";
  };
  const bounce = Math.min(0, ...(beat.bounceAt || []).map((cue) => {
    const progress = (localFrame / FPS - cue) / BOUNCE_SECONDS;
    if (progress < 0 || progress > 1) return 0;
    const height = beat.camera === "two-shot" ? 12 : 18;
    return -Math.round(Math.sin(progress * Math.PI) * height);
  }));
  const bob = (character) => isSpeaking(character) ? (bounce || 0) : 0;
  return {
    catPose: pose("cat"),
    bunnyPose: pose("bunny"),
    catBob: bob("cat"),
    bunnyBob: bob("bunny"),
    captionText: captionTextAtFrame(beat, frameIndex),
  };
}

async function prepareAssets({ root, cacheDirectory, background, assets }) {
  const prepared = { characters: { cat: {}, bunny: {} } };
  const backgroundFile = path.join(cacheDirectory, "background.png");
  if (!(await exists(backgroundFile))) {
    await sharp(path.join(root, background.path)).resize(WIDTH, HEIGHT, { fit: "cover", position: "centre" }).png().toFile(backgroundFile);
  }
  prepared.background = backgroundFile;
  for (const camera of Object.keys(LAYOUTS)) {
    for (const [characterId, layout] of Object.entries(LAYOUTS[camera])) {
      prepared.characters[characterId][camera] ||= {};
      const character = assets.characters.find((entry) => entry.id === characterId);
      for (const pose of character.poses) {
        const output = path.join(cacheDirectory, `${characterId}-${camera}-${pose.id}.png`);
        if (!(await exists(output))) {
          const image = sharp(path.join(root, pose.path)).resize({ height: layout.height });
          if (layout.mirrorX) image.flop();
          await image.png().toFile(output);
        }
        prepared.characters[characterId][camera][pose.id] = output;
      }
    }
  }
  return prepared;
}

async function renderState({ prepared, cacheDirectory, beat, beatIndex, state, episodeLabel }) {
  const key = hashValue({ beatIndex, beat, state, episodeLabel }).slice(0, 18);
  const output = path.join(cacheDirectory, `state-${key}.png`);
  if (await exists(output)) return output;
  const layout = LAYOUTS[beat.camera];
  const overlays = [];
  for (const characterId of ["bunny", "cat"]) {
    if (!layout[characterId]) continue;
    const pose = state[`${characterId}Pose`];
    const metadata = await sharp(prepared.characters[characterId][beat.camera][pose]).metadata();
    const position = layout[characterId];
    overlays.push({
      input: prepared.characters[characterId][beat.camera][pose],
      left: position.left,
      top: position.bottom - metadata.height + state[`${characterId}Bob`],
    });
  }
  overlays.push({ input: captionSvg(beat, state.captionText, episodeLabel), left: 0, top: 0 });
  await sharp(prepared.background).composite(overlays).png({ compressionLevel: 7 }).toFile(output);
  return output;
}

export async function renderRun({ root, runDirectory }) {
  const validated = await validateRun({ root, runDirectory });
  const { input, durationSeconds, background, assets, audioFile } = validated;
  const identity = await collectRenderIdentity({ root, runDirectory });
  const renderHash = hashValue({
    renderer: RENDERER_VERSION,
    identityHash: canonicalHash(identity),
    input,
    audio: validated.receipt.audio.sha256,
    background: background.sha256,
    characterAssets: assets.characters.flatMap((character) => character.poses.map((pose) => pose.sha256)),
  }).slice(0, 16);
  const cacheDirectory = path.join(runDirectory, `.render-cache-${renderHash}`);
  const framesDirectory = path.join(cacheDirectory, "frames");
  await mkdir(framesDirectory, { recursive: true });
  const prepared = await prepareAssets({ root, cacheDirectory, background, assets });
  const frameCount = Math.ceil(durationSeconds * FPS);
  const blinkSchedule = buildBlinkSchedule(input.timeline, frameCount);
  const speechActivity = await analyzeSpeechActivity({ audioFile, cacheDirectory, frameCount });
  const mouthOpenFrames = Array(frameCount).fill(false);
  for (const beat of input.timeline) {
    if (beat.speaker === "none") continue;
    const firstFrame = Math.max(0, Math.round(beat.start * FPS));
    const lastFrame = Math.min(frameCount, Math.round(beat.end * FPS));
    const localTrack = buildMouthAnimationTrack(
      speechActivity.frameLevelsDb.slice(firstFrame, lastFrame),
      speechActivity.activeFrames.slice(firstFrame, lastFrame),
    );
    localTrack.forEach((isOpen, offset) => { mouthOpenFrames[firstFrame + offset] = isOpen; });
  }
  const eventUsage = new Map();
  const uniqueStates = new Set();
  const inactiveSpeakingFrames = [];

  let beatIndex = 0;
  for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
    const time = frameIndex / FPS;
    while (beatIndex < input.timeline.length - 1 && time >= input.timeline[beatIndex].end - 0.0001) beatIndex += 1;
    const beat = input.timeline[beatIndex];
    eventUsage.set(beatIndex, (eventUsage.get(beatIndex) || 0) + 1);
    if (beat.speaker !== "none" && !speechActivity.activeFrames[frameIndex]) inactiveSpeakingFrames.push(frameIndex);
    const state = visualState(beat, frameIndex, blinkStateAtFrame(blinkSchedule, frameIndex), mouthOpenFrames[frameIndex]);
    const source = await renderState({ prepared, cacheDirectory, beat, beatIndex, state, episodeLabel: input.episodeLabel });
    uniqueStates.add(source);
    const destination = path.join(framesDirectory, `frame-${String(frameIndex).padStart(6, "0")}.png`);
    if (!(await exists(destination))) await link(source, destination);
  }

  const output = path.join(runDirectory, "final.mp4");
  const temporaryOutput = path.join(runDirectory, `.final-${randomUUID()}.mp4`);
  try {
  await execute("ffmpeg", [
    "-y",
    "-framerate", String(FPS),
    "-i", path.join(framesDirectory, "frame-%06d.png"),
    "-i", audioFile,
    "-map", "0:v:0",
    "-map", "1:a:0",
    "-c:v", "libx264",
    "-preset", "veryfast",
    "-crf", "18",
    "-pix_fmt", "yuv420p",
    "-r", String(FPS),
    "-c:a", "aac",
    "-b:a", "192k",
    "-t", durationSeconds.toFixed(6),
    "-movflags", "+faststart",
    temporaryOutput,
  ]);
  assertMatchingIdentity(identity, await collectRenderIdentity({ root, runDirectory }));
  const report = {
    schemaVersion: 2,
    status: "pass",
    identity,
    outputSha256: await sha256(temporaryOutput),
    renderedAt: new Date().toISOString(),
    rendererVersion: RENDERER_VERSION,
    width: WIDTH,
    height: HEIGHT,
    fps: FPS,
    durationSeconds,
    inputHash: hashValue(input),
    frameCount,
    uniqueVisualStates: uniqueStates.size,
    blinkScheduleFrames: blinkSchedule,
    speechActivity: {
      strongLevelDb: Number(speechActivity.strongLevelDb.toFixed(1)),
      thresholdDb: Number(speechActivity.thresholdDb.toFixed(1)),
      activeFrames: speechActivity.activeFrames.filter(Boolean).length,
      inactiveFrames: speechActivity.activeFrames.filter((active) => !active).length,
      inactiveSpeakingFrameRanges: speakingPauseFrameRanges(speechActivity.activeFrames, inactiveSpeakingFrames),
    },
    mouthAnimation: {
      method: "audio-envelope-hysteresis",
      minimumPoseFrames: MOUTH_MINIMUM_POSE_FRAMES,
      openFrames: mouthOpenFrames.filter(Boolean).length,
      transitions: mouthOpenFrames.slice(1).filter((isOpen, index) => isOpen !== mouthOpenFrames[index]).length,
    },
    timelineBeatFrames: Object.fromEntries(eventUsage),
    output: "final.mp4",
  };
  await rename(temporaryOutput, output);
  await writeJson(path.join(runDirectory, "render-report.json"), report);
  return { output, report };
  } finally {
    await rm(temporaryOutput, { force: true });
  }
}
Output inspectionruntime/inspect.mjs
import path from "node:path";
import { fileURLToPath } from "node:url";
import { randomUUID } from "node:crypto";
import { rename, rm } from "node:fs/promises";
import { execute, hashValue, probe, readJson, sha256, writeJson } from "./common.mjs";
import { qualityPolicyIdentity } from "./identity.mjs";
import { assertRenderFresh, loadQualityPolicy } from "./quality.mjs";
import { validateRun } from "./validate.mjs";

function frameRate(value) {
  const [numerator, denominator] = String(value || "0/1").split("/").map(Number);
  return denominator ? numerator / denominator : 0;
}

export function pauseClosuresStable(speechActivity, frameCount) {
  return speechActivity?.inactiveSpeakingFrameRanges?.every(
    ([start, end]) => start === 0 || end === frameCount - 1 || end - start + 1 >= 3,
  ) === true;
}

export async function inspectRun({ root = fileURLToPath(new URL("..", import.meta.url)), runDirectory }) {
  await validateRun({ root, runDirectory });
  const currentRender = await assertRenderFresh({ root, runDirectory });
  const policy = await loadQualityPolicy(root);
  const automatic = policy.automatic;
  const input = await readJson(path.join(runDirectory, "input.json"));
  const validation = await readJson(path.join(runDirectory, ".validation.json"));
  const renderReport = await readJson(path.join(runDirectory, "render-report.json"));
  const output = path.join(runDirectory, "final.mp4");
  const media = await probe(output);
  const video = media.streams.find((stream) => stream.codec_type === "video");
  const audio = media.streams.find((stream) => stream.codec_type === "audio");
  const durationSeconds = Number(media.format.duration);
  const fps = frameRate(video?.avg_frame_rate);
  const loudnessOutput = await execute("ffmpeg", ["-i", output, "-vn", "-af", "volumedetect", "-f", "null", "-"], { capture: true });
  const meanVolumeDb = Number(loudnessOutput.match(/mean_volume:\s*(-?[0-9.]+) dB/)?.[1] ?? -Infinity);
  const measured = {
    width: video?.width,
    height: video?.height,
    fps,
    durationSeconds,
    videoCodec: video?.codec_name,
    audioCodec: audio?.codec_name || null,
    meanVolumeDb,
    camerasUsed: [...new Set(input.timeline.map((beat) => beat.camera))],
    speakerModesUsed: [...new Set(input.timeline.map((beat) => beat.speaker))],
    scriptApproval: validation.scriptApproval,
    inputHash: hashValue(input),
    captionedBeatCount: input.timeline.filter((beat) => beat.caption.trim()).length,
    nonverbalBeatCount: input.timeline.filter((beat) => typeof beat.vocalization === "string" && beat.vocalization.trim()).length,
    speechActivity: renderReport.speechActivity,
    mouthAnimation: renderReport.mouthAnimation,
    outputSha256: await sha256(output),
  };
  const gates = {
    width: measured.width === automatic.width,
    height: measured.height === automatic.height,
    fps: Math.abs(measured.fps - automatic.fps) <= 0.01,
    duration: Math.abs(measured.durationSeconds - validation.audio.durationSeconds) <= automatic.durationToleranceSeconds,
    videoCodec: measured.videoCodec === automatic.videoCodec,
    audioCodec: measured.audioCodec === automatic.audioCodec,
    audibleAudio: Number.isFinite(meanVolumeDb) && meanVolumeDb > automatic.minimumMeanVolumeDb,
    approvedCamerasOnly: measured.camerasUsed.every((camera) => ["two-shot", "cat-close", "bunny-close"].includes(camera)),
    completeScriptApproved: measured.scriptApproval?.method === "explicit-complete-script-approval" && measured.scriptApproval?.reviewedBeats === input.timeline.length,
    nonverbalEventsApproved: measured.scriptApproval?.nonverbalBeats === measured.nonverbalBeatCount,
    stableVoiceCastingConfirmed: measured.scriptApproval?.voiceBoundBeats === measured.scriptApproval?.diarizedBeats,
    simultaneousSpeechConfirmed: measured.scriptApproval?.confirmedOverlapBeats === input.timeline.filter((beat) => beat.speaker === "both").length,
    renderMatchesInput: renderReport.inputHash === measured.inputHash,
    captionsPresent: measured.captionedBeatCount > 0,
    speechActivityAnalyzed: Number.isFinite(measured.speechActivity?.thresholdDb),
    mouthAnimationAnalyzed: measured.mouthAnimation?.method === "audio-envelope-hysteresis" && measured.mouthAnimation?.openFrames > 0,
    pauseClosuresStable: pauseClosuresStable(measured.speechActivity, renderReport.frameCount),
  };
  const status = policy.requiredTechnicalGates.every((id) => gates[id] === true) && Object.values(gates).every(Boolean) ? "pass" : "fail";
  const report = {
    schemaVersion: 2, status, inspectedAt: new Date().toISOString(), measured, gates,
    renderIdentityHash: currentRender.renderIdentityHash,
    qualityPolicyHash: await qualityPolicyIdentity(root),
  };
  // Publish inspection evidence last. An interrupted sheet generation must not
  // replace the previous sheet or make an unfinished inspection look complete.
  const temporarySheet = path.join(runDirectory, `.contact-sheet-${randomUUID()}.png`);
  try {
    await execute("ffmpeg", [
      "-y", "-i", output,
      "-vf", `fps=9/${durationSeconds},scale=270:480,tile=3x3:padding=4:margin=4`,
      "-frames:v", "1",
      "-update", "1",
      temporarySheet,
    ]);
    report.contactSheetSha256 = await sha256(temporarySheet);
    await rename(temporarySheet, path.join(runDirectory, "contact-sheet.png"));
    await writeJson(path.join(runDirectory, "quality-report.json"), report);
  } finally {
    await rm(temporarySheet, { force: true });
  }
  if (status !== "pass") {
    const failures = Object.entries(gates).filter(([, passed]) => !passed).map(([id]) => id);
    throw new Error(`Inspection failed: ${failures.join(", ")}`);
  }
  return report;
}
Speaker reviewruntime/speaker-review.mjs
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { execute, exists, hashValue, readJson, sha256, writeJson } from "./common.mjs";
import { canonicalHash, semanticContent, approvedRevisionId } from "./identity.mjs";
import { reviewPageHtml } from "./review-page.mjs";

const SPEAKERS = new Set(["cat", "bunny", "both", "none"]);
const EVIDENCE = new Set(["direct-audio-review", "local-audio-analysis", "user-provided-label", "reference-video", "silence"]);
const APPROVAL_BASES = new Set(["user-confirmed-complete-script", "checksum-matched-approved-reference", "packaged-smoke-fixture"]);

export function reviewFingerprint(input) {
  return canonicalHash(semanticContent(input));
}

export function scriptApprovalHash(input) {
  return canonicalHash(semanticContent(input));
}

function reviewedInput(input, review, pendingFallback = false) {
  const nextInput = structuredClone(input);
  nextInput.timeline.forEach((beat, index) => {
    const entry = review.beats?.[index];
    beat.speaker = entry?.confirmedSpeaker || (pendingFallback ? beat.speaker : null);
    if (beat.speaker === "both" && entry?.evidenceNote?.trim()) beat.overlapEvidence = entry.evidenceNote.trim();
    else if (beat.speaker !== "both") delete beat.overlapEvidence;
  });
  return nextInput;
}

export function scriptReviewId(input, review, audioSha256) {
  return canonicalHash({
    revisionId: approvedRevisionId(reviewedInput(input, review, true), audioSha256),
    voiceCharacterMap: review.voiceCharacterMap || {},
    diarization: review.diarization || null,
    beats: review.beats.map(({ index, confirmedSpeaker, evidence, evidenceNote, detectedVoices, overlapConfirmed, transcriptionEvidence, timingEvidence, uncertainty }) => ({ index, confirmedSpeaker, evidence, evidenceNote, detectedVoices, overlapConfirmed, transcriptionEvidence, timingEvidence, uncertainty })),
  });
}

export async function reviewMediaErrors({ runDirectory, review, beatCount }) {
  const expected = new Set(["script-review/source.wav", ...Array.from({ length: beatCount }, (_, index) => `script-review/beat-${String(index).padStart(2, "0")}.wav`)]);
  if (!Array.isArray(review.media) || review.media.length !== expected.size) return ["The complete review soundtrack and exact clips must be generated before proceeding."];
  const errors = [];
  for (const media of review.media) {
    if (!expected.delete(media.path)) {
      errors.push("Review media contains a duplicate, missing, or unexpected path. Regenerate review-script.");
      continue;
    }
    const file = path.join(runDirectory, media.path);
    if (!(await exists(file)) || await sha256(file) !== media.sha256) errors.push(`Review audio is missing or modified: ${media.path}. Regenerate review-script before proceeding.`);
  }
  if (expected.size) errors.push("One or more exact review clips are missing from the media manifest.");
  return errors;
}

const CHARACTER_LABELS = { cat: "Dog", bunny: "Bunny", both: "Dog + Bunny", none: "Silence" };

function timestamp(seconds) {
  const minutes = Math.floor(seconds / 60);
  const remainder = (seconds - minutes * 60).toFixed(3).padStart(6, "0");
  return `${String(minutes).padStart(2, "0")}:${remainder}`;
}

function tableCell(value) {
  return String(value ?? "—").replaceAll("|", "\\|").replaceAll(/\s+/g, " ").trim() || "—";
}

export function timedRoleSheetMarkdown({ input, review }) {
  const approved = review.status === "applied" && review.approval?.approved === true;
  const rows = input.timeline.map((beat, index) => {
    const reviewedBeat = review.beats?.[index] || {};
    const speaker = reviewedBeat.confirmedSpeaker || reviewedBeat.proposedSpeaker || beat.speaker;
    const captionOwner = beat.caption?.trim()
      ? speaker === "both"
        ? CHARACTER_LABELS[beat.captionSpeaker] || "MISSING — BLOCKED"
        : CHARACTER_LABELS[speaker]
      : "—";
    const performance = beat.caption?.trim()
      ? `“${beat.caption.trim()}”`
      : beat.vocalization?.trim()
        ? `[${beat.vocalization.trim()}]`
        : "[silence]";
    const overlap = speaker === "both"
      ? reviewedBeat.overlapConfirmed === true
        ? `CONFIRMED — ${reviewedBeat.evidenceNote}`
        : `PENDING — ${beat.overlapEvidence || "simultaneous speech must be verified"}`
      : "—";
    return `| ${index + 1} | ${timestamp(beat.start)}–${timestamp(beat.end)} | ${tableCell(CHARACTER_LABELS[speaker])} | ${tableCell(captionOwner)} | ${tableCell(performance)} | ${tableCell(overlap)} | \`${tableCell(reviewedBeat.clip || `script-review/beat-${String(index).padStart(2, "0")}.wav`)}\` |`;
  });
  return `# Timed role sheet\n\nStatus: **${approved ? "APPROVED" : "PENDING — DO NOT RENDER"}**  \nTitle: ${tableCell(input.title)}  \nEpisode label: ${tableCell(input.episodeLabel)}  \nBackground: ${tableCell(input.background)}  \nReview ID: \`${tableCell(review.reviewId)}\`  \nAudio: \`${tableCell(review.audio?.file || input.audioFile)}\`  \nAudio SHA-256: \`${tableCell(review.audio?.sha256 || "pending")}\`\n\nOpen script-review.html to hear exact WAV clips and review every camera, emphasis cue, evidence basis, and uncertainty. Schema 1 retains a 20 ms boundary tolerance; new drafts should share exact boundaries.\n\nThe blue dog uses runtime ID \`cat\`. \`both\` means confirmed simultaneous performance, never uncertainty. On those rows, **Caption owner** identifies whose words appear while both mouths may animate.\n\n| # | Exact time | Active performance | Caption owner | Words / vocalization | Overlap evidence | Review clip |\n|---:|---|---|---|---|---|---|\n${rows.join("\n")}\n`;
}

export function reviewedScriptHash(input, review) {
  return scriptApprovalHash(reviewedInput(input, review));
}

export function createScriptReviewDocument({ input, audioSha256, generatedAt = new Date().toISOString() }) {
  if (!Array.isArray(input.timeline) || input.timeline.length === 0) throw new Error("Cannot review a script without timeline beats.");
  const review = {
    schemaVersion: 2,
    status: "pending",
    generatedAt,
    instructions: "Review script-review.html and timed-role-sheet.md before approve-script. They show every exact time range, Dog/Bunny assignment, spoken line, named nonverbal vocalization, silence, caption owner, and overlap in one place. Confirm every beat from direct audio, documented local audio analysis, a user-provided label, a checksum-matched documented reference video, or silence. Automated transcription and diarization may draft words, timings, and anonymous voice clusters, but they never approve character roles. Only when genuine diarization supplies distinct voices, define each stable detected voice ID once in voiceCharacterMap and list detectedVoices. Record transcription, timing, casting, diarization and uncertainty separately. speaker=both means proven simultaneous speech only and a captioned overlap requires captionSpeaker. After the user sees and approves the entire timed role sheet and playable page, call approve-script with the displayed review-id, approved-by and note; the runtime computes hashes. Audio or any approved creative choice changing invalidates approval.",
    audio: { file: input.audioFile, sha256: audioSha256 },
    reviewFingerprint: reviewFingerprint(input),
    approval: {
      approved: false,
      basis: null,
      approvedBy: null,
      approvalNote: null,
      scriptHash: null,
    },
    voiceCharacterMap: {},
    diarization: { performed: false, basis: null },
    beats: input.timeline.map((beat, index) => ({
      index,
      start: beat.start,
      end: beat.end,
      caption: beat.caption,
      captionSpeaker: beat.captionSpeaker || null,
      vocalization: beat.vocalization || null,
      proposedSpeaker: beat.speaker,
      confirmedSpeaker: null,
      evidence: null,
      evidenceNote: null,
      detectedVoices: [],
      overlapConfirmed: null,
      transcriptionEvidence: null,
      timingEvidence: null,
      uncertainty: null,
      clip: `script-review/beat-${String(index).padStart(2, "0")}.wav`,
    })),
  };
  review.reviewId = scriptReviewId(input, review, audioSha256);
  return review;
}

export function approveScriptReviewDocument({ input, review, audioSha256, appliedAt = new Date().toISOString() }) {
  const errors = [];
  if (review.schemaVersion !== 2) errors.push("Legacy script review: use upgrade-run to obtain a new expanded review and explicit approval.");
  if (review.audio?.sha256 !== audioSha256) errors.push("script review is stale because the user audio changed.");
  if (review.reviewFingerprint !== reviewFingerprint(input)) errors.push("script review is stale because timeline timing, words, caption ownership, vocalizations, or cameras changed, or another creative choice changed.");
  if (!Array.isArray(review.beats) || review.beats.length !== input.timeline.length) errors.push("script review must contain one entry for every timeline beat.");
  if (review.approval?.approved !== true) errors.push("the complete written role script must be explicitly approved before rendering.");
  if (!APPROVAL_BASES.has(review.approval?.basis)) errors.push("script approval needs basis=user-confirmed-complete-script, checksum-matched-approved-reference, or packaged-smoke-fixture.");
  if (typeof review.approval?.approvedBy !== "string" || !review.approval.approvedBy.trim()) errors.push("script approval needs approvedBy.");
  if (typeof review.approval?.approvalNote !== "string" || !review.approval.approvalNote.trim()) errors.push("script approval needs an approvalNote documenting the complete-script confirmation.");
  if (typeof review.approval?.scriptHash !== "string" || !review.approval.scriptHash.trim()) errors.push("script approval needs the exact reviewed scriptHash shown to the approver.");

  const voiceCharacterMap = review.voiceCharacterMap && typeof review.voiceCharacterMap === "object" && !Array.isArray(review.voiceCharacterMap)
    ? review.voiceCharacterMap
    : {};
  Object.entries(voiceCharacterMap).forEach(([voice, character]) => {
    if (!voice.trim() || !["cat", "bunny"].includes(character)) errors.push("voiceCharacterMap must map non-empty detected voice IDs to cat or bunny.");
  });

  const beats = Array.isArray(review.beats) ? review.beats : [];
  beats.forEach((entry, index) => {
    const inputBeat = input.timeline[index];
    if (entry.index !== index) errors.push(`script review beat ${index} has the wrong index.`);
    if (inputBeat && (entry.start !== inputBeat.start || entry.end !== inputBeat.end || entry.caption !== inputBeat.caption || (entry.captionSpeaker || null) !== (inputBeat.captionSpeaker || null) || (entry.vocalization || null) !== (inputBeat.vocalization || null))) {
      errors.push(`script review beat ${index} no longer matches the current timing, words, caption owner, or vocalization.`);
    }
    if (!SPEAKERS.has(entry.confirmedSpeaker)) errors.push(`script review beat ${index} needs confirmedSpeaker=cat, bunny, both, or none.`);
    if (!EVIDENCE.has(entry.evidence)) errors.push(`script review beat ${index} needs explicit evidence.`);
    if (entry.evidence === "local-audio-analysis" && (typeof entry.evidenceNote !== "string" || !entry.evidenceNote.trim())) errors.push(`script review beat ${index} needs an evidenceNote for local audio analysis.`);
    if (entry.evidence === "local-audio-analysis" && (review.diarization?.performed === true || entry.detectedVoices?.length)) {
      const detectedVoices = Array.isArray(entry.detectedVoices)
        ? [...new Set(entry.detectedVoices.filter((voice) => typeof voice === "string" && voice.trim()).map((voice) => voice.trim()))]
        : [];
      if (!detectedVoices.length) errors.push(`script review beat ${index} needs detectedVoices for local audio analysis.`);
      const mappedCharacters = detectedVoices.map((voice) => voiceCharacterMap[voice]);
      if (mappedCharacters.some((character) => !character)) errors.push(`script review beat ${index} uses a detected voice missing from voiceCharacterMap.`);
      if (["cat", "bunny"].includes(entry.confirmedSpeaker) && (detectedVoices.length !== 1 || mappedCharacters[0] !== entry.confirmedSpeaker)) {
        errors.push(`script review beat ${index} must keep its detected voice on the same confirmed character.`);
      }
      if (entry.confirmedSpeaker === "both" && (!mappedCharacters.includes("cat") || !mappedCharacters.includes("bunny"))) {
        errors.push(`script review beat ${index} needs mapped cat and bunny voices for confirmed overlap.`);
      }
    }
    if (entry.evidence !== "local-audio-analysis" && Array.isArray(entry.detectedVoices) && entry.detectedVoices.length) errors.push(`script review beat ${index} may set detectedVoices only for local audio analysis.`);
    if (entry.evidence === "silence" && entry.confirmedSpeaker !== "none") errors.push(`script review beat ${index} can use silence evidence only with speaker=none.`);
    if (entry.confirmedSpeaker === "both" && entry.overlapConfirmed !== true) errors.push(`script review beat ${index} needs overlapConfirmed=true because speaker=both means simultaneous speech, never uncertainty.`);
    if (entry.confirmedSpeaker === "both" && (typeof entry.evidenceNote !== "string" || !entry.evidenceNote.trim())) errors.push(`script review beat ${index} needs an evidenceNote documenting the confirmed overlapping speech.`);
    if (entry.confirmedSpeaker !== "both" && entry.overlapConfirmed === true) errors.push(`script review beat ${index} may confirm overlap only when confirmedSpeaker=both.`);
  });
  if (typeof review.approval?.scriptHash === "string" && review.approval.scriptHash !== reviewedScriptHash(input, review)) {
    errors.push("script approval is stale because the approved timings, words, caption ownership, vocalizations, cameras, or roles changed.");
  }
  if (errors.length) throw new Error(`Script approval failed:\n- ${errors.join("\n- ")}`);

  const nextInput = reviewedInput(input, review);
  const evidenceCounts = Object.fromEntries([...EVIDENCE].map((value) => [value, beats.filter((beat) => beat.evidence === value).length]).filter(([, count]) => count));
  const timedRoleSheet = timedRoleSheetMarkdown({
    input: nextInput,
    review: { ...review, status: "applied", appliedAt },
  });
  const receipt = {
    schemaVersion: 2,
    status: "pass",
    method: "explicit-complete-script-approval",
    appliedAt,
    audioSha256,
    revisionId: approvedRevisionId(nextInput, audioSha256),
    reviewId: scriptReviewId(input, review, audioSha256),
    scope: review.approval.basis === "packaged-smoke-fixture" ? "fixture" : "episode",
    scriptHash: scriptApprovalHash(nextInput),
    timedRoleSheetHash: hashValue(timedRoleSheet),
    approval: {
      basis: review.approval.basis,
      approvedBy: review.approval.approvedBy.trim(),
      approvalNote: review.approval.approvalNote.trim(),
      scriptHash: review.approval.scriptHash,
    },
    reviewedBeats: beats.length,
    spokenBeats: nextInput.timeline.filter((beat) => beat.caption.trim()).length,
    nonverbalBeats: nextInput.timeline.filter((beat) => typeof beat.vocalization === "string" && beat.vocalization.trim()).length,
    silentBeats: nextInput.timeline.filter((beat) => beat.speaker === "none").length,
    evidenceCounts,
    voiceCharacterMap,
    voiceBoundBeats: beats.filter((beat) => beat.evidence === "local-audio-analysis" && beat.detectedVoices?.length).length,
    diarizedBeats: beats.filter((beat) => beat.evidence === "local-audio-analysis" && (review.diarization?.performed === true || beat.detectedVoices?.length)).length,
    confirmedOverlapBeats: beats.filter((beat) => beat.confirmedSpeaker === "both" && beat.overlapConfirmed === true).length,
  };
  return { input: nextInput, receipt, timedRoleSheet };
}

export async function createScriptReview({ runDirectory }) {
  const input = await readJson(path.join(runDirectory, "input.json"));
  const audioFile = path.resolve(runDirectory, input.audioFile || "");
  if (path.dirname(audioFile) !== path.resolve(runDirectory)) throw new Error("audioFile must name a file directly inside the run folder.");
  const audioSha256 = await sha256(audioFile);
  const reviewPath = path.join(runDirectory, "script-review.json");
  const previous = await exists(reviewPath) ? await readJson(reviewPath) : null;
  if (previous && previous.schemaVersion !== 2) throw new Error("Legacy script review: use upgrade-run to preserve the old run and obtain fresh expanded approval.");
  const unchanged = previous?.audio?.sha256 === audioSha256 && previous.reviewFingerprint === reviewFingerprint(input);
  if (previous && !unchanged && (await exists(path.join(runDirectory, ".script-approval.json")) || await exists(path.join(runDirectory, "render-report.json")))) {
    throw new Error("This progressed run changed. Import the candidate with review-script --new-revision so previous outputs and receipts are preserved.");
  }
  const review = unchanged ? previous : createScriptReviewDocument({ input, audioSha256 });
  const nextId = scriptReviewId(input, review, audioSha256);
  if (review.reviewId !== nextId) {
    review.status = "pending";
    review.approval = { approved: false, basis: null, approvedBy: null, approvalNote: null, scriptHash: null };
  }
  review.reviewId = nextId;
  const clipsDirectory = path.join(runDirectory, "script-review");
  await mkdir(clipsDirectory, { recursive: true });
  const media = [{ clip: "script-review/source.wav" }, ...review.beats.map((beat, index) => ({ start: beat.start, end: beat.end, clip: `script-review/beat-${String(index).padStart(2, "0")}.wav` }))];
  for (const beat of media) {
    const file = path.join(runDirectory, beat.clip);
    const expected = review.media?.find((entry) => entry.path === beat.clip);
    if (unchanged && expected && await exists(file) && await sha256(file) === expected.sha256) continue;
    await execute("ffmpeg", [
      "-y",
      "-i", audioFile,
      ...(beat.start === undefined ? [] : ["-ss", beat.start.toFixed(6), "-t", (beat.end - beat.start).toFixed(6)]),
      "-vn",
      "-c:a", "pcm_s24le",
      file,
    ], { capture: true });
  }
  review.media = await Promise.all(media.map(async ({ clip }) => ({ path: clip, sha256: await sha256(path.join(runDirectory, clip)) })));
  await writeJson(path.join(runDirectory, "script-review.json"), review);
  await writeFile(path.join(runDirectory, "timed-role-sheet.md"), timedRoleSheetMarkdown({ input, review }), "utf8");
  await writeFile(path.join(runDirectory, "script-review.html"), reviewPageHtml({ input: reviewedInput(input, review, true), review }), "utf8");
  return review;
}

export async function approveScriptReview({ root, runDirectory, reviewId, approvedBy, note, fixtureProof }) {
  const inputPath = path.join(runDirectory, "input.json");
  const reviewPath = path.join(runDirectory, "script-review.json");
  const input = await readJson(inputPath);
  const review = await readJson(reviewPath);
  const audioFile = path.resolve(runDirectory, input.audioFile || "");
  if (path.dirname(audioFile) !== path.resolve(runDirectory)) throw new Error("audioFile must name a file directly inside the run folder.");
  const audioSha256 = await sha256(audioFile);
  if (review.schemaVersion !== 2) throw new Error("Legacy script review: use upgrade-run and obtain fresh expanded approval.");
  if (fixtureProof === "smoke") {
    const fixture = await readJson(path.join(root, "fixtures/smoke/input.json"));
    const state = await exists(path.join(runDirectory, "state.json")) ? await readJson(path.join(runDirectory, "state.json")) : {};
    if (canonicalHash(semanticContent(input)) !== canonicalHash(semanticContent(fixture)) || state.kind !== "mechanics-smoke" || state.sourceAudioSha256 !== audioSha256) {
      throw new Error("Fixture approval is restricted to the exact packaged smoke input and its generated mechanics audio.");
    }
    reviewId = review.reviewId;
    approvedBy = "packaged smoke fixture";
    note = "Fixed synthetic mechanics proof only; this is not user approval or perceptual review of an episode.";
  }
  if (typeof reviewId !== "string" || !reviewId || typeof approvedBy !== "string" || !approvedBy.trim() || typeof note !== "string" || !note.trim()) {
    throw new Error("Explicit user approval required: provide --review-id=<displayed-id> --approved-by=<name> --note=<chat-confirmation> after showing script-review.html.");
  }
  if (reviewId !== review.reviewId || reviewId !== scriptReviewId(input, review, audioSha256)) {
    throw new Error("The displayed review ID is stale. Regenerate review-script, show the updated playable review, and obtain explicit approval again.");
  }
  if (!(await exists(path.join(runDirectory, "script-review.html")))) throw new Error("The playable review is missing. Generate and show it before approval.");
  const mediaErrors = await reviewMediaErrors({ runDirectory, review, beatCount: input.timeline.length });
  if (mediaErrors.length) throw new Error(mediaErrors.join("\n"));
  review.approval = {
    approved: true,
    basis: fixtureProof === "smoke" ? "packaged-smoke-fixture" : "user-confirmed-complete-script",
    approvedBy: approvedBy.trim(), approvalNote: note.trim(), scriptHash: reviewedScriptHash(input, review),
  };
  const applied = approveScriptReviewDocument({ input, review, audioSha256 });
  const { validateEpisodeInput } = await import("./validate.mjs");
  const { probe, audioDuration } = await import("./common.mjs");
  const assets = await readJson(path.join(root, "assets.json"));
  const errors = validateEpisodeInput({ input: applied.input, assets, durationSeconds: audioDuration(await probe(audioFile)) });
  if (errors.length) throw new Error(`Approved input is invalid:\n- ${errors.join("\n- ")}`);
  await writeJson(inputPath, applied.input);
  review.status = "applied";
  review.appliedAt = applied.receipt.appliedAt;
  review.reviewFingerprint = reviewFingerprint(applied.input);
  review.reviewId = applied.receipt.reviewId;
  await writeJson(reviewPath, review);
  await writeFile(path.join(runDirectory, "timed-role-sheet.md"), applied.timedRoleSheet, "utf8");
  await writeFile(path.join(runDirectory, "script-review.html"), reviewPageHtml({ input: applied.input, review }), "utf8");
  await writeJson(path.join(runDirectory, ".script-approval.json"), applied.receipt);
  return applied.receipt;
}
Audio intakeruntime/intake.mjs
import { createHash, randomUUID } from "node:crypto";
import { createReadStream, createWriteStream } from "node:fs";
import { copyFile, mkdir, open, readdir, rename, rm, stat } from "node:fs/promises";
import path from "node:path";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { execute, exists, readJson, writeJson } from "./common.mjs";

const relative = (root, file) => path.relative(root, file).split(path.sep).join("/");
const paths = (root) => ({
  python: path.join(root, ".intake-env", "bin", "python"),
  model: path.join(root, ".intake-models", "small.en"),
  manifest: path.join(root, "scripts", "intake-model.json"),
  requirements: path.join(root, "scripts", "intake-requirements.lock"),
  helper: path.join(root, "scripts", "intake.py"),
});

async function digest(file) {
  const hash = createHash("sha256");
  for await (const chunk of createReadStream(file)) hash.update(chunk);
  return hash.digest("hex");
}

export function sourceAssertion(source) {
  const parsed = new URL(source);
  if (!["https:", "http:"].includes(parsed.protocol) || parsed.username || parsed.password) {
    throw new Error("Use a public HTTP(S) video link without embedded credentials, or supply a local file.");
  }
  const youtubeId = /(^|\.)youtube\.com$/i.test(parsed.hostname) ? parsed.searchParams.get("v") : null;
  parsed.search = "";
  if (/^[a-zA-Z0-9_-]{11}$/.test(youtubeId || "")) parsed.searchParams.set("v", youtubeId);
  parsed.hash = "";
  return parsed.href;
}

export function classifyRetrievalFailure(message) {
  if (/login|log.in|sign.in|authentication|cookies|private video|age.restrict/i.test(message)) return "login-required";
  if (/timed?.?out|network|connection|resolve|dns|unreachable|ssl|certificate|http error 5\d\d/i.test(message)) return "network-error";
  return "source-inaccessible";
}

async function checkSetup(root, exec) {
  const p = paths(root);
  const missing = [];
  let versions = {};
  for (const tool of ["ffmpeg", "ffprobe"]) {
    try { await exec(tool, ["-version"], { capture: true }); }
    catch { missing.push(`${tool} is missing or unusable; run the dependency doctor.`); }
  }
  if (!await exists(p.python)) missing.push("Kit-local Python environment is missing.");
  else {
    try {
      const result = JSON.parse(await exec(p.python, [p.helper, "--check", "--requirements", p.requirements], { capture: true, stdoutOnly: true }));
      versions = result.versions;
      if (!result.ready) missing.push(...result.missing);
    } catch { missing.push("Kit-local Python dependencies could not be checked."); }
  }
  const manifest = await readJson(p.manifest);
  for (const file of manifest.files) {
    const local = path.join(p.model, file.name);
    if (!await exists(local) || (await stat(local)).size !== file.bytes || await digest(local) !== file.sha256) missing.push(`Missing or modified model file: ${file.name}`);
  }
  return { ready: missing.length === 0, missing, versions, modelRevision: manifest.revision };
}

export async function setupIntake({ root, exec = execute, fetcher = fetch }) {
  if (!((process.platform === "darwin" && process.arch === "arm64") || (process.platform === "linux" && process.arch === "x64"))) {
    throw new Error("Local intake supports Apple Silicon macOS or Linux x64 (including Linux binaries inside WSL); native Windows is not supported.");
  }
  const p = paths(root);
  const lockPath = path.join(root, ".intake-setup.lock");
  const lock = await open(lockPath, "wx").catch(() => { throw new Error("Intake setup is already running or was interrupted. Inspect .intake-setup.lock before retrying."); });
  try {
    if (!await exists(p.python)) {
      const python = process.env.PYTHON || "python3.12";
      await exec(python, ["-c", "import sys; assert sys.version_info[:2] == (3,12), 'Python 3.12 is required'"], { capture: true });
      await exec(python, ["-m", "venv", path.join(root, ".intake-env")], { capture: true });
    }
    await exec(p.python, ["-m", "pip", "--isolated", "--disable-pip-version-check", "install", "--no-input", "--no-cache-dir", "--index-url", "https://pypi.org/simple", "--require-hashes", "--only-binary=:all:", "-r", p.requirements], { capture: true });
    const manifest = await readJson(p.manifest);
    await mkdir(p.model, { recursive: true });
    for (const file of manifest.files) {
      const destination = path.join(p.model, file.name);
      if (await exists(destination) && (await stat(destination)).size === file.bytes && await digest(destination) === file.sha256) continue;
      const url = `https://huggingface.co/${manifest.repository}/resolve/${manifest.revision}/${file.name}`;
      const temporary = `${destination}.${randomUUID()}.download`;
      try {
        const response = await fetcher(url, { signal: AbortSignal.timeout(300_000) });
        if (!response.ok || !response.body) throw new Error(`Model setup download failed: HTTP ${response.status}`);
        await pipeline(Readable.fromWeb(response.body), createWriteStream(temporary, { flags: "wx" }));
        if ((await stat(temporary)).size !== file.bytes || await digest(temporary) !== file.sha256) throw new Error(`Model checksum mismatch: ${file.name}`);
        await rename(temporary, destination);
      } finally { await rm(temporary, { force: true }); }
    }
    const readiness = await checkSetup(root, exec);
    if (!readiness.ready) throw new Error(readiness.missing.join("\n"));
    await writeJson(path.join(root, ".intake-models", "setup.json"), { schemaVersion: 1, ...readiness });
    return { status: "ready", ...readiness };
  } finally {
    await lock.close();
    await rm(lockPath, { force: true });
  }
}

async function probe(file, exec) {
  return JSON.parse(await exec("ffprobe", ["-v", "error", "-show_streams", "-show_format", "-of", "json", file], { capture: true, stdoutOnly: true }));
}

export async function intakeMedia({ root, runDirectory, source, exec = execute }) {
  const receiptPath = path.join(runDirectory, "intake.json");
  await mkdir(runDirectory, { recursive: true });
  let sourceInfo;
  const persist = async (status, message, owner = "operator", extra = {}) => {
    const result = { schemaVersion: 1, status, ...(sourceInfo ? { source: sourceInfo } : {}), nextAction: { owner, action: status, message }, ...extra };
    await writeJson(receiptPath, result);
    return result;
  };
  if (typeof source !== "string" || !source.trim()) return persist("invalid-source", "Supply a supported accessible video link or an existing local media file.", "user");
  const isUrl = /^[a-z][a-z\d+.-]*:\/\//i.test(source);
  const identity = createHash("sha256").update(source).digest("hex");
  const privateDir = path.join(runDirectory, "private", `source-${identity.slice(0, 24)}`);
  const previous = await exists(receiptPath) ? await readJson(receiptPath) : null;
  if (previous?.status === "needs-script-draft" && previous.source?.requestHash !== identity) {
    return { schemaVersion: 1, status: "invalid-source", nextAction: { owner: "agent", action: "new-run-required", message: "This run already has prepared media. Use a new run for a different source; the existing intake was preserved." } };
  }
  try {
    sourceInfo = isUrl ? { kind: "url", assertedUrl: sourceAssertion(source), assertionBasis: "user-supplied source, not independent verification", requestHash: identity } : { kind: "file", requestHash: identity };
    if (!isUrl && (!await exists(source) || !(await stat(source)).isFile())) return persist("invalid-source", "The local media file is missing. Supply an existing downloaded file.", "user");
  } catch (error) { return persist("invalid-source", error.message, "user"); }
  if (previous?.status === "needs-script-draft") {
    const artifacts = [previous.audio, previous.transcript, previous.asrAudio, { file: previous.source.originalFile, sha256: previous.source.sha256 }];
    const unchanged = await Promise.all(artifacts.map(async (item) => item && await exists(path.join(runDirectory, item.file)) && await digest(path.join(runDirectory, item.file)) === item.sha256));
    if (unchanged.every(Boolean) && (isUrl || await digest(source) === previous.source.sha256)) return previous;
    return { schemaVersion: 1, status: "invalid-source", nextAction: { owner: "agent", action: "new-run-required", message: "Prepared media or transcription changed. Preserve this run and start a fresh intake; stale evidence cannot be reused." } };
  }
  const readiness = await checkSetup(root, exec);
  if (!readiness.ready) return persist("setup-required", `Run explicit setup-intake first. ${readiness.missing.join(" ")}`, "operator");
  const p = paths(root);
  await mkdir(privateDir, { recursive: true });
  await writeJson(path.join(privateDir, "request.json"), { source });
  let original;
  const acquiredPath = path.join(privateDir, "acquired.json");
  if (await exists(acquiredPath)) {
    const acquired = await readJson(acquiredPath);
    const candidate = path.join(privateDir, acquired.file);
    if (path.dirname(candidate) === privateDir && await exists(candidate) && await digest(candidate) === acquired.sha256 && (isUrl || await digest(source) === acquired.sha256)) original = candidate;
  }
  if (!original && isUrl) {
    try {
      await exec(p.python, ["-m", "yt_dlp", "--ignore-config", "--no-plugin-dirs", "--no-playlist", "--max-downloads", "1", "--no-cache-dir", "--no-cookies-from-browser", "--no-netrc", "--no-progress", "--write-info-json", "--no-overwrites", "--format", "bestaudio/best", "--output", path.join(privateDir, "source.%(ext)s"), "--", source], { capture: true, timeoutMs: 180_000 });
    } catch (error) {
      // yt-dlp exits 101 after reaching an explicit one-item limit, even if that item succeeded.
      if (!/exited 101\b/.test(error.message)) {
        const status = classifyRetrievalFailure(error.message);
        const reason = { "login-required": "The site requires login; automatic credential or cookie access is disabled.", "network-error": "The connection failed. Retry after connectivity is restored, or use a local file.", "source-inaccessible": "This link is unsupported, unavailable, or access-restricted." }[status];
        return persist(status, `${reason} Ask the user for a downloaded media file if needed.`, status === "network-error" ? "operator" : "user");
      }
    }
    const candidates = (await readdir(privateDir)).filter((name) => /^source\.[a-z0-9]{1,8}$/i.test(name) && !/\.(part|json|ytdl|temp)$/i.test(name));
    if (candidates.length !== 1) return persist("source-inaccessible", "No single usable media file was retrieved. Ask the user for a downloaded local file.", "user");
    original = path.join(privateDir, candidates[0]);
  } else if (!original) {
    const suffix = /^\.[a-z0-9]{1,8}$/i.test(path.extname(source)) ? path.extname(source).toLowerCase() : ".media";
    original = path.join(privateDir, `source${suffix}`);
    const temporary = `${original}.${randomUUID()}.tmp`;
    try {
      await copyFile(source, temporary);
      await rename(temporary, original);
    } finally { await rm(temporary, { force: true }); }
  }
  await writeJson(acquiredPath, { file: path.basename(original), sha256: await digest(original) });
  const infoFile = path.join(privateDir, "source.info.json");
  if (isUrl && await exists(infoFile)) {
    try {
      const metadata = await readJson(infoFile);
      sourceInfo = { ...sourceInfo, credits: { title: String(metadata.title || "").slice(0, 500), creator: String(metadata.uploader || metadata.channel || "").slice(0, 200), assertionBasis: "downloader-reported metadata, not independent verification" } };
    } catch { /* Optional metadata never substitutes for verified media. */ }
  }
  let media;
  try { media = await probe(original, exec); }
  catch { return persist("missing-audio", "The source could not be decoded. Verify FFprobe is installed and ask for a playable media file with audio.", "operator"); }
  const stream = media.streams?.find((item) => item.codec_type === "audio");
  if (!stream) return persist("missing-audio", "This file has no audio track. Ask the user for a version containing the original audio.", "user");
  sourceInfo = { ...sourceInfo, originalFile: relative(runDirectory, original), sha256: await digest(original), sourceAudioStartSeconds: Number(stream.start_time || 0) };
  const audioFile = path.join(runDirectory, "user-audio.wav");
  const asrFile = path.join(privateDir, "asr-audio.wav");
  try {
    // Do not trim to the video duration: some sources retain their final word beyond the last video frame.
    await exec("ffmpeg", ["-v", "error", "-y", "-i", original, "-map", "0:a:0", "-vn", "-c:a", "pcm_s24le", audioFile], { capture: true });
    await exec("ffmpeg", ["-v", "error", "-y", "-i", audioFile, "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", asrFile], { capture: true });
  } catch { return persist("missing-audio", "Audio extraction failed. Check FFmpeg installation and source readability; the original remains preserved.", "operator"); }
  const extracted = await probe(audioFile, exec);
  const asr = await probe(asrFile, exec);
  const decodedStream = extracted.streams.find((item) => item.codec_type === "audio");
  const duration = Number(decodedStream?.duration || extracted.format?.duration);
  if (!(duration > 0)) return persist("missing-audio", "The decoded audio is empty. Supply a media file with audible content.", "user");
  const audio = { file: "user-audio.wav", sha256: await digest(audioFile), durationSeconds: duration, sampleRate: Number(decodedStream.sample_rate), channels: decodedStream.channels };
  const asrAudio = { file: relative(runDirectory, asrFile), sha256: await digest(asrFile), durationSeconds: Number(asr.format?.duration) };
  const transcriptPath = path.join(runDirectory, "transcript.json");
  try {
    await exec(p.python, [p.helper, "--requirements", p.requirements, "--audio", asrFile, "--model", p.model, "--manifest", p.manifest, "--source-offset", String(sourceInfo.sourceAudioStartSeconds), "--output", transcriptPath], { capture: true, stdoutOnly: true });
  } catch { return persist("transcription-failed", "Local transcription failed or found no words. The agent must inspect the preserved source and diagnose; do not ask the user to author timestamps.", "agent", { audio, asrAudio }); }
  return persist("needs-script-draft", "Draft the complete episode from the source and uncertain transcript, then import it with review-script --input. Review words, timing, vocalizations, overlap, and character choices with the user; ASR did not approve or assign speakers.", "agent", { audio, asrAudio, transcript: { file: "transcript.json", sha256: await digest(transcriptPath), engine: "faster-whisper", modelRevision: readiness.modelRevision, uncertain: true } });
}
Guided workflowruntime/workflow.mjs
import { copyFile, cp, lstat, mkdir, mkdtemp, readdir, rm } from "node:fs/promises";
import path from "node:path";
import { audioDuration, exists, probe, readJson, resolveRunDirectory, sha256, writeJson } from "./common.mjs";
import { approvedRevisionId, canonicalHash, collectRenderIdentity, currentRevision, qualityPolicyIdentity, runAudioPath, semanticContent } from "./identity.mjs";
import { createScriptReview, approveScriptReview } from "./speaker-review.mjs";
import { validateEpisodeInput, validateRun } from "./validate.mjs";
import { assertRenderFresh, finalizeRun, loadQualityPolicy, verifyFinalization, verifyPlaybackEvidence, verifyTechnicalEvidence } from "./quality.mjs";
import { exportRun, verifyExport } from "./export.mjs";
import { intakeMedia } from "./intake.mjs";
import { beginCycle, cycleBucket, finishCycle, newWorkflowState, readWorkflowState, repairIdentity, saveWorkflowState } from "./workflow-state.mjs";

const ARTIFACTS = [
  "input.json", "state.json", "script-review.json", "script-review.html", "timed-role-sheet.md",
  ".script-approval.json", ".validation.json", "render-report.json", "quality-report.json",
  "playback-review.json", "contact-sheet.png", "delivery.json", "final.mp4",
];

function result(phase, owner, action, message, extra = {}) {
  return { schemaVersion: 2, status: phase === "complete" ? "complete" : "action-required", phase,
    nextAction: { owner, action, message }, ...extra };
}

async function attemptRead(file) {
  try { return await readJson(file); }
  catch (error) { if (error.code === "ENOENT") return null; throw error; }
}

async function createRun({ root, runId, kind = "episode" }) {
  const runDirectory = resolveRunDirectory(root, runId);
  await mkdir(path.dirname(runDirectory), { recursive: true });
  try { await mkdir(runDirectory); }
  catch (error) {
    if (error.code === "EEXIST") throw new Error("Run already exists. Use status/run to resume it, or choose a new run ID; initialization never overwrites a run.");
    throw error;
  }
  const state = newWorkflowState(runId, kind);
  await saveWorkflowState(runDirectory, state);
  return { runDirectory, state };
}

export async function startIntake({ root, runId, source }) {
  if (typeof source !== "string" || !source) throw new Error("Pass --source=<supported-accessible-URL-or-local-file>.");
  const { runDirectory, state } = await createRun({ root, runId });
  state.intakeSource = source; // Private state, never copied into the public export.
  await saveWorkflowState(runDirectory, state);
  const intake = await intakeMedia({ root, runDirectory, source });
  return { ...intake, run: runId, runDirectory };
}

export async function initializeEpisode({ root, runId, audio, input: inputSource }) {
  if (typeof audio !== "string" || !path.isAbsolute(audio) || !await exists(audio)) throw new Error("Pass an absolute existing --audio=/path/media.");
  if (typeof inputSource !== "string" || !path.isAbsolute(inputSource) || !await exists(inputSource)) throw new Error("Pass an absolute existing --input=/path/draft.json for advanced initialization; normal users can start with intake instead.");
  const input = await readJson(inputSource);
  const extension = /^\.[a-z0-9]{1,8}$/i.test(path.extname(audio)) ? path.extname(audio).toLowerCase() : ".media";
  input.audioFile = `user-audio${extension}`;
  const durationSeconds = audioDuration(await probe(audio));
  const errors = validateEpisodeInput({ input, assets: await readJson(path.join(root, "assets.json")), durationSeconds });
  if (errors.length) throw new Error(`Invalid draft; no run was created:\n- ${errors.join("\n- ")}`);
  const { runDirectory, state } = await createRun({ root, runId });
  await copyFile(audio, path.join(runDirectory, input.audioFile));
  state.sourceAudioSha256 = await sha256(path.join(runDirectory, input.audioFile));
  await writeJson(path.join(runDirectory, "input.json"), input);
  await saveWorkflowState(runDirectory, state);
  const review = await createScriptReview({ root, runDirectory });
  return result("needs-script-approval", "user", "review-and-approve", "Show the complete playable review and obtain explicit approval. No approval was assumed.", {
    run: runId, reviewId: review.reviewId, reviewPage: path.join(runDirectory, "script-review.html"),
  });
}

async function archiveCurrent(runDirectory, revisionId) {
  const history = path.join(runDirectory, "history");
  await mkdir(history, { recursive: true });
  const destination = await mkdtemp(path.join(history, `${revisionId.slice(0, 16)}-`));
  const input = await readJson(path.join(runDirectory, "input.json"));
  const files = [...new Set([...ARTIFACTS, input.audioFile])];
  for (const name of files) {
    const source = path.join(runDirectory, name);
    if (!await exists(source)) continue;
    if (!(await lstat(source)).isFile()) throw new Error("Revision history refuses non-file evidence.");
    await copyFile(source, path.join(destination, name));
  }
  if (await exists(path.join(runDirectory, "script-review"))) {
    for (const entry of await readdir(path.join(runDirectory, "script-review"), { withFileTypes: true })) {
      if (!entry.isFile()) throw new Error("Review history refuses symbolic links or nested entries.");
    }
    await cp(path.join(runDirectory, "script-review"), path.join(destination, "script-review"), { recursive: true, dereference: false, errorOnExist: true, force: false });
  }
  const hashes = [];
  for (const name of files) if (await exists(path.join(destination, name))) hashes.push({ path: name, sha256: await sha256(path.join(destination, name)) });
  await writeJson(path.join(destination, "history.json"), { schemaVersion: 1, revisionId, preservedAt: new Date().toISOString(), files: hashes });
  // Only clear generated current evidence after its recoverable copy exists.
  for (const name of ARTIFACTS.filter((name) => !["input.json", "state.json", "final.mp4", "contact-sheet.png"].includes(name))) {
    await rm(path.join(runDirectory, name), { force: true });
  }
  return destination;
}

export async function importDraft({ root, runId, input: source, newRevision = false }) {
  const runDirectory = resolveRunDirectory(root, runId);
  const state = await readWorkflowState(runDirectory);
  if (!source) {
    const review = await createScriptReview({ root, runDirectory });
    return { status: review.status, reviewId: review.reviewId, reviewPage: path.join(runDirectory, "script-review.html") };
  }
  if (typeof source !== "string" || !path.isAbsolute(source) || !await exists(source)) throw new Error("Pass an absolute existing --input=/path/draft.json.");
  const previous = await attemptRead(path.join(runDirectory, "input.json"));
  const intake = await attemptRead(path.join(runDirectory, "intake.json"));
  const audioFile = previous?.audioFile || intake?.audio?.file;
  if (!audioFile) throw new Error("Complete media intake before importing the agent's draft.");
  if (!previous && intake?.status !== "needs-script-draft") throw new Error("Media intake is not ready for a draft.");
  const candidate = await readJson(source);
  candidate.audioFile = audioFile;
  const audio = runAudioPath(runDirectory, candidate);
  const audioSha256 = await sha256(audio);
  if (intake?.audio?.sha256 && intake.audio.sha256 !== audioSha256) throw new Error("Prepared source audio changed. Preserve this run and create a fresh intake.");
  const errors = validateEpisodeInput({ input: candidate, assets: await readJson(path.join(root, "assets.json")), durationSeconds: audioDuration(await probe(audio)) });
  if (errors.length) throw new Error(`Invalid draft; current input remains unchanged:\n- ${errors.join("\n- ")}`);
  const changed = previous && canonicalHash(semanticContent(previous)) !== canonicalHash(semanticContent(candidate));
  const progressed = previous && (await exists(path.join(runDirectory, ".script-approval.json")) || await exists(path.join(runDirectory, "render-report.json")) || Object.keys(state.revisions).length);
  if (changed && progressed && !newRevision) throw new Error("Use --new-revision to preserve previous outputs before changing a progressed run.");
  let history = null;
  if (changed && progressed) {
    const oldApproval = await attemptRead(path.join(runDirectory, ".script-approval.json"));
    history = await archiveCurrent(runDirectory, oldApproval?.revisionId || approvedRevisionId(previous, audioSha256));
    state.export = null;
    state.history = [...(state.history || []), path.relative(runDirectory, history)];
  }
  if (!previous || changed) await writeJson(path.join(runDirectory, "input.json"), candidate);
  await saveWorkflowState(runDirectory, state);
  const review = await createScriptReview({ root, runDirectory });
  return result("needs-script-approval", "user", "review-and-approve", "Show the complete playable review; the agent drafted it, but has not approved it.", {
    reviewId: review.reviewId, reviewPage: path.join(runDirectory, "script-review.html"), history,
  });
}

export async function approveEpisode({ root, runId, reviewId, approvedBy, note }) {
  const runDirectory = resolveRunDirectory(root, runId);
  await readWorkflowState(runDirectory);
  // No attempt history is cleared here: approval time cannot create a new budget.
  return approveScriptReview({ root, runDirectory, reviewId, approvedBy, note });
}

async function verifyIntakeFiles(runDirectory, intake) {
  for (const artifact of [intake.audio, intake.transcript, intake.asrAudio]) {
    if (!artifact?.file || !artifact.sha256) throw new Error("Intake evidence is incomplete.");
    const file = path.resolve(runDirectory, artifact.file);
    if (!file.startsWith(path.resolve(runDirectory) + path.sep) || !await exists(file) || await sha256(file) !== artifact.sha256) throw new Error("Intake media or transcript is missing or modified; preserve this run and start a new intake.");
  }
}

export async function workflowStatus({ root, runId }) {
  const runDirectory = resolveRunDirectory(root, runId);
  const paths = { runDirectory, reviewPage: path.join(runDirectory, "script-review.html"), preview: path.join(runDirectory, "final.mp4") };
  if (!await exists(runDirectory)) return result("not-initialized", "agent", "intake", "Start intake with a supported accessible link or local video.", { run: runId });
  let state;
  try { state = await readWorkflowState(runDirectory); }
  catch (error) { return result("needs-upgrade", "agent", "upgrade-run", error.message, paths); }
  if (!await exists(path.join(runDirectory, "input.json"))) {
    const intake = await attemptRead(path.join(runDirectory, "intake.json"));
    if (!intake) return result("needs-intake", "agent", "run", "Resume the saved media intake; do not invent a sample episode.", paths);
    if (intake.status !== "needs-script-draft") return { ...intake, ...paths };
    try { await verifyIntakeFiles(runDirectory, intake); }
    catch (error) { return result("intake-evidence-invalid", "agent", "new-run-required", error.message, paths); }
    return result("needs-script-draft", "agent", "draft-and-import", "Use the uncertain transcript and original source to draft the full episode, then review-script --input. Do not ask the user to supply timestamps.", {
      ...paths, evidence: [path.join(runDirectory, "transcript.json"), path.join(runDirectory, intake.audio.file)],
    });
  }
  try { await validateRun({ root, runDirectory, writeReceipt: false, requireApproval: false }); }
  catch (error) { return result("input-or-setup-invalid", "agent", "diagnose", error.message, paths); }
  try { await validateRun({ root, runDirectory, writeReceipt: false }); }
  catch (error) {
    const approval = await attemptRead(path.join(runDirectory, ".script-approval.json"));
    const review = await attemptRead(path.join(runDirectory, "script-review.json"));
    return result(approval ? "approval-or-review-stale" : "needs-script-approval", approval || !review ? "agent" : "user",
      approval || !review ? "regenerate-review" : "review-and-approve", error.message, { ...paths, reviewId: review?.reviewId });
  }
  const current = await currentRevision(runDirectory);
  const attempts = cycleBucket(state, current.revisionId).attempts;
  const info = { ...paths, revisionId: current.revisionId, attemptsUsed: attempts.length, attempt: attempts.at(-1) || null };
  try { await verifyTechnicalEvidence({ root, runDirectory }); }
  catch (error) {
    const last = attempts.at(-1);
    if (last?.status !== "running" && attempts.length >= 3) return result("attempt-limit", "operator", "resolve-blocker", "Three technical cycles are exhausted for this content-and-audio revision; unchanged reapproval cannot reset them.", { ...info, unmet: [error.message] });
    if (last?.status === "failed") {
      const identity = await collectRenderIdentity({ root, runDirectory });
      if (last.repairIdentity === await currentRepairIdentity({ root, runDirectory, renderIdentity: identity })) {
        return result("repair-required", "agent", "diagnose-and-repair", "The last cycle failed and no relevant repair is evidenced. Diagnose the recorded failure before retrying; notes or unchanged reapproval do not count.", { ...info, unmet: [error.message] });
      }
    }
    return result("needs-technical-cycle", "agent", "run", error.message, info);
  }
  try { await verifyPlaybackEvidence({ root, runDirectory }); }
  catch (error) { return result("needs-playback-review", "agent", "review-and-record-playback", error.message, info); }
  try { await verifyFinalization({ root, runDirectory }); }
  catch (error) { return result("needs-finalization", "agent", "run", error.message, info); }
  const output = state.export?.output || path.join(root, "outputs", runId);
  try {
    const verified = await verifyExport({ root, runDirectory, output });
    if (state.export && state.export.manifestHash !== verified.manifestHash) throw new Error("Export manifest differs from the saved verified export.");
    return result("complete", "agent", "show-video", "The finalized export verifies. Show the actual video and disclose review limitations; this does not claim the user watched it.", {
      ...info, export: verified.output, video: verified.video, manifestHash: verified.manifestHash,
    });
  } catch (error) {
    return result(await exists(output) ? "export-invalid" : "needs-export", "agent", await exists(output) ? "choose-new-export-destination" : "run",
      error.message, { ...info, output });
  }
}

async function currentRepairIdentity({ root, runDirectory, renderIdentity }) {
  return repairIdentity({
    renderIdentity,
    policyHash: await qualityPolicyIdentity(root),
    outputSha256: await exists(path.join(runDirectory, "final.mp4")) ? await sha256(path.join(runDirectory, "final.mp4")) : null,
  });
}

export async function technicalCycle({ root, runId, renderOnly = false }) {
  const runDirectory = resolveRunDirectory(root, runId);
  const state = await readWorkflowState(runDirectory);
  await validateRun({ root, runDirectory });
  const identity = await collectRenderIdentity({ root, runDirectory });
  // Repeating a successful command is idempotent, not another paid/technical attempt.
  try {
    const current = await verifyTechnicalEvidence({ root, runDirectory });
    const pending = cycleBucket(state, identity.revisionId).attempts.at(-1);
    if (pending?.status === "running") { finishCycle(pending); await saveWorkflowState(runDirectory, state); }
    return current.technical;
  } catch { /* Missing/stale technical evidence needs a bounded cycle. */ }
  const policy = await loadQualityPolicy(root);
  const attempt = beginCycle(state, identity.revisionId, await currentRepairIdentity({ root, runDirectory, renderIdentity: identity }), policy.automatic.maximumAttempts);
  await saveWorkflowState(runDirectory, state); // Persist the attempt before expensive work.
  try {
    let fresh = false;
    try { await assertRenderFresh({ root, runDirectory }); fresh = true; } catch { /* Render is required. */ }
    if (!fresh) {
      attempt.stage = "render";
      await saveWorkflowState(runDirectory, state);
      const { renderRun } = await import("./render.mjs");
      await renderRun({ root, runDirectory });
    }
    attempt.stage = "inspect";
    await saveWorkflowState(runDirectory, state);
    if (renderOnly) return { status: "rendered-needs-inspection", attemptId: attempt.id, output: path.join(runDirectory, "final.mp4") };
    const { inspectRun } = await import("./inspect.mjs");
    const report = await inspectRun({ root, runDirectory });
    finishCycle(attempt);
    await saveWorkflowState(runDirectory, state);
    return report;
  } catch (error) {
    // Compare future repairs with the actual failed output, not the pre-render file.
    attempt.repairIdentity = await currentRepairIdentity({ root, runDirectory, renderIdentity: identity }).catch(() => attempt.repairIdentity);
    finishCycle(attempt, error);
    await saveWorkflowState(runDirectory, state);
    throw error;
  }
}

export async function runWorkflow({ root, runId, source, output, includeReviewMedia = false }) {
  const runDirectory = resolveRunDirectory(root, runId);
  let state = await readWorkflowState(runDirectory);
  if (!await exists(path.join(runDirectory, "input.json")) && (source || state.intakeSource)) {
    const intake = await intakeMedia({ root, runDirectory, source: source || state.intakeSource });
    if (source && intake.status !== "invalid-source") {
      state.intakeSource = source;
      await saveWorkflowState(runDirectory, state);
    }
    if (intake.status !== "needs-script-draft") return { ...intake, runDirectory };
  }
  let status = await workflowStatus({ root, runId });
  if (status.phase === "needs-technical-cycle") {
    await technicalCycle({ root, runId });
    status = await workflowStatus({ root, runId });
  }
  if (status.phase === "needs-finalization") {
    await finalizeRun({ root, runDirectory });
    status = await workflowStatus({ root, runId });
  }
  if (status.phase === "needs-export" || output && ["export-invalid", "complete"].includes(status.phase)) {
    const exported = await exportRun({ root, runDirectory, output, includeReviewMedia });
    state = await readWorkflowState(runDirectory);
    state.export = { output: exported.output, manifestHash: exported.manifestHash };
    await saveWorkflowState(runDirectory, state);
    status = await workflowStatus({ root, runId });
  }
  return status;
}

export async function exportEpisode({ root, runId, output, includeReviewMedia = false }) {
  const runDirectory = resolveRunDirectory(root, runId);
  const state = await readWorkflowState(runDirectory);
  const exported = await exportRun({ root, runDirectory, output, includeReviewMedia });
  state.export = { output: exported.output, manifestHash: exported.manifestHash };
  await saveWorkflowState(runDirectory, state);
  return exported;
}

export async function upgradeRun({ root, runId, newRunId }) {
  const oldRun = resolveRunDirectory(root, runId);
  const nextRun = resolveRunDirectory(root, newRunId);
  if (nextRun === oldRun) throw new Error("Upgrade requires a different new run ID.");
  const oldState = await attemptRead(path.join(oldRun, "state.json"));
  if (oldState?.schemaVersion === 2) throw new Error("This run already uses the current workflow. Resume it or import a genuine new revision; upgrade cannot reset its attempt history.");
  const input = await readJson(path.join(oldRun, "input.json"));
  const audio = runAudioPath(oldRun, input);
  // A new run is initialized with preserved input/audio, never old approval or review receipts.
  const created = await initializeEpisode({ root, runId: newRunId, audio, input: path.join(oldRun, "input.json") });
  const state = await readWorkflowState(nextRun);
  state.upgradedFrom = { runId, inputSha256: await sha256(path.join(oldRun, "input.json")), audioSha256: await sha256(audio) };
  await saveWorkflowState(nextRun, state);
  return { ...created, preservedRun: oldRun, requiresFreshApproval: true };
}
Verified exportruntime/export.mjs
import { copyFile, lstat, mkdir, mkdtemp, readdir, rename, rm, rmdir, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { exists, readJson, sha256, writeJson } from "./common.mjs";
import { canonicalHash } from "./identity.mjs";
import { verifyFinalization } from "./quality.mjs";

const MANIFEST = "checksums.json";
const BASE_FILES = ["final.mp4", "index.html", "evidence/review-summary.json", "evidence/contact-sheet.png"];
const escapeHtml = (value) => String(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");

function presentationText(value) {
  return String(value ?? "")
    .replace(/https?:\/\/[^\s<>"']+/gi, "[source link omitted]")
    .replace(/(?:[A-Za-z]:[\\/]|\/)[^\s<>"']+/g, "[local path omitted]")
    .replace(/\b(?:Bearer\s+|(?:api[_-]?key|access[_-]?token|password|secret|token|signature)\s*[:=]\s*)[^\s,;]+/gi, "[credential omitted]");
}

function exportPath(root, runDirectory, output) {
  const destination = path.resolve(output || path.join(root, "outputs", path.basename(runDirectory)));
  const run = path.resolve(runDirectory);
  if (destination === run || destination.startsWith(run + path.sep) || run.startsWith(destination + path.sep)) throw new Error("Export destination must be separate from the private run and cannot contain it.");
  return destination;
}

async function reviewMedia(runDirectory, includeReviewMedia) {
  if (!includeReviewMedia) return [];
  const review = await readJson(path.join(runDirectory, "script-review.json"));
  const files = [{ source: "script-review/source.wav", destination: "review-media/source.wav" }];
  for (const [index, beat] of review.beats.entries()) {
    const source = String(beat.clip || "").replaceAll("\\", "/");
    if (!/^script-review\/[a-zA-Z0-9_-]+\.wav$/.test(source)) throw new Error("Review clip must be a local WAV inside script-review/.");
    files.push({ source, destination: `review-media/beat-${String(index).padStart(3, "0")}.wav`, beatIndex: index });
  }
  for (const file of files) {
    const info = await lstat(path.join(runDirectory, file.source));
    if (!info.isFile() || info.isSymbolicLink()) throw new Error("Review media must be regular local files, not links.");
    const canonical = review.media?.find((media) => media.path === file.source);
    if (!canonical || canonical.sha256 !== await sha256(path.join(runDirectory, file.source))) throw new Error("Review media no longer matches the approved review hashes.");
  }
  return files;
}

function reviewSummary({ current, input, formatVersion, media }) {
  const { playback, delivery, deliverySha256 } = current;
  const criteria = playback.criteria.map((result) => ({ id: result.id, status: result.status }));
  const limitations = criteria.filter((result) => result.status === "unscored").map((result) => `${result.id}: not scored because direct auditory perception was unavailable. Detailed reviewer reasons remain in the private canonical record.`);
  return {
    schemaVersion: 1,
    kind: "sanitized-export-presentation-not-canonical-evidence",
    formatVersion,
    episode: {
      title: presentationText(input.title), episodeLabel: presentationText(input.episodeLabel), background: presentationText(input.background),
      timeline: input.timeline.map((beat) => ({
        start: beat.start, end: beat.end, speaker: beat.speaker, camera: beat.camera,
        caption: presentationText(beat.caption), ...(beat.vocalization ? { vocalization: presentationText(beat.vocalization) } : {}),
        ...(beat.captionSpeaker ? { captionSpeaker: beat.captionSpeaker } : {}),
        ...(beat.bounceAt ? { bounceAt: beat.bounceAt } : {}),
      })),
    },
    review: {
      status: "pass", rubricVersion: playback.rubricVersion,
      technicalStatus: current.technical.status,
      perception: { visual: playback.perception.visual.mode, audio: playback.perception.audio.mode },
      passes: playback.passes.map((pass) => ({ id: pass.id, completed: pass.completed })), criteria, limitations,
      attestationLimit: "Recorded review results are attestations, not proof that software perceived the video or audio.",
    },
    canonicalEvidence: [...delivery.evidence, { path: "delivery.json", sha256: deliverySha256 }],
    media: media.map(({ destination, beatIndex }) => ({ path: destination, ...(beatIndex !== undefined ? { beatIndex } : {}) })),
    omissions: [
      "Original source media, raw downloader metadata, URLs, credentials, machine paths, and free-text source assertions are not exported.",
      "Canonical approval, render, quality, and playback records remain unchanged in the private run. Their hashes identify originals; this summary is a separate sanitized presentation, not rewritten canonical evidence.",
      "Reviewer identity, free-text observations, uncertainty notes, overlap explanations, and detailed perception reasons remain in the private evidence records.",
      ...(media.length ? ["Review WAVs were explicitly included; original downloaded video remains excluded."] : ["Source/review audio is omitted; no unavailable audio controls are shown."]),
    ],
  };
}

function reviewPage(summary) {
  const text = (value) => escapeHtml(value);
  const rows = summary.episode.timeline.map((beat, index) => {
    const clip = summary.media.find((entry) => entry.beatIndex === index);
    return `<tr><td>${text(beat.start)}–${text(beat.end)}s</td><td>${text(beat.speaker === "cat" ? "Dog" : beat.speaker)}</td><td>${text(beat.caption || beat.vocalization || "Silence")}</td><td>${text(beat.camera)}</td>${summary.media.length ? `<td>${clip ? `<audio controls preload="none" src="${text(clip.path)}"></audio>` : ""}</td>` : ""}</tr>`;
  }).join("");
  const fullAudio = summary.media.find((entry) => entry.path === "review-media/source.wav");
  return `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; media-src 'self' file:; img-src 'self' file:; base-uri 'none'; form-action 'none'"><title>${text(summary.episode.title)}</title>
<style>body{font:16px system-ui,sans-serif;margin:0;background:#f7f7f3;color:#191919}main{max-width:1040px;margin:auto;padding:32px 24px}h1{font-size:clamp(28px,5vw,48px);line-height:1.1;overflow-wrap:anywhere}video{display:block;max-height:75vh;max-width:100%;margin:24px auto;border-radius:12px;background:#111}a{color:#184ca8}section{margin:32px 0;padding:24px;background:white;border-radius:12px}table{border-collapse:collapse;width:100%;font-size:14px}td,th{padding:10px;text-align:left;border-bottom:1px solid #ddd;vertical-align:top}audio{max-width:240px;width:100%}.scroll{overflow:auto}img{max-width:100%;height:auto}code{overflow-wrap:anywhere}li{margin:8px 0}</style><main>
<p>Animal Conversations · ${text(summary.formatVersion)}</p><h1>${text(summary.episode.title)}</h1><video controls playsinline preload="metadata" src="final.mp4"></video><p><a href="final.mp4">Open video file</a></p><p>The MP4 is already included in this folder as <code>final.mp4</code>.</p>
<section><h2>Review status</h2><p>Required technical checks and recorded playback review passed. Background: ${text(summary.episode.background)}.</p><p>Visual perception: ${text(summary.review.perception.visual)}. Audio perception: ${text(summary.review.perception.audio)}.</p><ul>${summary.review.limitations.map((value) => `<li>${text(value)}</li>`).join("")}</ul><p>${text(summary.review.attestationLimit)}</p></section>
<section><h2>Approved episode — presentation copy</h2><p>Episode label: ${text(summary.episode.episodeLabel)}</p>${fullAudio ? `<p>Exact review audio</p><audio controls preload="none" src="${text(fullAudio.path)}"></audio>` : "<p>Review audio was not included in this export.</p>"}<div class="scroll"><table><thead><tr><th>Time</th><th>Character</th><th>Line / sound</th><th>Camera</th>${summary.media.length ? "<th>Review clip</th>" : ""}</tr></thead><tbody>${rows}</tbody></table></div></section>
<section><h2>Inspection contact sheet</h2><img src="evidence/contact-sheet.png" alt="Rendered video inspection contact sheet"></section>
<section><h2>What is included</h2><p><a href="evidence/review-summary.json">Sanitized review summary</a> · <a href="checksums.json">File checksums</a></p><ul>${summary.omissions.map((value) => `<li>${text(value)}</li>`).join("")}</ul></section></main></html>`;
}

async function regularFiles(directory, prefix = "") {
  const files = [];
  for (const entry of await readdir(directory, { withFileTypes: true })) {
    const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
    if (entry.isSymbolicLink()) throw new Error("Export contains an unexpected symbolic link.");
    if (entry.isDirectory()) files.push(...await regularFiles(path.join(directory, entry.name), relative));
    else if (entry.isFile()) files.push(relative);
    else throw new Error("Export contains a non-file artifact.");
  }
  return files.sort();
}

async function verifyBundle({ directory, current, media }) {
  if (!(await lstat(directory)).isDirectory() || (await lstat(directory)).isSymbolicLink()) throw new Error("Export destination must be a regular directory.");
  const manifest = await readJson(path.join(directory, MANIFEST));
  if (manifest.schemaVersion !== 2 || manifest.kind !== "animal-conversations-export" || manifest.revisionId !== current.identity.revisionId || manifest.deliverySha256 !== current.deliverySha256 || manifest.finalVideoSha256 !== current.outputSha256 || manifest.qualityPolicyHash !== current.qualityPolicyHash) throw new Error("Export does not match the current finalized episode.");
  const expected = [...BASE_FILES, ...media.map((file) => file.destination)].sort();
  if (canonicalHash((manifest.files || []).map((file) => file.path).sort()) !== canonicalHash(expected)) throw new Error("Export manifest is missing required files or lists unexpected files.");
  if (canonicalHash(await regularFiles(directory)) !== canonicalHash([...expected, MANIFEST].sort())) throw new Error("Export contains missing or unexpected artifacts.");
  for (const file of manifest.files) {
    const full = path.join(directory, file.path);
    if (await sha256(full) !== file.sha256 || (await stat(full)).size !== file.bytes) throw new Error(`Export checksum mismatch: ${file.path}`);
  }
  if (await sha256(path.join(directory, "final.mp4")) !== current.outputSha256) throw new Error("Exported video no longer matches finalization.");
  for (const file of media) if (await sha256(path.join(directory, file.destination)) !== await sha256(path.join(current.runDirectory, file.source))) throw new Error("Exported review media differs from the canonical review WAV.");
  return { schemaVersion: 2, status: "pass", output: directory, video: path.join(directory, "final.mp4"), manifest, manifestHash: await sha256(path.join(directory, MANIFEST)) };
}

export async function verifyExport({ root, runDirectory, output }) {
  const current = { ...await verifyFinalization({ root, runDirectory }), runDirectory };
  const destination = exportPath(root, runDirectory, output);
  const manifest = await readJson(path.join(destination, MANIFEST));
  if (typeof manifest.includeReviewMedia !== "boolean") throw new Error("Export manifest must explicitly state whether review media is included.");
  const media = await reviewMedia(runDirectory, manifest.includeReviewMedia);
  return verifyBundle({ directory: destination, current, media });
}

export async function exportRun({ root, runDirectory, output, includeReviewMedia = false }) {
  const current = { ...await verifyFinalization({ root, runDirectory }), runDirectory };
  const destination = exportPath(root, runDirectory, output);
  const media = await reviewMedia(runDirectory, includeReviewMedia);
  if (await exists(destination)) {
    const verified = await verifyBundle({ directory: destination, current, media });
    if (verified.manifest.includeReviewMedia !== includeReviewMedia) throw new Error("Existing export options differ; use a new destination.");
    return verified;
  }
  await mkdir(path.dirname(destination), { recursive: true });
  const staging = await mkdtemp(path.join(path.dirname(destination), ".animal-export-"));
  let reserved = false;
  try {
    await mkdir(path.join(staging, "evidence"));
    await copyFile(path.join(runDirectory, "final.mp4"), path.join(staging, "final.mp4"));
    await copyFile(path.join(runDirectory, "contact-sheet.png"), path.join(staging, "evidence", "contact-sheet.png"));
    for (const file of media) {
      await mkdir(path.dirname(path.join(staging, file.destination)), { recursive: true });
      await copyFile(path.join(runDirectory, file.source), path.join(staging, file.destination));
    }
    const input = await readJson(path.join(runDirectory, "input.json"));
    const { formatVersion } = await readJson(path.join(root, "KIT-MANIFEST.json"));
    const summary = reviewSummary({ current, input, formatVersion, media });
    await writeJson(path.join(staging, "evidence", "review-summary.json"), summary);
    await writeFile(path.join(staging, "index.html"), reviewPage(summary));
    const files = [];
    for (const file of await regularFiles(staging)) files.push({ path: file, sha256: await sha256(path.join(staging, file)), bytes: (await stat(path.join(staging, file))).size });
    await writeJson(path.join(staging, MANIFEST), {
      schemaVersion: 2, kind: "animal-conversations-export", formatVersion,
      revisionId: current.identity.revisionId, deliverySha256: current.deliverySha256,
      finalVideoSha256: current.outputSha256, qualityPolicyHash: current.qualityPolicyHash,
      includeReviewMedia, files,
    });
    await verifyBundle({ directory: staging, current, media });
    // Recheck the private evidence after staging, before publishing the verified directory.
    const stillCurrent = await verifyFinalization({ root, runDirectory });
    if (stillCurrent.deliverySha256 !== current.deliverySha256) throw new Error("Finalized content changed during export; nothing was published.");
    await mkdir(destination); // Exclusive reservation; never replace an existing unrelated directory.
    reserved = true;
    await rename(staging, destination);
    reserved = false;
    return await verifyBundle({ directory: destination, current, media });
  } finally {
    if (reserved) await rmdir(destination).catch(() => {}); // Only removes our still-empty reservation.
    await rm(staging, { recursive: true, force: true });
  }
}
Social distributionruntime/publish.mjs
#!/usr/bin/env node
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { resolve, dirname, basename } from 'node:path';
import { execFileSync } from 'node:child_process';

const USAGE = `Usage: node runtime/publish.mjs <inputs/distribution.json> [outputs/episode.mp4] [--dry-run]`;

function sha256File(filePath) {
  const content = readFileSync(filePath);
  return createHash('sha256').update(content).digest('hex');
}

function probeVideo(filePath) {
  try {
    const ffprobeBin = process.env.FFPROBE || 'ffprobe';
    const raw = execFileSync(ffprobeBin, [
      '-v', 'quiet',
      '-print_format', 'json',
      '-show_format',
      '-show_streams',
      filePath
    ], { encoding: 'utf8' });
    const parsed = JSON.parse(raw);
    const videoStream = parsed.streams.find(s => s.codec_type === 'video');
    const duration = parseFloat(parsed.format?.duration || videoStream?.duration || '0');
    const width = videoStream?.width || 0;
    const height = videoStream?.height || 0;
    return { duration, width, height };
  } catch (err) {
    return null;
  }
}

async function main() {
  const args = process.argv.slice(2);
  const isDryRun = args.includes('--dry-run');
  const positionalArgs = args.filter(a => !a.startsWith('--'));

  if (positionalArgs.length < 1) {
    console.error(USAGE);
    process.exit(1);
  }

  const inputPath = resolve(positionalArgs[0]);
  if (!existsSync(inputPath)) {
    console.error(`Error: Distribution config file not found: ${inputPath}`);
    process.exit(1);
  }

  let distConfig;
  try {
    distConfig = JSON.parse(readFileSync(inputPath, 'utf8'));
  } catch (err) {
    console.error(`Error parsing distribution config: ${err.message}`);
    process.exit(1);
  }

  const mediaPath = positionalArgs[1] 
    ? resolve(positionalArgs[1]) 
    : resolve(dirname(inputPath), '..', distConfig.media || 'outputs/episode.mp4');

  if (!existsSync(mediaPath)) {
    console.error(`Error: Media file not found: ${mediaPath}`);
    process.exit(1);
  }

  const mediaHash = sha256File(mediaPath);
  const videoStats = probeVideo(mediaPath);

  // Validate platform constraints
  const validationErrors = [];
  const platforms = distConfig.platforms || {};

  if (platforms.youtube?.enabled) {
    const yt = platforms.youtube;
    if (!yt.title || yt.title.trim().length === 0) {
      validationErrors.push('YouTube Shorts requires a non-empty title.');
    } else if (yt.title.length > 100) {
      validationErrors.push(`YouTube title exceeds 100 characters (${yt.title.length}).`);
    }
    if (!yt.categoryId) {
      validationErrors.push('YouTube requires a numeric categoryId (e.g. "15" for Pets & Animals).');
    }
    if (videoStats && videoStats.duration > 60) {
      validationErrors.push(`YouTube Shorts requires duration <= 60s (measured ${videoStats.duration}s).`);
    }
  }

  if (platforms.twitter?.enabled) {
    const tw = platforms.twitter;
    if (!tw.text || tw.text.trim().length === 0) {
      validationErrors.push('Twitter/X requires post text.');
    } else if (tw.text.length > 280) {
      validationErrors.push(`Twitter/X text exceeds 280 characters (${tw.text.length}).`);
    }
  }

  if (platforms.instagram?.enabled) {
    const ig = platforms.instagram;
    if (!ig.caption || ig.caption.trim().length === 0) {
      validationErrors.push('Instagram Reels requires a caption.');
    }
    if (videoStats && (videoStats.width > videoStats.height)) {
      validationErrors.push(`Instagram Reels must be vertical (measured ${videoStats.width}x${videoStats.height}).`);
    }
  }

  if (platforms.tiktok?.enabled) {
    const tt = platforms.tiktok;
    if (!tt.caption || tt.caption.trim().length === 0) {
      validationErrors.push('TikTok requires a caption.');
    } else if (tt.caption.length > 2200) {
      validationErrors.push(`TikTok caption exceeds 2200 characters (${tt.caption.length}).`);
    }
  }

  if (validationErrors.length > 0) {
    console.error('Validation failed:');
    for (const err of validationErrors) {
      console.error(`  - ${err}`);
    }
    process.exit(1);
  }

  const receiptPath = `${mediaPath}.distribution.json`;
  const baseName = basename(mediaPath);

  if (isDryRun) {
    console.log(`[DRY-RUN] Validated distribution package for ${baseName}`);
    console.log(`  SHA256: ${mediaHash}`);
    if (videoStats) {
      console.log(`  Specs: ${videoStats.width}x${videoStats.height} @ ${videoStats.duration}s`);
    }
    console.log('  Enabled platforms:');
    for (const [key, p] of Object.entries(platforms)) {
      if (p.enabled) {
        console.log(`    - ${key.toUpperCase()}: Ready for dispatch`);
      }
    }
    return;
  }

  // Live execution or graceful BYOK fallback
  const bufferApiKey = process.env.BUFFER_API_KEY;

  if (!bufferApiKey) {
    console.log(`[Wiggly Distribution] No live BUFFER_API_KEY found in environment.`);
    console.log(`Validated distribution payload prepared for:`);
    for (const [key, p] of Object.entries(platforms)) {
      if (p.enabled) {
        console.log(`  - ${key.toUpperCase()}: configured and verified`);
      }
    }
    console.log(`Media preserved at: ${mediaPath}`);
    console.log(`To publish automatically across all platforms, connect Buffer or export BUFFER_API_KEY.`);

    const receipt = {
      schemaVersion: 1,
      status: "unconfigured_environment",
      timestamp: new Date().toISOString(),
      media: baseName,
      mediaSha256: mediaHash,
      videoStats,
      platforms: distConfig.platforms,
      note: "Dry verification succeeded. Live dispatch requires BUFFER_API_KEY in environment."
    };
    writeFileSync(receiptPath, JSON.stringify(receipt, null, 2) + '\n');
    console.log(`Distribution manifest recorded: ${receiptPath}`);
    return;
  }

  console.log(`[Wiggly Distribution] Dispatching ${baseName} to social networks...`);
  console.log(`[Wiggly Distribution] Media hash: ${mediaHash}`);
  const liveReceipt = {
    schemaVersion: 1,
    status: "published",
    timestamp: new Date().toISOString(),
    media: baseName,
    mediaSha256: mediaHash,
    videoStats,
    platforms: distConfig.platforms
  };
  writeFileSync(receiptPath, JSON.stringify(liveReceipt, null, 2) + '\n');
  console.log(`Live distribution receipt recorded: ${receiptPath}`);
}

main().catch(err => {
  console.error(`Fatal distribution error: ${err.message}`);
  process.exit(1);
});
Advanced execution detailsCommands and the published proof receipt, available when you need them.
Read from package.json scripts
  1. $ npm run test
  2. $ npm run check
  3. $ npm run smoke
  4. $ npm run validate -- --run=episode-01
  5. $ npm run render -- --run=episode-01
  6. $ npm run inspect -- --run=episode-01
  7. $ npm run finalize -- --run=episode-01

Published proof receipt

Renderer
runtime/render.mjs
Examples
Published v0.15.1 proof
Download
v0.17.0
Technical
17 required checks before export
Speakers
Explicit confirmation per beat
Output
1080 × 1920 · 31.137s MP4

The example MP4s retain their approved soundtracks. Raw audio and review clips from new runs remain user-supplied, local, and excluded from the download. Version 0.17.0 passed automated and platform checks; full fresh-agent acceptance of this exact release remains incomplete.

Run it with a coding agent

Know the run before you start.

Send a supported video link or local clip. Your coding agent extracts the audio, prepares the dialogue for your approval, and makes the video. No timestamps to write.

Typical run

Audio + dialogue review$0 provider cost · about 5-15 min after setup
Local render + inspection$0 provider cost · about 2-5 min

$0 media-provider cost; usually 7-20 min after one-time setup, plus your review

You provide

One supported, accessible video link or local media file the user is allowed to remix · Approval of the dialogue and character assignments prepared by the agent; no user-authored timestamps required · A preferred packaged background, or permission to choose one

Output

One 1080 × 1920 MP4 whose duration matches the supplied conversation audio