@aicopycoders/exodus 2026.9.1700-beta.1 → 2026.9.1900-beta.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.
@@ -256,10 +256,13 @@ Start (Show route):
256
256
  exodus video start --show <id> --script <file> [--wait] [--json] start an ad run; --wait stops at the storyboard gate
257
257
  Then, on either route:
258
258
  exodus video storyboard <runId> [--json] the scene cards
259
+ exodus video voices <runId> [--set <who>=<voiceId>] [--clear <who>] [--from <f>] [--json] who speaks, with whose voice
259
260
  exodus video approve <runId> [--json] approve the storyboard, or the uploaded cut
260
261
  exodus video flag <runId> --note "<what is wrong>" [--json] send the storyboard back — SHOW RUNS ONLY
262
+ exodus video retry-frame <runId> --node <nodeId> --scene <n> [--note "<t>"] [--json] redo one still at the pixel gate
261
263
  exodus video status <runId> [--json] the park and each scene's clip/voice/picture
262
264
  exodus video pull <runId> --out <dir> [--json] every piece + manifest.json
265
+ exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "<t>"] [--json] redo one clip at the final watch
263
266
  exodus video upload <runId> --file <cut.mp4> [--duration <s>] [--json] attach the cut (MP4/MOV/WebM, 200 MB)
264
267
  Cut: your choice of tool — Exodus does not stitch or mix. One option ships with the skill:
265
268
  node .claude/skills/exodus-video/scripts/first-cut.mjs <dir> [--skip n,m] [--no-music] [--out f]
@@ -75,6 +75,8 @@ exodus workflow list the brand's workflows
75
75
  exodus workflow describe <workflowId|name> what inputs it needs
76
76
  exodus workflow run <workflowId|name> --input <field>=@script.txt --wait
77
77
  start, stop at the storyboard gate
78
+ exodus workflow run <workflowId|name> --input <field>=@script.txt --voices @voices.json --wait
79
+ same, with the brand's voices already chosen
78
80
  exodus video status <runId> where it is, per scene
79
81
  ```
80
82
 
@@ -89,11 +91,15 @@ exodus video start --show <id> --script script.txt --wait start, wait for the s
89
91
 
90
92
  ```operator-guide
91
93
  exodus video storyboard <runId> read the scene cards
94
+ exodus video voices <runId> who speaks and with whose voice
95
+ exodus video voices <runId> --set <character>=<voiceId> give a character a voice
92
96
  exodus video approve <runId> keep going
93
97
  exodus video flag <runId> --note "what's wrong" send it back — SHOW RUNS ONLY
94
98
  exodus video retry-frame <runId> --node <nodeId> --scene <n> redo one still at the pixel gate
95
99
  exodus video status <runId> where it is, per scene
96
100
  exodus video pull <runId> --out ./ad-<runId> every piece + manifest.json
101
+ exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "…"]
102
+ redo one clip at the final watch
97
103
  … make the cut (your choice of tool)
98
104
  exodus video upload <runId> --file ./ad-<runId>/cut.mp4 attach it
99
105
  exodus video approve <runId> make it the ad
@@ -113,7 +119,8 @@ per-scene detail, and the checkpoint verbs cannot resolve a video park — see
113
119
  under it:
114
120
 
115
121
  - storyboard gate: needs `approve`, or `flag` on a Show run.
116
- - final watch: every piece is made; needs `pull`, a cut, and `upload`. A
122
+ - final watch: every piece is made; needs `pull`, a cut, and `upload`. It is also
123
+ the one park where a finished clip can be redone, with `retry-clip`. A
117
124
  workflow run only reaches this park if its video node sets `finalWatch: true`
118
125
  — otherwise it skips straight to finished.
119
126
  - paused: a builder checkpoint neither video verb resolves —
@@ -162,10 +169,13 @@ Verified against the routes, not assumed. Everything here is admin-gated on top.
162
169
  | `workflow run` | starts the run | not the entry point |
163
170
  | `video status` | works | works |
