@aicopycoders/exodus 2026.9.1700-beta.1 → 2026.9.1800-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.
@@ -89,6 +89,8 @@ exodus video start --show <id> --script script.txt --wait start, wait for the s
89
89
 
90
90
  ```operator-guide
91
91
  exodus video storyboard <runId> read the scene cards
92
+ exodus video voices <runId> who speaks and with whose voice
93
+ exodus video voices <runId> --set <character>=<voiceId> give a character a voice
92
94
  exodus video approve <runId> keep going
93
95
  exodus video flag <runId> --note "what's wrong" send it back — SHOW RUNS ONLY
94
96
  exodus video retry-frame <runId> --node <nodeId> --scene <n> redo one still at the pixel gate
@@ -162,6 +164,8 @@ Verified against the routes, not assumed. Everything here is admin-gated on top.
162
164
  | `workflow run` | starts the run | not the entry point |
163
165
  | `video status` | works | works |
164
166
  | `video storyboard` | works | works |
167
+ | `video voices` (read) | works | works |
168
+ | `video voices --set` | works | **refused** — the Show owns its voices |
165
169
  | `video approve` | works, both gates | works, both gates |
166
170
  | `video flag --note` | **refused** | works |
167
171
  | script-turn edits at the gate | **refused** | dashboard only |
@@ -196,13 +200,16 @@ Five consequences worth knowing before you promise a user anything:
196
200
 
197
201
  - **`flag` is Show-only and fails loudly.** `exodus video flag` on a workflow run
198
202
  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.
203
+ (`convex/videoModule.ts`, guarded on `!run.showId`). Script-turn edits refuse
204
+ the same way. There is **no** CLI path that sends a workflow run's storyboard
205
+ back with a note, and none that rewrites its scenes, script or prompts — the
206
+ whole-envelope edit mutation is wired only to the dashboard's gate screen, not
207
+ to any API route. The one exception is voices: `exodus video voices --set`
208
+ writes the cast's voice choices in place and nothing else (see "Giving the
209
+ characters voices"). For everything else the storyboard is approve-or-cancel
210
+ from the terminal: `exodus workflow cancel <runId>` and run again with a better
211
+ script, or edit the workflow. Do not offer `flag` to a user on the workflow
212
+ route.
206
213
  - **`--wait` lands on both video parks, but only those.** Since #1818 every
207
214
  `--wait` caller — `workflow run`, `triggers fire`, `checkpoint approve|retry`,
208
215
  `repair retry|skip` — ends at a storyboard gate or a final watch, exits 0, and
@@ -257,6 +264,94 @@ The note is read by the model that rewrites the storyboard, so make it concrete
257
264
  Either way, do not approve on the user's behalf unless they told you to run the
258
265
  whole loop unattended.
259
266
 
267
+ ## Giving the characters voices
268
+
269
+ By default a workflow run's characters have no voice chosen, so every clip keeps
270
+ the voice the video model invents. `exodus video voices` is where that gets
271
+ fixed, and it only works **before** the storyboard is approved — once clips start
272
+ it refuses.
273
+
274
+ ```
275
+ exodus video voices <runId>
276
+ exodus video voices <runId> --json
277
+ exodus video voices <runId> --set C1=abc123voiceid --set "HOST 2=def456voiceid"
278
+ exodus video voices <runId> --from voices.json
279
+ exodus video voices <runId> --clear C2
280
+ ```
281
+
282
+ Read it first. With no flags it prints one line per character: the character's
283
+ ID, the name the script uses, the voice chosen, whether ElevenLabs actually has
284
+ that voice on the user's account, and how many scenes that character speaks in.
285
+ Under it: how the voices get applied, who pays, and anything worth knowing before
286
+ approving.
287
+
288
+ Name a character either way. `C1` is the ID the planner handed out; `HOST 1` is
289
+ the name the script uses. Both work, and the name is the portable one — the same
290
+ voices file works on every run of that brand because the planner may hand out the
291
+ IDs differently each time.
292
+
293
+ **What `--set` changes.** The voice choices, and nothing else. The script, the
294
+ scene pictures and the run itself are left exactly as they were — no re-boarding,
295
+ no picture is redrawn, nothing is charged. A character whose voice was already
296
+ what you asked for reports "Nothing changed".
297
+
298
+ **What it refuses, and why nothing is written when it does.** A character name
299
+ nobody in the cast answers to, a voice ID with a space in it (people paste the
300
+ voice's *name* by mistake), and a voice ID ElevenLabs is certain it does not have
301
+ on that account. Each refusal says the plain reason and saves nothing. If
302
+ ElevenLabs simply cannot be reached, the choice IS saved and the output says the
303
+ check could not be made — a provider outage never blocks the run.
304
+
305
+ **The voices file.** A plain JSON object, keyed by the names the script uses:
306
+
307
+ ```json
308
+ {
309
+ "HOST 1": { "voiceId": "abc123voiceid", "label": "First host" },
310
+ "HOST 2": { "voiceId": "def456voiceid", "label": "Second host" }
311
+ }
312
+ ```
313
+
314
+ A bare string works instead of the object when you do not care about the label
315
+ (`"HOST 1": "abc123voiceid"`), and `null` clears that character's voice. `--set`
316
+ and `--clear` win over the same name in the file, so one entry can be overridden
317
+ without editing it. Use the name the file uses: if the file says `HOST 2` and the
318
+ flag says `C2`, that is one character named two ways, and the whole request is
319
+ refused with a sentence saying so. Voice IDs belong to the user's own ElevenLabs account and to that brand's
320
+ folder — never put a brand's voice IDs in a shared workflow or template.
321
+
322
+ **The JSON receipt** (`--json`), the same shape for reading and for setting:
323
+
324
+ ```
325
+ ok true
326
+ runId
327
+ changed[] the characters whose voice actually moved (set only)
328
+ canChange false once the run has left the storyboard review
329
+ whyNot why, in one sentence, when canChange is false
330
+ cast[] one per character
331
+ characterId, name
332
+ voice { voiceId, label } or null
333
+ availability { state: "available" | "missing" | "not-checked", … }
334
+ spokenScenes how many scenes this character speaks in
335
+ spokenSeconds those scenes' planned seconds — what ElevenLabs bills on
336
+ narrator the one fallback voice, set on the workflow, not here
337
+ treatment how the voices reach the clips: summary, provider, model,
338
+ speedChange, billedTo, estimatedCostUsd, costNote,
339
+ usage { clips, seconds }, keyOnRun
340
+ notices[] plain sentences worth reading before approving
341
+ ```
342
+
343
+ A failure is the same shape every `video` verb uses:
344
+ `{ "ok": false, "status": 400, "error": "<one plain sentence>" }`, exit 1.
345
+
346
+ **Before you approve, read `notices`.** The ones that cost the user something:
347
+ a character who speaks but has no voice (those clips keep their generated
348
+ voice), no ElevenLabs key saved on the run (no voice can be applied at all), and
349
+ a voice ElevenLabs no longer has.
350
+
351
+ `estimatedCostUsd` is always null. Exodus cannot price a voice conversion yet;
352
+ ElevenLabs bills the user's own account by audio length, and `usage` gives the
353
+ length. Do not invent a number.
354
+
260
355
  ## The handover: what `pull` gives you
261
356
 
262
357
  `pull` writes a flat folder and a `manifest.json` that indexes it. The manifest
@@ -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;
@@ -264,6 +265,55 @@ export declare function storyboardFlow(runId: string, json: boolean, deps: Video
264
265
  export declare function approveFlow(runId: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
265
266
  export declare function flagFlow(runId: string, note: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
266
267
  export declare function retryFrameFlow(runId: string, nodeId: string, sceneIndex: number, note: string | undefined, json: boolean, deps: VideoDeps): Promise<FlowResult>;
268
+ export interface CastVoiceRow {
269
+ characterId: string;
270
+ name: string;
271
+ voice: {
272
+ voiceId: string;
273
+ label?: string;
274
+ } | null;
275
+ availability?: {
276
+ state: "available";
277
+ providerName: string;
278
+ } | {
279
+ state: "missing";
280
+ } | {
281
+ state: "not-checked";
282
+ why: string;
283
+ };
284
+ spokenScenes: number;
285
+ spokenSeconds: number;
286
+ }
287
+ export interface CastVoiceSheet {
288
+ runId: string;
289
+ canChange: boolean;
290
+ whyNot?: string;
291
+ cast: CastVoiceRow[];
292
+ narrator: {
293
+ voiceId: string;
294
+ label?: string;
295
+ } | null;
296
+ treatment: {
297
+ summary: string;
298
+ provider: string;
299
+ model: string;
300
+ speedChange: boolean;
301
+ costNote: string;
302
+ usage: {
303
+ clips: number;
304
+ seconds: number;
305
+ };
306
+ };
307
+ notices: string[];
308
+ changed?: string[];
309
+ }
310
+ export type VoiceMap = Record<string, string | {
311
+ voiceId: string;
312
+ label?: string;
313
+ } | null>;
314
+ export declare function parseVoiceFlags(argv: readonly string[], readFile: (path: string) => string): VoiceMap | null;
315
+ export declare function voicesFlow(runId: string, voices: VoiceMap | null, json: boolean, deps: VideoDeps): Promise<FlowResult>;
316
+ export declare function voiceSheetLines(sheet: CastVoiceSheet): string[];
267
317
  export declare function pullFlow(runId: string, dir: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
268
318
  export declare function parseMvhdDurationSec(bytes: Uint8Array): number | null;
269
319
  export declare const NO_DURATION_MESSAGE: string;
@@ -25,24 +25,28 @@ The whole loop, in order:
25
25
  3. exodus video storyboard <runId>
26
26
  The scene cards: what each scene says and the picture it will look like.
27
27
 
28
- 4. exodus video approve <runId> (looks right — keep going)
28
+ 4. exodus video voices <runId>
29
+ Who is in the ad, which voice each one has, how the voices get applied
30
+ and who pays. Add --set to give someone a voice, before any clip is made.
31
+
32
+ 5. exodus video approve <runId> (looks right — keep going)
29
33
  exodus video flag <runId> --note "…" (something is wrong — send it back)
30
34
  exodus video retry-frame <runId> --node <nodeId> --scene <n>
31
35
  Redo ONE still at the pixel gate. Neighbours stay. The gate holds.
32
36
 
33
- 5. exodus video status <runId>
37
+ 6. exodus video status <runId>
34
38
  Where the run is and how each scene's clip turned out.
35
39
 
36
- 6. exodus video pull <runId> --out ./ad
40
+ 7. exodus video pull <runId> --out ./ad
37
41
  Writes every piece to that folder plus a manifest.json index.
38
42
 
39
- 7. Make your cut from those files.
43
+ 8. Make your cut from those files.
40
44
 
41
- 8. exodus video upload <runId> --file cut.mp4
45
+ 9. exodus video upload <runId> --file cut.mp4
42
46
  Attaches your cut to the run and prints the page to approve it on.
43
47
 
44
- 9. exodus video approve <runId>
45
- Or click Approve on the page from step 8.
48
+ 10. exodus video approve <runId>
49
+ Or click Approve on the page from step 9.
46
50
 
47
51
  Usage:
48
52
  exodus video shows [--json]
@@ -52,6 +56,7 @@ Usage:
52
56
  exodus video approve <runId> [--json]
53
57
  exodus video flag <runId> --note "<what is wrong>" [--json]
54
58
  exodus video retry-frame <runId> --node <nodeId> --scene <n> [--note "..."] [--json]
59
+ exodus video voices <runId> [--set <character>=<voiceId>] [--clear <character>] [--from <file.json>] [--json]
55
60
  exodus video pull <runId> --out <dir> [--json]
56
61
  exodus video upload <runId> --file <cut.mp4> [--duration <sec>] [--json]
57
62
 
@@ -70,6 +75,12 @@ Options:
70
75
  steer one frame redo (retry-frame)
71
76
  --node <nodeId> Which scene-frames node holds the still (retry-frame)
72
77
  --scene <n> Which scene's frame to redo. One scene only (retry-frame)
78
+ --set <who>=<id> Give a character an ElevenLabs voice (voices). Name the
79
+ character by its ID or by the name your script uses.
80
+ Repeat it once per character
81
+ --clear <who> Take a character's voice off again (voices). Repeatable
82
+ --from <file.json> A file of characters and voice IDs (voices). --set and
83
+ --clear win over the same name in the file
73
84
  --json Machine-readable output
74
85
  --help, -h Show this help
75
86
 
@@ -82,6 +93,9 @@ Examples:
82
93
  exodus video storyboard run_123
83
94
  exodus video approve run_123
84
95
  exodus video retry-frame run_123 --node frames-1 --scene 2
96
+ exodus video voices run_123
97
+ exodus video voices run_123 --set C1=abc123voiceid --set "HOST 2=def456voiceid"
98
+ exodus video voices run_123 --from voices.json
85
99
  exodus video pull run_123 --out ./ad-run_123
86
100
  exodus video upload run_123 --file ./cut.mp4
87
101
  `.trim();
