@aicopycoders/exodus 2026.9.1700-beta.1 → 2026.9.1900-beta.0
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 +3 -0
- package/assets/skills/exodus-video/SKILL.md +217 -18
- package/dist/commands/video.d.ts +74 -1
- package/dist/commands/video.js +415 -22
- package/dist/commands/workflow.d.ts +3 -0
- package/dist/commands/workflow.js +116 -4
- package/package.json +1 -1
package/dist/commands/video.js
CHANGED
|
@@ -25,24 +25,32 @@ The whole loop, in order:
|
|
|
25
25
|
3. exodus video storyboard <runId>
|
|
26
26
|
The scene cards: what each scene says and the picture it will look like.
|
|
27
27
|
|
|
28
|
-
4. exodus video
|
|
28
|
+
4. exodus video voices <runId>
|
|
29
|
+
Who is in the ad, which voice each one has, how the voices get applied
|
|
30
|
+
and who pays. Add --set to give someone a voice, before any clip is made.
|
|
31
|
+
|
|
32
|
+
5. exodus video approve <runId> (looks right — keep going)
|
|
29
33
|
exodus video flag <runId> --note "…" (something is wrong — send it back)
|
|
30
34
|
exodus video retry-frame <runId> --node <nodeId> --scene <n>
|
|
31
35
|
Redo ONE still at the pixel gate. Neighbours stay. The gate holds.
|
|
32
36
|
|
|
33
|
-
|
|
37
|
+
6. exodus video status <runId>
|
|
34
38
|
Where the run is and how each scene's clip turned out.
|
|
35
39
|
|
|
36
|
-
|
|
40
|
+
7. exodus video pull <runId> --out ./ad
|
|
37
41
|
Writes every piece to that folder plus a manifest.json index.
|
|
38
42
|
|
|
39
|
-
|
|
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
|
+
8. Make your cut from those files.
|
|
40
48
|
|
|
41
|
-
|
|
49
|
+
9. exodus video upload <runId> --file cut.mp4
|
|
42
50
|
Attaches your cut to the run and prints the page to approve it on.
|
|
43
51
|
|
|
44
|
-
|
|
45
|
-
|
|
52
|
+
10. exodus video approve <runId>
|
|
53
|
+
Or click Approve on the page from step 9.
|
|
46
54
|
|
|
47
55
|
Usage:
|
|
48
56
|
exodus video shows [--json]
|
|
@@ -52,6 +60,8 @@ Usage:
|
|
|
52
60
|
exodus video approve <runId> [--json]
|
|
53
61
|
exodus video flag <runId> --note "<what is wrong>" [--json]
|
|
54
62
|
exodus video retry-frame <runId> --node <nodeId> --scene <n> [--note "..."] [--json]
|
|
63
|
+
exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "..."] [--json]
|
|
64
|
+
exodus video voices <runId> [--set <character>=<voiceId>] [--clear <character>] [--from <file.json>] [--json]
|
|
55
65
|
exodus video pull <runId> --out <dir> [--json]
|
|
56
66
|
exodus video upload <runId> --file <cut.mp4> [--duration <sec>] [--json]
|
|
57
67
|
|
|
@@ -67,9 +77,18 @@ Options:
|
|
|
67
77
|
--duration <sec> How long your cut is, in seconds. Only needed when the
|
|
68
78
|
length can't be read off the file itself
|
|
69
79
|
--note "<text>" What is wrong with the storyboard (flag), or how to
|
|
70
|
-
steer one
|
|
71
|
-
--node <nodeId> Which scene-frames node holds the still (retry-frame)
|
|
72
|
-
|
|
80
|
+
steer one redo (retry-frame, retry-clip)
|
|
81
|
+
--node <nodeId> Which scene-frames node holds the still (retry-frame).
|
|
82
|
+
Which video step holds the clip, when a scene has one on
|
|
83
|
+
more than one step (retry-clip)
|
|
84
|
+
--scene <n> Which scene to redo. One scene only, a whole number
|
|
85
|
+
(retry-frame, retry-clip)
|
|
86
|
+
--set <who>=<id> Give a character an ElevenLabs voice (voices). Name the
|
|
87
|
+
character by its ID or by the name your script uses.
|
|
88
|
+
Repeat it once per character
|
|
89
|
+
--clear <who> Take a character's voice off again (voices). Repeatable
|
|
90
|
+
--from <file.json> A file of characters and voice IDs (voices). --set and
|
|
91
|
+
--clear win over the same name in the file
|
|
73
92
|
--json Machine-readable output
|
|
74
93
|
--help, -h Show this help
|
|
75
94
|
|
|
@@ -82,6 +101,10 @@ Examples:
|
|
|
82
101
|
exodus video storyboard run_123
|
|
83
102
|
exodus video approve run_123
|
|
84
103
|
exodus video retry-frame run_123 --node frames-1 --scene 2
|
|
104
|
+
exodus video retry-clip run_123 --scene 3 --note "keep the handshake in frame"
|
|
105
|
+
exodus video voices run_123
|
|
106
|
+
exodus video voices run_123 --set C1=abc123voiceid --set "HOST 2=def456voiceid"
|
|
107
|
+
exodus video voices run_123 --from voices.json
|
|
85
108
|
exodus video pull run_123 --out ./ad-run_123
|
|
86
109
|
exodus video upload run_123 --file ./cut.mp4
|
|
87
110
|
`.trim();
|
|
@@ -94,6 +117,7 @@ const FLAG_PATH = "/api/v2/video/storyboard/flag";
|
|
|
94
117
|
const APPROVE_PATH = "/api/v2/workflow/approve";
|
|
95
118
|
const SCENE_RETRY_PATH = "/api/v2/workflow/scene/retry";
|
|
96
119
|
const FINAL_PATH = "/api/v2/video/final";
|
|
120
|
+
export const VOICES_PATH = "/api/v2/video/voices";
|
|
97
121
|
const ASSET_UPLOAD_URL_PATH = "/api/v2/workflows/asset-upload-url";
|
|
98
122
|
const ASSETS_PATH = "/api/v2/workflows/assets";
|
|
99
123
|
function withoutJudgeFields({ judgeDetail: _d, judgeSeverity: _s, ...rest }) {
|
|
@@ -245,7 +269,14 @@ export function stepName(kind) {
|
|
|
245
269
|
return "A step in this run";
|
|
246
270
|
return STEP_NAMES[kind] ?? `The ${kind} step`;
|
|
247
271
|
}
|
|
248
|
-
export function
|
|
272
|
+
export function reviewUrl(dashboardUrl, run) {
|
|
273
|
+
if (run.moduleOwned === true)
|
|
274
|
+
return `${dashboardUrl}/video?ad=${run._id}`;
|
|
275
|
+
if (run.workflowId)
|
|
276
|
+
return `${dashboardUrl}/workflows/${run.workflowId}/runs/${run._id}`;
|
|
277
|
+
return `${dashboardUrl}/runs/${run._id}`;
|
|
278
|
+
}
|
|
279
|
+
export function stopLines(stop, runId, runUrl) {
|
|
249
280
|
if (stop.at === "storyboard-gate") {
|
|
250
281
|
const lines = [
|
|
251
282
|
"Parked: the storyboard is waiting for your yes.",
|
|
@@ -263,6 +294,8 @@ export function stopLines(stop, runId, dashboardUrl) {
|
|
|
263
294
|
"Parked: every piece is made and the run is waiting for a cut.",
|
|
264
295
|
`Pull the pieces: exodus video pull ${runId} --out ./ad-${runId}`,
|
|
265
296
|
`Then upload: exodus video upload ${runId} --file cut.mp4`,
|
|
297
|
+
`Redo one clip: exodus video retry-clip ${runId} --scene <n>`,
|
|
298
|
+
`Watch it here: ${runUrl}`,
|
|
266
299
|
];
|
|
267
300
|
}
|
|
268
301
|
if (stop.at === "failed") {
|
|
@@ -270,7 +303,7 @@ export function stopLines(stop, runId, dashboardUrl) {
|
|
|
270
303
|
return [
|
|
271
304
|
`${stepName(stop.step)} failed${stop.error ? `: ${stop.error}` : "."}`,
|
|
272
305
|
`Try that step again: exodus workflow repair ${runId} retry`,
|
|
273
|
-
`Or open the run: ${
|
|
306
|
+
`Or open the run: ${runUrl}`,
|
|
274
307
|
];
|
|
275
308
|
}
|
|
276
309
|
return [
|
|
@@ -287,7 +320,7 @@ export function stopLines(stop, runId, dashboardUrl) {
|
|
|
287
320
|
if (stop.at === "paused") {
|
|
288
321
|
return [
|
|
289
322
|
`Parked: this run is waiting on someone${stop.reason ? ` (${stop.reason})` : ""}.`,
|
|
290
|
-
`Open it: ${
|
|
323
|
+
`Open it: ${runUrl}`,
|
|
291
324
|
];
|
|
292
325
|
}
|
|
293
326
|
return [`Working: ${stageWord(stop.stage)}.`];
|
|
@@ -343,7 +376,31 @@ function reservedCastFrames(items) {
|
|
|
343
376
|
}
|
|
344
377
|
return byIndex;
|
|
345
378
|
}
|
|
346
|
-
function
|
|
379
|
+
function castVoicePins(artifact) {
|
|
380
|
+
const pins = new Map();
|
|
381
|
+
let envelope = artifact?.storyboard;
|
|
382
|
+
if (envelope === undefined && typeof artifact?.storyboardJson === "string") {
|
|
383
|
+
try {
|
|
384
|
+
envelope = JSON.parse(artifact.storyboardJson);
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
return pins;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
const cast = envelope?.cast;
|
|
391
|
+
if (!Array.isArray(cast))
|
|
392
|
+
return pins;
|
|
393
|
+
for (const member of cast) {
|
|
394
|
+
const characterId = typeof member.characterId === "string" ? member.characterId.trim() : "";
|
|
395
|
+
const voiceId = typeof member.voiceId === "string" ? member.voiceId.trim() : "";
|
|
396
|
+
if (!characterId || !voiceId)
|
|
397
|
+
continue;
|
|
398
|
+
const label = typeof member.voiceLabel === "string" ? member.voiceLabel.trim() : "";
|
|
399
|
+
pins.set(characterId, { voiceId, voiceLabel: label ? label : null });
|
|
400
|
+
}
|
|
401
|
+
return pins;
|
|
402
|
+
}
|
|
403
|
+
function planCastRefs(run, items, pins) {
|
|
347
404
|
const ledger = reservedCastFrames(items);
|
|
348
405
|
const ledgerRows = [...ledger.entries()].sort(([a], [b]) => a - b);
|
|
349
406
|
const locked = run.castLock?.cast;
|
|
@@ -407,12 +464,15 @@ function planCastRefs(run, items) {
|
|
|
407
464
|
const downloads = [];
|
|
408
465
|
for (const row of rows) {
|
|
409
466
|
const file = row.url ? `${row.stem}.${extFor(row.url, "image")}` : null;
|
|
467
|
+
const pin = row.characterId ? pins.get(row.characterId) : undefined;
|
|
410
468
|
cast.push({
|
|
411
469
|
characterId: row.characterId,
|
|
412
470
|
name: row.name,
|
|
413
471
|
file,
|
|
414
472
|
status: row.status,
|
|
415
473
|
error: row.error,
|
|
474
|
+
voiceId: pin?.voiceId ?? null,
|
|
475
|
+
voiceLabel: pin?.voiceLabel ?? null,
|
|
416
476
|
});
|
|
417
477
|
if (file && row.url)
|
|
418
478
|
downloads.push({ file, url: row.url });
|
|
@@ -583,6 +643,11 @@ export function planPull(run, items, opts) {
|
|
|
583
643
|
clip: clip?.download.file ?? null,
|
|
584
644
|
words,
|
|
585
645
|
wordsFrom,
|
|
646
|
+
wordsDescribe: wordsFrom === null
|
|
647
|
+
? null
|
|
648
|
+
: clip?.revoiced === true && wordsFrom === "clip"
|
|
649
|
+
? "original-performance"
|
|
650
|
+
: "this-file",
|
|
586
651
|
voice: voice?.download.file ?? null,
|
|
587
652
|
keyframe: keyframeByScene.get(sceneIndex)?.file ?? null,
|
|
588
653
|
qc: clip?.qc ?? null,
|
|
@@ -595,7 +660,7 @@ export function planPull(run, items, opts) {
|
|
|
595
660
|
keyframeFindings: frameItem?.findings?.map(withoutJudgeFields) ?? null,
|
|
596
661
|
};
|
|
597
662
|
});
|
|
598
|
-
const { cast, downloads: castDownloads } = planCastRefs(run, items);
|
|
663
|
+
const { cast, downloads: castDownloads } = planCastRefs(run, items, castVoicePins(storyboardArtifact));
|
|
599
664
|
const downloads = [
|
|
600
665
|
...(reference ? [reference] : []),
|
|
601
666
|
...keyframeByScene.values(),
|
|
@@ -611,7 +676,7 @@ export function planPull(run, items, opts) {
|
|
|
611
676
|
manifest: {
|
|
612
677
|
runId: run._id,
|
|
613
678
|
pulledAt: opts.pulledAt,
|
|
614
|
-
dashboardUrl:
|
|
679
|
+
dashboardUrl: reviewUrl(opts.dashboardUrl, run),
|
|
615
680
|
storyboard,
|
|
616
681
|
reference: reference?.file ?? null,
|
|
617
682
|
music: music?.file ?? null,
|
|
@@ -653,6 +718,7 @@ export function markPullFailure(manifest, failure) {
|
|
|
653
718
|
if (scene.words === failure.file || lostWordsSource) {
|
|
654
719
|
scene.words = null;
|
|
655
720
|
scene.wordsFrom = null;
|
|
721
|
+
scene.wordsDescribe = null;
|
|
656
722
|
}
|
|
657
723
|
}
|
|
658
724
|
}
|
|
@@ -758,7 +824,7 @@ export async function startFlow(opts, deps) {
|
|
|
758
824
|
return { code: 1, lines: ["The server started the ad but did not say which run it is."] };
|
|
759
825
|
}
|
|
760
826
|
const runId = started.runId;
|
|
761
|
-
const url = started.url ??
|
|
827
|
+
const url = started.url ?? reviewUrl(deps.dashboardUrl, { _id: runId });
|
|
762
828
|
if (!opts.wait) {
|
|
763
829
|
const lines = [
|
|
764
830
|
`Started ad run ${runId}`,
|
|
@@ -811,7 +877,7 @@ export async function waitFlow(runId, opts, deps) {
|
|
|
811
877
|
}
|
|
812
878
|
return {
|
|
813
879
|
code: stop.at === "failed" ? 1 : 0,
|
|
814
|
-
lines: [...lines, ...stopLines(stop, runId, deps.dashboardUrl)],
|
|
880
|
+
lines: [...lines, ...stopLines(stop, runId, reviewUrl(deps.dashboardUrl, run))],
|
|
815
881
|
};
|
|
816
882
|
}
|
|
817
883
|
const timeoutLine = `Still running after ${Math.round((maxPolls * interval) / 60000)} minutes. Check in with: exodus video status ${runId}`;
|
|
@@ -845,13 +911,22 @@ export async function statusFlow(runId, json, deps) {
|
|
|
845
911
|
const items = itemsRes.data.items ?? [];
|
|
846
912
|
const stop = classifyRun(run);
|
|
847
913
|
const hasFinal = items.some((i) => i.itemKind === "final" && i.status === "done");
|
|
914
|
+
const warnings = run.nodes
|
|
915
|
+
.filter((n) => n.warning)
|
|
916
|
+
.map((n) => ({ nodeId: n.nodeId, step: n.kind, warning: n.warning }));
|
|
848
917
|
if (json) {
|
|
849
|
-
return {
|
|
918
|
+
return {
|
|
919
|
+
code: 0,
|
|
920
|
+
lines: [JSON.stringify({ runId, status: run.status, stop, items, hasFinal, warnings })],
|
|
921
|
+
};
|
|
850
922
|
}
|
|
851
923
|
const revoicedByScene = new Set();
|
|
924
|
+
const timedOnOriginalVoice = new Set();
|
|
852
925
|
for (const artifact of outputsOfNodeKind(run, "video")) {
|
|
853
926
|
if (artifact.type === "video" && artifact.revoiced === true && typeof artifact.sceneIndex === "number") {
|
|
854
927
|
revoicedByScene.add(artifact.sceneIndex);
|
|
928
|
+
if ((artifact.words?.length ?? 0) > 0)
|
|
929
|
+
timedOnOriginalVoice.add(artifact.sceneIndex);
|
|
855
930
|
}
|
|
856
931
|
}
|
|
857
932
|
const byScene = new Map();
|
|
@@ -871,7 +946,11 @@ export async function statusFlow(runId, json, deps) {
|
|
|
871
946
|
const headline = stop.at === "failed" && stop.repair
|
|
872
947
|
? `Ad run ${runId} — Stopped, a step failed`
|
|
873
948
|
: `Ad run ${runId} — ${displayRunStatus(run.status)}`;
|
|
874
|
-
const lines = [
|
|
949
|
+
const lines = [
|
|
950
|
+
headline,
|
|
951
|
+
...stopLines(stop, runId, reviewUrl(deps.dashboardUrl, run)),
|
|
952
|
+
...warnings.map((w) => `Heads-up (${w.step}): ${w.warning}`),
|
|
953
|
+
];
|
|
875
954
|
if (byScene.size === 0) {
|
|
876
955
|
lines.push("", "No scenes yet — this run hasn't made anything to look at.");
|
|
877
956
|
}
|
|
@@ -894,6 +973,9 @@ export async function statusFlow(runId, json, deps) {
|
|
|
894
973
|
lines.push(` ${row.clip.error}`);
|
|
895
974
|
if (revoicedByScene.has(sceneIndex))
|
|
896
975
|
lines.push(" voice: cast voice applied");
|
|
976
|
+
if (timedOnOriginalVoice.has(sceneIndex)) {
|
|
977
|
+
lines.push(" word timings: measured on the original voice (close, not frame-exact)");
|
|
978
|
+
}
|
|
897
979
|
for (const finding of row.frame?.findings ?? []) {
|
|
898
980
|
lines.push(` picture: ${finding.code} (${finding.severity}): ${finding.detail}`);
|
|
899
981
|
}
|
|
@@ -943,6 +1025,23 @@ export async function storyboardFlow(runId, json, deps) {
|
|
|
943
1025
|
for (const scene of loose)
|
|
944
1026
|
lines.push(...sceneCardLines(scene));
|
|
945
1027
|
}
|
|
1028
|
+
const voiceRes = await deps
|
|
1029
|
+
.get(`${VOICES_PATH}?runId=${encodeURIComponent(runId)}`)
|
|
1030
|
+
.catch(() => null);
|
|
1031
|
+
if (voiceRes?.ok) {
|
|
1032
|
+
const sheet = (voiceRes.data ?? {});
|
|
1033
|
+
const cast = Array.isArray(sheet.cast) ? sheet.cast : [];
|
|
1034
|
+
const canChange = sheet.canChange !== false;
|
|
1035
|
+
if (canChange || cast.some((row) => row.voice !== null)) {
|
|
1036
|
+
const summary = voiceSummaryLines(cast, Array.isArray(sheet.notices) ? sheet.notices : []);
|
|
1037
|
+
if (summary.length > 0) {
|
|
1038
|
+
lines.push("", ...summary);
|
|
1039
|
+
if (canChange) {
|
|
1040
|
+
lines.push(`set voices with: exodus video voices ${runId} --set <character>=<voiceId>`);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
946
1045
|
lines.push("", `approve with: exodus video approve ${runId}`, `send it back: exodus video flag ${runId} --note "what's wrong"`);
|
|
947
1046
|
if (cards.framesNodeId) {
|
|
948
1047
|
lines.push(`redo one frame: exodus video retry-frame ${runId} --node ${cards.framesNodeId} --scene <n>`);
|
|
@@ -977,10 +1076,16 @@ export async function approveFlow(runId, json, deps) {
|
|
|
977
1076
|
return errorResult(res, json);
|
|
978
1077
|
if (json)
|
|
979
1078
|
return { code: 0, lines: [JSON.stringify({ ok: true, runId, data: res.data })] };
|
|
1079
|
+
const voices = res.data
|
|
1080
|
+
?.voices;
|
|
1081
|
+
const summary = voices
|
|
1082
|
+
? voiceSummaryLines(Array.isArray(voices.cast) ? voices.cast : [], Array.isArray(voices.notices) ? voices.notices : [])
|
|
1083
|
+
: [];
|
|
980
1084
|
return {
|
|
981
1085
|
code: 0,
|
|
982
1086
|
lines: [
|
|
983
1087
|
"Approved.",
|
|
1088
|
+
...(summary.length > 0 ? ["", ...summary, ""] : []),
|
|
984
1089
|
`See what happens next: exodus video status ${runId}`,
|
|
985
1090
|
],
|
|
986
1091
|
};
|
|
@@ -1037,6 +1142,260 @@ export async function retryFrameFlow(runId, nodeId, sceneIndex, note, json, deps
|
|
|
1037
1142
|
],
|
|
1038
1143
|
};
|
|
1039
1144
|
}
|
|
1145
|
+
const UPLOADED_CUT_WARNING = "The cut you already uploaded will not include the new clip. Pull the pieces again, " +
|
|
1146
|
+
"re-cut, and upload again.";
|
|
1147
|
+
export function planClipRedo(run, items, target) {
|
|
1148
|
+
const stop = classifyRun(run);
|
|
1149
|
+
const uploadedCut = items.some((i) => i.itemKind === "final" && i.status === "done");
|
|
1150
|
+
const rows = items.filter((i) => i.itemKind === "clip" &&
|
|
1151
|
+
i.sceneIndex === target.sceneIndex &&
|
|
1152
|
+
(target.nodeId === undefined || i.nodeId === target.nodeId));
|
|
1153
|
+
if (rows.length === 0) {
|
|
1154
|
+
return { ok: false, reason: `Scene ${target.sceneIndex} has no clip to redo.` };
|
|
1155
|
+
}
|
|
1156
|
+
const nodeIds = [...new Set(rows.map((r) => r.nodeId))];
|
|
1157
|
+
if (nodeIds.length > 1) {
|
|
1158
|
+
return {
|
|
1159
|
+
ok: false,
|
|
1160
|
+
reason: `Scene ${target.sceneIndex} has a clip on more than one step (${nodeIds.join(", ")}), ` +
|
|
1161
|
+
"so say which one: --node <nodeId>.",
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
const row = rows[0];
|
|
1165
|
+
const node = run.nodes.find((n) => n.nodeId === row.nodeId);
|
|
1166
|
+
if (node?.status === "running") {
|
|
1167
|
+
return {
|
|
1168
|
+
ok: false,
|
|
1169
|
+
reason: `The "${row.nodeId}" step is still making other scenes. Try again once it has finished ` +
|
|
1170
|
+
`(exodus video status ${run._id}).`,
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1173
|
+
if (node && node.status !== "done") {
|
|
1174
|
+
return {
|
|
1175
|
+
ok: false,
|
|
1176
|
+
reason: `The "${row.nodeId}" step did not finish (it is "${node.status}"), ` +
|
|
1177
|
+
"and one clip can only be redone on a step that finished.",
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
if (row.status === "running") {
|
|
1181
|
+
return { ok: false, reason: `Scene ${target.sceneIndex} is already being redone.` };
|
|
1182
|
+
}
|
|
1183
|
+
const redoable = row.status === "failed" ||
|
|
1184
|
+
(row.status === "done" && (row.flagged === true || stop.at === "final-watch"));
|
|
1185
|
+
if (!redoable) {
|
|
1186
|
+
if (row.status === "done") {
|
|
1187
|
+
return {
|
|
1188
|
+
ok: false,
|
|
1189
|
+
reason: `Scene ${target.sceneIndex}'s clip is finished and nothing flagged it. A finished clip ` +
|
|
1190
|
+
"can only be redone while the run is waiting for the final cut.",
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
const word = ITEM_STATUS_WORD[row.status] ?? row.status;
|
|
1194
|
+
return {
|
|
1195
|
+
ok: false,
|
|
1196
|
+
reason: `Scene ${target.sceneIndex}'s clip is ${word}, so there is nothing to redo yet.`,
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
return {
|
|
1200
|
+
ok: true,
|
|
1201
|
+
nodeId: row.nodeId,
|
|
1202
|
+
sceneIndex: target.sceneIndex,
|
|
1203
|
+
attempt: row.attempt ?? 0,
|
|
1204
|
+
warnings: uploadedCut ? [UPLOADED_CUT_WARNING] : [],
|
|
1205
|
+
};
|
|
1206
|
+
}
|
|
1207
|
+
export async function retryClipFlow(runId, target, note, json, deps) {
|
|
1208
|
+
const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1209
|
+
if (!runRes.ok)
|
|
1210
|
+
return errorResult(runRes, json);
|
|
1211
|
+
const run = asVideoRun(runRes.data);
|
|
1212
|
+
const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1213
|
+
if (!itemsRes.ok)
|
|
1214
|
+
return errorResult(itemsRes, json);
|
|
1215
|
+
const items = itemsRes.data.items ?? [];
|
|
1216
|
+
const plan = planClipRedo(run, items, target);
|
|
1217
|
+
if (!plan.ok) {
|
|
1218
|
+
return {
|
|
1219
|
+
code: 1,
|
|
1220
|
+
lines: json ? [JSON.stringify({ ok: false, error: plan.reason })] : [plan.reason],
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
const res = await deps.post(SCENE_RETRY_PATH, {
|
|
1224
|
+
runId,
|
|
1225
|
+
nodeId: plan.nodeId,
|
|
1226
|
+
sceneIndex: plan.sceneIndex,
|
|
1227
|
+
...(note ? { note } : {}),
|
|
1228
|
+
});
|
|
1229
|
+
if (!res.ok)
|
|
1230
|
+
return errorResult(res, json);
|
|
1231
|
+
const triggerRunId = res.data.triggerRunId;
|
|
1232
|
+
const review = reviewUrl(deps.dashboardUrl, run);
|
|
1233
|
+
if (json) {
|
|
1234
|
+
return {
|
|
1235
|
+
code: 0,
|
|
1236
|
+
lines: [
|
|
1237
|
+
JSON.stringify({
|
|
1238
|
+
ok: true,
|
|
1239
|
+
runId,
|
|
1240
|
+
nodeId: plan.nodeId,
|
|
1241
|
+
sceneIndex: plan.sceneIndex,
|
|
1242
|
+
triggerRunId,
|
|
1243
|
+
attemptBefore: plan.attempt,
|
|
1244
|
+
reviewUrl: review,
|
|
1245
|
+
warnings: plan.warnings,
|
|
1246
|
+
...(note ? { note } : {}),
|
|
1247
|
+
}),
|
|
1248
|
+
],
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
return {
|
|
1252
|
+
code: 0,
|
|
1253
|
+
lines: [
|
|
1254
|
+
`Redoing scene ${plan.sceneIndex}'s clip on ${plan.nodeId}.`,
|
|
1255
|
+
`triggerRunId: ${triggerRunId ?? "-"}`,
|
|
1256
|
+
"Every other scene stays as it is, and so do the pictures, the voices and the script.",
|
|
1257
|
+
...(note
|
|
1258
|
+
? ["Your note steers how this clip moves. It never changes the words that are spoken."]
|
|
1259
|
+
: []),
|
|
1260
|
+
`Watch it land: exodus video status ${runId}`,
|
|
1261
|
+
`Review the run: ${review}`,
|
|
1262
|
+
...plan.warnings,
|
|
1263
|
+
],
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
export function parseVoiceFlags(argv, readFile) {
|
|
1267
|
+
const fromFile = {};
|
|
1268
|
+
const typed = {};
|
|
1269
|
+
let asked = false;
|
|
1270
|
+
const valueFor = (i, flag) => {
|
|
1271
|
+
const arg = argv[i];
|
|
1272
|
+
if (arg.startsWith(`${flag}=`))
|
|
1273
|
+
return [arg.slice(flag.length + 1), i];
|
|
1274
|
+
const next = argv[i + 1];
|
|
1275
|
+
if (next === undefined)
|
|
1276
|
+
throw new Error(`${flag} needs a value.`);
|
|
1277
|
+
return [next, i + 1];
|
|
1278
|
+
};
|
|
1279
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1280
|
+
const arg = argv[i];
|
|
1281
|
+
if (arg === "--set" || arg.startsWith("--set=")) {
|
|
1282
|
+
const [raw, next] = valueFor(i, "--set");
|
|
1283
|
+
i = next;
|
|
1284
|
+
const eq = raw.lastIndexOf("=");
|
|
1285
|
+
if (eq <= 0 || eq === raw.length - 1) {
|
|
1286
|
+
throw new Error("--set must look like <character>=<voice id>, for example " +
|
|
1287
|
+
`--set C1=abc123voiceid (got "${raw}").`);
|
|
1288
|
+
}
|
|
1289
|
+
typed[raw.slice(0, eq).trim()] = raw.slice(eq + 1).trim();
|
|
1290
|
+
asked = true;
|
|
1291
|
+
}
|
|
1292
|
+
else if (arg === "--clear" || arg.startsWith("--clear=")) {
|
|
1293
|
+
const [raw, next] = valueFor(i, "--clear");
|
|
1294
|
+
i = next;
|
|
1295
|
+
if (!raw.trim())
|
|
1296
|
+
throw new Error("--clear needs a character, for example --clear C1.");
|
|
1297
|
+
typed[raw.trim()] = null;
|
|
1298
|
+
asked = true;
|
|
1299
|
+
}
|
|
1300
|
+
else if (arg === "--from" || arg.startsWith("--from=")) {
|
|
1301
|
+
const [file, next] = valueFor(i, "--from");
|
|
1302
|
+
i = next;
|
|
1303
|
+
let parsed;
|
|
1304
|
+
try {
|
|
1305
|
+
parsed = JSON.parse(readFile(file));
|
|
1306
|
+
}
|
|
1307
|
+
catch {
|
|
1308
|
+
throw new Error(`Could not read ${file} as a list of characters and voice IDs.`);
|
|
1309
|
+
}
|
|
1310
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1311
|
+
throw new Error(`Could not read ${file} as a list of characters and voice IDs.`);
|
|
1312
|
+
}
|
|
1313
|
+
Object.assign(fromFile, parsed);
|
|
1314
|
+
asked = true;
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
if (!asked)
|
|
1318
|
+
return null;
|
|
1319
|
+
const sameName = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
|
|
1320
|
+
const merged = {};
|
|
1321
|
+
for (const [who, choice] of Object.entries(fromFile)) {
|
|
1322
|
+
if (!Object.keys(typed).some((t) => sameName(t, who)))
|
|
1323
|
+
merged[who] = choice;
|
|
1324
|
+
}
|
|
1325
|
+
return { ...merged, ...typed };
|
|
1326
|
+
}
|
|
1327
|
+
export async function voicesFlow(runId, voices, json, deps) {
|
|
1328
|
+
const res = voices
|
|
1329
|
+
? await deps.post(VOICES_PATH, { runId, voices })
|
|
1330
|
+
: await deps.get(`${VOICES_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1331
|
+
if (!res.ok)
|
|
1332
|
+
return errorResult(res, json);
|
|
1333
|
+
const sheet = res.data;
|
|
1334
|
+
if (json)
|
|
1335
|
+
return { code: 0, lines: [JSON.stringify({ ok: true, ...sheet })] };
|
|
1336
|
+
return { code: 0, lines: voiceSheetLines(sheet) };
|
|
1337
|
+
}
|
|
1338
|
+
function availabilityWords(row) {
|
|
1339
|
+
if (!row.voice)
|
|
1340
|
+
return "no voice yet";
|
|
1341
|
+
switch (row.availability?.state) {
|
|
1342
|
+
case "available":
|
|
1343
|
+
return "ElevenLabs has it";
|
|
1344
|
+
case "missing":
|
|
1345
|
+
return "ElevenLabs does not have it";
|
|
1346
|
+
case "not-checked":
|
|
1347
|
+
return "not checked just now";
|
|
1348
|
+
default:
|
|
1349
|
+
return "not checked";
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
function voiceWords(voice) {
|
|
1353
|
+
if (!voice)
|
|
1354
|
+
return "—";
|
|
1355
|
+
return voice.label ? `${voice.label} (${voice.voiceId})` : voice.voiceId;
|
|
1356
|
+
}
|
|
1357
|
+
function voiceSummaryLines(cast, notices) {
|
|
1358
|
+
const rows = cast.filter((row) => row.spokenScenes > 0 || row.voice !== null);
|
|
1359
|
+
const width = Math.max(0, ...rows.map((row) => row.name.length));
|
|
1360
|
+
const lines = rows.length > 0 ? ["Voices"] : [];
|
|
1361
|
+
for (const row of rows) {
|
|
1362
|
+
lines.push(row.voice
|
|
1363
|
+
? ` ${row.name.padEnd(width)} → ${voiceWords(row.voice)} ${availabilityWords(row)}`
|
|
1364
|
+
: ` ${row.name.padEnd(width)} → no voice chosen (keeps the generated voice)`);
|
|
1365
|
+
}
|
|
1366
|
+
for (const notice of notices)
|
|
1367
|
+
lines.push(`Heads-up: ${notice}`);
|
|
1368
|
+
return lines;
|
|
1369
|
+
}
|
|
1370
|
+
export function voiceSheetLines(sheet) {
|
|
1371
|
+
const lines = [`Voices for run ${sheet.runId}`];
|
|
1372
|
+
if (sheet.cast.length === 0)
|
|
1373
|
+
lines.push(" Nobody is in this ad yet.");
|
|
1374
|
+
for (const row of sheet.cast) {
|
|
1375
|
+
const voice = voiceWords(row.voice);
|
|
1376
|
+
lines.push(` ${row.characterId} ${row.name} ${voice} ${availabilityWords(row)} ` +
|
|
1377
|
+
`speaks in ${row.spokenScenes} scenes, about ${row.spokenSeconds}s`);
|
|
1378
|
+
}
|
|
1379
|
+
if (sheet.narrator) {
|
|
1380
|
+
const label = sheet.narrator.label ? `${sheet.narrator.label} ` : "";
|
|
1381
|
+
lines.push(` Narrator ${label}(${sheet.narrator.voiceId}) set on the workflow, not here`);
|
|
1382
|
+
}
|
|
1383
|
+
if (sheet.changed) {
|
|
1384
|
+
lines.push(sheet.changed.length > 0
|
|
1385
|
+
? `Changed: ${sheet.changed.join(", ")}. Pictures and script were not touched.`
|
|
1386
|
+
: "Nothing changed — those voices were already set. Pictures and script were not touched.");
|
|
1387
|
+
}
|
|
1388
|
+
lines.push(`How voices are applied: ${sheet.treatment.summary}${sheet.treatment.speedChange ? "" : " No speed change."}`);
|
|
1389
|
+
lines.push(`Cost: billed to your own ElevenLabs key. ${sheet.treatment.costNote} ` +
|
|
1390
|
+
`About ${sheet.treatment.usage.seconds} seconds of speech across ` +
|
|
1391
|
+
`${sheet.treatment.usage.clips} clips will be converted.`);
|
|
1392
|
+
for (const notice of sheet.notices)
|
|
1393
|
+
lines.push(notice);
|
|
1394
|
+
lines.push(sheet.canChange
|
|
1395
|
+
? "You can still change voices until you approve the storyboard."
|
|
1396
|
+
: (sheet.whyNot ?? "Voices can no longer be changed on this run."));
|
|
1397
|
+
return lines;
|
|
1398
|
+
}
|
|
1040
1399
|
export async function pullFlow(runId, dir, json, deps) {
|
|
1041
1400
|
const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1042
1401
|
if (!runRes.ok)
|
|
@@ -1317,7 +1676,7 @@ export async function uploadFlow(runId, filePath, durationFlag, json, deps) {
|
|
|
1317
1676
|
if (!attached.ok)
|
|
1318
1677
|
return errorResult(attached, json);
|
|
1319
1678
|
const final = attached.data;
|
|
1320
|
-
const finalWatchUrl = final.finalWatchUrl ??
|
|
1679
|
+
const finalWatchUrl = final.finalWatchUrl ?? reviewUrl(deps.dashboardUrl, { _id: runId });
|
|
1321
1680
|
if (json) {
|
|
1322
1681
|
return {
|
|
1323
1682
|
code: 0,
|
|
@@ -1343,6 +1702,9 @@ const VALUE_FLAGS = new Set([
|
|
|
1343
1702
|
"duration",
|
|
1344
1703
|
"node",
|
|
1345
1704
|
"scene",
|
|
1705
|
+
"set",
|
|
1706
|
+
"clear",
|
|
1707
|
+
"from",
|
|
1346
1708
|
]);
|
|
1347
1709
|
export function parsePositional(args = process.argv.slice(3)) {
|
|
1348
1710
|
const out = [];
|
|
@@ -1402,7 +1764,17 @@ export async function run(flags) {
|
|
|
1402
1764
|
}, defaultDeps));
|
|
1403
1765
|
}
|
|
1404
1766
|
const runId = rest[0];
|
|
1405
|
-
const needsRunId = [
|
|
1767
|
+
const needsRunId = [
|
|
1768
|
+
"status",
|
|
1769
|
+
"storyboard",
|
|
1770
|
+
"approve",
|
|
1771
|
+
"flag",
|
|
1772
|
+
"retry-frame",
|
|
1773
|
+
"retry-clip",
|
|
1774
|
+
"voices",
|
|
1775
|
+
"pull",
|
|
1776
|
+
"upload",
|
|
1777
|
+
];
|
|
1406
1778
|
if (needsRunId.includes(sub) && !runId) {
|
|
1407
1779
|
usage(`video ${sub} needs a run id: exodus video ${sub} <runId>`);
|
|
1408
1780
|
}
|
|
@@ -1432,6 +1804,27 @@ export async function run(flags) {
|
|
|
1432
1804
|
}
|
|
1433
1805
|
return printResult(await retryFrameFlow(runId, nodeId, sceneIndex, flagString(flags, "note"), json, defaultDeps));
|
|
1434
1806
|
}
|
|
1807
|
+
if (sub === "retry-clip") {
|
|
1808
|
+
const sceneRaw = flagString(flags, "scene");
|
|
1809
|
+
if (sceneRaw === undefined) {
|
|
1810
|
+
usage("video retry-clip needs --scene <n>, the scene whose clip to redo.");
|
|
1811
|
+
}
|
|
1812
|
+
const sceneIndex = Number(sceneRaw);
|
|
1813
|
+
if (!Number.isInteger(sceneIndex)) {
|
|
1814
|
+
usage(`video retry-clip --scene must be a whole scene number, not "${sceneRaw}".`);
|
|
1815
|
+
}
|
|
1816
|
+
return printResult(await retryClipFlow(runId, { sceneIndex, nodeId: flagString(flags, "node") }, flagString(flags, "note"), json, defaultDeps));
|
|
1817
|
+
}
|
|
1818
|
+
if (sub === "voices") {
|
|
1819
|
+
let voices;
|
|
1820
|
+
try {
|
|
1821
|
+
voices = parseVoiceFlags(process.argv.slice(3), defaultDeps.readFile);
|
|
1822
|
+
}
|
|
1823
|
+
catch (err) {
|
|
1824
|
+
usage(err.message);
|
|
1825
|
+
}
|
|
1826
|
+
return printResult(await voicesFlow(runId, voices, json, defaultDeps));
|
|
1827
|
+
}
|
|
1435
1828
|
if (sub === "pull") {
|
|
1436
1829
|
const dir = flagString(flags, "out");
|
|
1437
1830
|
if (!dir)
|
|
@@ -3,6 +3,7 @@ import { type PollOptions, type PollResult } from "../lib/poll.js";
|
|
|
3
3
|
import { LEGACY_WORKFLOW_RUN_STATUS_VALUES, type RunStatus } from "../lib/runStatus.js";
|
|
4
4
|
import { type RunDeliverySummary } from "../lib/runVerdict.js";
|
|
5
5
|
import { type Channel } from "../lib/channel.js";
|
|
6
|
+
import { type VoiceMap } from "./video.js";
|
|
6
7
|
export declare const helpText: string;
|
|
7
8
|
export type WorkflowNodeKind = "brief" | "brief-form" | "asset" | "bot" | "primer" | "image" | "image-rig" | "rig" | "storyboard" | "reference" | "scene-frames" | "video" | "voiceover" | "output" | "call" | "show-set" | "show-cast" | "show-voices" | "product-truth" | "transform" | "formatter" | "splitter" | "collector" | "checkpoint" | "prompt";
|
|
8
9
|
export type WorkflowSlotState = "locked" | "auto" | "ask" | "inferred";
|
|
@@ -407,6 +408,7 @@ interface RunFlowOptions {
|
|
|
407
408
|
fill?: string;
|
|
408
409
|
autoApprove?: boolean;
|
|
409
410
|
imageRigOverrides?: Record<string, unknown>;
|
|
411
|
+
voices?: VoiceMap;
|
|
410
412
|
wait: boolean;
|
|
411
413
|
json: boolean;
|
|
412
414
|
out?: string;
|
|
@@ -427,6 +429,7 @@ export declare function serverWarningsToPrint(serverWarnings: unknown, alreadyPr
|
|
|
427
429
|
export declare function rejectTerminalFlag(args: string[]): void;
|
|
428
430
|
export declare function parseAutoApproveFlag(args: string[]): boolean;
|
|
429
431
|
export declare function parseRigOverridesFlag(args: string[], readFile?: (path: string) => string): Record<string, unknown> | undefined;
|
|
432
|
+
export declare function parseVoicesFlag(args: string[], readFile?: (path: string) => string): VoiceMap | undefined;
|
|
430
433
|
export declare function parseFillFlag(args: string[]): string | undefined;
|
|
431
434
|
export declare function formatWorkflowList(workflows: WorkflowListItem[]): string;
|
|
432
435
|
export declare function formatRecentRuns(runs: WorkflowRunProjection[]): string;
|