AGENTS.md · git:20261026.aca20cd · 2026-10-26 · sha256 d7353f9058a045b9

AGENTS.md git:20261026.aca20cdB

Immutable. This exact content never changes and is served at /api/v1/blob/d7353f9058a045b9.

# Venice Video Harness

This workspace is an agent-first, Venice-optimized harness for **consistency-first video creation at any length and format**.

It is meant to be operated through natural language by any coding agent (opencode, Claude Code, Cursor, Codex, etc.). The user should not be asked to run terminal commands manually. The agent reads the rules, selects the right playbooks, and executes code as needed.

The shared `VENICE_API_KEY` lives in `.env` and is sourced by many scripts: do not move or rename `.env`.

## Contract For Downstream Apps (SSOT)

This repository is the upstream single source of truth (SSOT) for Venice video generation and editing functionality. Multiple app UIs build on top of it. All functionality and features live downstream of this repository.

Apps build UIs on top. They do not re-implement harness functionality.

### The Upstream-First Rule

Every new capability, model integration, routing rule, or bug fix lands in this repository first.

If an app needs something the harness does not do, the builder agent requests the feature here (see How To Request A Feature). The agent does not build a local variant. Local variants fork behavior and drift.

### How To Request A Feature

Open a GitHub issue with the Feature Request template (`.github/ISSUE_TEMPLATE/feature_request.md`). Include:

- The app name.
- The workflow the app is building.
- The harness command or module involved.
- The expected behavior.
- Why it belongs upstream.

### How To Report A Bug

Open a GitHub issue with the Bug Report template (`.github/ISSUE_TEMPLATE/bug_report.md`). Include:

- The harness version or commit.
- The Node version and the operating system.
- The exact command or playbook.
- The model IDs involved.
- The expected behavior and the actual behavior.
- The full error output.
- Any provenance sidecars (`*.provenance.json`) or recipe sidecars (`*.recipe.json`) for the affected assets.

### How To Send A Pull Request

Follow [`CONTRIBUTING.md`](CONTRIBUTING.md): run the tests with no key and an empty `VENICE_VIDEO_CONFIG_DIR`, add a `CHANGELOG.md` entry under `## Unreleased`, re-run `npm run manifest` after registry changes, and report any paid probes in the PR body. CI runs both suites on Linux and macOS and fails any test that reaches the Venice API.

### How To Consume The Harness

Three patterns are sanctioned, in order of preference:

1. **Import `venice-video-harness/core`.** The pure half of the harness ships as a workspace package (`packages/core`, exported from the root package as the `venice-video-harness/core` subpath, with per-module entries under `venice-video-harness/core/<path>.js`). It is plain data in, plain data out: the model registry and capability predicates (`venice/models`), the series schema and capability sets (`series/types`), the text-model catalogue, silent-reject thresholds, the capabilities-manifest builder, the agent pipeline/guide text, and the wizard/stream choice tables. It imports no Node builtins, no npm packages and nothing outside itself, never reads `process.env`, `Buffer` or `import.meta.url`, and builds in a browser bundler with no polyfills — `tests/core-purity.test.mjs` enforces that on every run. A browser app takes the same code the CLI runs instead of a hand copy of it. More of the harness moves into core as the pure halves of the planning modules are split from their IO (see `plan-to-update.md`).
2. Depend on the published package or the repository. Import from `src/` or call the CLI (`venice-video`).
3. Copy-and-adapt. Transform harness code into the app's native stack. Prefer (1) wherever core already covers it.

When you transform code, preserve these semantics:

- The model registry capability flags in `packages/core/src/venice/models.ts`.
- The reference-slot ordering contract in `src/mini-drama/reference-slots.ts`.
- The provenance and recipe sidecars (`src/venice/provenance.ts`, `src/venice/recipe.ts`).
- The routing tables and the numbered rules in this file.

Do not transform selectively in ways that drop safety gates (pre-flight checks, approval gates, budget caps).

### Pointer Map

- `AGENTS.md` (this file) — orchestration rules and the agent contract.
- `README.md` — user-facing docs.
- `packages/core/` — the pure core (`venice-video-harness/core`): registry, schema, capability sets, choices. Keep it pure; the purity test will tell you if you did not.
- `packages/core/src/venice/models.ts` — the model registry.
- `packages/core/src/series/types.ts` — the series schema (`SeriesState`, `EpisodeScript`, `ShotScript`), defaults and capability sets.
- `packages/core/src/agent/guide.ts` — the condensed operating rules shipped inside the CLI.
- `.agents/commands/` — workflow playbooks.
- `.agents/skills/` — production knowledge.

## What This Harness Does

1. Helps an agent plan and execute consistency-first Venice video workflows
2. Supports recurring characters, locked visual systems, and reference-driven generation
3. Provides reusable orchestration through `AGENTS.md` plus the playbooks, sub-agent definitions, and skills in `.agents/` (provider-neutral markdown — usable by any coding agent, renamed from `.claude/` in 2.14.0)
4. Includes a comprehensive model registry covering 50+ Venice video, image, audio, and music models
5. Includes a working narrative reference implementation in `src/mini-drama/`
6. Preserves generated media by archiving instead of destructively replacing

## Supported Use Cases

This harness is not limited to any single video format. It supports:

- **Episodic series** (drama, comedy, documentary, educational)
- **Trailers and teasers**
- **Branded cinematic sequences**
- **Product launch videos**
- **Recurring-character social content**
- **Narrative explainers**
- **Style-locked creative campaigns**
- **Long-form content** (assemble multi-shot sequences of any length)
- **Any Venice workflow where visual continuity matters**

## How To Operate

The intended interface is:
- Natural-language requests to the agent
- Orchestration rules in `AGENTS.md`
- Workflow playbooks in `.agents/commands/`
- Reusable Venice knowledge in `.agents/skills/`
- Underlying TypeScript and script execution in `src/` and `scripts/`

The CLI and scripts are the execution layer underneath the harness, not the primary user interface.

For a human driving the CLI directly, `venice-video shell` opens a persistent
session: `use <project> [part]` selects a project once and every command then
defaults `-p` / `-e` to it, `&` backgrounds a render, `/jobs` inspects those
background commands, and `status` reports the pipeline stage plus the next
command to run. The selection lives in user config, so an agent shelling out to
one-shot commands inherits it — always pass `-p` explicitly rather than relying
on whatever the user happens to have selected.

## Venice API Coverage

### Video Endpoints

| Endpoint | Purpose | Module |
|----------|---------|--------|
| `POST /video/queue` | Queue video generation | `src/venice/video.ts` |
| `POST /video/retrieve` | Poll/download result | `src/venice/video.ts` |
| `POST /video/quote` | Get cost estimate | `src/venice/video.ts` |
| `POST /video/complete` | Cleanup after download | `src/venice/video.ts` |

### Image Endpoints

| Endpoint | Purpose | Module |
|----------|---------|--------|
| `POST /image/generate` | Text-to-image | `src/venice/generate.ts` |
| `POST /image/multi-edit` | Layered multi-image editing | `src/venice/multi-edit.ts` |
| `POST /image/upscale` | AI upscaling | `src/venice/edit.ts` |
| `POST /image/background-remove` | Background removal | `src/venice/edit.ts` |
| `POST /images/edit` | **DEPRECATED** (May 2025) | `src/venice/edit.ts` |

### Audio Endpoints

| Endpoint | Purpose | Module |
|----------|---------|--------|
| `POST /audio/speech` | Text-to-speech (Kokoro, Qwen3) | `src/venice/audio.ts` |
| `POST /audio/queue` | Queue music/SFX generation | `src/venice/audio.ts` |
| `POST /audio/retrieve` | Poll/download audio result | `src/venice/audio.ts` |
| `POST /audio/complete` | Cleanup after download | `src/venice/audio.ts` |

### Chat Endpoint

| Endpoint | Purpose | Module |
|----------|---------|--------|
| `POST /chat/completions` | Vision-based QA, script generation | `src/venice/client.ts` |

## Model Registry

The full model registry lives in `packages/core/src/venice/models.ts` with typed specs for every model. Key categories:

### Video Models (50+ models)

**Action / Movement / Dialogue:**
- `kling-v3-pro-image-to-video` (3-15s, audio, `end_image_url`)
- `kling-o3-pro-image-to-video` (3-15s, audio, `end_image_url`)
- `kling-2.6-pro-image-to-video` (5-10s, audio, `end_image_url`)
- `wan-2.6-image-to-video` (5-15s, 1080p, audio, `audio_url` input)
- `sora-2-pro-image-to-video` (4-12s, 1080p, audio)

**Atmosphere / Establishing / Mood:**
- `seedance-2-0-enhanced-reference-to-video` (default for ALL lanes since 2026-07-30 — atmosphere shots anchor to location refs via `@Image` tags)
- `seedance-2-0-image-to-video` (4-15s, 720p, native stereo audio — legacy atmosphere default)
- `veo3.1-fast-image-to-video` (4-8s, up to 4K, audio)
- `veo3-fast-image-to-video` (8s, audio)
- `pixverse-v5.6-image-to-video` (5-8s, up to 1080p, audio)

**Character Consistency (Reference-to-Video):**
- `seedance-2-0-enhanced-reference-to-video` (**THE default for all three lanes** — action, atmosphere, character. 4-15s, 1080p-capable, `reference_image_urls` up to **9**, `@Image` tags, `reference_audio_urls`, native audio, ~1.5x standard R2V price. Delisted from GET /models but live on queue/quote.)
- `seedance-2-0-reference-to-video` (standard R2V, 4-15s, `reference_image_urls` up to 9, `@Image` tags, native audio)
- `happyhorse-1-1-reference-to-video` (R2V, 3-15s, `reference_image_urls` up to 9, per-reference audio, phoneme-level lip-sync)
- `minimax-h3-reference-to-video` (R2V, **5-15s**, `reference_image_urls` up to 9, `audio_url` input, native stereo audio, **2K only**)
- `kling-o3-standard-reference-to-video` (fallback only when characters alone overflow the 9-ref budget; 3-15s, `elements`, `reference_image_urls`, `scene_image_urls`)
- `kling-o3-pro-reference-to-video` (3-15s, full reference support)

**MiniMax H3 (open-weight omni-modal, added 2026-07-31):**
- `minimax-h3-text-to-video` / `minimax-h3-image-to-video` / `minimax-h3-reference-to-video`
- One model covers T2V, I2V, and multimodal reference, with native stereo audio in the render. 24fps, 2500-char prompt limit.
- **2K is the only resolution.** `resolution: '720p'` is a hard HTTP 400 — there is no cheap draft tier, so every H3 take is a finish-quality spend. The generator pins `resolution: '2K'` for any `minimax-h3-*` model.
- **The duration ladder starts at 5s.** 3s and 4s both 400. Script H3 episodes on a 5-15s grid; the duration preflight rejects off-ladder shots before anything is queued.
- Pricing at time of sync: $0.81 for 5s, $2.44 for 15s (~$0.16/s at 2K) — roughly a third of what the other families cost per second.
- `audio` is not configurable (like HappyHorse), so the generator omits the field entirely.
- i2v inherits aspect from the start image and exposes no `aspect_ratios`; t2v and R2V accept `16:9 / 9:16 / 1:1 / 4:3 / 3:4 / 21:9`.
- **R2V is pure-reference-only.** Sending `image_url` (or `end_image_url`) alongside `reference_image_urls` is a hard 400: *"image_url and end_image_url cannot be combined with reference media for this model."* `minimax-h3-reference-to-video` is therefore in `MODELS_USING_IMAGE_TAGS`, which is what puts the generator in pure reference mode. It honors `@ImageN` tags — verified by paid render, both tagged characters landed on their assigned `@Image1` / `@Image2` slots.
- **Reference aspect influences output orientation, so keep a 16:9 plate in the stack.** With the harness's normal slot plan (1:1 character sheets + the 16:9 storyboard blocking plate) and `aspect_ratio: '16:9'`, a paid render returned a true 2560×1440. But a stack of uniformly portrait references returned 1440×1920 *despite* `aspect_ratio: '16:9'` — the requested ratio did not override them. Character-only H3 shots with no blocking plate are the orientation risk; check the first-frame contact sheet before assembling.

**MiniMax H3 Max / H3 Max Turbo (added 2026-09-03):**
- `minimax-h3-max-text-to-video` / `-image-to-video` / `-reference-to-video`, and
  `minimax-h3-max-turbo-text-to-video` / `-image-to-video`.
- **Related to MiniMax H3 in name only.** Four differences, each of which costs
  a render if you assume H3 behavior:
  - **768P, and 2K is a hard 400** (`Expected '480P' | '768P'`) — the exact
    inverse of base H3. The generator's resolution pin matches
    `minimax-h3-max` *before* `minimax-h3` for this reason; do not reorder
    those branches. 480P is the draft tier, 768P the finish.
  - **They want plain prompts** (`promptStyle: 'simple'` in the registry).
    These models stage their own framing, coverage, and cutting from a stated
    intent, and the directorial stack overrides that instinct. `buildVideoPrompt`
    and `buildMontagePrompt` drop blocking, the locked location description, and
    the geography-hold paragraphs for them; identity (`@ImageN`), the beat, the
    line, the sound, and a compact look survive. Use `modelWantsSimplePrompt(id)`
    rather than an id check when adding new behavior.
  - **`private` tier** (H3 is `anonymized`), and uncensored. Prompt cap 10000
    chars, though the useful prompt is a couple of sentences.
  - **Price.** $0.024/s for H3 Max and $0.012/s for Turbo at 768P, against
    $0.10/s for base H3 — Turbo is the cheapest lane in the registry, cheap
    enough that a 15s take is disposable: render several and pick.
- **Best used for montages and single-take storytelling.** This is where the
  simple-prompt instinct pays: describe the sequence and let the model cut it.
  Note the montage window now derives from the montage model's own ladder, so
  H3 Max montages plan at 5-15s rather than Seedance's 30s.
- Shared with H3: the 5-15s ladder (4s is a hard 400) and native audio that is
  **not** toggleable, so the generator omits the `audio` field entirely.
- **Turbo has no R2V lane.** `minimax-h3-max-turbo-reference-to-video` is
  "Specified model not found", so the `minimax-h3-max-turbo` family routes
  character-consistency and lip-sync shots to `minimax-h3-max-reference-to-video`.
- R2V is treated as pure-reference (in `MODELS_USING_IMAGE_TAGS`) like H3 R2V.
  Note the difference from H3: `/video/quote` *accepted* `image_url` alongside
  `reference_image_urls` here, but quote validates less than queue, and
  pure-reference is the right mode regardless — it keeps compositional
  authority with the reference stack and is what makes `@ImageN` resolve.
- i2v lanes inherit aspect from the start image and expose no `aspect_ratios`;
  t2v and R2V accept `16:9 / 21:9 / 4:3 / 1:1 / 3:4 / 9:16`.

**Long Duration:**
- `longcat-image-to-video` / `longcat-distilled-image-to-video` (up to **30s**, no audio)
- `ltx-2-fast-image-to-video` / `ltx-2-v2-3-fast-image-to-video` (up to **20s**, up to 4K)
- `ltx-2-19b-full-image-to-video` (up to **18s**, audio)

**Budget / Fast:**
- `wan-2.6-flash-image-to-video` (5-15s, fast)
- `kling-v3-standard-image-to-video` (3-15s)
- `grok-imagine-image-to-video` (5-15s)

### Video Model Capabilities

| Capability | Models |
|-----------|--------|
| `elements` (structured @Element refs) | Kling O3 R2V (standard + pro) |
| `reference_image_urls` (flat ref array) | Seedance 2.0 R2V family (**up to 9**), HappyHorse 1.1 R2V (**up to 9**), MiniMax H3 R2V (**up to 9**), Kling O3 R2V, Vidu Q3 (legacy 4-image budget elsewhere) |
| `scene_image_urls` (environment refs) | Kling O3 R2V (standard + pro) |
| `end_image_url` (frame targeting) | All Kling image-to-video, PixVerse Transition |
| `audio_url` (background audio input) | Wan 2.6, Wan 2.5 Preview, Seedance 2.0 R2V family, MiniMax H3 R2V |
| `reference_audio_urls` (voice-donor clips, @AudioN) | Seedance 2.0 R2V / Enhanced R2V / Fast R2V, HappyHorse 1.1 R2V (≤3 clips, 2-15s each, ≤15s aggregate, needs ≥1 reference image) |
| `@Image` tags (flat ref prompt syntax) | Seedance 2.0 R2V, Grok Imagine R2V |
| Native stereo audio with lip-sync | Seedance 2.0 (8+ languages) |
| Native stereo audio, not toggleable | HappyHorse 1.1, MiniMax H3 (omit the `audio` field or the request 400s) |
| 2K output | MiniMax H3, MiniMax Hailuo 03 (2K is their ONLY resolution) |
| 4K output | **Seedance 2.0** (i2v/t2v/r2v — re-probed live 2026-09-07: `resolution: '4k'` quotes 200), Veo 3.1, LTX 2.5 Fast (2160p), Kling O3/V3 4K. Seedance 2.5 tops out at **1080p** (2K/4K 400). The harness had been capping Seedance at 720p until 2026-09-07; the resolution picker (Stream) now offers each model's true ladder. |
| 30s duration | Longcat |
| 20s duration | LTX 2.0 Fast, LTX 2.0 v2.3 Fast |
| 15s duration | Seedance 2.0, Kling O3/V3, Wan 2.6 |

### Image Generation Models

`nano-banana-pro` (default for storyboard), `nano-banana-2`, `gpt-image-2` (high-quality alternative to nano-banana-pro), `gpt-image-1-5`, `flux-2-pro`, `flux-2-max`, `grok-imagine`, `hunyuan-image-v3`, `qwen-image-2`, `qwen-image-2-pro`, `recraft-v4`, `recraft-v4-pro`, `seedream-v4`, `seedream-v5-lite`, `chroma`, `hidream`, and more.

### Multi-Edit Models (10 models)

`qwen-edit`, `qwen-image-2-edit`, `qwen-image-2-pro-edit`, `flux-2-max-edit`, `gpt-image-2-edit` (high-quality alternative to nano-banana-pro-edit), `gpt-image-1-5-edit`, `grok-imagine-edit`, `nano-banana-2-edit`, `nano-banana-pro-edit`, `seedream-v4-edit`, `seedream-v5-lite-edit`

### TTS Models

- **Kokoro** (`tts-kokoro`): 50+ voices across English, Chinese, Japanese, Korean, Spanish, French, Hindi, Italian, Portuguese
- **Qwen3** (`tts-qwen3-0-6b`, `tts-qwen3-1-7b`): Style-prompted voices (Vivian, Serena, Dylan, Eric, Ryan, Aiden, etc.) with emotion/delivery control
- **ElevenLabs** (`elevenlabs-tts-v3`, `elevenlabs-tts-multilingual-v2`): Premium TTS

### Music / SFX Models

- **Music**: `elevenlabs-music`, `minimax-music-v2`, `minimax-music-v25`, `minimax-music-v26`, `lyria-3-pro`, `ace-step-15`, `stable-audio-25`
- **Expressive speech / prompt-driven audio**: `seed-audio-1-0` (BytePlus Seed Audio 1.0 — `music`-type async model with 25 named voices, speed 0.5–2, 2048-char prompt; premium prompt-directed narration/VO). Generate with `generate-audio --prompt … [--voice … --speed … --out …]`, or pass `--model seed-audio-1-0 --voice … --speed …` to `generate-music`.
- **SFX**: `elevenlabs-sound-effects-v2`, `mmaudio-v2-text-to-audio`

