@aicopycoders/exodus 2026.9.1800-beta.0 → 2026.9.1900-beta.1

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.
@@ -40,6 +40,16 @@ The whole loop, in order:
40
40
  7. exodus video pull <runId> --out ./ad
41
41
  Writes every piece to that folder plus a manifest.json index.
42
42
 
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
+ exodus video revoice <runId> --all
48
+ Redo only the VOICE on clips that kept the video model's own voice
49
+ (for example, the run had no ElevenLabs key when they were made). The
50
+ picture stays, no new video is made, and a clip whose voice pass
51
+ cannot finish is kept as it was. Use --scene <n> for one clip.
52
+
43
53
  8. Make your cut from those files.
44
54
 
45
55
  9. exodus video upload <runId> --file cut.mp4
@@ -56,6 +66,8 @@ Usage:
56
66
  exodus video approve <runId> [--json]
57
67
  exodus video flag <runId> --note "<what is wrong>" [--json]
58
68
  exodus video retry-frame <runId> --node <nodeId> --scene <n> [--note "..."] [--json]
69
+ exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "..."] [--json]
70
+ exodus video revoice <runId> (--scene <n> | --all) [--node <nodeId>] [--json]
59
71
  exodus video voices <runId> [--set <character>=<voiceId>] [--clear <character>] [--from <file.json>] [--json]
60
72
  exodus video pull <runId> --out <dir> [--json]
61
73
  exodus video upload <runId> --file <cut.mp4> [--duration <sec>] [--json]
@@ -72,9 +84,14 @@ Options:
72
84
  --duration <sec> How long your cut is, in seconds. Only needed when the
73
85
  length can't be read off the file itself
74
86
  --note "<text>" What is wrong with the storyboard (flag), or how to
75
- steer one frame redo (retry-frame)
76
- --node <nodeId> Which scene-frames node holds the still (retry-frame)
77
- --scene <n> Which scene's frame to redo. One scene only (retry-frame)
87
+ steer one redo (retry-frame, retry-clip)
88
+ --node <nodeId> Which scene-frames node holds the still (retry-frame).
89
+ Which video step holds the clip, when a scene has one on
90
+ more than one step (retry-clip, revoice)
91
+ --scene <n> Which scene to redo. One scene only, a whole number
92
+ (retry-frame, retry-clip, revoice)
93
+ --all Every finished clip that kept the video model's voice
94
+ (revoice)
78
95
  --set <who>=<id> Give a character an ElevenLabs voice (voices). Name the
79
96
  character by its ID or by the name your script uses.
80
97
  Repeat it once per character
@@ -93,6 +110,8 @@ Examples:
93
110
  exodus video storyboard run_123
94
111
  exodus video approve run_123
95
112
  exodus video retry-frame run_123 --node frames-1 --scene 2
113
+ exodus video retry-clip run_123 --scene 3 --note "keep the handshake in frame"
114
+ exodus video revoice run_123 --all
96
115
  exodus video voices run_123
97
116
  exodus video voices run_123 --set C1=abc123voiceid --set "HOST 2=def456voiceid"
98
117
  exodus video voices run_123 --from voices.json
@@ -107,6 +126,9 @@ const STORYBOARD_PATH = "/api/v2/video/storyboard";
107
126
  const FLAG_PATH = "/api/v2/video/storyboard/flag";
108
127
  const APPROVE_PATH = "/api/v2/workflow/approve";
109
128
  const SCENE_RETRY_PATH = "/api/v2/workflow/scene/retry";
129
+ const SCENE_REVOICE_PATH = "/api/v2/workflow/scene/revoice";
130
+ const REVOICE_NOT_ON_THIS_SERVER = "This Exodus server does not have the voice redo yet, so nothing was started and nothing " +
131
+ "was spent. It arrives with the next server update.";
110
132
  const FINAL_PATH = "/api/v2/video/final";
111
133
  export const VOICES_PATH = "/api/v2/video/voices";
112
134
  const ASSET_UPLOAD_URL_PATH = "/api/v2/workflows/asset-upload-url";
@@ -260,7 +282,14 @@ export function stepName(kind) {
260
282
  return "A step in this run";
261
283
  return STEP_NAMES[kind] ?? `The ${kind} step`;
262
284
  }
