@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.
@@ -25,24 +25,32 @@ 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
+ exodus video retry-clip <runId> --scene <n>
44
+ Redo ONE finished clip while the run waits for the cut. Everything
45
+ else stays. A --note steers the motion, never the words.
46
+
47
+ 8. Make your cut from those files.
40
48
 
41
- 8. exodus video upload <runId> --file cut.mp4
49
+ 9. exodus video upload <runId> --file cut.mp4
42
50
  Attaches your cut to the run and prints the page to approve it on.
43
51
 
44
- 9. exodus video approve <runId>
45
- Or click Approve on the page from step 8.
52
+ 10. exodus video approve <runId>
53
+ Or click Approve on the page from step 9.
46
54
 
47
55
  Usage:
48
56
  exodus video shows [--json]
@@ -52,6 +60,8 @@ Usage:
52
60
  exodus video approve <runId> [--json]
53
61
  exodus video flag <runId> --note "<what is wrong>" [--json]
54
62
  exodus video retry-frame <runId> --node <nodeId> --scene <n> [--note "..."] [--json]
63
+ exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "..."] [--json]
64
+ exodus video voices <runId> [--set <character>=<voiceId>] [--clear <character>] [--from <file.json>] [--json]
55
65
  exodus video pull <runId> --out <dir> [--json]
56
66
  exodus video upload <runId> --file <cut.mp4> [--duration <sec>] [--json]
57
67
 
@@ -67,9 +77,18 @@ Options:
67
77
  --duration <sec> How long your cut is, in seconds. Only needed when the
68
78
  length can't be read off the file itself
69
79
  --note "<text>" What is wrong with the storyboard (flag), or how to
70
- steer one frame redo (retry-frame)
71
- --node <nodeId> Which scene-frames node holds the still (retry-frame)
72
- --scene <n> Which scene's frame to redo. One scene only (retry-frame)
80
+ steer one redo (retry-frame, retry-clip)
81
+ --node <nodeId> Which scene-frames node holds the still (retry-frame).
82
+ Which video step holds the clip, when a scene has one on
83
+ more than one step (retry-clip)
84
+ --scene <n> Which scene to redo. One scene only, a whole number
85
+ (retry-frame, retry-clip)
86
+ --set <who>=<id> Give a character an ElevenLabs voice (voices). Name the
87
+ character by its ID or by the name your script uses.
88
+ Repeat it once per character
89
+ --clear <who> Take a character's voice off again (voices). Repeatable
90
+ --from <file.json> A file of characters and voice IDs (voices). --set and
91
+ --clear win over the same name in the file
73
92
  --json Machine-readable output
74
93
  --help, -h Show this help
75
94
 
@@ -82,6 +101,10 @@ Examples:
82
101
  exodus video storyboard run_123
83
102
  exodus video approve run_123
84
103
  exodus video retry-frame run_123 --node frames-1 --scene 2
104
+ exodus video retry-clip run_123 --scene 3 --note "keep the handshake in frame"
105
+ exodus video voices run_123
106
+ exodus video voices run_123 --set C1=abc123voiceid --set "HOST 2=def456voiceid"
107
+ exodus video voices run_123 --from voices.json
85
108
  exodus video pull run_123 --out ./ad-run_123
86
109
  exodus video upload run_123 --file ./cut.mp4
