@aicopycoders/exodus 2026.9.2000-beta.0 → 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/CLAUDE.md +1 -1
- package/assets/docs/PIPELINES.md +7 -10
- 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 +215 -163
- package/assets/skills/exodus-video/scripts/first-cut.mjs +17 -30
- 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 +639 -77
- 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,38 +6,66 @@ 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
|
-
exodus video — make
|
|
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
|
|
|
17
33
|
The whole loop, in order:
|
|
18
34
|
|
|
19
|
-
1. exodus
|
|
20
|
-
Which
|
|
35
|
+
1. exodus workflow list
|
|
36
|
+
Which workflows this brand has saved. A video workflow is one that makes
|
|
37
|
+
ads.
|
|
38
|
+
|
|
39
|
+
2. exodus workflow describe <workflowId|name>
|
|
40
|
+
What that workflow needs before it can run, and what it gives back.
|
|
21
41
|
|
|
22
|
-
|
|
23
|
-
Starts
|
|
42
|
+
3. exodus workflow run <workflowId|name> --input <field>=@script.txt --wait
|
|
43
|
+
Starts the ad on your script and waits. It stops when the storyboard
|
|
44
|
+
needs your yes. "describe" names the field your script goes in. You can
|
|
45
|
+
also settle the voices here, at the launch, instead of at step 5 — the
|
|
46
|
+
launch flags all live in: exodus workflow --help
|
|
24
47
|
|
|
25
|
-
|
|
48
|
+
4. exodus video storyboard <runId>
|
|
26
49
|
The scene cards: what each scene says and the picture it will look like.
|
|
27
50
|
|
|
28
|
-
|
|
51
|
+
5. exodus video voices <runId>
|
|
29
52
|
Who is in the ad, which voice each one has, how the voices get applied
|
|
30
53
|
and who pays. Add --set to give someone a voice, before any clip is made.
|
|
31
54
|
|
|
32
|
-
|
|
33
|
-
exodus video flag <runId> --note "…" (something is wrong — send it back)
|
|
55
|
+
6. exodus video approve <runId> (looks right — keep going)
|
|
34
56
|
exodus video retry-frame <runId> --node <nodeId> --scene <n>
|
|
35
57
|
Redo ONE still at the pixel gate. Neighbours stay. The gate holds.
|
|
36
58
|
|
|
37
|
-
|
|
38
|
-
Where the run is and how each scene's clip turned out.
|
|
59
|
+
7. exodus video status <runId>
|
|
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.
|
|
39
63
|
|
|
40
|
-
|
|
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.
|
|
67
|
+
|
|
68
|
+
8. exodus video pull <runId> --out ./ad
|
|
41
69
|
Writes every piece to that folder plus a manifest.json index.
|
|
42
70
|
|
|
43
71
|
exodus video retry-clip <runId> --scene <n>
|
|
@@ -50,41 +78,34 @@ The whole loop, in order:
|
|
|
50
78
|
picture stays, no new video is made, and a clip whose voice pass
|
|
51
79
|
cannot finish is kept as it was. Use --scene <n> for one clip.
|
|
52
80
|
|
|
53
|
-
|
|
81
|
+
9. Make your cut from those files.
|
|
54
82
|
|
|
55
|
-
|
|
56
|
-
|
|
83
|
+
10. exodus video upload <runId> --file cut.mp4
|
|
84
|
+
Attaches your cut to the run and prints the page to approve it on.
|
|
57
85
|
|
|
58
|
-
|
|
59
|
-
Or click Approve on the page from step
|
|
86
|
+
11. exodus video approve <runId>
|
|
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.
|
|
60
90
|
|
|
61
91
|
Usage:
|
|
62
|
-
exodus video
|
|
63
|
-
exodus video start --show <id> --script <file> [--voice-path <v>] [--no-music] [--wait] [--json]
|
|
64
|
-
exodus video status <runId> [--json]
|
|
92
|
+
exodus video status <runId> [--rejected-draft] [--json]
|
|
65
93
|
exodus video storyboard <runId> [--json]
|
|
66
|
-
exodus video approve <runId> [--json]
|
|
67
|
-
exodus video flag <runId> --note "<what is wrong>" [--json]
|
|
94
|
+
exodus video approve <runId> [--approve-stale-cut] [--json]
|
|
68
95
|
exodus video retry-frame <runId> --node <nodeId> --scene <n> [--note "..."] [--json]
|
|
69
96
|
exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "..."] [--json]
|
|
70
97
|
exodus video revoice <runId> (--scene <n> | --all) [--node <nodeId>] [--json]
|
|
71
98
|
exodus video voices <runId> [--set <character>=<voiceId>] [--clear <character>] [--from <file.json>] [--json]
|
|
72
99
|
exodus video pull <runId> --out <dir> [--json]
|
|
73
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]
|
|
74
102
|
|
|
75
103
|
Options:
|
|
76
|
-
--show <id> Which Show the ad belongs to (from: exodus video shows)
|
|
77
|
-
--script <file> A text file with the words you want the ad to say
|
|
78
|
-
--voice-path <v> Override the Show's voice path for this ad
|
|
79
|
-
--no-music Skip the music bed
|
|
80
|
-
--wait Stay open and report as the run moves; stops at every
|
|
81
|
-
point that needs you
|
|
82
104
|
--out <dir> Folder to write the pulled pieces into (pull)
|
|
83
105
|
--file <cut.mp4> Your finished cut (upload). MP4, MOV or WebM, up to 200MB
|
|
84
106
|
--duration <sec> How long your cut is, in seconds. Only needed when the
|
|
85
107
|
length can't be read off the file itself
|
|
86
|
-
--note "<text>"
|
|
87
|
-
steer one redo (retry-frame, retry-clip)
|
|
108
|
+
--note "<text>" How to steer one redo (retry-frame, retry-clip)
|
|
88
109
|
--node <nodeId> Which scene-frames node holds the still (retry-frame).
|
|
89
110
|
Which video step holds the clip, when a scene has one on
|
|
90
111
|
more than one step (retry-clip, revoice)
|
|
@@ -92,21 +113,40 @@ Options:
|
|
|
92
113
|
(retry-frame, retry-clip, revoice)
|
|
93
114
|
--all Every finished clip that kept the video model's voice
|
|
94
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
|
|
95
119
|
--set <who>=<id> Give a character an ElevenLabs voice (voices). Name the
|
|
96
120
|
character by its ID or by the name your script uses.
|
|
97
121
|
Repeat it once per character
|
|
98
122
|
--clear <who> Take a character's voice off again (voices). Repeatable
|
|
99
123
|
--from <file.json> A file of characters and voice IDs (voices). --set and
|
|
100
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)
|
|
101
140
|
--json Machine-readable output
|
|
102
|
-
--help, -h
|
|
141
|
+
--help, -h Print this help
|
|
103
142
|
|
|
104
143
|
Video is admin-only. If every command here answers "video isn't enabled for
|
|
105
144
|
this key", your dashboard user needs the admin role on this brand.
|
|
106
145
|
|
|
107
146
|
Examples:
|
|
108
|
-
exodus
|
|
109
|
-
exodus
|
|
147
|
+
exodus workflow list
|
|
148
|
+
exodus workflow describe "Video Ad"
|
|
149
|
+
exodus workflow run "Video Ad" --input script=@./script.txt --wait
|
|
110
150
|
exodus video storyboard run_123
|
|
111
151
|
exodus video approve run_123
|
|
112
152
|
exodus video retry-frame run_123 --node frames-1 --scene 2
|
|
@@ -120,8 +160,12 @@ Examples:
|
|
|
120
160
|
`.trim();
|
|
121
161
|
const SHOWS_PATH = "/api/v2/shows";
|
|
122
162
|
const RUNS_PATH = "/api/v2/video/runs";
|
|
163
|
+
const SCRIPT_RUNS_PATH = "/api/v2/video/script-runs";
|
|
123
164
|
const RUN_PATH = "/api/v2/workflow";
|
|
124
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.";
|
|
125
169
|
const STORYBOARD_PATH = "/api/v2/video/storyboard";
|
|
126
170
|
const FLAG_PATH = "/api/v2/video/storyboard/flag";
|
|
127
171
|
const APPROVE_PATH = "/api/v2/workflow/approve";
|
|
@@ -136,6 +180,48 @@ const ASSETS_PATH = "/api/v2/workflows/assets";
|
|
|
136
180
|
function withoutJudgeFields({ judgeDetail: _d, judgeSeverity: _s, ...rest }) {
|
|
137
181
|
return rest;
|
|
138
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
|
+
}
|
|
139
225
|
export function asVideoRun(data) {
|
|
140
226
|
const run = (data ?? {});
|
|
141
227
|
return { ...run, nodes: Array.isArray(run.nodes) ? run.nodes : [] };
|
|
@@ -248,6 +334,7 @@ export function classifyRun(run) {
|
|
|
248
334
|
...(pausedNode.kind === "scene-frames"
|
|
249
335
|
? { framesNodeId: pausedNode.nodeId }
|
|
250
336
|
: {}),
|
|
337
|
+
...(isShowAd(run) ? { showAd: true } : {}),
|
|
251
338
|
};
|
|
252
339
|
}
|
|
253
340
|
if (parkedAtFinalWatch(run))
|
|
@@ -257,6 +344,22 @@ export function classifyRun(run) {
|
|
|
257
344
|
const active = run.nodes.find((n) => n.status === "running");
|
|
258
345
|
return { at: "running", stage: active?.kind ?? "starting" };
|
|
259
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
|
+
}
|
|
260
363
|
const STAGE_WORDS = {
|
|
261
364
|
brief: "reading the script",
|
|
262
365
|
storyboard: "writing the storyboard",
|
|
@@ -282,8 +385,11 @@ export function stepName(kind) {
|
|
|
282
385
|
return "A step in this run";
|
|
283
386
|
return STEP_NAMES[kind] ?? `The ${kind} step`;
|
|
284
387
|
}
|
|
388
|
+
export function isShowAd(run) {
|
|
389
|
+
return run.moduleOwned === true;
|
|
390
|
+
}
|
|
285
391
|
export function reviewUrl(dashboardUrl, run) {
|
|
286
|
-
if (run
|
|
392
|
+
if (isShowAd(run))
|
|
287
393
|
return `${dashboardUrl}/video?ad=${run._id}`;
|
|
288
394
|
if (run.workflowId)
|
|
289
395
|
return `${dashboardUrl}/workflows/${run.workflowId}/runs/${run._id}`;
|
|
@@ -295,14 +401,31 @@ export function stopLines(stop, runId, runUrl) {
|
|
|
295
401
|
"Parked: the storyboard is waiting for your yes.",
|
|
296
402
|
`Read it: exodus video storyboard ${runId}`,
|
|
297
403
|
`Approve it: exodus video approve ${runId}`,
|
|
298
|
-
`Send back: exodus video flag ${runId} --note "what's wrong"`,
|
|
299
404
|
];
|
|
405
|
+
if (stop.showAd) {
|
|
406
|
+
lines.push(`Send back: exodus video flag ${runId} --note "what's wrong"`);
|
|
407
|
+
}
|
|
300
408
|
if (stop.framesNodeId) {
|
|
301
409
|
lines.push(`Redo one frame: exodus video retry-frame ${runId} --node ${stop.framesNodeId} --scene <n>`);
|
|
302
410
|
}
|
|
303
411
|
return lines;
|
|
304
412
|
}
|
|
305
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
|
+
}
|
|
306
429
|
return [
|
|
307
430
|
"Parked: every piece is made and the run is waiting for a cut.",
|
|
308
431
|
`Pull the pieces: exodus video pull ${runId} --out ./ad-${runId}`,
|
|
@@ -313,6 +436,13 @@ export function stopLines(stop, runId, runUrl) {
|
|
|
313
436
|
}
|
|
314
437
|
if (stop.at === "failed") {
|
|
315
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
|
+
}
|
|
316
446
|
return [
|
|
317
447
|
`${stepName(stop.step)} failed${stop.error ? `: ${stop.error}` : "."}`,
|
|
318
448
|
`Try that step again: exodus workflow repair ${runId} retry`,
|
|
@@ -338,7 +468,40 @@ export function stopLines(stop, runId, runUrl) {
|
|
|
338
468
|
}
|
|
339
469
|
return [`Working: ${stageWord(stop.stage)}.`];
|
|
340
470
|
}
|
|
341
|
-
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
|
+
}
|
|
342
505
|
const FALLBACK_EXT = {
|
|
343
506
|
image: "png",
|
|
344
507
|
video: "mp4",
|
|
@@ -355,6 +518,10 @@ function extFor(url, family) {
|
|
|
355
518
|
export function scenePrefix(sceneIndex) {
|
|
356
519
|
return `scene-${String(sceneIndex).padStart(2, "0")}`;
|
|
357
520
|
}
|
|
521
|
+
function clipQcOf(item) {
|
|
522
|
+
const artifact = item?.artifact;
|
|
523
|
+
return artifact && artifact.type === "video" ? artifact.qc?.takes : undefined;
|
|
524
|
+
}
|
|
358
525
|
function outputsOfNodeKind(run, kind) {
|
|
359
526
|
return run.nodes.filter((n) => n.kind === kind).flatMap((n) => n.outputs ?? []);
|
|
360
527
|
}
|
|
@@ -384,7 +551,9 @@ function keyframeDownload(sceneIndex, imageUrl) {
|
|
|
384
551
|
function reservedCastFrames(items) {
|
|
385
552
|
const byIndex = new Map();
|
|
386
553
|
for (const item of items) {
|
|
387
|
-
if (item.itemKind === "frame" &&
|
|
554
|
+
if (item.itemKind === "frame" &&
|
|
555
|
+
item.sceneIndex >= CAST_LEDGER_BASE &&
|
|
556
|
+
item.sceneIndex < SET_OPTION_LEDGER_BASE) {
|
|
388
557
|
byIndex.set(item.sceneIndex, item);
|
|
389
558
|
}
|
|
390
559
|
}
|
|
@@ -537,6 +706,8 @@ export function planPull(run, items, opts) {
|
|
|
537
706
|
for (const frame of artifact.frames ?? []) {
|
|
538
707
|
if (!frame.imageUrl)
|
|
539
708
|
continue;
|
|
709
|
+
if (!isPullSceneIndex(frame.sceneIndex))
|
|
710
|
+
continue;
|
|
540
711
|
keyframeByScene.set(frame.sceneIndex, keyframeDownload(frame.sceneIndex, frame.imageUrl));
|
|
541
712
|
}
|
|
542
713
|
}
|
|
@@ -554,7 +725,7 @@ export function planPull(run, items, opts) {
|
|
|
554
725
|
};
|
|
555
726
|
continue;
|
|
556
727
|
}
|
|
557
|
-
if (typeof artifact.sceneIndex !== "number" || artifact.sceneIndex
|
|
728
|
+
if (typeof artifact.sceneIndex !== "number" || !isPullSceneIndex(artifact.sceneIndex))
|
|
558
729
|
continue;
|
|
559
730
|
voiceByScene.set(artifact.sceneIndex, {
|
|
560
731
|
download: {
|
|
@@ -595,6 +766,8 @@ export function planPull(run, items, opts) {
|
|
|
595
766
|
}
|
|
596
767
|
if (artifact.type !== "video" || typeof artifact.sceneIndex !== "number")
|
|
597
768
|
continue;
|
|
769
|
+
if (!isPullSceneIndex(artifact.sceneIndex))
|
|
770
|
+
continue;
|
|
598
771
|
const clip = clipFromArtifact(artifact.sceneIndex, artifact);
|
|
599
772
|
if (!clip)
|
|
600
773
|
continue;
|
|
@@ -604,6 +777,8 @@ export function planPull(run, items, opts) {
|
|
|
604
777
|
const frameItemByScene = new Map();
|
|
605
778
|
const sceneFrameNodeIds = new Set(run.nodes.filter((n) => n.kind === "scene-frames").map((n) => n.nodeId));
|
|
606
779
|
for (const item of items) {
|
|
780
|
+
if (!isPullSceneIndex(item.sceneIndex))
|
|
781
|
+
continue;
|
|
607
782
|
if (item.itemKind === "clip")
|
|
608
783
|
clipItemByScene.set(item.sceneIndex, item);
|
|
609
784
|
if (item.itemKind === "frame" && sceneFrameNodeIds.has(item.nodeId)) {
|
|
@@ -635,7 +810,7 @@ export function planPull(run, items, opts) {
|
|
|
635
810
|
...frameItemByScene.keys(),
|
|
636
811
|
]),
|
|
637
812
|
]
|
|
638
|
-
.filter((sceneIndex) => sceneIndex
|
|
813
|
+
.filter((sceneIndex) => isPullSceneIndex(sceneIndex))
|
|
639
814
|
.sort((a, b) => a - b);
|
|
640
815
|
const scenes = sceneIndexes.map((sceneIndex) => {
|
|
641
816
|
const clip = clipByScene.get(sceneIndex);
|
|
@@ -670,7 +845,7 @@ export function planPull(run, items, opts) {
|
|
|
670
845
|
: "this-file",
|
|
671
846
|
voice: voice?.download.file ?? null,
|
|
672
847
|
keyframe: keyframeByScene.get(sceneIndex)?.file ?? null,
|
|
673
|
-
qc: clip?.qc
|
|
848
|
+
qc: clip?.qc ? qcWithoutJudgeWording(clip.qc) : null,
|
|
674
849
|
revoiced: clip ? clip.revoiced : null,
|
|
675
850
|
speechTrimmed: clip ? clip.speechTrimmed : null,
|
|
676
851
|
rawStorageId: clip ? clip.rawStorageId : null,
|
|
@@ -701,6 +876,10 @@ export function planPull(run, items, opts) {
|
|
|
701
876
|
storyboard,
|
|
702
877
|
reference: reference?.file ?? null,
|
|
703
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),
|
|
704
883
|
cast,
|
|
705
884
|
narration: narrationDownload
|
|
706
885
|
? { file: narrationDownload.file, timing: "narration.json" }
|
|
@@ -710,6 +889,14 @@ export function planPull(run, items, opts) {
|
|
|
710
889
|
...(run.provenance?.format || run.provenance?.voice
|
|
711
890
|
? { provenance: run.provenance }
|
|
712
891
|
: {}),
|
|
892
|
+
...(run.videoChoice
|
|
893
|
+
? {
|
|
894
|
+
videoChoice: {
|
|
895
|
+
videoModel: run.videoChoice.videoModel,
|
|
896
|
+
voiceMode: run.videoChoice.voiceMode,
|
|
897
|
+
},
|
|
898
|
+
}
|
|
899
|
+
: {}),
|
|
713
900
|
},
|
|
714
901
|
};
|
|
715
902
|
}
|
|
@@ -840,7 +1027,7 @@ export async function startFlow(opts, deps) {
|
|
|
840
1027
|
showId: opts.showId,
|
|
841
1028
|
script,
|
|
842
1029
|
...(opts.voicePath ? { voicePath: opts.voicePath } : {}),
|
|
843
|
-
...(opts.music ===
|
|
1030
|
+
...(opts.music === undefined ? {} : { music: opts.music }),
|
|
844
1031
|
});
|
|
845
1032
|
if (!res.ok)
|
|
846
1033
|
return errorResult(res, opts.json);
|
|
@@ -869,6 +1056,264 @@ export async function startFlow(opts, deps) {
|
|
|
869
1056
|
lines: opts.json ? waited.lines : [`Started ad run ${runId}`, `Watch it: ${url}`, "", ...waited.lines],
|
|
870
1057
|
};
|
|
871
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
|
+
}
|
|
872
1317
|
const POLL_INTERVAL_MS = 5000;
|
|
873
1318
|
const MAX_POLLS = 720;
|
|
874
1319
|
export async function waitFlow(runId, opts, deps) {
|
|
@@ -894,15 +1339,16 @@ export async function waitFlow(runId, opts, deps) {
|
|
|
894
1339
|
}
|
|
895
1340
|
continue;
|
|
896
1341
|
}
|
|
1342
|
+
const resolved = await resolveStopAtPark(stop, runId, deps);
|
|
897
1343
|
if (opts.json) {
|
|
898
1344
|
return {
|
|
899
1345
|
code: stop.at === "failed" ? 1 : 0,
|
|
900
|
-
lines: [JSON.stringify({ runId, stop, status: run.status, url: opts.url })],
|
|
1346
|
+
lines: [JSON.stringify({ runId, stop: resolved, status: run.status, url: opts.url })],
|
|
901
1347
|
};
|
|
902
1348
|
}
|
|
903
1349
|
return {
|
|
904
1350
|
code: stop.at === "failed" ? 1 : 0,
|
|
905
|
-
lines: [...lines, ...stopLines(
|
|
1351
|
+
lines: [...lines, ...stopLines(resolved, runId, reviewUrl(deps.dashboardUrl, run))],
|
|
906
1352
|
};
|
|
907
1353
|
}
|
|
908
1354
|
const timeoutLine = `Still running after ${Math.round((maxPolls * interval) / 60000)} minutes. Check in with: exodus video status ${runId}`;
|
|
@@ -927,6 +1373,11 @@ function itemWord(item) {
|
|
|
927
1373
|
return "flagged";
|
|
928
1374
|
return ITEM_STATUS_WORD[item.status] ?? item.status;
|
|
929
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
|
+
}
|
|
930
1381
|
export async function statusFlow(runId, json, deps) {
|
|
931
1382
|
const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
932
1383
|
if (!runRes.ok)
|
|
@@ -936,15 +1387,35 @@ export async function statusFlow(runId, json, deps) {
|
|
|
936
1387
|
if (!itemsRes.ok)
|
|
937
1388
|
return errorResult(itemsRes, json);
|
|
938
1389
|
const items = itemsRes.data.items ?? [];
|
|
939
|
-
const
|
|
940
|
-
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));
|
|
941
1396
|
const warnings = run.nodes
|
|
942
1397
|
.filter((n) => n.warning)
|
|
943
1398
|
.map((n) => ({ nodeId: n.nodeId, step: n.kind, warning: n.warning }));
|
|
944
1399
|
if (json) {
|
|
945
1400
|
return {
|
|
946
1401
|
code: 0,
|
|
947
|
-
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
|
+
],
|
|
948
1419
|
};
|
|
949
1420
|
}
|
|
950
1421
|
const revoicedByScene = new Set();
|
|
@@ -975,8 +1446,10 @@ export async function statusFlow(runId, json, deps) {
|
|
|
975
1446
|
: `Ad run ${runId} — ${displayRunStatus(run.status)}`;
|
|
976
1447
|
const lines = [
|
|
977
1448
|
headline,
|
|
978
|
-
...
|
|
1449
|
+
...(carryingOn(run) ? [CARRYING_ON_LINE] : []),
|
|
1450
|
+
...guidance,
|
|
979
1451
|
...(stop.at === "running" && run.pauseAhead ? [pauseAheadLine(run.pauseAhead)] : []),
|
|
1452
|
+
...planFailureLines(failedStoryboard, runId),
|
|
980
1453
|
...warnings.map((w) => `Heads-up (${w.step}): ${w.warning}`),
|
|
981
1454
|
];
|
|
982
1455
|
if (byScene.size === 0) {
|
|
@@ -987,6 +1460,21 @@ export async function statusFlow(runId, json, deps) {
|
|
|
987
1460
|
for (const sceneIndex of [...byScene.keys()].sort((a, b) => a - b)) {
|
|
988
1461
|
const row = byScene.get(sceneIndex);
|
|
989
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}`);
|
|
990
1478
|
for (const finding of row.clip?.findings ?? []) {
|
|
991
1479
|
lines.push(` ${finding.code} (${finding.severity}): ${finding.detail}`);
|
|
992
1480
|
const judgeSeverity = finding.judgeSeverity ?? finding.severity;
|
|
@@ -1019,6 +1507,45 @@ export async function statusFlow(runId, json, deps) {
|
|
|
1019
1507
|
: "Final cut: not uploaded yet.");
|
|
1020
1508
|
return { code: 0, lines };
|
|
1021
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
|
+
}
|
|
1022
1549
|
export async function storyboardFlow(runId, json, deps) {
|
|
1023
1550
|
const res = await deps.get(`${STORYBOARD_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1024
1551
|
if (!res.ok)
|
|
@@ -1070,7 +1597,14 @@ export async function storyboardFlow(runId, json, deps) {
|
|
|
1070
1597
|
}
|
|
1071
1598
|
}
|
|
1072
1599
|
}
|
|
1073
|
-
|
|
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
|
+
}
|
|
1074
1608
|
if (cards.framesNodeId) {
|
|
1075
1609
|
lines.push(`redo one frame: exodus video retry-frame ${runId} --node ${cards.framesNodeId} --scene <n>`);
|
|
1076
1610
|
}
|
|
@@ -1098,14 +1632,30 @@ function sceneCardLines(scene) {
|
|
|
1098
1632
|
lines.push(` picture: ${scene.frame.status}`);
|
|
1099
1633
|
return lines;
|
|
1100
1634
|
}
|
|
1101
|
-
export async function approveFlow(runId,
|
|
1102
|
-
const res = await deps.post(APPROVE_PATH, {
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
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;
|
|
1109
1659
|
const summary = voices
|
|
1110
1660
|
? voiceSummaryLines(Array.isArray(voices.cast) ? voices.cast : [], Array.isArray(voices.notices) ? voices.notices : [])
|
|
1111
1661
|
: [];
|
|
@@ -1113,6 +1663,7 @@ export async function approveFlow(runId, json, deps) {
|
|
|
1113
1663
|
code: 0,
|
|
1114
1664
|
lines: [
|
|
1115
1665
|
"Approved.",
|
|
1666
|
+
...warnings,
|
|
1116
1667
|
...(summary.length > 0 ? ["", ...summary, ""] : []),
|
|
1117
1668
|
`See what happens next: exodus video status ${runId}`,
|
|
1118
1669
|
],
|
|
@@ -1170,8 +1721,9 @@ export async function retryFrameFlow(runId, nodeId, sceneIndex, note, json, deps
|
|
|
1170
1721
|
],
|
|
1171
1722
|
};
|
|
1172
1723
|
}
|
|
1173
|
-
const UPLOADED_CUT_WARNING = "The cut you already uploaded will not include the new clip.
|
|
1174
|
-
"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.";
|
|
1175
1727
|
export function planClipRedo(run, items, target) {
|
|
1176
1728
|
const stop = classifyRun(run);
|
|
1177
1729
|
const found = findClipRow(run, items, target);
|
|
@@ -1204,7 +1756,7 @@ export function planClipRedo(run, items, target) {
|
|
|
1204
1756
|
};
|
|
1205
1757
|
}
|
|
1206
1758
|
function findClipRow(run, items, target) {
|
|
1207
|
-
const uploadedCut = items
|
|
1759
|
+
const uploadedCut = hasAttachedCut(items);
|
|
1208
1760
|
const rows = items.filter((i) => i.itemKind === "clip" &&
|
|
1209
1761
|
i.sceneIndex === target.sceneIndex &&
|
|
1210
1762
|
(target.nodeId === undefined || i.nodeId === target.nodeId));
|
|
@@ -1603,6 +2155,13 @@ export function voiceSheetLines(sheet) {
|
|
|
1603
2155
|
: (sheet.whyNot ?? "Voices can no longer be changed on this run."));
|
|
1604
2156
|
return lines;
|
|
1605
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
|
+
}
|
|
1606
2165
|
export async function pullFlow(runId, dir, json, deps) {
|
|
1607
2166
|
const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1608
2167
|
if (!runRes.ok)
|
|
@@ -1646,6 +2205,7 @@ export async function pullFlow(runId, dir, json, deps) {
|
|
|
1646
2205
|
const lines = [
|
|
1647
2206
|
`Pulled ${wrote} files into ${dir}`,
|
|
1648
2207
|
`Every piece is indexed in ${path.join(dir, "manifest.json")} — scene numbers there are the run's own.`,
|
|
2208
|
+
...musicLines(plan.manifest),
|
|
1649
2209
|
];
|
|
1650
2210
|
const flagged = plan.manifest.scenes.filter((s) => s.flagged);
|
|
1651
2211
|
if (flagged.length > 0) {
|
|
@@ -1902,7 +2462,12 @@ export async function uploadFlow(runId, filePath, durationFlag, json, deps) {
|
|
|
1902
2462
|
const VALUE_FLAGS = new Set([
|
|
1903
2463
|
"show",
|
|
1904
2464
|
"script",
|
|
2465
|
+
"conceit",
|
|
2466
|
+
"style",
|
|
2467
|
+
"direction",
|
|
1905
2468
|
"voice-path",
|
|
2469
|
+
"video-model",
|
|
2470
|
+
"voice",
|
|
1906
2471
|
"note",
|
|
1907
2472
|
"out",
|
|
1908
2473
|
"file",
|
|
@@ -1955,20 +2520,12 @@ export async function run(flags, occurrences) {
|
|
|
1955
2520
|
if (sub === "shows")
|
|
1956
2521
|
return printResult(await showsFlow(json, defaultDeps));
|
|
1957
2522
|
if (sub === "start") {
|
|
1958
|
-
const
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
return printResult(await startFlow({
|
|
1965
|
-
showId,
|
|
1966
|
-
scriptFile,
|
|
1967
|
-
voicePath: flagString(flags, "voice-path"),
|
|
1968
|
-
music: flags["music"] === false ? false : undefined,
|
|
1969
|
-
wait: flags["wait"] === true,
|
|
1970
|
-
json,
|
|
1971
|
-
}, 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));
|
|
1972
2529
|
}
|
|
1973
2530
|
const runId = rest[0];
|
|
1974
2531
|
const needsRunId = [
|
|
@@ -1986,12 +2543,17 @@ export async function run(flags, occurrences) {
|
|
|
1986
2543
|
if (needsRunId.includes(sub) && !runId) {
|
|
1987
2544
|
usage(`video ${sub} needs a run id: exodus video ${sub} <runId>`);
|
|
1988
2545
|
}
|
|
1989
|
-
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
|
+
}
|
|
1990
2550
|
return printResult(await statusFlow(runId, json, defaultDeps));
|
|
2551
|
+
}
|
|
1991
2552
|
if (sub === "storyboard")
|
|
1992
2553
|
return printResult(await storyboardFlow(runId, json, defaultDeps));
|
|
1993
|
-
if (sub === "approve")
|
|
1994
|
-
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
|
+
}
|
|
1995
2557
|
if (sub === "flag") {
|
|
1996
2558
|
const note = flagString(flags, "note");
|
|
1997
2559
|
if (!note)
|