venice-video-harness 2.22.0 → 2.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -482,7 +482,7 @@ Use `POST /video/quote` (via `quoteVideo()`) to estimate costs before committing
482
482
 
483
483
  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.
484
484
 
485
- 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 Turbo; 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. Every video family other than Turbo renders slower than playback; the UI says so beside the selector. Per-beat cost comes from the family's quoted `usdPer15s` (Turbo is $0.11 at 480P, not the $0.18 the old constant assumed). (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 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`.
485
+ 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.
486
486
 
487
487
  ## Learned Anti-Patterns (Production Issues Log)
488
488
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,85 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.24.0 — 2026-09-07
4
+
5
+ ### Added
6
+
7
+ - **Look-ahead writer buffer — the writer authors beats ahead of the render by
8
+ default.** The stream now runs the writer and the renderer as a
9
+ producer/consumer pair: the writer keeps up to `--lookahead` beats (default
10
+ **15**) authored and waiting in a buffer, so a render never blocks on a
11
+ writer-model call. Priming fills the buffer while the stream is paused, so
12
+ clicking Start renders back to back with no writer latency. This also lets a
13
+ slower, better writer keep pace as long as it stays ahead.
14
+ - `--lookahead <n>` sets the buffer depth. `0` restores the pre-2.24 serial
15
+ behaviour (author each beat just before it renders).
16
+ - `--no-refill` fills the buffer once, then authors on demand as it drains;
17
+ the default keeps the buffer topped up as the renderer consumes it.
18
+ - Both are switchable at runtime from the Stream tab (a **Look-ahead buffer**
19
+ control: depth input + "keep topped up" toggle) and via
20
+ `POST /stream/config` (`lookahead`, `autoRefill`). The Stream tab shows a
21
+ live `buffered / depth` meter.
22
+ - Switching the writer drops the beats the old writer had buffered (keeping
23
+ only the one on the wire) so the new writer takes over from the next beat.
24
+ - Budget still bounds it: the writer never authors beats the budget cannot
25
+ render. The buffer persists in the manifest (`pendingBeats`) so a resume
26
+ renders the pre-authored beats without paying for them again.
27
+ - Engine: `lookahead` / `autoRefill` on `StreamEngineOptions`, `lookahead` /
28
+ `autoRefill` / `buffered` / `pendingBeats` in the manifest, and
29
+ `STREAM_DEFAULT_LOOKAHEAD` (15), all exported.
30
+
31
+ ### Changed
32
+
33
+ - **Default stream video family is now `minimax-h3-max`, pinned to 480P.** Turbo
34
+ reads noticeably lower quality, so the default is the sharper MiniMax H3 Max
35
+ model — but kept at **480P** (not its 768P draft tier) so it still generates
36
+ fast: ~45 s/beat at $0.22 per 15 s (verified via `POST /video/quote`; 768P is
37
+ $0.36 and selectable). It renders slower than playback, but the look-ahead
38
+ buffer takes the writer latency out of the picture and the Stream tab shows
39
+ the hold honestly. Turbo ($0.11, ~30 s) is still one dropdown away for a
40
+ cheaper live watch. `STREAM_DEFAULT_VIDEO_FAMILY`, the default
41
+ `STREAM_MODEL_T2V` / `STREAM_MODEL_I2V` lanes, the H3 Max draft resolution,
42
+ and the CLI `--video-family` default all move to `minimax-h3-max` @ 480P.
43
+
44
+ ### Fixed
45
+
46
+ - **Stream tab: switching projects no longer leaks the previous project's
47
+ beats or its disabled/attached controls.** The view is keyed by project, so
48
+ it remounts with fresh state on a project switch (the old project's beats
49
+ stayed under the player, and the writer/video/resolution selects stayed
50
+ greyed out because the engine was still bound to the other project).
51
+
52
+ ## 2.23.0 — 2026-09-07
53
+
54
+ ### Added
55
+
56
+ - **Pre-written beats for the stream: `stream --beats-file`.** Author beats up
57
+ front and the stream renders them without ever calling the writer model.
58
+ Accepts a bare JSON array of beats or the `{ "beats": [...] }` shape of
59
+ `/stream/export.json` (entries with an `authored` object are unwrapped, so an
60
+ exported stream replays as-is). Each entry is normalized against the locked
61
+ cast the same way a writer's output would be; a beat with no description
62
+ fails at load, before anything bills. The live writer (defaulting to
63
+ `STREAM_DEFAULT_WRITER` for a new stream) is only the fallback past the last
64
+ scripted beat, and a writer switch from the Stream tab changes only that
65
+ fallback. Engine side: `scriptedBeats` on `StreamEngineOptions`,
66
+ `makeScriptedAuthor()`, and `parseScriptedBeats()`, all exported.
67
+
68
+ ## 2.22.1 — 2026-09-05
69
+
70
+ ### Added
71
+
72
+ - **Full prompt per beat, and export.** Each stream beat now records `render`
73
+ — the exact model, prompt, resolution, duration, and start frame sent to the
74
+ video model — in `beat-NNNNN.json` and the manifest. The Stream tab's Story
75
+ So Far list has a **Full prompt** toggle per beat (authored beat + verbatim
76
+ video prompt, Copy prompt / Copy as JSON) and **JSON** / **Markdown** export
77
+ links for every beat plus the writer's system prompt
78
+ (`GET /api/projects/:slug/stream/export.json|md?episode=N`, engine attached
79
+ or not). Beats from before this field existed are backfilled on resume from
80
+ their `.recipe.json` sidecars. `exportStreamJson` / `exportStreamMarkdown`
81
+ are exported from `stream-engine.ts`.
82
+
3
83
  ## 2.22.0 — 2026-09-05
