@aicopycoders/exodus 2026.9.2000-beta.1 → 2026.9.2000-beta.10
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 +126 -19
- 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 +119 -4
- package/dist/commands/video.js +613 -48
- package/dist/commands/workflow.d.ts +51 -3
- 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,7 +57,13 @@ 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.
|
|
@@ -62,18 +84,21 @@ The whole loop, in order:
|
|
|
62
84
|
Attaches your cut to the run and prints the page to approve it on.
|
|
63
85
|
|
|
64
86
|
11. exodus video approve <runId>
|
|
65
|
-
Or click Approve on the page from step 10.
|
|
87
|
+
Or click Approve on the page from step 10. If you redid a clip after
|
|
88
|
+
uploading, this stops and says which scenes changed. Upload a fresh cut,
|
|
89
|
+
or add --approve-stale-cut to deliver the cut you already uploaded.
|
|
66
90
|
|
|
67
91
|
Usage:
|
|
68
|
-
exodus video status <runId> [--json]
|
|
92
|
+
exodus video status <runId> [--rejected-draft] [--json]
|
|
69
93
|
exodus video storyboard <runId> [--json]
|
|
70
|
-
exodus video approve <runId> [--json]
|
|
94
|
+
exodus video approve <runId> [--approve-stale-cut] [--json]
|
|
71
95
|
exodus video retry-frame <runId> --node <nodeId> --scene <n> [--note "..."] [--json]
|
|
72
96
|
exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "..."] [--json]
|
|
73
97
|
exodus video revoice <runId> (--scene <n> | --all) [--node <nodeId>] [--json]
|
|
74
98
|
exodus video voices <runId> [--set <character>=<voiceId>] [--clear <character>] [--from <file.json>] [--json]
|
|
75
99
|
exodus video pull <runId> --out <dir> [--json]
|
|
76
100
|
exodus video upload <runId> --file <cut.mp4> [--duration <sec>] [--json]
|
|
101
|
+
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
102
|
|
|
78
103
|
Options:
|
|
79
104
|
--out <dir> Folder to write the pulled pieces into (pull)
|
|
@@ -88,12 +113,30 @@ Options:
|
|
|
88
113
|
(retry-frame, retry-clip, revoice)
|
|
89
114
|
--all Every finished clip that kept the video model's voice
|
|
90
115
|
(revoice)
|
|
116
|
+
--approve-stale-cut Deliver the cut you uploaded even though a clip changed
|
|
117
|
+
after you uploaded it (approve). Without it, approving
|
|
118
|
+
stops and names the scenes that changed
|
|
91
119
|
--set <who>=<id> Give a character an ElevenLabs voice (voices). Name the
|
|
92
120
|
character by its ID or by the name your script uses.
|
|
93
121
|
Repeat it once per character
|
|
94
122
|
--clear <who> Take a character's voice off again (voices). Repeatable
|
|
95
123
|
--from <file.json> A file of characters and voice IDs (voices). --set and
|
|
96
124
|
--clear win over the same name in the file
|
|
125
|
+
--rejected-draft Print the storyboard draft the system turned down
|
|
126
|
+
(status). It was thrown away — a record only
|
|
127
|
+
--script <file> Text file of what the ad says (start)
|
|
128
|
+
--conceit <key> Kind of ad: ${joinedOr(CONCEIT_KEYS)} (start)
|
|
129
|
+
--style <slug> Look: ${joinedOr(STYLE_SLUGS)} (start)
|
|
130
|
+
--direction "<note>" Note for the whole ad (start)
|
|
131
|
+
--voice-path <path> How the voices are made (start)
|
|
132
|
+
--video-model <id> Which video model films this ad, instead of the kind of
|
|
133
|
+
ad's own default (start, with --conceit)
|
|
134
|
+
--voice <mode> native: the video model speaks the lines. voice-first:
|
|
135
|
+
the lines are recorded first (start, with --conceit)
|
|
136
|
+
--music Put a music bed under the ad (start). An ad has no music
|
|
137
|
+
unless you ask for it here
|
|
138
|
+
--no-music Leave the music bed off (start), which it already is
|
|
139
|
+
--wait Wait until the storyboard needs a yes (start)
|
|
97
140
|
--json Machine-readable output
|
|
98
141
|
--help, -h Print this help
|
|
99
142
|
|
|
@@ -117,8 +160,12 @@ Examples:
|
|
|
117
160
|
`.trim();
|
|
118
161
|
const SHOWS_PATH = "/api/v2/shows";
|
|
119
162
|
const RUNS_PATH = "/api/v2/video/runs";
|
|
163
|
+
const SCRIPT_RUNS_PATH = "/api/v2/video/script-runs";
|
|
120
164
|
const RUN_PATH = "/api/v2/workflow";
|
|
121
165
|
const ITEMS_PATH = "/api/v2/workflow/items";
|
|
166
|
+
const REJECTED_DRAFT_PATH = "/api/v2/workflow/rejected-draft";
|
|
167
|
+
const REJECTED_DRAFT_NOT_ON_THIS_SERVER = "This Exodus server does not keep rejected drafts yet, so there is nothing to read. It arrives " +
|
|
168
|
+
"with the next server update.";
|
|
122
169
|
const STORYBOARD_PATH = "/api/v2/video/storyboard";
|
|
123
170
|
const FLAG_PATH = "/api/v2/video/storyboard/flag";
|
|
124
171
|
const APPROVE_PATH = "/api/v2/workflow/approve";
|
|
@@ -133,6 +180,48 @@ const ASSETS_PATH = "/api/v2/workflows/assets";
|
|
|
133
180
|
function withoutJudgeFields({ judgeDetail: _d, judgeSeverity: _s, ...rest }) {
|
|
134
181
|
return rest;
|
|
135
182
|
}
|
|
183
|
+
export function qcWithoutJudgeWording(qc) {
|
|
184
|
+
if (!qc.takes)
|
|
185
|
+
return qc;
|
|
186
|
+
return {
|
|
187
|
+
...qc,
|
|
188
|
+
takes: qc.takes.map(({ judgeWording: _w, ...rest }) => rest),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
export const NO_TAKE_HISTORY_LINE = "no history recorded for this clip";
|
|
192
|
+
const TAKE_NEIGHBOUR_SENTENCE = {
|
|
193
|
+
"not-applicable": () => "never compared",
|
|
194
|
+
"not-attached": () => "no neighbours attached",
|
|
195
|
+
"none-accepted": () => "no accepted neighbours yet",
|
|
196
|
+
attached: ({ scenes }) => `compared against scene${scenes.length === 1 ? "" : "s"} ${scenes.join(", ")}`,
|
|
197
|
+
};
|
|
198
|
+
export function renderQcTakeHistory(takes) {
|
|
199
|
+
if (!takes || takes.length === 0)
|
|
200
|
+
return [NO_TAKE_HISTORY_LINE];
|
|
201
|
+
const lines = [];
|
|
202
|
+
takes.forEach((take, index) => {
|
|
203
|
+
const say = TAKE_NEIGHBOUR_SENTENCE[take.neighbours.state];
|
|
204
|
+
const parts = [
|
|
205
|
+
take.failCodes.length ? `failed ${take.failCodes.join(", ")}` : "passed",
|
|
206
|
+
...(take.warnCodes.length ? [`warned ${take.warnCodes.join(", ")}`] : []),
|
|
207
|
+
say(take.neighbours),
|
|
208
|
+
];
|
|
209
|
+
const bonus = take.kind === "soft-retry" ? " (bonus take)" : "";
|
|
210
|
+
lines.push(`take ${index + 1}${bonus}: ${parts.join("; ")}`);
|
|
211
|
+
for (const { code, wording } of take.judgeWording ?? []) {
|
|
212
|
+
lines.push(` checker said (${code}): ${wording}`);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
return lines;
|
|
216
|
+
}
|
|
217
|
+
function printDisplacedHistory(lines, lastRedo) {
|
|
218
|
+
const history = lastRedo.displacedHistory;
|
|
219
|
+
if (!history)
|
|
220
|
+
return;
|
|
221
|
+
lines.push(` ${history.title}`);
|
|
222
|
+
for (const line of history.lines)
|
|
223
|
+
lines.push(` ${line}`);
|
|
224
|
+
}
|
|
136
225
|
export function asVideoRun(data) {
|
|
137
226
|
const run = (data ?? {});
|
|
138
227
|
return { ...run, nodes: Array.isArray(run.nodes) ? run.nodes : [] };
|
|
@@ -245,6 +334,7 @@ export function classifyRun(run) {
|
|
|
245
334
|
...(pausedNode.kind === "scene-frames"
|
|
246
335
|
? { framesNodeId: pausedNode.nodeId }
|
|
247
336
|
: {}),
|
|
337
|
+
...(isShowAd(run) ? { showAd: true } : {}),
|
|
248
338
|
};
|
|
249
339
|
}
|
|
250
340
|
if (parkedAtFinalWatch(run))
|
|
@@ -254,6 +344,22 @@ export function classifyRun(run) {
|
|
|
254
344
|
const active = run.nodes.find((n) => n.status === "running");
|
|
255
345
|
return { at: "running", stage: active?.kind ?? "starting" };
|
|
256
346
|
}
|
|
347
|
+
export function hasAttachedCut(items) {
|
|
348
|
+
return items.some((i) => i.itemKind === "final" && i.status === "done");
|
|
349
|
+
}
|
|
350
|
+
export function resolveStop(stop, cutAttached) {
|
|
351
|
+
if (stop.at !== "final-watch")
|
|
352
|
+
return stop;
|
|
353
|
+
return { at: "final-watch", cutAttached };
|
|
354
|
+
}
|
|
355
|
+
export async function resolveStopAtPark(stop, runId, deps) {
|
|
356
|
+
if (stop.at !== "final-watch")
|
|
357
|
+
return stop;
|
|
358
|
+
const res = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
359
|
+
if (!res.ok)
|
|
360
|
+
return resolveStop(stop, null);
|
|
361
|
+
return resolveStop(stop, hasAttachedCut(res.data.items ?? []));
|
|
362
|
+
}
|
|
257
363
|
const STAGE_WORDS = {
|
|
258
364
|
brief: "reading the script",
|
|
259
365
|
storyboard: "writing the storyboard",
|
|
@@ -279,8 +385,11 @@ export function stepName(kind) {
|
|
|
279
385
|
return "A step in this run";
|
|
280
386
|
return STEP_NAMES[kind] ?? `The ${kind} step`;
|
|
281
387
|
}
|
|
388
|
+
export function isShowAd(run) {
|
|
389
|
+
return run.moduleOwned === true;
|
|
390
|
+
}
|
|
282
391
|
export function reviewUrl(dashboardUrl, run) {
|
|
283
|
-
if (run
|
|
392
|
+
if (isShowAd(run))
|
|
284
393
|
return `${dashboardUrl}/video?ad=${run._id}`;
|
|
285
394
|
if (run.workflowId)
|
|
286
395
|
return `${dashboardUrl}/workflows/${run.workflowId}/runs/${run._id}`;
|
|
@@ -292,14 +401,31 @@ export function stopLines(stop, runId, runUrl) {
|
|
|
292
401
|
"Parked: the storyboard is waiting for your yes.",
|
|
293
402
|
`Read it: exodus video storyboard ${runId}`,
|
|
294
403
|
`Approve it: exodus video approve ${runId}`,
|
|
295
|
-
`Send back: exodus video flag ${runId} --note "what's wrong"`,
|
|
296
404
|
];
|
|
405
|
+
if (stop.showAd) {
|
|
406
|
+
lines.push(`Send back: exodus video flag ${runId} --note "what's wrong"`);
|
|
407
|
+
}
|
|
297
408
|
if (stop.framesNodeId) {
|
|
298
409
|
lines.push(`Redo one frame: exodus video retry-frame ${runId} --node ${stop.framesNodeId} --scene <n>`);
|
|
299
410
|
}
|
|
300
411
|
return lines;
|
|
301
412
|
}
|
|
302
413
|
if (stop.at === "final-watch") {
|
|
414
|
+
if (stop.cutAttached === null) {
|
|
415
|
+
return [
|
|
416
|
+
"Parked: every piece is made.",
|
|
417
|
+
`Couldn't check whether a cut is already uploaded. See where it stands: exodus video status ${runId}`,
|
|
418
|
+
`Watch it here: ${runUrl}`,
|
|
419
|
+
];
|
|
420
|
+
}
|
|
421
|
+
if (stop.cutAttached) {
|
|
422
|
+
return [
|
|
423
|
+
"Parked: your cut is uploaded and waiting for your approval.",
|
|
424
|
+
`Approve it: exodus video approve ${runId}`,
|
|
425
|
+
`Watch it here: ${runUrl}`,
|
|
426
|
+
"Changed your mind? You can still redo a clip and upload a new cut.",
|
|
427
|
+
];
|
|
428
|
+
}
|
|
303
429
|
return [
|
|
304
430
|
"Parked: every piece is made and the run is waiting for a cut.",
|
|
305
431
|
`Pull the pieces: exodus video pull ${runId} --out ./ad-${runId}`,
|
|
@@ -310,6 +436,13 @@ export function stopLines(stop, runId, runUrl) {
|
|
|
310
436
|
}
|
|
311
437
|
if (stop.at === "failed") {
|
|
312
438
|
if (stop.repair) {
|
|
439
|
+
if (!stop.step) {
|
|
440
|
+
return [
|
|
441
|
+
"This run stopped at a step that can't be picked back up from here.",
|
|
442
|
+
"Start a fresh run to get this ad made.",
|
|
443
|
+
`Open the run: ${runUrl}`,
|
|
444
|
+
];
|
|
445
|
+
}
|
|
313
446
|
return [
|
|
314
447
|
`${stepName(stop.step)} failed${stop.error ? `: ${stop.error}` : "."}`,
|
|
315
448
|
`Try that step again: exodus workflow repair ${runId} retry`,
|
|
@@ -335,7 +468,40 @@ export function stopLines(stop, runId, runUrl) {
|
|
|
335
468
|
}
|
|
336
469
|
return [`Working: ${stageWord(stop.stage)}.`];
|
|
337
470
|
}
|
|
338
|
-
export
|
|
471
|
+
export function failedStoryboardNode(run) {
|
|
472
|
+
return run.nodes.find((n) => n.kind === "storyboard" && n.status === "failed");
|
|
473
|
+
}
|
|
474
|
+
export function errorEchoesReasons(error, record) {
|
|
475
|
+
if (!error || record?.kind !== "rejected")
|
|
476
|
+
return false;
|
|
477
|
+
const reasons = record.reasons ?? [];
|
|
478
|
+
return reasons.length > 0 && reasons.every((reason) => error.includes(reason));
|
|
479
|
+
}
|
|
480
|
+
export function planFailureLines(node, runId) {
|
|
481
|
+
const record = node?.planFailure;
|
|
482
|
+
if (!record || !Array.isArray(record.lines) || !node)
|
|
483
|
+
return [];
|
|
484
|
+
const reasons = record.kind === "rejected" ? (record.reasons ?? []) : [];
|
|
485
|
+
const bodyCount = Math.max(0, record.lines.length - reasons.length);
|
|
486
|
+
const lines = [`What happened to the storyboard step (${node.nodeId}):`];
|
|
487
|
+
record.lines.forEach((line, i) => {
|
|
488
|
+
lines.push(i < bodyCount ? ` ${line}` : ` - ${line}`);
|
|
489
|
+
});
|
|
490
|
+
if (node?.hasRejectedDraft === true) {
|
|
491
|
+
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`);
|
|
492
|
+
}
|
|
493
|
+
return lines;
|
|
494
|
+
}
|
|
495
|
+
export const MUSIC_HEARD_CODE = "music-heard";
|
|
496
|
+
export const CAST_LEDGER_BASE = HELPER_LEDGER_BASE + 10000;
|
|
497
|
+
export function musicBedState(run) {
|
|
498
|
+
if (run.musicBed === undefined)
|
|
499
|
+
return "unknown";
|
|
500
|
+
return run.musicBed ? "on" : "off";
|
|
501
|
+
}
|
|
502
|
+
function isPullSceneIndex(sceneIndex) {
|
|
503
|
+
return sceneIndex >= 0 && sceneIndex < HELPER_LEDGER_BASE;
|
|
504
|
+
}
|
|
339
505
|
const FALLBACK_EXT = {
|
|
340
506
|
image: "png",
|
|
341
507
|
video: "mp4",
|
|
@@ -352,6 +518,10 @@ function extFor(url, family) {
|
|
|
352
518
|
export function scenePrefix(sceneIndex) {
|
|
353
519
|
return `scene-${String(sceneIndex).padStart(2, "0")}`;
|
|
354
520
|
}
|
|
521
|
+
function clipQcOf(item) {
|
|
522
|
+
const artifact = item?.artifact;
|
|
523
|
+
return artifact && artifact.type === "video" ? artifact.qc?.takes : undefined;
|
|
524
|
+
}
|
|
355
525
|
function outputsOfNodeKind(run, kind) {
|
|
356
526
|
return run.nodes.filter((n) => n.kind === kind).flatMap((n) => n.outputs ?? []);
|
|
357
527
|
}
|
|
@@ -381,7 +551,9 @@ function keyframeDownload(sceneIndex, imageUrl) {
|
|
|
381
551
|
function reservedCastFrames(items) {
|
|
382
552
|
const byIndex = new Map();
|
|
383
553
|
for (const item of items) {
|
|
384
|
-
if (item.itemKind === "frame" &&
|
|
554
|
+
if (item.itemKind === "frame" &&
|
|
555
|
+
item.sceneIndex >= CAST_LEDGER_BASE &&
|
|
556
|
+
item.sceneIndex < SET_OPTION_LEDGER_BASE) {
|
|
385
557
|
byIndex.set(item.sceneIndex, item);
|
|
386
558
|
}
|
|
387
559
|
}
|
|
@@ -534,6 +706,8 @@ export function planPull(run, items, opts) {
|
|
|
534
706
|
for (const frame of artifact.frames ?? []) {
|
|
535
707
|
if (!frame.imageUrl)
|
|
536
708
|
continue;
|
|
709
|
+
if (!isPullSceneIndex(frame.sceneIndex))
|
|
710
|
+
continue;
|
|
537
711
|
keyframeByScene.set(frame.sceneIndex, keyframeDownload(frame.sceneIndex, frame.imageUrl));
|
|
538
712
|
}
|
|
539
713
|
}
|
|
@@ -551,7 +725,7 @@ export function planPull(run, items, opts) {
|
|
|
551
725
|
};
|
|
552
726
|
continue;
|
|
553
727
|
}
|
|
554
|
-
if (typeof artifact.sceneIndex !== "number" || artifact.sceneIndex
|
|
728
|
+
if (typeof artifact.sceneIndex !== "number" || !isPullSceneIndex(artifact.sceneIndex))
|
|
555
729
|
continue;
|
|
556
730
|
voiceByScene.set(artifact.sceneIndex, {
|
|
557
731
|
download: {
|
|
@@ -592,6 +766,8 @@ export function planPull(run, items, opts) {
|
|
|
592
766
|
}
|
|
593
767
|
if (artifact.type !== "video" || typeof artifact.sceneIndex !== "number")
|
|
594
768
|
continue;
|
|
769
|
+
if (!isPullSceneIndex(artifact.sceneIndex))
|
|
770
|
+
continue;
|
|
595
771
|
const clip = clipFromArtifact(artifact.sceneIndex, artifact);
|
|
596
772
|
if (!clip)
|
|
597
773
|
continue;
|
|
@@ -601,6 +777,8 @@ export function planPull(run, items, opts) {
|
|
|
601
777
|
const frameItemByScene = new Map();
|
|
602
778
|
const sceneFrameNodeIds = new Set(run.nodes.filter((n) => n.kind === "scene-frames").map((n) => n.nodeId));
|
|
603
779
|
for (const item of items) {
|
|
780
|
+
if (!isPullSceneIndex(item.sceneIndex))
|
|
781
|
+
continue;
|
|
604
782
|
if (item.itemKind === "clip")
|
|
605
783
|
clipItemByScene.set(item.sceneIndex, item);
|
|
606
784
|
if (item.itemKind === "frame" && sceneFrameNodeIds.has(item.nodeId)) {
|
|
@@ -632,7 +810,7 @@ export function planPull(run, items, opts) {
|
|
|
632
810
|
...frameItemByScene.keys(),
|
|
633
811
|
]),
|
|
634
812
|
]
|
|
635
|
-
.filter((sceneIndex) => sceneIndex
|
|
813
|
+
.filter((sceneIndex) => isPullSceneIndex(sceneIndex))
|
|
636
814
|
.sort((a, b) => a - b);
|
|
637
815
|
const scenes = sceneIndexes.map((sceneIndex) => {
|
|
638
816
|
const clip = clipByScene.get(sceneIndex);
|
|
@@ -667,7 +845,7 @@ export function planPull(run, items, opts) {
|
|
|
667
845
|
: "this-file",
|
|
668
846
|
voice: voice?.download.file ?? null,
|
|
669
847
|
keyframe: keyframeByScene.get(sceneIndex)?.file ?? null,
|
|
670
|
-
qc: clip?.qc
|
|
848
|
+
qc: clip?.qc ? qcWithoutJudgeWording(clip.qc) : null,
|
|
671
849
|
revoiced: clip ? clip.revoiced : null,
|
|
672
850
|
speechTrimmed: clip ? clip.speechTrimmed : null,
|
|
673
851
|
rawStorageId: clip ? clip.rawStorageId : null,
|
|
@@ -698,6 +876,10 @@ export function planPull(run, items, opts) {
|
|
|
698
876
|
storyboard,
|
|
699
877
|
reference: reference?.file ?? null,
|
|
700
878
|
music: music?.file ?? null,
|
|
879
|
+
musicBed: musicBedState(run),
|
|
880
|
+
musicHeardScenes: scenes
|
|
881
|
+
.filter((scene) => scene.findings.some((f) => f.code === MUSIC_HEARD_CODE))
|
|
882
|
+
.map((scene) => scene.sceneIndex),
|
|
701
883
|
cast,
|
|
702
884
|
narration: narrationDownload
|
|
703
885
|
? { file: narrationDownload.file, timing: "narration.json" }
|
|
@@ -707,6 +889,14 @@ export function planPull(run, items, opts) {
|
|
|
707
889
|
...(run.provenance?.format || run.provenance?.voice
|
|
708
890
|
? { provenance: run.provenance }
|
|
709
891
|
: {}),
|
|
892
|
+
...(run.videoChoice
|
|
893
|
+
? {
|
|
894
|
+
videoChoice: {
|
|
895
|
+
videoModel: run.videoChoice.videoModel,
|
|
896
|
+
voiceMode: run.videoChoice.voiceMode,
|
|
897
|
+
},
|
|
898
|
+
}
|
|
899
|
+
: {}),
|
|
710
900
|
},
|
|
711
901
|
};
|
|
712
902
|
}
|
|
@@ -837,7 +1027,7 @@ export async function startFlow(opts, deps) {
|
|
|
837
1027
|
showId: opts.showId,
|
|
838
1028
|
script,
|
|
839
1029
|
...(opts.voicePath ? { voicePath: opts.voicePath } : {}),
|
|
840
|
-
...(opts.music ===
|
|
1030
|
+
...(opts.music === undefined ? {} : { music: opts.music }),
|
|
841
1031
|
});
|
|
842
1032
|
if (!res.ok)
|
|
843
1033
|
return errorResult(res, opts.json);
|
|
@@ -866,6 +1056,264 @@ export async function startFlow(opts, deps) {
|
|
|
866
1056
|
lines: opts.json ? waited.lines : [`Started ad run ${runId}`, `Watch it: ${url}`, "", ...waited.lines],
|
|
867
1057
|
};
|
|
868
1058
|
}
|
|
1059
|
+
function nonBlank(value) {
|
|
1060
|
+
if (value === undefined || value.trim().length === 0)
|
|
1061
|
+
return undefined;
|
|
1062
|
+
return value;
|
|
1063
|
+
}
|
|
1064
|
+
function isVoiceMode(value) {
|
|
1065
|
+
return VOICE_MODES.some((mode) => mode === value);
|
|
1066
|
+
}
|
|
1067
|
+
function isConceit(value) {
|
|
1068
|
+
return CONCEIT_KEYS.some((key) => key === value);
|
|
1069
|
+
}
|
|
1070
|
+
export function planMusicChoice(occurrences) {
|
|
1071
|
+
let music;
|
|
1072
|
+
for (const { flag, value } of occurrences) {
|
|
1073
|
+
if (flag === "no-music") {
|
|
1074
|
+
music = false;
|
|
1075
|
+
continue;
|
|
1076
|
+
}
|
|
1077
|
+
if (flag !== "music")
|
|
1078
|
+
continue;
|
|
1079
|
+
if (value === undefined || value.startsWith("--")) {
|
|
1080
|
+
music = true;
|
|
1081
|
+
continue;
|
|
1082
|
+
}
|
|
1083
|
+
const word = value.trim().toLowerCase();
|
|
1084
|
+
const literal = word === "true" ? true : word === "false" ? false : undefined;
|
|
1085
|
+
if (literal === undefined) {
|
|
1086
|
+
return {
|
|
1087
|
+
ok: false,
|
|
1088
|
+
line: `video start --music takes true or false, or no value at all (got "${value}").`,
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
music = literal;
|
|
1092
|
+
}
|
|
1093
|
+
return { ok: true, music };
|
|
1094
|
+
}
|
|
1095
|
+
function planValueFlag(occurrences, name) {
|
|
1096
|
+
let value;
|
|
1097
|
+
for (const occurrence of occurrences) {
|
|
1098
|
+
if (occurrence.flag !== name)
|
|
1099
|
+
continue;
|
|
1100
|
+
const given = occurrence.value;
|
|
1101
|
+
if (given === undefined || given.startsWith("--") || given.trim().length === 0) {
|
|
1102
|
+
return { ok: false, line: `video start --${name} needs a value.` };
|
|
1103
|
+
}
|
|
1104
|
+
value = given.trim();
|
|
1105
|
+
}
|
|
1106
|
+
return { ok: true, value };
|
|
1107
|
+
}
|
|
1108
|
+
export function planVideoStart(flags, occurrences) {
|
|
1109
|
+
const showId = nonBlank(flagString(flags, "show"));
|
|
1110
|
+
const scriptFile = nonBlank(flagString(flags, "script"));
|
|
1111
|
+
const conceit = nonBlank(flagString(flags, "conceit"));
|
|
1112
|
+
const style = nonBlank(flagString(flags, "style"));
|
|
1113
|
+
if (showId && (conceit || style)) {
|
|
1114
|
+
return {
|
|
1115
|
+
kind: "usage",
|
|
1116
|
+
line: "Pass either --show <id>, or --conceit and --style. Not both.",
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
if (!scriptFile) {
|
|
1120
|
+
return {
|
|
1121
|
+
kind: "usage",
|
|
1122
|
+
line: "video start needs --script <file>, a text file of what the ad says.",
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
const musicChoice = planMusicChoice(occurrences);
|
|
1126
|
+
if (!musicChoice.ok)
|
|
1127
|
+
return { kind: "usage", line: musicChoice.line };
|
|
1128
|
+
const videoModelFlag = planValueFlag(occurrences, "video-model");
|
|
1129
|
+
if (!videoModelFlag.ok)
|
|
1130
|
+
return { kind: "usage", line: videoModelFlag.line };
|
|
1131
|
+
const voiceFlag = planValueFlag(occurrences, "voice");
|
|
1132
|
+
if (!voiceFlag.ok)
|
|
1133
|
+
return { kind: "usage", line: voiceFlag.line };
|
|
1134
|
+
const videoModel = videoModelFlag.value;
|
|
1135
|
+
const voiceMode = voiceFlag.value;
|
|
1136
|
+
if (showId && (videoModel || voiceMode)) {
|
|
1137
|
+
return {
|
|
1138
|
+
kind: "usage",
|
|
1139
|
+
line: "--video-model and --voice work with --conceit, not --show. A Show films on its own model.",
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
if (voiceMode && !isVoiceMode(voiceMode)) {
|
|
1143
|
+
return {
|
|
1144
|
+
kind: "usage",
|
|
1145
|
+
line: `video start --voice must be native or voice-first (got "${voiceMode}").`,
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
if (showId) {
|
|
1149
|
+
return {
|
|
1150
|
+
kind: "show",
|
|
1151
|
+
opts: {
|
|
1152
|
+
showId,
|
|
1153
|
+
scriptFile,
|
|
1154
|
+
voicePath: flagString(flags, "voice-path"),
|
|
1155
|
+
music: musicChoice.music,
|
|
1156
|
+
wait: flags["wait"] === true,
|
|
1157
|
+
json: flags["json"] === true,
|
|
1158
|
+
},
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
if (!conceit) {
|
|
1162
|
+
return {
|
|
1163
|
+
kind: "usage",
|
|
1164
|
+
line: `video start needs --conceit <${CONCEIT_KEYS.join("|")}>.`,
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
if (!isConceit(conceit)) {
|
|
1168
|
+
return {
|
|
1169
|
+
kind: "usage",
|
|
1170
|
+
line: `video start --conceit must be one of ${joinedOr(CONCEIT_KEYS)}.`,
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1173
|
+
if (!style) {
|
|
1174
|
+
return {
|
|
1175
|
+
kind: "usage",
|
|
1176
|
+
line: `video start needs --style <${STYLE_SLUGS.join("|")}>.`,
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
const direction = nonBlank(flagString(flags, "direction"));
|
|
1180
|
+
const voicePath = nonBlank(flagString(flags, "voice-path"));
|
|
1181
|
+
const opts = {
|
|
1182
|
+
scriptFile,
|
|
1183
|
+
conceit,
|
|
1184
|
+
style,
|
|
1185
|
+
wait: flags["wait"] === true,
|
|
1186
|
+
json: flags["json"] === true,
|
|
1187
|
+
};
|
|
1188
|
+
if (direction)
|
|
1189
|
+
opts.direction = direction;
|
|
1190
|
+
if (voicePath)
|
|
1191
|
+
opts.voicePath = voicePath;
|
|
1192
|
+
if (videoModel)
|
|
1193
|
+
opts.videoModel = videoModel;
|
|
1194
|
+
if (voiceMode && isVoiceMode(voiceMode))
|
|
1195
|
+
opts.voiceMode = voiceMode;
|
|
1196
|
+
if (musicChoice.music !== undefined)
|
|
1197
|
+
opts.music = musicChoice.music;
|
|
1198
|
+
return { kind: "script", opts };
|
|
1199
|
+
}
|
|
1200
|
+
function isRecord(value) {
|
|
1201
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1202
|
+
}
|
|
1203
|
+
function isResolvedCard(value) {
|
|
1204
|
+
if (!isRecord(value))
|
|
1205
|
+
return false;
|
|
1206
|
+
if (!isRecord(value.conceit) || !isRecord(value.style))
|
|
1207
|
+
return false;
|
|
1208
|
+
if (typeof value.conceit.key !== "string" || typeof value.conceit.label !== "string")
|
|
1209
|
+
return false;
|
|
1210
|
+
if (typeof value.family !== "string")
|
|
1211
|
+
return false;
|
|
1212
|
+
if (!("wrapper" in value))
|
|
1213
|
+
return false;
|
|
1214
|
+
if (value.wrapper !== null && typeof value.wrapper !== "string")
|
|
1215
|
+
return false;
|
|
1216
|
+
if (typeof value.style.slug !== "string" || typeof value.style.label !== "string")
|
|
1217
|
+
return false;
|
|
1218
|
+
return true;
|
|
1219
|
+
}
|
|
1220
|
+
function startedVideoChoice(value) {
|
|
1221
|
+
if (!isRecord(value))
|
|
1222
|
+
return null;
|
|
1223
|
+
if (typeof value.videoModel !== "string" || typeof value.voiceMode !== "string")
|
|
1224
|
+
return null;
|
|
1225
|
+
return { videoModel: value.videoModel, voiceMode: value.voiceMode };
|
|
1226
|
+
}
|
|
1227
|
+
function scriptStartedLines(runId, url, card, video) {
|
|
1228
|
+
return [
|
|
1229
|
+
`Started ad run ${runId}`,
|
|
1230
|
+
...(card
|
|
1231
|
+
? [
|
|
1232
|
+
`Conceit: ${card.conceit.label}`,
|
|
1233
|
+
`Family: ${card.family}`,
|
|
1234
|
+
`Wrapper: ${card.wrapper ?? ""}`,
|
|
1235
|
+
`Style: ${card.style.label}`,
|
|
1236
|
+
]
|
|
1237
|
+
: ["The server did not include a resolved card."]),
|
|
1238
|
+
...(video ? [`Video model: ${video.videoModel}`, `Voice: ${video.voiceMode}`] : []),
|
|
1239
|
+
`Watch it: ${url}`,
|
|
1240
|
+
];
|
|
1241
|
+
}
|
|
1242
|
+
function waitJsonWithCard(waited, card, video) {
|
|
1243
|
+
const first = waited.lines[0];
|
|
1244
|
+
if (typeof first !== "string")
|
|
1245
|
+
return waited;
|
|
1246
|
+
let parsed;
|
|
1247
|
+
try {
|
|
1248
|
+
parsed = JSON.parse(first);
|
|
1249
|
+
}
|
|
1250
|
+
catch {
|
|
1251
|
+
return waited;
|
|
1252
|
+
}
|
|
1253
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
1254
|
+
return waited;
|
|
1255
|
+
return {
|
|
1256
|
+
code: waited.code,
|
|
1257
|
+
lines: [JSON.stringify({ ...parsed, card, ...(video ? { videoChoice: video } : {}) })],
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
export async function startScriptFlow(opts, deps) {
|
|
1261
|
+
let script;
|
|
1262
|
+
try {
|
|
1263
|
+
script = deps.readFile(opts.scriptFile);
|
|
1264
|
+
}
|
|
1265
|
+
catch (e) {
|
|
1266
|
+
return {
|
|
1267
|
+
code: 1,
|
|
1268
|
+
lines: [
|
|
1269
|
+
`Can't read the script file "${opts.scriptFile}": ${e instanceof Error ? e.message : String(e)}`,
|
|
1270
|
+
],
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
if (script.trim().length === 0) {
|
|
1274
|
+
return { code: 1, lines: [`The script file "${opts.scriptFile}" is empty.`] };
|
|
1275
|
+
}
|
|
1276
|
+
const res = await deps.post(SCRIPT_RUNS_PATH, {
|
|
1277
|
+
script,
|
|
1278
|
+
conceit: opts.conceit,
|
|
1279
|
+
style: opts.style,
|
|
1280
|
+
...(opts.direction ? { direction: opts.direction } : {}),
|
|
1281
|
+
...(opts.voicePath ? { voicePath: opts.voicePath } : {}),
|
|
1282
|
+
...(opts.videoModel ? { videoModel: opts.videoModel } : {}),
|
|
1283
|
+
...(opts.voiceMode ? { voiceMode: opts.voiceMode } : {}),
|
|
1284
|
+
...(opts.music === true ? { music: true } : {}),
|
|
1285
|
+
});
|
|
1286
|
+
if (!res.ok)
|
|
1287
|
+
return errorResult(res, opts.json);
|
|
1288
|
+
const started = res.data;
|
|
1289
|
+
if (!started.runId) {
|
|
1290
|
+
return { code: 1, lines: ["The server started the ad but did not say which run it is."] };
|
|
1291
|
+
}
|
|
1292
|
+
const runId = started.runId;
|
|
1293
|
+
const url = typeof started.url === "string" ? started.url : "";
|
|
1294
|
+
const card = isResolvedCard(started.card) ? started.card : null;
|
|
1295
|
+
const video = startedVideoChoice(started.videoChoice);
|
|
1296
|
+
if (!opts.wait) {
|
|
1297
|
+
return {
|
|
1298
|
+
code: 0,
|
|
1299
|
+
lines: opts.json
|
|
1300
|
+
? [JSON.stringify({ runId, url, card, ...(video ? { videoChoice: video } : {}) })]
|
|
1301
|
+
: [
|
|
1302
|
+
...scriptStartedLines(runId, url, card, video),
|
|
1303
|
+
"",
|
|
1304
|
+
"Wait for the storyboard here instead: exodus video start … --wait",
|
|
1305
|
+
`Or check in whenever: exodus video status ${runId}`,
|
|
1306
|
+
],
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
const waited = await waitFlow(runId, { json: opts.json, url }, deps);
|
|
1310
|
+
if (opts.json)
|
|
1311
|
+
return waitJsonWithCard(waited, card, video);
|
|
1312
|
+
return {
|
|
1313
|
+
code: waited.code,
|
|
1314
|
+
lines: [...scriptStartedLines(runId, url, card, video), "", ...waited.lines],
|
|
1315
|
+
};
|
|
1316
|
+
}
|
|
869
1317
|
const POLL_INTERVAL_MS = 5000;
|
|
870
1318
|
const MAX_POLLS = 720;
|
|
871
1319
|
export async function waitFlow(runId, opts, deps) {
|
|
@@ -891,15 +1339,16 @@ export async function waitFlow(runId, opts, deps) {
|
|
|
891
1339
|
}
|
|
892
1340
|
continue;
|
|
893
1341
|
}
|
|
1342
|
+
const resolved = await resolveStopAtPark(stop, runId, deps);
|
|
894
1343
|
if (opts.json) {
|
|
895
1344
|
return {
|
|
896
1345
|
code: stop.at === "failed" ? 1 : 0,
|
|
897
|
-
lines: [JSON.stringify({ runId, stop, status: run.status, url: opts.url })],
|
|
1346
|
+
lines: [JSON.stringify({ runId, stop: resolved, status: run.status, url: opts.url })],
|
|
898
1347
|
};
|
|
899
1348
|
}
|
|
900
1349
|
return {
|
|
901
1350
|
code: stop.at === "failed" ? 1 : 0,
|
|
902
|
-
lines: [...lines, ...stopLines(
|
|
1351
|
+
lines: [...lines, ...stopLines(resolved, runId, reviewUrl(deps.dashboardUrl, run))],
|
|
903
1352
|
};
|
|
904
1353
|
}
|
|
905
1354
|
const timeoutLine = `Still running after ${Math.round((maxPolls * interval) / 60000)} minutes. Check in with: exodus video status ${runId}`;
|
|
@@ -924,6 +1373,11 @@ function itemWord(item) {
|
|
|
924
1373
|
return "flagged";
|
|
925
1374
|
return ITEM_STATUS_WORD[item.status] ?? item.status;
|
|
926
1375
|
}
|
|
1376
|
+
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.";
|
|
1377
|
+
function carryingOn(run) {
|
|
1378
|
+
return (run.status === "queued" &&
|
|
1379
|
+
run.nodes.some((n) => n.status !== "idle" && n.status !== "out-of-scope"));
|
|
1380
|
+
}
|
|
927
1381
|
export async function statusFlow(runId, json, deps) {
|
|
928
1382
|
const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
929
1383
|
if (!runRes.ok)
|
|
@@ -933,15 +1387,35 @@ export async function statusFlow(runId, json, deps) {
|
|
|
933
1387
|
if (!itemsRes.ok)
|
|
934
1388
|
return errorResult(itemsRes, json);
|
|
935
1389
|
const items = itemsRes.data.items ?? [];
|
|
936
|
-
const
|
|
937
|
-
const
|
|
1390
|
+
const hasFinal = hasAttachedCut(items);
|
|
1391
|
+
const stop = resolveStop(classifyRun(run), hasFinal);
|
|
1392
|
+
const failedStoryboard = failedStoryboardNode(run);
|
|
1393
|
+
const guidance = stopLines(stop.at === "failed" && errorEchoesReasons(stop.error, failedStoryboard?.planFailure)
|
|
1394
|
+
? { ...stop, error: undefined }
|
|
1395
|
+
: stop, runId, reviewUrl(deps.dashboardUrl, run));
|
|
938
1396
|
const warnings = run.nodes
|
|
939
1397
|
.filter((n) => n.warning)
|
|
940
1398
|
.map((n) => ({ nodeId: n.nodeId, step: n.kind, warning: n.warning }));
|
|
941
1399
|
if (json) {
|
|
942
1400
|
return {
|
|
943
1401
|
code: 0,
|
|
944
|
-
lines: [
|
|
1402
|
+
lines: [
|
|
1403
|
+
JSON.stringify({
|
|
1404
|
+
runId,
|
|
1405
|
+
status: run.status,
|
|
1406
|
+
stop,
|
|
1407
|
+
items,
|
|
1408
|
+
hasFinal,
|
|
1409
|
+
warnings,
|
|
1410
|
+
guidance,
|
|
1411
|
+
...(run.musicBed === undefined ? {} : { musicBed: run.musicBed }),
|
|
1412
|
+
...(failedStoryboard?.planFailure ? { planFailure: failedStoryboard.planFailure } : {}),
|
|
1413
|
+
...(failedStoryboard?.planFailure
|
|
1414
|
+
? { storyboardNodeId: failedStoryboard.nodeId }
|
|
1415
|
+
: {}),
|
|
1416
|
+
...(failedStoryboard?.hasRejectedDraft === true ? { hasRejectedDraft: true } : {}),
|
|
1417
|
+
}),
|
|
1418
|
+
],
|
|
945
1419
|
};
|
|
946
1420
|
}
|
|
947
1421
|
const revoicedByScene = new Set();
|
|
@@ -972,8 +1446,10 @@ export async function statusFlow(runId, json, deps) {
|
|
|
972
1446
|
: `Ad run ${runId} — ${displayRunStatus(run.status)}`;
|
|
973
1447
|
const lines = [
|
|
974
1448
|
headline,
|
|
975
|
-
...
|
|
1449
|
+
...(carryingOn(run) ? [CARRYING_ON_LINE] : []),
|
|
1450
|
+
...guidance,
|
|
976
1451
|
...(stop.at === "running" && run.pauseAhead ? [pauseAheadLine(run.pauseAhead)] : []),
|
|
1452
|
+
...planFailureLines(failedStoryboard, runId),
|
|
977
1453
|
...warnings.map((w) => `Heads-up (${w.step}): ${w.warning}`),
|
|
978
1454
|
];
|
|
979
1455
|
if (byScene.size === 0) {
|
|
@@ -984,6 +1460,21 @@ export async function statusFlow(runId, json, deps) {
|
|
|
984
1460
|
for (const sceneIndex of [...byScene.keys()].sort((a, b) => a - b)) {
|
|
985
1461
|
const row = byScene.get(sceneIndex);
|
|
986
1462
|
lines.push(`${String(sceneIndex).padEnd(5)} ${itemWord(row.clip).padEnd(8)} ${itemWord(row.voiceover).padEnd(8)} ${itemWord(row.frame)}`);
|
|
1463
|
+
if (row.clip?.lastRedo) {
|
|
1464
|
+
lines.push(` clip: ${row.clip.lastRedo.label}`);
|
|
1465
|
+
printDisplacedHistory(lines, row.clip.lastRedo);
|
|
1466
|
+
}
|
|
1467
|
+
if (row.clip?.lastRedo || row.clip?.flagged) {
|
|
1468
|
+
for (const line of renderQcTakeHistory(clipQcOf(row.clip))) {
|
|
1469
|
+
lines.push(` ${line}`);
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
if (row.voiceover?.lastRedo) {
|
|
1473
|
+
lines.push(` voice: ${row.voiceover.lastRedo.label}`);
|
|
1474
|
+
printDisplacedHistory(lines, row.voiceover.lastRedo);
|
|
1475
|
+
}
|
|
1476
|
+
if (row.frame?.lastRedo)
|
|
1477
|
+
lines.push(` picture: ${row.frame.lastRedo.label}`);
|
|
987
1478
|
for (const finding of row.clip?.findings ?? []) {
|
|
988
1479
|
lines.push(` ${finding.code} (${finding.severity}): ${finding.detail}`);
|
|
989
1480
|
const judgeSeverity = finding.judgeSeverity ?? finding.severity;
|
|
@@ -1016,6 +1507,45 @@ export async function statusFlow(runId, json, deps) {
|
|
|
1016
1507
|
: "Final cut: not uploaded yet.");
|
|
1017
1508
|
return { code: 0, lines };
|
|
1018
1509
|
}
|
|
1510
|
+
export async function rejectedDraftFlow(runId, json, deps) {
|
|
1511
|
+
const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1512
|
+
if (!runRes.ok)
|
|
1513
|
+
return errorResult(runRes, json);
|
|
1514
|
+
const node = failedStoryboardNode(asVideoRun(runRes.data));
|
|
1515
|
+
if (!node) {
|
|
1516
|
+
const note = "The storyboard on this run didn't fail, so there is no rejected draft to read.";
|
|
1517
|
+
return {
|
|
1518
|
+
code: 0,
|
|
1519
|
+
lines: json ? [JSON.stringify({ runId, nodeId: null, draft: null })] : [note],
|
|
1520
|
+
};
|
|
1521
|
+
}
|
|
1522
|
+
const res = await deps.get(`${REJECTED_DRAFT_PATH}?runId=${encodeURIComponent(runId)}&nodeId=${encodeURIComponent(node.nodeId)}`);
|
|
1523
|
+
if (missingRouteLine(res, "exodus video status --rejected-draft")) {
|
|
1524
|
+
return {
|
|
1525
|
+
code: 1,
|
|
1526
|
+
lines: json
|
|
1527
|
+
? [JSON.stringify({ ok: false, status: 404, error: REJECTED_DRAFT_NOT_ON_THIS_SERVER })]
|
|
1528
|
+
: [REJECTED_DRAFT_NOT_ON_THIS_SERVER],
|
|
1529
|
+
};
|
|
1530
|
+
}
|
|
1531
|
+
if (!res.ok)
|
|
1532
|
+
return errorResult(res, json);
|
|
1533
|
+
const draft = res.data.draft ?? null;
|
|
1534
|
+
if (json)
|
|
1535
|
+
return { code: 0, lines: [JSON.stringify({ runId, nodeId: node.nodeId, draft })] };
|
|
1536
|
+
if (!draft)
|
|
1537
|
+
return { code: 0, lines: ["No rejected draft was kept for this attempt."] };
|
|
1538
|
+
return {
|
|
1539
|
+
code: 0,
|
|
1540
|
+
lines: [
|
|
1541
|
+
`REJECTED DRAFT — ad run ${runId}, step ${node.nodeId}`,
|
|
1542
|
+
"The system turned this draft down and threw it away. It is kept only as a record of what happened.",
|
|
1543
|
+
"Nothing in it is part of your ad. There is nothing here to act on or edit.",
|
|
1544
|
+
"",
|
|
1545
|
+
...draft.split("\n"),
|
|
1546
|
+
],
|
|
1547
|
+
};
|
|
1548
|
+
}
|
|
1019
1549
|
export async function storyboardFlow(runId, json, deps) {
|
|
1020
1550
|
const res = await deps.get(`${STORYBOARD_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1021
1551
|
if (!res.ok)
|
|
@@ -1067,7 +1597,14 @@ export async function storyboardFlow(runId, json, deps) {
|
|
|
1067
1597
|
}
|
|
1068
1598
|
}
|
|
1069
1599
|
}
|
|
1070
|
-
|
|
1600
|
+
const runRes = await deps
|
|
1601
|
+
.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`)
|
|
1602
|
+
.catch(() => null);
|
|
1603
|
+
const showAd = runRes?.ok === true && isShowAd(asVideoRun(runRes.data));
|
|
1604
|
+
lines.push("", `approve with: exodus video approve ${runId}`);
|
|
1605
|
+
if (showAd) {
|
|
1606
|
+
lines.push(`send it back: exodus video flag ${runId} --note "what's wrong"`);
|
|
1607
|
+
}
|
|
1071
1608
|
if (cards.framesNodeId) {
|
|
1072
1609
|
lines.push(`redo one frame: exodus video retry-frame ${runId} --node ${cards.framesNodeId} --scene <n>`);
|
|
1073
1610
|
}
|
|
@@ -1095,14 +1632,30 @@ function sceneCardLines(scene) {
|
|
|
1095
1632
|
lines.push(` picture: ${scene.frame.status}`);
|
|
1096
1633
|
return lines;
|
|
1097
1634
|
}
|
|
1098
|
-
export async function approveFlow(runId,
|
|
1099
|
-
const res = await deps.post(APPROVE_PATH, {
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1635
|
+
export async function approveFlow(runId, opts, deps) {
|
|
1636
|
+
const res = await deps.post(APPROVE_PATH, {
|
|
1637
|
+
runId,
|
|
1638
|
+
...(opts.approveStaleCut ? { approveStaleCut: true } : {}),
|
|
1639
|
+
});
|
|
1640
|
+
if (!res.ok) {
|
|
1641
|
+
const refusal = errorResult(res, opts.json);
|
|
1642
|
+
const staleCut = !opts.json && videoApiError(res).includes("approve this cut anyway");
|
|
1643
|
+
return staleCut
|
|
1644
|
+
? {
|
|
1645
|
+
...refusal,
|
|
1646
|
+
lines: [
|
|
1647
|
+
...refusal.lines,
|
|
1648
|
+
`To deliver it anyway: exodus video approve ${runId} --approve-stale-cut`,
|
|
1649
|
+
],
|
|
1650
|
+
}
|
|
1651
|
+
: refusal;
|
|
1652
|
+
}
|
|
1653
|
+
const data = res.data;
|
|
1654
|
+
const warnings = Array.isArray(data?.warnings) ? data.warnings : [];
|
|
1655
|
+
if (opts.json) {
|
|
1656
|
+
return { code: 0, lines: [JSON.stringify({ ok: true, runId, warnings, data: res.data })] };
|
|
1657
|
+
}
|
|
1658
|
+
const voices = data?.voices;
|
|
1106
1659
|
const summary = voices
|
|
1107
1660
|
? voiceSummaryLines(Array.isArray(voices.cast) ? voices.cast : [], Array.isArray(voices.notices) ? voices.notices : [])
|
|
1108
1661
|
: [];
|
|
@@ -1110,6 +1663,7 @@ export async function approveFlow(runId, json, deps) {
|
|
|
1110
1663
|
code: 0,
|
|
1111
1664
|
lines: [
|
|
1112
1665
|
"Approved.",
|
|
1666
|
+
...warnings,
|
|
1113
1667
|
...(summary.length > 0 ? ["", ...summary, ""] : []),
|
|
1114
1668
|
`See what happens next: exodus video status ${runId}`,
|
|
1115
1669
|
],
|
|
@@ -1167,8 +1721,9 @@ export async function retryFrameFlow(runId, nodeId, sceneIndex, note, json, deps
|
|
|
1167
1721
|
],
|
|
1168
1722
|
};
|
|
1169
1723
|
}
|
|
1170
|
-
const UPLOADED_CUT_WARNING = "The cut you already uploaded will not include the new clip.
|
|
1171
|
-
"re-cut
|
|
1724
|
+
const UPLOADED_CUT_WARNING = "The cut you already uploaded will not include the new clip. If this redo replaces the clip, " +
|
|
1725
|
+
"exodus video approve stops until you pull the pieces again, re-cut and upload again, " +
|
|
1726
|
+
"or pass --approve-stale-cut to deliver the cut you uploaded as it is.";
|
|
1172
1727
|
export function planClipRedo(run, items, target) {
|
|
1173
1728
|
const stop = classifyRun(run);
|
|
1174
1729
|
const found = findClipRow(run, items, target);
|
|
@@ -1201,7 +1756,7 @@ export function planClipRedo(run, items, target) {
|
|
|
1201
1756
|
};
|
|
1202
1757
|
}
|
|
1203
1758
|
function findClipRow(run, items, target) {
|
|
1204
|
-
const uploadedCut = items
|
|
1759
|
+
const uploadedCut = hasAttachedCut(items);
|
|
1205
1760
|
const rows = items.filter((i) => i.itemKind === "clip" &&
|
|
1206
1761
|
i.sceneIndex === target.sceneIndex &&
|
|
1207
1762
|
(target.nodeId === undefined || i.nodeId === target.nodeId));
|
|
@@ -1600,6 +2155,13 @@ export function voiceSheetLines(sheet) {
|
|
|
1600
2155
|
: (sheet.whyNot ?? "Voices can no longer be changed on this run."));
|
|
1601
2156
|
return lines;
|
|
1602
2157
|
}
|
|
2158
|
+
function musicLines(manifest) {
|
|
2159
|
+
const lines = [`music: ${manifest.musicBed}`];
|
|
2160
|
+
if (manifest.musicHeardScenes.length > 0) {
|
|
2161
|
+
lines.push(`music heard in scene ${manifest.musicHeardScenes.join(", ")}`);
|
|
2162
|
+
}
|
|
2163
|
+
return lines;
|
|
2164
|
+
}
|
|
1603
2165
|
export async function pullFlow(runId, dir, json, deps) {
|
|
1604
2166
|
const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1605
2167
|
if (!runRes.ok)
|
|
@@ -1643,6 +2205,7 @@ export async function pullFlow(runId, dir, json, deps) {
|
|
|
1643
2205
|
const lines = [
|
|
1644
2206
|
`Pulled ${wrote} files into ${dir}`,
|
|
1645
2207
|
`Every piece is indexed in ${path.join(dir, "manifest.json")} — scene numbers there are the run's own.`,
|
|
2208
|
+
...musicLines(plan.manifest),
|
|
1646
2209
|
];
|
|
1647
2210
|
const flagged = plan.manifest.scenes.filter((s) => s.flagged);
|
|
1648
2211
|
if (flagged.length > 0) {
|
|
@@ -1899,7 +2462,12 @@ export async function uploadFlow(runId, filePath, durationFlag, json, deps) {
|
|
|
1899
2462
|
const VALUE_FLAGS = new Set([
|
|
1900
2463
|
"show",
|
|
1901
2464
|
"script",
|
|
2465
|
+
"conceit",
|
|
2466
|
+
"style",
|
|
2467
|
+
"direction",
|
|
1902
2468
|
"voice-path",
|
|
2469
|
+
"video-model",
|
|
2470
|
+
"voice",
|
|
1903
2471
|
"note",
|
|
1904
2472
|
"out",
|
|
1905
2473
|
"file",
|
|
@@ -1952,20 +2520,12 @@ export async function run(flags, occurrences) {
|
|
|
1952
2520
|
if (sub === "shows")
|
|
1953
2521
|
return printResult(await showsFlow(json, defaultDeps));
|
|
1954
2522
|
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));
|
|
2523
|
+
const plan = planVideoStart(flags, occurrences);
|
|
2524
|
+
if (plan.kind === "usage")
|
|
2525
|
+
usage(plan.line);
|
|
2526
|
+
if (plan.kind === "show")
|
|
2527
|
+
return printResult(await startFlow(plan.opts, defaultDeps));
|
|
2528
|
+
return printResult(await startScriptFlow(plan.opts, defaultDeps));
|
|
1969
2529
|
}
|
|
1970
2530
|
const runId = rest[0];
|
|
1971
2531
|
const needsRunId = [
|
|
@@ -1983,12 +2543,17 @@ export async function run(flags, occurrences) {
|
|
|
1983
2543
|
if (needsRunId.includes(sub) && !runId) {
|
|
1984
2544
|
usage(`video ${sub} needs a run id: exodus video ${sub} <runId>`);
|
|
1985
2545
|
}
|
|
1986
|
-
if (sub === "status")
|
|
2546
|
+
if (sub === "status") {
|
|
2547
|
+
if (flags["rejected-draft"] !== undefined && flags["rejected-draft"] !== false) {
|
|
2548
|
+
return printResult(await rejectedDraftFlow(runId, json, defaultDeps));
|
|
2549
|
+
}
|
|
1987
2550
|
return printResult(await statusFlow(runId, json, defaultDeps));
|
|
2551
|
+
}
|
|
1988
2552
|
if (sub === "storyboard")
|
|
1989
2553
|
return printResult(await storyboardFlow(runId, json, defaultDeps));
|
|
1990
|
-
if (sub === "approve")
|
|
1991
|
-
return printResult(await approveFlow(runId, json, defaultDeps));
|
|
2554
|
+
if (sub === "approve") {
|
|
2555
|
+
return printResult(await approveFlow(runId, { json, approveStaleCut: flags["approve-stale-cut"] === true }, defaultDeps));
|
|
2556
|
+
}
|
|
1992
2557
|
if (sub === "flag") {
|
|
1993
2558
|
const note = flagString(flags, "note");
|
|
1994
2559
|
if (!note)
|