@aicopycoders/exodus 2026.9.1100-beta.1 → 2026.9.1100-beta.3

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.
@@ -117,7 +117,9 @@ scene-NN.keyframe.<ext> the picture for scene NN
117
117
  scene-NN.voice.<ext> the voice track for scene NN, when one was delivered
118
118
  (a Show whose voices live inside the clips delivers none)
119
119
  scene-NN.<ext> the clip for scene NN, usually .mp4, voice included
120
- scene-NN.words.json [{w, s, e}] word timings inside that clip, for captions
120
+ scene-NN.words.json [{w, s, e}] word timings for the speech in that scene, from
121
+ the clip's own dialogue or, on a narrated scene, from the
122
+ voice track (seconds from the start of that file)
121
123
  music.<ext> the music bed, composed to the clips' total length
122
124
  manifest.json the index below
123
125
  ```
@@ -130,6 +132,8 @@ storyboard, reference, music filenames, or null when not delivered
130
132
  scenes[] one per scene, in order
131
133
  sceneIndex, durationSec the run's number and the clip's real length
132
134
  clip, voice, keyframe, words filenames, or null
135
+ wordsFrom clip | voice, which file the word times are
136
+ timed against; null when none came
133
137
  clipStatus done | failed | running | pending | missing
134
138
  error why the clip failed, when it did
135
139
  flagged, findings[] QC verdict: {check, code, severity: fail|warn, detail}
@@ -140,6 +144,9 @@ failed[] files that did not download: {file, url, er
140
144
  How to read it before cutting:
141
145
 
142
146
  - `clipStatus: "done"` and a `clip` filename: the scene is cuttable.
147
+ - `wordsFrom: "voice"` (or narration in the storyboard with no on-camera line):
148
+ a narrated scene. Its clip is picture only; the voice track is the sound.
149
+ Never cut a narrated scene with the clip's own audio.
143
150
  - `flagged: true` is a warning, not a block. The clip was delivered anyway; the
144
151
  findings say what the QC model saw (`wrong-character`, `eyeline-off`,
145
152
  `set-drift`, `speech-cutoff`, `framing-off`). Tell the user which scenes are
@@ -159,10 +166,15 @@ How to read it before cutting:
159
166
 
160
167
  Use the bundled script. It reads the manifest, puts every A-roll scene in order
161
168
  as the spine, lays each cutaway over the spine at the moment its line is spoken,
162
- normalizes everything to 1080x1920 at 24 fps, fills a silent clip with the
163
- scene's voice track, lays the music bed underneath at a low level, and writes one
164
- MP4 with faststart for upload. The path is from the workspace root; from a brand
165
- subfolder, prefix `../`:
169
+ normalizes everything to 1080x1920 at 24 fps, and writes one MP4 with faststart
170
+ for upload. The audio track comes first: a narrated scene (narration in the
171
+ storyboard, no on-camera line) plays its voice track and nothing the clip
172
+ recorded, and the picture is fitted to the voice (a longer clip is trimmed, a
173
+ longer voice is sped up to at most 1.26x and then the last frame holds). A
174
+ dialogue scene keeps the line it performed. Every segment is loudness-normalized
175
+ to -16 LUFS, a continuous room-tone bed runs under the whole ad so the joins do
176
+ not read as dead air, and the music bed sits underneath at a low level. The path
177
+ is from the workspace root; from a brand subfolder, prefix `../`:
166
178
 
167
179
  ```
168
180
  node .claude/skills/exodus-video/scripts/first-cut.mjs ./ad-<runId>
@@ -185,6 +197,9 @@ the same words grouped into short subtitle lines. `--out v2.mp4` names them
185
197
 
186
198
  The times are on the finished ad's timeline, so a word's time is where you hear
187
199
  it in the MP4. Cutaways contribute nothing, because their audio never plays. A
200
+ narrated scene counts even when it is only a picture and a voice track: its word
201
+ times come from the voice track, and `manifest.json` says `wordsFrom: "clip"` or
202
+ `"voice"` per scene so you can tell which file a scene's times were read off. A
188
203
  scene that came with no word times leaves a gap, and the report names it. When no
189
204
  scene delivered word times the script writes neither file and says so. Tell the
190
205
  user the two files exist and that they are what the editor imports for captions.
@@ -1,15 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
  // A plain first cut from a folder written by `exodus video pull`: the A-roll
3
3
  // scenes in the manifest's order make the spine, each cutaway lays over the