4
84
 
5
85
  ### Added
package/README.md CHANGED
@@ -944,6 +944,58 @@ venice-video loop -p ~/VeniceVideos/my-film -e 1 --mode looping # or state it
944
944
  venice-video loop -p ~/VeniceVideos/my-film -e 1 --mode production
945
945
  ```
946
946
 
947
+ ### Pre-written beats: `stream --beats-file`
948
+
949
+ The stream writes every beat with a live writer model. To author the beats
950
+ yourself — or have an agent write them up front — pass `--beats-file`. The
951
+ first N beats of the stream are then served from the file and the writer model
952
+ is **never called** for them; only if the stream runs past the last scripted
953
+ beat does the live writer take over (defaulting to `STREAM_DEFAULT_WRITER`).
954
+
955
+ ```bash
956
+ venice-video stream -p ~/VeniceVideos/my-film -e 1 \
957
+ --beats-file ~/VeniceVideos/my-film/beats.json \
958
+ --direction "live studio audience laugh track after every joke" \
959
+ --budget 2
960
+ ```
961
+
962
+ With `--beats-file` a new stream needs no `--writer`: the file IS the writer
963
+ decision for the beats it covers. A `--writer` still overrides the fallback
964
+ used past the file. On resume the scripted lane re-attaches the same way —
965
+ beats already rendered are never re-rendered, and a writer switch from the
966
+ Stream tab changes only the fallback.
967
+
968
+ The file is JSON and accepts two shapes:
969
+
970
+ ```jsonc
971
+ // 1. A bare array of beats.
972
+ [
973
+ {
974
+ "description": "The bell jingles as JAKE strides in and takes the couch.",
975
+ "characters": ["JAKE KELLER", "MEL"],
976
+ "dialogue": { "character": "JAKE KELLER", "line": "The usual.", "delivery": "cheerful" },
977
+ "sfx": "door bell, live studio audience applause",
978
+ "cameraMovement": "slow dolly in to a wide of the cafe",
979
+ "summary": "Jake arrives at the cafe."
980
+ }
981
+ ]
982
+ ```
983
+
984
+ ```jsonc
985
+ // 2. The { "beats": [...] } shape of /stream/export.json — entries with an
986
+ // "authored" object are unwrapped, so an exported stream replays as-is.
987
+ { "beats": [ { "n": 1, "authored": { "description": "…", … } } ] }
988
+ ```
989
+
990
+ Beat fields match `AuthoredBeat` in `stream-engine.ts`. Each entry is
991
+ normalized against the locked cast (names snap to the cast's spelling, missing
992
+ fields are completed), and a beat with no `description` fails at load — before
993
+ anything bills. The stream's continuity rules still apply to what you write:
994
+ each beat is one continuous shot that begins where the previous beat ended,
995
+ and every beat should END on a wide or medium-wide frame, never a human-face
996
+ close-up (the next beat chains off that frame, and MiniMax i2v dies on a
997
+ face-filled start frame — anti-pattern 31).
998
+
947
999
  Loop mode starts with one **required, deliberate decision** — **is this for
948
1000
  LOOPING or for PRODUCTION?** — because it is a real quality-vs-flow tradeoff, not
949
1001
  a default to fall through. In a terminal it asks; non-interactively you must pass
@@ -1061,7 +1113,7 @@ How it works:
1061
1113
  writer and the per-beat cost print before beat 1 bills.
1062
1114
  2. The writer writes beat 1 from the series bible: concept, setting, aesthetic,
1063
1115
  and cast.
1064
- 3. Beat 1 renders text-to-video on MiniMax H3 Max Turbo.
1116
+ 3. Beat 1 renders text-to-video on MiniMax H3 Max (the default; the faster, lower-quality Turbo lane is selectable).
1065
1117
  4. The writer reads `story-so-far.md` (one line per prior beat) plus the last
1066
1118
  6 beats verbatim, and writes beat 2 so it begins exactly where beat 1 ended.
1067
1119
  5. Beat 2 renders image-to-video off beat 1's last frame.
@@ -1081,13 +1133,39 @@ venice-video stream -p <dir> \
1081
1133
  -e 1 \ # episode the stream lives under (default 1)
1082
1134
  --direction "<text>" \ # standing direction folded into every beat's writer prompt
1083
1135
  --writer <model> \ # writer; asked for a new stream, required non-interactively (see the bakeoff table)
1084
- --video-family <family> \ # minimax-h3-max-turbo (default) | minimax-h3-max | wan-3-0 | grok-imagine | seedance-2-0 | seedance-2-5 | kling-o3-standard
1136
+ --video-family <family> \ # minimax-h3-max (default) | minimax-h3-max-turbo | wan-3-0 | grok-imagine | seedance-2-0 | seedance-2-5 | kling-o3-standard
1085
1137
  --resolution 480P \ # default: the family's draft tier
1086
1138
  --duration 15s \ # per-beat length, snapped to the 5-15s ladder
1139
+ --lookahead 15 \ # beats authored AHEAD of the render (0 = serial)
1087
1140
  --budget 2 # stop after ~$2; Continue authorizes another budget
1141
+ # --no-refill # fill the look-ahead buffer once, then author on demand
1088
1142
  # --unbounded # no cap (streams until Ctrl-C)
1089
1143
  ```