## Default Venice Routing

**Core principle (reference-first, default model Seedance 2.5 as of 2026-08-07): Seedance 2.5 R2V (`seedance-2-5-reference-to-video`) for ALL lanes — action, atmosphere, and character. Every shot renders in pure reference mode (no start image) from an `@Image` slot plan: character sheets, storyboard blocking plates, and multiple location angles (up to 30 refs on Seedance 2.5). Scenes ship as ONE Seedance 2.5 single-pass generation by default — the montage lane up to 30s (rule 50), or a native multi-shot up to 15s when montage is off — NOT as hand-stitched bundles. Seedance 2.0 R2V Enhanced (the prior default; 1080p-capable) is still registry-known and selectable via `videoDefaults`.**

Every shot uses reference-to-video for consistency. Seedance 2.0 R2V Enhanced is the default for all three lanes, using flat `reference_image_urls` with `@Image` prompt tags allocated by the central slot planner (`src/mini-drama/reference-slots.ts`): (1) one primary angle per character, (2) the beat's storyboard blocking plate (PROTECTED — shows where characters stand in the location relative to each other), (3) location angles wide→medium→detail, (4) second character angles. Overflow drops second character angles first, then trailing location angles; plates are dropped last. The Kling O3 fallback now fires only when the character count alone would overflow the 9-ref budget (7+ characters), not at 3+. Empty establishing/mood shots also run R2V, anchored to location refs.

**Scene-level default: Seedance native multi-shot.** When a scene comprises 2–3 consecutive beats of the same character (or pairwise-overlapping characters) in a continuous action, **always render the whole scene as a single Seedance R2V generation up to 15s with `Lens switch.` separators between beats** — not as separate renders concatenated at assembly time. Identity, environment, lighting, and voice-donor references hold across the lens switches inside a single generation; separate renders drift between cuts even with the same refs. Cost is also 3× lower and wall-clock is faster. The planner should bundle beats by default and only split when (a) the project explicitly selected exact lip-sync and a beat needs Wan 2.7 driven by a specific dialogue MP3; (b) beats span different locations or non-overlapping character pools; or (c) total runtime exceeds 15s. See rule 21.

Preferred defaults (overridable per-project via `series.json` → `videoDefaults`):

| Role | Default Model | When Used |
|------|--------------|-----------|
| Character shots (up to ~6 characters) | `seedance-2-5-reference-to-video` | Default R2V — up to 30 `reference_image_urls` with `@Image` tags (chars + storyboard plate + location angles), pure reference mode (no start image), every integer 4-30s, **480p/720p/1080p** (harness auto-pins 720p; 1080p available on request — quote 2026-09-07), native stereo audio |
| Character shots (budget overflow) | `kling-o3-standard-reference-to-video` | Auto-fallback — structured `elements` + `reference_image_urls`. Rare on 2.5's 30-ref budget; kept for extreme character counts / scene-image needs |
| Native character dialogue | `seedance-2-5-reference-to-video` | Default. Generates the authored line in-frame; `reference_audio_urls` voice donors preserve timbre/accent/pacing. Native mouth sync is prompt-driven, not deterministic to an exact supplied recording. |
| Exact lip-sync, low/medium motion | `resolveLipSyncModel(family)` | Only when `audioStrategy: lip-sync`. Venice speech drives mouth movement through `audio_url`; min 3s audio. Stays in-family on Seedance (`seedance-2-5-reference-to-video`) and MiniMax H3 (`minimax-h3-reference-to-video`), both of which accept a top-level `audio_url`, and on Wan 3.0 (`wan-3-0-reference-to-video`), which rejects `audio_url` but lip-syncs to the dialogue MP3 sent as `reference_audio_urls` (`MODELS_LIP_SYNC_VIA_REFERENCE_AUDIO`; pure reference mode; at most 15s of reference audio per render, so lip-sync shots run 5-15s even though Wan renders to 30s — `LIP_SYNC_REFERENCE_AUDIO_MAX_SEC`, enforced before queueing; shorter clips are padded with silence to the render length, since an unpadded tail gets invented speech). Other families fall back to `wan-2-7-image-to-video`, which needs a keyframe from a Seedance R2V identity pass (rule 32). |
| Native/high-motion dialogue | `seedance-2-5-reference-to-video` | Preserves identity across large motion with native prompt-driven dialogue and voice references; does not follow an exact dialogue audio file. |
| Multi-character dialogue shot | `wan-2-7-reference-to-video` | `per_reference_audio` — each `elements[].audio_url` drives a different speaker's mouth. Max 10s. |
| Establishing / mood / action (no chars) | `seedance-2-5-reference-to-video` | Anchors to location reference angles via `@Image` tags; pure reference mode. (`seedance-2-0-image-to-video` remains available for panel-anchored work.) |
| Image Generation (all panels) | `nano-banana-2` | Global default for character AND faceless panels. Seedance no longer restricts face-bearing input to seedream (see below). `gpt-image-2` / `nano-banana-pro` are high-quality alternatives with sharper typography |
| Multi-Edit (all panels) | `nano-banana-2-edit` | Global default for character fixes and style-match. `gpt-image-2-edit` is a high-quality alternative |
| TTS | `tts-kokoro` | 50+ voices, fast, consistent |
| Music | `elevenlabs-music` | High quality music generation |
| Expressive speech / audio | `seed-audio-1-0` | Prompt-directed narration/VO with named voices + speed (async queue) |
| SFX | `elevenlabs-sound-effects-v2` | Best sound effect quality |

### Image / Video Family Pairing

**Venice removed the Seedance seedream-only face restriction (2026-07).** Seedance 2.0 previously rejected face-bearing input images that weren't produced by `seedream-v5-lite` / `seedream-v5-lite-edit`; it now accepts face-bearing images from **any** image family. The harness therefore uses a single high-quality default — `nano-banana-2` — for every panel, character-bearing or faceless, generation and multi-edit alike. Override per-series via `videoDefaults.imageDefaults.generationModel` / `editModel`.

### Seedance Pre-flight Gate (neutralized)

The former provenance-driven pre-flight gate is a **no-op** as of 2026-07. Because Seedance accepts any image family, there is nothing to validate, reroute, or launder before a Seedance call — `ensureSeedanceCompatibility()` (`src/venice/seedance-preflight.ts`) always proceeds, and `series.videoDefaults.seedanceCompatibility` is no longer auto-set. Provenance sidecars (`shot-NNN.provenance.json`) are still written as harmless metadata (they still record `hasFace` for other tooling), but nothing gates on them anymore.

> Note: the Seedance face **consent** attestation (HTTP 409 `needs_consent`, handled at queue time) is a separate mechanism and is unaffected.

The provenance sidecar format is `shot-NNN.provenance.json` next to each PNG:

```json
{
  "generationModel": "nano-banana-2",
  "editModels": ["nano-banana-2-edit"],
  "hasFace": true,
  "createdAt": "...",
  "updatedAt": "..."
}
```

The storyboard assembler, panel-fixer, reference-manager, and mini-drama panel generator all write this automatically. Images without sidecars (e.g. old assets from before this change) are treated as "unknown" and will trigger the pre-flight gate so the user can decide whether they have faces. If you know an existing image has no face, you can hand-edit the sidecar to set `"hasFace": false` and it will pass.

## Video Queue Request Parameters

The full request schema for `POST /api/v1/video/queue`:

```json
{
  "model": "kling-v3-pro-image-to-video",
  "prompt": "A slow dolly shot pushes forward...",
  "duration": "8s",
  "image_url": "data:image/png;base64,...",
  "end_image_url": "data:image/png;base64,...",
  "negative_prompt": "low quality, blurry",
  "aspect_ratio": "9:16",
  "resolution": "1080p",
  "audio": true,
  "audio_url": "data:audio/mpeg;base64,...",
  "video_url": "data:video/mp4;base64,...",
  "reference_image_urls": ["data:image/png;base64,..."],
  "elements": [
    {
      "frontal_image_url": "data:image/png;base64,...",
      "reference_image_urls": ["data:image/png;base64,..."],
      "video_url": "data:video/mp4;base64,..."
    }
  ],
  "scene_image_urls": ["data:image/png;base64,..."]
}
```

**Parameter availability is model-dependent.** The harness automatically skips unsupported params per model. Use `getVideoModel()` from `venice-video-harness/core` (`packages/core/src/venice/models.ts`) to check capabilities.

## Editing Pipeline

Parallel to the generation pipeline. The generation pipeline **synthesizes** shots from prompts; the editing pipeline **cuts** already-existing media (either Venice-generated or user-supplied raw footage). They share ffmpeg and the burn-in-subtitles skill but are otherwise independent.

