@aicopycoders/exodus 2026.9.2000-beta.10 → 2026.9.2000-beta.11

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.
@@ -101,7 +101,7 @@ exodus video approve <runId> keep going
101
101
  exodus video retry-frame <runId> --node <nodeId> --scene <n> redo one still at the pixel gate
102
102
  exodus video status <runId> where it is, per scene
103
103
  exodus video pull <runId> --out ./ad-<runId> every piece + manifest.json
104
- exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "…"]
104
+ exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "…"] [--voice-first]
105
105
  redo one clip at the final watch
106
106
  exodus video revoice <runId> --all | --scene <n> redo only the voice; no new video
107
107
  … make the cut (your choice of tool)
@@ -602,6 +602,9 @@ scenes[] one per scene, in order
602
602
  on record when the voice pass replaced the
603
603
  clip with a new file; null when the clip IS
604
604
  that file, or there is no clip
605
+ voiceMode "voice-first" when the clip was redone voice
606
+ first (its line recorded, then the clip made
607
+ to match it); null otherwise
605
608
  provenance which saved rulebook this run followed:
606
609
  {format: {via, nodeId, rigId, rigName,
607
610
  rulesFrom, specVersion}}. Absent when the
@@ -746,6 +749,7 @@ is no second run, no new storyboard and no re-render of anything else.
746
749
  exodus video retry-clip <runId> --scene 3
747
750
  exodus video retry-clip <runId> --scene 3 --note "keep both hands in frame on the handshake"
748
751
  exodus video retry-clip <runId> --scene 3 --node video-2