1090
1144
 
1145
+ #### Look-ahead writer buffer
1146
+
1147
+ By default the writer runs **ahead** of the render. It is a producer/consumer
1148
+ pair: the writer keeps up to `--lookahead` beats (default **15**) authored and
1149
+ waiting in a buffer, and the renderer pulls from it — so a render never blocks
1150
+ on a writer-model call. While the stream is paused after priming, the writer is
1151
+ already filling the buffer, so clicking Start renders back to back with no
1152
+ writer latency between beats. It also lets you run a slower, better writer
1153
+ without stalling playback, as long as the writer stays ahead of the render.
1154
+
1155
+ - `--lookahead <n>` sets the depth. `0` is serial: each beat is authored just
1156
+ before it renders (the pre-2.24 behaviour), so every beat pays the writer
1157
+ latency.
1158
+ - `--no-refill` fills the buffer once and then authors on demand as it drains;
1159
+ the default keeps it topped up to the depth as the renderer consumes it.
1160
+ - Both are switchable live from the Stream tab (the **Look-ahead buffer**
1161
+ control — a depth field and a "keep topped up" toggle) and via
1162
+ `POST /stream/config`. The tab shows a live `buffered / depth` meter.
1163
+ - Switching the writer drops the beats the old writer had queued (keeping only
1164
+ the one on the wire) so the new writer takes over from the next beat.
1165
+ - The budget still bounds it — the writer never authors beats the budget cannot
1166
+ render — and the buffer is saved in `stream-manifest.json` (`pendingBeats`),
1167
+ so a resume renders the pre-authored beats without paying for them again.
1168
+
1091
1169
  The stream is resumable: re-running `stream` continues from the last beat on
1092
1170
  disk and chains off it. After 3 consecutive failures (write, chain, or render)
1093
1171
  the engine stops rather than skip a beat — a stream cannot have a hidden cut.
@@ -1171,16 +1249,18 @@ Not offered, with the reason:
1171
1249
  ##### Video Family Matrix
1172
1250
 
1173
1251
  Speed is the wall time to render one 15 s beat. "Lag" is what the viewer
1174
- feels: with the default writer (~4 s) added, Turbo makes a 15 s beat in ~35 s,
1175
- so the player holds ~20 s between beats once it has caught up. Every other
1176
- family holds for a minute or more. Cost is the quote for 15 s at the family's
1252
+ feels: the default `minimax-h3-max` at 480P renders a 15 s beat in ~45 s, so the
1253
+ player holds ~30 s between beats once it has caught up; the faster Turbo lane
1254
+ cuts that to a ~20 s hold at lower quality. The look-ahead buffer takes the
1255
+ writer's time out of this — only the render remains. Every family other than
1256
+ Turbo holds for a minute or more. Cost is the quote for 15 s at the family's
1177
1257
  draft resolution. Quality is relative to what the harness knows about each
1178
1258
  family (see the model registry and AGENTS.md).
1179
1259
 
1180
1260
  | Family | Privacy | Speed (15 s beat) | Cost / 15 s | Quality | Faces on start frame | Verdict |
1181
1261
  |---|---|---|---|---|---|---|
1182
- | `minimax-h3-max-turbo` **(default)** | ●●● private | ●●● ~30 s | ●●● $0.11 | ●●○ good motion, native audio, improvises dialogue | ✗ dies after billing; engine soft-resets | The only lane that nearly keeps pace. Draft look at 480P; 768P selectable. |
1183
- | `minimax-h3-max` | ●●● private | ●●○ ~60 s | ●●● $0.22 | ●●● sharper than Turbo, same model family | ✗ same limit | Pick when you want the Turbo look at finish quality and will accept a 1-minute hold. |
1262
+ | `minimax-h3-max` **(default)** | ●●● private | ●●○ ~45 s @ 480P | ●●● $0.22 | ●●● sharper than Turbo, same model | ✗ dies after billing; engine soft-resets | The default. H3 Max quality pinned to 480P for speed; ~30 s hold. 768P selectable at $0.36. |
1263
+ | `minimax-h3-max-turbo` | ●●● private | ●●● ~30 s | ●●● $0.11 | ●●○ good motion, native audio, lower quality | ✗ same limit | Fastest and cheapest, the only lane that nearly keeps pace. Draft look at 480P; pick when a live watch matters more than fidelity. |
1184
1264
  | `wan-3-0` | ●○○ anonymized | ●○○ ~120 s | ●●○ $0.68 | ●●● strong, up to 1080p, 30 s ladder | ✓ accepts faces | Best choice if the show is face-heavy and the camera rule is not enough. Slow. |