263
- export function stopLines(stop, runId, dashboardUrl) {
285
+ export function reviewUrl(dashboardUrl, run) {
286
+ if (run.moduleOwned === true)
287
+ return `${dashboardUrl}/video?ad=${run._id}`;
288
+ if (run.workflowId)
289
+ return `${dashboardUrl}/workflows/${run.workflowId}/runs/${run._id}`;
290
+ return `${dashboardUrl}/runs/${run._id}`;
291
+ }
292
+ export function stopLines(stop, runId, runUrl) {
264
293
  if (stop.at === "storyboard-gate") {
265
294
  const lines = [
266
295
  "Parked: the storyboard is waiting for your yes.",
@@ -278,6 +307,8 @@ export function stopLines(stop, runId, dashboardUrl) {
278
307
  "Parked: every piece is made and the run is waiting for a cut.",
279
308
  `Pull the pieces: exodus video pull ${runId} --out ./ad-${runId}`,
280
309
  `Then upload: exodus video upload ${runId} --file cut.mp4`,
310
+ `Redo one clip: exodus video retry-clip ${runId} --scene <n>`,
311
+ `Watch it here: ${runUrl}`,
281
312
  ];
282
313
  }
283
314
  if (stop.at === "failed") {
@@ -285,7 +316,7 @@ export function stopLines(stop, runId, dashboardUrl) {
285
316
  return [
286
317
  `${stepName(stop.step)} failed${stop.error ? `: ${stop.error}` : "."}`,
287
318
  `Try that step again: exodus workflow repair ${runId} retry`,
288
- `Or open the run: ${dashboardUrl}/video?ad=${runId}`,
319
+ `Or open the run: ${runUrl}`,
289
320
  ];
290
321
  }
291
322
  return [
@@ -302,7 +333,7 @@ export function stopLines(stop, runId, dashboardUrl) {
302
333
  if (stop.at === "paused") {
303
334
  return [
304
335
  `Parked: this run is waiting on someone${stop.reason ? ` (${stop.reason})` : ""}.`,
305
- `Open it: ${dashboardUrl}/video?ad=${runId}`,
336
+ `Open it: ${runUrl}`,
306
337
  ];
307
338
  }
308
339
  return [`Working: ${stageWord(stop.stage)}.`];
@@ -341,6 +372,7 @@ function clipFromArtifact(sceneIndex, artifact) {
341
372
  words: artifact.words ?? null,
342
373
  revoiced: artifact.revoiced === true,
343
374
  speechTrimmed: artifact.speechTrimmed === true,
375
+ rawStorageId: artifact.rawStorageId ?? null,
344
376
  };
345
377
  }
346
378
  function keyframeDownload(sceneIndex, imageUrl) {
@@ -358,7 +390,31 @@ function reservedCastFrames(items) {
358
390
  }
359
391
  return byIndex;
360
392
  }
361
- function planCastRefs(run, items) {
393
+ function castVoicePins(artifact) {
394
+ const pins = new Map();
395
+ let envelope = artifact?.storyboard;
396
+ if (envelope === undefined && typeof artifact?.storyboardJson === "string") {
397
+ try {
398
+ envelope = JSON.parse(artifact.storyboardJson);
399
+ }
400
+ catch {
401
+ return pins;
402
+ }
403
+ }
404
+ const cast = envelope?.cast;
405
+ if (!Array.isArray(cast))
406
+ return pins;
407
+ for (const member of cast) {
408
+ const characterId = typeof member.characterId === "string" ? member.characterId.trim() : "";
409
+ const voiceId = typeof member.voiceId === "string" ? member.voiceId.trim() : "";
410
+ if (!characterId || !voiceId)
411
+ continue;
412
+ const label = typeof member.voiceLabel === "string" ? member.voiceLabel.trim() : "";
413
+ pins.set(characterId, { voiceId, voiceLabel: label ? label : null });
414
+ }
415
+ return pins;
416
+ }
417
+ function planCastRefs(run, items, pins) {
362
418
  const ledger = reservedCastFrames(items);
363
419
  const ledgerRows = [...ledger.entries()].sort(([a], [b]) => a - b);
364
420
  const locked = run.castLock?.cast;
@@ -422,12 +478,15 @@ function planCastRefs(run, items) {
422
478
  const downloads = [];
423
479
  for (const row of rows) {
424
480
  const file = row.url ? `${row.stem}.${extFor(row.url, "image")}` : null;
481
+ const pin = row.characterId ? pins.get(row.characterId) : undefined;
425
482
  cast.push({
426
483
  characterId: row.characterId,
427
484
  name: row.name,
428
485
  file,
429
486
  status: row.status,
430
487
  error: row.error,
488
+ voiceId: pin?.voiceId ?? null,
489
+ voiceLabel: pin?.voiceLabel ?? null,
431
490
  });
432
491
  if (file && row.url)
433
492
  downloads.push({ file, url: row.url });
@@ -598,11 +657,17 @@ export function planPull(run, items, opts) {
598
657
  clip: clip?.download.file ?? null,
599
658
  words,
600
659
  wordsFrom,
660
+ wordsDescribe: wordsFrom === null
661
+ ? null
662
+ : clip?.revoiced === true && wordsFrom === "clip"
663
+ ? "original-performance"
664
+ : "this-file",
601
665
  voice: voice?.download.file ?? null,
602
666
  keyframe: keyframeByScene.get(sceneIndex)?.file ?? null,
603
667
  qc: clip?.qc ?? null,
604
668
  revoiced: clip ? clip.revoiced : null,
605
669
  speechTrimmed: clip ? clip.speechTrimmed : null,
670
+ rawStorageId: clip ? clip.rawStorageId : null,
606
671
  clipStatus: item?.status ?? "missing",
607
672
  error: item?.error ?? null,
608
673
  flagged: item?.flagged === true,
@@ -610,7 +675,7 @@ export function planPull(run, items, opts) {
610
675
  keyframeFindings: frameItem?.findings?.map(withoutJudgeFields) ?? null,
611
676
  };
612
677
  });
613
- const { cast, downloads: castDownloads } = planCastRefs(run, items);
678
+ const { cast, downloads: castDownloads } = planCastRefs(run, items, castVoicePins(storyboardArtifact));
614
679
  const downloads = [
615
680
  ...(reference ? [reference] : []),
616
681
  ...keyframeByScene.values(),
@@ -626,7 +691,7 @@ export function planPull(run, items, opts) {
626
691
  manifest: {
627
692
  runId: run._id,
628
693
  pulledAt: opts.pulledAt,
629
- dashboardUrl: `${opts.dashboardUrl}/video?ad=${run._id}`,
694
+ dashboardUrl: reviewUrl(opts.dashboardUrl, run),
630
695
  storyboard,
631
696
  reference: reference?.file ?? null,
632
697
  music: music?.file ?? null,
@@ -660,6 +725,7 @@ export function markPullFailure(manifest, failure) {
660
725
  scene.clip = null;
661
726
  scene.revoiced = null;
662
727
  scene.speechTrimmed = null;
728
+ scene.rawStorageId = null;
663
729
  }
664
730
  if (scene.voice === failure.file)
665
731
  scene.voice = null;
@@ -668,6 +734,7 @@ export function markPullFailure(manifest, failure) {
668
734
  if (scene.words === failure.file || lostWordsSource) {
669
735
  scene.words = null;
670
736
  scene.wordsFrom = null;
737
+ scene.wordsDescribe = null;
671
738
  }
672
739
  }
673
740
  }
@@ -773,7 +840,7 @@ export async function startFlow(opts, deps) {
773
840
  return { code: 1, lines: ["The server started the ad but did not say which run it is."] };
774
841
  }
775
842
  const runId = started.runId;
776
- const url = started.url ?? `${deps.dashboardUrl}/video?ad=${runId}`;
843
+ const url = started.url ?? reviewUrl(deps.dashboardUrl, { _id: runId });
777
844
  if (!opts.wait) {
778
845
  const lines = [
779
846
  `Started ad run ${runId}`,
@@ -826,7 +893,7 @@ export async function waitFlow(runId, opts, deps) {
826
893
  }
827
894
  return {
828
895
  code: stop.at === "failed" ? 1 : 0,
829
- lines: [...lines, ...stopLines(stop, runId, deps.dashboardUrl)],
896
+ lines: [...lines, ...stopLines(stop, runId, reviewUrl(deps.dashboardUrl, run))],
830
897
  };
831
898
  }
832
899
  const timeoutLine = `Still running after ${Math.round((maxPolls * interval) / 60000)} minutes. Check in with: exodus video status ${runId}`;
@@ -860,13 +927,22 @@ export async function statusFlow(runId, json, deps) {
860
927
  const items = itemsRes.data.items ?? [];
861
928
  const stop = classifyRun(run);
862
929
  const hasFinal = items.some((i) => i.itemKind === "final" && i.status === "done");
930
+ const warnings = run.nodes
931
+ .filter((n) => n.warning)
932
+ .map((n) => ({ nodeId: n.nodeId, step: n.kind, warning: n.warning }));
863
933
  if (json) {
864
- return { code: 0, lines: [JSON.stringify({ runId, status: run.status, stop, items, hasFinal })] };
934
+ return {
935
+ code: 0,
936
+ lines: [JSON.stringify({ runId, status: run.status, stop, items, hasFinal, warnings })],
937
+ };
865
938
  }
866
939
  const revoicedByScene = new Set();
940
+ const timedOnOriginalVoice = new Set();
867
941
  for (const artifact of outputsOfNodeKind(run, "video")) {
868
942
  if (artifact.type === "video" && artifact.revoiced === true && typeof artifact.sceneIndex === "number") {
869
943
  revoicedByScene.add(artifact.sceneIndex);
944
+ if ((artifact.words?.length ?? 0) > 0)
945
+ timedOnOriginalVoice.add(artifact.sceneIndex);
870
946
  }
871
947
  }
872
948
  const byScene = new Map();
@@ -886,7 +962,11 @@ export async function statusFlow(runId, json, deps) {
886
962
  const headline = stop.at === "failed" && stop.repair
887
963
  ? `Ad run ${runId} — Stopped, a step failed`
888
964
  : `Ad run ${runId} — ${displayRunStatus(run.status)}`;
889
- const lines = [headline, ...stopLines(stop, runId, deps.dashboardUrl)];
965
+ const lines = [
966
+ headline,
967
+ ...stopLines(stop, runId, reviewUrl(deps.dashboardUrl, run)),
968
+ ...warnings.map((w) => `Heads-up (${w.step}): ${w.warning}`),
969
+ ];
890
970
  if (byScene.size === 0) {
891
971
  lines.push("", "No scenes yet — this run hasn't made anything to look at.");
892
972
  }
@@ -909,6 +989,9 @@ export async function statusFlow(runId, json, deps) {
909
989
  lines.push(` ${row.clip.error}`);
910
990
  if (revoicedByScene.has(sceneIndex))
911
991
  lines.push(" voice: cast voice applied");
992
+ if (timedOnOriginalVoice.has(sceneIndex)) {
993
+ lines.push(" word timings: measured on the original voice (close, not frame-exact)");
994
+ }
912
995
  for (const finding of row.frame?.findings ?? []) {
913
996
  lines.push(` picture: ${finding.code} (${finding.severity}): ${finding.detail}`);
914
997
  }
@@ -958,6 +1041,23 @@ export async function storyboardFlow(runId, json, deps) {
958
1041
  for (const scene of loose)
959
1042
  lines.push(...sceneCardLines(scene));
960
1043
  }
1044
+ const voiceRes = await deps
1045
+ .get(`${VOICES_PATH}?runId=${encodeURIComponent(runId)}`)
1046
+ .catch(() => null);
1047
+ if (voiceRes?.ok) {
1048
+ const sheet = (voiceRes.data ?? {});
1049
+ const cast = Array.isArray(sheet.cast) ? sheet.cast : [];
1050
+ const canChange = sheet.canChange !== false;
1051
+ if (canChange || cast.some((row) => row.voice !== null)) {
1052
+ const summary = voiceSummaryLines(cast, Array.isArray(sheet.notices) ? sheet.notices : []);
1053
+ if (summary.length > 0) {
1054
+ lines.push("", ...summary);
1055
+ if (canChange) {
1056
+ lines.push(`set voices with: exodus video voices ${runId} --set <character>=<voiceId>`);
1057
+ }
1058
+ }
1059
+ }
1060
+ }
961
1061
  lines.push("", `approve with: exodus video approve ${runId}`, `send it back: exodus video flag ${runId} --note "what's wrong"`);
962
1062
  if (cards.framesNodeId) {
963
1063
  lines.push(`redo one frame: exodus video retry-frame ${runId} --node ${cards.framesNodeId} --scene <n>`);
@@ -992,10 +1092,16 @@ export async function approveFlow(runId, json, deps) {
992
1092
  return errorResult(res, json);
993
1093
  if (json)
994
1094
  return { code: 0, lines: [JSON.stringify({ ok: true, runId, data: res.data })] };
1095
+ const voices = res.data
1096
+ ?.voices;
1097
+ const summary = voices
1098
+ ? voiceSummaryLines(Array.isArray(voices.cast) ? voices.cast : [], Array.isArray(voices.notices) ? voices.notices : [])
1099
+ : [];
995
1100
  return {
996
1101
  code: 0,
997
1102
  lines: [
998
1103
  "Approved.",
1104
+ ...(summary.length > 0 ? ["", ...summary, ""] : []),
999
1105
  `See what happens next: exodus video status ${runId}`,
1000
1106
  ],
1001
1107
  };
@@ -1052,24 +1158,294 @@ export async function retryFrameFlow(runId, nodeId, sceneIndex, note, json, deps
1052
1158
  ],
1053
1159
  };
1054
1160
  }
1055
- export function parseVoiceFlags(argv, readFile) {
1161
+ const UPLOADED_CUT_WARNING = "The cut you already uploaded will not include the new clip. Pull the pieces again, " +
1162
+ "re-cut, and upload again.";
1163
+ export function planClipRedo(run, items, target) {
1164
+ const stop = classifyRun(run);
1165
+ const found = findClipRow(run, items, target);
1166
+ if ("reason" in found)
1167
+ return { ok: false, reason: found.reason };
1168
+ const { row, uploadedCut } = found;
1169
+ const redoable = row.status === "failed" ||
1170
+ (row.status === "done" && (row.flagged === true || stop.at === "final-watch"));
1171
+ if (!redoable) {
1172
+ if (row.status === "done") {
1173
+ return {
1174
+ ok: false,
1175
+ reason: `Scene ${target.sceneIndex}'s clip is finished and nothing flagged it. A finished clip ` +
1176
+ "can only be redone while the run is waiting for the final cut.",
1177
+ };
1178
+ }
1179
+ const word = ITEM_STATUS_WORD[row.status] ?? row.status;
1180
+ return {
1181
+ ok: false,
1182
+ reason: `Scene ${target.sceneIndex}'s clip is ${word}, so there is nothing to redo yet.`,
1183
+ };
1184
+ }
1185
+ return {
1186
+ ok: true,
1187
+ nodeId: row.nodeId,
1188
+ sceneIndex: target.sceneIndex,
1189
+ attempt: row.attempt ?? 0,
1190
+ warnings: uploadedCut ? [UPLOADED_CUT_WARNING] : [],
1191
+ };
1192
+ }
1193
+ function findClipRow(run, items, target) {
1194
+ const uploadedCut = items.some((i) => i.itemKind === "final" && i.status === "done");
1195
+ const rows = items.filter((i) => i.itemKind === "clip" &&
1196
+ i.sceneIndex === target.sceneIndex &&
1197
+ (target.nodeId === undefined || i.nodeId === target.nodeId));
1198
+ if (rows.length === 0) {
1199
+ return { reason: `Scene ${target.sceneIndex} has no clip to redo.` };
1200
+ }
1201
+ const nodeIds = [...new Set(rows.map((r) => r.nodeId))];
1202
+ if (nodeIds.length > 1) {
1203
+ return {
1204
+ reason: `Scene ${target.sceneIndex} has a clip on more than one step (${nodeIds.join(", ")}), ` +
1205
+ "so say which one: --node <nodeId>.",
1206
+ };
1207
+ }
1208
+ const row = rows[0];
1209
+ const node = run.nodes.find((n) => n.nodeId === row.nodeId);
1210
+ if (node?.status === "running") {
1211
+ return {
1212
+ reason: `The "${row.nodeId}" step is still making other scenes. Try again once it has finished ` +
1213
+ `(exodus video status ${run._id}).`,
1214
+ };
1215
+ }
1216
+ if (node && node.status !== "done") {
1217
+ return {
1218
+ reason: `The "${row.nodeId}" step did not finish (it is "${node.status}"), ` +
1219
+ "and one clip can only be redone on a step that finished.",
1220
+ };
1221
+ }
1222
+ if (row.status === "running") {
1223
+ return { reason: `Scene ${target.sceneIndex} is already being redone.` };
1224
+ }
1225
+ return { row, uploadedCut };
1226
+ }
1227
+ export async function retryClipFlow(runId, target, note, json, deps) {
1228
+ const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
1229
+ if (!runRes.ok)
1230
+ return errorResult(runRes, json);
1231
+ const run = asVideoRun(runRes.data);
1232
+ const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
1233
+ if (!itemsRes.ok)
1234
+ return errorResult(itemsRes, json);
1235
+ const items = itemsRes.data.items ?? [];
1236
+ const plan = planClipRedo(run, items, target);
1237
+ if (!plan.ok) {
1238
+ return {
1239
+ code: 1,
1240
+ lines: json ? [JSON.stringify({ ok: false, error: plan.reason })] : [plan.reason],
1241
+ };
1242
+ }
1243
+ const res = await deps.post(SCENE_RETRY_PATH, {
1244
+ runId,
1245
+ nodeId: plan.nodeId,
1246
+ sceneIndex: plan.sceneIndex,
1247
+ ...(note ? { note } : {}),
1248
+ });
1249
+ if (!res.ok)
1250
+ return errorResult(res, json);
1251
+ const triggerRunId = res.data.triggerRunId;
1252
+ const review = reviewUrl(deps.dashboardUrl, run);
1253
+ if (json) {
1254
+ return {
1255
+ code: 0,
1256
+ lines: [
1257
+ JSON.stringify({
1258
+ ok: true,
1259
+ runId,
1260
+ nodeId: plan.nodeId,
1261
+ sceneIndex: plan.sceneIndex,
1262
+ triggerRunId,
1263
+ attemptBefore: plan.attempt,
1264
+ reviewUrl: review,
1265
+ warnings: plan.warnings,
1266
+ ...(note ? { note } : {}),
1267
+ }),
1268
+ ],
1269
+ };
1270
+ }
1271
+ return {
1272
+ code: 0,
1273
+ lines: [
1274
+ `Redoing scene ${plan.sceneIndex}'s clip on ${plan.nodeId}.`,
1275
+ `triggerRunId: ${triggerRunId ?? "-"}`,
1276
+ "Every other scene stays as it is, and so do the pictures, the voices and the script.",
1277
+ ...(note
1278
+ ? ["Your note steers how this clip moves. It never changes the words that are spoken."]
1279
+ : []),
1280
+ `Watch it land: exodus video status ${runId}`,
1281
+ `Review the run: ${review}`,
1282
+ ...plan.warnings,
1283
+ ],
1284
+ };
1285
+ }
1286
+ function clipIsStillRaw(item) {
1287
+ const a = item.artifact;
1288
+ return (item.status === "done" &&
1289
+ a?.type === "video" &&
1290
+ a.revoiced !== true &&
1291
+ a.speechTrimmed !== true &&
1292
+ a.tailTrimmed !== true);
1293
+ }
1294
+ const VOICE_NOT_CHANGED_CODES = new Set(["voice-unpinned", "voice-not-applied"]);
1295
+ export function planClipRevoice(run, items, target) {
1296
+ const found = findClipRow(run, items, target);
1297
+ if ("reason" in found)
1298
+ return { ok: false, reason: found.reason };
1299
+ const { row, uploadedCut } = found;
1300
+ const remake = `To make the clip again from scratch: exodus video retry-clip ${run._id} --scene ${target.sceneIndex}`;
1301
+ if (row.status !== "done" || row.artifact?.type !== "video") {
1302
+ return {
1303
+ ok: false,
1304
+ reason: `Scene ${target.sceneIndex} has no finished clip, so there is no voice to redo. ${remake}`,
1305
+ };
1306
+ }
1307
+ if (!clipIsStillRaw(row)) {
1308
+ return {
1309
+ ok: false,
1310
+ reason: `Scene ${target.sceneIndex}'s clip has already been through the voice pass, ` +
1311
+ `so it cannot go through it again. ${remake}`,
1312
+ };
1313
+ }
1314
+ return {
1315
+ ok: true,
1316
+ nodeId: row.nodeId,
1317
+ sceneIndex: target.sceneIndex,
1318
+ attempt: row.attempt ?? 0,
1319
+ warnings: uploadedCut ? [UPLOADED_CUT_WARNING] : [],
1320
+ };
1321
+ }
1322
+ function sceneListWords(sceneIndexes) {
1323
+ if (sceneIndexes.length === 1)
1324
+ return `Scene ${sceneIndexes[0]}`;
1325
+ return `Scenes ${sceneIndexes.slice(0, -1).join(", ")} and ${sceneIndexes[sceneIndexes.length - 1]}`;
1326
+ }
1327
+ export async function revoiceFlow(runId, target, json, deps) {
1328
+ const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
1329
+ if (!runRes.ok)
1330
+ return errorResult(runRes, json);
1331
+ const run = asVideoRun(runRes.data);
1332
+ const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
1333
+ if (!itemsRes.ok)
1334
+ return errorResult(itemsRes, json);
1335
+ const items = itemsRes.data.items ?? [];
1336
+ const targets = "all" in target
1337
+ ? items
1338
+ .filter((i) => i.itemKind === "clip" &&
1339
+ clipIsStillRaw(i) &&
1340
+ (i.findings ?? []).some((f) => VOICE_NOT_CHANGED_CODES.has(f.code)))
1341
+ .sort((a, b) => a.sceneIndex - b.sceneIndex)
1342
+ .map((i) => ({ sceneIndex: i.sceneIndex, nodeId: i.nodeId }))
1343
+ : [target];
1344
+ if (targets.length === 0) {
1345
+ const nothing = "No clip on this run is waiting for a voice pass, so nothing was started.";
1346
+ return {
1347
+ code: 0,
1348
+ lines: json ? [JSON.stringify({ ok: true, runId, scenes: [], note: nothing })] : [nothing],
1349
+ };
1350
+ }
1351
+ const scenes = [];
1352
+ const warnings = new Set();
1353
+ for (const one of targets) {
1354
+ const plan = planClipRevoice(run, items, one);
1355
+ if (!plan.ok) {
1356
+ scenes.push({ sceneIndex: one.sceneIndex, nodeId: one.nodeId, ok: false, error: plan.reason });
1357
+ continue;
1358
+ }
1359
+ const res = await deps.post(SCENE_REVOICE_PATH, {
1360
+ runId,
1361
+ nodeId: plan.nodeId,
1362
+ sceneIndex: plan.sceneIndex,
1363
+ });
1364
+ if (missingRouteLine(res, "exodus video revoice")) {
1365
+ return {
1366
+ code: 1,
1367
+ lines: json
1368
+ ? [JSON.stringify({ ok: false, status: 404, error: REVOICE_NOT_ON_THIS_SERVER })]
1369
+ : [REVOICE_NOT_ON_THIS_SERVER],
1370
+ };
1371
+ }
1372
+ if (res.ok) {
1373
+ plan.warnings.forEach((w) => warnings.add(w));
1374
+ scenes.push({
1375
+ sceneIndex: plan.sceneIndex,
1376
+ nodeId: plan.nodeId,
1377
+ ok: true,
1378
+ triggerRunId: res.data.triggerRunId,
1379
+ });
1380
+ continue;
1381
+ }
1382
+ scenes.push({
1383
+ sceneIndex: plan.sceneIndex,
1384
+ nodeId: plan.nodeId,
1385
+ ok: false,
1386
+ error: videoApiError(res),
1387
+ });
1388
+ }
1389
+ const queued = scenes.filter((r) => r.ok).length;
1390
+ const code = scenes.every((r) => r.ok) ? 0 : 1;
1391
+ const review = reviewUrl(deps.dashboardUrl, run);
1392
+ if (json) {
1393
+ return {
1394
+ code,
1395
+ lines: [
1396
+ JSON.stringify({
1397
+ ok: code === 0,
1398
+ runId,
1399
+ scenes,
1400
+ reviewUrl: review,
1401
+ warnings: [...warnings],
1402
+ }),
1403
+ ],
1404
+ };
1405
+ }
1406
+ const refusedWith = new Map();
1407
+ for (const r of scenes) {
1408
+ if (!r.ok)
1409
+ refusedWith.set(r.error, [...(refusedWith.get(r.error) ?? []), r.sceneIndex]);
1410
+ }
1411
+ const sceneLines = scenes.flatMap((r) => {
1412
+ if (r.ok) {
1413
+ return [
1414
+ `Scene ${r.sceneIndex}: redoing the voice on ${r.nodeId}. triggerRunId: ${r.triggerRunId ?? "-"}`,
1415
+ ];
1416
+ }
1417
+ const sharing = refusedWith.get(r.error) ?? [];
1418
+ if (sharing[0] !== r.sceneIndex)
1419
+ return [];
1420
+ return [`${sceneListWords(sharing)}: not started. ${r.error}`];
1421
+ });
1422
+ if (queued === 0)
1423
+ return { code, lines: sceneLines };
1424
+ return {
1425
+ code,
1426
+ lines: [
1427
+ ...sceneLines,
1428
+ "The picture in each clip stays exactly as it is. No new video is made.",
1429
+ "The words are checked against the script, the clip is trimmed to the speech, and the chosen voice is applied. ElevenLabs bills that to your own key.",
1430
+ "If the voice pass cannot finish on a clip, that clip is kept as it was.",
1431
+ `Watch them land: exodus video status ${runId}`,
1432
+ `Review the run: ${review}`,
1433
+ ...warnings,
1434
+ ],
1435
+ };
1436
+ }
1437
+ export function parseVoiceFlags(occurrences, readFile) {
1056
1438
  const fromFile = {};
1057
1439
  const typed = {};
1058
1440
  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];
1441
+ const valueFor = (occurrence) => {
1442
+ if (occurrence.value === undefined)
1443
+ throw new Error(`--${occurrence.flag} needs a value.`);
1444
+ return occurrence.value;
1067
1445
  };
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;
1446
+ for (const occurrence of occurrences) {
1447
+ if (occurrence.flag === "set") {
1448
+ const raw = valueFor(occurrence);
1073
1449
  const eq = raw.lastIndexOf("=");
1074
1450
  if (eq <= 0 || eq === raw.length - 1) {
1075
1451
  throw new Error("--set must look like <character>=<voice id>, for example " +
@@ -1078,17 +1454,15 @@ export function parseVoiceFlags(argv, readFile) {
1078
1454
  typed[raw.slice(0, eq).trim()] = raw.slice(eq + 1).trim();
1079
1455
  asked = true;
1080
1456
  }
1081
- else if (arg === "--clear" || arg.startsWith("--clear=")) {
1082
- const [raw, next] = valueFor(i, "--clear");
1083
- i = next;
1457
+ else if (occurrence.flag === "clear") {
1458
+ const raw = valueFor(occurrence);
1084
1459
  if (!raw.trim())
1085
1460
  throw new Error("--clear needs a character, for example --clear C1.");
1086
1461
  typed[raw.trim()] = null;
1087
1462
  asked = true;
1088
1463
  }
1089
- else if (arg === "--from" || arg.startsWith("--from=")) {
1090
- const [file, next] = valueFor(i, "--from");
1091
- i = next;
1464
+ else if (occurrence.flag === "from") {
1465
+ const file = valueFor(occurrence);
1092
1466
  let parsed;
1093
1467
  try {
1094
1468
  parsed = JSON.parse(readFile(file));
@@ -1138,14 +1512,32 @@ function availabilityWords(row) {
1138
1512
  return "not checked";
1139
1513
  }
1140
1514
  }
1515
+ function voiceWords(row) {
1516
+ const voice = row.voice;
1517
+ if (!voice)
1518
+ return "—";
1519
+ const words = voice.label ? `${voice.label} (${voice.voiceId})` : voice.voiceId;
1520
+ return row.voiceFrom === "run-default" ? `${words}, the run's default voice` : words;
1521
+ }
1522
+ function voiceSummaryLines(cast, notices) {
1523
+ const rows = cast.filter((row) => row.spokenScenes > 0 || row.voice !== null);
1524
+ const width = Math.max(0, ...rows.map((row) => row.name.length));
1525
+ const lines = rows.length > 0 ? ["Voices"] : [];
1526
+ for (const row of rows) {
1527
+ lines.push(row.voice
1528
+ ? ` ${row.name.padEnd(width)} → ${voiceWords(row)} ${availabilityWords(row)}`
1529
+ : ` ${row.name.padEnd(width)} → no voice chosen (keeps the generated voice)`);
1530
+ }
1531
+ for (const notice of notices)
1532
+ lines.push(`Heads-up: ${notice}`);
1533
+ return lines;
1534
+ }
1141
1535
  export function voiceSheetLines(sheet) {
1142
1536
  const lines = [`Voices for run ${sheet.runId}`];
1143
1537
  if (sheet.cast.length === 0)
1144
1538
  lines.push(" Nobody is in this ad yet.");
1145
1539
  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
- : "—";
1540
+ const voice = voiceWords(row);
1149
1541
  lines.push(` ${row.characterId} ${row.name} ${voice} ${availabilityWords(row)} ` +
1150
1542
  `speaks in ${row.spokenScenes} scenes, about ${row.spokenSeconds}s`);
1151
1543
  }
@@ -1449,7 +1841,7 @@ export async function uploadFlow(runId, filePath, durationFlag, json, deps) {
1449
1841
  if (!attached.ok)
1450
1842
  return errorResult(attached, json);
1451
1843
  const final = attached.data;
1452
- const finalWatchUrl = final.finalWatchUrl ?? `${deps.dashboardUrl}/video?ad=${runId}`;
1844
+ const finalWatchUrl = final.finalWatchUrl ?? reviewUrl(deps.dashboardUrl, { _id: runId });
1453
1845
  if (json) {
1454
1846
  return {
1455
1847
  code: 0,
@@ -1511,7 +1903,7 @@ function usage(line) {
1511
1903
  console.error(`Error: ${line}`);
1512
1904
  process.exit(1);
1513
1905
  }
1514
- export async function run(flags) {
1906
+ export async function run(flags, occurrences) {
1515
1907
  const [sub, ...rest] = parsePositional();
1516
1908
  const json = flags["json"] === true;
1517
1909
  if (!sub) {
@@ -1543,6 +1935,8 @@ export async function run(flags) {
1543
1935
  "approve",
1544
1936
  "flag",
1545
1937
  "retry-frame",
1938
+ "retry-clip",
1939
+ "revoice",
1546
1940
  "voices",
1547
1941
  "pull",
1548
1942
  "upload",
@@ -1576,10 +1970,35 @@ export async function run(flags) {
1576
1970
  }
1577
1971
  return printResult(await retryFrameFlow(runId, nodeId, sceneIndex, flagString(flags, "note"), json, defaultDeps));
1578
1972
  }
1973
+ if (sub === "retry-clip") {
1974
+ const sceneRaw = flagString(flags, "scene");
1975
+ if (sceneRaw === undefined) {
1976
+ usage("video retry-clip needs --scene <n>, the scene whose clip to redo.");
1977
+ }
1978
+ const sceneIndex = Number(sceneRaw);
1979
+ if (!Number.isInteger(sceneIndex)) {
1980
+ usage(`video retry-clip --scene must be a whole scene number, not "${sceneRaw}".`);
1981
+ }
1982
+ return printResult(await retryClipFlow(runId, { sceneIndex, nodeId: flagString(flags, "node") }, flagString(flags, "note"), json, defaultDeps));
1983
+ }
1984
+ if (sub === "revoice") {
1985
+ const sceneRaw = flagString(flags, "scene");
1986
+ const all = flags["all"] === true;
1987
+ if (all === (sceneRaw !== undefined)) {
1988
+ usage("video revoice needs one of --scene <n> (one clip) or --all (every clip that kept the video model's voice).");
1989
+ }
1990
+ if (all)
1991
+ return printResult(await revoiceFlow(runId, { all: true }, json, defaultDeps));
1992
+ const sceneIndex = Number(sceneRaw);
1993
+ if (!Number.isInteger(sceneIndex)) {
1994
+ usage(`video revoice --scene must be a whole scene number, not "${sceneRaw}".`);
1995
+ }
1996
+ return printResult(await revoiceFlow(runId, { sceneIndex, nodeId: flagString(flags, "node") }, json, defaultDeps));
1997
+ }
1579
1998
  if (sub === "voices") {
1580
1999
  let voices;
1581
2000
  try {
1582
- voices = parseVoiceFlags(process.argv.slice(3), defaultDeps.readFile);
2001
+ voices = parseVoiceFlags(occurrences, defaultDeps.readFile);
1583
2002
  }
1584
2003
  catch (err) {
1585
2004
  usage(err.message);