Inspired by [browser-use/video-use](https://github.com/browser-use/video-use), the editing pipeline adopts the "text + on-demand visuals" philosophy: the LLM reads a compact `takes_packed.md` transcript rather than frame-dumping the video, and only calls `timeline-view` composite PNGs at explicit decision points.

### When To Reach For Editing vs Generation

| Task | Pipeline | Entry Point |
|------|----------|-------------|
| Synthesize new shots from prompts | Generation | `/produce-episode`, `/generate-episode-videos` |
| Re-cut a generated episode for pacing | Editing | `/edit-footage` |
| Trim filler words from a VO take | Editing | `/edit-footage` |
| Edit raw user-supplied footage | Editing | `/edit-footage` |
| Rescue a truncated TTS VO | Editing | `/edit-footage` |
| Add branded lower-thirds / title cards | Editing | `overlay-designer` agent |
| Post-assembly QA on any rendered video | Editing | `cut-qa` agent |

### The Five Steps

1. **Transcribe** via local `whisper-cpp` → per-source `*.words.json` + `takes_packed.md` pack
2. **Read pack** — LLM forms a cut strategy from text
3. **Confirm** — propose strategy to user, wait for yes
4. **Render EDL** — JSON cut list → ffmpeg concat with 30ms audio fades (archive-first)
5. **Self-eval** — `cut-qa` agent runs 6 programmatic checks at every cut boundary; max 3 fix iterations

### Required Tools

- `whisper-cpp` on PATH (`brew install whisper-cpp`) for transcription
- A whisper.cpp model at `~/.cache/whisper.cpp/ggml-base.en.bin` (or `$WHISPER_CPP_MODELS_DIR`)
- `sharp` npm dep (included) for the timeline_view composite
- All other requirements come from the generation pipeline

### Key Files

- `.agents/skills/video-editing/SKILL.md` — full philosophy, EDL format, anti-patterns
- `.agents/commands/edit-footage.md` — end-to-end playbook
- `.agents/agents/cut-qa.md` — post-render quality gate
- `.agents/agents/overlay-designer.md` — branded motion-graphics planner
- `src/editing/` — type definitions, packer, aligner, EDL renderer, self-eval
- `scripts/transcribe-sources.ts` — transcription CLI
- `scripts/timeline-view.ts` — filmstrip + waveform + word-labels composite
- `scripts/render-overlay.ts` — overlay compositing

## Architecture

```
src/
  venice/           Venice API client layer (model-agnostic)
    client.ts       HTTP transport with retries and rate limiting
    models.ts       Complete model registry with capabilities
    video.ts        Video queue/retrieve/quote/complete
    generate.ts     Image generation
    multi-edit.ts   Multi-image layered editing
    edit.ts         Upscale, background remove
    audio.ts        TTS, music, SFX, queued audio
    voices.ts       Voice catalog (Kokoro + Qwen3)
    types.ts        Full API type definitions
  series/           Project state and character management
    types.ts        Character, ShotScript, SeriesState types
    manager.ts      Create/load/save series
  mini-drama/       Reference narrative video implementation
    cli.ts          Commander CLI (25+ commands)
    prompt-builder  Image + video prompt construction
    video-generator Video rendering with frame chaining
    generation-planner  Single vs multi-shot planning (up to 6 shots per unit)
    panel-fixer     Multi-edit character correction
    subtitle-generator  SRT from script
    assembler       Video assembly + audio mix
  editing/          Parallel editing pipeline (inspired by browser-use/video-use)
    types.ts        WordTiming, Take, TakesPack, Edl, EditSession, Overlay
    packer.ts       Collapse word streams -> takes_packed.md
    aligner.ts      Ground-truth script alignment + truncation detection
    providers/      Transcriber providers (whisper.cpp default)
    silence.ts      silencedetect wrapper + filler-word detection
    edl.ts          EDL authoring / validation / serialization
    render.ts       EDL -> final-edit.mp4 with 30ms audio fades (archive-first)
    self-eval.ts    Programmatic cut-qa checks (aspect, jump, pop, truncation)
    overlays.ts     Overlay manifest types + Venice-logo validator
  storyboard/       Legacy screenplay pipeline
  characters/       Character extraction
  parsers/          Fountain + PDF parsing
  assembly/         Remotion scaffold
```

## Included Reference Implementation

The `src/mini-drama/` directory contains a full narrative video pipeline. It demonstrates:

- Series creation with locked aesthetics and seed
- Character design with 4-angle reference images
- Voice audition and locking via Venice TTS
- Episode script workshopping via LLM
- Two-pass storyboard generation (generate + multi-edit refine)
- Vision-based QA for character/setting consistency
- Video generation with model routing and frame chaining
- Audio post-production with layered ambient beds
- Subtitle burn-in and final assembly

Use it directly for narrative content, or adapt the patterns for any format.

## Budgeting

This harness is quality-first, not bargain-first. When planning runs, account for:
- Image generation + multi-edit refinement passes
- Video generation (varies by model and duration)
- Venice TTS, SFX, ambience, and music
- Re-renders needed to fix continuity issues

Use `POST /video/quote` (via `quoteVideo()`) to estimate costs before committing to generation.

## Agent Rules

1. Never ask the user to run terminal commands manually.
2. Treat the user's natural-language request as the primary interface.
3. Read the relevant command/playbook before executing a workflow.
4. Prefer reusable harness patterns over one-off hacks.
5. Preserve generated shot assets by archiving prior versions instead of deleting them.
6. Keep secrets out of source control.
7. Use the model registry (`packages/core/src/venice/models.ts`) to validate model capabilities before making API calls.
8. Check model support for `elements`, `reference_image_urls`, `scene_image_urls`, `end_image_url`, and `audio_url` before including them in requests.
9. **Never group shots with different characters into multi-shot units.** Multi-shot grouping requires pairwise character overlap between consecutive shots — shots cutting between different speakers (e.g., host → guest) must be separate singles so each gets R2V identity anchoring.
10. **Always validate durations against model specs, but PREFER 15s.** Seedance 2.0 accepts every integer 4-15s and HappyHorse 1.1 accepts 3-15s natively (confirmed against `GET /api/v1/models?type=video`). For any beat that could be 15s, default to 15s — 2x15s shots beat 5x6s on identity stability (no inter-shot drift), motion completion (gestures and expressions land), continuity (fewer cuts to police), and cost. Only use shorts (3-8s) for deliberate quick beats: hard cuts, sight gags, single-frame reactions, the closing title card. The `insert-shot` CLI defaults `--duration` to `15s` for this reason. The `workshop-episode` system prompt also instructs the script LLM to prefer 12-15s shots; if the LLM ignores it, the post-condition advisory in stdout flags the draft. The video queue function still auto-snaps invalid durations to the nearest valid value as a safety net.
11. **Front-load style in all prompts.** Aesthetic/style descriptions must appear at the START of prompts, not buried at the end. This prevents style drift across angles and shots.
12. **Use cfg_scale 10 for character references and storyboard panels.** Lower values (e.g., 7) allow the model too much freedom, causing style inconsistency between angles.
13. **Always pass `aspectRatio: '16:9'` (or the series ratio) explicitly to R2V video generation.** The R2V model requires `aspect_ratio` and will default to 16:9 if omitted, but always be explicit to prevent orientation bugs.
14. **Never multi-edit close-up face shots on 16:9 panels.** The 1024x1024→16:9 crop removes ~25% from top/bottom, losing foreheads and chins. Generate close-up panels from scratch with `nano-banana-pro` instead, then use multi-edit only for medium/wide shots.
15. **Match lighting across consecutive shots in the same location.** When generating panels for sequential shots in the same environment, style-match later shots against earlier ones. Explicitly describe the established lighting in each subsequent prompt.
16. **Use `silhouetteCharacters` for distant/silhouetted figures.** Characters visible only as silhouettes (e.g., figure in doorway) go in `silhouetteCharacters`, not `characters`. This ensures they appear in panels without triggering R2V routing or "no people" negative prompts.
17. **Describe the Venice AI logo as crossed-keys, never as "triple-V" or "VVV".** The actual logo is two ornate skeleton keys crossed in an X with a chevron/book at top. Use the full geometric description in prompts, or multi-edit with the logo PNG as reference.
18. **Multi-shot units render on Seedance 2.0 R2V Enhanced by default (2026-08-05) — the Kling multi-shot lane is an explicit override only.** `DEFAULT_MULTISHOT_MODEL` / `resolveMultiShotModel()` in `src/series/types.ts` route every multi-shot generation unit to the SAME reference-first lane as singles: one Seedance native multi-shot generation with `Lens switch.` separators (rule 21), anchored to the full @Image slot plan (character sheets, blocking plate, location angles) in pure reference mode — no start frame. `buildMultiShotPrompt()` dispatches on the resolved model. The old default, `kling-o3-pro-image-to-video`, has NO `elements` and NO `reference_image_urls` support, so every multi-shot unit silently dropped ALL identity anchoring (anti-pattern 1's trap, and the reason grouping rules had to police characters so hard). To deliberately use the Kling format anyway, set `videoDefaults.multiShotModel: "kling-o3-pro-image-to-video"` — the legacy Kling 3.0 structure (subjects up front, `Shot N (Xs):` labels, `[Character, voice]: "dialogue"`, `Immediately, cut to:` separators, max 6 shots / 15s; see the [Kling 3.0 Prompting Guide](https://blog.fal.ai/kling-3-0-prompting-guide/)) is kept behind that override.
19. **Seedance 2.0 R2V uses `@Image` tags, not `@Element` tags.** When the resolved model is Seedance R2V, replace character names with `@Image1`, `@Image2` in prompts. Do NOT use `@Element` tags — Seedance does not support structured elements. The prompt builder handles this automatically via `useImageTags`.
20. **Keep Seedance prompts under 60 words for best results.** Seedance responds to precision, not volume. Use the 5-part structure: Subject, Action (present tense, one verb), Camera (shot size + movement), Style (lighting, color), Constraints (what to exclude). See [Seedance prompting guide](https://venice.ai/blog/seedance-sota-video-generation-live-on-venice).
21. **Default to Seedance native multi-shot for any 2–3 beat scene.** Before planning a bundle of separate Seedance renders, first ask whether the beats can fit into ONE generation up to 15s with `Lens switch.` separators between them. The native multi-shot path is the default; bundled separate renders are the fallback. Identity, environment, and lighting hold across the lens switches inside a single generation, and the result costs and takes ~3× less than three separate renders. Reach for a bundle only when (a) a beat needs Wan 2.7 lip-sync to a specific dialogue MP3, (b) beats span different locations or non-overlapping characters, or (c) total runtime > 15s. Prompt structure: one front-loaded STYLE + character anchor at the top, then per-beat `Shot N (Xs): ...` blocks with the 5-part structure (Subject, Action, Camera, Style, Constraints) kept under ~50 words each, separated by literal `Lens switch.` lines. Pass character refs once via `reference_image_urls` and reference them inline as `@Image1`, `@Image2`, etc. in every beat.
22. **Seedance excels at physics-aware prompting.** Describe forces, not just actions — "tires smoke as car drifts 90 degrees" rather than "car turns." Friction, weight, material interactions, and contact physics produce better results with Seedance's physics-aware training.
23. **3+ character shots auto-fallback to Kling O3 R2V.** When the default R2V model is Seedance (flat refs, max 4 images), shots with 3+ characters automatically fall back to Kling O3 R2V which supports structured `elements` for better per-character identity separation.
24. **Seedance accepts face-bearing input images from ANY image family (2026-07).** Venice removed the old restriction that Seedance 2.0 only accepted face-bearing images produced by `seedream-v5-lite` / `seedream-v5-lite-edit`. There is no longer any seedream requirement: generate character portraits, character panels, and references with the global default `nano-banana-2` (or any family you prefer) and feed them straight to Seedance. The provenance-driven pre-flight gate is now a no-op (`ensureSeedanceCompatibility` always proceeds) and `seedanceCompatibility` is no longer auto-set. Provenance sidecars are still written as metadata but nothing gates on them. (Historical context: anti-pattern 13.) The Seedance face **consent** attestation (409 `needs_consent`) is unrelated and still handled at queue time.
25. **Always ask before burning in subtitles.** Before assembling the final video on any project that includes a VO track, ask the user "Burn in subtitles? (yes / no)" — burn-in is a permanent baked-into-pixels decision and is not always wanted. If yes, follow `.agents/skills/burn-in-subtitles/SKILL.md`: never hand-estimate caption timings, always derive them from `ffmpeg silencedetect` on the rendered VO via `.agents/skills/burn-in-subtitles/scripts/derive-captions.ts`, and use single `...` ellipses only in TTS VO_TEXT (doubled `......` cause Kokoro/ElevenLabs to silently truncate the audio).
26. **Never use doubled ellipses in TTS VO scripts.** Kokoro and ElevenLabs handle single `...` reliably as breath gaps. Doubled `......` cause silent truncation — the audio file ends mid-script with no error, and you only catch it when downstream captions reference dropped text. Use commas + single `...` for combined rhythm, or break across multiple TTS calls and concat with ffmpeg `apad`.
27. **Editing pipeline is text-first.** When the task is to cut / trim / re-order existing media (not synthesize new shots), always transcribe sources first via `scripts/transcribe-sources.ts` and reason over `takes_packed.md`. Call `scripts/timeline-view.ts` ONLY at explicit decision points — never frame-dump to browse the footage. See `.agents/skills/video-editing/SKILL.md`.
28. **Never render an EDL without user confirmation of the cut strategy.** Post a summary (sources, duration, trim rules, transitions) and wait for "yes" before calling `renderEdl()`. The render is cheap; a throwaway 15-minute render because intent was guessed is not. Mirrors video-use design principle 3.
29. **Always run cut-qa after every assembly / edit render.** The `cut-qa` agent runs programmatic checks (aspect, visual jump, VO truncation, **dialogue/VO overlap** — assert no two spoken clips play simultaneously, see rule 35, lighting, audio pop, subtitle overlap) at cut boundaries. Max 3 fix iterations before surfacing to the user. Applies to BOTH the generation-pipeline assembler and the editing-pipeline render.
30. **Overlays are a post-process, never baked into the EDL render.** Lower-thirds, title cards, chapter markers, and logo-bugs live in an `OverlayManifest` rendered via `scripts/render-overlay.ts` on top of `final-edit.mp4`. Changing overlay wording must not require re-rendering the cut.
31. **Never auto-trim silence gaps that originated from `...` in a TTS script.** Those are intentional breath beats rendered by Kokoro / ElevenLabs, not dead air. The filler-word detector (`src/editing/silence.ts`) excludes them. User confirmation is required for every filler-word trim before it lands in an EDL.
32. **Keyframe-only lip-sync models get their keyframe from a Seedance R2V pass — not from a panel.** This path runs only when `audioStrategy: lip-sync` AND the resolved lip-sync model takes no `reference_image_urls`; native dialogue stays on Seedance/HappyHorse with voice-donor references. **Most projects never hit it:** `resolveLipSyncModel(family)` keeps Seedance and MiniMax H3 projects on their own R2V lane (both accept a top-level `audio_url`), which already anchors identity from the reference stack, so those render in a single pass. Only families with no audio-driven lane fall back to `wan-2-7-image-to-video`, whose sole identity anchor is the single `image_url` keyframe — and a panel-derived keyframe drifts mid-clip because the panel was generated without strong character anchoring. Pipeline (transparent to the user): (a) render a quick Seedance R2V identity-lock pass via `videoDefaults.characterConsistencyModel` with all character refs and no audio → `shot-NNN-r2v-keyframe.mp4`; (b) extract frame 1 → `shot-NNN-r2v-keyframe.png`; (c) render via the lip-sync model using that keyframe as `image_url` and the dialogue MP3 as `audio_url`. If the dialogue MP3 isn't on disk yet, the generator inline-TTS-renders it via the character's locked voice and saves it at the canonical `audio/dialogue-shot-NNN.mp3` path so the assembler picks it up later. Cost ~$0.85/shot total, versus one render in-family. Skipped automatically when: the lip-sync model takes reference images, no dialogue, high motion, or multi-speaker dialogue (Wan 2.7 R2V `per_reference_audio` instead). **Opt-out:** per-shot via `ShotScript.disableSeedanceKeyframe = true`; series-wide via `series.json` `videoDefaults.seedanceKeyframeForWan: false`; one-off via `generate-videos --no-seedance-keyframe`. If Stage A or the keyframe extraction errors out, the generator logs the failure and falls back to the panel-anchored single-pass render. Note the attachment of the dialogue MP3 to `audio_url` is independent of this pre-pass — it happens for every audio-input-capable exact-lip-sync route.
33. **Native model dialogue is preferred over exact TTS-driven lip-sync; suppress music/SFX in the video prompt.** Seedance 2.0 and HappyHorse 1.1 generate in-character dialogue with voice-donor references; the audio-driven `audio_url` lane is reserved for the explicit exact-lip-sync strategy when the panel prompt carries a detailed voiceDesc and per-shot delivery direction. The harness now defaults `assemble-episode --dialogue-replace` to OFF (was on) and `--native-volume` to 1.0 (was 0.2). Music and ambient/SFX are added in post via `musicCues[]` / `media.generate_music` / `media.generate_ambient` / `assemble.mix_audio`. To keep the video model from baking music or sound effects into the dialogue track, `prompt-builder.ts` appends `background music, soundtrack, score, musical score, sound effects, sfx, foley, orchestral hits, sound design, audio drops` to every `negative_prompt`, and the `workshop-episode` system prompt instructs the script LLM to include the same negative in every shot description. Venice TTS remains the exception path for accent control, language swap, or repairing a botched native take — call `override-audio --dialogue` to produce `dialogue-shot-NNN.mp3` files, then pass `--dialogue-replace` to `assemble-episode` (and drop `--native-volume` to ~0.2).
34. **Venice deprecation headers are now logged as structured warnings.** `VeniceClient.post*` reads `x-venice-model-deprecation-warning` and `x-venice-model-deprecation-date` from every response and emits a `⚠ MODEL DEPRECATION:` line on stderr the first time each unique (model, date) pair is seen in the process. HTTP 410 Gone responses also get a structured warning. The MCP wrapper pattern-matches these into `warnings[]` so the agent driving the pipeline sees them in tool responses, instead of only finding out post-sunset when a model starts 404ing (the exact failure mode we hit with `qwen-2.5-vl` sunsetting 2025-09-22). See `src/venice/client.ts::reportVeniceDeprecation`.
35. **Schedule dialogue/VO with a global no-overlap scheduler driven by MEASURED clip durations — never by the script's planned shot lengths.** At assembly time, two audio clips may never play at once unless they are intentionally layered (e.g. a music bed vs a line). Build the schedule against the actual `ffprobe` duration of each rendered/normalized segment, not the `duration` field in the script (the rendered clip is almost always shorter or longer than its planned slot). Algorithm: walk shots in timeline order keeping a single `nextFreeSec` cursor; place each line at `max(shotStart + lead, nextFreeSec + gap)`; set `nextFreeSec = placedStart + audioDur`; use one cursor for ALL spoken lines regardless of speaker (narrator AND character) — a per-speaker cursor is the classic bug (see anti-pattern 19). Keep a small `gap` (≈0.2-0.3s) between consecutive lines. SFX and the music bed are exempt because they are meant to underlay. After mixing, verify with the cut-qa overlap check.
36. **Author VO so each line fits its shot, and when it can't, extend the picture — never let audio bleed into the next shot.** A line of TTS runs ≈2.3-2.7 words/sec plus ~0.4s lead-in; budget `shotSeconds × 2.4` words and write to it. If a finished line is longer than its shot's video, the assembler must (a) extend that shot by freezing/holding its last frame to cover the audio (`ffmpeg tpad=stop_mode=clone:stop_duration=...`), or (b) the line must be shortened/split — it must NOT spill onto the next shot. Long narrator lines over short establishing shots are the usual offender; either tighten the narration or hold the frame. This is the authoring complement to the scheduler in rule 35.
37. **Re-anchor every separately-rendered shot to the SAME locked references and restate the character's invariant traits in every prompt.** Identity, scale, palette, and wardrobe drift across independently generated shots even when the story is continuous. For each shot pass the identical canonical `reference_image_urls` (not a frame grabbed from a different shot), and repeat the character's fixed traits inline every time (markings, hair color, costume, and **relative size** — e.g. "as tall as the boy"). Size is a trait the model forgets most: if a character's scale changed in-story (grew/shrank), encode a `sizeState` per shot and state it explicitly. Prefer Seedance native multi-shot (rule 21) for consecutive beats precisely because identity/scale/lighting hold within one generation; across separate renders, the per-prompt trait restatement is what holds them together. Verify drift on a contact sheet of first-frames before assembling (see anti-pattern 20).
38. **Direct the scene, don't decorate it.** Before writing any shot's `description` or `delivery` — in `workshop-episode`, `insert-shot`, or a manual `script.json` edit — decide what the beat is DOING (the turn, POV, power, subtext) and name ONE intention, then derive camera/light/blocking/performance/sound from it. Do not stack "cinematic / epic / beautiful / masterpiece / 4k" adjectives; they give the model nothing to serve. Hold one directorial voice across the episode. This is baked into the `workshop-episode` system prompt (`src/mini-drama/cli.ts`) so both the CLI and the venice-video-mcp `episode.workshop` inherit it. Direct **intention/camera/light/blocking/performance/sound only** — identity is locked downstream (rules 9, 19, 32), so never hand-write full physical character descriptions or reference-image tags into `description`. When a take is close-but-wrong, fix ONE variable at a time; when continuing, direct from the accepted footage's real ending, not the original plan. The optional **Seedance 2.0 Skill OS** (install into `.agents/skills/seedance-20/`; see README "Directing layer") supplies the full `directing-engine`, `retake-protocol`, `continuation-handoff`, `seedance-copyright`, and `seedance-antislop` behind this rule; ignore its non-Venice surface/API references.
39. **Every AI pass writes a recipe sidecar; finishing passes must append to it.** Each generated/edited asset gets `shot-NNN.recipe.json` (via `appendRecipePass()` in `src/venice/recipe.ts`) — an append-only log where every entry is a replayable Venice call: kind (`generate` / `multi-edit` / `video-generate` / `mechanical`), role (`content` / `identity` / `look` / `mechanical`), model, prompt, negative, seed, cfg, and reference-image paths (stable on-disk paths, never data: URIs). The harness writes it automatically for character refs, storyboard passes 1–3, the seedance launder pass, keyframe extraction, and video renders. **Finishing convention:** shots are finished with AI model calls, not local pixel edits — any post-harness polish/fix pass (agent, MCP, one-off script) must go through `appendRecipePass()` too, which also updates the provenance sidecar in the same write so the Seedance gate stays honest. Roles make finishing safe: `look` passes can be redone freely; `identity` passes (character refine, R2V anchors, seeds, `@Image` mappings) must not be disturbed by a look polish; redoing a `content` pass invalidates everything after it. To regenerate a shot that matches the episode, replay its recipe (same STYLE string, seed, cfg, refs, style anchor — the `.style-anchor.png` in each scene dir is intentionally kept on disk) instead of hand-prompting. The pass-1 `--debug` prompt dump is superseded by this; the recipe is always written.
40. **Dialogue shots on reference-audio-capable models carry a per-character voice-donor clip (`reference_audio_urls`, bound in-prompt as @AudioN).** When a shot routes to a reference-audio model (Seedance 2.0 R2V family, HappyHorse 1.1 R2V) and the speaker is a visible non-narrator character, the harness attaches that character's voice reference so the native model dialogue keeps the same timbre/accent/pacing across shots. The clip lives at `characters/<slug>/voice-reference.mp3` — generated on demand via `seed-audio-1-0` from the character's `voiceDescription`, supplied by the operator, or **harvested from a rendered clip** (`generate-voice-reference --from-shot <n>` / `lock-character --voice-from-shot <n>`, optional `--from-start/--from-end` windowing): the shot's audio is extracted, silence-trimmed at both ends, and normalized into the 2-15s window — locking every later shot to the voice the audience actually heard instead of a seed-audio synthesis the model's first performance may disagree with. Harvest is local-only (no Venice call) and records a `mechanical`/`identity` recipe pass with the source clip path. The generator auto-creates a missing clip inline before rendering (mirroring the inline dialogue-TTS pattern) and persists `voiceReferencePath` to `character.json`. Wire-in rules: Venice REQUIRES ≥1 reference image alongside reference audio (audio-only is rejected), each clip is 2-15s with an aggregate ≤15s across ≤3 clips (out-of-budget clips dropped + warned), and the @AudioN index in the prompt MUST match the push order into `reference_audio_urls`. The prompt binds it as "Use @Audio1 only for voice identity — timbre, accent, pacing; regenerate clean studio dialogue" so the model doesn't copy any junk-tail noise. Opt-outs: `generate-videos --no-voice-reference`, or series-wide `videoDefaults.voiceReferenceForDialogue: false`. Explicit clips: `generate-voice-reference` / `lock-character --voice-reference <file>` (CLI), `character { action: "generate_voice_reference" }` / `character { action: "lock", voiceReference }` (MCP). Wan 2.7 lip-sync shots do NOT take reference audio — they get `audio_url` instead (rule 32).
 41. **Locations are first-class entities with generated reference images, folded into panels and video like character refs.** A `Location` (name, slug, description, lightingNotes, seed) carries faceless reference plates under `locations/<slug>/`: exactly four wide compass plates — one from-scratch `north` hero plate plus derived `south` / `east` / `west` — each a multi-edit of the north plate (nano-banana-2-edit), so every plate is ONE coherent space instead of independent gens of different rooms (see rules 56 and 61). Provenance `hasFace:false`. Tag any shot with `location: <slug>`. Effects: (a) **storyboard Pass 1** injects the location's locked `description` + `lightingNotes` into the panel prompt (serves anti-pattern 7) and adds the north hero plate as an environment anchor; (b) **Pass 2 refine** prefers the location ref as the environment/style anchor (characters first, location takes the last free slot); (c) **video**: Kling O3 R2V shots auto-populate `scene_image_urls` from the location (hand-set `sceneImagePaths` override wins); Seedance / HappyHorse (no `scene_image_urls`) get location plates via the reference slot planner (`reference-slots.ts`) — the north hero plate then its derived same-room plates land in `reference_image_urls` with per-plate `@ImageN` role clauses ("the south wall of the same location"), within the per-model budget. Create locations with `add-location` / `generate-location-references` (CLI) or the `location` MCP tool (`add` / `generate_references` / `list`). `workshop-episode` also emits a `locations[]` array, tags every shot with a slug, and auto-generates any missing refs right after saving the draft (cost logged). Reuse existing slugs across episodes instead of redefining a place.

42. **The reference stack is the shot's single source of consistency (reference-first, 2026-07-30).** Seedance 2.0 R2V Enhanced is the default for ALL lanes and every shot renders in **pure reference mode — no `image_url` start frame** — from an ordered `@Image` slot plan built by `src/mini-drama/reference-slots.ts`: (1) one primary angle per character ("@Image1 is Bob"), (2) the beat's **storyboard blocking plate** (PROTECTED, when present — off by default since 2026-08-13, see rule 56), (3) location plates — the north hero plate then its derived same-room plates ("@Image5 is the south wall of the castle courtyard"), (4) second character angles. Budget is per-model (`getMaxReferenceImages`, 9 on Seedance R2V family + HappyHorse 1.1 R2V, 4 legacy elsewhere); overflow drops second character angles first, then trailing location angles — plates last. The prompt's `@ImageN` indices and the `reference_image_urls` push order come from the SAME slot list, so they can never disagree. Storyboard plates are composed images of a scene beat (multiple characters positioned in a location relative to each other — "@Image7 shows Bob and Alice fighting over the golden chalice inside the courtyard") generated per beat by `storyboard-reference-generator.ts` (auto-planned in `workshop-episode` / `generate-videos`, manual via `generate-storyboard-refs`), stored at `storyboards/<slug>.png`, and bound with the role clause "use it ONLY for composition, blocking, and spatial relationships; take each character's appearance from their own reference." Different beats of the same scene get different plates (different angles/moments) — every asset must stay consistent across space and time. Last-frame chaining and panel start-frames remain available for i2v models and the rule-32 keyframe pipeline, but are NOT used on the reference-first path.

43. **Never re-submit a render whose `queue_id` might still be in flight — re-attach instead.** Venice charges at queue time, not at download time, so a lost `queue_id` is money already spent. Every queued video/audio render is now recorded to `pending-jobs.json` in the config dir *before* the first poll (`src/venice/job-store.ts`), keyed by output path. `generateVideo`, `generateQueuedAudio`, and `renderVideoFile` all check for an existing record first and resume polling it; the record is cleared only on successful download or a definitive terminal failure. Consequences for an agent: (a) if a generation command is interrupted, re-run the SAME command with the SAME output path — that re-attaches; do not "start fresh", and do not delete the pending record to make a warning go away; (b) inspect in-flight work with `venice-video queue` and only `queue clear` a record you know is dead (Venice keeps a job ~1h; `isStale` flags older ones); (c) `queue` is Venice's side of the work while the shell's `/jobs` is only the current session's background commands — they are different lists. Cancellation is cooperative: an `AbortSignal` is threaded through the client, poll loops, and retry backoff via `AsyncLocalStorage` (`src/venice/operation-context.ts`), so a Ctrl-C stops polling but does NOT stop Venice from finishing and billing the render.

44. **Every suggested next step is a runnable command line, and every state-changing command refreshes the treatment page.** Two rules that both exist because the operator cannot see what the harness knows. (a) **Never print a bare verb or a slash-prefixed name as a suggestion.** `/` is reserved for the shell's meta-commands (`/help`, `/status`, `/jobs`, `/use`); a production command typed with a leading slash fails as an unknown meta-command. Suggestions must carry `-p <project>` and `-e <episode>` so they work pasted into any terminal, not only a shell session with a selection. `qualifyCommand()` in `src/session/status.ts` does this for anything derived from `collectProjectStatus`. (b) **`WORKSHOP.html` is a live document.** `refreshTreatment()` (`src/mini-drama/treatment.ts`) re-renders it from on-disk state — stage, counts, next command, and per-shot panel/clip/voiceover/QA-verdict — and every command that produces an artifact calls `updateTreatment()` on its way out. It rewrites only `WORKSHOP.html` and `WORKSHOP.md`, never `workshop.json` (the draft belongs to the workshop and its revision counter). It renders the *live* `script.json` when one exists, so `insert-shot` additions appear. It is wrapped so it cannot throw: a corrupt panel or half-written `qa-report.json` degrades to a blank cell rather than failing a paid render. Thumbnails are WebP data URIs (a `file://` subresource would be blocked by the browser) cached against mtime in `.treatment-thumbs.json`. **When adding a command that writes an artifact, add the `updateTreatment(series, episode)` call too.**

45. **A gate's status reporter must check the same conditions as the gate.** `collectProjectStatus` mirrors the pipeline's file-based state machine, and any drift between the two produces confidently wrong advice. The case that bit: `storyboard-episode` accepts either `script-approved.json` OR `script.status === 'approved'`, but the reporter checked only the file — and `workshop --approve` sets only the status. Every workshop-driven project was therefore told to run `approve-script` on a script it had already shot, forever. When changing a gate condition in `cli.ts`, change `classifyEpisode` in the same commit.

46. **The intelligence model is a per-project setting, and its vision companion never crosses the privacy tier.** Three steps reason rather than render — the workshop, the shot script, and storyboard QA — and the model behind them is chosen at project creation and stored on `series.intelligence` (`{ model, visionModel }`), not on `videoDefaults`, which is for models that make pixels. Precedence at every call site is explicit `--model`, then the project's setting, then `DEFAULT_INTELLIGENCE_MODEL`. The registry is `src/venice/text-models.ts`; `resolveIntelligence()` pairs a text-only choice with the cheapest vision-capable model **in the same privacy tier**, because quietly promoting a `private` project's panels to an `anonymized` model to work around a missing capability would break the promise the operator made when they picked private. Two related invariants: (a) an explicit `--model` on `qa-storyboard` names the panel reader directly and is used verbatim — substituting a "safer" model silently ignores the flag; (b) a QA shot whose vision call *failed* is unchecked, not low-severity, so it is counted separately (`summary.errored`) and suppresses the "no critical issues, run qa-approve" suggestion. Folding those into `FLAG-LOW` alone once let a storyboard where every single call had failed read as clean.

47. **Ask a model for JSON through `client.chatJson()`, never by parsing `post()` yourself.** Reasoning models are now the default, and three things break naive parsing: fences appear inconsistently; some models emit *almost* valid JSON (GLM 5.2 drops a closing brace roughly one attempt in three); and a model with no vision answers an image prompt with an empty string or an opaque 400 rather than a useful error. `chatJson` strips fences, retries once quoting the parse error back to the model, and names the no-vision case explicitly. Related: `describeApiError()` reads all three Venice error shapes — `{error:"..."}`, `{error:{message}}`, and `{issues:[{message}]}` — because reading only `error.message` discarded both the "Did you mean: …" model-not-found hint and the "Image content is not supported by this model" validation message, leaving operators with a bare HTTP status. Reasoning also costs tokens from `max_tokens`, so a budget sized for a plain model can return empty content.

48. **The CLI is self-describing — discover state and order from it, do not guess.** `venice-video agent-guide [--json]` prints the core operating rules (this section in miniature) and ships inside the binary, so it is available even on a bare global install with no `AGENTS.md`. `venice-video pipeline [--json]` prints the ordered stages, their gates, and the command that advances each; it mirrors `classifyEpisode` in `src/session/status.ts` (rule 45 — change both together). `venice-video status -p <project> [--json]` reports where a project stands and the next command. `--json` is supported on `status`, `pipeline`, `agent-guide`, `doctor`, and `queue` (and globally as `venice-video --json <command>`); it prints exactly one JSON object on stdout, and the human text rendering is unchanged. Exit codes are honest — `status` with no project exits non-zero — and ordinary errors are a clean `error:` line, not a stack trace (`VENICE_VIDEO_DEBUG=1` for the stack). The condensed guide is kept in `src/agent/guide.ts` and duplicated as an installable skill at `.agents/skills/venice-agent-guide/`; when a non-negotiable here changes, change those two as well.

49. **Spatial consistency is authored, not inferred — every prompt states placement relative to locked anchors (2026-08-05).** Visual consistency (identity, wardrobe, palette) is handled by the reference stack, but *spatial* consistency — who stands where, which side of frame, facing which way, relative to which landmark — drifts unless it is stated the same way in every generation. The harness now carries geometry as first-class data: (a) **`Location.spatialAnchors`** is the locked geography of a place — 3-5 named landmarks and their fixed relative positions ("bar counter along the back wall; entrance door opposite it; neon window left of the door as seen from the counter"). It is baked into the location reference angles at generation time, injected as "Fixed layout (never rearrange): …" into every panel and video prompt for shots tagged with the location, and is sticky on merge — an existing anchor set is never overwritten by a later script part. (b) **`ShotScript.blocking`** is the shot's authored geometry — 1-2 sentences placing each character/object relative to the named anchors, the frame (screen left/right, foreground/background), and their facing/eyeline. The workshop and script LLM prompts require it for every character shot, with continuity rules: characters keep their screen side and relative positions across consecutive shots unless a movement is written into the action; screen direction and eyelines are preserved (180-degree rule); close-ups still name what is behind/beside the subject. It is injected verbatim (with @ImageN/@ElementN name substitution) into the panel prompt (`BLOCKING: …`), the video prompt (`Blocking: …`), the multi-shot per-beat blocks (both the Seedance native lane and the legacy Kling format), and it seeds the beat's storyboard blocking-plate description. (c) The video prompt's plate clause now forbids mirroring/swapping ("each character stays on the same side of the scene… do not mirror, swap, or rearrange who stands where"), and plateless location shots get a geography-hold clause anchored to the location's first `@ImageN` slot. (d) **QA reads geometry**: `qa-storyboard` evaluates a fourth SPATIAL CONTINUITY dimension against the shot's stated blocking and the location's landmarks, and attaches the nearest prior panel from the same location so side-swaps, mirrored geography, and moved landmarks are caught against real coverage — a spatial flip that breaks the scene is FLAG-CRITICAL. (e) `workshop-episode` warns when character shots are missing `blocking` or locations are missing `spatialAnchors`; `insert-shot` inherits the anchor shot's location and blocking (same-scene splices keep the established geography) and takes `--location` / `--blocking` overrides; `add-location` takes `--spatial-anchors`. The failure mode this kills: characters teleporting across the room, swapping frame sides between coverage, and set geography silently mirroring between shots that read as the same scene (see anti-patterns 20 and 26).

50. **Montage-first generation is the default on this branch (Seedance 2.5, 2026-08-07).** Seedance 2.5 (`seedance-2-5-reference-to-video`, quote/queue only — not on GET /models) renders every integer 4-30s in one pass, 480p/720p, aspect 21:9/16:9/4:3/1:1/3:4/9:16, with up to **30** image references (release-note ceiling 30 img / 10 video / 10 audio, 50 total — enforced harness-side, the quote endpoint does not police it) plus `audio_url`, `reference_audio_urls`, and `reference_video_urls`. The planner (`buildGenerationPlan`) therefore groups each SCENE — a maximal run of consecutive shots sharing a `location` tag (`groupShotsIntoScenes` in `src/mini-drama/montage.ts`) — into ONE `montage` generation unit up to 30s, prompted with the **timestamped SEQUENCE grammar** from the vault's "Make a full trailer with Seedance 2.5" pack (`buildMontagePrompt` in `prompt-builder.ts`): a SHOT header declaring "an N-second fast-cut montage, cut it yourself in the edit", @Image identity declarations + role clauses from the SAME reference slot plan as every other lane, a CAMERA line with hard-cut enforcement, per-beat `[0:03-0:05] …` blocks (action, blocking, dialogue, diegetic sound), the geography-hold clause, then ONE style token ending in "Face stable throughout, no deformation. Diegetic sound only, no music, no on-screen text." and a SHORT negative. The four vault rules (no music / face-stability line / @Image discipline / short negatives) are hard-coded. Beat timestamps live on `GenerationUnit.montageBeats` — the SAME list the post-render cutter (`cutMontageIntoShots`) slices on, scaled to the actual rendered duration, so the prompt and the edit can never disagree. Every beat is cut to (a) the canonical `scene-001/shot-NNN.mp4` the assembler/QA/subtitles already read, and (b) `episode-N/media-library/scene-NN/shot-NNN.mp4` alongside the uncut master and a `manifest.json` (per-cut timestamps, shot type, description) for hand editing / the Venice Video Creator. **The `autoEdit` toggle decides what happens next:** `videoDefaults.autoEdit: true` (or `generate-videos --auto-edit`) chains straight into `assemble-episode`; the default `false` (or `--no-auto-edit`) stops at the media library. Inserts, title cards, and `mustStaySingle` shots fall through as classic singles; exact-lip-sync projects keep their dedicated lane. Opt out of montage planning entirely with `videoDefaults.montageMode: false` or `generate-videos --no-montage` (restores the 2.0-era per-shot / 15s multi-shot planner, which remains untouched). Montage duration preflight validates the UNIT total against the 2.5 ladder, not the per-beat windows. Smoke tests: `scripts/smoke-montage-plan.ts`, `scripts/smoke-montage-cut.ts`. **The render route is chosen up front at project creation** — `venice-video new` (and `new-series`) asks "montage (default, recommended — one single-pass generation per scene, auto-cut into a media library for later editing) vs standard (special-purpose per-shot planning: non-groupable scripts, per-shot render control, non-Seedance families; more prone to consistency drift)", or pass `--route montage|standard`. Montage is the default for everyone; standard is the escape hatch, not the beginner path. The answer sets `videoDefaults.montageMode` (montage→true, standard→false); omitting it keeps the montage-first default. See `RENDER_ROUTE_CHOICES` in `src/mini-drama/choices.ts`.
    **Update 2026-10-25: one generation per continuous run, a shot-list prompt.** A 31-render continuity bakeoff (Seedance 2.5 R2V, 720p) found:
    - One generation kept a recurring vehicle and a character's facing across cuts in 3 of 3 takes. Separate per-shot generations swapped the vehicle in 4 of 4 (one of them was given the previous shot's last frame) and turned the character round in 3 of 3.
    - Its planned cuts landed as hard cuts 25 of 25 times, within 0.55 s, mostly early.
    - The SEQUENCE prompt with all four views of each place scored worst with blind judges, and the extra views put one place's action on the other place's plate.

    So:
    - **Grouping.** `groupShotsIntoScenes` keeps a shot whose `continuity.from` is `continuous` (rule 64) in the running scene even when its location changes. `new-scene` always splits, and `later` or no continuity splits on a location change, as before.
    - **Inserts.** A continuous insert rides in its run, and keeps its own clip: a montage keys every clip by the shot id with an insert's suffix (`shot-013b.mp4` beside 13's `shot-013.mp4`; `MontageBeat` and `GenerationUnitSegment` carry `shotIdSuffix`), in the cut, the media library and its manifest, the skip check, `--retake` archiving and `qa-videos`. The cutter refuses a beat map where two shots would write one clip.
    - **Prompt.** The default montage prompt is a shot list (`videoDefaults.montageGrammar: 'shots'`, `buildShotListMontagePrompt`). It names every place in its first line, then gives references in first-appearance order. Each shot is a line `Shot N | a-bs | <place> (@ImageK): …`, joined by "Cut to", and followed by "Every cut is an abrupt single-frame hard cut". An object cast member that is a vehicle (`isObjectCastMember`: traits that start "inanimate object", or `Character.prop`) gets a count line ("Exactly one … and no other vehicles"); what the object is decides it (`objectVehicleKind` in `plan-check.ts`: the head noun of its name and of its description), so a car key or a bus ticket gets none. A continuous shot says where the previous one ended (`endState`).
    - **Length.** Past a soft guard (8000 characters for the shot list, 5000 for SEQUENCE) blocking shrinks first, then descriptions, never below 40%. Past the model's hard cap (no registry field: 10,000 by default, 2,500 for MiniMax H3) descriptions go lower, and a prompt that still does not fit is refused before anything is queued. References, dialogue and the STYLE tail are never cut (`fitMontageProse`).
    - **References.** When the unit's shots name their `plateView`, `buildMontageSlotPlan` (`reference-slots.ts`) sends only each shot's staged view (`montageReferenceViews: 'staged'`), each with "for shots N only … does not set where people, vehicles or props are in any other shot". `'all'` adds the other canonical views of each place and the cast's second angles, in first-appearance order; it adds no custom angles and is not the old plan. Over budget, extra views and second angles go first, then later views of a place already shown, then other place images and storyboard plates from the end; the cast goes last. A shot whose image was dropped points at the first image of its place still sent, and that image's clause names it. A unit where no shot names a `plateView` (every script written before the field) sends what a montage always sent: every view of its place, custom angles and second angles, with the usual role clauses.
    - **Cutter.** `cutMontageIntoShots` snaps each planned boundary to a detected scene cut within ±0.8 s (`snapBoundaries`). Cuts are matched in order, and the assignment that snaps the most boundaries wins, then the smallest total offset, so a boundary never takes the next boundary's cut. ffmpeg reports a scene cut at the new shot's first frame, so a snapped cut trims nothing. At a planned cut no scene cut confirmed, the frame on each side shows a copy of its neighbour (video only, one frame; the clip keeps its length). Cuts fall on frames and neighbouring clips share their boundary, so no audio is lost: the clips' audio adds up to the master's. A cut pushed past the end of a short render is pulled back inside it (every clip keeps a frame), and a clip written without video fails the run. `manifest.json` records the detected cuts, the boundaries and any cut nobody planned.
    - **Skips.** A montage unit whose clips are all on disk is skipped even when no master carries its name. Masters are named after the scene and the first and last shot, so a change in grouping (a script that gains `continuity` merges places and renumbers later scenes) renames units whose shots did not change; rendering them again would bill them again. Moving a master aside by hand no longer forces a re-render either: the log names the `--retake` command. `qa-videos` checks such a unit from its clips (`unitFootage`): head glitches in the first, boundary light at the first and last, identity frames from each; the neighbour-continuity and speech checks read the shot clips in any case.
    - **Re-takes.** `generate-videos --retake <shots>` archives (`-vN`) and re-renders the whole unit holding those shots, and nothing else; a number covers its suffixed inserts. An empty list, or a shot the script does not have, is refused before any work, and the duration preflight runs before anything is archived (`archiveRetakeUnits`). `generateEpisodeVideos` gets the whole plan plus `onlyUnits` (plan positions: an insert's single shares its base shot's unit id), so shots match their units in script order and a chained unit starts from the clip before it, re-taken or not. Never re-take one shot of a continuous run on its own: its neighbours were drawn in the same generation.
    - **Fallback.** `montageGrammar: 'sequence'` restores the SEQUENCE prompt together with its grouping (one location per generation, inserts as singles, `continuity` ignored) and its references (the scene's first shot). The cutter snaps in both grammars.
    - **What one generation does worse.** It carries the first plate's state forward: in 2 of 3 takes a vessel that was moored in the first plate was still moored in the departure shot. Write the state change into the shot (the doors close, the lines are cast off, it pulls away), or split there with `new-scene`.

    Tests: `tests/montage-engine.test.mjs`, `tests/montage-retake.test.mjs`, `tests/prop-cast.test.mjs`, `tests/video-qa.test.mjs`.

51. **Seedance 2.5 is the default video model across every lane (2026-08-07).** The seedance/auto family defaults — `DEFAULT_ACTION_MODEL`, `DEFAULT_ATMOSPHERE_MODEL`, `DEFAULT_CHARACTER_CONSISTENCY_MODEL`, `DEFAULT_MULTISHOT_MODEL`, `resolveVideoFamilyDefaults('seedance'|'auto')`, and the in-family `resolveLipSyncModel('seedance'|'auto')` — all resolve to `seedance-2-5-reference-to-video` (was `seedance-2-0-enhanced-reference-to-video`). This makes the whole harness uniform with the montage lane (rule 50, `DEFAULT_MONTAGE_MODEL`), which was already 2.5. Nothing hardcodes 2.0 ids in the routing path — the prompt builder and planner branch on capability sets (`MODELS_USING_IMAGE_TAGS`, `MODELS_SUPPORTING_REFERENCE_IMAGES`, `MODELS_SUPPORTING_REFERENCE_AUDIO`, `resolveMultiShotModel`, `resolveMontageMode`), and Seedance 2.5 R2V is in every one of them, plus `MODELS_SUPPORTING_AUDIO_INPUT` (its spec has `audio_input: true`; the coverage test enforces the set membership). What 2.5 buys as the default: single-pass durations up to 30s (every integer 4-30s), up to **30** reference images (vs 9), aspect 21:9/16:9/4:3/1:1/3:4/9:16, plus `audio_url` / `reference_audio_urls` / `reference_video_urls`. The one trade-off: 2.5 tops out at **720p** where 2.0 R2V Enhanced reached 1080p — but the mini-drama generator already pins `720p` for every `seedance` model, so no request regresses and none can 400 on a 1080p body. The 3+/7+ character Kling fallback effectively stops firing at 2.5's 30-ref budget (kept for extreme counts / scene-image needs). **To go back to 1080p on a project**, set `videoDefaults.characterConsistencyModel` (and/or `actionModel`/`atmosphereModel`) to `seedance-2-0-enhanced-reference-to-video` — it stays registry-known. **Prompt-writing for 2.5** is covered by the installed `.agents/skills/sd25-pe/` skill (Seedance 2.5 Prompt Optimizer + a harness-bridge section): compile the creative content with it, then let the harness own identity/refs, duration, resolution, and the no-music suffix (see the skill's "Reconciliations"). When this default changes again, update `src/series/types.ts`, regenerate `capabilities.json` (`npm run manifest`), update the routing table above + the `venice-video-model-routing` skill, **and sync the Creator app (`products/venice-video-editor`)**: copy the regenerated `capabilities.json` into `Sources/VeniceVideoCreator/Resources/Capabilities/`, add/adjust the `SupplementalModels` entry for the (delisted) default model, and update `ProductionOrchestrator.preferredAutoFamily`, `MultiShotPlanner.WindowBudget`, and the model-selection/duration text in `AgentInstructions` — the app froze at the harness's 2.0-era state once because this step was missing.

52. **QA the rendered video, not just the panels (2026-08-10).** Panel QA (`qa-storyboard`) runs before any video exists, and each generation unit re-interprets the character references independently — so the same protagonist can render as visibly different people across units while every unit looks fine in isolation. Canopy-run shipped with three different leads in the front half for exactly this reason. The pipeline now has a post-render stage: **`qa-videos -p <project> -e <n>`** (a) scans every unit's head frames for the Seedance transition-junk flash (per-frame luma spike that reverts — free, ffmpeg signalstats), (b) checks boundary luma jumps between consecutive units in assembly order, (c) vision-checks per-unit hero frames against the character sheets, and (d) runs the **cross-unit identity check** — one hero frame per unit, all in ONE vision call, "is this the same person in every frame?" — which is the only check that can see drift that panel QA structurally cannot. The report is `video-qa-report.json`; a failing one **blocks `assemble-episode`** (a missing one only warns, for back-compat). `--skip-video-qa` is the operator's "I watched the footage myself," not a fix. `status` now surfaces the stage as `clips complete, unverified` → `qa-videos`.
    **Update 2026-10-26: the cuts themselves (neighbour continuity), and lines cut off at a cut.** The identity checks compare only people, so a vehicle that changes between two generations, a person who faces the other way after the cut, or an object a close-up shows and the next shot does not, all passed.
    - **The pairwise check.** One vision call per cut (`checkPairContinuity` in `src/mini-drama/video-qa.ts`), counted and printed before any is made, with the name of the judging model. The call gets the middle and last frame of shot A and the first and middle frame of shot B, cut as `assemble-episode` cuts them (`trimStart`, `trimEnd`, `flip`; the judge is told when a shot's frames are mirrored, so reversed markings are not read as a different object), plus the shot text and the reference images of the object cast members (vehicles, props) in the two shots. The model fills in a checklist: vehicle same or different, people (only those in both shots), props, facing same or reversed, action continues or breaks, light. The verdict is computed from the answers (`verdictFromAnswers`): a different vehicle, person or prop, or a reversed facing, is FLAG-CRITICAL and blocks assembly; a broken action is moderate, a light change low. A reply that does not answer all six questions with an allowed value leaves the cut unchecked, never passed (rule 46), and an unchecked check blocks assembly too. So does a clip that cannot be read (not on disk, a bad time or reference in a cut list): the cuts beside it are unchecked, with no call made.
    - **Which cuts, and how they join.** `--continuity joins` (the default) checks the cuts between two generations, `all` every cut, `off` none. How a cut joins comes from the script (`markJoins`), and only when the clip before it is the script's previous shot (an edit that reorders shots, or puts a cutaway between them, is not the join the script describes). `continuous` is judged in full, even across a change of location; `new-scene` is not judged; `later` is a time cut. With no `continuity.from` (scripts written before it) or a join the script does not describe, only a cut known to stay in one place (both shots tagged with the same location) is judged, and never as critical; with a location missing on either side, or two different ones, the cut is not judged. A time cut and an unstated join use the same rule: the judge is told, and only a different vehicle, person or prop counts, as moderate. So a project that passed before is not blocked on its scene changes or time jumps.
    - **Edits.** `--cut-list <file>` checks an edit instead of the plan: `{"clips":[{file,in,out,shot,location,newScene,text,refs,speech}]}`, or an EDL with `cuts: [{master,srcIn,srcOut,shot,location}]`. Every cut is checked by default. A clip that names a script shot takes its join from the script; one that does not is judged as continuous with the clip before unless it sets `newScene: true` or the two set different `location`s. In the plan, a shot whose clip is not on disk leaves the cuts beside it unchecked, as in a cut list. The report goes next to the file, and an unchecked cut fails the run. `--skip-vision` and `--continuity off` make no call. A film cut in an editor never passes through `assemble-episode`, so this is its only check.
    - **The judge decides the result.** On known breaks from a continuity bakeoff, `claude-opus-5` with the checklist and a vehicle reference flagged a boat swap and a facing flip as critical and a stuck departure as moderate, and passed the clean cuts; `kimi-k3` missed them. The default stays the project's vision model (rule 46), and every run says which model judged; pass `--model` with a stronger judge where the project's privacy tier allows it.
    - **Lines cut off at a cut.** A clip with an on-camera speaker (the line's speaker is in the shot and is not the narrator) whose cut is not in silence gets a warning (`checkSpeechTail`: ffmpeg silencedetect, below −35 dBFS for at least 60 ms, or 20 ms at the very end of the sound). When the source runs on past the cut it says how far the speech goes. It never blocks, because loud ambience at a cut reads the same. Nothing is checked on a clip with no audio stream, on a clip whose sound stops before its cut, or at a cut where the next clip plays on from the same sound (`playsOn`: a montage's beat cuts, an edit's contiguous cuts in one take).
    - **Scene cuts are not light jumps.** The boundary-luma check skips a unit join that changes location (`isLocationChange`; in montage mode every join is one). Vision images go at most 1024 px on the long edge, as JPEG (`visionDataUri`): full 1080p PNGs came back HTTP 413.
    - `qa-videos` exits non-zero whenever its report does not pass. Tests: `tests/continuity-qa.test.mjs`, `tests/speech-tail.test.mjs`, `tests/video-qa-vision-input.test.mjs`.

53. **When units drift, harvest an anchor — don't re-roll the sheets (2026-08-10).** Regenerating reference sheets and re-rendering everything is the expensive, drift-prone fix. The cheap, convergent fix: **`harvest-anchor -p <project> -c <CHARACTER> --video <unit-master> --at <sec> [--crop w:h:x:y]`** extracts a frame from a unit that PASSED qa-videos into the character's reference stack as `anchor.png` (prior anchor archived, provenance sidecar written). The reference-slot allocator prefers `anchor.png` over the generated sheets for the primary identity slot, so re-renders of the drifting units anchor to the *exact rendered identity* the keeper units already have, instead of re-interpreting the sheets a fourth time. Inspect the harvested frame before re-rendering — a soft, occluded, or badly lit anchor propagates its flaws into every later shot. This is the canopy-run rescue (harvested a still of the stable unit s04, re-rendered s01–s03 for ~$13, identity converged) promoted to a first-class command.

54. **Reference images are a storyboard precondition, enforced (2026-08-10).** `workshop --approve` materializes characters and locations as *data* — it does not generate reference images. Storyboarding without them silently produces reference-free panels and a failed refinement pass (canopy-run burned a full 14-panel render this way). `storyboard-episode` now preflights: every scripted character needs `front.png`/`three-quarter.png` (or `anchor.png`) on disk and every scripted location needs at least one angle, or the command blocks with the exact `add-character` / `generate-location-references` commands to run. The right sequence after workshop approval is: `add-character` per cast member → `generate-location-references` per location → `generate-storyboard-refs` → `storyboard-episode`.

55. **Approval gates read what they approve (2026-08-10).** `qa-approve` used to write its artifact blindly; canopy-run cleared the QA gate with 5 of 14 shots never actually read (the vision model returned empty for them). Now `qa-approve` opens the latest `qa-report.json` and blocks when it contains criticals or unchecked (errored) shots — `--force` is the operator asserting they reviewed the panels themselves. Similarly, `qa-storyboard` no longer strands unread shots: when the chosen model returns empty/errors for a shot, it automatically retries that shot on the project's paired vision companion (same privacy tier by construction) before marking it UNCHECKED.

56. **Location angles are ONE coherent space (anchor → derive), and storyboard plates are off by default (2026-08-13).** Two coupled changes that fix location drift and shot-to-shot sameness. (a) **`location-generator.ts` derives angles instead of re-imagining them.** `wide.png` is the only from-scratch text-to-image generation — the hero establishing plate. `angle-2` / `angle-3` / `angle-4` are each a `/image/multi-edit` of `wide.png` (nano-banana-2-edit by default), a pure single-image re-angle with a "SAME room, keep every surface/material/light, only the camera moves" contract. This replaces the old `wide` / `medium` / `detail` ladder, which was three INDEPENDENT t2i calls (same seed, different prompt ⇒ three visibly different rooms) fed to the video model at once as "the same place" — the root cause of location drift. Because every angle descends from one plate, the set is guaranteed coherent; the edit model preserves the wide's exact frame (no 1:1 crop). Angle prompts LEAD with the wall that should FILL the new frame — a negative ">90° turn away from X" ("window behind camera") reverts the edit to the master, while "the RIGHT-hand wall now fills the frame" holds (validated on `il-caso-impossibile`, 3/3). Legacy `wide`/`angle-2/3/4`/`medium`/`detail` are still read from disk on old projects and still requestable via `--angles`; the default set since 2026-10-05 is the compass set `north,south,east,west` (rule 61). Deriving requires the hero plate (`north.png`, or legacy `wide.png`), which is generated first automatically when missing. There is not yet a real-photo location anchor (the character `anchor.png` equivalent) — locations still start from a generated hero plate. (b) **Storyboard blocking plates no longer auto-generate.** `workshop-episode` and `generate-videos` skip plate generation unless `videoDefaults.useStoryboardPlates: true` (`resolveUseStoryboardPlates`). A plate is a full pictorial frame; used as an R2V reference it drags every shot in a beat toward the plate's composition (the "too similar to previous shots" drift), one plate is shared across a whole beat regardless of each shot's framing, and its composited version of the location is a fourth, conflicting environment signal. Spatial consistency is instead carried by the coherent location angles + the shot's authored text `blocking` (rule 49) + the geography-hold clause the prompt builder already emits. Plates remain available on demand via `generate-storyboard-refs` (for dense multi-character blocking the text can't disambiguate) and, when present on disk, are still consumed as the PROTECTED slot by `reference-slots.ts`. When changing this, keep `location-generator.ts`, `reference-slots.ts`, `storyboard-reference-generator.ts` (plate base image), the CLI ref-preflight checks, `workshop.ts`/`web/state.ts` art angle lists, and this rule in sync.

57. **The browser UI is the built-in node web app — default to `venice-video web`.** When the operator asks for a browser UI, a dashboard, or to "open the harness in a browser," start the bundled local web app: `venice-video web` (browser dashboard + a whitelisted command runner over the workspace; binds localhost only, `http://127.0.0.1:3000` by default). Do NOT scaffold a separate/ad-hoc UI or point them at anything else. Per the workspace dev-server rule, kill existing node processes first and run on port 3000. The compiled front-end ships inside the npm package (`dist/web/ui/dist`); `npm run web:build` rebuilds it. Command lives in `src/mini-drama/cli.ts` (`web`), server in `src/web/server.ts`.

58. **Loop mode has two modes — a disposable Turbo draft (watch) and an identity-locked Max R2V create — both gate-skipping and money-capped (2026-09-04).** `venice-video loop -p <project> -e <n> [--mode watch|create]` boots the web UI (Loop tab) plus an in-process `LoopEngine` (`src/mini-drama/loop-engine.ts`) that renders every shot into `episodes/episode-NNN/loop/` and keeps regenerating fresh takes so the browser can watch the whole plan on repeat while it evolves. Both modes **bypass the references(only for watch) / storyboard / QA gates** — the only hard precondition is a shot script with ≥1 shot — and write ONLY under `loop/` (`shot-NNN--takeK.mp4` + `loop-manifest.json`), never touching canonical `scene-001/` renders or `series.json`, so a loop can run alongside real production. **The mode is the session's first, REQUIRED decision** — the `loop` command asks "is this for LOOPING (creative flow, lower quality) or PRODUCTION (gather usable shots, higher quality)?" It is a deliberate quality-vs-flow tradeoff, never a silent default: interactive `promptChoice` in a TTY, and a **hard error** in a non-interactive run with no `--mode` (agents MUST pass it). `--mode` accepts natural words — `looping`/`loop`/`fun`/`creative` → watch, `production`/`prod`/`gather` → create (`normalizeLoopMode`). Internally the modes are still `watch`/`create`. **The two modes are the point:** (a) **watch** (enjoyment / creative flow) = MiniMax H3 Max **Turbo** at 480P (~$0.012/s): the first generation is **t2v**, every shot after it **chains i2v off the previous last frame**, and it **NEVER uses R2V** (R2V renders are too slow for a loop, and Turbo has none anyway). It ignores panels and the reference stack — identity is NOT locked; it is a fast fun loop, never production-fidelity. (b) **create** (gather good shots for a project) = the real reference-first routing on the **non-Turbo** H3 Max family at 768P (~$0.024/s): character shots render on `minimax-h3-max-reference-to-video` with the full `@Image` reference stack + voice-donor audio (identity **locked**, takes usable), atmosphere shots on Max i2v/t2v, **each shot rendered independently (chaining OFF by default** — R2V and a start frame can't combine on MiniMax). Create degrades a character shot to i2v/t2v only when its references are missing on disk — so for create, generate character/location references first (rule 54). Both share the render primitive: create mode reuses `resolveShotReferenceInputs` + `ensureVoiceReferenceForShot` (exported from `video-generator.ts`, the SAME resolution `renderSingleShotUnit` uses), so its reference stack can't drift from the real pipeline. Other invariants: (c) **chaining default follows the mode:** watch chains (shot 1 t2v, every later shot i2v off the previous shot's current-take LAST frame via `extractLastFrame`, so the loop plays as one piece); create does NOT chain (each shot is independent R2V — chaining and R2V are mutually exclusive on MiniMax). `--no-chain` forces it off. Chaining uses a lean prompt because the start frame, not a reference stack, drives the render. (d) **Every take renders the model's full length (15s default)** — not the shot's scripted duration — for maximum footage/playback per render; override with `--duration`. (e) **The loop regenerates continuously** and does NOT settle after a fixed number of takes: `--max-takes` is a **ring buffer** (candidate takes kept per shot; older non-current takes are pruned and their files deleted so an infinite run can't fill the disk), NOT a stop condition. It stops only on Pause, `--once` (one pass), or the budget. (f) **Money:** billed at queue time; `--budget` (default $2) pauses the loop, and the UI **Resume** button (or a per-shot regenerate) authorizes another budget's worth via `engine.start()` — so "Start/Resume" always does something. `--unbounded` removes the budget cap (spends until stopped) but keeps the ring buffer. (g) Resolution is reachable because `renderVideoFile` honors an optional `resolution` override validated against the model's `resolutions` (it otherwise force-pins `768P` for every `minimax-h3-max*` id); **watch defaults 480P (the infinite-loop tier), create 768P**. (h) The engine shares the web server's `EventHub` and broadcasts `loop-updated` for instant hot-swap; the manifest (with `mode` + `chain`) also feeds `collectEpisodeState` so a plain `venice-video web` shows the last loop state. (i) **A shot that fails `MAX_CONSECUTIVE_SHOT_ERRORS` (3) times in a row is given up on** — marked `failed`, dropped from `pickNext`, no longer re-queued/re-billed (a manual `regenerate` clears it). This caps the money leak where a server-side-doomed shot (a MiniMax i2v face start frame — billed at queue time, then `/video/retrieve` 500s) is otherwise re-selected fewest-takes-first and re-billed every cycle. (j) **Face-continuity prompting (`--face-continuity`, default on):** prompts each chained character shot to END on the character's face so the next i2v continuation is smoother — but it is **auto-suppressed** when the chain i2v model rejects face start frames (`i2vRejectsFaceStartFrame`, i.e. all MiniMax i2v lanes; a face-ending frame becomes the next start frame and would trip the death in anti-pattern 31). So it is dormant on both current loop lanes and activates on a face-accepting i2v lane; for face loops today use create/R2V. When changing loop behavior, keep `loop-engine.ts`, the `loop` command in `cli.ts`, the `/api/projects/:slug/loop/*` endpoints + `WebServerOptions.loop` in `src/web/server.ts`, `resolveShotReferenceInputs`/`extractLastFrame`/`i2vRejectsFaceStartFrame` in `video-generator.ts`/`models.ts`, and the `LoopView` UI in sync.

59. **MiniMax H3 Max (simple-prompt) models IMPROVISE dialogue — the scripted line is intent, not a script (2026-09-04).** Unlike Seedance/Wan, which render the exact line you give them, the H3 Max family (`promptStyle: 'simple'`) performs markedly better carrying natural, continuous speech across a whole generation than reciting a verbatim quote — a fixed line fights the model the same way the directorial blocks do. So in **native-dialogue mode** the prompt builders render a speaker's line as INTENT — `[@ImageN, voice, delivery] conveys: "…"` plus a one-line "improvise naturally in character, keep the meaning and tone, don't recite word for word" note — instead of the directorial `[…]: "exact line"` quote. This is automatic via `shouldImproviseDialogue(modelId, series)` in `prompt-builder.ts` (`modelWantsSimplePrompt(modelId)` AND `videoDefaults.audioStrategy !== 'lip-sync'`), applied in both `buildVideoPrompt` (singles) and `buildMontagePrompt` (montage) — the two paths the H3 Max family renders through. Directorial models keep the exact quote; the legacy Seedance-native / Kling multi-shot builders (`buildMultiShotPrompt`) are untouched because those lanes are never simple-prompt. **The exception is exact-lip-sync**: there the `audio_url` drives the exact spoken words, so the line stays verbatim (the improv gate excludes `audioStrategy === 'lip-sync'`). **Consequence:** when a simple-prompt model improvises, burned/exported captions must be derived by transcribing the rendered audio (the editing pipeline's `silencedetect`/whisper path), NOT from `script.json` — the model will not say it word for word. When changing this, keep `shouldImproviseDialogue` / `formatDialogueLine` / `IMPROV_DIALOGUE_NOTE` in `prompt-builder.ts` and this rule in sync.

60. **`stream` is not `loop` — "infinite" means the STORY never ends, not that the playback cycles (2026-09-04).** When the operator asks for an infinite / never-ending / always-on story, use `venice-video stream -p <project> [--direction "…"]`, not `loop`. The two are different products: `loop` takes a fixed shot plan and re-renders the same N shots forever so the *playback* cycles (`--max-takes` is a ring buffer of candidate renders per shot); `stream` (`src/mini-drama/stream-engine.ts`) never repeats and never re-renders. It authors the story live: the intelligence model (`series.intelligence`, or `--writer`) writes ONE beat at a time from the series bible + `story-so-far.md` (one summary line per prior beat) + the last `STREAM_RECENT_BEATS` (6) beats verbatim, with a hard continuity rule ("begins EXACTLY where the previous beat ended, the camera does not cut"). Beat 1 renders t2v on MiniMax H3 Max (the default as of 2026-09-07 — Turbo read too low-quality; the faster/cheaper `minimax-h3-max-turbo` lane is one dropdown away); every later beat renders i2v off the previous beat's last frame (`extractLastFrame`). Invariants: (a) **no re-anchoring, ever** — every frame descends from the frame before it, identity drifts slowly by design, and there is no t2v reset cadence; (b) **no ring buffer** — every beat stays on disk in order (`stream/beat-NNNNN.mp4` + `.json`), disk is the only limit; (c) **a stream cannot skip a beat** — after `MAX_CONSECUTIVE_ERRORS` (3) failures at write, chain-frame, or render the engine STOPS (a skipped beat would be a hidden cut), where the loop would give up on one shot and move on; (d) it needs only `series.json` — no script, storyboard, QA, or references; a locked aesthetic and a cast (`--skip-images` is fine) make the writer much better; the command registers its episode in `series.json` when missing (the browser builds its episode list from there — an unregistered episode rendered beats the Stream tab could not show, and hid the Start button behind "No episodes yet."); (e) `--direction` is standing direction folded into every writer prompt (the place for "laugh track after every joke"), and `openingBeat` lets a caller pin beat 1 verbatim; (f) budget/resume semantics match the loop (billed at queue time, `--budget` stops, Start/Continue authorizes another, `--unbounded` lifts the cap; `stream-manifest.json` resumes from the longest unbroken prefix of beats whose files exist and chains off the last one); (g) **the writer is the session's first decision — ASK the operator which model writes the beats before starting a new stream.** It is the voice of the whole story and it bills from beat 1. Interactive runs get a `promptChoice` over `selectableTextModels()`; a non-interactive new stream with no `--writer` is a hard error (pass `--writer <model>` or `--writer default`), mirroring `loop --mode`. A resumed stream keeps the project default. The writer and the per-beat cost print before beat 1 bills. Do not treat `series.intelligence` as the operator's choice for the stream — it was chosen for QA and scripting, not latency. The stream's own default is `STREAM_DEFAULT_WRITER` (`deepseek-v4-flash-0731-fast`, 3.8s median, 9/9 valid, thinking off) from the 2026-09-05 bakeoff (`scripts/bakeoff-stream-writer.ts`; results in `src/mini-drama/stream-choices.ts` and the README). `chatJson` takes `disableThinking`; the stream writer sets it per choice — with thinking on, the same models are 3-10x slower and reasoning-only models burn the token budget and return nothing. Both the writer and the video family are dropdowns in the Stream tab (`POST /stream/config`, `engine.configure()`); a change applies to the NEXT beat, the i2v chain survives a family switch (the start frame is a PNG), and a resumed stream keeps the models in its manifest. The default `minimax-h3-max` is pinned to 480P for speed (~45s/beat, $0.22) — sharper than Turbo but still slower than playback; the look-ahead buffer removes the writer latency but not the render, and only the cheaper/lower-quality Turbo lane nearly keeps pace (the UI says so beside the selector). Per-beat cost comes from the family's quoted `usdPer15s` (verified via `POST /video/quote`: H3 Max is $0.22 at 480P and $0.36 at 768P; Turbo $0.11 at 480P). (h) **A face-ending beat must not kill the stream.** MiniMax i2v dies server-side on a face-filled start frame after billing (anti-pattern 31), and the chain makes one bad frame poison every retry. Three defenses, in order: the writer's system prompt carries a MANDATORY camera rule — end every beat wide, never on a human face close-up; a failed chained render steps the start frame back (`STREAM_CHAIN_STEP_BACK_SEC`); and after `STREAM_CHAIN_FAILURES_BEFORE_RESET` (2) chained failures on one beat, the engine renders that beat **t2v as a soft reset** (`lane: 't2v-reset'`), with the previous beat's summary prepended so the prompt re-establishes the scene. Identity drifts for one beat; a skipped beat or a dead stream is worse. The Stream tab shows `lastError` while a retry is in flight and marks reset beats. (i) **The Stream tab merges, never replaces.** The on-disk manifest is re-read on every `state-changed` (the workspace watcher fires on each beat's files); it is a fallback. `stream-updated` SSE is the truth. The view merges beats by number so a stale disk snapshot can never remove a beat the SSE already delivered — that race is what made new beats appear only after a reload. Only the `<video>` element swaps source (keyed by file); the page never reloads, and playback is kicked explicitly after each swap. The whole `StreamView` is keyed by project slug in `App.tsx` so switching projects REMOUNTS it with fresh state — without the key, the previous project's beats stayed under the player and the writer/video/resolution selects stayed greyed out because the engine was still bound to the other project (fixed 2026-09-07). (j) **Every beat keeps its exact video prompt.** `StreamBeat.render` = model, prompt, resolution, duration, start frame — byte-for-byte what `renderVideoFile` sent. The Stream tab exposes it per beat ("Full prompt") and as a JSON/Markdown export of the whole stream (`/stream/export.json|md`), so an operator can fine-tune outside the harness. Older beats are backfilled from `.recipe.json` on resume. When the prompt builder changes, the recorded prompt is the evidence of what a given beat actually got. The writer is `AuthorFn` and the renderer `RenderFn`, both injectable (`tests/stream-engine.test.mjs`). Output for the browser is the **Stream** tab (`StreamView.tsx`, `stream-updated` SSE, `/api/projects/:slug/stream/{state,start,stop}`), which plays forward from beat 1 and holds on the newest beat until the next lands. When changing stream behavior, keep `stream-engine.ts`, the `stream` command in `cli.ts`, `server.ts` (`WebServerOptions.stream`), `state.ts`, `StreamView.tsx`, `api.ts` (`names` list), `types.ts`, and this rule in sync. Note the `-e` option on `stream` is a string parsed by hand — passing Commander's `parseInt` collided with the `applyContextDefaults` hook and yielded `episode-NaN`. (k) **Pre-written beats: `stream --beats-file` (2026-09-07).** When the operator wants the beats authored up front — no live writer at all for the scripted span — pass `--beats-file <path>`: a JSON file holding a bare array of `AuthoredBeat` objects or the `{ "beats": [...] }` shape of `/stream/export.json` (entries with an `authored` object are unwrapped, so an exported stream replays as-is). `parseScriptedBeats()` unwraps and type-checks; `normalizeBeat()` runs per entry against the locked cast BEFORE anything bills, so a bad beat fails at load. Engine side, `scriptedBeats` on `StreamEngineOptions` wraps any author (injected override included) in `makeScriptedAuthor()`: beat N of the stream is served from file position N−1 until the file runs out, then the live writer takes over as the fallback — for a new stream that fallback defaults to `STREAM_DEFAULT_WRITER`, so `--beats-file` alone satisfies the writer decision of (g). A writer switch from the Stream tab (`configure`) or a resumed manifest changes ONLY the fallback; scripted beats keep serving, and already-rendered beats are never re-rendered. The continuity rules still bind the file's author: each beat is one continuous shot beginning where the previous ended, and every beat must END wide (anti-pattern 31 — the chain's start frame must not be a human-face close-up). (l) **Look-ahead writer buffer — the writer authors AHEAD of the render by default (2026-09-07, 2.24.0).** The stream runs the writer (producer) and renderer (consumer) concurrently: `runWriter()` keeps up to `lookahead` beats (default `STREAM_DEFAULT_LOOKAHEAD` = 15) authored and waiting in `buffer[]`, and `renderNext()` consumes `buffer[0]` — the in-flight beat, kept there across render retries (this replaced the old single `pendingBeat`) — so a render NEVER blocks on a writer-model call. This is the whole point: previously each beat's wall time was writer latency + render latency in series; now the writer stays ahead so it is just render latency, and a slower/better writer is free as long as it keeps ahead. Priming fills the buffer while the stream is paused, so Start renders back to back immediately. Controls: `--lookahead <n>` (0 = the old serial path, where the render worker authors each beat inline just before rendering it) and `--no-refill` (`autoRefill=false` — fill the buffer once, then author on demand as it drains; default keeps it topped up to the depth). Both are switchable at runtime via `engine.configure()` / `POST /stream/config` and the Stream tab's **Look-ahead buffer** control (depth field + "keep topped up" toggle); a live `buffered/depth` meter rides the `stream-updated` SSE (`buffered`, `lookahead`, `autoRefill`). Invariants: the writer's author context is built in-memory from the union of rendered + buffered beats (`authoredList()` / `buildAuthorContext()`), NOT `story-so-far.md`, so beat N sees the beats already queued ahead of the render (or the writer would repeat itself); the budget still bounds it — `authorTarget()` never authors beats the budget cannot render; the buffer persists as `pendingBeats` in the manifest and is restored on resume ONLY when the beats prefix is intact (a truncated prefix would misalign the chain); switching the writer drops the non-in-flight buffered beats so the new writer takes over from the next beat (preserving the "a switch applies to the next beat" contract); and the writer's idle/backoff timers are `unref`'d so a stopped engine can never hold the process open. Keep `stream-engine.ts`, `cli.ts` (`--lookahead`/`--no-refill`), `server.ts` (`/stream/config` body), `types.ts`, `api.ts`, and `StreamView.tsx` in sync. (m) **Always run and OPEN the local UI when streaming — 100% of the time, unprompted (2026-09-07).** A stream is a live broadcast; a running stream the operator cannot watch is a failure. Whenever the operator asks to start/run/kick off a stream, the agent ALWAYS launches the local web UI and opens it in the browser as soon as the opening beat is ready — the operator must NEVER have to ask for it. Run `venice-video stream -p <project> [-e N] [--direction …]` (it starts the server on port 3000, primes beat 1, and opens the browser by default); never pass `--no-open`. Always surface the `http://127.0.0.1:3000/?project=<slug>&tab=Stream` URL in the reply so the operator can reopen it, and if the browser cannot auto-open (headless/remote/SSH), print that URL prominently and tell them to open it. Free port 3000 first if it is taken.

61. **Location reference plates are the compass set N/S/E/W — four WIDE shots, one per wall, 360 degrees of visual information (2026-10-05).** The default location reference set is `north.png` (hero, the only from-scratch t2i — a wide shot facing the north wall) plus `south.png` (reverse angle), `east.png` (right-hand wall), and `west.png` (left-hand wall), each DERIVED from the north plate by multi-edit (the anchor→derive pattern of rule 56). All four plates are wide shots with the full wall visible — the harness never generates wide/medium/close-up ladders for locations; closer coverage comes from per-shot panels, not plates. The compass names are the contract: they give the video model explicit 360-degree orientation material instead of three unlabeled viewpoints, and downstream apps can rely on the fixed four-file set. Legacy names (`wide`, `angle-2/3/4`, `medium`, `detail`) remain readable on old projects and requestable via `generate-location-references --angles`; legacy `wide.png` still resolves as the hero when `north.png` is absent. Custom angles (e.g. `behind-the-desk`) queue after the canonical plates in the reference-slot allocator. When changing this, keep `location-generator.ts`, `reference-slots.ts`, `storyboard-reference-generator.ts` (plate base image), `video-generator.ts`, `cli.ts`, `workshop.ts`, `web/state.ts`, the CastView UI copy, and this rule in sync.
62. **`-basic` Seedance ids are faces-off twins — never send them an image of a person (2026-10-05).** Venice lists each Seedance lane twice: the plain id (face-capable: 409 `needs_consent` handshake, face screening) and a `-basic` twin that runs WITHOUT face handling and refuses any input image that shows a person (422 `provider_content_policy`, credits refunded). The refusal text blames the prompt, so operators rewrite prompts that were never the problem; in one downstream project 31 of 32 takes with a character reference failed on a `-basic` id (20 as content-policy rejections) while the face-capable twins were refused ~6%. The registry marks the three `seedance-2-0-*-basic` specs `facesOff: true` (`VideoModelSpec.facesOff`, exported in `capabilities.json`); `isFacesOffModel(id)` reads it and falls back to the id shape for the live-listed `seedance-2-5-*-basic` spellings, and `faceCapableTwinId(id)` strips the suffix. Three layers enforce it: (a) routing — `resolveVideoModel`, `buildMultiShotPrompt`, and `buildMontagePrompt` swap a faces-off `characterConsistencyModel` / `lipSyncModel` / `unit.model` for its twin whenever the shot or unit has characters, and say so in `modelResolution.reason`; (b) preflight — `renderVideoFile` calls `assertFacesOffCompatible` (`src/venice/seedance-preflight.ts`) before building the body: it reads the `hasFace` provenance sidecars of every image the request would send, and throws `FacesOffModelError` naming the twin when the model is faces-off and any image is `hasFace:true`, or is undecided (missing sidecar / no `hasFace`) on a shot with characters — only an explicit `hasFace:false` (location plates, rule 41) clears an image; (c) text-only and faceless-image renders on a `-basic` id are left alone (an atmosphere lane set to `seedance-2-0-text-to-video-basic` is fine). Test: `tests/faces-off-models.test.mjs`. When adding a Seedance id, set `facesOff` on its `-basic` twin.
63. **QA approval is bound to the panels a human reviewed (2026-10-05).** `qa-approved.json` used to be `{ episode, approvedAt, notes }` and `generate-videos` checked only that it existed, so a panel regenerated (`storyboard-episode --force`, `fix-panel`, a hand edit) or a prompt / reference / image-model change after approval still unblocked a billed render nobody had looked at. `qa-approve` now records, per shot (keyed by `shotKey`), `panelSha256` (the panel file's bytes) and `settingsDigest` (sha256 of the canonical JSON of `PanelSettings`: the `buildImagePrompt` output plus the location note, the character/location reference paths that exist on disk in the same precedence `storyboard-episode` uses, `imageDefaults.generationModel` / `editModel`, `storyboardAspectRatio`, and the per-shot `sceneImagePaths` / `sceneRefDescription` / `skipRefine`). `generate-videos` recomputes both for every shot in the script BEFORE reading the API key and refuses the run when any shot differs, listing each mismatch (`panel-changed`, `panel-missing`, `settings-changed`, `not-recorded` — a shot added after approval, or a legacy artifact without `shots`) and the `qa-approve` command to re-approve. `--skip-qa` still bypasses it and still does not clear QA. Helpers live in `src/mini-drama/panel-approval.ts`; `settingsDigest` and `compareApproval` are pure so a browser host can reuse them over its own storage. Test: `tests/panel-approval.test.mjs`. When you change what feeds a panel's generation, add it to `panelSettingsForShot` or the binding goes blind to it.

64. **The plate is the stage: shots are planned against their location plates, and checked before any spend (2026-10-26).** The video model draws what a reference plate shows, at the distance it shows it, not what the shot text asks. A beat staged on the nearest existing plate comes out as that plate: a boarding staged on a station platform whose plates show the ferry only as a shape at the far end boards the train beside the actors, and a crossing staged on another set's deck draws that set's boat, so the vessel changes between shots. So the plan carries its staging, and the workshop checks it:
   - **Every shot carries its plan.** `plateView` (the compass view it is staged on; that plate leads its panel and video location references through `locationPlateOrder` in `reference-slots.ts`, which every plate-picking caller reads), `stagedOn` (what in that view the action uses, and where in the frame), and `continuity` (`from`: `continuous` / `later` / `new-scene`, plus the `endState` the next shot starts from). A location that adjoins another names it in `near`. Old scripts without these fields behave exactly as before (north-first plates).
   - **The plan check runs on every workshop draft** (`src/mini-drama/plan-check.ts`, pure, words, free): each shot's staging nouns (places, vehicles, parts of a boat) against its location's text, view by view when the `spatialAnchors` say what each view shows ("View 1 (north): ..."). Locations are found the way `getLocation` finds them (slug or name, any case). What has to be on the plate is what the action touches (the verb's own object: "boards the ferry", "climbs the staircase"; a touched thing is close whatever else the sentence puts far off), a boat part the shot stands on, and a vehicle a clause is about ("the navy ferry pulls away"); a thing merely named, looked at or passing warns. Distance words reach only their own phrase ("the gangway in the foreground with the horizon behind" keeps the gangway close). Words that are also verbs count as the thing only as nouns in a shot ("pigeons launch", "the ferry docks", "the conversation stalls" ask for nothing). An object cast member listed in the shot carries itself through its own reference, so on a new place it is not asked of the location (OBJECT CAST STAYS OUT OF LOCATIONS). It also flags a journey whose vessel changes and a vehicle seen at two places with no reference of its own, and warns when a close-up or insert shows a small object (a key, a lock, a note, a lens) that no neighbouring shot of its scene and no plate of the place shows (`prop-continuity`): those shots draw that spot from the plate, so the object changes at the cut. A key light, a key witness, an 85mm lens, a lock of hair, a lamp-lit room and "she notes" are not the object.
   - **Fixed plates block, new places warn.** A location whose plates already exist and cannot change for this story (`WorkshopOptions.fixedLocation`, default none) is held to what its plates show; on a new location a missing thing is a warning to write it into the description. So by default the only blocking finding is a shot on a location neither the draft nor the project has. A downstream layer (a shared library of location sets) registers its options once with `setWorkshopPlanProvider()`: which locations are fixed, a plate reader (`readPlates`, a vision pass over the real plates, `src/mini-drama/plate-read.ts`) and re-plan advice.
   - **A shot the reader could not read is unchecked, never a pass** (rule 46): the call failed, the plates are not on disk, or the answer left it out. A vision "fits" moves a shot to the view that carries it, and never clears a word-check block.
   - **What the plate image shows.** A caller that has read each plate image (`PlanLocation.inventory`: vehicles, the places an action can use, loose objects, each near / mid / far; supplied to the workshop through `WorkshopOptions.plateInventory`) adds it to the words. It can show a thing close that the words leave out; a far mention in it never makes a thing look far off, and a boat part it lists (a bow, a deck) belongs to its own boat, so parts are checked against the words only. A vehicle the staged view shows close to the camera that is not the shot's own is a `competing-vehicle` finding: a block when it is another class of vehicle and the action uses the shot's (gondolas where the shot boards the ferry, a bus at a train platform), a warning when it could be the same boat under another name (a "motor launch" where the shot says "the ferry"; only a vision pass tells those apart) or the shot only names its vehicle. The fix it names is a plate staging ref (rule 65). A shot whose staging ref replaced its view, or that renders from a staged first frame, is not checked against the old plate.
   - **Gates.** The workshop re-plans the blocking shots once (`--no-plate-check` skips the check, at `--approve` too). A paid draft is never lost: a check that crashes keeps the draft unchecked, a re-plan that fails keeps the draft before it, one that does no better is saved in `workshop-replans/` while the earlier draft is kept, and a re-plan that wins saves the draft it replaced there. A revision (`--feedback`) works from the previous draft without its old plan report. `workshop --approve` re-checks the words on the draft it approves and refuses blocking findings or unchecked shots without `--force` (the operator checked the plates themselves); `workshop --recheck` re-runs the check on the current draft without re-drafting. The plan report shows in WORKSHOP.html/md. Limitation: a multi-shot or montage unit leads with its first shot's `plateView`.
   Tests: `tests/plan-check.test.mjs`, `tests/plan-check-gates.test.mjs`, `tests/plan-check-inventory.test.mjs`, and `tests/continuity-qa.test.mjs` for `prop-continuity`. When you add a staging noun or an object, add it to the vocabulary with its verb sense excluded, and a test for both.

65. **Fix an object in a shot by editing its first frame and re-generating it, not by patching the clip (2026-10-26).** The video model copies a reference's whole composition, not just the thing it is bound to, and a patch painted over moving footage flickers as the subject moves under it. So the fix is made in the image the shot is generated from, and a person picks it (`venice-video stage-ref`, `src/mini-drama/stage-ref.ts`):
   - **A wrong object in the frame** (a flag, a prop that should not be there, a hand doing the wrong thing): take the shot's own first frame (`stage-ref -p <project> -e <n> --shot <id> --from-shot <id> --prompt "..."`; the first frame keeps the shot's light and grade). A clip cut from a montage whose cut landed on a detected scene cut (its media-library `manifest.json` says `snapped`) is read one frame in; any other clip cut from a longer render is read 0.5 s in, because a cut at a planned time can start on the shot before, and a unit's first clip can start on a transition flash (anti-pattern 30). Pick the edit as a start frame. The shot then re-renders i2v from the picked frame, at its own length, with no reference stack, no end frame, no keyframe pass and without the location's words, so nothing the edit removed comes back. Compositing patches (a tracked fill, a paint-out) are only for a locked-off plate with nothing moving through the patch; on moving footage the re-take costs one short render and does not flicker.
   - **The re-take stays in the lane's privacy tier (rule 46).** It renders on the image-to-video lane of the shot's own family (`imageToVideoTwin` in core: Seedance 2.5 R2V → `seedance-2-5-image-to-video`, a private t2v or R2V lane → its private i2v lane), else on the i2v lane of the project's atmosphere or action model when that is in the same tier (`stagedFrameLane`). A twin in another tier never counts, and with none the shot is refused. So is an exact lip-sync shot (`audioStrategy: lip-sync`, with dialogue) whose i2v lane takes no dialogue audio: a plate staging ref keeps it on its R2V lane.
   - **A wrong thing in a reference** (a plate's moored boat where the shot needs another vessel, a lowered gangway): edit the plate (`--from-plate <view>`) and pick it as a plate. It leads the shot's R2V references in place of the view it was edited from, which is not sent (`locationCandidates` in `reference-slots.ts`; `pickedPlate` in `staging.ts` is the pick, `stagedPlateFor` the view a shot is staged on); on Kling O3 R2V it is the scene image in place of the plate. The storyboard blocking plate, composed on the plate before the edit, is not sent with it, and the prompt says the staged image is right wherever the location's words describe something it does not show.
   - **Two models, one person.** The edit runs on gpt-image-2-edit and nano-banana-2-edit at once (`--models`): one tends to keep fine detail, the other often gives the better close-up, so both land on a side-by-side `pick.html` and a person picks (`--pick <n|model|file>`, or the terminal prompt). An agent never picks. `--crop`, up to two `--ref` images, `--use` (also with `--pick`), `--clear` and `--list` complete it. Every result gets provenance and recipe sidecars (rule 39), and each edit that lands is recorded in the session at once, so a later failure never loses a paid candidate. A hand-made pick is stored as PNG with its provenance (the sidecars are named after png/jpg/jpeg/webp files only), and a session candidate named by its file is that candidate. `hasFace` is false only when the source and every `--ref` are recorded face-free, or with `--no-face`: a character sheet added as a ref keeps the face checks (anti-pattern 31, rule 62) on.
   - **A staged shot renders from its pick, or not at all.** The pick sets `mustStaySingle` and the planner keeps a staged shot out of every group (`shotBlocksMontage`; `buildMontagePrompt` refuses a unit that holds one), so it renders on its own. Its clip records the pick's hash in `.video.json`; a clip not made from the current pick is rendered again and archived (`-vN.mp4`) only when the new one lands. A render already queued for the shot is collected first, whatever is decided next (rule 43): one queued from the pick is the clip, one queued before it is kept until the pick's render lands, and its recipe says it was queued from inputs not recorded there. Then, before anything is billed or moved, `generate-videos` refuses a staged shot it cannot render from the pick: the picked image is gone, no i2v lane in the tier (above), a MiniMax i2v lane with a frame that may show a face (it fails server-side after billing), or a plate pick on a lane that sends no location reference (i2v from the panel, t2v, an R2V lane with neither `@Image` tags nor scene images: pick it as a start frame, `--pick <n> --use start-frame`). A `--retake` asks the first three (`stagedShotRefusal`) before it archives anything; the last is found once the shot's references resolve. A unit that starts from a refused shot's last frame waits too, instead of starting from an older clip. Every suggested fix is a full command (`-p`, `-e`, `--shot`). `generate-videos` lists each shot and why, exits non-zero, and does not mark the episode produced; `produce-episode` stops there. `stage-ref` warns about such a lane when the pick is made.
   - **Every later step makes the same check (rule 45).** `staleStagedShots` (`staging.ts`) lists the staged shots with no clip from their current pick. `generate-videos` reads it once more at the end, so a staged shot a `--retake` leaves alone still fails the run. `status` sends the episode back to `generate-videos -p <project> -e <n>` for them, `qa-videos` reports their units as not checked and the cuts beside their clips as unchecked (never a pass; with `--cut-list`, an edit that uses footage from before a pick does not pass either), and `assemble-episode` refuses them unless `--force` (which assembles with a warning). The montage cuts in `media-library/` stay as they were, as the archive: a re-render lands in `scene-001/` only, nothing reads the library as current, and `stage-ref` (at the pick) and `generate-videos` (where it points to the library for hand editing) say so when the shot has a cut there.
   - **A pick changes the packaging, not the neighbours' clips.** A staged shot inside a montage or multi-shot run splits the run, so the shots around it land in units with new names. `stage-ref` prints the units that change, at the pick and at `--clear`. `generate-videos` skips a unit whose clips are all on disk, whatever its name (rule 50), so the shots around the pick keep their clips and only the staged shot renders. It is then a generation of its own, so the cuts on either side of it join two generations, and `qa-videos` checks them for continuity (rule 52). A new unit with a clip missing renders whole (billed) and replaces the clips of its other shots (archived as `-vN.mp4`, not deleted); `--retake` renders one on purpose. Clip files carry an insert's suffix (`shot-013b.mp4`), and `--from-shot 13b` and every staged check read the same key.
   - **Re-attaching stays safe (rule 43).** A staged shot's pending jobs (its clip, and the keyframe pass of the lip-sync keyframe pipeline) carry the pick's hash. A keyframe render queued from other inputs is collected and rendered again from the current ones, so the keyframe carries the pick.
   - Limits: an i2v re-take carries no voice reference, so an on-camera speaker's voice can drift (a plate staging ref keeps it); a pick belongs to one shot, and the location's plates never change; and loop mode (rule 58) renders a shot as it was before a start-frame pick, because it picks its own lanes and an i2v take off a face panel fails on MiniMax.
   Tests: `tests/stage-ref.test.mjs`, `tests/stage-ref-render.test.mjs`, `tests/stage-ref-gates.test.mjs`. Code: `src/mini-drama/stage-ref.ts`, `src/mini-drama/staging.ts`, and the staged branches in `reference-slots.ts`, `prompt-builder.ts`, `video-generator.ts`, `generation-planner.ts`, `src/session/status.ts` and the `qa-videos` / `assemble-episode` commands.

## Learned Anti-Patterns (Production Issues Log)

Issues discovered during production and their fixes. The agent should internalize these to avoid repeating them.

### 1. Multi-Shot Grouping Bug: Wrong Character Overlap Check
**Symptom:** Shots cutting between different characters (e.g., Chad-only → Vivienne-only) were grouped into multi-shot units, which at the time used `kling-o3-pro-image-to-video` — a model with NO `elements` or `reference_image_urls` support. Characters lost all identity anchoring.
**Root cause:** `hasOverlappingCharacters()` checked each shot's characters against the union pool instead of requiring pairwise overlap between consecutive shots.
**Fix:** Rewrote to require every consecutive pair of shots to share at least one character. Shots with disjoint characters now always render as singles with R2V. (2026-08-05: the referenceless-model half of this trap was removed at the root — multi-shot units now default to Seedance R2V Enhanced with the full reference slot plan, see rule 18. The pairwise-overlap check remains because grouping disjoint characters is still wrong on any lane.)
**File:** `src/mini-drama/generation-planner.ts`

### 2. Character Reference Style Inconsistency Across Angles
**Symptom:** Front-facing reference was cartoon/stylized but profile and full-body drifted to photorealistic.
**Root cause:** (a) Aesthetic description was at the END of the prompt — the model committed to a rendering style before seeing the style instructions. (b) `cfg_scale: 7` gave the model too much latitude. (c) No anti-realism terms in negative prompt.
**Fix:** (a) Front-loaded `STYLE:` prefix and added `STYLE REMINDER:` suffix in `buildCharacterReferencePrompt`. (b) Bumped `cfg_scale` to 10. (c) Added `photorealistic, photograph, photo` to negative prompt.
**Files:** `src/mini-drama/prompt-builder.ts`, `src/mini-drama/cli.ts`
**Fallback:** When base generation still drifts, use a two-pass approach: generate base image, then style-match via multi-edit against a good reference shot.

### 3. Atmosphere Model Duration Validation
**Symptom:** `veo3.1-fast-image-to-video` returned 400 error for `duration: "3s"` — it only accepts 4s/6s/8s. Seedance 2.0 (now the default) accepts 4s/5s/8s/10s/12s/15s — not all integers.
**Root cause:** Script had 3s establishing/insert shots. No validation against model's allowed durations.
**Fix:** Added auto-snap in `queueVideo()` that checks the model's duration spec and snaps to nearest valid value with a warning.
**File:** `src/venice/video.ts`

### 4. Talk Show Format: All Character Shots Must Be R2V Singles
**Symptom:** Character appearance was inconsistent between cuts in talk show format.
**Root cause:** The generation planner was optimizing for temporal continuity (multi-shot grouping) when the format actually needs identity consistency (R2V singles with reference anchoring).
**Fix:** For formats with frequent speaker cuts (talk shows, interviews, panels), set `mustStaySingle: true` on all shots or ensure no cross-character grouping occurs. Every character shot uses the default R2V model (`seedance-2-0-reference-to-video` for 1-2 characters, auto-fallback to `kling-o3-standard-reference-to-video` for 3+) with reference images for identity anchoring.

### 5. R2V Model Defaults to 9:16 (Vertical) Without Explicit Aspect Ratio
**Symptom:** Shot 10 video generated as 716x1284 (portrait) despite the panel being 16:9 landscape.
**Root cause:** `buildModelParams()` in `models.ts` defaulted R2V models to `'9:16'` when no `aspectRatio` was passed. The video generation pipeline didn't always propagate the series' aspect ratio.
**Fix:** Changed the R2V fallback default from `'9:16'` to `'16:9'` in `buildModelParams()`. Added a warning in `queueVideo()` when no explicit aspect ratio is provided for R2V models. Always pass `aspectRatio` explicitly in generation scripts.
**File:** `src/venice/models.ts`, `src/venice/video.ts`

### 6. Multi-Edit Crops Foreheads on 16:9 Close-Up Panels
**Symptom:** After multi-editing a close-up face shot, the forehead (with a logo/sigil) was completely cropped off.
**Root cause:** Venice multi-edit always returns 1024x1024. Restoring 16:9 aspect ratio crops ~25% from top and bottom. Close-up face shots lose foreheads and chins.
**Fix:** For close-up shots that need forehead detail (logos, sigils, headwear), generate the panel from scratch with `nano-banana-pro` instead of multi-editing an existing panel. Multi-edit is safe for medium/wide shots where the crop margins don't hit critical content. Added a warning in `panel-fixer.ts`.
**File:** `src/mini-drama/panel-fixer.ts`

### 7. Lighting Inconsistency Between Consecutive Shots in Same Location
**Symptom:** Shot 3 (circuit close-up in sietch) was extremely dark while shot 2 (SeehRov at workbench in same sietch) had warm amber lighting. Jarring cut.
**Root cause:** Each panel was generated independently with no reference to the preceding shot's lighting. The same environment description produced wildly different interpretations.
**Fix:** For consecutive shots in the same location, style-match the later panel against the earlier one using multi-edit. In the panel generation prompt, explicitly describe the lighting conditions from the preceding shot. Add the preceding shot's panel as a style reference in the multi-edit pass.
**Rule:** When scripting shots, if two consecutive shots share the same environment, the second shot's prompt must explicitly reference the lighting established in the first.

### 8. Establishing Shots Missing Silhouetted Characters
**Symptom:** Shot 11 (SeehRov silhouetted in doorway) had `characters: []` in the script because he's a distant silhouette, not a face-detail character. The panel generator treated it as an empty scene with "no people" in the negative prompt.
**Root cause:** The binary `characters` array was either "full R2V character" or "empty scene with no people." No middle ground for silhouetted/distant figures.
**Fix:** Added `silhouetteCharacters` field to `ShotScript`. Characters listed here appear in panel prompts (described by wardrobe for silhouette identification) but don't trigger R2V routing or "no people" negative prompts. The prompt builder includes them as "distant silhouetted figure" descriptions.
**Files:** `src/series/types.ts`, `src/mini-drama/prompt-builder.ts`

### 9. Logo/Sigil Mismatch: "Triple-V" vs Actual Venice AI Logo
**Symptom:** Prompts described "Venice triple-V sigil" or "VVV" but the actual Venice AI logo is a crossed-keys design (two ornate skeleton keys crossed in an X with a chevron/book shape at top). Models generated random V-shaped symbols instead.
**Root cause:** The character and series descriptions used shorthand "triple-V" which doesn't describe the actual logo geometry.
**Fix:** Always use the full logo description: "the Venice AI crossed-keys logo — two ornate skeleton keys crossed in an X formation with a chevron/open-book shape at the top where they cross." Describe logos in text prompts only — do not pass logo PNG files as multi-edit references.
**Rule:** Never use "VVV" or "triple-V" in prompts to describe the Venice AI logo. Always describe the crossed-keys geometry.

### 10. Hardcoded R2V Aspect Ratio `9:16` in Mini-Drama Pipeline
**Symptom:** R2V character shots rendered as vertical/portrait despite the series being set to 16:9 landscape.
**Root cause:** `renderVideoFile` in `video-generator.ts` hardcoded `body.aspect_ratio = '9:16'` for all R2V models. This bypassed the corrected default in `models.ts` / `video.ts` because the mini-drama pipeline builds its own request body without calling `queueVideo()` or `buildModelParams()`.
**Fix:** Changed to `body.aspect_ratio = options.aspectRatio ?? '16:9'` and threaded `series.storyboardAspectRatio` through from the render call sites.
**Rule:** Never hardcode aspect ratios in model-specific branches. Always derive from the series `storyboardAspectRatio` setting. After video generation, run `validate-video-outputs` to verify all shots match the expected orientation.
**Files:** `src/mini-drama/video-generator.ts`

### 11. Logo PNG as Multi-Edit Reference Causes Visual Overlay
**Symptom:** Passing `VVV_Token_White.png` (white logo on transparent background) as a multi-edit reference image caused the model to render the logo file as a massive white overlay composited onto the scene, instead of using it as a design reference.
**Root cause:** Multi-edit models interpret reference images literally when they contain large transparent/white areas. The model sees the white shape and composits it rather than extracting the design pattern.
**Fix:** Removed logo PNG from multi-edit reference slots. Describe logo designs in the text prompt only.
**Rule:** Never pass mostly-transparent or mostly-white PNG files as multi-edit references. Describe logos, symbols, and marks in text prompts. Reserve multi-edit reference slots exclusively for character face/body references and scene environment references.

### 12. Close-Up Character Panels: Inverted Pipeline for Better Face Match
**Symptom:** Generating a scene panel from scratch and then multi-editing the face to match a character reference produced a different-looking person — the base generation's face was too dominant for multi-edit to override.
**Root cause:** For tight close-ups, the generated face occupies most of the frame. Multi-edit adjustments are not strong enough to fully replace facial identity at that scale.
**Fix:** Use an "inverted" approach: start from the character's reference image (e.g., `profile.png`) as the base image and multi-edit the background/environment onto it. This guarantees the face IS the reference.
**Rule:** For close-up character shots, prefer the inverted pipeline: start from the character reference image and edit the background, rather than generating a scene and editing the face.

### 13. Seedance 2.0 Blocks Face-Bearing Non-Seedream Images
**Symptom:** Seedance 2.0 video calls 4xx'd when character portraits or character-bearing panels were generated with `nano-banana-pro`, `flux-2-pro`, or any other family. Initially thought to be a blanket ban on all non-seedream images.
**Root cause:** Seedance's gate specifically rejects input images that contain a recognizable human face when they weren't produced by `seedream-v5-lite` / `seedream-v5-lite-edit`. Images without human faces (establishing shots, atmosphere plates, scene refs, object inserts, silhouettes) are accepted from any family.
**Fix:**
- Added `hasFace` tracking to image provenance sidecars.
- Relaxed the pre-flight gate to only flag images where `hasFace !== false` and the generator is non-seedream.
- Split image-model defaults by context: `seedream-v5-lite` for face-bearing work (character refs, character panels, multi-edit character fixes), `nano-banana-pro` for faceless work (atmosphere, establishing, style match). The mini-drama CLI and storyboard assembler pick per-shot based on `shot.characters.length`.
- Added `imageDefaults` and `seedanceCompatibility` to `VideoModelDefaults` so faceless-side defaults remain overridable.
- Added provenance sidecars (`shot-NNN.provenance.json`) via `src/venice/provenance.ts`, written by the storyboard assembler, panel-fixer, reference-manager, and mini-drama panel generator.
- Added a pre-flight gate (`src/venice/seedance-preflight.ts`) that runs before every Seedance call and — if any face-bearing images are incompatible — prompts the user, reroutes the shot to Kling O3 R2V / Veo 3.1, or launders the images through `seedream-v5-lite-edit`.
**Rule:** The Seedance face rule applies only to images with human faces. Always generate character-bearing panels and references with seedream; you can use `nano-banana-pro` freely for atmosphere/establishing/insert shots. When editing face-bearing panels, use `seedream-v5-lite-edit`. If a project intentionally uses non-seedream face-bearing images, override `videoDefaults` to a non-Seedance family (Kling O3 + Veo).
**⚠ SUPERSEDED (2026-07):** Venice removed the seedream-only face restriction entirely — Seedance 2.0 now accepts face-bearing input images from **any** image family. The pre-flight gate is neutralized (always proceeds), `seedanceCompatibility` is no longer auto-set, and the global image default is `nano-banana-2` for all panels (character and faceless). The provenance sidecars are still written but nothing gates on them. See rule 24. This entry is retained as historical context only.
**Files:** `src/venice/provenance.ts`, `src/venice/seedance-preflight.ts`, `src/series/types.ts`, `src/series/manager.ts`, `src/mini-drama/video-generator.ts`, `src/storyboard/assembler.ts`

### 14. Editing Without Transcripts Wastes Tokens
**Symptom:** Agent asked to "edit this footage" started frame-dumping random PNGs from the timeline to decide where to cut, burning tokens without producing a coherent strategy.
**Root cause:** Frame-dump-first is the wrong substrate for cut decisions. 30 minutes of footage at 24fps = 43,200 frames × ~1,500 tokens = 64M tokens of noise. The LLM cannot hold that context and fabricates its way through the edit.
**Fix:** Always transcribe first via `scripts/transcribe-sources.ts`, read the resulting `takes_packed.md` (~12KB), and only call `scripts/timeline-view.ts` at explicit decision points (comparing retakes, resolving an ambiguous pause, verifying a mouth-close before a cut). Inspired by browser-use/video-use.
**Rule:** The text transcript is the primary editing surface. Pixels are consulted on demand only. See `.agents/skills/video-editing/SKILL.md`.
**Files:** `src/editing/packer.ts`, `scripts/transcribe-sources.ts`, `scripts/timeline-view.ts`

### 15. Skipping "Propose Strategy, Wait For Confirmation" Causes Throwaway Renders
**Symptom:** Agent started rendering an EDL before the user had approved the cut strategy. User then asked for a completely different structure, wasting a 15-minute render.
**Root cause:** The render is cheap to launch and expensive to throw away. Without an explicit pre-render confirmation step, intent is inferred and frequently wrong.
**Fix:** `.agents/commands/edit-footage.md` step 3 and `.agents/skills/video-editing/SKILL.md` design principle 3 both mandate: post a summary (sources, duration estimate, trim rules, transitions) and wait for "yes / revise / cancel" BEFORE running `renderEdl()`.
**Rule:** Never render without confirmation. The render is cheap; the redo is not. Video-use design principle 3 is non-negotiable.
**Files:** `.agents/commands/edit-footage.md`, `.agents/skills/video-editing/SKILL.md`

### 16. Auto-Trimming "..." Dead Air From Kokoro VOs Breaks Intended Pacing
**Symptom:** Filler-word detector was configured to trim all silence gaps ≥ 0.45s. This removed the intentional breath beats rendered by Kokoro for `...` in `VO_TEXT`, producing a rushed, rhythm-less VO.
**Root cause:** `...` in a Kokoro TTS script renders as an intentional ~0.6s breath gap. It's a creative beat, not dead air.
**Fix:** `DEFAULT_FILLER_UNIGRAMS` in `src/editing/silence.ts` explicitly excludes `...` and the filler-word detector never touches gaps that were triggered by `...` in aligned mode. Always require user confirmation before a filler trim lands — `you know` and `i mean` can also be content-bearing for certain speakers.
**Rule:** Never auto-trim silence gaps that originated from a script's `...`. Never land filler-word trims without user confirmation. See `.agents/skills/video-editing/SKILL.md` anti-pattern E2.
**Files:** `src/editing/silence.ts`, `.agents/skills/burn-in-subtitles/SKILL.md` rules 1-2

### 17. Rendering Overlays As Part Of The EDL Pass
**Symptom:** Agent baked lower-thirds and title cards into the EDL render, then had to throw away the render when the user wanted to change the overlay wording.
**Root cause:** Overlays are a post-process, not an edit decision. They belong in a separate compositing pass on top of the delivered cut.
**Fix:** Overlay designs live in `OverlayManifest` (`src/editing/overlays.ts`), are rendered via `scripts/render-overlay.ts` on top of `final-edit.mp4`, and produce `delivered.mp4`. The EDL render never touches overlays.
**Rule:** EDL handles cut decisions. Overlays are applied separately. `overlay-designer` agent only runs AFTER the EDL cut is approved.
**Files:** `src/editing/overlays.ts`, `scripts/render-overlay.ts`, `.agents/agents/overlay-designer.md`

### 18. Not Archiving Prior Renders Before A New Edit
**Symptom:** A "quick fix" re-render overwrote a 15-minute `final-edit.mp4` before the user had a chance to compare against the prior version.
**Root cause:** `renderEdl` or `render-overlay.ts` was called without the archive-first path enabled, or a shell one-liner was used that bypassed the harness renderer.
**Fix:** Both `src/editing/render.ts` and `scripts/render-overlay.ts` archive any existing output to `<stem>-v<N>.<ext>` BEFORE writing the new file. This is on by default; disabling it requires passing `--skip-archive` explicitly. Mirrors workspace rule `.cursor/rules/shot-asset-safety.mdc`.
**Rule:** Never bypass the harness renderer for editing output. Never call `ffmpeg` directly to overwrite a delivery file without archiving first.
**Files:** `src/editing/render.ts`, `scripts/render-overlay.ts`, `.cursor/rules/shot-asset-safety.mdc`

### 19. Dialogue/VO Overlap From Per-Speaker Scheduling On Planned (Not Measured) Durations
**Symptom:** In an assembled episode, the narrator's line for one shot was still playing when the next shot's character line (or the next narrator line) began — voices talked over each other at several cuts. Worst on short establishing shots whose narration ran long.
**Root cause:** Two compounding bugs in the assembler's dialogue placement. (a) The no-overlap guard advanced a cursor only for narrator-vs-narrator (`if (isNarrator) prevNarrEnd = …`), so a long narrator line followed by a *character* line — or any character line — got no overlap protection. (b) Each line was placed at the shot's start derived from the script's planned `duration` field, but the rendered/normalized segment was a different length, so the "start of the next shot" the scheduler assumed didn't match the real timeline. A 7.5s narrator line over a 5.0s rendered segment spilled 2.5s into the next shot.
**Fix:** One global `nextFreeSec` cursor for ALL spoken lines (narrator and character alike); place at `max(shotStart + lead, nextFreeSec + gap)`; advance the cursor after every line. Compute `shotStart` from the MEASURED `ffprobe` duration of each segment, not the script slot. See rule 35 (scheduler) and rule 36 (author VO to fit / hold the frame when it can't).
**Files:** any assembler that mixes dialogue (e.g. `src/mini-drama/assembler.ts` and project `*-assemble*.mjs` scripts); add the overlap assertion to the cut-qa audio check (rule 29).

### 20. Visual Continuity Drift Across Separately-Rendered Shots (Identity, Scale, Palette)
**Symptom:** Across shots rendered as separate generations, a character's size, costume, markings, or the scene's palette/lighting changed shot to shot — e.g. a character that grew mid-story appeared small again in a later shot, or a defeated/absent character reappeared.
**Root cause:** Each shot was generated independently. Even with the same reference set, prompts that didn't restate the invariant traits let the model re-interpret scale/wardrobe/markings; and traits that *changed in-story* (a size change, a costume, a character being removed after an event) were not encoded per-shot, so the model reverted to the reference's default.
**Fix:** Re-anchor every shot to the identical canonical refs and restate fixed traits inline in every prompt, including **relative size** and any in-story state change (track a `sizeState`/`presence` per shot). Prefer Seedance native multi-shot (rule 21) for consecutive beats so identity/scale/lighting hold within one generation. Before assembly, render a contact sheet of each shot's first frame and scan for drift; re-roll offenders. See rule 37.
**Files:** prompt builders (`src/mini-drama/prompt-builder.ts`) and project render scripts; first-frame contact-sheet check belongs in the storyboard/QA step.

### 21. Film-Stock Names In Style Prompts Trigger Seedance Film-Burn Flares
**Symptom:** `seedance-2-0-enhanced-*` renders showed persistent orange film-burn / light-leak flares in frame corners — not just at shot boundaries but through entire shots. (The Salt Book, 5 attempts on one shot.)
**Root cause:** The style block named a film stock ("35mm anamorphic, Kodak Portra 400 pushed"). The stock name is the trigger; `negative_prompt` alone does NOT suppress it.
**Fix:** Remove film-stock names from the style block (keep the *look* words: overexposed, chalky, bone white) AND add positive in-prompt language: "Clean pristine frame from edge to edge — absolutely no film burn, no light leaks, no orange or red flares at any frame edge or corner, no vignetting." Keep the negative_prompt too, but it is secondary.

### 22. Prop-Ref Contamination: The Model Re-Stages The Reference Image's Whole Composition
**Symptom:** A prop reference (charred page lying on the ground with a corpse's roped hand on it, yellow sack behind) kept re-staging its own composition into gens — the page returned to the ground, the corpse's hand landed on it, yellow cloth appeared on the living character's wrist — across 4+ re-rolls, *regardless of prompt text forbidding all of it*. Cropping the ref was not enough (leftover corner objects still leaked).
**Root cause:** Seedance treats the entire reference image as staging truth, not just the object it's bound to. Refs are stronger than negative text.
**Fix:** Every prop ref must be a **clean plate**: the prop alone on neutral ground — no hands, no wardrobe, no scene furniture. Build one with `POST /image/edit` (qwen-edit, "Remove the X completely…", multiple passes if needed) — one $0.0x edit call beats fighting the video model. Also add a ref-role clause in the prompt: "@ImageN defines ONLY what the prop LOOKS like; it does NOT define where the prop is." Same technique for staging refs that contain elements which must not recur (e.g. remove the corpse for a character-alone scene).

### 23. Hand/Limb Ownership In Close-Up Inserts Goes To The Wrong Body
**Symptom:** In a hands-only close-up, the folding action was performed by the nearby corpse's roped, yellow-sleeved hands instead of the detective's.
**Root cause:** In an insert shot the model picks WHOSE hands from scene context; any nearby body competes for the limbs.
**Fix:** (a) Stage the insert away from the other body entirely ("he is STANDING, hands at chest height… nothing else in frame: no ground, no body"). (b) Give each character a wrist-level wardrobe signature and ban the wrong one by name ("grey linen sleeves + white shirt cuffs" vs "mustard-yellow sleeves + rope — must NOT appear in this close-up"). (c) Declare the non-actor fully inert ("hands NEVER touch, hold, or fold anything"). Continuity bible character entries should carry a `wrist_signature` field; gen-qa should check inserts for sleeve/cuff mismatches.

### 24. Dialogue Accent Casting Drifts Per Generation
**Symptom:** One gen rendered Greek-accented English while every neighboring gen was standard American — jarring at cuts.
**Root cause:** Seedance infers accent from setting/character context (Greek island → Greek accent). A weak one-liner ("neutral American accent") did not fix it.
**Fix:** A dedicated VOICE DIRECTION block with (a) explicit nationality ("Both actors are AMERICAN"), (b) a concrete anchor ("flat Midwestern… like classic 1950s Hollywood film-noir actors"), (c) per-character voice register descriptions, and (d) "speaks in a flat American accent" repeated inline in each dialogue shot paragraph. Continuity bible should carry a per-character `voice` field injected into every dialogue gen. QA: whisper-transcribe every dialogue gen (catches wrong words); accent needs a human ear — export a review MP3 per dialogue gen.

### 25. Face-Down Bodies Flip Supine (Or Act) Across Re-Rolls
**Symptom:** A corpse staged face-down flipped face-up in ~50% of gens; in others it moved or its face became visible.
**Root cause:** "face down" alone is too weak an anchor; the model prefers showing faces.
**Fix:** Stack redundant prone language: "FACE DOWN, ON HIS STOMACH, his BACK to the sky, the BACK of his head toward camera… NEVER supine, NEVER rolls over… we never see his eyes, nose, mouth" — in the continuity rules AND inline in every shot paragraph where the body appears. Bind a staging reference frame showing the correct position and mark it "must match this image exactly."

### 26. Seedance Replaces Scripted Hard Cuts With Dissolves
**Symptom:** A multi-shot gen rendered one transition as a slow dissolve/superimposition (a face ghosted over an insert) even though the prompt said "separated by hard cuts."
**Root cause:** One mention of "hard cuts" at the top of a multi-shot prompt is not binding per-transition.
**Fix:** Add to the style block: "ALL transitions between shots are instant HARD CUTS — never dissolves, never cross-fades, never superimpositions, never double-exposures." Keep the "CUT TO:" separators between shot paragraphs.

### 27. Seedance Face-Media Consent 409 And Queue-Attempt Rate Limit
**Symptom:** (a) R2V/i2v requests with human-face references returned 409 `needs_consent`. (b) After ~20 failed queue attempts, the account tripped a 30s 429.
**Fix:** (a) Attach `consents.seedance: { confirmed_terms_and_privacy: true, confirmed_legal_right: true, confirmed_screening_acknowledged: true }` to `/video/queue` for face-bearing requests — surface the policy text once per session for the user to ack, then auto-attach. (b) Back off on repeated 4xx; don't hammer the queue endpoint.

### 28. Spatial Drift Across Shots: Side-Swaps, Teleporting Props, Mirrored Geography
**Symptom:** Two shots that read as the same scene disagree spatially — a character who was screen-left in the master is screen-right in coverage; a prop on the table has moved or vanished; the room's layout is mirrored (door now on the other side); eyelines cross the 180-degree line so speakers appear to look the same way.
**Root cause:** Prompts described the *action* but left placement implicit, so every generation re-inferred the geometry from scratch. Identity references lock what things look like, not where they are; the blocking plate helps, but a plate alone (without matching placement language in the prompt) is a weak anchor the model can reinterpret or mirror.
**Fix:** Author the geometry once and restate it identically everywhere (rule 49): lock each location's landmark layout in `Location.spatialAnchors`, write per-shot `blocking` (position vs named anchors + frame side + depth + facing/eyeline), and let the harness inject both into panel, plate, and video prompts verbatim. Keep screen sides and eyelines constant across a scene's shots unless a movement is scripted. QA panels for spatial continuity against the previous same-location panel (the fourth `qa-storyboard` dimension); treat a side-swap or mirrored geography as FLAG-CRITICAL and re-roll before rendering video.

### 29b. Stale Blocking Plates Re-Inject a Fixed Character's OLD Identity
**Symptom:** A character's reference sheets are regenerated (identity fix), the drifting units are re-rendered — and the SAME unit keeps drifting no matter how many re-rolls (canopy-run s03 took 3 failed takes, 2026-08-11). The rendered figure matches neither the new sheets nor randomness: it matches the *old* identity.
**Root cause:** `generate-storyboard-refs` composes blocking plates FROM the character references at the time it runs. Fixing a character's sheets later does not touch the plates, and the plate rides along in the render's reference stack (`@Image3 storyboard blocking reference`) still showing the pre-fix character. The video model reads identity from every image it is given, "composition only" instructions notwithstanding.
**Fix:** After ANY character-reference change (add-character override, harvest-anchor, sheet regeneration), re-run `generate-storyboard-refs -p <project> -e <n> --force` before re-rendering. If one unit keeps failing cross-unit QA after its character was fixed, inspect its `.recipe.json` `referenceImagePaths` and open every image in the stack — the drift source is usually in there.

### 29. Cross-Unit Identity Drift: Same References, Different Protagonist Per Unit
**Symptom:** The assembled film's lead visibly changes appearance at generation-unit boundaries — canopy-run (2026-08-10) had three recognizably different protagonists across its front half, stabilizing only where one long unit happened to dominate. Every individual unit passed inspection; the storyboard QA had passed; the defect only exists ACROSS units.
**Root cause:** Each generation unit is an independent render that re-interprets the character reference sheets from scratch. Panel QA runs pre-render, per-unit render QA (if any) runs per-unit — no stage ever put frames from *different units* side by side and asked "same person?". A generated reference sheet is itself an interpretation, so unit renders orbit it at varying distances rather than converging.
**Fix:** `qa-videos` after every `generate-videos`: its cross-unit check sends one hero frame per unit in a single vision call, in film order, and flags the drifting units. A failing report blocks `assemble-episode`. To fix flagged units: `harvest-anchor` a clean frame from a passing unit into the character's stack (`anchor.png` outranks the sheets in the identity slot), re-render ONLY the drifting units, re-run `qa-videos`. Do not regenerate the sheets and re-roll everything — that restarts the drift lottery instead of converging it.

### 30. Montage Head Frames Carry Transition Junk Into the Final Cut
**Symptom:** A fraction-of-a-second flash/glitch at the very start of the assembled film (or at an interior beat boundary): a few frames of unrelated composition or luma spike, gone before the eye can parse it but obvious to a viewer as "a glitch".
**Root cause:** Seedance montage generations front-load transition frames so the beats can be cut apart; the per-beat cutter slices at the planned timestamps, so when the model's actual transition lands a few frames off the boundary, junk frames survive at a cut's head — most visibly on the first beat of unit 1, which becomes the film's opening frames.
**Fix:** `qa-videos` detects it programmatically (per-frame luma scan over each unit's first second; a spike that reverts within 3 frames is a flash, not a scene change — ffmpeg `signalstats`, zero API cost). Fix by re-rendering the unit or trimming the flagged frames off the head of that beat's cut before assembly; for the film's first shot, always eyeball frames 0-10 of the final master before delivery.

### 31. Loop Watch Mode: t2v Aspect 400, Chain Frame Past Stream End, Face Frames Die Server-Side (2026-09-04)
**Symptom:** First watch-mode loop run failed at three successive layers. (a) Every t2v queue 400'd with `aspect_ratio: Required`. (b) After that was fixed, chained i2v takes 400'd with `image_url is required` because the extracted last-frame PNG did not exist — yet `extractLastFrame` had not thrown. (c) Once chaining produced real frames, every chained render whose start frame contained a recognizable human face queued successfully, then died server-side: `/video/retrieve` 500s ("An unknown error occurred") forever. The engine's give-up-after-6-polls path cleared the pending record and re-queued fresh, billing ~$0.18 per attempt (4 attempts on one shot).
**Root cause:** (a) `renderVideoFile` set `aspect_ratio` only for `reference-to-video` and non-i2v Seedance; MiniMax H3 Max (Turbo) t2v REQUIRES it. (b) `extractLastFrame` probed the CONTAINER duration and sought to `duration - 0.05`; MiniMax writes an audio track slightly longer than the video stream, so the seek landed past the last decodable frame — and ffmpeg exits 0 having written no file. (c) MiniMax i2v accepts a face-bearing start frame at queue time (billed) but the render fails server-side, surfacing only as a retrieve 500. Faceless start frames (the robot) render fine; t2v (no input image) renders fine. There is no MiniMax equivalent of the Seedance 409 `needs_consent` handshake.
**Fix:** (a) `video-generator.ts` now sets `body.aspect_ratio` for every `text-to-video` model as well. (b) `extractLastFrame` probes the `v:0` stream duration and steps back in widening offsets (0.1/0.3/0.6/1.0s) until the PNG actually exists on disk, throwing otherwise. (c) The face-death is Venice-side, but the fallout is now capped: `LoopEngine` counts consecutive render failures per shot and **gives up on a shot after `MAX_CONSECUTIVE_SHOT_ERRORS` (3)** — it is marked `failed`, dropped from `pickNext`, and no longer re-queued/re-billed (a manual `regenerate` revives it). This is the real fix for the money leak — note the re-queue was NOT `isQueueGoneError` (400/404/410, not 500): the loop force-requeues, so a doomed shot was re-selected fewest-takes-first by the *scheduler* every cycle. Watch mode with human faces still hits the death per chained take, so `--no-chain` remains the clean workaround; create mode routes character shots to R2V (face **references**, not a start frame) and, when refs are missing, now degrades to **t2v** instead of a face-bearing i2v (`i2vRejectsFaceStartFrame` in `models.ts`). **Verified 2026-09-04:** MiniMax **R2V** accepts face-bearing reference sheets — a 5s render off a character `front.png` succeeded in ~13s (`scripts/probe-minimax-r2v-face.ts`). So only i2v *start frames* die on a face, not R2V *references*: create-mode character loops are viable, and create/R2V is the working path for smooth character-face loops.
**Related — face-continuity prompting:** the loop can prompt each chained shot to END on the character's face for smoother i2v continuations (`--face-continuity`, on by default), but it is **auto-suppressed on any i2v model that rejects face start frames** (all MiniMax i2v lanes), because a face-ending frame is the next shot's start frame and would trip exactly this death. So today it is dormant on both loop lanes; it activates on a non-MiniMax i2v lane or once Venice fixes MiniMax i2v. For face continuity now, use create mode (R2V locks the face from the sheets, no i2v chaining).
**Files:** `src/mini-drama/video-generator.ts`, `src/mini-drama/loop-engine.ts`, `src/venice/models.ts`, `scripts/probe-minimax-r2v-face.ts`

## Output

Generated project output belongs in:

```text
output/
```

No active generated projects are included in this harness copy.

## Environment

- `VENICE_API_KEY` in `.env` (required)
- `ffmpeg` and `ffprobe` on PATH (for video/audio processing)
- Node.js 20+ with TypeScript (ES modules, Node16 resolution)

## Important

- This is an agent-operated harness first, not a CLI-first app
- It is Venice-specific and consistency-focused by design
- The included mini-drama workflow is a reference implementation, not the only intended use case
- The model registry is synced from the live Venice API -- update it when Venice adds new models