@@ -94,6 +108,7 @@ const FLAG_PATH = "/api/v2/video/storyboard/flag";
94
108
  const APPROVE_PATH = "/api/v2/workflow/approve";
95
109
  const SCENE_RETRY_PATH = "/api/v2/workflow/scene/retry";
96
110
  const FINAL_PATH = "/api/v2/video/final";
111
+ export const VOICES_PATH = "/api/v2/video/voices";
97
112
  const ASSET_UPLOAD_URL_PATH = "/api/v2/workflows/asset-upload-url";
98
113
  const ASSETS_PATH = "/api/v2/workflows/assets";
99
114
  function withoutJudgeFields({ judgeDetail: _d, judgeSeverity: _s, ...rest }) {
@@ -1037,6 +1052,123 @@ export async function retryFrameFlow(runId, nodeId, sceneIndex, note, json, deps
1037
1052
  ],
1038
1053
  };
1039
1054
  }
1055
+ export function parseVoiceFlags(argv, readFile) {
1056
+ const fromFile = {};
1057
+ const typed = {};
1058
+ let asked = false;
1059
+ const valueFor = (i, flag) => {
1060
+ const arg = argv[i];
1061
+ if (arg.startsWith(`${flag}=`))
1062
+ return [arg.slice(flag.length + 1), i];
1063
+ const next = argv[i + 1];
1064
+ if (next === undefined)
1065
+ throw new Error(`${flag} needs a value.`);
1066
+ return [next, i + 1];
1067
+ };
1068
+ for (let i = 0; i < argv.length; i++) {
1069
+ const arg = argv[i];
1070
+ if (arg === "--set" || arg.startsWith("--set=")) {
1071
+ const [raw, next] = valueFor(i, "--set");
1072
+ i = next;
1073
+ const eq = raw.lastIndexOf("=");
1074
+ if (eq <= 0 || eq === raw.length - 1) {
1075
+ throw new Error("--set must look like <character>=<voice id>, for example " +
1076
+ `--set C1=abc123voiceid (got "${raw}").`);
1077
+ }
1078
+ typed[raw.slice(0, eq).trim()] = raw.slice(eq + 1).trim();
1079
+ asked = true;
1080
+ }
1081
+ else if (arg === "--clear" || arg.startsWith("--clear=")) {
1082
+ const [raw, next] = valueFor(i, "--clear");
1083
+ i = next;
1084
+ if (!raw.trim())
1085
+ throw new Error("--clear needs a character, for example --clear C1.");
1086
+ typed[raw.trim()] = null;
1087
+ asked = true;
1088
+ }
1089
+ else if (arg === "--from" || arg.startsWith("--from=")) {
1090
+ const [file, next] = valueFor(i, "--from");
1091
+ i = next;
1092
+ let parsed;
1093
+ try {
1094
+ parsed = JSON.parse(readFile(file));
1095
+ }
1096
+ catch {
1097
+ throw new Error(`Could not read ${file} as a list of characters and voice IDs.`);
1098
+ }
1099
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1100
+ throw new Error(`Could not read ${file} as a list of characters and voice IDs.`);
1101
+ }
1102
+ Object.assign(fromFile, parsed);
1103
+ asked = true;
1104
+ }
1105
+ }
1106
+ if (!asked)
1107
+ return null;
1108
+ const sameName = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
1109
+ const merged = {};
1110
+ for (const [who, choice] of Object.entries(fromFile)) {
1111
+ if (!Object.keys(typed).some((t) => sameName(t, who)))
1112
+ merged[who] = choice;
1113
+ }
1114
+ return { ...merged, ...typed };
1115
+ }
1116
+ export async function voicesFlow(runId, voices, json, deps) {
1117
+ const res = voices
1118
+ ? await deps.post(VOICES_PATH, { runId, voices })
1119
+ : await deps.get(`${VOICES_PATH}?runId=${encodeURIComponent(runId)}`);
1120
+ if (!res.ok)
1121
+ return errorResult(res, json);
1122
+ const sheet = res.data;
1123
+ if (json)
1124
+ return { code: 0, lines: [JSON.stringify({ ok: true, ...sheet })] };
1125
+ return { code: 0, lines: voiceSheetLines(sheet) };
1126
+ }
1127
+ function availabilityWords(row) {
1128
+ if (!row.voice)
1129
+ return "no voice yet";
1130
+ switch (row.availability?.state) {
1131
+ case "available":
1132
+ return "ElevenLabs has it";
1133
+ case "missing":
1134
+ return "ElevenLabs does not have it";
1135
+ case "not-checked":
1136
+ return "not checked just now";
1137
+ default:
1138
+ return "not checked";
1139
+ }
1140
+ }
1141
+ export function voiceSheetLines(sheet) {
1142
+ const lines = [`Voices for run ${sheet.runId}`];
1143
+ if (sheet.cast.length === 0)
1144
+ lines.push(" Nobody is in this ad yet.");
1145
+ for (const row of sheet.cast) {
1146
+ const voice = row.voice
1147
+ ? `${row.voice.label ? `${row.voice.label} (${row.voice.voiceId})` : row.voice.voiceId}`
1148
+ : "—";
1149
+ lines.push(` ${row.characterId} ${row.name} ${voice} ${availabilityWords(row)} ` +
1150
+ `speaks in ${row.spokenScenes} scenes, about ${row.spokenSeconds}s`);
1151
+ }
1152
+ if (sheet.narrator) {
1153
+ const label = sheet.narrator.label ? `${sheet.narrator.label} ` : "";
1154
+ lines.push(` Narrator ${label}(${sheet.narrator.voiceId}) set on the workflow, not here`);
1155
+ }
1156
+ if (sheet.changed) {
1157
+ lines.push(sheet.changed.length > 0
1158
+ ? `Changed: ${sheet.changed.join(", ")}. Pictures and script were not touched.`
1159
+ : "Nothing changed — those voices were already set. Pictures and script were not touched.");
1160
+ }
1161
+ lines.push(`How voices are applied: ${sheet.treatment.summary}${sheet.treatment.speedChange ? "" : " No speed change."}`);
1162
+ lines.push(`Cost: billed to your own ElevenLabs key. ${sheet.treatment.costNote} ` +
1163
+ `About ${sheet.treatment.usage.seconds} seconds of speech across ` +
1164
+ `${sheet.treatment.usage.clips} clips will be converted.`);
1165
+ for (const notice of sheet.notices)
1166
+ lines.push(notice);
1167
+ lines.push(sheet.canChange
1168
+ ? "You can still change voices until you approve the storyboard."
1169
+ : (sheet.whyNot ?? "Voices can no longer be changed on this run."));
1170
+ return lines;
1171
+ }
1040
1172
  export async function pullFlow(runId, dir, json, deps) {
1041
1173
  const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
1042
1174
  if (!runRes.ok)
@@ -1343,6 +1475,9 @@ const VALUE_FLAGS = new Set([
1343
1475
  "duration",
1344
1476
  "node",
1345
1477
  "scene",
1478
+ "set",
1479
+ "clear",
1480
+ "from",
1346
1481
  ]);
1347
1482
  export function parsePositional(args = process.argv.slice(3)) {
1348
1483
  const out = [];
@@ -1402,7 +1537,16 @@ export async function run(flags) {
1402
1537
  }, defaultDeps));
