@aicopycoders/exodus 2026.9.2000-beta.1 → 2026.9.2000-beta.11
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 +2 -1
- package/assets/skills/exodus-meme/SKILL.md +4 -3
- package/assets/skills/exodus-template/SKILL.md +2 -1
- package/assets/skills/exodus-video/SKILL.md +142 -21
- package/assets/skills/exodus-workflow/SKILL.md +1 -1
- package/dist/commands/genesis.js +12 -133
- package/dist/commands/image.js +1 -1
- package/dist/commands/status.js +8 -18
- package/dist/commands/template.js +11 -4
- package/dist/commands/video.d.ts +126 -5
- package/dist/commands/video.js +656 -54
- package/dist/commands/workflow.d.ts +52 -4
- package/dist/commands/workflow.js +62 -9
- package/dist/lib/helperLedgerBase.d.ts +3 -0
- package/dist/lib/helperLedgerBase.js +3 -0
- package/package.json +1 -1
package/dist/commands/video.js
CHANGED
|
@@ -6,11 +6,27 @@ import { displayRunStatus, formatApiError } from "../lib/format.js";
|
|
|
6
6
|
import { missingRouteLine } from "../lib/route-support.js";
|
|
7
7
|
import { hasBinary } from "../lib/preflight.js";
|
|
8
8
|
import { ASSET_UPLOAD_POLICY, pauseAheadLine, } from "./workflow.js";
|
|
9
|
+
import { HELPER_LEDGER_BASE, SET_OPTION_LEDGER_BASE } from "../lib/helperLedgerBase.js";
|
|
10
|
+
const CONCEIT_KEYS = ["podcast", "ugc", "stage", "street", "personification"];
|
|
11
|
+
const VOICE_MODES = ["native", "voice-first"];
|
|
12
|
+
const STYLE_SLUGS = [
|
|
13
|
+
"pixar-3d",
|
|
14
|
+
"claymation-stop-motion",
|
|
15
|
+
"lego-toy-brick",
|
|
16
|
+
"balloon",
|
|
17
|
+
"video-game-8bit",
|
|
18
|
+
];
|
|
19
|
+
function joinedOr(items) {
|
|
20
|
+
if (items.length <= 1)
|
|
21
|
+
return items[0] ?? "";
|
|
22
|
+
return `${items.slice(0, -1).join(", ")} or ${items[items.length - 1]}`;
|
|
23
|
+
}
|
|
9
24
|
export const helpText = `
|
|
10
25
|
exodus video — make a video ad from a saved workflow, pull every piece, upload your cut
|
|
11
26
|
|
|
12
27
|
The dashboard makes the PIECES of an ad: the storyboard, one picture per scene,
|
|
13
|
-
one voice track per scene, one video clip per scene,
|
|
28
|
+
one voice track per scene, one video clip per scene, and a music bed if you
|
|
29
|
+
asked for one with --music. It does
|
|
14
30
|
not make the finished ad. You pull the pieces to a folder, cut them together
|
|
15
31
|
with whatever editing tools you like, and upload the cut back.
|
|
16
32
|
|
|
@@ -41,14 +57,22 @@ The whole loop, in order:
|
|
|
41
57
|
Redo ONE still at the pixel gate. Neighbours stay. The gate holds.
|
|
42
58
|
|
|
43
59
|
7. exodus video status <runId>
|
|
44
|
-
Where the run is and how each scene's clip turned out.
|
|
60
|
+
Where the run is and how each scene's clip turned out. If the storyboard
|
|
61
|
+
failed, it also says which parts of it were accepted and why the next one
|
|
62
|
+
was turned down.
|
|
63
|
+
|
|
64
|
+
exodus video status <runId> --rejected-draft
|
|
65
|
+
Prints the storyboard draft that was turned down. A record of what
|
|
66
|
+
happened, thrown away by the system — never something to act on.
|
|
45
67
|
|
|
46
68
|
8. exodus video pull <runId> --out ./ad
|
|
47
69
|
Writes every piece to that folder plus a manifest.json index.
|
|
48
70
|
|
|
49
71
|
exodus video retry-clip <runId> --scene <n>
|
|
50
72
|
Redo ONE finished clip while the run waits for the cut. Everything
|
|
51
|
-
else stays. A --note steers the motion, never the words.
|
|
73
|
+
else stays. A --note steers the motion, never the words. Add
|
|
74
|
+
--voice-first when the words came out wrong: the line is recorded in
|
|
75
|
+
the character's own voice first, then the clip is made to match it.
|
|
52
76
|
|
|
53
77
|
exodus video revoice <runId> --all
|
|
54
78
|
Redo only the VOICE on clips that kept the video model's own voice
|
|
@@ -62,18 +86,21 @@ The whole loop, in order:
|
|
|
62
86
|
Attaches your cut to the run and prints the page to approve it on.
|
|
63
87
|
|
|
64
88
|
11. exodus video approve <runId>
|
|
65
|
-
Or click Approve on the page from step 10.
|
|
89
|
+
Or click Approve on the page from step 10. If you redid a clip after
|
|
90
|
+
uploading, this stops and says which scenes changed. Upload a fresh cut,
|
|
91
|
+
or add --approve-stale-cut to deliver the cut you already uploaded.
|
|
66
92
|
|
|
67
93
|
Usage:
|
|
68
|
-
exodus video status <runId> [--json]
|
|
94
|
+
exodus video status <runId> [--rejected-draft] [--json]
|
|
69
95
|
exodus video storyboard <runId> [--json]
|
|
70
|
-
exodus video approve <runId> [--json]
|
|
96
|
+
exodus video approve <runId> [--approve-stale-cut] [--json]
|
|
71
97
|
exodus video retry-frame <runId> --node <nodeId> --scene <n> [--note "..."] [--json]
|
|
72
|
-
exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "..."] [--json]
|
|
98
|
+
exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "..."] [--voice-first] [--json]
|
|
73
99
|
exodus video revoice <runId> (--scene <n> | --all) [--node <nodeId>] [--json]
|
|
74
100
|
exodus video voices <runId> [--set <character>=<voiceId>] [--clear <character>] [--from <file.json>] [--json]
|
|
75
101
|
exodus video pull <runId> --out <dir> [--json]
|
|
76
102
|
exodus video upload <runId> --file <cut.mp4> [--duration <sec>] [--json]
|
|
103
|
+
exodus video start --script <file> --conceit <${CONCEIT_KEYS.join("|")}> --style <${STYLE_SLUGS.join("|")}> [--direction "<note>"] [--voice-path <path>] [--video-model <id>] [--voice <native|voice-first>] [--music] [--wait] [--json]
|
|
77
104
|
|
|
78
105
|
Options:
|
|
79
106
|
--out <dir> Folder to write the pulled pieces into (pull)
|
|
@@ -88,12 +115,33 @@ Options:
|
|
|
88
115
|
(retry-frame, retry-clip, revoice)
|
|
89
116
|
--all Every finished clip that kept the video model's voice
|
|
90
117
|
(revoice)
|
|
118
|
+
--voice-first Record the scene's line in the character's own voice,
|
|
119
|
+
then make the clip to match it (retry-clip). For a clip
|
|
120
|
+
whose words came out wrong
|
|
121
|
+
--approve-stale-cut Deliver the cut you uploaded even though a clip changed
|
|
122
|
+
after you uploaded it (approve). Without it, approving
|
|
123
|
+
stops and names the scenes that changed
|
|
91
124
|
--set <who>=<id> Give a character an ElevenLabs voice (voices). Name the
|
|
92
125
|
character by its ID or by the name your script uses.
|
|
93
126
|
Repeat it once per character
|
|
94
127
|
--clear <who> Take a character's voice off again (voices). Repeatable
|
|
95
128
|
--from <file.json> A file of characters and voice IDs (voices). --set and
|
|
96
129
|
--clear win over the same name in the file
|
|
130
|
+
--rejected-draft Print the storyboard draft the system turned down
|
|
131
|
+
(status). It was thrown away — a record only
|
|
132
|
+
--script <file> Text file of what the ad says (start)
|
|
133
|
+
--conceit <key> Kind of ad: ${joinedOr(CONCEIT_KEYS)} (start)
|
|
134
|
+
--style <slug> Look: ${joinedOr(STYLE_SLUGS)} (start)
|
|
135
|
+
--direction "<note>" Note for the whole ad (start)
|
|
136
|
+
--voice-path <path> How the voices are made (start)
|
|
137
|
+
--video-model <id> Which video model films this ad, instead of the kind of
|
|
138
|
+
ad's own default (start, with --conceit)
|
|
139
|
+
--voice <mode> native: the video model speaks the lines. voice-first:
|
|
140
|
+
the lines are recorded first (start, with --conceit)
|
|
141
|
+
--music Put a music bed under the ad (start). An ad has no music
|
|
142
|
+
unless you ask for it here
|
|
143
|
+
--no-music Leave the music bed off (start), which it already is
|
|
144
|
+
--wait Wait until the storyboard needs a yes (start)
|
|
97
145
|
--json Machine-readable output
|
|
98
146
|
--help, -h Print this help
|
|
99
147
|
|
|
@@ -108,6 +156,7 @@ Examples:
|
|
|
108
156
|
exodus video approve run_123
|
|
109
157
|
exodus video retry-frame run_123 --node frames-1 --scene 2
|
|
110
158
|
exodus video retry-clip run_123 --scene 3 --note "keep the handshake in frame"
|
|
159
|
+
exodus video retry-clip run_123 --scene 3 --voice-first
|
|
111
160
|
exodus video revoice run_123 --all
|
|
112
161
|
exodus video voices run_123
|
|
113
162
|
exodus video voices run_123 --set C1=abc123voiceid --set "HOST 2=def456voiceid"
|
|
@@ -117,13 +166,20 @@ Examples:
|
|
|
117
166
|
`.trim();
|
|
118
167
|
const SHOWS_PATH = "/api/v2/shows";
|
|
119
168
|
const RUNS_PATH = "/api/v2/video/runs";
|
|
169
|
+
const SCRIPT_RUNS_PATH = "/api/v2/video/script-runs";
|
|
120
170
|
const RUN_PATH = "/api/v2/workflow";
|
|
121
171
|
const ITEMS_PATH = "/api/v2/workflow/items";
|
|
172
|
+
const REJECTED_DRAFT_PATH = "/api/v2/workflow/rejected-draft";
|
|
173
|
+
const REJECTED_DRAFT_NOT_ON_THIS_SERVER = "This Exodus server does not keep rejected drafts yet, so there is nothing to read. It arrives " +
|
|
174
|
+
"with the next server update.";
|
|
122
175
|
const STORYBOARD_PATH = "/api/v2/video/storyboard";
|
|
123
176
|
const FLAG_PATH = "/api/v2/video/storyboard/flag";
|
|
124
177
|
const APPROVE_PATH = "/api/v2/workflow/approve";
|
|
125
178
|
const SCENE_RETRY_PATH = "/api/v2/workflow/scene/retry";
|
|
126
179
|
const SCENE_REVOICE_PATH = "/api/v2/workflow/scene/revoice";
|
|
180
|
+
const SCENE_VOICE_FIRST_PATH = "/api/v2/workflow/scene/voice-first";
|
|
181
|
+
const VOICE_FIRST_NOT_ON_THIS_SERVER = "This Exodus server cannot redo a clip voice first yet, so nothing was started and nothing " +
|
|
182
|
+
"was spent. It arrives with the next server update.";
|
|
127
183
|
const REVOICE_NOT_ON_THIS_SERVER = "This Exodus server does not have the voice redo yet, so nothing was started and nothing " +
|
|
128
184
|
"was spent. It arrives with the next server update.";
|
|
129
185
|
const FINAL_PATH = "/api/v2/video/final";
|
|
@@ -133,6 +189,48 @@ const ASSETS_PATH = "/api/v2/workflows/assets";
|
|
|
133
189
|
function withoutJudgeFields({ judgeDetail: _d, judgeSeverity: _s, ...rest }) {
|
|
134
190
|
return rest;
|
|
135
191
|
}
|
|
192
|
+
export function qcWithoutJudgeWording(qc) {
|
|
193
|
+
if (!qc.takes)
|
|
194
|
+
return qc;
|
|
195
|
+
return {
|
|
196
|
+
...qc,
|
|
197
|
+
takes: qc.takes.map(({ judgeWording: _w, ...rest }) => rest),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
export const NO_TAKE_HISTORY_LINE = "no history recorded for this clip";
|
|
201
|
+
const TAKE_NEIGHBOUR_SENTENCE = {
|
|
202
|
+
"not-applicable": () => "never compared",
|
|
203
|
+
"not-attached": () => "no neighbours attached",
|
|
204
|
+
"none-accepted": () => "no accepted neighbours yet",
|
|
205
|
+
attached: ({ scenes }) => `compared against scene${scenes.length === 1 ? "" : "s"} ${scenes.join(", ")}`,
|
|
206
|
+
};
|
|
207
|
+
export function renderQcTakeHistory(takes) {
|
|
208
|
+
if (!takes || takes.length === 0)
|
|
209
|
+
return [NO_TAKE_HISTORY_LINE];
|
|
210
|
+
const lines = [];
|
|
211
|
+
takes.forEach((take, index) => {
|
|
212
|
+
const say = TAKE_NEIGHBOUR_SENTENCE[take.neighbours.state];
|
|
213
|
+
const parts = [
|
|
214
|
+
take.failCodes.length ? `failed ${take.failCodes.join(", ")}` : "passed",
|
|
215
|
+
...(take.warnCodes.length ? [`warned ${take.warnCodes.join(", ")}`] : []),
|
|
216
|
+
say(take.neighbours),
|
|
217
|
+
];
|
|
218
|
+
const bonus = take.kind === "soft-retry" ? " (bonus take)" : "";
|
|
219
|
+
lines.push(`take ${index + 1}${bonus}: ${parts.join("; ")}`);
|
|
220
|
+
for (const { code, wording } of take.judgeWording ?? []) {
|
|
221
|
+
lines.push(` checker said (${code}): ${wording}`);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
return lines;
|
|
225
|
+
}
|
|
226
|
+
function printDisplacedHistory(lines, lastRedo) {
|
|
227
|
+
const history = lastRedo.displacedHistory;
|
|
228
|
+
if (!history)
|
|
229
|
+
return;
|
|
230
|
+
lines.push(` ${history.title}`);
|
|
231
|
+
for (const line of history.lines)
|
|
232
|
+
lines.push(` ${line}`);
|
|
233
|
+
}
|
|
136
234
|
export function asVideoRun(data) {
|
|
137
235
|
const run = (data ?? {});
|
|
138
236
|
return { ...run, nodes: Array.isArray(run.nodes) ? run.nodes : [] };
|
|
@@ -245,6 +343,7 @@ export function classifyRun(run) {
|
|
|
245
343
|
...(pausedNode.kind === "scene-frames"
|
|
246
344
|
? { framesNodeId: pausedNode.nodeId }
|
|
247
345
|
: {}),
|
|
346
|
+
...(isShowAd(run) ? { showAd: true } : {}),
|
|
248
347
|
};
|
|
249
348
|
}
|
|
250
349
|
if (parkedAtFinalWatch(run))
|
|
@@ -254,6 +353,22 @@ export function classifyRun(run) {
|
|
|
254
353
|
const active = run.nodes.find((n) => n.status === "running");
|
|
255
354
|
return { at: "running", stage: active?.kind ?? "starting" };
|
|
256
355
|
}
|
|
356
|
+
export function hasAttachedCut(items) {
|
|
357
|
+
return items.some((i) => i.itemKind === "final" && i.status === "done");
|
|
358
|
+
}
|
|
359
|
+
export function resolveStop(stop, cutAttached) {
|
|
360
|
+
if (stop.at !== "final-watch")
|
|
361
|
+
return stop;
|
|
362
|
+
return { at: "final-watch", cutAttached };
|
|
363
|
+
}
|
|
364
|
+
export async function resolveStopAtPark(stop, runId, deps) {
|
|
365
|
+
if (stop.at !== "final-watch")
|
|
366
|
+
return stop;
|
|
367
|
+
const res = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
368
|
+
if (!res.ok)
|
|
369
|
+
return resolveStop(stop, null);
|
|
370
|
+
return resolveStop(stop, hasAttachedCut(res.data.items ?? []));
|
|
371
|
+
}
|
|
257
372
|
const STAGE_WORDS = {
|
|
258
373
|
brief: "reading the script",
|
|
259
374
|
storyboard: "writing the storyboard",
|
|
@@ -279,8 +394,11 @@ export function stepName(kind) {
|
|
|
279
394
|
return "A step in this run";
|
|
280
395
|
return STEP_NAMES[kind] ?? `The ${kind} step`;
|
|
281
396
|
}
|
|
397
|
+
export function isShowAd(run) {
|
|
398
|
+
return run.moduleOwned === true;
|
|
399
|
+
}
|
|
282
400
|
export function reviewUrl(dashboardUrl, run) {
|
|
283
|
-
if (run
|
|
401
|
+
if (isShowAd(run))
|
|
284
402
|
return `${dashboardUrl}/video?ad=${run._id}`;
|
|
285
403
|
if (run.workflowId)
|
|
286
404
|
return `${dashboardUrl}/workflows/${run.workflowId}/runs/${run._id}`;
|
|
@@ -292,14 +410,31 @@ export function stopLines(stop, runId, runUrl) {
|
|
|
292
410
|
"Parked: the storyboard is waiting for your yes.",
|
|
293
411
|
`Read it: exodus video storyboard ${runId}`,
|
|
294
412
|
`Approve it: exodus video approve ${runId}`,
|
|
295
|
-
`Send back: exodus video flag ${runId} --note "what's wrong"`,
|
|
296
413
|
];
|
|
414
|
+
if (stop.showAd) {
|
|
415
|
+
lines.push(`Send back: exodus video flag ${runId} --note "what's wrong"`);
|
|
416
|
+
}
|
|
297
417
|
if (stop.framesNodeId) {
|
|
298
418
|
lines.push(`Redo one frame: exodus video retry-frame ${runId} --node ${stop.framesNodeId} --scene <n>`);
|
|
299
419
|
}
|
|
300
420
|
return lines;
|
|
301
421
|
}
|
|
302
422
|
if (stop.at === "final-watch") {
|
|
423
|
+
if (stop.cutAttached === null) {
|
|
424
|
+
return [
|
|
425
|
+
"Parked: every piece is made.",
|
|
426
|
+
`Couldn't check whether a cut is already uploaded. See where it stands: exodus video status ${runId}`,
|
|
427
|
+
`Watch it here: ${runUrl}`,
|
|
428
|
+
];
|
|
429
|
+
}
|
|
430
|
+
if (stop.cutAttached) {
|
|
431
|
+
return [
|
|
432
|
+
"Parked: your cut is uploaded and waiting for your approval.",
|
|
433
|
+
`Approve it: exodus video approve ${runId}`,
|
|
434
|
+
`Watch it here: ${runUrl}`,
|
|
435
|
+
"Changed your mind? You can still redo a clip and upload a new cut.",
|
|
436
|
+
];
|
|
437
|
+
}
|
|
303
438
|
return [
|
|
304
439
|
"Parked: every piece is made and the run is waiting for a cut.",
|
|
305
440
|
`Pull the pieces: exodus video pull ${runId} --out ./ad-${runId}`,
|
|
@@ -310,6 +445,13 @@ export function stopLines(stop, runId, runUrl) {
|
|
|
310
445
|
}
|
|
311
446
|
if (stop.at === "failed") {
|
|
312
447
|
if (stop.repair) {
|
|
448
|
+
if (!stop.step) {
|
|
449
|
+
return [
|
|
450
|
+
"This run stopped at a step that can't be picked back up from here.",
|
|
451
|
+
"Start a fresh run to get this ad made.",
|
|
452
|
+
`Open the run: ${runUrl}`,
|
|
453
|
+
];
|
|
454
|
+
}
|
|
313
455
|
return [
|
|
314
456
|
`${stepName(stop.step)} failed${stop.error ? `: ${stop.error}` : "."}`,
|
|
315
457
|
`Try that step again: exodus workflow repair ${runId} retry`,
|
|
@@ -335,7 +477,40 @@ export function stopLines(stop, runId, runUrl) {
|
|
|
335
477
|
}
|
|
336
478
|
return [`Working: ${stageWord(stop.stage)}.`];
|
|
337
479
|
}
|
|
338
|
-
export
|
|
480
|
+
export function failedStoryboardNode(run) {
|
|
481
|
+
return run.nodes.find((n) => n.kind === "storyboard" && n.status === "failed");
|
|
482
|
+
}
|
|
483
|
+
export function errorEchoesReasons(error, record) {
|
|
484
|
+
if (!error || record?.kind !== "rejected")
|
|
485
|
+
return false;
|
|
486
|
+
const reasons = record.reasons ?? [];
|
|
487
|
+
return reasons.length > 0 && reasons.every((reason) => error.includes(reason));
|
|
488
|
+
}
|
|
489
|
+
export function planFailureLines(node, runId) {
|
|
490
|
+
const record = node?.planFailure;
|
|
491
|
+
if (!record || !Array.isArray(record.lines) || !node)
|
|
492
|
+
return [];
|
|
493
|
+
const reasons = record.kind === "rejected" ? (record.reasons ?? []) : [];
|
|
494
|
+
const bodyCount = Math.max(0, record.lines.length - reasons.length);
|
|
495
|
+
const lines = [`What happened to the storyboard step (${node.nodeId}):`];
|
|
496
|
+
record.lines.forEach((line, i) => {
|
|
497
|
+
lines.push(i < bodyCount ? ` ${line}` : ` - ${line}`);
|
|
498
|
+
});
|
|
499
|
+
if (node?.hasRejectedDraft === true) {
|
|
500
|
+
lines.push(" A rejected draft is on file. The system threw it away, so it is a record of what happened and nothing to act on.", ` Read it: exodus video status ${runId} --rejected-draft`);
|
|
501
|
+
}
|
|
502
|
+
return lines;
|
|
503
|
+
}
|
|
504
|
+
export const MUSIC_HEARD_CODE = "music-heard";
|
|
505
|
+
export const CAST_LEDGER_BASE = HELPER_LEDGER_BASE + 10000;
|
|
506
|
+
export function musicBedState(run) {
|
|
507
|
+
if (run.musicBed === undefined)
|
|
508
|
+
return "unknown";
|
|
509
|
+
return run.musicBed ? "on" : "off";
|
|
510
|
+
}
|
|
511
|
+
function isPullSceneIndex(sceneIndex) {
|
|
512
|
+
return sceneIndex >= 0 && sceneIndex < HELPER_LEDGER_BASE;
|
|
513
|
+
}
|
|
339
514
|
const FALLBACK_EXT = {
|
|
340
515
|
image: "png",
|
|
341
516
|
video: "mp4",
|
|
@@ -352,6 +527,10 @@ function extFor(url, family) {
|
|
|
352
527
|
export function scenePrefix(sceneIndex) {
|
|
353
528
|
return `scene-${String(sceneIndex).padStart(2, "0")}`;
|
|
354
529
|
}
|
|
530
|
+
function clipQcOf(item) {
|
|
531
|
+
const artifact = item?.artifact;
|
|
532
|
+
return artifact && artifact.type === "video" ? artifact.qc?.takes : undefined;
|
|
533
|
+
}
|
|
355
534
|
function outputsOfNodeKind(run, kind) {
|
|
356
535
|
return run.nodes.filter((n) => n.kind === kind).flatMap((n) => n.outputs ?? []);
|
|
357
536
|
}
|
|
@@ -370,6 +549,7 @@ function clipFromArtifact(sceneIndex, artifact) {
|
|
|
370
549
|
revoiced: artifact.revoiced === true,
|
|
371
550
|
speechTrimmed: artifact.speechTrimmed === true,
|
|
372
551
|
rawStorageId: artifact.rawStorageId ?? null,
|
|
552
|
+
voiceMode: artifact.voiceMode ?? null,
|
|
373
553
|
};
|
|
374
554
|
}
|
|
375
555
|
function keyframeDownload(sceneIndex, imageUrl) {
|
|
@@ -381,7 +561,9 @@ function keyframeDownload(sceneIndex, imageUrl) {
|
|
|
381
561
|
function reservedCastFrames(items) {
|
|
382
562
|
const byIndex = new Map();
|
|
383
563
|
for (const item of items) {
|
|
384
|
-
if (item.itemKind === "frame" &&
|
|
564
|
+
if (item.itemKind === "frame" &&
|
|
565
|
+
item.sceneIndex >= CAST_LEDGER_BASE &&
|
|
566
|
+
item.sceneIndex < SET_OPTION_LEDGER_BASE) {
|
|
385
567
|
byIndex.set(item.sceneIndex, item);
|
|
386
568
|
}
|
|
387
569
|
}
|
|
@@ -534,6 +716,8 @@ export function planPull(run, items, opts) {
|
|
|
534
716
|
for (const frame of artifact.frames ?? []) {
|
|
535
717
|
if (!frame.imageUrl)
|
|
536
718
|
continue;
|
|
719
|
+
if (!isPullSceneIndex(frame.sceneIndex))
|
|
720
|
+
continue;
|
|
537
721
|
keyframeByScene.set(frame.sceneIndex, keyframeDownload(frame.sceneIndex, frame.imageUrl));
|
|
538
722
|
}
|
|
539
723
|
}
|
|
@@ -551,7 +735,7 @@ export function planPull(run, items, opts) {
|
|
|
551
735
|
};
|
|
552
736
|
continue;
|
|
553
737
|
}
|
|
554
|
-
if (typeof artifact.sceneIndex !== "number" || artifact.sceneIndex
|
|
738
|
+
if (typeof artifact.sceneIndex !== "number" || !isPullSceneIndex(artifact.sceneIndex))
|
|
555
739
|
continue;
|
|
556
740
|
voiceByScene.set(artifact.sceneIndex, {
|
|
557
741
|
download: {
|
|
@@ -592,6 +776,8 @@ export function planPull(run, items, opts) {
|
|
|
592
776
|
}
|
|
593
777
|
if (artifact.type !== "video" || typeof artifact.sceneIndex !== "number")
|
|
594
778
|
continue;
|
|
779
|
+
if (!isPullSceneIndex(artifact.sceneIndex))
|
|
780
|
+
continue;
|
|
595
781
|
const clip = clipFromArtifact(artifact.sceneIndex, artifact);
|
|
596
782
|
if (!clip)
|
|
597
783
|
continue;
|
|
@@ -601,6 +787,8 @@ export function planPull(run, items, opts) {
|
|
|
601
787
|
const frameItemByScene = new Map();
|
|
602
788
|
const sceneFrameNodeIds = new Set(run.nodes.filter((n) => n.kind === "scene-frames").map((n) => n.nodeId));
|
|
603
789
|
for (const item of items) {
|
|
790
|
+
if (!isPullSceneIndex(item.sceneIndex))
|
|
791
|
+
continue;
|
|
604
792
|
if (item.itemKind === "clip")
|
|
605
793
|
clipItemByScene.set(item.sceneIndex, item);
|
|
606
794
|
if (item.itemKind === "frame" && sceneFrameNodeIds.has(item.nodeId)) {
|
|
@@ -632,7 +820,7 @@ export function planPull(run, items, opts) {
|
|
|
632
820
|
...frameItemByScene.keys(),
|
|
633
821
|
]),
|
|
634
822
|
]
|
|
635
|
-
.filter((sceneIndex) => sceneIndex
|
|
823
|
+
.filter((sceneIndex) => isPullSceneIndex(sceneIndex))
|
|
636
824
|
.sort((a, b) => a - b);
|
|
637
825
|
const scenes = sceneIndexes.map((sceneIndex) => {
|
|
638
826
|
const clip = clipByScene.get(sceneIndex);
|
|
@@ -667,10 +855,11 @@ export function planPull(run, items, opts) {
|
|
|
667
855
|
: "this-file",
|
|
668
856
|
voice: voice?.download.file ?? null,
|
|
669
857
|
keyframe: keyframeByScene.get(sceneIndex)?.file ?? null,
|
|
670
|
-
qc: clip?.qc
|
|
858
|
+
qc: clip?.qc ? qcWithoutJudgeWording(clip.qc) : null,
|
|
671
859
|
revoiced: clip ? clip.revoiced : null,
|
|
672
860
|
speechTrimmed: clip ? clip.speechTrimmed : null,
|
|
673
861
|
rawStorageId: clip ? clip.rawStorageId : null,
|
|
862
|
+
voiceMode: clip ? clip.voiceMode : null,
|
|
674
863
|
clipStatus: item?.status ?? "missing",
|
|
675
864
|
error: item?.error ?? null,
|
|
676
865
|
flagged: item?.flagged === true,
|
|
@@ -698,6 +887,10 @@ export function planPull(run, items, opts) {
|
|
|
698
887
|
storyboard,
|
|
699
888
|
reference: reference?.file ?? null,
|
|
700
889
|
music: music?.file ?? null,
|
|
890
|
+
musicBed: musicBedState(run),
|
|
891
|
+
musicHeardScenes: scenes
|
|
892
|
+
.filter((scene) => scene.findings.some((f) => f.code === MUSIC_HEARD_CODE))
|
|
893
|
+
.map((scene) => scene.sceneIndex),
|
|
701
894
|
cast,
|
|
702
895
|
narration: narrationDownload
|
|
703
896
|
? { file: narrationDownload.file, timing: "narration.json" }
|
|
@@ -707,6 +900,14 @@ export function planPull(run, items, opts) {
|
|
|
707
900
|
...(run.provenance?.format || run.provenance?.voice
|
|
708
901
|
? { provenance: run.provenance }
|
|
709
902
|
: {}),
|
|
903
|
+
...(run.videoChoice
|
|
904
|
+
? {
|
|
905
|
+
videoChoice: {
|
|
906
|
+
videoModel: run.videoChoice.videoModel,
|
|
907
|
+
voiceMode: run.videoChoice.voiceMode,
|
|
908
|
+
},
|
|
909
|
+
}
|
|
910
|
+
: {}),
|
|
710
911
|
},
|
|
711
912
|
};
|
|
712
913
|
}
|
|
@@ -732,6 +933,7 @@ export function markPullFailure(manifest, failure) {
|
|
|
732
933
|
scene.revoiced = null;
|
|
733
934
|
scene.speechTrimmed = null;
|
|
734
935
|
scene.rawStorageId = null;
|
|
936
|
+
scene.voiceMode = null;
|
|
735
937
|
}
|
|
736
938
|
if (scene.voice === failure.file)
|
|
737
939
|
scene.voice = null;
|
|
@@ -837,7 +1039,7 @@ export async function startFlow(opts, deps) {
|
|
|
837
1039
|
showId: opts.showId,
|
|
838
1040
|
script,
|
|
839
1041
|
...(opts.voicePath ? { voicePath: opts.voicePath } : {}),
|
|
840
|
-
...(opts.music ===
|
|
1042
|
+
...(opts.music === undefined ? {} : { music: opts.music }),
|
|
841
1043
|
});
|
|
842
1044
|
if (!res.ok)
|
|
843
1045
|
return errorResult(res, opts.json);
|
|
@@ -866,6 +1068,264 @@ export async function startFlow(opts, deps) {
|
|
|
866
1068
|
lines: opts.json ? waited.lines : [`Started ad run ${runId}`, `Watch it: ${url}`, "", ...waited.lines],
|
|
867
1069
|
};
|
|
868
1070
|
}
|
|
1071
|
+
function nonBlank(value) {
|
|
1072
|
+
if (value === undefined || value.trim().length === 0)
|
|
1073
|
+
return undefined;
|
|
1074
|
+
return value;
|
|
1075
|
+
}
|
|
1076
|
+
function isVoiceMode(value) {
|
|
1077
|
+
return VOICE_MODES.some((mode) => mode === value);
|
|
1078
|
+
}
|
|
1079
|
+
function isConceit(value) {
|
|
1080
|
+
return CONCEIT_KEYS.some((key) => key === value);
|
|
1081
|
+
}
|
|
1082
|
+
export function planMusicChoice(occurrences) {
|
|
1083
|
+
let music;
|
|
1084
|
+
for (const { flag, value } of occurrences) {
|
|
1085
|
+
if (flag === "no-music") {
|
|
1086
|
+
music = false;
|
|
1087
|
+
continue;
|
|
1088
|
+
}
|
|
1089
|
+
if (flag !== "music")
|
|
1090
|
+
continue;
|
|
1091
|
+
if (value === undefined || value.startsWith("--")) {
|
|
1092
|
+
music = true;
|
|
1093
|
+
continue;
|
|
1094
|
+
}
|
|
1095
|
+
const word = value.trim().toLowerCase();
|
|
1096
|
+
const literal = word === "true" ? true : word === "false" ? false : undefined;
|
|
1097
|
+
if (literal === undefined) {
|
|
1098
|
+
return {
|
|
1099
|
+
ok: false,
|
|
1100
|
+
line: `video start --music takes true or false, or no value at all (got "${value}").`,
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
music = literal;
|
|
1104
|
+
}
|
|
1105
|
+
return { ok: true, music };
|
|
1106
|
+
}
|
|
1107
|
+
function planValueFlag(occurrences, name) {
|
|
1108
|
+
let value;
|
|
1109
|
+
for (const occurrence of occurrences) {
|
|
1110
|
+
if (occurrence.flag !== name)
|
|
1111
|
+
continue;
|
|
1112
|
+
const given = occurrence.value;
|
|
1113
|
+
if (given === undefined || given.startsWith("--") || given.trim().length === 0) {
|
|
1114
|
+
return { ok: false, line: `video start --${name} needs a value.` };
|
|
1115
|
+
}
|
|
1116
|
+
value = given.trim();
|
|
1117
|
+
}
|
|
1118
|
+
return { ok: true, value };
|
|
1119
|
+
}
|
|
1120
|
+
export function planVideoStart(flags, occurrences) {
|
|
1121
|
+
const showId = nonBlank(flagString(flags, "show"));
|
|
1122
|
+
const scriptFile = nonBlank(flagString(flags, "script"));
|
|
1123
|
+
const conceit = nonBlank(flagString(flags, "conceit"));
|
|
1124
|
+
const style = nonBlank(flagString(flags, "style"));
|
|
1125
|
+
if (showId && (conceit || style)) {
|
|
1126
|
+
return {
|
|
1127
|
+
kind: "usage",
|
|
1128
|
+
line: "Pass either --show <id>, or --conceit and --style. Not both.",
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
1131
|
+
if (!scriptFile) {
|
|
1132
|
+
return {
|
|
1133
|
+
kind: "usage",
|
|
1134
|
+
line: "video start needs --script <file>, a text file of what the ad says.",
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
const musicChoice = planMusicChoice(occurrences);
|
|
1138
|
+
if (!musicChoice.ok)
|
|
1139
|
+
return { kind: "usage", line: musicChoice.line };
|
|
1140
|
+
const videoModelFlag = planValueFlag(occurrences, "video-model");
|
|
1141
|
+
if (!videoModelFlag.ok)
|
|
1142
|
+
return { kind: "usage", line: videoModelFlag.line };
|
|
1143
|
+
const voiceFlag = planValueFlag(occurrences, "voice");
|
|
1144
|
+
if (!voiceFlag.ok)
|
|
1145
|
+
return { kind: "usage", line: voiceFlag.line };
|
|
1146
|
+
const videoModel = videoModelFlag.value;
|
|
1147
|
+
const voiceMode = voiceFlag.value;
|
|
1148
|
+
if (showId && (videoModel || voiceMode)) {
|
|
1149
|
+
return {
|
|
1150
|
+
kind: "usage",
|
|
1151
|
+
line: "--video-model and --voice work with --conceit, not --show. A Show films on its own model.",
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
if (voiceMode && !isVoiceMode(voiceMode)) {
|
|
1155
|
+
return {
|
|
1156
|
+
kind: "usage",
|
|
1157
|
+
line: `video start --voice must be native or voice-first (got "${voiceMode}").`,
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
if (showId) {
|
|
1161
|
+
return {
|
|
1162
|
+
kind: "show",
|
|
1163
|
+
opts: {
|
|
1164
|
+
showId,
|
|
1165
|
+
scriptFile,
|
|
1166
|
+
voicePath: flagString(flags, "voice-path"),
|
|
1167
|
+
music: musicChoice.music,
|
|
1168
|
+
wait: flags["wait"] === true,
|
|
1169
|
+
json: flags["json"] === true,
|
|
1170
|
+
},
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1173
|
+
if (!conceit) {
|
|
1174
|
+
return {
|
|
1175
|
+
kind: "usage",
|
|
1176
|
+
line: `video start needs --conceit <${CONCEIT_KEYS.join("|")}>.`,
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
if (!isConceit(conceit)) {
|
|
1180
|
+
return {
|
|
1181
|
+
kind: "usage",
|
|
1182
|
+
line: `video start --conceit must be one of ${joinedOr(CONCEIT_KEYS)}.`,
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
if (!style) {
|
|
1186
|
+
return {
|
|
1187
|
+
kind: "usage",
|
|
1188
|
+
line: `video start needs --style <${STYLE_SLUGS.join("|")}>.`,
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
const direction = nonBlank(flagString(flags, "direction"));
|
|
1192
|
+
const voicePath = nonBlank(flagString(flags, "voice-path"));
|
|
1193
|
+
const opts = {
|
|
1194
|
+
scriptFile,
|
|
1195
|
+
conceit,
|
|
1196
|
+
style,
|
|
1197
|
+
wait: flags["wait"] === true,
|
|
1198
|
+
json: flags["json"] === true,
|
|
1199
|
+
};
|
|
1200
|
+
if (direction)
|
|
1201
|
+
opts.direction = direction;
|
|
1202
|
+
if (voicePath)
|
|
1203
|
+
opts.voicePath = voicePath;
|
|
1204
|
+
if (videoModel)
|
|
1205
|
+
opts.videoModel = videoModel;
|
|
1206
|
+
if (voiceMode && isVoiceMode(voiceMode))
|
|
1207
|
+
opts.voiceMode = voiceMode;
|
|
1208
|
+
if (musicChoice.music !== undefined)
|
|
1209
|
+
opts.music = musicChoice.music;
|
|
1210
|
+
return { kind: "script", opts };
|
|
1211
|
+
}
|
|
1212
|
+
function isRecord(value) {
|
|
1213
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1214
|
+
}
|
|
1215
|
+
function isResolvedCard(value) {
|
|
1216
|
+
if (!isRecord(value))
|
|
1217
|
+
return false;
|
|
1218
|
+
if (!isRecord(value.conceit) || !isRecord(value.style))
|
|
1219
|
+
return false;
|
|
1220
|
+
if (typeof value.conceit.key !== "string" || typeof value.conceit.label !== "string")
|
|
1221
|
+
return false;
|
|
1222
|
+
if (typeof value.family !== "string")
|
|
1223
|
+
return false;
|
|
1224
|
+
if (!("wrapper" in value))
|
|
1225
|
+
return false;
|
|
1226
|
+
if (value.wrapper !== null && typeof value.wrapper !== "string")
|
|
1227
|
+
return false;
|
|
1228
|
+
if (typeof value.style.slug !== "string" || typeof value.style.label !== "string")
|
|
1229
|
+
return false;
|
|
1230
|
+
return true;
|
|
1231
|
+
}
|
|
1232
|
+
function startedVideoChoice(value) {
|
|
1233
|
+
if (!isRecord(value))
|
|
1234
|
+
return null;
|
|
1235
|
+
if (typeof value.videoModel !== "string" || typeof value.voiceMode !== "string")
|
|
1236
|
+
return null;
|
|
1237
|
+
return { videoModel: value.videoModel, voiceMode: value.voiceMode };
|
|
1238
|
+
}
|
|
1239
|
+
function scriptStartedLines(runId, url, card, video) {
|
|
1240
|
+
return [
|
|
1241
|
+
`Started ad run ${runId}`,
|
|
1242
|
+
...(card
|
|
1243
|
+
? [
|
|
1244
|
+
`Conceit: ${card.conceit.label}`,
|
|
1245
|
+
`Family: ${card.family}`,
|
|
1246
|
+
`Wrapper: ${card.wrapper ?? ""}`,
|
|
1247
|
+
`Style: ${card.style.label}`,
|
|
1248
|
+
]
|
|
1249
|
+
: ["The server did not include a resolved card."]),
|
|
1250
|
+
...(video ? [`Video model: ${video.videoModel}`, `Voice: ${video.voiceMode}`] : []),
|
|
1251
|
+
`Watch it: ${url}`,
|
|
1252
|
+
];
|
|
1253
|
+
}
|
|
1254
|
+
function waitJsonWithCard(waited, card, video) {
|
|
1255
|
+
const first = waited.lines[0];
|
|
1256
|
+
if (typeof first !== "string")
|
|
1257
|
+
return waited;
|
|
1258
|
+
let parsed;
|
|
1259
|
+
try {
|
|
1260
|
+
parsed = JSON.parse(first);
|
|
1261
|
+
}
|
|
1262
|
+
catch {
|
|
1263
|
+
return waited;
|
|
1264
|
+
}
|
|
1265
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
1266
|
+
return waited;
|
|
1267
|
+
return {
|
|
1268
|
+
code: waited.code,
|
|
1269
|
+
lines: [JSON.stringify({ ...parsed, card, ...(video ? { videoChoice: video } : {}) })],
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
export async function startScriptFlow(opts, deps) {
|
|
1273
|
+
let script;
|
|
1274
|
+
try {
|
|
1275
|
+
script = deps.readFile(opts.scriptFile);
|
|
1276
|
+
}
|
|
1277
|
+
catch (e) {
|
|
1278
|
+
return {
|
|
1279
|
+
code: 1,
|
|
1280
|
+
lines: [
|
|
1281
|
+
`Can't read the script file "${opts.scriptFile}": ${e instanceof Error ? e.message : String(e)}`,
|
|
1282
|
+
],
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
if (script.trim().length === 0) {
|
|
1286
|
+
return { code: 1, lines: [`The script file "${opts.scriptFile}" is empty.`] };
|
|
1287
|
+
}
|
|
1288
|
+
const res = await deps.post(SCRIPT_RUNS_PATH, {
|
|
1289
|
+
script,
|
|
1290
|
+
conceit: opts.conceit,
|
|
1291
|
+
style: opts.style,
|
|
1292
|
+
...(opts.direction ? { direction: opts.direction } : {}),
|
|
1293
|
+
...(opts.voicePath ? { voicePath: opts.voicePath } : {}),
|
|
1294
|
+
...(opts.videoModel ? { videoModel: opts.videoModel } : {}),
|
|
1295
|
+
...(opts.voiceMode ? { voiceMode: opts.voiceMode } : {}),
|
|
1296
|
+
...(opts.music === true ? { music: true } : {}),
|
|
1297
|
+
});
|
|
1298
|
+
if (!res.ok)
|
|
1299
|
+
return errorResult(res, opts.json);
|
|
1300
|
+
const started = res.data;
|
|
1301
|
+
if (!started.runId) {
|
|
1302
|
+
return { code: 1, lines: ["The server started the ad but did not say which run it is."] };
|
|
1303
|
+
}
|
|
1304
|
+
const runId = started.runId;
|
|
1305
|
+
const url = typeof started.url === "string" ? started.url : "";
|
|
1306
|
+
const card = isResolvedCard(started.card) ? started.card : null;
|
|
1307
|
+
const video = startedVideoChoice(started.videoChoice);
|
|
1308
|
+
if (!opts.wait) {
|
|
1309
|
+
return {
|
|
1310
|
+
code: 0,
|
|
1311
|
+
lines: opts.json
|
|
1312
|
+
? [JSON.stringify({ runId, url, card, ...(video ? { videoChoice: video } : {}) })]
|
|
1313
|
+
: [
|
|
1314
|
+
...scriptStartedLines(runId, url, card, video),
|
|
1315
|
+
"",
|
|
1316
|
+
"Wait for the storyboard here instead: exodus video start … --wait",
|
|
1317
|
+
`Or check in whenever: exodus video status ${runId}`,
|
|
1318
|
+
],
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1321
|
+
const waited = await waitFlow(runId, { json: opts.json, url }, deps);
|
|
1322
|
+
if (opts.json)
|
|
1323
|
+
return waitJsonWithCard(waited, card, video);
|
|
1324
|
+
return {
|
|
1325
|
+
code: waited.code,
|
|
1326
|
+
lines: [...scriptStartedLines(runId, url, card, video), "", ...waited.lines],
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
869
1329
|
const POLL_INTERVAL_MS = 5000;
|
|
870
1330
|
const MAX_POLLS = 720;
|
|
871
1331
|
export async function waitFlow(runId, opts, deps) {
|
|
@@ -891,15 +1351,16 @@ export async function waitFlow(runId, opts, deps) {
|
|
|
891
1351
|
}
|
|
892
1352
|
continue;
|
|
893
1353
|
}
|
|
1354
|
+
const resolved = await resolveStopAtPark(stop, runId, deps);
|
|
894
1355
|
if (opts.json) {
|
|
895
1356
|
return {
|
|
896
1357
|
code: stop.at === "failed" ? 1 : 0,
|
|
897
|
-
lines: [JSON.stringify({ runId, stop, status: run.status, url: opts.url })],
|
|
1358
|
+
lines: [JSON.stringify({ runId, stop: resolved, status: run.status, url: opts.url })],
|
|
898
1359
|
};
|
|
899
1360
|
}
|
|
900
1361
|
return {
|
|
901
1362
|
code: stop.at === "failed" ? 1 : 0,
|
|
902
|
-
lines: [...lines, ...stopLines(
|
|
1363
|
+
lines: [...lines, ...stopLines(resolved, runId, reviewUrl(deps.dashboardUrl, run))],
|
|
903
1364
|
};
|
|
904
1365
|
}
|
|
905
1366
|
const timeoutLine = `Still running after ${Math.round((maxPolls * interval) / 60000)} minutes. Check in with: exodus video status ${runId}`;
|
|
@@ -924,6 +1385,11 @@ function itemWord(item) {
|
|
|
924
1385
|
return "flagged";
|
|
925
1386
|
return ITEM_STATUS_WORD[item.status] ?? item.status;
|
|
926
1387
|
}
|
|
1388
|
+
const CARRYING_ON_LINE = "Waiting for a worker to carry on. Everything finished so far is kept — the run picks up from there by itself.";
|
|
1389
|
+
function carryingOn(run) {
|
|
1390
|
+
return (run.status === "queued" &&
|
|
1391
|
+
run.nodes.some((n) => n.status !== "idle" && n.status !== "out-of-scope"));
|
|
1392
|
+
}
|
|
927
1393
|
export async function statusFlow(runId, json, deps) {
|
|
928
1394
|
const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
929
1395
|
if (!runRes.ok)
|
|
@@ -933,20 +1399,46 @@ export async function statusFlow(runId, json, deps) {
|
|
|
933
1399
|
if (!itemsRes.ok)
|
|
934
1400
|
return errorResult(itemsRes, json);
|
|
935
1401
|
const items = itemsRes.data.items ?? [];
|
|
936
|
-
const
|
|
937
|
-
const
|
|
1402
|
+
const hasFinal = hasAttachedCut(items);
|
|
1403
|
+
const stop = resolveStop(classifyRun(run), hasFinal);
|
|
1404
|
+
const failedStoryboard = failedStoryboardNode(run);
|
|
1405
|
+
const guidance = stopLines(stop.at === "failed" && errorEchoesReasons(stop.error, failedStoryboard?.planFailure)
|
|
1406
|
+
? { ...stop, error: undefined }
|
|
1407
|
+
: stop, runId, reviewUrl(deps.dashboardUrl, run));
|
|
938
1408
|
const warnings = run.nodes
|
|
939
1409
|
.filter((n) => n.warning)
|
|
940
1410
|
.map((n) => ({ nodeId: n.nodeId, step: n.kind, warning: n.warning }));
|
|
941
1411
|
if (json) {
|
|
942
1412
|
return {
|
|
943
1413
|
code: 0,
|
|
944
|
-
lines: [
|
|
1414
|
+
lines: [
|
|
1415
|
+
JSON.stringify({
|
|
1416
|
+
runId,
|
|
1417
|
+
status: run.status,
|
|
1418
|
+
stop,
|
|
1419
|
+
items,
|
|
1420
|
+
hasFinal,
|
|
1421
|
+
warnings,
|
|
1422
|
+
guidance,
|
|
1423
|
+
...(run.musicBed === undefined ? {} : { musicBed: run.musicBed }),
|
|
1424
|
+
...(failedStoryboard?.planFailure ? { planFailure: failedStoryboard.planFailure } : {}),
|
|
1425
|
+
...(failedStoryboard?.planFailure
|
|
1426
|
+
? { storyboardNodeId: failedStoryboard.nodeId }
|
|
1427
|
+
: {}),
|
|
1428
|
+
...(failedStoryboard?.hasRejectedDraft === true ? { hasRejectedDraft: true } : {}),
|
|
1429
|
+
}),
|
|
1430
|
+
],
|
|
945
1431
|
};
|
|
946
1432
|
}
|
|
947
1433
|
const revoicedByScene = new Set();
|
|
1434
|
+
const voiceFirstByScene = new Set();
|
|
948
1435
|
const timedOnOriginalVoice = new Set();
|
|
949
1436
|
for (const artifact of outputsOfNodeKind(run, "video")) {
|
|
1437
|
+
if (artifact.type === "video" &&
|
|
1438
|
+
artifact.voiceMode === "voice-first" &&
|
|
1439
|
+
typeof artifact.sceneIndex === "number") {
|
|
1440
|
+
voiceFirstByScene.add(artifact.sceneIndex);
|
|
1441
|
+
}
|
|
950
1442
|
if (artifact.type === "video" && artifact.revoiced === true && typeof artifact.sceneIndex === "number") {
|
|
951
1443
|
revoicedByScene.add(artifact.sceneIndex);
|
|
952
1444
|
if ((artifact.words?.length ?? 0) > 0)
|
|
@@ -972,8 +1464,10 @@ export async function statusFlow(runId, json, deps) {
|
|
|
972
1464
|
: `Ad run ${runId} — ${displayRunStatus(run.status)}`;
|
|
973
1465
|
const lines = [
|
|
974
1466
|
headline,
|
|
975
|
-
...
|
|
1467
|
+
...(carryingOn(run) ? [CARRYING_ON_LINE] : []),
|
|
1468
|
+
...guidance,
|
|
976
1469
|
...(stop.at === "running" && run.pauseAhead ? [pauseAheadLine(run.pauseAhead)] : []),
|
|
1470
|
+
...planFailureLines(failedStoryboard, runId),
|
|
977
1471
|
...warnings.map((w) => `Heads-up (${w.step}): ${w.warning}`),
|
|
978
1472
|
];
|
|
979
1473
|
if (byScene.size === 0) {
|
|
@@ -984,6 +1478,21 @@ export async function statusFlow(runId, json, deps) {
|
|
|
984
1478
|
for (const sceneIndex of [...byScene.keys()].sort((a, b) => a - b)) {
|
|
985
1479
|
const row = byScene.get(sceneIndex);
|
|
986
1480
|
lines.push(`${String(sceneIndex).padEnd(5)} ${itemWord(row.clip).padEnd(8)} ${itemWord(row.voiceover).padEnd(8)} ${itemWord(row.frame)}`);
|
|
1481
|
+
if (row.clip?.lastRedo) {
|
|
1482
|
+
lines.push(` clip: ${row.clip.lastRedo.label}`);
|
|
1483
|
+
printDisplacedHistory(lines, row.clip.lastRedo);
|
|
1484
|
+
}
|
|
1485
|
+
if (row.clip?.lastRedo || row.clip?.flagged) {
|
|
1486
|
+
for (const line of renderQcTakeHistory(clipQcOf(row.clip))) {
|
|
1487
|
+
lines.push(` ${line}`);
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
if (row.voiceover?.lastRedo) {
|
|
1491
|
+
lines.push(` voice: ${row.voiceover.lastRedo.label}`);
|
|
1492
|
+
printDisplacedHistory(lines, row.voiceover.lastRedo);
|
|
1493
|
+
}
|
|
1494
|
+
if (row.frame?.lastRedo)
|
|
1495
|
+
lines.push(` picture: ${row.frame.lastRedo.label}`);
|
|
987
1496
|
for (const finding of row.clip?.findings ?? []) {
|
|
988
1497
|
lines.push(` ${finding.code} (${finding.severity}): ${finding.detail}`);
|
|
989
1498
|
const judgeSeverity = finding.judgeSeverity ?? finding.severity;
|
|
@@ -998,6 +1507,8 @@ export async function statusFlow(runId, json, deps) {
|
|
|
998
1507
|
lines.push(` ${row.clip.error}`);
|
|
999
1508
|
if (revoicedByScene.has(sceneIndex))
|
|
1000
1509
|
lines.push(" voice: cast voice applied");
|
|
1510
|
+
if (voiceFirstByScene.has(sceneIndex))
|
|
1511
|
+
lines.push(" clip: redone voice first");
|
|
1001
1512
|
if (timedOnOriginalVoice.has(sceneIndex)) {
|
|
1002
1513
|
lines.push(" word timings: measured on the original voice (close, not frame-exact)");
|
|
1003
1514
|
}
|
|
@@ -1016,6 +1527,45 @@ export async function statusFlow(runId, json, deps) {
|
|
|
1016
1527
|
: "Final cut: not uploaded yet.");
|
|
1017
1528
|
return { code: 0, lines };
|
|
1018
1529
|
}
|
|
1530
|
+
export async function rejectedDraftFlow(runId, json, deps) {
|
|
1531
|
+
const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1532
|
+
if (!runRes.ok)
|
|
1533
|
+
return errorResult(runRes, json);
|
|
1534
|
+
const node = failedStoryboardNode(asVideoRun(runRes.data));
|
|
1535
|
+
if (!node) {
|
|
1536
|
+
const note = "The storyboard on this run didn't fail, so there is no rejected draft to read.";
|
|
1537
|
+
return {
|
|
1538
|
+
code: 0,
|
|
1539
|
+
lines: json ? [JSON.stringify({ runId, nodeId: null, draft: null })] : [note],
|
|
1540
|
+
};
|
|
1541
|
+
}
|
|
1542
|
+
const res = await deps.get(`${REJECTED_DRAFT_PATH}?runId=${encodeURIComponent(runId)}&nodeId=${encodeURIComponent(node.nodeId)}`);
|
|
1543
|
+
if (missingRouteLine(res, "exodus video status --rejected-draft")) {
|
|
1544
|
+
return {
|
|
1545
|
+
code: 1,
|
|
1546
|
+
lines: json
|
|
1547
|
+
? [JSON.stringify({ ok: false, status: 404, error: REJECTED_DRAFT_NOT_ON_THIS_SERVER })]
|
|
1548
|
+
: [REJECTED_DRAFT_NOT_ON_THIS_SERVER],
|
|
1549
|
+
};
|
|
1550
|
+
}
|
|
1551
|
+
if (!res.ok)
|
|
1552
|
+
return errorResult(res, json);
|
|
1553
|
+
const draft = res.data.draft ?? null;
|
|
1554
|
+
if (json)
|
|
1555
|
+
return { code: 0, lines: [JSON.stringify({ runId, nodeId: node.nodeId, draft })] };
|
|
1556
|
+
if (!draft)
|
|
1557
|
+
return { code: 0, lines: ["No rejected draft was kept for this attempt."] };
|
|
1558
|
+
return {
|
|
1559
|
+
code: 0,
|
|
1560
|
+
lines: [
|
|
1561
|
+
`REJECTED DRAFT — ad run ${runId}, step ${node.nodeId}`,
|
|
1562
|
+
"The system turned this draft down and threw it away. It is kept only as a record of what happened.",
|
|
1563
|
+
"Nothing in it is part of your ad. There is nothing here to act on or edit.",
|
|
1564
|
+
"",
|
|
1565
|
+
...draft.split("\n"),
|
|
1566
|
+
],
|
|
1567
|
+
};
|
|
1568
|
+
}
|
|
1019
1569
|
export async function storyboardFlow(runId, json, deps) {
|
|
1020
1570
|
const res = await deps.get(`${STORYBOARD_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1021
1571
|
if (!res.ok)
|
|
@@ -1067,7 +1617,14 @@ export async function storyboardFlow(runId, json, deps) {
|
|
|
1067
1617
|
}
|
|
1068
1618
|
}
|
|
1069
1619
|
}
|
|
1070
|
-
|
|
1620
|
+
const runRes = await deps
|
|
1621
|
+
.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`)
|
|
1622
|
+
.catch(() => null);
|
|
1623
|
+
const showAd = runRes?.ok === true && isShowAd(asVideoRun(runRes.data));
|
|
1624
|
+
lines.push("", `approve with: exodus video approve ${runId}`);
|
|
1625
|
+
if (showAd) {
|
|
1626
|
+
lines.push(`send it back: exodus video flag ${runId} --note "what's wrong"`);
|
|
1627
|
+
}
|
|
1071
1628
|
if (cards.framesNodeId) {
|
|
1072
1629
|
lines.push(`redo one frame: exodus video retry-frame ${runId} --node ${cards.framesNodeId} --scene <n>`);
|
|
1073
1630
|
}
|
|
@@ -1095,14 +1652,30 @@ function sceneCardLines(scene) {
|
|
|
1095
1652
|
lines.push(` picture: ${scene.frame.status}`);
|
|
1096
1653
|
return lines;
|
|
1097
1654
|
}
|
|
1098
|
-
export async function approveFlow(runId,
|
|
1099
|
-
const res = await deps.post(APPROVE_PATH, {
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1655
|
+
export async function approveFlow(runId, opts, deps) {
|
|
1656
|
+
const res = await deps.post(APPROVE_PATH, {
|
|
1657
|
+
runId,
|
|
1658
|
+
...(opts.approveStaleCut ? { approveStaleCut: true } : {}),
|
|
1659
|
+
});
|
|
1660
|
+
if (!res.ok) {
|
|
1661
|
+
const refusal = errorResult(res, opts.json);
|
|
1662
|
+
const staleCut = !opts.json && videoApiError(res).includes("approve this cut anyway");
|
|
1663
|
+
return staleCut
|
|
1664
|
+
? {
|
|
1665
|
+
...refusal,
|
|
1666
|
+
lines: [
|
|
1667
|
+
...refusal.lines,
|
|
1668
|
+
`To deliver it anyway: exodus video approve ${runId} --approve-stale-cut`,
|
|
1669
|
+
],
|
|
1670
|
+
}
|
|
1671
|
+
: refusal;
|
|
1672
|
+
}
|
|
1673
|
+
const data = res.data;
|
|
1674
|
+
const warnings = Array.isArray(data?.warnings) ? data.warnings : [];
|
|
1675
|
+
if (opts.json) {
|
|
1676
|
+
return { code: 0, lines: [JSON.stringify({ ok: true, runId, warnings, data: res.data })] };
|
|
1677
|
+
}
|
|
1678
|
+
const voices = data?.voices;
|
|
1106
1679
|
const summary = voices
|
|
1107
1680
|
? voiceSummaryLines(Array.isArray(voices.cast) ? voices.cast : [], Array.isArray(voices.notices) ? voices.notices : [])
|
|
1108
1681
|
: [];
|
|
@@ -1110,6 +1683,7 @@ export async function approveFlow(runId, json, deps) {
|
|
|
1110
1683
|
code: 0,
|
|
1111
1684
|
lines: [
|
|
1112
1685
|
"Approved.",
|
|
1686
|
+
...warnings,
|
|
1113
1687
|
...(summary.length > 0 ? ["", ...summary, ""] : []),
|
|
1114
1688
|
`See what happens next: exodus video status ${runId}`,
|
|
1115
1689
|
],
|
|
@@ -1167,8 +1741,9 @@ export async function retryFrameFlow(runId, nodeId, sceneIndex, note, json, deps
|
|
|
1167
1741
|
],
|
|
1168
1742
|
};
|
|
1169
1743
|
}
|
|
1170
|
-
const UPLOADED_CUT_WARNING = "The cut you already uploaded will not include the new clip.
|
|
1171
|
-
"re-cut
|
|
1744
|
+
const UPLOADED_CUT_WARNING = "The cut you already uploaded will not include the new clip. If this redo replaces the clip, " +
|
|
1745
|
+
"exodus video approve stops until you pull the pieces again, re-cut and upload again, " +
|
|
1746
|
+
"or pass --approve-stale-cut to deliver the cut you uploaded as it is.";
|
|
1172
1747
|
export function planClipRedo(run, items, target) {
|
|
1173
1748
|
const stop = classifyRun(run);
|
|
1174
1749
|
const found = findClipRow(run, items, target);
|
|
@@ -1201,7 +1776,7 @@ export function planClipRedo(run, items, target) {
|
|
|
1201
1776
|
};
|
|
1202
1777
|
}
|
|
1203
1778
|
function findClipRow(run, items, target) {
|
|
1204
|
-
const uploadedCut = items
|
|
1779
|
+
const uploadedCut = hasAttachedCut(items);
|
|
1205
1780
|
const rows = items.filter((i) => i.itemKind === "clip" &&
|
|
1206
1781
|
i.sceneIndex === target.sceneIndex &&
|
|
1207
1782
|
(target.nodeId === undefined || i.nodeId === target.nodeId));
|
|
@@ -1234,7 +1809,7 @@ function findClipRow(run, items, target) {
|
|
|
1234
1809
|
}
|
|
1235
1810
|
return { row, uploadedCut };
|
|
1236
1811
|
}
|
|
1237
|
-
export async function retryClipFlow(runId, target, note, json, deps) {
|
|
1812
|
+
export async function retryClipFlow(runId, target, { note, voiceFirst = false }, json, deps) {
|
|
1238
1813
|
const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1239
1814
|
if (!runRes.ok)
|
|
1240
1815
|
return errorResult(runRes, json);
|
|
@@ -1250,12 +1825,20 @@ export async function retryClipFlow(runId, target, note, json, deps) {
|
|
|
1250
1825
|
lines: json ? [JSON.stringify({ ok: false, error: plan.reason })] : [plan.reason],
|
|
1251
1826
|
};
|
|
1252
1827
|
}
|
|
1253
|
-
const res = await deps.post(SCENE_RETRY_PATH, {
|
|
1828
|
+
const res = await deps.post(voiceFirst ? SCENE_VOICE_FIRST_PATH : SCENE_RETRY_PATH, {
|
|
1254
1829
|
runId,
|
|
1255
1830
|
nodeId: plan.nodeId,
|
|
1256
1831
|
sceneIndex: plan.sceneIndex,
|
|
1257
1832
|
...(note ? { note } : {}),
|
|
1258
1833
|
});
|
|
1834
|
+
if (voiceFirst && missingRouteLine(res, "exodus video retry-clip --voice-first")) {
|
|
1835
|
+
return {
|
|
1836
|
+
code: 1,
|
|
1837
|
+
lines: json
|
|
1838
|
+
? [JSON.stringify({ ok: false, status: 404, error: VOICE_FIRST_NOT_ON_THIS_SERVER })]
|
|
1839
|
+
: [VOICE_FIRST_NOT_ON_THIS_SERVER],
|
|
1840
|
+
};
|
|
1841
|
+
}
|
|
1259
1842
|
if (!res.ok)
|
|
1260
1843
|
return errorResult(res, json);
|
|
1261
1844
|
const triggerRunId = res.data.triggerRunId;
|
|
@@ -1274,6 +1857,7 @@ export async function retryClipFlow(runId, target, note, json, deps) {
|
|
|
1274
1857
|
reviewUrl: review,
|
|
1275
1858
|
warnings: plan.warnings,
|
|
1276
1859
|
...(note ? { note } : {}),
|
|
1860
|
+
...(voiceFirst ? { voiceMode: "voice-first" } : {}),
|
|
1277
1861
|
}),
|
|
1278
1862
|
],
|
|
1279
1863
|
};
|
|
@@ -1281,7 +1865,10 @@ export async function retryClipFlow(runId, target, note, json, deps) {
|
|
|
1281
1865
|
return {
|
|
1282
1866
|
code: 0,
|
|
1283
1867
|
lines: [
|
|
1284
|
-
|
|
1868
|
+
voiceFirst
|
|
1869
|
+
? `Redoing scene ${plan.sceneIndex}'s clip on ${plan.nodeId}, voice first: the line is ` +
|
|
1870
|
+
"recorded in the character's own voice, then the clip is made to match it."
|
|
1871
|
+
: `Redoing scene ${plan.sceneIndex}'s clip on ${plan.nodeId}.`,
|
|
1285
1872
|
`triggerRunId: ${triggerRunId ?? "-"}`,
|
|
1286
1873
|
"Every other scene stays as it is, and so do the pictures, the voices and the script.",
|
|
1287
1874
|
...(note
|
|
@@ -1600,6 +2187,13 @@ export function voiceSheetLines(sheet) {
|
|
|
1600
2187
|
: (sheet.whyNot ?? "Voices can no longer be changed on this run."));
|
|
1601
2188
|
return lines;
|
|
1602
2189
|
}
|
|
2190
|
+
function musicLines(manifest) {
|
|
2191
|
+
const lines = [`music: ${manifest.musicBed}`];
|
|
2192
|
+
if (manifest.musicHeardScenes.length > 0) {
|
|
2193
|
+
lines.push(`music heard in scene ${manifest.musicHeardScenes.join(", ")}`);
|
|
2194
|
+
}
|
|
2195
|
+
return lines;
|
|
2196
|
+
}
|
|
1603
2197
|
export async function pullFlow(runId, dir, json, deps) {
|
|
1604
2198
|
const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1605
2199
|
if (!runRes.ok)
|
|
@@ -1643,7 +2237,13 @@ export async function pullFlow(runId, dir, json, deps) {
|
|
|
1643
2237
|
const lines = [
|
|
1644
2238
|
`Pulled ${wrote} files into ${dir}`,
|
|
1645
2239
|
`Every piece is indexed in ${path.join(dir, "manifest.json")} — scene numbers there are the run's own.`,
|
|
2240
|
+
...musicLines(plan.manifest),
|
|
1646
2241
|
];
|
|
2242
|
+
const voiceFirst = plan.manifest.scenes.filter((s) => s.voiceMode === "voice-first");
|
|
2243
|
+
if (voiceFirst.length > 0) {
|
|
2244
|
+
lines.push("", `Redone voice first (the line recorded, then the clip made to match it): ` +
|
|
2245
|
+
`scene${voiceFirst.length === 1 ? "" : "s"} ${voiceFirst.map((s) => s.sceneIndex).join(", ")}`);
|
|
2246
|
+
}
|
|
1647
2247
|
const flagged = plan.manifest.scenes.filter((s) => s.flagged);
|
|
1648
2248
|
if (flagged.length > 0) {
|
|
1649
2249
|
lines.push("", `${flagged.length} clip${flagged.length === 1 ? "" : "s"} came back flagged — usable, but look before you cut:`);
|
|
@@ -1899,7 +2499,12 @@ export async function uploadFlow(runId, filePath, durationFlag, json, deps) {
|
|
|
1899
2499
|
const VALUE_FLAGS = new Set([
|
|
1900
2500
|
"show",
|
|
1901
2501
|
"script",
|
|
2502
|
+
"conceit",
|
|
2503
|
+
"style",
|
|
2504
|
+
"direction",
|
|
1902
2505
|
"voice-path",
|
|
2506
|
+
"video-model",
|
|
2507
|
+
"voice",
|
|
1903
2508
|
"note",
|
|
1904
2509
|
"out",
|
|
1905
2510
|
"file",
|
|
@@ -1952,20 +2557,12 @@ export async function run(flags, occurrences) {
|
|
|
1952
2557
|
if (sub === "shows")
|
|
1953
2558
|
return printResult(await showsFlow(json, defaultDeps));
|
|
1954
2559
|
if (sub === "start") {
|
|
1955
|
-
const
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
return printResult(await startFlow({
|
|
1962
|
-
showId,
|
|
1963
|
-
scriptFile,
|
|
1964
|
-
voicePath: flagString(flags, "voice-path"),
|
|
1965
|
-
music: flags["music"] === false ? false : undefined,
|
|
1966
|
-
wait: flags["wait"] === true,
|
|
1967
|
-
json,
|
|
1968
|
-
}, defaultDeps));
|
|
2560
|
+
const plan = planVideoStart(flags, occurrences);
|
|
2561
|
+
if (plan.kind === "usage")
|
|
2562
|
+
usage(plan.line);
|
|
2563
|
+
if (plan.kind === "show")
|
|
2564
|
+
return printResult(await startFlow(plan.opts, defaultDeps));
|
|
2565
|
+
return printResult(await startScriptFlow(plan.opts, defaultDeps));
|
|
1969
2566
|
}
|
|
1970
2567
|
const runId = rest[0];
|
|
1971
2568
|
const needsRunId = [
|
|
@@ -1983,12 +2580,17 @@ export async function run(flags, occurrences) {
|
|
|
1983
2580
|
if (needsRunId.includes(sub) && !runId) {
|
|
1984
2581
|
usage(`video ${sub} needs a run id: exodus video ${sub} <runId>`);
|
|
1985
2582
|
}
|
|
1986
|
-
if (sub === "status")
|
|
2583
|
+
if (sub === "status") {
|
|
2584
|
+
if (flags["rejected-draft"] !== undefined && flags["rejected-draft"] !== false) {
|
|
2585
|
+
return printResult(await rejectedDraftFlow(runId, json, defaultDeps));
|
|
2586
|
+
}
|
|
1987
2587
|
return printResult(await statusFlow(runId, json, defaultDeps));
|
|
2588
|
+
}
|
|
1988
2589
|
if (sub === "storyboard")
|
|
1989
2590
|
return printResult(await storyboardFlow(runId, json, defaultDeps));
|
|
1990
|
-
if (sub === "approve")
|
|
1991
|
-
return printResult(await approveFlow(runId, json, defaultDeps));
|
|
2591
|
+
if (sub === "approve") {
|
|
2592
|
+
return printResult(await approveFlow(runId, { json, approveStaleCut: flags["approve-stale-cut"] === true }, defaultDeps));
|
|
2593
|
+
}
|
|
1992
2594
|
if (sub === "flag") {
|
|
1993
2595
|
const note = flagString(flags, "note");
|
|
1994
2596
|
if (!note)
|
|
@@ -2018,7 +2620,7 @@ export async function run(flags, occurrences) {
|
|
|
2018
2620
|
if (!Number.isInteger(sceneIndex)) {
|
|
2019
2621
|
usage(`video retry-clip --scene must be a whole scene number, not "${sceneRaw}".`);
|
|
2020
2622
|
}
|
|
2021
|
-
return printResult(await retryClipFlow(runId, { sceneIndex, nodeId: flagString(flags, "node") }, flagString(flags, "note"), json, defaultDeps));
|
|
2623
|
+
return printResult(await retryClipFlow(runId, { sceneIndex, nodeId: flagString(flags, "node") }, { note: flagString(flags, "note"), voiceFirst: flags["voice-first"] === true }, json, defaultDeps));
|
|
2022
2624
|
}
|
|
2023
2625
|
if (sub === "revoice") {
|
|
2024
2626
|
const sceneRaw = flagString(flags, "scene");
|