@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.
- package/assets/docs/PIPELINES.md +4 -0
- package/assets/skills/exodus-video/SKILL.md +191 -17
- package/dist/bin/exodus.js +3 -36
- package/dist/commands/video.d.ts +36 -3
- package/dist/commands/video.js +458 -39
- package/dist/commands/workflow.d.ts +11 -7
- package/dist/commands/workflow.js +144 -84
- package/dist/lib/args.d.ts +10 -0
- package/dist/lib/args.js +39 -0
- package/package.json +1 -1
package/dist/commands/video.js
CHANGED
|
@@ -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
|
|
76
|
-
--node <nodeId> Which scene-frames node holds the still (retry-frame)
|
|
77
|
-
|
|
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
|
|
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: ${
|
|
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: ${
|
|
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
|
|
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:
|
|
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 ??
|
|
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 {
|
|
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 = [
|
|
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
|
-
|
|
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 = (
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
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 (
|
|
1069
|
-
|
|
1070
|
-
|
|
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 (
|
|
1082
|
-
const
|
|
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 (
|
|
1090
|
-
const
|
|
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
|
|
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 ??
|
|
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(
|
|
2001
|
+
voices = parseVoiceFlags(occurrences, defaultDeps.readFile);
|
|
1583
2002
|
}
|
|
1584
2003
|
catch (err) {
|
|
1585
2004
|
usage(err.message);
|