4
- // spine at the moment its cued line is spoken, the music bed goes underneath,
5
- // and one MP4 comes out ready to upload.
4
+ // spine at the moment its cued line is spoken, a room-tone bed and the music
5
+ // bed go underneath, and one MP4 comes out ready to upload.
6
6
  //
7
7
  // node first-cut.mjs <pulled-dir> [--out cut.mp4] [--skip 2,5] [--no-music]
8
8
  //
9
- // A scene with a clip contributes the clip. A clip with no audio stream gets
10
- // the scene's voice track, or silence. A scene with no clip but a keyframe
11
- // becomes a still for the length of its voice track, else the storyboard's
12
- // planned duration, with the voice if one was delivered. --skip drops scenes.
9
+ // The audio track comes first. A narrated scene (the storyboard gives it
10
+ // narration and no on-camera line) plays its voice track and NOTHING the clip
11
+ // recorded: the voice replaces the clip's audio, and the picture is fitted to
12
+ // the voice, trimmed when the clip runs longer, sped up to at most 1.26x and
13
+ // then held on its last frame when the voice runs longer. A dialogue scene
14
+ // keeps the audio it performed. A scene with no clip but a keyframe becomes a
15
+ // still for the length of its voice track, else the storyboard's planned
16
+ // duration. Every segment is loudness-normalized to -16 LUFS before the join.
17
+ // --skip drops scenes.
13
18
  //
14
19
  // After the render it writes the whole ad's word times beside the MP4, as
15
20
  // <out>.words.json and <out>.srt, for captioning in post.
@@ -26,6 +31,16 @@ const norm = (label) =>
26
31
  `[${label}]scale=${WIDTH}:${HEIGHT}:force_original_aspect_ratio=decrease,` +
27
32
  `pad=${WIDTH}:${HEIGHT}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=${FPS},format=yuv420p`;
28
33
  const stereo = "aresample=48000,aformat=channel_layouts=stereo";
34
+ /** Per-segment loudness, the level every clip was normalized to before the join. */
35
+ const LOUDNORM = "loudnorm=I=-16:TP=-1.5:LRA=11,aresample=48000";
36
+ /** Air after the last spoken word of a narrated scene, so the line lands. */
37
+ export const TAIL_AIR_SEC = 0.8;
38
+ /** The most a voice track may be sped up to fit its clip. */
39
+ export const ATEMPO_CAP = 1.26;
40
+ /** A continuous low bed under the whole ad, so the joins between clips do not read as dead air. */
41
+ const ROOM_TONE =
42
+ "anoisesrc=color=brown:amplitude=0.0035:r=48000,highpass=f=60,lowpass=f=900,aformat=channel_layouts=stereo";
43
+ const MUSIC_VOLUME = 0.18;
29
44
 
30
45
  /** The word rule the planner sized every line with (scout models.ts wordCount). */