164
171
  | `video storyboard` | works | works |
172
+ | `video voices` (read) | works | works |
173
+ | `video voices --set` | works | **refused** — the Show owns its voices |
165
174
  | `video approve` | works, both gates | works, both gates |
166
175
  | `video flag --note` | **refused** | works |
167
176
  | script-turn edits at the gate | **refused** | dashboard only |
168
177
  | `video retry-frame` | works | works |
178
+ | `video retry-clip` | works | works |
169
179
  | `video pull` | works | works |
170
180
  | `video upload` | only if the video node sets `finalWatch: true` | works |
171
181
  | `workflow checkpoint approve` | **refused at a video gate** | **refused at a video gate** |
@@ -196,13 +206,16 @@ Five consequences worth knowing before you promise a user anything:
196
206
 
197
207
  - **`flag` is Show-only and fails loudly.** `exodus video flag` on a workflow run
198
208
  answers "Flag-with-note is only available on Video-module ads."
199
- (`convex/videoModule.ts:2208`, guarded on `!run.showId`). Script-turn edits
200
- refuse the same way (`:2334`). There is **no** CLI path that sends a workflow
201
- run's storyboard back with a note — the direct-edit mutation exists but is
202
- wired only to the dashboard's gate screen, not to any API route. From the
203
- terminal the storyboard is approve-or-cancel: `exodus workflow cancel <runId>`
204
- and run again with a better script, or edit the workflow. Do not offer `flag`
205
- to a user on the workflow route.
209
+ (`convex/videoModule.ts`, guarded on `!run.showId`). Script-turn edits refuse
210
+ the same way. There is **no** CLI path that sends a workflow run's storyboard
211
+ back with a note, and none that rewrites its scenes, script or prompts — the
212
+ whole-envelope edit mutation is wired only to the dashboard's gate screen, not
213
+ to any API route. The one exception is voices: `exodus video voices --set`
214
+ writes the cast's voice choices in place and nothing else (see "Giving the
215
+ characters voices"). For everything else the storyboard is approve-or-cancel
216
+ from the terminal: `exodus workflow cancel <runId>` and run again with a better
217
+ script, or edit the workflow. Do not offer `flag` to a user on the workflow
218
+ route.
206
219
  - **`--wait` lands on both video parks, but only those.** Since #1818 every
207
220
  `--wait` caller — `workflow run`, `triggers fire`, `checkpoint approve|retry`,
208
221
  `repair retry|skip` — ends at a storyboard gate or a final watch, exits 0, and
@@ -211,11 +224,14 @@ Five consequences worth knowing before you promise a user anything:
211
224
  slots park is still not a landing: it prints its notice once and the loop
212
225
  keeps polling, so a resolve from another shell carries the run on. Do not read
213
226
  a park banner mid-poll as the wait having finished.
214
- - **The run's page link differs.** A Show run's `dashboardUrl` opens
215
- `/video?show=…&ad=…`; a showless run gets `/runs/<runId>`, because the `/video`
216
- page cannot render one (`convex/http.ts:9204-9211`). The manifest's
217
- `dashboardUrl` is always `/video?ad=<runId>`, which is the Show-route shape —
218
- on a workflow run, hand the user the link `upload` prints, not the manifest's.
227
+ - **The run's page link differs, and every link the CLI prints follows the run.**
228
+ A Show ad opens on the `/video` page. A showless workflow run opens on
229
+ `/workflows/<workflowId>/runs/<runId>`, because `/video` answers "That ad isn't
230
+ here" for a run with no Show (`getAdDetail` rejects it;
231
+ `convex/http.ts:9204-9211`). Since #1851 the manifest's `dashboardUrl`, the
232
+ link in every park block and the link `upload` prints are all minted that way,
233
+ so they agree on both routes. When all the CLI holds is a run id it mints
234
+ `/runs/<runId>`, the canonical forwarder, which lands on the same page.
219
235
 
220
236
  ## The script file
221
237
 
