@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
|
|
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
|
-
|
|
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;
|
package/dist/commands/video.d.ts
CHANGED
|
@@ -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>;
|
package/dist/commands/video.js
CHANGED
|
@@ -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
|
-
|
|
309
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
906
|
-
|
|
907
|
-
|
|
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
|
-
|
|
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
|
|
1046
|
+
const attaching = await withUploadRetry("attach", deps, () => deps.post(FINAL_PATH, {
|
|
917
1047
|
runId,
|
|
918
|
-
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
|
|
1061
|
+
lines: [JSON.stringify({ ok: true, runId, assetId, durationSec, finalWatchUrl })],
|
|
929
1062
|
};
|
|
930
1063
|
}
|
|
931
1064
|
return {
|