31
46
  export function wordCount(text) {
@@ -43,12 +58,30 @@ export function lineWordCounts(storyboard) {
43
58
 
44
59
  const CUTAWAY_KINDS = new Set(["cutaway", "insert"]);
45
60
 
61
+ const hasText = (value) => typeof value === "string" && value.trim().length > 0;
62
+
63
+ /**
64
+ * A narrated scene: on a script-bound storyboard (one that carries the member's
65
+ * script lines) the scene has narration and no on-camera line. That is the same
66
+ * fact the server uses to leave a narrator scene's clip mute and its voice track
67
+ * separate. A storyboard without script lines predates that rule: its voiced
68
+ * clips were lip-synced to the voice on the server, so they keep their own audio.
69
+ */
70
+ export function narratedScene(scene, storyboard) {
71
+ const scriptBound = Array.isArray(storyboard?.script) && storyboard.script.length > 0;
72
+ const spoken = (Array.isArray(scene?.dialogue) ? scene.dialogue : []).some(
73
+ (turn) => hasText(turn?.line) || hasText(turn?.text),
74
+ );
75
+ return scriptBound && hasText(scene?.voText) && !spoken;
76
+ }
77
+
46
78
  export function scenePlans(storyboard) {
47
79
  const plans = new Map();
48
80
  for (const scene of storyboard?.scenes ?? []) {
49
81
  if (typeof scene?.sceneIndex !== "number") continue;
50
82
  plans.set(scene.sceneIndex, {
51
83
  cutaway: CUTAWAY_KINDS.has(scene.kind),
84
+ narrated: narratedScene(scene, storyboard),
52
85
  lineIds: Array.isArray(scene.lineIds) ? scene.lineIds : [],
53
86
  cueLineId: typeof scene.cueLineId === "string" ? scene.cueLineId : null,
54
87
  durationSec: typeof scene.durationSec === "number" ? scene.durationSec : null,
@@ -57,6 +90,30 @@ export function scenePlans(storyboard) {
57
90
  return plans;
58
91
  }
59
92
 
93
+ /**
94
+ * Fit a picture to its voice track. The segment runs as long as the voice plus
95
+ * a beat of air. A clip that runs longer is trimmed; a voice that runs longer
96
+ * is sped up to at most ATEMPO_CAP and the clip's last frame is held for
97
+ * whatever is still left. A still (no clip) simply runs the voice's length.
98
+ */
99
+ export function fitNarration({ clipSeconds, voiceSeconds }) {
100
+ const target = voiceSeconds + TAIL_AIR_SEC;
101
+ if (clipSeconds === null) return { seconds: target, atempo: 1, holdSec: 0, trimmedBy: 0 };
102
+ if (target <= clipSeconds) {
103
+ return { seconds: target, atempo: 1, holdSec: 0, trimmedBy: clipSeconds - target };
104
+ }
105
+ const room = Math.max(clipSeconds - TAIL_AIR_SEC, 0.1);
106
+ const atempo = Math.min(ATEMPO_CAP, voiceSeconds / room);
107
+ const fitted = voiceSeconds / atempo + TAIL_AIR_SEC;
108
+ if (fitted <= clipSeconds) return { seconds: fitted, atempo, holdSec: 0, trimmedBy: 0 };
109
+ return { seconds: fitted, atempo, holdSec: fitted - clipSeconds, trimmedBy: 0 };
110
+ }
111
+
112
+ function scaleWords(words, atempo) {
113
+ if (!words || atempo === 1) return words;
114
+ return words.map((w) => ({ ...w, s: w.s / atempo, e: w.e / atempo }));
115
+ }
116
+
60
117
  export function cueOffsetOnSpine(spine, counts, cueLineId) {
61
118
  for (const seg of spine) {
62
119
  const idx = seg.lineIds.indexOf(cueLineId);
@@ -115,29 +172,61 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
115
172
  continue;
116
173
  }
117
174
 
175
+ const narrated = plan.narrated || scene.wordsFrom === "voice";
176
+ const voice = exists(scene.voice) ? scene.voice : null;
177
+ const flag = scene.flagged ? " (flagged)" : "";
178
+
118
179
  if (exists(scene.clip)) {
119
180
  const clip = probe(scene.clip);
120
- const voice = clip.hasAudio || !exists(scene.voice) ? null : scene.voice;
121
- const flag = scene.flagged ? " (flagged)" : "";
122
- const silent = clip.hasAudio ? "" : voice ? `, silent clip + ${voice}` : ", silent clip";
123
- spine.push({
124
- sceneIndex: n, source: "clip", file: scene.clip, seconds: clip.seconds,
125
- hasAudio: clip.hasAudio, voice, startSec, lineIds: plan.lineIds,
126
- words: readWords(scene.words), note: `${flag}${silent}`,
127
- });
128
- startSec += clip.seconds;
181
+ const base = {
182
+ sceneIndex: n, source: "clip", file: scene.clip, clipSeconds: clip.seconds,
183
+ startSec, lineIds: plan.lineIds,
184
+ };
185
+ if (voice && (narrated || !clip.hasAudio)) {
186
+ const fit = fitNarration({ clipSeconds: clip.seconds, voiceSeconds: probe(voice).seconds });
187
+ const how = [];
188
+ if (fit.trimmedBy > 0.05) how.push(`clip trimmed ${clip.seconds.toFixed(1)}s -> ${fit.seconds.toFixed(1)}s`);
189
+ if (fit.atempo > 1.001) how.push(`voice sped up ${fit.atempo.toFixed(2)}x`);
190
+ if (fit.holdSec > 0.05) how.push(`last frame held ${fit.holdSec.toFixed(1)}s`);
191
+ spine.push({
192
+ ...base, audio: "narration", voice, seconds: fit.seconds, atempo: fit.atempo,
193
+ holdSec: fit.holdSec, words: scaleWords(readWords(scene.words), fit.atempo),
194
+ note: `${flag}, ${voice} replaces the clip's audio${how.length ? ` (${how.join(", ")})` : ""}`,
195
+ });
196
+ } else if (narrated) {
197
+ spine.push({
198
+ ...base, audio: "silence", voice: null, seconds: clip.seconds, atempo: 1, holdSec: 0,
199
+ words: null,
200
+ note: `${flag}, narrated scene without its voice track: the clip's own audio is muted`,
201
+ });
202
+ } else if (clip.hasAudio) {
203
+ spine.push({
204
+ ...base, audio: "clip", voice: null, seconds: clip.seconds, atempo: 1, holdSec: 0,
205
+ words: readWords(scene.words), note: `${flag}, its own dialogue`,
206
+ });
207
+ } else {
208
+ spine.push({
209
+ ...base, audio: "silence", voice: null, seconds: clip.seconds, atempo: 1, holdSec: 0,
210
+ words: null, note: `${flag}, silent clip`,
211
+ });
212
+ }
213
+ startSec += spine[spine.length - 1].seconds;
129
214
  continue;
130
215
  }
131
216
  if (exists(scene.keyframe)) {
132
- const voice = exists(scene.voice) ? scene.voice : null;
133
- const seconds = voice ? probe(voice).seconds : (plan.durationSec ?? 4);
217
+ const fit = voice
218
+ ? fitNarration({ clipSeconds: null, voiceSeconds: probe(voice).seconds })
219
+ : { seconds: plan.durationSec ?? 4, atempo: 1, holdSec: 0 };
134
220
  spine.push({
135
- sceneIndex: n, source: "still", file: scene.keyframe, seconds,
136
- hasAudio: false, voice, startSec, lineIds: plan.lineIds,
137
- words: null,
138
- note: `${voice ? ` + ${voice}` : ", silent"} (${seconds.toFixed(1)}s, clip ${scene.clipStatus})`,
221
+ sceneIndex: n, source: "still", file: scene.keyframe, clipSeconds: null,
222
+ seconds: fit.seconds, audio: voice ? "narration" : "silence", voice, atempo: 1, holdSec: 0,
223
+ startSec, lineIds: plan.lineIds,
224
+ // #1689: on a narrated scene the words are the voice track's, and the
225
+ // voice starts where the segment does, so they need no offset here.
226
+ words: voice ? readWords(scene.words) : null,
227
+ note: `${flag}${voice ? ` + ${voice}` : ", silent"} (${fit.seconds.toFixed(1)}s, clip ${scene.clipStatus})`,
139
228
  });
140
- startSec += seconds;
229
+ startSec += fit.seconds;
141
230
  continue;
142
231
  }
143
232
  dropped.push({ n, reason: `scene ${n}: ${nothing}` });
@@ -191,14 +280,20 @@ export function buildFfmpegArgs(timeline, { resolve, music, out }) {
191
280
  seg.source === "clip"
192
281
  ? addInput("-i", resolve(seg.file))
193
282
  : addInput("-loop", "1", "-t", String(seg.seconds), "-i", resolve(seg.file));
194
- filters.push(`${norm(`${i}:v`)}[v${i}]`);
195
- if (seg.hasAudio) {
196
- filters.push(`[${i}:a]${stereo}[a${i}]`);
197
- } else if (seg.voice) {
283
+ const fit = [];
284
+ if (seg.source === "clip" && seg.clipSeconds !== null && seg.seconds < seg.clipSeconds - 0.01) {
285
+ fit.push(`trim=0:${sec(seg.seconds)},setpts=PTS-STARTPTS`);
286
+ }
287
+ if (seg.holdSec > 0.01) fit.push(`tpad=stop_mode=clone:stop_duration=${sec(seg.holdSec)}`);
288
+ filters.push(`${norm(`${i}:v`)}${fit.length ? `,${fit.join(",")}` : ""}[v${i}]`);
289
+ if (seg.audio === "narration") {
198
290
  const v = addInput("-i", resolve(seg.voice));
199
- filters.push(`[${v}:a]${stereo},apad,atrim=0:${seg.seconds}[a${i}]`);
291
+ const tempo = seg.atempo > 1.001 ? `atempo=${seg.atempo.toFixed(4)},` : "";
292
+ filters.push(`[${v}:a]${stereo},${tempo}${LOUDNORM},apad,atrim=0:${sec(seg.seconds)}[a${i}]`);
293
+ } else if (seg.audio === "clip") {
294
+ filters.push(`[${i}:a]${stereo},${LOUDNORM},apad,atrim=0:${sec(seg.seconds)}[a${i}]`);
200
295
  } else {
201
- filters.push(`anullsrc=r=48000:cl=stereo,atrim=0:${seg.seconds}[a${i}]`);
296
+ filters.push(`anullsrc=r=48000:cl=stereo,atrim=0:${sec(seg.seconds)}[a${i}]`);
202
297
  }
203
298
  pairs.push(`[v${i}][a${i}]`);
204
299
  }
@@ -225,13 +320,17 @@ export function buildFfmpegArgs(timeline, { resolve, music, out }) {
225
320
  videoLabel = `ov${j}`;
226
321
  });
227
322
 
228
- let audioLabel = "voice";
323
+ filters.push(`${ROOM_TONE}[room]`);
324
+ const beds = ["[room]"];
229
325
  if (music) {
230
326
  const m = addInput("-stream_loop", "-1", "-i", music);
231
- filters.push(`[${m}:a]${stereo},volume=0.18[bed]`);
232
- filters.push(`[voice][bed]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[mix]`);
233
- audioLabel = "mix";
327
+ filters.push(`[${m}:a]${stereo},volume=${MUSIC_VOLUME}[bed]`);
328
+ beds.push("[bed]");
234
329
  }
330
+ filters.push(
331
+ `[voice]${beds.join("")}amix=inputs=${beds.length + 1}:duration=first:dropout_transition=0:normalize=0[mix]`,
332
+ );
333
+ const audioLabel = "mix";
235
334
 
236
335
  return [
237
336
  "-y", "-hide_banner", "-loglevel", "error",
@@ -462,7 +561,7 @@ function main() {
462
561
  }
463
562
  const silent = noWordTimes(timeline);
464
563
  if (silent.length > 0) console.log(`No word times: ${silent.join(", ")}`);
465
- console.log(`Music bed: ${music ?? "none"}`);
564
+ console.log(`Room tone: under the whole ad. Music bed: ${music ?? "none"}`);
466
565
  console.log(`Upload it: exodus video upload ${manifest.runId} --file ${out}`);
467
566
  }
468
567
 
@@ -41,6 +41,7 @@ export type ArtifactSubset = {
41
41
  sceneIndex?: number;
42
42
  audioUrl?: string;
43
43
  durationSec?: number;
44
+ words?: ClipWord[];
44
45
  } | {
45
46
  type: "text" | "primer" | "session" | "document";
46
47
  };
@@ -129,12 +130,16 @@ export type RunStop = {
129
130
  } | {
130
131
  at: "failed";
131
132
  error?: string;
133
+ repair?: true;
134
+ nodeId?: string;
135
+ step?: string;
132
136
  } | {
133
137
  at: "finished";
134
138
  status: string;
135
139
  };
136
140
  export declare function classifyRun(run: VideoRun): RunStop;
137
141
  export declare function stageWord(stage: string): string;
142
+ export declare function stepName(kind: string | undefined): string;
138
143
  export declare function stopLines(stop: RunStop, runId: string, dashboardUrl: string): string[];
139
144
  export interface PullDownload {
140
145
  file: string;
@@ -149,6 +154,7 @@ export interface ManifestScene {
149
154
  durationSec: number | null;
150
155
  clip: string | null;
151
156
  words: string | null;
157
+ wordsFrom: "clip" | "voice" | null;
152
158
  voice: string | null;
153
159
  keyframe: string | null;
154
160
  qc: ClipQc | null;
@@ -208,6 +214,7 @@ export declare function flagFlow(runId: string, note: string, json: boolean, dep
208
214
  export declare function pullFlow(runId: string, dir: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
209
215
  export declare function parseMvhdDurationSec(bytes: Uint8Array): number | null;
210
216
  export declare const NO_DURATION_MESSAGE: string;
217
+ export declare function describeFetchFailure(err: unknown): string;
211
218
  export declare function uploadFlow(runId: string, filePath: string, durationFlag: string | undefined, json: boolean, deps: VideoDeps): Promise<FlowResult>;
212
219
  export declare function parsePositional(args?: string[]): string[];
213
220
  export declare function run(flags: Record<string, string | boolean>): Promise<void>;
@@ -181,6 +181,17 @@ export function classifyRun(run) {
181
181
  if (run.isTerminal)
182
182
  return { at: "finished", status: run.status };
183
183
  if (isAwaitingApproval(run.status)) {
184
+ if (run.pauseReason === "repair") {
185
+ const deadNode = run.nodes.find((n) => n.status === "failed");
186
+ const why = deadNode?.error ?? run.error;
187
+ return {
188
+ at: "failed",
189
+ repair: true,
190
+ nodeId: deadNode?.nodeId ?? run.pausedNodeId,
191
+ ...(deadNode?.kind ? { step: deadNode.kind } : {}),
192
+ ...(why ? { error: why } : {}),
193
+ };
194
+ }
184
195
  const pausedNode = run.nodes.find((n) => n.nodeId === run.pausedNodeId);
185
196
  if (pausedNode && parkedAtStoryboardGate(run, pausedNode)) {
186
197
  return { at: "storyboard-gate", nodeId: pausedNode.nodeId };
@@ -204,6 +215,19 @@ const STAGE_WORDS = {
204
215
  export function stageWord(stage) {
205
216
  return STAGE_WORDS[stage] ?? stage;
206
217
  }
218
+ const STEP_NAMES = {
219
+ brief: "The script read",
220
+ storyboard: "The storyboard",
221
+ reference: "The reference still",
222
+ "scene-frames": "The scene pictures",
223
+ voiceover: "The voices",
224
+ video: "The clips",
225
+ };
226
+ export function stepName(kind) {
227
+ if (!kind)
228
+ return "A step in this run";
229
+ return STEP_NAMES[kind] ?? `The ${kind} step`;
230
+ }
207
231
  export function stopLines(stop, runId, dashboardUrl) {
208
232
  if (stop.at === "storyboard-gate") {
209
233
  return [
@@ -221,6 +245,13 @@ export function stopLines(stop, runId, dashboardUrl) {
221
245
  ];
222
246
  }
223
247
  if (stop.at === "failed") {
248
+ if (stop.repair) {
249
+ return [
250
+ `${stepName(stop.step)} failed${stop.error ? `: ${stop.error}` : "."}`,
251
+ `Try that step again: exodus workflow repair ${runId} retry`,
252
+ `Or open the run: ${dashboardUrl}/video?ad=${runId}`,
253
+ ];
254
+ }
224
255
  return [
225
256
  `This run failed${stop.error ? `: ${stop.error}` : "."}`,
226
257
  `See how far it got: exodus video status ${runId}`,
@@ -305,8 +336,11 @@ export function planPull(run, items, opts) {
305
336
  if (!artifact.audioUrl)
306
337
  continue;
307
338
  voiceByScene.set(artifact.sceneIndex, {
308
- file: `${scenePrefix(artifact.sceneIndex)}.voice.${extFor(artifact.audioUrl, "audio")}`,
309
- url: artifact.audioUrl,
339
+ download: {
340
+ file: `${scenePrefix(artifact.sceneIndex)}.voice.${extFor(artifact.audioUrl, "audio")}`,
341
+ url: artifact.audioUrl,
342
+ },
343
+ words: artifact.words ?? null,
310
344
  });
311
345
  }
312
346
  const clipByScene = new Map();
@@ -345,18 +379,30 @@ export function planPull(run, items, opts) {
345
379
  ].sort((a, b) => a - b);
346
380
  const scenes = sceneIndexes.map((sceneIndex) => {
347
381
  const clip = clipByScene.get(sceneIndex);
382
+ const voice = voiceByScene.get(sceneIndex);
348
383
  const item = clipItemByScene.get(sceneIndex);
349
- let words = null;
384
+ let source = null;
385
+ let wordsFrom = null;
350
386
  if (clip?.words && clip.words.length > 0) {
387
+ source = clip.words;
388
+ wordsFrom = "clip";
389
+ }
390
+ else if (voice?.words && voice.words.length > 0) {
391
+ source = voice.words;
392
+ wordsFrom = "voice";
393
+ }
394
+ let words = null;
395
+ if (source) {
351
396
  words = `${scenePrefix(sceneIndex)}.words.json`;
352
- texts.push({ file: words, body: `${JSON.stringify(clip.words, null, 2)}\n` });
397
+ texts.push({ file: words, body: `${JSON.stringify(source, null, 2)}\n` });
353
398
  }
354
399
  return {
355
400
  sceneIndex,
356
401
  durationSec: clip?.durationSec ?? null,
357
402
  clip: clip?.download.file ?? null,
358
403
  words,
359
- voice: voiceByScene.get(sceneIndex)?.file ?? null,
404
+ wordsFrom,
405
+ voice: voice?.download.file ?? null,
360
406
  keyframe: keyframeByScene.get(sceneIndex)?.file ?? null,
361
407
  qc: clip?.qc ?? null,
362
408
  clipStatus: item?.status ?? "missing",
@@ -368,7 +414,7 @@ export function planPull(run, items, opts) {
368
414
  const downloads = [
369
415
  ...(reference ? [reference] : []),
370
416
  ...keyframeByScene.values(),
371
- ...voiceByScene.values(),
417
+ ...[...voiceByScene.values()].map((v) => v.download),
372
418
  ...[...clipByScene.values()].map((c) => c.download),
373
419
  ...(music ? [music] : []),
374
420
  ];
@@ -396,14 +442,18 @@ export function markPullFailure(manifest, failure) {
396
442
  if (manifest.music === failure.file)
397
443
  manifest.music = null;
398
444
  for (const scene of manifest.scenes) {
445
+ const lostWordsSource = (scene.clip === failure.file && scene.wordsFrom === "clip") ||
446
+ (scene.voice === failure.file && scene.wordsFrom === "voice");
399
447
  if (scene.clip === failure.file)
400
448
  scene.clip = null;
401
449
  if (scene.voice === failure.file)
402
450
  scene.voice = null;
403
451
  if (scene.keyframe === failure.file)
404
452
  scene.keyframe = null;
405
- if (scene.words === failure.file)
453
+ if (scene.words === failure.file || lostWordsSource) {
406
454
  scene.words = null;
455
+ scene.wordsFrom = null;
456
+ }
407
457
  }
408
458
  }
409
459
  const PULL_CONCURRENCY = 4;
@@ -607,7 +657,10 @@ export async function statusFlow(runId, json, deps) {
607
657
  row[item.itemKind] = item;
608
658
  byScene.set(item.sceneIndex, row);
609
659
  }
610
- const lines = [`Ad run ${runId} — ${displayRunStatus(run.status)}`, ...stopLines(stop, runId, deps.dashboardUrl)];
660
+ const headline = stop.at === "failed" && stop.repair
661
+ ? `Ad run ${runId} — Stopped, a step failed`
662
+ : `Ad run ${runId} — ${displayRunStatus(run.status)}`;
663
+ const lines = [headline, ...stopLines(stop, runId, deps.dashboardUrl)];
611
664
  if (byScene.size === 0) {
612
665
  lines.push("", "No scenes yet — this run hasn't made anything to look at.");
613
666
  }
@@ -848,6 +901,70 @@ export function parseMvhdDurationSec(bytes) {
848
901
  export const NO_DURATION_MESSAGE = "Can't tell how long this cut is.\n" +
849
902
  "Install ffmpeg (which brings ffprobe), or pass the length yourself: --duration <seconds>";
850
903
  const MB = 1024 * 1024;
904
+ const UPLOAD_ATTEMPTS = 3;
905
+ const UPLOAD_BACKOFF_MS = [500, 1500];
906
+ const UPLOAD_STEP_LABEL = {
907
+ mint: "asking the server for an upload slot",
908
+ store: "sending the file to storage",
909
+ register: "registering the uploaded file",
910
+ attach: "attaching the cut to the run",
911
+ };
912
+ export function describeFetchFailure(err) {
913
+ if (!(err instanceof Error))
914
+ return String(err);
915
+ const parts = [];
916
+ let cause = err.cause;
917
+ for (let depth = 0; depth < 2 && cause && typeof cause === "object"; depth++) {
918
+ const level = cause;
919
+ const code = typeof level.code === "string" && level.code ? level.code : undefined;
920
+ const message = typeof level.message === "string" && level.message ? level.message : undefined;
921
+ if (code && message)
922
+ parts.push(`${code}: ${message}`);
923
+ else if (code)
924
+ parts.push(code);
925
+ else if (message)
926
+ parts.push(message);
927
+ cause = level.cause;
928
+ }
929
+ return parts.length > 0 ? `${err.message} (${parts.join("; ")})` : err.message;
930
+ }
931
+ async function withUploadRetry(step, deps, fn) {
932
+ let cause = "";
933
+ for (let attempt = 1; attempt <= UPLOAD_ATTEMPTS; attempt++) {
934
+ try {
935
+ return { ok: true, value: await fn() };
936
+ }
937
+ catch (e) {
938
+ cause = describeFetchFailure(e);
939
+ const wait = UPLOAD_BACKOFF_MS[attempt - 1];
940
+ if (attempt < UPLOAD_ATTEMPTS && wait !== undefined)
941
+ await deps.sleep(wait);
942
+ }
943
+ }
944
+ return { ok: false, step, attempts: UPLOAD_ATTEMPTS, cause };
945
+ }
946
+ function uploadRetryResult(failed, json) {
947
+ if (json) {
948
+ return {
949
+ code: 1,
950
+ lines: [
951
+ JSON.stringify({
952
+ ok: false,
953
+ step: failed.step,
954
+ attempts: failed.attempts,
955
+ error: failed.cause,
956
+ }),
957
+ ],
958
+ };
959
+ }
960
+ return {
961
+ code: 1,
962
+ lines: [
963
+ `Upload failed while ${UPLOAD_STEP_LABEL[failed.step]} (${failed.attempts} tries): ${failed.cause}`,
964
+ "Check your connection and run the same command again.",
965
+ ],
966
+ };
967
+ }
851
968
  export async function uploadFlow(runId, filePath, durationFlag, json, deps) {
852
969
  const stat = deps.statFile(filePath);
853
970
  if (!stat)
@@ -890,34 +1007,50 @@ export async function uploadFlow(runId, filePath, durationFlag, json, deps) {
890
1007
  const durationSec = asked ?? deps.probeDurationSec(filePath) ?? parseMvhdDurationSec(bytes);
891
1008
  if (durationSec === null)
892
1009
  return { code: 1, lines: [NO_DURATION_MESSAGE] };
893
- const mint = await deps.post(ASSET_UPLOAD_URL_PATH, {});
1010
+ const minting = await withUploadRetry("mint", deps, () => deps.post(ASSET_UPLOAD_URL_PATH, {}));
1011
+ if (!minting.ok)
1012
+ return uploadRetryResult(minting, json);
1013
+ const mint = minting.value;
894
1014
  if (!mint.ok)
895
1015
  return errorResult(mint, json);
896
1016
  const minted = mint.data;
897
- if (!minted.uploadUrl || !minted.receiptId) {
1017
+ const uploadUrl = minted.uploadUrl;
1018
+ const receiptId = minted.receiptId;
1019
+ if (!uploadUrl || !receiptId) {
898
1020
  return { code: 1, lines: ["The server did not hand back a place to upload to."] };
899
1021
  }
900
- const put = await deps.uploadBytes(minted.uploadUrl, mime, bytes);
1022
+ const storing = await withUploadRetry("store", deps, () => deps.uploadBytes(uploadUrl, mime, bytes));
1023
+ if (!storing.ok)
1024
+ return uploadRetryResult(storing, json);
1025
+ const put = storing.value;
901
1026
  if (!put.ok || !put.storageId) {
902
1027
  const detail = put.body ? `: ${put.body.slice(0, 200)}` : "";
903
1028
  return { code: 1, lines: [`Upload failed (HTTP ${put.status})${detail}`] };
904
1029
  }
905
- const registered = await deps.post(ASSETS_PATH, {
906
- storageId: put.storageId,
907
- receiptId: minted.receiptId,
1030
+ const storageId = put.storageId;
1031
+ const registering = await withUploadRetry("register", deps, () => deps.post(ASSETS_PATH, {
1032
+ storageId,
1033
+ receiptId,
908
1034
  filename: path.basename(filePath),
909
- });
1035
+ }));
1036
+ if (!registering.ok)
1037
+ return uploadRetryResult(registering, json);
1038
+ const registered = registering.value;
910
1039
  if (!registered.ok)
911
1040
  return errorResult(registered, json);
912
1041
  const asset = registered.data;
913
- if (!asset.assetId) {
1042
+ const assetId = asset.assetId;
1043
+ if (!assetId) {
914
1044
  return { code: 1, lines: ["The server stored the file but did not say what to call it."] };
915
1045
  }
916
- const attached = await deps.post(FINAL_PATH, {
1046
+ const attaching = await withUploadRetry("attach", deps, () => deps.post(FINAL_PATH, {
917
1047
  runId,
918
- assetId: asset.assetId,
1048
+ assetId,
919
1049
  durationSec,
920
- });
1050
+ }));
1051
+ if (!attaching.ok)
1052
+ return uploadRetryResult(attaching, json);
1053
+ const attached = attaching.value;
921
1054
  if (!attached.ok)
922
1055
  return errorResult(attached, json);
923
1056
  const final = attached.data;
@@ -925,7 +1058,7 @@ export async function uploadFlow(runId, filePath, durationFlag, json, deps) {
925
1058
  if (json) {
926
1059
  return {
927
1060
  code: 0,
928
- lines: [JSON.stringify({ ok: true, runId, assetId: asset.assetId, durationSec, finalWatchUrl })],
1061
+ lines: [JSON.stringify({ ok: true, runId, assetId, durationSec, finalWatchUrl })],
929
1062
  };
930
1063
  }
931
1064
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicopycoders/exodus",
3
- "version": "2026.9.1100-beta.1",
3
+ "version": "2026.9.1100-beta.3",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",