752
+ exodus video retry-clip <runId> --scene 3 --voice-first
749
753
  ```
750
754
 
751
755
  `--node` is only needed when a run has clips for that scene on two video steps;
@@ -768,7 +772,17 @@ revoiced and trimmed the same way, with fresh findings and fresh word timings.
768
772
  scene's motion prompt only (`withRedoDirection`,
769
773
  `scout/src/trigger/workflow-scene-retry.ts`); the spoken line still comes from
770
774
  the approved storyboard. Asking for different words in a note will not change
771
- them. If the words are wrong, that is a new run.
775
+ them. If the script's words are wrong, that is a new run.
776
+
777
+ **`--voice-first` fixes a clip that said its line wrong** (#2299). It is for a
778
+ clip whose findings include `speech-mismatch`, `speech-repeat`,
779
+ `speech-after-line` or `speech-long-pause`. The scene's line is recorded in each
780
+ speaker's pinned ElevenLabs voice, and MiniMax (`minimax-h3/reference-to-video`)
781
+ makes the clip to match the recording. Only that clip changes; the word check
782
+ and the other checks still run on it. It needs an ElevenLabs key and a voice for
783
+ every speaker in the scene, and it is refused for a scene with no spoken line.
784
+ The clip then reads `clip: redone voice first` in `status`, carries
785
+ `voiceMode: "voice-first"` in the manifest, and is listed in the `pull` summary.
772
786
 
773
787
  **A redo whose take fails the checks keeps the original clip.** On a finished
774
788
  clip the row stays `done` with the ORIGINAL take and only its findings are
@@ -74,6 +74,7 @@ export type ArtifactSubset = {
74
74
  speechTrimmed?: boolean;
75
75
  tailTrimmed?: boolean;
76
76
  rawStorageId?: string;
77
+ voiceMode?: "voice-first";
77
78
  } | {
78
79
  type: "audio";
79
80
  sceneIndex?: number;
@@ -273,6 +274,7 @@ export interface ManifestScene {
273
274
  revoiced: boolean | null;
274
275
  speechTrimmed: boolean | null;
275
276
  rawStorageId: string | null;
277
+ voiceMode: "voice-first" | null;
276
278
  clipStatus: string;
277
279
  error: string | null;
278
280
  flagged: boolean;
@@ -411,7 +413,11 @@ export interface ClipRedoTarget {
411
413
  nodeId?: string;
412
414
  }
413
415
  export declare function planClipRedo(run: VideoRun, items: NodeItem[], target: ClipRedoTarget): ClipRedoPlan;
414
- export declare function retryClipFlow(runId: string, target: ClipRedoTarget, note: string | undefined, json: boolean, deps: VideoDeps): Promise<FlowResult>;
416
+ export interface ClipRedoOptions {
417
+ note?: string;
418
+ voiceFirst?: boolean;
419
+ }
420
+ export declare function retryClipFlow(runId: string, target: ClipRedoTarget, { note, voiceFirst }: ClipRedoOptions, json: boolean, deps: VideoDeps): Promise<FlowResult>;
415
421
  export declare const VOICE_PATHS_NEVER_HEARD: Set<string>;
416
422
  export declare function planClipRevoice(run: VideoRun, items: NodeItem[], target: ClipRedoTarget): ClipRedoPlan;
417
423
  export type RevoiceTarget = ClipRedoTarget | {
@@ -70,7 +70,9 @@ The whole loop, in order:
70
70
 
71
71
  exodus video retry-clip <runId> --scene <n>
72
72
  Redo ONE finished clip while the run waits for the cut. Everything
73
- else stays. A --note steers the motion, never the words.
73
+ else stays. A --note steers the motion, never the words. Add
74
+ --voice-first when the words came out wrong: the line is recorded in
75
+ the character's own voice first, then the clip is made to match it.
74
76
 
75
77
  exodus video revoice <runId> --all
76
78
  Redo only the VOICE on clips that kept the video model's own voice
@@ -93,7 +95,7 @@ Usage:
93
95
  exodus video storyboard <runId> [--json]
94
96
  exodus video approve <runId> [--approve-stale-cut] [--json]
95
97
  exodus video retry-frame <runId> --node <nodeId> --scene <n> [--note "..."] [--json]
96
- exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "..."] [--json]
98
+ exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "..."] [--voice-first] [--json]
97
99
  exodus video revoice <runId> (--scene <n> | --all) [--node <nodeId>] [--json]
98
100
  exodus video voices <runId> [--set <character>=<voiceId>] [--clear <character>] [--from <file.json>] [--json]
99
101
  exodus video pull <runId> --out <dir> [--json]
@@ -113,6 +115,9 @@ Options:
113
115
  (retry-frame, retry-clip, revoice)
114
116
  --all Every finished clip that kept the video model's voice
115
117
  (revoice)
118
+ --voice-first Record the scene's line in the character's own voice,
119
+ then make the clip to match it (retry-clip). For a clip
120
+ whose words came out wrong
116
121
  --approve-stale-cut Deliver the cut you uploaded even though a clip changed
117
122
  after you uploaded it (approve). Without it, approving
118
123
  stops and names the scenes that changed
@@ -151,6 +156,7 @@ Examples:
151
156
  exodus video approve run_123
152
157
  exodus video retry-frame run_123 --node frames-1 --scene 2
153
158
  exodus video retry-clip run_123 --scene 3 --note "keep the handshake in frame"
159
+ exodus video retry-clip run_123 --scene 3 --voice-first
154
160
  exodus video revoice run_123 --all
155
161
  exodus video voices run_123
156
162
  exodus video voices run_123 --set C1=abc123voiceid --set "HOST 2=def456voiceid"
@@ -171,6 +177,9 @@ const FLAG_PATH = "/api/v2/video/storyboard/flag";
171
177
  const APPROVE_PATH = "/api/v2/workflow/approve";
172
178
  const SCENE_RETRY_PATH = "/api/v2/workflow/scene/retry";
173
179
  const SCENE_REVOICE_PATH = "/api/v2/workflow/scene/revoice";
180
+ const SCENE_VOICE_FIRST_PATH = "/api/v2/workflow/scene/voice-first";
181
+ const VOICE_FIRST_NOT_ON_THIS_SERVER = "This Exodus server cannot redo a clip voice first yet, so nothing was started and nothing " +
182
+ "was spent. It arrives with the next server update.";
174
183
  const REVOICE_NOT_ON_THIS_SERVER = "This Exodus server does not have the voice redo yet, so nothing was started and nothing " +
175
184
  "was spent. It arrives with the next server update.";
176
185
  const FINAL_PATH = "/api/v2/video/final";
@@ -540,6 +549,7 @@ function clipFromArtifact(sceneIndex, artifact) {
540
549
  revoiced: artifact.revoiced === true,
541
550
  speechTrimmed: artifact.speechTrimmed === true,
542
551
  rawStorageId: artifact.rawStorageId ?? null,
552
+ voiceMode: artifact.voiceMode ?? null,
543
553
  };
544
554
  }
545
555
  function keyframeDownload(sceneIndex, imageUrl) {
@@ -849,6 +859,7 @@ export function planPull(run, items, opts) {
849
859
  revoiced: clip ? clip.revoiced : null,
850
860
  speechTrimmed: clip ? clip.speechTrimmed : null,
851
861
  rawStorageId: clip ? clip.rawStorageId : null,
862
+ voiceMode: clip ? clip.voiceMode : null,
852
863
  clipStatus: item?.status ?? "missing",
853
864
  error: item?.error ?? null,
854
865
  flagged: item?.flagged === true,
@@ -922,6 +933,7 @@ export function markPullFailure(manifest, failure) {
922
933
  scene.revoiced = null;
923
934
  scene.speechTrimmed = null;
924
935
  scene.rawStorageId = null;
936
+ scene.voiceMode = null;
925
937
  }
926
938
  if (scene.voice === failure.file)
927
939
  scene.voice = null;
@@ -1419,8 +1431,14 @@ export async function statusFlow(runId, json, deps) {
1419
1431
  };
1420
1432
  }
1421
1433
  const revoicedByScene = new Set();
1434
+ const voiceFirstByScene = new Set();
1422
1435
  const timedOnOriginalVoice = new Set();
1423
1436
  for (const artifact of outputsOfNodeKind(run, "video")) {
1437
+ if (artifact.type === "video" &&
1438
+ artifact.voiceMode === "voice-first" &&
1439
+ typeof artifact.sceneIndex === "number") {
1440
+ voiceFirstByScene.add(artifact.sceneIndex);
1441
+ }
1424
1442
  if (artifact.type === "video" && artifact.revoiced === true && typeof artifact.sceneIndex === "number") {
1425
1443
  revoicedByScene.add(artifact.sceneIndex);
1426
1444
  if ((artifact.words?.length ?? 0) > 0)
@@ -1489,6 +1507,8 @@ export async function statusFlow(runId, json, deps) {
1489
1507
  lines.push(` ${row.clip.error}`);
1490
1508
  if (revoicedByScene.has(sceneIndex))
1491
1509
  lines.push(" voice: cast voice applied");
1510
+ if (voiceFirstByScene.has(sceneIndex))
1511
+ lines.push(" clip: redone voice first");
1492
1512
  if (timedOnOriginalVoice.has(sceneIndex)) {
1493
1513
  lines.push(" word timings: measured on the original voice (close, not frame-exact)");
1494
1514
  }
@@ -1789,7 +1809,7 @@ function findClipRow(run, items, target) {
1789
1809
  }
1790
1810
  return { row, uploadedCut };
1791
1811
  }
1792
- export async function retryClipFlow(runId, target, note, json, deps) {
1812
+ export async function retryClipFlow(runId, target, { note, voiceFirst = false }, json, deps) {
1793
1813
  const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
1794
1814
  if (!runRes.ok)
1795
1815
  return errorResult(runRes, json);
@@ -1805,12 +1825,20 @@ export async function retryClipFlow(runId, target, note, json, deps) {
1805
1825
  lines: json ? [JSON.stringify({ ok: false, error: plan.reason })] : [plan.reason],
1806
1826
  };
1807
1827
  }
1808
- const res = await deps.post(SCENE_RETRY_PATH, {
1828
+ const res = await deps.post(voiceFirst ? SCENE_VOICE_FIRST_PATH : SCENE_RETRY_PATH, {
1809
1829
  runId,
1810
1830
  nodeId: plan.nodeId,
1811
1831
  sceneIndex: plan.sceneIndex,
1812
1832
  ...(note ? { note } : {}),
1813
1833
  });
1834
+ if (voiceFirst && missingRouteLine(res, "exodus video retry-clip --voice-first")) {
1835
+ return {
1836
+ code: 1,
1837
+ lines: json
1838
+ ? [JSON.stringify({ ok: false, status: 404, error: VOICE_FIRST_NOT_ON_THIS_SERVER })]
1839
+ : [VOICE_FIRST_NOT_ON_THIS_SERVER],
1840
+ };
1841
+ }
1814
1842
  if (!res.ok)
1815
1843
  return errorResult(res, json);
1816
1844
  const triggerRunId = res.data.triggerRunId;
@@ -1829,6 +1857,7 @@ export async function retryClipFlow(runId, target, note, json, deps) {
1829
1857
  reviewUrl: review,
1830
1858
  warnings: plan.warnings,
1831
1859
  ...(note ? { note } : {}),
1860
+ ...(voiceFirst ? { voiceMode: "voice-first" } : {}),
1832
1861
  }),
1833
1862
  ],
1834
1863
  };
@@ -1836,7 +1865,10 @@ export async function retryClipFlow(runId, target, note, json, deps) {
1836
1865
  return {
1837
1866
  code: 0,
1838
1867
  lines: [
1839
- `Redoing scene ${plan.sceneIndex}'s clip on ${plan.nodeId}.`,
1868
+ voiceFirst
1869
+ ? `Redoing scene ${plan.sceneIndex}'s clip on ${plan.nodeId}, voice first: the line is ` +
1870
+ "recorded in the character's own voice, then the clip is made to match it."
1871
+ : `Redoing scene ${plan.sceneIndex}'s clip on ${plan.nodeId}.`,
1840
1872
  `triggerRunId: ${triggerRunId ?? "-"}`,