1403
1538
  }
1404
1539
  const runId = rest[0];
1405
- const needsRunId = ["status", "storyboard", "approve", "flag", "retry-frame", "pull", "upload"];
1540
+ const needsRunId = [
1541
+ "status",
1542
+ "storyboard",
1543
+ "approve",
1544
+ "flag",
1545
+ "retry-frame",
1546
+ "voices",
1547
+ "pull",
1548
+ "upload",
1549
+ ];
1406
1550
  if (needsRunId.includes(sub) && !runId) {
1407
1551
  usage(`video ${sub} needs a run id: exodus video ${sub} <runId>`);
1408
1552
  }
@@ -1432,6 +1576,16 @@ export async function run(flags) {
1432
1576
  }
1433
1577
  return printResult(await retryFrameFlow(runId, nodeId, sceneIndex, flagString(flags, "note"), json, defaultDeps));
1434
1578
  }
1579
+ if (sub === "voices") {
1580
+ let voices;
1581
+ try {
1582
+ voices = parseVoiceFlags(process.argv.slice(3), defaultDeps.readFile);
1583
+ }
1584
+ catch (err) {
1585
+ usage(err.message);
1586
+ }
1587
+ return printResult(await voicesFlow(runId, voices, json, defaultDeps));
1588
+ }
1435
1589
  if (sub === "pull") {
1436
1590
  const dir = flagString(flags, "out");
1437
1591
  if (!dir)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicopycoders/exodus",
3
- "version": "2026.9.1700-beta.1",
3
+ "version": "2026.9.1800-beta.0",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",