@@ -241,8 +257,10 @@ THE SOFTGEL: Tap below and give your eyes ClearBlink.
241
257
  ## Reading the storyboard gate
242
258
 
243
259
  `storyboard <runId>` prints one card per scene on both routes: who speaks, the
244
- line, the planned duration, and whether its picture is drawn. Read it for the
245
- user, then ask one question.
260
+ line, the planned duration, and whether its picture is drawn. Under the cards it
261
+ also lists the voices — one line per character who speaks or already has a voice
262
+ — so the user can see who sounds like whom before paying for the clips. Read it
263
+ for the user, then ask one question.
246
264
 
247
265
  On a **workflow run** the question is approve or don't — there is no flag. If
248
266
  the storyboard is wrong, say so plainly and let the user decide between
@@ -257,6 +275,117 @@ The note is read by the model that rewrites the storyboard, so make it concrete
257
275
  Either way, do not approve on the user's behalf unless they told you to run the
258
276
  whole loop unattended.
259
277
 
278
+ ## Giving the characters voices
279
+
280
+ By default a workflow run's characters have no voice chosen, so every clip keeps
281
+ the voice the video model invents. `exodus video voices` is where that gets
282
+ fixed, and it only works **before** the storyboard is approved — once clips start
283
+ it refuses.
284
+
285
+ ```
286
+ exodus video voices <runId>
287
+ exodus video voices <runId> --json
288
+ exodus video voices <runId> --set C1=abc123voiceid --set "HOST 2=def456voiceid"
289
+ exodus video voices <runId> --from voices.json
290
+ exodus video voices <runId> --clear C2
291
+ ```
292
+
293
+ Read it first. With no flags it prints one line per character: the character's
294
+ ID, the name the script uses, the voice chosen, whether ElevenLabs actually has
295
+ that voice on the user's account, and how many scenes that character speaks in.
296
+ Under it: how the voices get applied, who pays, and anything worth knowing before
297
+ approving.
298
+
299
+ Name a character either way. `C1` is the ID the planner handed out; `HOST 1` is
300
+ the name the script uses. Both work, and the name is the portable one — the same
301
+ voices file works on every run of that brand because the planner may hand out the
302
+ IDs differently each time.
303
+
304
+ **What `--set` changes.** The voice choices, and nothing else. The script, the
305
+ scene pictures and the run itself are left exactly as they were — no re-boarding,
306
+ no picture is redrawn, nothing is charged. A character whose voice was already
307
+ what you asked for reports "Nothing changed".
308
+
309
+ **What it refuses, and why nothing is written when it does.** A character name
310
+ nobody in the cast answers to, a voice ID with a space in it (people paste the
311
+ voice's *name* by mistake), and a voice ID ElevenLabs is certain it does not have
312
+ on that account. Each refusal says the plain reason and saves nothing. If
313
+ ElevenLabs simply cannot be reached, the choice IS saved and the output says the
314
+ check could not be made — a provider outage never blocks the run.
315
+
316
+ **Approving checks the voices one last time.** `video approve` re-checks every
317
+ chosen voice before the clips are paid for. If ElevenLabs is certain one of them
318
+ is gone — deleted from the account, or the account changed — the approve is
319
+ refused in one plain sentence, nothing is approved and the command exits 1. Fix
320
+ it by choosing another voice (`video voices <runId> --set <character>=<voiceId>`)
321
+ or clearing that character (`--clear <character>`, which lets the clip keep the
322
+ voice the video model makes), then approve again. When the approve goes through,
323
+ the receipt lists each speaking character and the voice they got, plus any
324
+ `Heads-up:` lines worth reading — that is the last moment a wrong voice is cheap
325
+ to fix.
326
+
327
+ **The voices file.** A plain JSON object, keyed by the names the script uses:
328
+
329
+ ```json
330
+ {
331
+ "HOST 1": { "voiceId": "abc123voiceid", "label": "First host" },
332
+ "HOST 2": { "voiceId": "def456voiceid", "label": "Second host" }
333
+ }
334
+ ```
335
+
336
+ A bare string works instead of the object when you do not care about the label
337
+ (`"HOST 1": "abc123voiceid"`), and `null` clears that character's voice. `--set`
338
+ and `--clear` win over the same name in the file, so one entry can be overridden
339
+ without editing it. Use the name the file uses: if the file says `HOST 2` and the
340
+ flag says `C2`, that is one character named two ways, and the whole request is
341
+ refused with a sentence saying so. Voice IDs belong to the user's own ElevenLabs account and to that brand's
342
+ folder — never put a brand's voice IDs in a shared workflow or template.
343
+
344
+ **One file, two moments.** A brand keeps a single `voices.json` in its own
345
+ folder. Hand it to the run at the start with `exodus workflow run <workflow>
346
+ --voices @voices.json` when the script is one the user supplied (it names its
347
+ speakers, so `HOST 1` means something before the planner has run), or hand it in
348
+ at the storyboard review with `exodus video voices <runId> --from voices.json`.
349
+ Launching with voices refuses before the run is created — nothing is spent — if
350
+ a speaker is not in the script, the same speaker is named twice, or ElevenLabs
351
+ is certain it does not have one of the voices; a workflow whose script is
352
+ written by a bot is refused with a pointer at the review-time command, because
353
+ nobody knows the speaker names yet. When the storyboard arrives, the voices are
354
+ already set and `exodus video voices <runId>` shows them.
355
+
356
+ **The JSON receipt** (`--json`), the same shape for reading and for setting:
357
+
358
+ ```
359
+ ok true
360
+ runId
361
+ changed[] the characters whose voice actually moved (set only)
362
+ canChange false once the run has left the storyboard review
363
+ whyNot why, in one sentence, when canChange is false
364
+ cast[] one per character
365
+ characterId, name
366
+ voice { voiceId, label } or null
367
+ availability { state: "available" | "missing" | "not-checked", … }
368
+ spokenScenes how many scenes this character speaks in
369
+ spokenSeconds those scenes' planned seconds — what ElevenLabs bills on
370
+ narrator the one fallback voice, set on the workflow, not here
371
+ treatment how the voices reach the clips: summary, provider, model,
372
+ speedChange, billedTo, estimatedCostUsd, costNote,
373
+ usage { clips, seconds }, keyOnRun
374
+ notices[] plain sentences worth reading before approving
375
+ ```
376
+
377
+ A failure is the same shape every `video` verb uses:
378
+ `{ "ok": false, "status": 400, "error": "<one plain sentence>" }`, exit 1.
379
+
380
+ **Before you approve, read `notices`.** The ones that cost the user something:
381
+ a character who speaks but has no voice (those clips keep their generated
382
+ voice), no ElevenLabs key saved on the run (no voice can be applied at all), and
383
+ a voice ElevenLabs no longer has.
384
+
385
+ `estimatedCostUsd` is always null. Exodus cannot price a voice conversion yet;
386
+ ElevenLabs bills the user's own account by audio length, and `usage` gives the
387
+ length. Do not invent a number.
388
+
260
389
  ## The handover: what `pull` gives you
261
390
 
262
391
  `pull` writes a flat folder and a `manifest.json` that indexes it. The manifest
@@ -283,18 +412,28 @@ manifest.json the index below
283
412
  `manifest.json`:
284
413
 
285
414
  ```
286
- runId, pulledAt, dashboardUrl the run and its page (/video?ad=<runId>)
415
+ runId, pulledAt, dashboardUrl the run and the page that opens it:
416
+ /video?ad=<runId> for a Show ad,
417
+ /workflows/<workflowId>/runs/<runId> for a
418
+ workflow run
287
419
  storyboard, reference, music filenames, or null when not delivered
288
420
  narration { file, timing }, or null on a per-scene run
289
421
  cast[] one per identity still
290
422
  characterId, name from the run's cast lock; null for an
291
423
  unclaimed ledger still
292
424
  file, status, error
425
+ voiceId, voiceLabel the ElevenLabs voice this character was
426
+ given before the clips were made; null when
427
+ nobody chose one
293
428
  scenes[] one per scene, in order
294
429
  sceneIndex, durationSec the run's number and the clip's real length
295
430
  clip, voice, keyframe, words filenames, or null
296
431
  wordsFrom clip | voice — which file the word times are
297
432
  measured against; null when none came
433
+ wordsDescribe this-file | original-performance — whether
434
+ those times were measured on the file you
435
+ have, or on the take a cast voice replaced;
436
+ null when no words came
298
437
  clipStatus done | failed | running | pending | missing
299
438
  error why the clip failed, when it did
300
439
  flagged, findings[] QC verdict: {check, code, severity: fail|warn, detail}
@@ -371,6 +510,18 @@ re-based to the start of that scene's cut; on a per-scene run it is instead a
371
510
  blind Scribe pass over that scene's own voice track. Nothing is aligned to the
372
511
  finished ad — that timeline does not exist until you make it.
373
512
 
513
+ **How far to trust them** (`wordsDescribe`). `"this-file"` means the times were
514
+ measured on the very file `wordsFrom` names, so they hold to the frame: cut
515
+ captions and cue points straight off them. `"original-performance"` means the
516
+ scene's clip was re-voiced *after* its words were timed — the times describe the
517
+ take the cast voice replaced. The words and their order are right and the timing
518
+ is close, but nothing re-measures it, so check any cut that has to land on a
519
+ single frame. A clip carrying a `voice-timing-shifted` warning is the same thing
520
+ with a measured gap: the swapped audio came back a different length, so treat
521
+ that scene's times as a rough guide and watch it before you trust it. Nothing in
522
+ the pipeline speeds audio or video up or slows it down to make the two agree —
523
+ the timings are reported honestly instead.
524
+
374
525
  **Cast identity stills** (`cast-*.<ext>`). A full-body identity still per cast
375
526
  member, on a plain neutral background in the run's style, minted once and
376
527
  frozen onto the run as its cast lock. Every scene picture was anchored to these,
@@ -392,14 +543,19 @@ music failure degrades to a warning rather than blocking the run.
392
543
  - `wordsFrom: "voice"`, or a scene whose storyboard entry has `voText` and no
393
544
  on-camera line: the clip is picture only and the voice track is the sound.
394
545
  Do not use that clip's own audio.
546
+ - `wordsDescribe: "original-performance"`: the word times were measured before
547
+ that clip's cast voice was swapped in. Close, not frame-exact — say so if the
548
+ user is cutting captions to the frame.
395
549
  - `manifest.narration` present: listen to `narration.mp3` end to end, then the
396
550
  joins between adjacent `scene-NN.voice` files, before you trust the cut.
397
551
  Matching voice IDs and a one-speaker diarization result do not prove
398
552
  continuity (#1802).
399
553
  - `flagged: true` is a warning, not a block. The clip was delivered anyway; the
400
554
  findings say what the QC model saw (`wrong-character`, `eyeline-off`,
401
- `set-drift`, `speech-cutoff`, `framing-off`). Tell the user which scenes are
402
- flagged and why, and let them decide whether to keep, trim, or drop each one.
555
+ `set-drift`, `speech-cutoff`, `framing-off`, `voice-timing-shifted`). Tell the
556
+ user which scenes are flagged and why, and let them decide whether to keep,
557
+ trim, drop or redo each one (`retry-clip`, see "Redoing one clip at the final
558
+ watch").
403
559
  A `speech-cutoff` on the CTA scene is the one to worry about; the ad's last
404
560
  words are missing. `status` shows the checker's own wording behind each
405
561
  finding; the manifest carries only the verdict.
@@ -411,6 +567,49 @@ music failure degrades to a warning rather than blocking the run.
411
567
  the run parks at the final watch, which means nothing is still rendering.
412
568
  - `failed[]` non-empty: run the same `pull` again.
413
569
 
570
+ ## Redoing one clip at the final watch
571
+
572
+ `retry-clip` re-renders ONE scene's clip on the run that already made it. There
573
+ is no second run, no new storyboard and no re-render of anything else.
574
+
575
+ ```
576
+ exodus video retry-clip <runId> --scene 3
577
+ exodus video retry-clip <runId> --scene 3 --note "keep both hands in frame on the handshake"
578
+ exodus video retry-clip <runId> --scene 3 --node video-2
579
+ ```
580
+
581
+ `--node` is only needed when a run has clips for that scene on two video steps;
582
+ the CLI asks for it by name when it does.
583
+
584
+ **When it is allowed.** A failed clip, once the step that made it has finished. A
585
+ finished clip, only while the run is parked at the final watch, or when QC
586
+ flagged it (`flagged: true` in `status` and in the manifest). Everything else is
587
+ refused in one plain sentence before any task is queued: a step still rendering
588
+ its other scenes or one that failed outright, a scene already being redone, a
589
+ finished clip on a run that is not at its final watch. An ad that is already
590
+ delivered is refused by the server, in its own sentence.
591
+
592
+ **What stays untouched.** Every other scene's clip, every keyframe, the cast
593
+ pins, the voices and the approved script. The redone clip re-renders from the
594
+ same keyframe with the same voice path the first take used, so it comes back
595
+ revoiced and trimmed the same way, with fresh findings and fresh word timings.
596
+
597
+ **A `--note` steers the motion, never the words.** It is folded into that
598
+ scene's motion prompt only (`withRedoDirection`,
599
+ `scout/src/trigger/workflow-scene-retry.ts`); the spoken line still comes from
600
+ the approved storyboard. Asking for different words in a note will not change
601
+ them. If the words are wrong, that is a storyboard flag or a new run.
602
+
603
+ **A redo whose take fails the checks keeps the original clip.** On a finished
604
+ clip the row stays `done` with the ORIGINAL take and only its findings are
605
+ refreshed, because a worse take must never replace a delivered one. So never
606
+ report that the clip changed. Read `status` again for the new findings, then pull
607
+ and compare the file before and after.
608
+
609
+ **Re-cut afterwards.** A cut you already uploaded still holds the old clip, and
610
+ the CLI says so when that is the case. Pull again, re-cut and upload again before
611
+ anyone approves.
612
+
414
613
  ## Cutting the ad
415
614
 
416
615
  Editing is the part every user does differently, and the team is deliberately
@@ -1,5 +1,6 @@
1
1
  import { type ApiResponse } from "../lib/client.js";
2
2
  export declare const helpText: string;
3
+ export declare const VOICES_PATH = "/api/v2/video/voices";
3
4
  export interface ClipWord {
4
5
  w: string;
5
6
  s: number;
@@ -69,6 +70,7 @@ export interface VideoRunNode {
69
70
  kind: string;
70
71
  status: "idle" | "running" | "done" | "failed" | "skipped";
71
72
  error?: string;
73
+ warning?: string;
72
74
  outputs?: ArtifactSubset[];
73
75
  }
74
76
  export type BuilderPauseReason = "taste" | "repair" | "slots" | "call" | "checkpoint";
@@ -95,6 +97,8 @@ export interface VideoRun {
95
97
  pausedNodeId?: string;
96
98
  nodes: VideoRunNode[];
97
99
  castLock?: PullCastLock | null;
100
+ workflowId?: string;
101
+ moduleOwned?: boolean;
98
102
  }
99
103
  export declare function asVideoRun(data: unknown): VideoRun;
100
104
  export interface NodeItem {
@@ -176,7 +180,8 @@ export type RunStop = {
176
180
  export declare function classifyRun(run: VideoRun): RunStop;
177
181
  export declare function stageWord(stage: string): string;
178
182
  export declare function stepName(kind: string | undefined): string;
179
- export declare function stopLines(stop: RunStop, runId: string, dashboardUrl: string): string[];
183
+ export declare function reviewUrl(dashboardUrl: string, run: Pick<VideoRun, "_id" | "workflowId" | "moduleOwned">): string;
184
+ export declare function stopLines(stop: RunStop, runId: string, runUrl: string): string[];
180
185
  export interface PullDownload {
181
186
  file: string;
182
187
  url: string;
@@ -191,6 +196,7 @@ export interface ManifestScene {
191
196
  clip: string | null;
192
197
  words: string | null;
193
198
  wordsFrom: "clip" | "voice" | null;
199
+ wordsDescribe: "original-performance" | "this-file" | null;
194
200
  voice: string | null;
195
201
  keyframe: string | null;
196
202
  qc: ClipQc | null;
@@ -213,6 +219,8 @@ export interface ManifestCastRef {
213
219
  file: string | null;
214
220
  status: string;
215
221
  error: string | null;
222
+ voiceId: string | null;
223
+ voiceLabel: string | null;
216
224
  }
217
225
  export interface VideoManifest {
218
226
  runId: string;
@@ -264,6 +272,71 @@ export declare function storyboardFlow(runId: string, json: boolean, deps: Video
264
272
  export declare function approveFlow(runId: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
265
273
  export declare function flagFlow(runId: string, note: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
266
274
  export declare function retryFrameFlow(runId: string, nodeId: string, sceneIndex: number, note: string | undefined, json: boolean, deps: VideoDeps): Promise<FlowResult>;
275
+ export type ClipRedoPlan = {
276
+ ok: true;
277
+ nodeId: string;
278
+ sceneIndex: number;
279
+ attempt: number;
280
+ warnings: string[];
281
+ } | {
282
+ ok: false;
283
+ reason: string;
284
+ };
285
+ export interface ClipRedoTarget {
286
+ sceneIndex: number;
287
+ nodeId?: string;
288
+ }
289
+ export declare function planClipRedo(run: VideoRun, items: NodeItem[], target: ClipRedoTarget): ClipRedoPlan;
290
+ export declare function retryClipFlow(runId: string, target: ClipRedoTarget, note: string | undefined, json: boolean, deps: VideoDeps): Promise<FlowResult>;
291
+ export interface CastVoiceRow {
292
+ characterId: string;
293
+ name: string;
294
+ voice: {
295
+ voiceId: string;
296
+ label?: string;
297
+ } | null;
298
+ availability?: {
299
+ state: "available";
300
+ providerName: string;
301
+ } | {
302
+ state: "missing";
303
+ } | {
304
+ state: "not-checked";
305
+ why: string;
306
+ };
307
+ spokenScenes: number;
308
+ spokenSeconds: number;
309
+ }
310
+ export interface CastVoiceSheet {
311
+ runId: string;
312
+ canChange: boolean;
313
+ whyNot?: string;
314
+ cast: CastVoiceRow[];
315
+ narrator: {
316
+ voiceId: string;
317
+ label?: string;
318
+ } | null;
319
+ treatment: {
320
+ summary: string;
321
+ provider: string;
322
+ model: string;
323
+ speedChange: boolean;
324
+ costNote: string;
325
+ usage: {
326
+ clips: number;
327
+ seconds: number;
328
+ };
329
+ };
330
+ notices: string[];
331
+ changed?: string[];
332
+ }
333
+ export type VoiceMap = Record<string, string | {
334
+ voiceId: string;
335
+ label?: string;
336
+ } | null>;
337
+ export declare function parseVoiceFlags(argv: readonly string[], readFile: (path: string) => string): VoiceMap | null;
338
+ export declare function voicesFlow(runId: string, voices: VoiceMap | null, json: boolean, deps: VideoDeps): Promise<FlowResult>;
339
+ export declare function voiceSheetLines(sheet: CastVoiceSheet): string[];
267
340
  export declare function pullFlow(runId: string, dir: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
268
341
  export declare function parseMvhdDurationSec(bytes: Uint8Array): number | null;
269
342
  export declare const NO_DURATION_MESSAGE: string;