1185
1265
  | `grok-imagine` | ●○○ anonymized | ●○○ ~90 s | ●○○ $0.95 | ●●○ | ✓ | Faster than Wan, pricier, lower ceiling. |
1186
1266
  | `seedance-2-0` | ●○○ anonymized | ○○○ ~180 s | ●○○ $1.32 | ●●● the harness production look, native lip-synced dialogue | ✓ | Production fidelity. The viewer waits ~3 min per beat. Use for a stream you export, not one you watch. |
@@ -1193,7 +1273,8 @@ family (see the model registry and AGENTS.md).
1193
1273
  |---|---|---|---|
1194
1274
  | Watch it live, cheapest, private | `deepseek-v4-flash-0731-fast` | `minimax-h3-max-turbo` @ 480P | ~35 s per beat, ~20 s hold, ~$0.11/beat, ~$13/hour of story |
1195
1275
  | Watch it live, best sitcom writing | `mistral-small-2603` | `minimax-h3-max-turbo` | Same lag, warmer beats |
1196
- | Sharper picture, still private | `deepseek-v4-flash-0731-fast` | `minimax-h3-max` @ 768P | ~65 s per beat, ~50 s hold, $0.22/beat |
1276
+ | Sharper picture, still fast (default) | `deepseek-v4-flash-0731-fast` | `minimax-h3-max` @ 480P | ~45 s per beat, ~30 s hold, $0.22/beat |
1277
+ | Max fidelity, will accept the wait | `deepseek-v4-flash-0731-fast` | `minimax-h3-max` @ 768P | ~65 s per beat, ~50 s hold, $0.36/beat |
1197
1278
  | Human faces fill the frame often | any fast writer | `wan-3-0` | Faces never kill the chain; ~2 min per beat |
1198
1279
  | Production look to export later | `kimi-k3` | `seedance-2-0` or `-2-5` | ~3.5 min per beat, $1.32-1.93/beat; run it overnight, do not watch it live |
1199
1280
  | Strict privacy for both text and pixels | `deepseek-v4-flash-0731-fast` or `mistral-small-2603` | `minimax-h3-max-turbo` or `minimax-h3-max` | The only fully private pairing; MiniMax is the sole private video family here |
@@ -1214,6 +1295,16 @@ Rules of thumb:
1214
1295
  15 s. The old `TURBO_USD_PER_SEC` constant assumed $0.18; budgets now buy
1215
1296
  more beats than they used to.
1216
1297
 
1298
+ **Take the prompts elsewhere.** Every beat records exactly what was sent to the
1299
+ video model (`render` in `beat-NNNNN.json` and the manifest: model, prompt,
1300
+ resolution, duration, start frame). In the Stream tab, **Full prompt** on any
1301
+ beat in the Story So Far list expands the authored beat and the verbatim video
1302
+ prompt with Copy buttons. **JSON** and **Markdown** at the top of the list
1303
+ download every beat's authored text and full prompt plus the writer's system
1304
+ prompt (`GET /api/projects/:slug/stream/export.json|md?episode=N`; works
1305
+ after the engine has stopped, too). Beats rendered before this field existed
1306
+ are backfilled on resume from their `.recipe.json` sidecars.
1307
+
1217
1308
  The `stream` command registers its episode in `series.json` if it is missing, so
1218
1309
  the Stream tab always has an episode to show. (Before 2.21.1 a stream under an
1219
1310
  unregistered episode rendered beats the browser could not display.)