1841
1873
  "Every other scene stays as it is, and so do the pictures, the voices and the script.",
1842
1874
  ...(note
@@ -2207,6 +2239,11 @@ export async function pullFlow(runId, dir, json, deps) {
2207
2239
  `Every piece is indexed in ${path.join(dir, "manifest.json")} — scene numbers there are the run's own.`,
2208
2240
  ...musicLines(plan.manifest),
2209
2241
  ];
2242
+ const voiceFirst = plan.manifest.scenes.filter((s) => s.voiceMode === "voice-first");
2243
+ if (voiceFirst.length > 0) {
2244
+ lines.push("", `Redone voice first (the line recorded, then the clip made to match it): ` +
2245
+ `scene${voiceFirst.length === 1 ? "" : "s"} ${voiceFirst.map((s) => s.sceneIndex).join(", ")}`);
2246
+ }
2210
2247
  const flagged = plan.manifest.scenes.filter((s) => s.flagged);
2211
2248
  if (flagged.length > 0) {
2212
2249
  lines.push("", `${flagged.length} clip${flagged.length === 1 ? "" : "s"} came back flagged — usable, but look before you cut:`);
@@ -2583,7 +2620,7 @@ export async function run(flags, occurrences) {
2583
2620
  if (!Number.isInteger(sceneIndex)) {
2584
2621
  usage(`video retry-clip --scene must be a whole scene number, not "${sceneRaw}".`);
2585
2622
  }
2586
- return printResult(await retryClipFlow(runId, { sceneIndex, nodeId: flagString(flags, "node") }, flagString(flags, "note"), json, defaultDeps));
2623
+ return printResult(await retryClipFlow(runId, { sceneIndex, nodeId: flagString(flags, "node") }, { note: flagString(flags, "note"), voiceFirst: flags["voice-first"] === true }, json, defaultDeps));
2587
2624
  }
2588
2625
  if (sub === "revoice") {
2589
2626
  const sceneRaw = flagString(flags, "scene");
@@ -409,7 +409,7 @@ export interface RunFormatProvenance {
409
409
  };
410
410
  specVersion: string | null;
411
411
  }
412
- export type VoicePath = "sts-revoice" | "lipsync-retarget" | "native-prompt" | "omni-audio-ids" | "gemini-direct";
412
+ export type VoicePath = "sts-revoice" | "lipsync-retarget" | "native-prompt" | "omni-audio-ids" | "gemini-direct" | "voice-first";
413
413
  export type RunVoiceProvenance = {
414
414
  source: "show";
415
415
  path: VoicePath;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicopycoders/exodus",
3
- "version": "2026.9.2000-beta.10",
3
+ "version": "2026.9.2000-beta.11",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",