87
110
  `.trim();
@@ -94,6 +117,7 @@ const FLAG_PATH = "/api/v2/video/storyboard/flag";
94
117
  const APPROVE_PATH = "/api/v2/workflow/approve";
95
118
  const SCENE_RETRY_PATH = "/api/v2/workflow/scene/retry";
96
119
  const FINAL_PATH = "/api/v2/video/final";
120
+ export const VOICES_PATH = "/api/v2/video/voices";
97
121
  const ASSET_UPLOAD_URL_PATH = "/api/v2/workflows/asset-upload-url";
98
122
  const ASSETS_PATH = "/api/v2/workflows/assets";
99
123
  function withoutJudgeFields({ judgeDetail: _d, judgeSeverity: _s, ...rest }) {
@@ -245,7 +269,14 @@ export function stepName(kind) {
245
269
  return "A step in this run";
246
270
  return STEP_NAMES[kind] ?? `The ${kind} step`;
247
271
  }
248
- export function stopLines(stop, runId, dashboardUrl) {
272
+ export function reviewUrl(dashboardUrl, run) {
273
+ if (run.moduleOwned === true)
274
+ return `${dashboardUrl}/video?ad=${run._id}`;
275
+ if (run.workflowId)
276
+ return `${dashboardUrl}/workflows/${run.workflowId}/runs/${run._id}`;
277
+ return `${dashboardUrl}/runs/${run._id}`;
278
+ }
279
+ export function stopLines(stop, runId, runUrl) {
249
280
  if (stop.at === "storyboard-gate") {
250
281
  const lines = [
251
282
  "Parked: the storyboard is waiting for your yes.",
@@ -263,6 +294,8 @@ export function stopLines(stop, runId, dashboardUrl) {
263
294
  "Parked: every piece is made and the run is waiting for a cut.",
264
295
  `Pull the pieces: exodus video pull ${runId} --out ./ad-${runId}`,
265
296
  `Then upload: exodus video upload ${runId} --file cut.mp4`,
297
+ `Redo one clip: exodus video retry-clip ${runId} --scene <n>`,
298
+ `Watch it here: ${runUrl}`,
266
299
  ];
267
300
  }
268
301
  if (stop.at === "failed") {
@@ -270,7 +303,7 @@ export function stopLines(stop, runId, dashboardUrl) {
270
303
  return [
271
304
  `${stepName(stop.step)} failed${stop.error ? `: ${stop.error}` : "."}`,
272
305
  `Try that step again: exodus workflow repair ${runId} retry`,
273
- `Or open the run: ${dashboardUrl}/video?ad=${runId}`,
306
+ `Or open the run: ${runUrl}`,
274
307
  ];
275
308
  }
276
309
  return [
@@ -287,7 +320,7 @@ export function stopLines(stop, runId, dashboardUrl) {
287
320
  if (stop.at === "paused") {
288
321
  return [
289
322
  `Parked: this run is waiting on someone${stop.reason ? ` (${stop.reason})` : ""}.`,
290
- `Open it: ${dashboardUrl}/video?ad=${runId}`,
323
+ `Open it: ${runUrl}`,
291
324
  ];
292
325
  }
293
326
  return [`Working: ${stageWord(stop.stage)}.`];
@@ -343,7 +376,31 @@ function reservedCastFrames(items) {
343
376
  }
344
377
  return byIndex;
345
378
  }
346
- function planCastRefs(run, items) {
379
+ function castVoicePins(artifact) {
380
+ const pins = new Map();
381
+ let envelope = artifact?.storyboard;
382
+ if (envelope === undefined && typeof artifact?.storyboardJson === "string") {
383
+ try {
384
+ envelope = JSON.parse(artifact.storyboardJson);
385
+ }
386
+ catch {
387
+ return pins;
388
+ }
389
+ }
390
+ const cast = envelope?.cast;
391
+ if (!Array.isArray(cast))
392
+ return pins;
393
+ for (const member of cast) {
394
+ const characterId = typeof member.characterId === "string" ? member.characterId.trim() : "";
395
+ const voiceId = typeof member.voiceId === "string" ? member.voiceId.trim() : "";
396
+ if (!characterId || !voiceId)
397
+ continue;
398
+ const label = typeof member.voiceLabel === "string" ? member.voiceLabel.trim() : "";
399
+ pins.set(characterId, { voiceId, voiceLabel: label ? label : null });
400
+ }
401
+ return pins;
402
+ }
403
+ function planCastRefs(run, items, pins) {
347
404
  const ledger = reservedCastFrames(items);
348
405
  const ledgerRows = [...ledger.entries()].sort(([a], [b]) => a - b);
349
406
  const locked = run.castLock?.cast;
@@ -407,12 +464,15 @@ function planCastRefs(run, items) {
407
464
  const downloads = [];
408
465
  for (const row of rows) {
409
466
  const file = row.url ? `${row.stem}.${extFor(row.url, "image")}` : null;
467
+ const pin = row.characterId ? pins.get(row.characterId) : undefined;
410
468
  cast.push({
411
469
  characterId: row.characterId,
412
470
  name: row.name,
413
471
  file,
414
472
  status: row.status,
415
473
  error: row.error,
474
+ voiceId: pin?.voiceId ?? null,
475
+ voiceLabel: pin?.voiceLabel ?? null,
416
476
  });
417
477
  if (file && row.url)
418
478
  downloads.push({ file, url: row.url });
@@ -583,6 +643,11 @@ export function planPull(run, items, opts) {
583
643
  clip: clip?.download.file ?? null,
584
644
  words,
585
645
  wordsFrom,
646
+ wordsDescribe: wordsFrom === null
647
+ ? null
648
+ : clip?.revoiced === true && wordsFrom === "clip"
649
+ ? "original-performance"
650
+ : "this-file",
586
651
  voice: voice?.download.file ?? null,
587
652
  keyframe: keyframeByScene.get(sceneIndex)?.file ?? null,
588
653
  qc: clip?.qc ?? null,
@@ -595,7 +660,7 @@ export function planPull(run, items, opts) {
595
660
  keyframeFindings: frameItem?.findings?.map(withoutJudgeFields) ?? null,
596
661
  };
597
662
  });
598
- const { cast, downloads: castDownloads } = planCastRefs(run, items);
663
+ const { cast, downloads: castDownloads } = planCastRefs(run, items, castVoicePins(storyboardArtifact));
599
664
  const downloads = [
600
665
  ...(reference ? [reference] : []),
601
666
  ...keyframeByScene.values(),
@@ -611,7 +676,7 @@ export function planPull(run, items, opts) {
611
676
  manifest: {
612
677
  runId: run._id,
613
678
  pulledAt: opts.pulledAt,
614
- dashboardUrl: `${opts.dashboardUrl}/video?ad=${run._id}`,
679
+ dashboardUrl: reviewUrl(opts.dashboardUrl, run),
615
680
  storyboard,
616
681
  reference: reference?.file ?? null,
617
682
  music: music?.file ?? null,
@@ -653,6 +718,7 @@ export function markPullFailure(manifest, failure) {
653
718
  if (scene.words === failure.file || lostWordsSource) {
654
719
  scene.words = null;
655
720
  scene.wordsFrom = null;
721
+ scene.wordsDescribe = null;
656
722
  }
657
723
  }
658
724
  }
@@ -758,7 +824,7 @@ export async function startFlow(opts, deps) {
758
824
  return { code: 1, lines: ["The server started the ad but did not say which run it is."] };
759
825
  }
760
826
  const runId = started.runId;
761
- const url = started.url ?? `${deps.dashboardUrl}/video?ad=${runId}`;
827
+ const url = started.url ?? reviewUrl(deps.dashboardUrl, { _id: runId });
762
828
  if (!opts.wait) {
763
829
  const lines = [
764
830
  `Started ad run ${runId}`,
@@ -811,7 +877,7 @@ export async function waitFlow(runId, opts, deps) {
811
877
  }
812
878
  return {
813
879
  code: stop.at === "failed" ? 1 : 0,
814
- lines: [...lines, ...stopLines(stop, runId, deps.dashboardUrl)],
880
+ lines: [...lines, ...stopLines(stop, runId, reviewUrl(deps.dashboardUrl, run))],
815
881
  };
816
882
  }
817
883
  const timeoutLine = `Still running after ${Math.round((maxPolls * interval) / 60000)} minutes. Check in with: exodus video status ${runId}`;
@@ -845,13 +911,22 @@ export async function statusFlow(runId, json, deps) {
845
911
  const items = itemsRes.data.items ?? [];
846
912
  const stop = classifyRun(run);
847
913
  const hasFinal = items.some((i) => i.itemKind === "final" && i.status === "done");
914
+ const warnings = run.nodes
915
+ .filter((n) => n.warning)
916
+ .map((n) => ({ nodeId: n.nodeId, step: n.kind, warning: n.warning }));
848
917
  if (json) {
849
- return { code: 0, lines: [JSON.stringify({ runId, status: run.status, stop, items, hasFinal })] };
918
+ return {
919
+ code: 0,
920
+ lines: [JSON.stringify({ runId, status: run.status, stop, items, hasFinal, warnings })],
921
+ };
850
922
  }
851
923
  const revoicedByScene = new Set();
924
+ const timedOnOriginalVoice = new Set();
852
925
  for (const artifact of outputsOfNodeKind(run, "video")) {
853
926
  if (artifact.type === "video" && artifact.revoiced === true && typeof artifact.sceneIndex === "number") {
854
927
  revoicedByScene.add(artifact.sceneIndex);
928
+ if ((artifact.words?.length ?? 0) > 0)
929
+ timedOnOriginalVoice.add(artifact.sceneIndex);
855
930
  }
856
931
  }
857
932
  const byScene = new Map();
@@ -871,7 +946,11 @@ export async function statusFlow(runId, json, deps) {
871
946
  const headline = stop.at === "failed" && stop.repair
872
947
  ? `Ad run ${runId} — Stopped, a step failed`
873
948
  : `Ad run ${runId} — ${displayRunStatus(run.status)}`;
874
- const lines = [headline, ...stopLines(stop, runId, deps.dashboardUrl)];
949
+ const lines = [
950
+ headline,
951
+ ...stopLines(stop, runId, reviewUrl(deps.dashboardUrl, run)),
952
+ ...warnings.map((w) => `Heads-up (${w.step}): ${w.warning}`),
953
+ ];
875
954
  if (byScene.size === 0) {
876
955
  lines.push("", "No scenes yet — this run hasn't made anything to look at.");
877
956
  }
@@ -894,6 +973,9 @@ export async function statusFlow(runId, json, deps) {
894
973
  lines.push(` ${row.clip.error}`);
895
974
  if (revoicedByScene.has(sceneIndex))
896
975
  lines.push(" voice: cast voice applied");
976
+ if (timedOnOriginalVoice.has(sceneIndex)) {
977
+ lines.push(" word timings: measured on the original voice (close, not frame-exact)");
978
+ }
897
979
  for (const finding of row.frame?.findings ?? []) {
898
980
  lines.push(` picture: ${finding.code} (${finding.severity}): ${finding.detail}`);
899
981
  }
@@ -943,6 +1025,23 @@ export async function storyboardFlow(runId, json, deps) {
943
1025
  for (const scene of loose)
944
1026
  lines.push(...sceneCardLines(scene));
945
1027
  }
1028
+ const voiceRes = await deps
1029
+ .get(`${VOICES_PATH}?runId=${encodeURIComponent(runId)}`)
1030
+ .catch(() => null);
1031
+ if (voiceRes?.ok) {
1032
+ const sheet = (voiceRes.data ?? {});
1033
+ const cast = Array.isArray(sheet.cast) ? sheet.cast : [];
1034
+ const canChange = sheet.canChange !== false;
1035
+ if (canChange || cast.some((row) => row.voice !== null)) {
1036
+ const summary = voiceSummaryLines(cast, Array.isArray(sheet.notices) ? sheet.notices : []);
1037
+ if (summary.length > 0) {
1038
+ lines.push("", ...summary);
1039
+ if (canChange) {
1040
+ lines.push(`set voices with: exodus video voices ${runId} --set <character>=<voiceId>`);
1041
+ }
1042
+ }
1043
+ }
1044
+ }
946
1045
  lines.push("", `approve with: exodus video approve ${runId}`, `send it back: exodus video flag ${runId} --note "what's wrong"`);
947
1046
  if (cards.framesNodeId) {
948
1047
  lines.push(`redo one frame: exodus video retry-frame ${runId} --node ${cards.framesNodeId} --scene <n>`);
@@ -977,10 +1076,16 @@ export async function approveFlow(runId, json, deps) {
977
1076
  return errorResult(res, json);
978
1077
  if (json)
979
1078
  return { code: 0, lines: [JSON.stringify({ ok: true, runId, data: res.data })] };
1079
+ const voices = res.data
1080
+ ?.voices;
1081
+ const summary = voices
1082
+ ? voiceSummaryLines(Array.isArray(voices.cast) ? voices.cast : [], Array.isArray(voices.notices) ? voices.notices : [])
1083
+ : [];
980
1084
  return {
981
1085
  code: 0,
982
1086
  lines: [
983
1087
  "Approved.",
1088
+ ...(summary.length > 0 ? ["", ...summary, ""] : []),
984
1089
  `See what happens next: exodus video status ${runId}`,
985
1090
  ],
986
1091
  };
@@ -1037,6 +1142,260 @@ export async function retryFrameFlow(runId, nodeId, sceneIndex, note, json, deps
1037
1142
  ],
1038
1143
  };
1039
1144
  }
1145
+ const UPLOADED_CUT_WARNING = "The cut you already uploaded will not include the new clip. Pull the pieces again, " +
1146
+ "re-cut, and upload again.";
1147
+ export function planClipRedo(run, items, target) {
1148
+ const stop = classifyRun(run);
1149
+ const uploadedCut = items.some((i) => i.itemKind === "final" && i.status === "done");
1150
+ const rows = items.filter((i) => i.itemKind === "clip" &&
1151
+ i.sceneIndex === target.sceneIndex &&
1152
+ (target.nodeId === undefined || i.nodeId === target.nodeId));
1153
+ if (rows.length === 0) {
1154
+ return { ok: false, reason: `Scene ${target.sceneIndex} has no clip to redo.` };
1155
+ }
1156
+ const nodeIds = [...new Set(rows.map((r) => r.nodeId))];
1157
+ if (nodeIds.length > 1) {
1158
+ return {
1159
+ ok: false,
1160
+ reason: `Scene ${target.sceneIndex} has a clip on more than one step (${nodeIds.join(", ")}), ` +
1161
+ "so say which one: --node <nodeId>.",
1162
+ };
1163
+ }
1164
+ const row = rows[0];
1165
+ const node = run.nodes.find((n) => n.nodeId === row.nodeId);
1166
+ if (node?.status === "running") {
1167
+ return {
1168
+ ok: false,
1169
+ reason: `The "${row.nodeId}" step is still making other scenes. Try again once it has finished ` +
1170
+ `(exodus video status ${run._id}).`,
1171
+ };
1172
+ }
1173
+ if (node && node.status !== "done") {
1174
+ return {
1175
+ ok: false,
1176
+ reason: `The "${row.nodeId}" step did not finish (it is "${node.status}"), ` +
1177
+ "and one clip can only be redone on a step that finished.",
1178
+ };
1179
+ }
1180
+ if (row.status === "running") {
1181
+ return { ok: false, reason: `Scene ${target.sceneIndex} is already being redone.` };
1182
+ }
1183
+ const redoable = row.status === "failed" ||
1184
+ (row.status === "done" && (row.flagged === true || stop.at === "final-watch"));
1185
+ if (!redoable) {
1186
+ if (row.status === "done") {
1187
+ return {
1188
+ ok: false,
1189
+ reason: `Scene ${target.sceneIndex}'s clip is finished and nothing flagged it. A finished clip ` +
1190
+ "can only be redone while the run is waiting for the final cut.",
1191
+ };
1192
+ }
1193
+ const word = ITEM_STATUS_WORD[row.status] ?? row.status;
1194
+ return {
1195
+ ok: false,
1196
+ reason: `Scene ${target.sceneIndex}'s clip is ${word}, so there is nothing to redo yet.`,
1197
+ };
1198
+ }
1199
+ return {
1200
+ ok: true,
1201
+ nodeId: row.nodeId,
1202
+ sceneIndex: target.sceneIndex,
1203
+ attempt: row.attempt ?? 0,
1204
+ warnings: uploadedCut ? [UPLOADED_CUT_WARNING] : [],
1205
+ };
1206
+ }
1207
+ export async function retryClipFlow(runId, target, note, json, deps) {
1208
+ const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
1209
+ if (!runRes.ok)
1210
+ return errorResult(runRes, json);
1211
+ const run = asVideoRun(runRes.data);
1212
+ const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
1213
+ if (!itemsRes.ok)
1214
+ return errorResult(itemsRes, json);
1215
+ const items = itemsRes.data.items ?? [];
1216
+ const plan = planClipRedo(run, items, target);
1217
+ if (!plan.ok) {
1218
+ return {
1219
+ code: 1,
1220
+ lines: json ? [JSON.stringify({ ok: false, error: plan.reason })] : [plan.reason],
1221
+ };
1222
+ }
1223
+ const res = await deps.post(SCENE_RETRY_PATH, {
1224
+ runId,
1225
+ nodeId: plan.nodeId,
1226
+ sceneIndex: plan.sceneIndex,
1227
+ ...(note ? { note } : {}),
1228
+ });
1229
+ if (!res.ok)
1230
+ return errorResult(res, json);
1231
+ const triggerRunId = res.data.triggerRunId;
1232
+ const review = reviewUrl(deps.dashboardUrl, run);
1233
+ if (json) {
1234
+ return {
1235
+ code: 0,
1236
+ lines: [
1237
+ JSON.stringify({
1238
+ ok: true,
1239
+ runId,
1240
+ nodeId: plan.nodeId,
1241
+ sceneIndex: plan.sceneIndex,
1242
+ triggerRunId,
1243
+ attemptBefore: plan.attempt,
1244
+ reviewUrl: review,
1245
+ warnings: plan.warnings,
1246
+ ...(note ? { note } : {}),
1247
+ }),
1248
+ ],
1249
+ };
1250
+ }
1251
+ return {
1252
+ code: 0,
1253
+ lines: [
1254
+ `Redoing scene ${plan.sceneIndex}'s clip on ${plan.nodeId}.`,
1255
+ `triggerRunId: ${triggerRunId ?? "-"}`,
1256
+ "Every other scene stays as it is, and so do the pictures, the voices and the script.",
1257
+ ...(note
1258
+ ? ["Your note steers how this clip moves. It never changes the words that are spoken."]
1259
+ : []),
1260
+ `Watch it land: exodus video status ${runId}`,
1261
+ `Review the run: ${review}`,
1262
+ ...plan.warnings,
1263
+ ],
1264
+ };
1265
+ }
1266
+ export function parseVoiceFlags(argv, readFile) {
1267
+ const fromFile = {};
1268
+ const typed = {};
1269
+ let asked = false;
1270
+ const valueFor = (i, flag) => {
1271
+ const arg = argv[i];
1272
+ if (arg.startsWith(`${flag}=`))
1273
+ return [arg.slice(flag.length + 1), i];
1274
+ const next = argv[i + 1];
1275
+ if (next === undefined)
1276
+ throw new Error(`${flag} needs a value.`);
1277
+ return [next, i + 1];
1278
+ };
1279
+ for (let i = 0; i < argv.length; i++) {
1280
+ const arg = argv[i];
1281
+ if (arg === "--set" || arg.startsWith("--set=")) {
1282
+ const [raw, next] = valueFor(i, "--set");
1283
+ i = next;
1284
+ const eq = raw.lastIndexOf("=");
1285
+ if (eq <= 0 || eq === raw.length - 1) {
1286
+ throw new Error("--set must look like <character>=<voice id>, for example " +
1287
+ `--set C1=abc123voiceid (got "${raw}").`);
1288
+ }
1289
+ typed[raw.slice(0, eq).trim()] = raw.slice(eq + 1).trim();
1290
+ asked = true;
1291
+ }
1292
+ else if (arg === "--clear" || arg.startsWith("--clear=")) {
1293
+ const [raw, next] = valueFor(i, "--clear");
1294
+ i = next;
1295
+ if (!raw.trim())
1296
+ throw new Error("--clear needs a character, for example --clear C1.");
1297
+ typed[raw.trim()] = null;
1298
+ asked = true;
1299
+ }
1300
+ else if (arg === "--from" || arg.startsWith("--from=")) {
1301
+ const [file, next] = valueFor(i, "--from");
1302
+ i = next;
1303
+ let parsed;
1304
+ try {
1305
+ parsed = JSON.parse(readFile(file));
1306
+ }
1307
+ catch {
1308
+ throw new Error(`Could not read ${file} as a list of characters and voice IDs.`);
1309
+ }
1310
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1311
+ throw new Error(`Could not read ${file} as a list of characters and voice IDs.`);
1312
+ }
1313
+ Object.assign(fromFile, parsed);
1314
+ asked = true;
1315
+ }
1316
+ }
1317
+ if (!asked)
1318
+ return null;
1319
+ const sameName = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
1320
+ const merged = {};
1321
+ for (const [who, choice] of Object.entries(fromFile)) {
1322
+ if (!Object.keys(typed).some((t) => sameName(t, who)))
1323
+ merged[who] = choice;
1324
+ }
1325
+ return { ...merged, ...typed };
1326
+ }
1327
+ export async function voicesFlow(runId, voices, json, deps) {
1328
+ const res = voices
1329
+ ? await deps.post(VOICES_PATH, { runId, voices })
1330
+ : await deps.get(`${VOICES_PATH}?runId=${encodeURIComponent(runId)}`);
1331
+ if (!res.ok)
1332
+ return errorResult(res, json);
1333
+ const sheet = res.data;
1334
+ if (json)
1335
+ return { code: 0, lines: [JSON.stringify({ ok: true, ...sheet })] };
1336
+ return { code: 0, lines: voiceSheetLines(sheet) };
1337
+ }
1338
+ function availabilityWords(row) {
1339
+ if (!row.voice)
1340
+ return "no voice yet";
1341
+ switch (row.availability?.state) {
1342
+ case "available":
1343
+ return "ElevenLabs has it";
1344
+ case "missing":
1345
+ return "ElevenLabs does not have it";
1346
+ case "not-checked":
1347
+ return "not checked just now";
1348
+ default:
1349
+ return "not checked";
1350
+ }
1351
+ }
1352
+ function voiceWords(voice) {
1353
+ if (!voice)
1354
+ return "—";
1355
+ return voice.label ? `${voice.label} (${voice.voiceId})` : voice.voiceId;
1356
+ }
1357
+ function voiceSummaryLines(cast, notices) {
1358
+ const rows = cast.filter((row) => row.spokenScenes > 0 || row.voice !== null);
1359
+ const width = Math.max(0, ...rows.map((row) => row.name.length));
1360
+ const lines = rows.length > 0 ? ["Voices"] : [];
1361
+ for (const row of rows) {
1362
+ lines.push(row.voice
1363
+ ? ` ${row.name.padEnd(width)} → ${voiceWords(row.voice)} ${availabilityWords(row)}`
1364
+ : ` ${row.name.padEnd(width)} → no voice chosen (keeps the generated voice)`);
1365
+ }
1366
+ for (const notice of notices)
1367
+ lines.push(`Heads-up: ${notice}`);
1368
+ return lines;
1369
+ }
1370
+ export function voiceSheetLines(sheet) {
1371
+ const lines = [`Voices for run ${sheet.runId}`];
1372
+ if (sheet.cast.length === 0)
1373
+ lines.push(" Nobody is in this ad yet.");
1374
+ for (const row of sheet.cast) {
1375
+ const voice = voiceWords(row.voice);
1376
+ lines.push(` ${row.characterId} ${row.name} ${voice} ${availabilityWords(row)} ` +
1377
+ `speaks in ${row.spokenScenes} scenes, about ${row.spokenSeconds}s`);
1378
+ }
1379
+ if (sheet.narrator) {
1380
+ const label = sheet.narrator.label ? `${sheet.narrator.label} ` : "";
1381
+ lines.push(` Narrator ${label}(${sheet.narrator.voiceId}) set on the workflow, not here`);
1382
+ }
1383
+ if (sheet.changed) {
1384
+ lines.push(sheet.changed.length > 0
1385
+ ? `Changed: ${sheet.changed.join(", ")}. Pictures and script were not touched.`
1386
+ : "Nothing changed — those voices were already set. Pictures and script were not touched.");
1387
+ }
1388
+ lines.push(`How voices are applied: ${sheet.treatment.summary}${sheet.treatment.speedChange ? "" : " No speed change."}`);
1389
+ lines.push(`Cost: billed to your own ElevenLabs key. ${sheet.treatment.costNote} ` +
1390
+ `About ${sheet.treatment.usage.seconds} seconds of speech across ` +
1391
+ `${sheet.treatment.usage.clips} clips will be converted.`);
1392
+ for (const notice of sheet.notices)
1393
+ lines.push(notice);
1394
+ lines.push(sheet.canChange
1395
+ ? "You can still change voices until you approve the storyboard."
1396
+ : (sheet.whyNot ?? "Voices can no longer be changed on this run."));
1397
+ return lines;
1398
+ }
1040
1399
  export async function pullFlow(runId, dir, json, deps) {
1041
1400
  const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
1042
1401
  if (!runRes.ok)
@@ -1317,7 +1676,7 @@ export async function uploadFlow(runId, filePath, durationFlag, json, deps) {
1317
1676
  if (!attached.ok)
1318
1677
  return errorResult(attached, json);
1319
1678
  const final = attached.data;
1320
- const finalWatchUrl = final.finalWatchUrl ?? `${deps.dashboardUrl}/video?ad=${runId}`;
1679
+ const finalWatchUrl = final.finalWatchUrl ?? reviewUrl(deps.dashboardUrl, { _id: runId });
1321
1680
  if (json) {
1322
1681
  return {
1323
1682
  code: 0,
@@ -1343,6 +1702,9 @@ const VALUE_FLAGS = new Set([
1343
1702
  "duration",
1344
1703
  "node",
1345
1704
  "scene",
1705
+ "set",
1706
+ "clear",
1707
+ "from",
1346
1708
  ]);
1347
1709
  export function parsePositional(args = process.argv.slice(3)) {
1348
1710
  const out = [];
@@ -1402,7 +1764,17 @@ export async function run(flags) {
1402
1764
  }, defaultDeps));
1403
1765
  }
1404
1766
  const runId = rest[0];
1405
- const needsRunId = ["status", "storyboard", "approve", "flag", "retry-frame", "pull", "upload"];
1767
+ const needsRunId = [
1768
+ "status",
1769
+ "storyboard",
1770
+ "approve",
1771
+ "flag",
1772
+ "retry-frame",
1773
+ "retry-clip",
1774
+ "voices",
1775
+ "pull",
1776
+ "upload",
1777
+ ];
1406
1778
  if (needsRunId.includes(sub) && !runId) {
1407
1779
  usage(`video ${sub} needs a run id: exodus video ${sub} <runId>`);
1408
1780
  }
@@ -1432,6 +1804,27 @@ export async function run(flags) {
1432
1804
  }
1433
1805
  return printResult(await retryFrameFlow(runId, nodeId, sceneIndex, flagString(flags, "note"), json, defaultDeps));
1434
1806
  }
1807
+ if (sub === "retry-clip") {
1808
+ const sceneRaw = flagString(flags, "scene");
1809
+ if (sceneRaw === undefined) {
1810
+ usage("video retry-clip needs --scene <n>, the scene whose clip to redo.");
1811
+ }
1812
+ const sceneIndex = Number(sceneRaw);
1813
+ if (!Number.isInteger(sceneIndex)) {
1814
+ usage(`video retry-clip --scene must be a whole scene number, not "${sceneRaw}".`);
1815
+ }
1816
+ return printResult(await retryClipFlow(runId, { sceneIndex, nodeId: flagString(flags, "node") }, flagString(flags, "note"), json, defaultDeps));
1817
+ }
1818
+ if (sub === "voices") {
1819
+ let voices;
1820
+ try {
1821
+ voices = parseVoiceFlags(process.argv.slice(3), defaultDeps.readFile);
1822
+ }
1823
+ catch (err) {
1824
+ usage(err.message);
1825
+ }
1826
+ return printResult(await voicesFlow(runId, voices, json, defaultDeps));
1827
+ }
1435
1828
  if (sub === "pull") {
1436
1829
  const dir = flagString(flags, "out");
1437
1830
  if (!dir)
@@ -3,6 +3,7 @@ import { type PollOptions, type PollResult } from "../lib/poll.js";
3
3
  import { LEGACY_WORKFLOW_RUN_STATUS_VALUES, type RunStatus } from "../lib/runStatus.js";
4
4
  import { type RunDeliverySummary } from "../lib/runVerdict.js";
5
5
  import { type Channel } from "../lib/channel.js";
6
+ import { type VoiceMap } from "./video.js";
6
7
  export declare const helpText: string;
7
8
  export type WorkflowNodeKind = "brief" | "brief-form" | "asset" | "bot" | "primer" | "image" | "image-rig" | "rig" | "storyboard" | "reference" | "scene-frames" | "video" | "voiceover" | "output" | "call" | "show-set" | "show-cast" | "show-voices" | "product-truth" | "transform" | "formatter" | "splitter" | "collector" | "checkpoint" | "prompt";
8
9
  export type WorkflowSlotState = "locked" | "auto" | "ask" | "inferred";
@@ -407,6 +408,7 @@ interface RunFlowOptions {
407
408
  fill?: string;
408
409
  autoApprove?: boolean;
409
410
  imageRigOverrides?: Record<string, unknown>;
411
+ voices?: VoiceMap;
410
412
  wait: boolean;
411
413
  json: boolean;
412
414
  out?: string;
@@ -427,6 +429,7 @@ export declare function serverWarningsToPrint(serverWarnings: unknown, alreadyPr
427
429
  export declare function rejectTerminalFlag(args: string[]): void;
428
430
  export declare function parseAutoApproveFlag(args: string[]): boolean;
429
431
  export declare function parseRigOverridesFlag(args: string[], readFile?: (path: string) => string): Record<string, unknown> | undefined;
432
+ export declare function parseVoicesFlag(args: string[], readFile?: (path: string) => string): VoiceMap | undefined;
430
433
  export declare function parseFillFlag(args: string[]): string | undefined;
431
434
  export declare function formatWorkflowList(workflows: WorkflowListItem[]): string;
432
435
  export declare function formatRecentRuns(runs: WorkflowRunProjection[]): string;