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

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}
@@ -185,6 +189,9 @@ the same words grouped into short subtitle lines. `--out v2.mp4` names them
185
189
 
186
190
  The times are on the finished ad's timeline, so a word's time is where you hear
187
191
  it in the MP4. Cutaways contribute nothing, because their audio never plays. A
192
+ narrated scene counts even when it is only a picture and a voice track: its word
193
+ times come from the voice track, and `manifest.json` says `wordsFrom: "clip"` or
194
+ `"voice"` per scene so you can tell which file a scene's times were read off. A
188
195
  scene that came with no word times leaves a gap, and the report names it. When no
189
196
  scene delivered word times the script writes neither file and says so. Tell the
190
197
  user the two files exist and that they are what the editor imports for captions.
@@ -134,7 +134,9 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
134
134
  spine.push({
135
135
  sceneIndex: n, source: "still", file: scene.keyframe, seconds,
136
136
  hasAudio: false, voice, startSec, lineIds: plan.lineIds,
137
- words: null,
137
+ // #1689: on a narrated scene the words are the voice track's, and the
138
+ // voice starts where the segment does, so they need no offset here.
139
+ words: voice ? readWords(scene.words) : null,
138
140
  note: `${voice ? ` + ${voice}` : ", silent"} (${seconds.toFixed(1)}s, clip ${scene.clipStatus})`,
139
141
  });
140
142
  startSec += seconds;
@@ -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.2",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",