package/capabilities.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "harnessVersion": "2.22.0",
4
- "generatedAt": "2026-09-05T14:38:23-03:00",
3
+ "harnessVersion": "2.24.0",
4
+ "generatedAt": "2026-09-07T14:25:24-03:00",
5
5
  "videoModels": [
6
6
  {
7
7
  "id": "wan-2.6-image-to-video",
@@ -76,7 +76,7 @@ export const AGENT_GUIDE = [
76
76
  'Loop mode (`venice-video loop -p <project> -e <n> --mode <looping|production>`): once a shot script exists, render the whole plan continuously and watch it as a live browser loop that hot-swaps fresh takes. It SKIPS the storyboard/QA gates and writes only under episodes/episode-NNN/loop/, so it never touches the canonical cut. LOOPING = creative flow, lower quality (Turbo 480P, disposable); PRODUCTION = gather usable, identity-locked takes (Max R2V @768P). --mode is REQUIRED non-interactively; --budget caps spend (default $2). This is not in the `pipeline` stage list — it is under `branches`.',
77
77
  'Loop mode plays AND renders at the same time — it does NOT pre-generate everything. The browser loops the takes that already exist while the worker keeps generating new ones, swapping each shot\'s newer take in on the loop\'s NEXT pass (never mid-clip). Two things make a running loop LOOK pre-generated when it is not: (a) it PAUSES when it hits --budget or you click Pause in the UI, and then just replays what is on disk — raise --budget or pass --unbounded to keep it generating; (b) --max-takes is a per-shot ring buffer (default 3, older takes pruned), not a total, so once every shot has its cap the change per cycle is subtle.',
78
78
  'Which mode evolves while you watch: LOOPING (Turbo, 480P) renders faster than it plays, so the video visibly changes as you watch. PRODUCTION/create renders each shot slowly on Max R2V — it is for GATHERING keeper takes, not a continuously-evolving watch, and on a short or few-shot plan it can look static even while running because one take takes longer to render than a full loop cycle takes to play. If the user wants to "watch it keep changing", pick LOOPING and a higher/unbounded budget.',
79
- 'Stream mode (`venice-video stream -p <project> -e <n> --writer <model> [--direction "..."]`): an INFINITE, live-authored story — not a loop. The writer model authors one beat at a time; beat 1 renders t2v, every later beat i2v off the previous last frame. Needs only series.json. TWO things to do before starting: (1) ASK the operator which model writes the beats — it is the voice of the whole story, bills from beat 1, and its speed sets the lag behind playback; a non-interactive new stream with no --writer is a hard error (--writer default = deepseek-v4-flash-0731-fast, the fastest reliable writer in the bakeoff; kimi-k3 is 3x slower). Both the writer and the video family are dropdowns in the Stream tab; every family other than MiniMax H3 Max Turbo renders slower than playback. (2) Put the camera rule in --direction if the show has human faces: end every beat wide, never on a face close-up (MiniMax i2v dies on a face-filled start frame; the engine also enforces this in the writer prompt and falls back to a t2v soft reset after 2 chained failures). The command registers its episode in series.json so the Stream tab can show it.',
79
+ 'Stream mode (`venice-video stream -p <project> -e <n> --writer <model> [--direction "..."]`): an INFINITE, live-authored story — not a loop. The writer model authors one beat at a time; beat 1 renders t2v, every later beat i2v off the previous last frame. Needs only series.json. TWO things to do before starting: (1) ASK the operator which model writes the beats — it is the voice of the whole story, bills from beat 1, and its speed sets the lag behind playback; a non-interactive new stream with no --writer is a hard error (--writer default = deepseek-v4-flash-0731-fast, the fastest reliable writer in the bakeoff; kimi-k3 is 3x slower). Both the writer and the video family are dropdowns in the Stream tab; the default MiniMax H3 Max renders slower than playback (higher quality) — only the cheaper/lower-quality Turbo lane nearly keeps pace. (2) Put the camera rule in --direction if the show has human faces: end every beat wide, never on a face close-up (MiniMax i2v dies on a face-filled start frame; the engine also enforces this in the writer prompt and falls back to a t2v soft reset after 2 chained failures). The command registers its episode in series.json so the Stream tab can show it.',
80
80
  'Three video lanes, chosen per shot by the router (see the venice-video-model-routing skill): t2v (prompt only), i2v (animate a supplied START image via image_url — establishing/atmosphere shots, or chaining off a previous last frame), R2V (identity anchored to a reference stack — the default for character shots). "i2v" means a supplied first frame; "R2V" means reference_image_urls, not a start frame — do not conflate them.',
81
81
  'To animate a single image you already have (plain i2v, no project), the routing skill\'s bundled `scripts/venice-video.py --image <file> --model <...-image-to-video>` is the standalone path; the project pipeline is for multi-shot, consistency-first work.',
82
82
  ],
@@ -1 +1 @@
1
- {"version":3,"file":"guide.js","sourceRoot":"","sources":["../../src/agent/guide.ts"],"names":[],"mappings":"AAAA,8EAA8E;AAC9E,uDAAuD;AACvD,EAAE;AACF,2EAA2E;AAC3E,8EAA8E;AAC9E,sEAAsE;AACtE,8EAA8E;AAC9E,sDAAsD;AACtD,EAAE;AACF,6EAA6E;AAC7E,8EAA8E;AAC9E,8EAA8E;AAC9E,4EAA4E;AAC5E,EAAE;AACF,gFAAgF;AAChF,8DAA8D;AAC9D,8EAA8E;AAO9E,MAAM,CAAC,MAAM,WAAW,GAA4B;IAClD;QACE,KAAK,EAAE,kCAAkC;QACzC,MAAM,EAAE;YACN,2GAA2G;YAC3G,sGAAsG;YACtG,0GAA0G;SAC3G;KACF;IACD;QACE,KAAK,EAAE,8BAA8B;QACrC,MAAM,EAAE;YACN,sGAAsG;YACtG,wNAAwN;YACxN,kJAAkJ;YAClJ,wGAAwG;SACzG;KACF;IACD;QACE,KAAK,EAAE,yCAAyC;QAChD,MAAM,EAAE;YACN,2FAA2F;YAC3F,4JAA4J;YAC5J,6FAA6F;SAC9F;KACF;IACD;QACE,KAAK,EAAE,2BAA2B;QAClC,MAAM,EAAE;YACN,mGAAmG;YACnG,8JAA8J;YAC9J,kJAAkJ;SACnJ;KACF;IACD;QACE,KAAK,EAAE,4CAA4C;QACnD,MAAM,EAAE;YACN,4PAA4P;YAC5P,kNAAkN;YAClN,kKAAkK;YAClK,6MAA6M;SAC9M;KACF;IACD;QACE,KAAK,EAAE,8BAA8B;QACrC,MAAM,EAAE;YACN,oHAAoH;YACpH,+JAA+J;YAC/J,gKAAgK;YAChK,8JAA8J;YAC9J,6SAA6S;YAC7S,kHAAkH;SACnH;KACF;IACD;QACE,KAAK,EAAE,+DAA+D;QACtE,MAAM,EAAE;YACN,+LAA+L;YAC/L,ylBAAylB;YACzlB,4nBAA4nB;YAC5nB,gfAAgf;YAChf,wnCAAwnC;YACxnC,4aAA4a;YAC5a,gQAAgQ;SACjQ;KACF;IACD;QACE,KAAK,EAAE,gCAAgC;QACvC,MAAM,EAAE;YACN,+EAA+E;YAC/E,gJAAgJ;YAChJ,sFAAsF;YACtF,oIAAoI;SACrI;KACF;CACF,CAAC;AAEF,MAAM,UAAU,WAAW;IACzB,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,QAAQ,EAAE,WAAW,EAAE,CAAC;AAC/C,CAAC;AAED,MAAM,UAAU,WAAW;IACzB,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,KAAK,CAAC,IAAI,CAAC,sEAAsE,CAAC,CAAC;IACnF,KAAK,CAAC,IAAI,CAAC,mFAAmF,CAAC,CAAC;IAChG,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACf,KAAK,MAAM,OAAO,IAAI,WAAW,EAAE,CAAC;QAClC,KAAK,CAAC,IAAI,CAAC,MAAM,OAAO,CAAC,KAAK,EAAE,CAAC,CAAC;QAClC,KAAK,MAAM,KAAK,IAAI,OAAO,CAAC,MAAM;YAAE,KAAK,CAAC,IAAI,CAAC,OAAO,KAAK,EAAE,CAAC,CAAC;QAC/D,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACjB,CAAC;IACD,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,CAAC;AACpC,CAAC"}
1
+ {"version":3,"file":"guide.js","sourceRoot":"","sources":["../../src/agent/guide.ts"],"names":[],"mappings":"AAAA,8EAA8E;AAC9E,uDAAuD;AACvD,EAAE;AACF,2EAA2E;AAC3E,8EAA8E;AAC9E,sEAAsE;AACtE,8EAA8E;AAC9E,sDAAsD;AACtD,EAAE;AACF,6EAA6E;AAC7E,8EAA8E;AAC9E,8EAA8E;AAC9E,4EAA4E;AAC5E,EAAE;AACF,gFAAgF;AAChF,8DAA8D;AAC9D,8EAA8E;AAO9E,MAAM,CAAC,MAAM,WAAW,GAA4B;IAClD;QACE,KAAK,EAAE,kCAAkC;QACzC,MAAM,EAAE;YACN,2GAA2G;YAC3G,sGAAsG;YACtG,0GAA0G;SAC3G;KACF;IACD;QACE,KAAK,EAAE,8BAA8B;QACrC,MAAM,EAAE;YACN,sGAAsG;YACtG,wNAAwN;YACxN,kJAAkJ;YAClJ,wGAAwG;SACzG;KACF;IACD;QACE,KAAK,EAAE,yCAAyC;QAChD,MAAM,EAAE;YACN,2FAA2F;YAC3F,4JAA4J;YAC5J,6FAA6F;SAC9F;KACF;IACD;QACE,KAAK,EAAE,2BAA2B;QAClC,MAAM,EAAE;YACN,mGAAmG;YACnG,8JAA8J;YAC9J,kJAAkJ;SACnJ;KACF;IACD;QACE,KAAK,EAAE,4CAA4C;QACnD,MAAM,EAAE;YACN,4PAA4P;YAC5P,kNAAkN;YAClN,kKAAkK;YAClK,6MAA6M;SAC9M;KACF;IACD;QACE,KAAK,EAAE,8BAA8B;QACrC,MAAM,EAAE;YACN,oHAAoH;YACpH,+JAA+J;YAC/J,gKAAgK;YAChK,8JAA8J;YAC9J,6SAA6S;YAC7S,kHAAkH;SACnH;KACF;IACD;QACE,KAAK,EAAE,+DAA+D;QACtE,MAAM,EAAE;YACN,+LAA+L;YAC/L,ylBAAylB;YACzlB,4nBAA4nB;YAC5nB,gfAAgf;YAChf,qrCAAqrC;YACrrC,4aAA4a;YAC5a,gQAAgQ;SACjQ;KACF;IACD;QACE,KAAK,EAAE,gCAAgC;QACvC,MAAM,EAAE;YACN,+EAA+E;YAC/E,gJAAgJ;YAChJ,sFAAsF;YACtF,oIAAoI;SACrI;KACF;CACF,CAAC;AAEF,MAAM,UAAU,WAAW;IACzB,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,QAAQ,EAAE,WAAW,EAAE,CAAC;AAC/C,CAAC;AAED,MAAM,UAAU,WAAW;IACzB,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,KAAK,CAAC,IAAI,CAAC,sEAAsE,CAAC,CAAC;IACnF,KAAK,CAAC,IAAI,CAAC,mFAAmF,CAAC,CAAC;IAChG,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACf,KAAK,MAAM,OAAO,IAAI,WAAW,EAAE,CAAC;QAClC,KAAK,CAAC,IAAI,CAAC,MAAM,OAAO,CAAC,KAAK,EAAE,CAAC,CAAC;QAClC,KAAK,MAAM,KAAK,IAAI,OAAO,CAAC,MAAM;YAAE,KAAK,CAAC,IAAI,CAAC,OAAO,KAAK,EAAE,CAAC,CAAC;QAC/D,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACjB,CAAC;IACD,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,CAAC;AACpC,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/mini-drama/cli.ts"],"names":[],"mappings":";AAEA,OAAO,eAAe,CAAC;AACvB,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AA2JpC,QAAA,MAAM,OAAO,SAAgB,CAAC;AA67J9B,OAAO,EAAE,OAAO,EAAE,CAAC"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/mini-drama/cli.ts"],"names":[],"mappings":";AAEA,OAAO,eAAe,CAAC;AACvB,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AA2JpC,QAAA,MAAM,OAAO,SAAgB,CAAC;AA2+J9B,OAAO,EAAE,OAAO,EAAE,CAAC"}
@@ -4565,13 +4565,16 @@ program
4565
4565
  .option('-e, --episode <number>', 'Episode number the stream lives under', '1')
4566
4566
  .option('--direction <text>', 'Standing direction folded into every beat (e.g. "live studio audience laugh track after every joke")')
4567
4567
  .option('--writer <model>', 'Model that writes beats. Asked interactively for a new stream; required (or "default" = deepseek-v4-flash-0731-fast, the fastest reliable writer in the bakeoff) in a non-interactive run. A resumed stream keeps the writer it last ran with. Switchable from the Stream tab.')
4568
- .option('--video-family <family>', 'Video family for the beats: minimax-h3-max-turbo (default; the only one that keeps pace with playback) | minimax-h3-max | wan-3-0 | grok-imagine | seedance-2-0 | seedance-2-5 | kling-o3-standard. Switchable from the Stream tab.')
4568
+ .option('--beats-file <path>', 'JSON file of pre-written beats served in order before the live writer runs — no writer call for the beats it covers. Accepts an array of beats or { "beats": [...] } (the /stream/export.json shape; entries with an "authored" object are unwrapped). Each beat: { description, characters, dialogue: { character, line, delivery? } | null, sfx, cameraMovement, summary }.')
4569
+ .option('--video-family <family>', 'Video family for the beats: minimax-h3-max (default; higher fidelity, renders slower than playback) | minimax-h3-max-turbo (fastest and cheapest, nearly keeps pace, lower quality) | wan-3-0 | grok-imagine | seedance-2-0 | seedance-2-5 | kling-o3-standard. Switchable from the Stream tab.')
4569
4570
  .option('--port <port>', 'Port to listen on', '3000')
4570
4571
  .option('--host <host>', 'Host to bind (localhost only by default)', '127.0.0.1')
4571
4572
  .option('--resolution <res>', 'Render resolution (default: the video family\'s draft tier, 480P on MiniMax)')
4572
4573
  .option('--duration <dur>', 'Per-beat duration, snapped to the 5-15s ladder', '15s')
4573
4574
  .option('--budget <usd>', 'Stop after this much estimated spend (each Start authorizes another budget)', '2')
4574
4575
  .option('--unbounded', 'No budget cap — stream until stopped', false)
4576
+ .option('--lookahead <n>', 'How many beats the writer authors AHEAD of the renderer so a render never waits on a writer call. 0 = serial (author each beat just before it renders).', '15')
4577
+ .option('--no-refill', 'Fill the look-ahead buffer once at the start, then stop topping it up (author on demand after it drains). Default keeps the buffer full as it drains.')
4575
4578
  .option('--no-open', 'Do not open the browser automatically')
4576
4579
  .action(async (opts) => {
4577
4580
  const json = wantsJson();
@@ -4599,6 +4602,11 @@ program
4599
4602
  console.warn('⚠ No cast (add-character). The writer will invent characters; identity will drift more.');
4600
4603
  }
4601
4604
  const budgetUsd = Number.parseFloat(opts.budget);
4605
+ const lookahead = Number.parseInt(String(opts.lookahead), 10);
4606
+ if (!Number.isFinite(lookahead) || lookahead < 0) {
4607
+ failJson(json, `--lookahead must be a non-negative integer (0 = serial). Got "${opts.lookahead}".`);
4608
+ process.exit(1);
4609
+ }
4602
4610
  const slug = series.slug;
4603
4611
  const workspace = dirname(projectDir);
4604
4612
  if (basename(projectDir) !== slug) {
@@ -4620,6 +4628,11 @@ program
4620
4628
  else if (resumed) {
4621
4629
  writer = undefined; // the engine keeps the manifest's writer
4622
4630
  }
4631
+ else if (opts.beatsFile) {
4632
+ // Pre-written beats skip the writer for the beats they cover. The stored
4633
+ // model is only the fallback past the last scripted beat.
4634
+ writer = STREAM_DEFAULT_WRITER;
4635
+ }
4623
4636
  else if (!json && stdin.isTTY) {
4624
4637
  writer = await promptChoice('Which model writes the beats? (its speed sets how far the stream lags playback)', STREAM_WRITER_CHOICES.map(w => ({
4625
4638
  label: `${w.label} — ~${w.medianSec}s/beat, ${w.reliability} valid, ${w.privacy}${w.id === STREAM_DEFAULT_WRITER ? ' (default)' : ''}`,
@@ -4637,6 +4650,38 @@ program
4637
4650
  process.exit(1);
4638
4651
  return;
4639
4652
  }
4653
+ // Pre-written beats are normalized against the locked cast the same way a
4654
+ // writer's output would be: names snap to the cast's spelling, missing
4655
+ // fields are completed, and a beat with no description fails here — before
4656
+ // anything bills.
4657
+ let scriptedBeats;
4658
+ if (opts.beatsFile) {
4659
+ const beatsPath = resolve(opts.beatsFile);
4660
+ if (!existsSync(beatsPath)) {
4661
+ failJson(json, `Beats file not found: ${beatsPath}`);
4662
+ process.exit(1);
4663
+ return;
4664
+ }
4665
+ const { parseScriptedBeats, normalizeBeat } = await import('./stream-engine.js');
4666
+ try {
4667
+ const raw = JSON.parse(await readFile(beatsPath, 'utf-8'));
4668
+ scriptedBeats = parseScriptedBeats(raw).map((b, i) => {
4669
+ try {
4670
+ return normalizeBeat(b, series);
4671
+ }
4672
+ catch (err) {
4673
+ throw new Error(`beat ${i + 1}: ${err.message}`);
4674
+ }
4675
+ });
4676
+ }
4677
+ catch (err) {
4678
+ failJson(json, `Could not load --beats-file: ${err.message}`);
4679
+ process.exit(1);
4680
+ return;
4681
+ }
4682
+ if (!json)
4683
+ console.log(`Loaded ${scriptedBeats.length} pre-written beat(s) from ${beatsPath}.`);
4684
+ }
4640
4685
  // The stream needs only series.json, but the browser builds its episode
4641
4686
  // list FROM series.json. A stream under an unregistered episode rendered
4642
4687
  // beats the Stream tab could not show (it fell through to "No episodes
@@ -4669,6 +4714,9 @@ program
4669
4714
  budgetUsd: Number.isFinite(budgetUsd) ? budgetUsd : undefined,
4670
4715
  unbounded: opts.unbounded,
4671
4716
  direction: opts.direction,
4717
+ scriptedBeats,
4718
+ lookahead,
4719
+ autoRefill: opts.refill,
4672
4720
  broadcaster: hub,
4673
4721
  });
4674
4722
  await engine.init();
@@ -4685,7 +4733,7 @@ program
4685
4733
  // story. A resumed session (beats on disk) opens at once, also paused.
4686
4734
  const before = engine.state();
4687
4735
  if (!json && before.beats.length === 0) {
4688
- const perBeat = (getStreamVideoChoice(before.videoFamily)?.usdPer15s ?? 0.11) * (Number.parseInt(before.duration, 10) / 15);
4736
+ const perBeat = (getStreamVideoChoice(before.videoFamily)?.usdPer15s ?? 0.22) * (Number.parseInt(before.duration, 10) / 15);
4689
4737
  const fam = getStreamVideoChoice(before.videoFamily);
4690
4738
  console.log(`Writer: ${describeStreamWriter(before.model.writer)}. Video: ${before.model.t2v} @ ${before.resolution || 'default'}, ${before.duration}/beat, about $${perBeat.toFixed(2)} per beat (billed at queue time)${fam && fam.speed !== 'keeps up' ? ` — ${fam.label} renders slower than playback (~${fam.renderSecApprox}s per beat)` : ''}.`);
4691
4739
  console.log('Rendering the opening beat before opening the browser…');
@@ -4702,6 +4750,8 @@ program
4702
4750
  resolution: status.resolution,
4703
4751
  duration: status.duration,
4704
4752
  budget: opts.unbounded ? 'unbounded' : budgetUsd,
4753
+ lookahead,
4754
+ autoRefill: opts.refill,
4705
4755
  beats: status.beats.length,
4706
4756
  });
4707
4757
  }
@@ -4712,6 +4762,9 @@ program
4712
4762
  console.log(` video: ${status.model.t2v} (beat 1) then ${status.model.i2v} chained off each last frame @ ${status.resolution || 'default'}, ${status.duration}/beat`);
4713
4763
  console.log(' models: switch the writer or the video model any time from the Stream tab; changes apply to the next beat.');
4714
4764
  console.log(` direction: ${opts.direction ?? '(none)'}`);
4765
+ console.log(` lookahead: ${lookahead > 0 ? `${lookahead} beats authored ahead of the render${opts.refill ? ', kept topped up' : ', filled once then on demand'} (no writer latency between beats)` : 'serial — each beat is authored just before it renders'}`);
4766
+ if (scriptedBeats?.length)
4767
+ console.log(` beats-file: ${scriptedBeats.length} pre-written beat(s) render before the live writer takes over`);
4715
4768
  console.log(` budget: ${opts.unbounded ? 'unbounded (streams until you stop it)' : `$${budgetUsd.toFixed(2)} (Start authorizes another budget)`}`);
4716
4769
  console.log(` beats: ${status.beats.length} on disk`);
4717
4770
  console.log(' state: PAUSED — click Start in the browser to continue the story. New beats then render back to back.');