@aicopycoders/exodus 2026.9.2000-beta.2 → 2026.9.2000-beta.21
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 +201 -39
- package/assets/skills/exodus-video/scripts/first-cut.mjs +538 -38
- package/assets/skills/exodus-workflow/SKILL.md +1 -1
- package/dist/commands/ads.js +1 -1
- package/dist/commands/image.js +1 -1
- package/dist/commands/template.js +11 -4
- package/dist/commands/video.d.ts +137 -7
- package/dist/commands/video.js +778 -64
- package/dist/commands/workflow.d.ts +53 -4
- package/dist/commands/workflow.js +76 -10
- package/dist/lib/helperLedgerBase.d.ts +3 -0
- package/dist/lib/helperLedgerBase.js +3 -0
- package/dist/lib/meta-ads.d.ts +1 -0
- package/package.json +1 -1
package/dist/commands/video.js
CHANGED
|
@@ -6,11 +6,30 @@ 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
|
+
}
|
|
24
|
+
function cantSayNames(names) {
|
|
25
|
+
return `the recorded voice can't say ${joinedOr(names.map((n) => `"${n}"`))}`;
|
|
26
|
+
}
|
|
9
27
|
export const helpText = `
|
|
10
28
|
exodus video — make a video ad from a saved workflow, pull every piece, upload your cut
|
|
11
29
|
|
|
12
30
|
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,
|
|
31
|
+
one voice track per scene, one video clip per scene, and a music bed if you
|
|
32
|
+
asked for one with --music. It does
|
|
14
33
|
not make the finished ad. You pull the pieces to a folder, cut them together
|
|
15
34
|
with whatever editing tools you like, and upload the cut back.
|
|
16
35
|
|
|
@@ -41,14 +60,22 @@ The whole loop, in order:
|
|
|
41
60
|
Redo ONE still at the pixel gate. Neighbours stay. The gate holds.
|
|
42
61
|
|
|
43
62
|
7. exodus video status <runId>
|
|
44
|
-
Where the run is and how each scene's clip turned out.
|
|
63
|
+
Where the run is and how each scene's clip turned out. If the storyboard
|
|
64
|
+
failed, it also says which parts of it were accepted and why the next one
|
|
65
|
+
was turned down.
|
|
66
|
+
|
|
67
|
+
exodus video status <runId> --rejected-draft
|
|
68
|
+
Prints the storyboard draft that was turned down. A record of what
|
|
69
|
+
happened, thrown away by the system — never something to act on.
|
|
45
70
|
|
|
46
71
|
8. exodus video pull <runId> --out ./ad
|
|
47
72
|
Writes every piece to that folder plus a manifest.json index.
|
|
48
73
|
|
|
49
74
|
exodus video retry-clip <runId> --scene <n>
|
|
50
75
|
Redo ONE finished clip while the run waits for the cut. Everything
|
|
51
|
-
else stays. A --note steers the motion, never the words.
|
|
76
|
+
else stays. A --note steers the motion, never the words. Add
|
|
77
|
+
--voice-first when the words came out wrong: the line is recorded in
|
|
78
|
+
the character's own voice first, then the clip is made to match it.
|
|
52
79
|
|
|
53
80
|
exodus video revoice <runId> --all
|
|
54
81
|
Redo only the VOICE on clips that kept the video model's own voice
|
|
@@ -62,18 +89,21 @@ The whole loop, in order:
|
|
|
62
89
|
Attaches your cut to the run and prints the page to approve it on.
|
|
63
90
|
|
|
64
91
|
11. exodus video approve <runId>
|
|
65
|
-
Or click Approve on the page from step 10.
|
|
92
|
+
Or click Approve on the page from step 10. If you redid a clip after
|
|
93
|
+
uploading, this stops and says which scenes changed. Upload a fresh cut,
|
|
94
|
+
or add --approve-stale-cut to deliver the cut you already uploaded.
|
|
66
95
|
|
|
67
96
|
Usage:
|
|
68
|
-
exodus video status <runId> [--json]
|
|
97
|
+
exodus video status <runId> [--rejected-draft] [--json]
|
|
69
98
|
exodus video storyboard <runId> [--json]
|
|
70
|
-
exodus video approve <runId> [--json]
|
|
99
|
+
exodus video approve <runId> [--approve-stale-cut] [--json]
|
|
71
100
|
exodus video retry-frame <runId> --node <nodeId> --scene <n> [--note "..."] [--json]
|
|
72
|
-
exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "..."] [--json]
|
|
101
|
+
exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "..."] [--voice-first] [--json]
|
|
73
102
|
exodus video revoice <runId> (--scene <n> | --all) [--node <nodeId>] [--json]
|
|
74
103
|
exodus video voices <runId> [--set <character>=<voiceId>] [--clear <character>] [--from <file.json>] [--json]
|
|
75
104
|
exodus video pull <runId> --out <dir> [--json]
|
|
76
105
|
exodus video upload <runId> --file <cut.mp4> [--duration <sec>] [--json]
|
|
106
|
+
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] [--review-storyboard] [--wait] [--json]
|
|
77
107
|
|
|
78
108
|
Options:
|
|
79
109
|
--out <dir> Folder to write the pulled pieces into (pull)
|
|
@@ -88,12 +118,37 @@ Options:
|
|
|
88
118
|
(retry-frame, retry-clip, revoice)
|
|
89
119
|
--all Every finished clip that kept the video model's voice
|
|
90
120
|
(revoice)
|
|
121
|
+
--voice-first Record the scene's line in the character's own voice,
|
|
122
|
+
then make the clip to match it (retry-clip). For a clip
|
|
123
|
+
whose words came out wrong
|
|
124
|
+
--approve-stale-cut Deliver the cut you uploaded even though a clip changed
|
|
125
|
+
after you uploaded it (approve). Without it, approving
|
|
126
|
+
stops and names the scenes that changed
|
|
91
127
|
--set <who>=<id> Give a character an ElevenLabs voice (voices). Name the
|
|
92
128
|
character by its ID or by the name your script uses.
|
|
93
129
|
Repeat it once per character
|
|
94
130
|
--clear <who> Take a character's voice off again (voices). Repeatable
|
|
95
131
|
--from <file.json> A file of characters and voice IDs (voices). --set and
|
|
96
132
|
--clear win over the same name in the file
|
|
133
|
+
--rejected-draft Print the storyboard draft the system turned down
|
|
134
|
+
(status). It was thrown away — a record only
|
|
135
|
+
--script <file> Text file of what the ad says (start)
|
|
136
|
+
--conceit <key> Kind of ad: ${joinedOr(CONCEIT_KEYS)} (start)
|
|
137
|
+
--style <slug> Look: ${joinedOr(STYLE_SLUGS)} (start)
|
|
138
|
+
--direction "<note>" Note for the whole ad (start)
|
|
139
|
+
--voice-path <path> How the voices are made (start)
|
|
140
|
+
--video-model <id> Which video model films this ad, instead of the kind of
|
|
141
|
+
ad's own default (start, with --conceit)
|
|
142
|
+
--voice <mode> native: the video model speaks the lines. voice-first:
|
|
143
|
+
the lines are recorded first (start, with --conceit)
|
|
144
|
+
--music Put a music bed under the ad (start). An ad has no music
|
|
145
|
+
unless you ask for it here
|
|
146
|
+
--no-music Leave the music bed off (start), which it already is
|
|
147
|
+
--review-storyboard Stop at the storyboard so you can approve it yourself
|
|
148
|
+
(start, with --conceit). By default the app approves its
|
|
149
|
+
own storyboard when its checks pass
|
|
150
|
+
--wait Wait until the run needs you (start): at the
|
|
151
|
+
storyboard, or once every piece is made
|
|
97
152
|
--json Machine-readable output
|
|
98
153
|
--help, -h Print this help
|
|
99
154
|
|
|
@@ -108,6 +163,7 @@ Examples:
|
|
|
108
163
|
exodus video approve run_123
|
|
109
164
|
exodus video retry-frame run_123 --node frames-1 --scene 2
|
|
110
165
|
exodus video retry-clip run_123 --scene 3 --note "keep the handshake in frame"
|
|
166
|
+
exodus video retry-clip run_123 --scene 3 --voice-first
|
|
111
167
|
exodus video revoice run_123 --all
|
|
112
168
|
exodus video voices run_123
|
|
113
169
|
exodus video voices run_123 --set C1=abc123voiceid --set "HOST 2=def456voiceid"
|
|
@@ -117,13 +173,20 @@ Examples:
|
|
|
117
173
|
`.trim();
|
|
118
174
|
const SHOWS_PATH = "/api/v2/shows";
|
|
119
175
|
const RUNS_PATH = "/api/v2/video/runs";
|
|
176
|
+
const SCRIPT_RUNS_PATH = "/api/v2/video/script-runs";
|
|
120
177
|
const RUN_PATH = "/api/v2/workflow";
|
|
121
178
|
const ITEMS_PATH = "/api/v2/workflow/items";
|
|
179
|
+
const REJECTED_DRAFT_PATH = "/api/v2/workflow/rejected-draft";
|
|
180
|
+
const REJECTED_DRAFT_NOT_ON_THIS_SERVER = "This Exodus server does not keep rejected drafts yet, so there is nothing to read. It arrives " +
|
|
181
|
+
"with the next server update.";
|
|
122
182
|
const STORYBOARD_PATH = "/api/v2/video/storyboard";
|
|
123
183
|
const FLAG_PATH = "/api/v2/video/storyboard/flag";
|
|
124
184
|
const APPROVE_PATH = "/api/v2/workflow/approve";
|
|
125
185
|
const SCENE_RETRY_PATH = "/api/v2/workflow/scene/retry";
|
|
126
186
|
const SCENE_REVOICE_PATH = "/api/v2/workflow/scene/revoice";
|
|
187
|
+
const SCENE_VOICE_FIRST_PATH = "/api/v2/workflow/scene/voice-first";
|
|
188
|
+
const VOICE_FIRST_NOT_ON_THIS_SERVER = "This Exodus server cannot redo a clip voice first yet, so nothing was started and nothing " +
|
|
189
|
+
"was spent. It arrives with the next server update.";
|
|
127
190
|
const REVOICE_NOT_ON_THIS_SERVER = "This Exodus server does not have the voice redo yet, so nothing was started and nothing " +
|
|
128
191
|
"was spent. It arrives with the next server update.";
|
|
129
192
|
const FINAL_PATH = "/api/v2/video/final";
|
|
@@ -133,6 +196,51 @@ const ASSETS_PATH = "/api/v2/workflows/assets";
|
|
|
133
196
|
function withoutJudgeFields({ judgeDetail: _d, judgeSeverity: _s, ...rest }) {
|
|
134
197
|
return rest;
|
|
135
198
|
}
|
|
199
|
+
export function qcWithoutJudgeWording(qc) {
|
|
200
|
+
if (!qc.takes)
|
|
201
|
+
return qc;
|
|
202
|
+
return {
|
|
203
|
+
...qc,
|
|
204
|
+
takes: qc.takes.map(({ judgeWording: _w, heardText: _h, ...rest }) => rest),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
export const NO_TAKE_HISTORY_LINE = "no history recorded for this clip";
|
|
208
|
+
const TAKE_NEIGHBOUR_SENTENCE = {
|
|
209
|
+
"not-applicable": () => "never compared",
|
|
210
|
+
"not-attached": () => "no neighbours attached",
|
|
211
|
+
"none-accepted": () => "no accepted neighbours yet",
|
|
212
|
+
attached: ({ scenes }) => `compared against scene${scenes.length === 1 ? "" : "s"} ${scenes.join(", ")}`,
|
|
213
|
+
};
|
|
214
|
+
export function renderQcTakeHistory(takes) {
|
|
215
|
+
if (!takes || takes.length === 0)
|
|
216
|
+
return [NO_TAKE_HISTORY_LINE];
|
|
217
|
+
const lines = [];
|
|
218
|
+
takes.forEach((take, index) => {
|
|
219
|
+
const say = TAKE_NEIGHBOUR_SENTENCE[take.neighbours.state];
|
|
220
|
+
const parts = [
|
|
221
|
+
take.failCodes.length ? `failed ${take.failCodes.join(", ")}` : "passed",
|
|
222
|
+
...(take.warnCodes.length ? [`warned ${take.warnCodes.join(", ")}`] : []),
|
|
223
|
+
say(take.neighbours),
|
|
224
|
+
];
|
|
225
|
+
const bonus = take.kind === "soft-retry" ? " (bonus take)" : "";
|
|
226
|
+
lines.push(`take ${index + 1}${bonus}: ${parts.join("; ")}`);
|
|
227
|
+
for (const { code, wording } of take.judgeWording ?? []) {
|
|
228
|
+
lines.push(` checker said (${code}): ${wording}`);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
return lines;
|
|
232
|
+
}
|
|
233
|
+
function redoLine(lastRedo) {
|
|
234
|
+
return lastRedo.reason ? `${lastRedo.label}: ${lastRedo.reason}` : lastRedo.label;
|
|
235
|
+
}
|
|
236
|
+
function printDisplacedHistory(lines, lastRedo) {
|
|
237
|
+
const history = lastRedo.displacedHistory;
|
|
238
|
+
if (!history)
|
|
239
|
+
return;
|
|
240
|
+
lines.push(` ${history.title}`);
|
|
241
|
+
for (const line of history.lines)
|
|
242
|
+
lines.push(` ${line}`);
|
|
243
|
+
}
|
|
136
244
|
export function asVideoRun(data) {
|
|
137
245
|
const run = (data ?? {});
|
|
138
246
|
return { ...run, nodes: Array.isArray(run.nodes) ? run.nodes : [] };
|
|
@@ -220,6 +328,14 @@ function parkedAtFinalWatch(run) {
|
|
|
220
328
|
return (!parkedByBuilderCheckpoint(run) &&
|
|
221
329
|
run.nodes.some((n) => n.kind === "video" && n.status === "done"));
|
|
222
330
|
}
|
|
331
|
+
function heldReason(run) {
|
|
332
|
+
const verdict = run.storyboardAutoApproval;
|
|
333
|
+
if (verdict?.outcome !== "held")
|
|
334
|
+
return undefined;
|
|
335
|
+
const reason = verdict.reason?.trim() ||
|
|
336
|
+
"The app didn't approve this storyboard by itself because its checks found a problem.";
|
|
337
|
+
return `${reason} (checked ${new Date(verdict.at).toISOString()})`;
|
|
338
|
+
}
|
|
223
339
|
export function classifyRun(run) {
|
|
224
340
|
if (run.status === "failed")
|
|
225
341
|
return { at: "failed", error: run.error };
|
|
@@ -239,16 +355,33 @@ export function classifyRun(run) {
|
|
|
239
355
|
}
|
|
240
356
|
const pausedNode = run.nodes.find((n) => n.nodeId === run.pausedNodeId);
|
|
241
357
|
if (pausedNode && parkedAtStoryboardGate(run, pausedNode)) {
|
|
358
|
+
const held = heldReason(run);
|
|
242
359
|
return {
|
|
243
360
|
at: "storyboard-gate",
|
|
244
361
|
nodeId: pausedNode.nodeId,
|
|
245
362
|
...(pausedNode.kind === "scene-frames"
|
|
246
363
|
? { framesNodeId: pausedNode.nodeId }
|
|
247
364
|
: {}),
|
|
365
|
+
...(isShowAd(run) ? { showAd: true } : {}),
|
|
366
|
+
...(held ? { heldReason: held } : {}),
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
if (parkedAtFinalWatch(run)) {
|
|
370
|
+
const autoRedoScenes = run.nodes
|
|
371
|
+
.filter((n) => n.kind === "video")
|
|
372
|
+
.flatMap((n) => n.autoRedoScenes ?? []);
|
|
373
|
+
if (autoRedoScenes.length > 0)
|
|
374
|
+
return { at: "running", stage: "video", autoRedoScenes };
|
|
375
|
+
const missingScenes = run.nodes
|
|
376
|
+
.filter((n) => n.kind === "video")
|
|
377
|
+
.flatMap((n) => n.missingScenes ?? []);
|
|
378
|
+
const framesNodeId = run.nodes.find((n) => n.kind === "scene-frames")?.nodeId;
|
|
379
|
+
return {
|
|
380
|
+
at: "final-watch",
|
|
381
|
+
...(missingScenes.length > 0 ? { missingScenes } : {}),
|
|
382
|
+
...(missingScenes.length > 0 && framesNodeId ? { framesNodeId } : {}),
|
|
248
383
|
};
|
|
249
384
|
}
|
|
250
|
-
if (parkedAtFinalWatch(run))
|
|
251
|
-
return { at: "final-watch" };
|
|
252
385
|
return { at: "paused", nodeId: run.pausedNodeId, reason: run.pauseReason };
|
|
253
386
|
}
|
|
254
387
|
const active = run.nodes.find((n) => n.status === "running");
|
|
@@ -257,10 +390,22 @@ export function classifyRun(run) {
|
|
|
257
390
|
export function hasAttachedCut(items) {
|
|
258
391
|
return items.some((i) => i.itemKind === "final" && i.status === "done");
|
|
259
392
|
}
|
|
393
|
+
export function scenesBeingRedone(run, items) {
|
|
394
|
+
const scenes = new Set(run.nodes.filter((n) => n.kind === "video").flatMap((n) => n.autoRedoScenes ?? []));
|
|
395
|
+
for (const i of items) {
|
|
396
|
+
if (i.itemKind === "clip" &&
|
|
397
|
+
i.status === "running" &&
|
|
398
|
+
(i.attempt ?? 1) > 1 &&
|
|
399
|
+
i.staleClaim !== true) {
|
|
400
|
+
scenes.add(i.sceneIndex);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return [...scenes].sort((a, b) => a - b);
|
|
404
|
+
}
|
|
260
405
|
export function resolveStop(stop, cutAttached) {
|
|
261
406
|
if (stop.at !== "final-watch")
|
|
262
407
|
return stop;
|
|
263
|
-
return {
|
|
408
|
+
return { ...stop, cutAttached };
|
|
264
409
|
}
|
|
265
410
|
export async function resolveStopAtPark(stop, runId, deps) {
|
|
266
411
|
if (stop.at !== "final-watch")
|
|
@@ -295,27 +440,58 @@ export function stepName(kind) {
|
|
|
295
440
|
return "A step in this run";
|
|
296
441
|
return STEP_NAMES[kind] ?? `The ${kind} step`;
|
|
297
442
|
}
|
|
443
|
+
export function isShowAd(run) {
|
|
444
|
+
return run.moduleOwned === true;
|
|
445
|
+
}
|
|
298
446
|
export function reviewUrl(dashboardUrl, run) {
|
|
299
|
-
if (run.moduleOwned === true)
|
|
300
|
-
return `${dashboardUrl}/video?ad=${run._id}`;
|
|
301
447
|
if (run.workflowId)
|
|
302
448
|
return `${dashboardUrl}/workflows/${run.workflowId}/runs/${run._id}`;
|
|
303
449
|
return `${dashboardUrl}/runs/${run._id}`;
|
|
304
450
|
}
|
|
451
|
+
function scenesPhrase(scenes) {
|
|
452
|
+
if (scenes.length === 1)
|
|
453
|
+
return `scene ${scenes[0]}`;
|
|
454
|
+
return `scenes ${scenes.slice(0, -1).join(", ")} and ${scenes[scenes.length - 1]}`;
|
|
455
|
+
}
|
|
456
|
+
function missingSceneLines(missing, framesNodeId, runId, runUrl) {
|
|
457
|
+
const scenes = missing.map((m) => m.sceneIndex);
|
|
458
|
+
const lines = [
|
|
459
|
+
`Parked: ${scenesPhrase(scenes)} ${scenes.length === 1 ? "has" : "have"} no clip yet, so the ad isn't complete.`,
|
|
460
|
+
];
|
|
461
|
+
for (const { sceneIndex, pictureRefused } of missing) {
|
|
462
|
+
if (!pictureRefused) {
|
|
463
|
+
lines.push(`Redo clip ${sceneIndex}: exodus video retry-clip ${runId} --scene ${sceneIndex}`);
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
lines.push(`Scene ${sceneIndex}'s picture was refused by the video service as too close to an existing character, so redraw it before redoing the clip.`);
|
|
467
|
+
lines.push(framesNodeId
|
|
468
|
+
? `Redraw frame ${sceneIndex}: exodus video retry-frame ${runId} --node ${framesNodeId} --scene ${sceneIndex} --note "<what to change>"`
|
|
469
|
+
: `Redraw frame ${sceneIndex}: on the run page, ${runUrl}`);
|
|
470
|
+
lines.push(`Then redo clip ${sceneIndex}: exodus video retry-clip ${runId} --scene ${sceneIndex}`);
|
|
471
|
+
}
|
|
472
|
+
lines.push("The ad can't be approved until every scene has a clip.", `Watch it here: ${runUrl}`);
|
|
473
|
+
return lines;
|
|
474
|
+
}
|
|
305
475
|
export function stopLines(stop, runId, runUrl) {
|
|
306
476
|
if (stop.at === "storyboard-gate") {
|
|
307
477
|
const lines = [
|
|
478
|
+
...(stop.heldReason ? [stop.heldReason] : []),
|
|
308
479
|
"Parked: the storyboard is waiting for your yes.",
|
|
309
480
|
`Read it: exodus video storyboard ${runId}`,
|
|
310
481
|
`Approve it: exodus video approve ${runId}`,
|
|
311
|
-
`Send back: exodus video flag ${runId} --note "what's wrong"`,
|
|
312
482
|
];
|
|
483
|
+
if (stop.showAd) {
|
|
484
|
+
lines.push(`Send back: exodus video flag ${runId} --note "what's wrong"`);
|
|
485
|
+
}
|
|
313
486
|
if (stop.framesNodeId) {
|
|
314
487
|
lines.push(`Redo one frame: exodus video retry-frame ${runId} --node ${stop.framesNodeId} --scene <n>`);
|
|
315
488
|
}
|
|
316
489
|
return lines;
|
|
317
490
|
}
|
|
318
491
|
if (stop.at === "final-watch") {
|
|
492
|
+
if (stop.missingScenes?.length) {
|
|
493
|
+
return missingSceneLines(stop.missingScenes, stop.framesNodeId, runId, runUrl);
|
|
494
|
+
}
|
|
319
495
|
if (stop.cutAttached === null) {
|
|
320
496
|
return [
|
|
321
497
|
"Parked: every piece is made.",
|
|
@@ -341,6 +517,13 @@ export function stopLines(stop, runId, runUrl) {
|
|
|
341
517
|
}
|
|
342
518
|
if (stop.at === "failed") {
|
|
343
519
|
if (stop.repair) {
|
|
520
|
+
if (!stop.step) {
|
|
521
|
+
return [
|
|
522
|
+
"This run stopped at a step that can't be picked back up from here.",
|
|
523
|
+
"Start a fresh run to get this ad made.",
|
|
524
|
+
`Open the run: ${runUrl}`,
|
|
525
|
+
];
|
|
526
|
+
}
|
|
344
527
|
return [
|
|
345
528
|
`${stepName(stop.step)} failed${stop.error ? `: ${stop.error}` : "."}`,
|
|
346
529
|
`Try that step again: exodus workflow repair ${runId} retry`,
|
|
@@ -364,9 +547,49 @@ export function stopLines(stop, runId, runUrl) {
|
|
|
364
547
|
`Open it: ${runUrl}`,
|
|
365
548
|
];
|
|
366
549
|
}
|
|
550
|
+
if (stop.autoRedoScenes?.length)
|
|
551
|
+
return [autoRedoLine(stop.autoRedoScenes)];
|
|
367
552
|
return [`Working: ${stageWord(stop.stage)}.`];
|
|
368
553
|
}
|
|
369
|
-
|
|
554
|
+
function autoRedoLine(scenes) {
|
|
555
|
+
const one = scenes.length === 1;
|
|
556
|
+
return (`Working: the app is redoing ${scenesPhrase(scenes)} on ${one ? "its" : "their"} own because ` +
|
|
557
|
+
`${one ? "it" : "they"} repeated words. The ad isn't ready to watch until ${one ? "it lands" : "they land"}.`);
|
|
558
|
+
}
|
|
559
|
+
export function failedStoryboardNode(run) {
|
|
560
|
+
return run.nodes.find((n) => n.kind === "storyboard" && n.status === "failed");
|
|
561
|
+
}
|
|
562
|
+
export function errorEchoesReasons(error, record) {
|
|
563
|
+
if (!error || record?.kind !== "rejected")
|
|
564
|
+
return false;
|
|
565
|
+
const reasons = record.reasons ?? [];
|
|
566
|
+
return reasons.length > 0 && reasons.every((reason) => error.includes(reason));
|
|
567
|
+
}
|
|
568
|
+
export function planFailureLines(node, runId) {
|
|
569
|
+
const record = node?.planFailure;
|
|
570
|
+
if (!record || !Array.isArray(record.lines) || !node)
|
|
571
|
+
return [];
|
|
572
|
+
const reasons = record.kind === "rejected" ? (record.reasons ?? []) : [];
|
|
573
|
+
const bodyCount = Math.max(0, record.lines.length - reasons.length);
|
|
574
|
+
const lines = [`What happened to the storyboard step (${node.nodeId}):`];
|
|
575
|
+
record.lines.forEach((line, i) => {
|
|
576
|
+
lines.push(i < bodyCount ? ` ${line}` : ` - ${line}`);
|
|
577
|
+
});
|
|
578
|
+
if (node?.hasRejectedDraft === true) {
|
|
579
|
+
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`);
|
|
580
|
+
}
|
|
581
|
+
return lines;
|
|
582
|
+
}
|
|
583
|
+
export const MUSIC_HEARD_CODE = "music-heard";
|
|
584
|
+
export const CAST_LEDGER_BASE = HELPER_LEDGER_BASE + 10000;
|
|
585
|
+
export function musicBedState(run) {
|
|
586
|
+
if (run.musicBed === undefined)
|
|
587
|
+
return "unknown";
|
|
588
|
+
return run.musicBed ? "on" : "off";
|
|
589
|
+
}
|
|
590
|
+
function isPullSceneIndex(sceneIndex) {
|
|
591
|
+
return sceneIndex >= 0 && sceneIndex < HELPER_LEDGER_BASE;
|
|
592
|
+
}
|
|
370
593
|
const FALLBACK_EXT = {
|
|
371
594
|
image: "png",
|
|
372
595
|
video: "mp4",
|
|
@@ -383,6 +606,10 @@ function extFor(url, family) {
|
|
|
383
606
|
export function scenePrefix(sceneIndex) {
|
|
384
607
|
return `scene-${String(sceneIndex).padStart(2, "0")}`;
|
|
385
608
|
}
|
|
609
|
+
function clipQcOf(item) {
|
|
610
|
+
const artifact = item?.artifact;
|
|
611
|
+
return artifact && artifact.type === "video" ? artifact.qc?.takes : undefined;
|
|
612
|
+
}
|
|
386
613
|
function outputsOfNodeKind(run, kind) {
|
|
387
614
|
return run.nodes.filter((n) => n.kind === kind).flatMap((n) => n.outputs ?? []);
|
|
388
615
|
}
|
|
@@ -401,6 +628,8 @@ function clipFromArtifact(sceneIndex, artifact) {
|
|
|
401
628
|
revoiced: artifact.revoiced === true,
|
|
402
629
|
speechTrimmed: artifact.speechTrimmed === true,
|
|
403
630
|
rawStorageId: artifact.rawStorageId ?? null,
|
|
631
|
+
voiceMode: artifact.voiceMode ?? null,
|
|
632
|
+
voiceFirstBlockedBy: artifact.voiceFirstBlockedBy ?? null,
|
|
404
633
|
};
|
|
405
634
|
}
|
|
406
635
|
function keyframeDownload(sceneIndex, imageUrl) {
|
|
@@ -412,7 +641,9 @@ function keyframeDownload(sceneIndex, imageUrl) {
|
|
|
412
641
|
function reservedCastFrames(items) {
|
|
413
642
|
const byIndex = new Map();
|
|
414
643
|
for (const item of items) {
|
|
415
|
-
if (item.itemKind === "frame" &&
|
|
644
|
+
if (item.itemKind === "frame" &&
|
|
645
|
+
item.sceneIndex >= CAST_LEDGER_BASE &&
|
|
646
|
+
item.sceneIndex < SET_OPTION_LEDGER_BASE) {
|
|
416
647
|
byIndex.set(item.sceneIndex, item);
|
|
417
648
|
}
|
|
418
649
|
}
|
|
@@ -565,6 +796,8 @@ export function planPull(run, items, opts) {
|
|
|
565
796
|
for (const frame of artifact.frames ?? []) {
|
|
566
797
|
if (!frame.imageUrl)
|
|
567
798
|
continue;
|
|
799
|
+
if (!isPullSceneIndex(frame.sceneIndex))
|
|
800
|
+
continue;
|
|
568
801
|
keyframeByScene.set(frame.sceneIndex, keyframeDownload(frame.sceneIndex, frame.imageUrl));
|
|
569
802
|
}
|
|
570
803
|
}
|
|
@@ -582,7 +815,7 @@ export function planPull(run, items, opts) {
|
|
|
582
815
|
};
|
|
583
816
|
continue;
|
|
584
817
|
}
|
|
585
|
-
if (typeof artifact.sceneIndex !== "number" || artifact.sceneIndex
|
|
818
|
+
if (typeof artifact.sceneIndex !== "number" || !isPullSceneIndex(artifact.sceneIndex))
|
|
586
819
|
continue;
|
|
587
820
|
voiceByScene.set(artifact.sceneIndex, {
|
|
588
821
|
download: {
|
|
@@ -623,6 +856,8 @@ export function planPull(run, items, opts) {
|
|
|
623
856
|
}
|
|
624
857
|
if (artifact.type !== "video" || typeof artifact.sceneIndex !== "number")
|
|
625
858
|
continue;
|
|
859
|
+
if (!isPullSceneIndex(artifact.sceneIndex))
|
|
860
|
+
continue;
|
|
626
861
|
const clip = clipFromArtifact(artifact.sceneIndex, artifact);
|
|
627
862
|
if (!clip)
|
|
628
863
|
continue;
|
|
@@ -632,6 +867,8 @@ export function planPull(run, items, opts) {
|
|
|
632
867
|
const frameItemByScene = new Map();
|
|
633
868
|
const sceneFrameNodeIds = new Set(run.nodes.filter((n) => n.kind === "scene-frames").map((n) => n.nodeId));
|
|
634
869
|
for (const item of items) {
|
|
870
|
+
if (!isPullSceneIndex(item.sceneIndex))
|
|
871
|
+
continue;
|
|
635
872
|
if (item.itemKind === "clip")
|
|
636
873
|
clipItemByScene.set(item.sceneIndex, item);
|
|
637
874
|
if (item.itemKind === "frame" && sceneFrameNodeIds.has(item.nodeId)) {
|
|
@@ -654,6 +891,9 @@ export function planPull(run, items, opts) {
|
|
|
654
891
|
continue;
|
|
655
892
|
keyframeByScene.set(sceneIndex, keyframeDownload(sceneIndex, artifact.imageUrl));
|
|
656
893
|
}
|
|
894
|
+
const pendingRedo = scenesBeingRedone(run, items);
|
|
895
|
+
for (const sceneIndex of pendingRedo)
|
|
896
|
+
clipByScene.delete(sceneIndex);
|
|
657
897
|
const sceneIndexes = [
|
|
658
898
|
...new Set([
|
|
659
899
|
...clipByScene.keys(),
|
|
@@ -663,7 +903,7 @@ export function planPull(run, items, opts) {
|
|
|
663
903
|
...frameItemByScene.keys(),
|
|
664
904
|
]),
|
|
665
905
|
]
|
|
666
|
-
.filter((sceneIndex) => sceneIndex
|
|
906
|
+
.filter((sceneIndex) => isPullSceneIndex(sceneIndex))
|
|
667
907
|
.sort((a, b) => a - b);
|
|
668
908
|
const scenes = sceneIndexes.map((sceneIndex) => {
|
|
669
909
|
const clip = clipByScene.get(sceneIndex);
|
|
@@ -698,10 +938,12 @@ export function planPull(run, items, opts) {
|
|
|
698
938
|
: "this-file",
|
|
699
939
|
voice: voice?.download.file ?? null,
|
|
700
940
|
keyframe: keyframeByScene.get(sceneIndex)?.file ?? null,
|
|
701
|
-
qc: clip?.qc
|
|
941
|
+
qc: clip?.qc ? qcWithoutJudgeWording(clip.qc) : null,
|
|
702
942
|
revoiced: clip ? clip.revoiced : null,
|
|
703
943
|
speechTrimmed: clip ? clip.speechTrimmed : null,
|
|
704
944
|
rawStorageId: clip ? clip.rawStorageId : null,
|
|
945
|
+
voiceMode: clip ? clip.voiceMode : null,
|
|
946
|
+
voiceFirstBlockedBy: clip ? clip.voiceFirstBlockedBy : null,
|
|
705
947
|
clipStatus: item?.status ?? "missing",
|
|
706
948
|
error: item?.error ?? null,
|
|
707
949
|
flagged: item?.flagged === true,
|
|
@@ -729,6 +971,11 @@ export function planPull(run, items, opts) {
|
|
|
729
971
|
storyboard,
|
|
730
972
|
reference: reference?.file ?? null,
|
|
731
973
|
music: music?.file ?? null,
|
|
974
|
+
musicBed: musicBedState(run),
|
|
975
|
+
musicHeardScenes: scenes
|
|
976
|
+
.filter((scene) => scene.findings.some((f) => f.code === MUSIC_HEARD_CODE))
|
|
977
|
+
.map((scene) => scene.sceneIndex),
|
|
978
|
+
pendingRedo,
|
|
732
979
|
cast,
|
|
733
980
|
narration: narrationDownload
|
|
734
981
|
? { file: narrationDownload.file, timing: "narration.json" }
|
|
@@ -738,6 +985,14 @@ export function planPull(run, items, opts) {
|
|
|
738
985
|
...(run.provenance?.format || run.provenance?.voice
|
|
739
986
|
? { provenance: run.provenance }
|
|
740
987
|
: {}),
|
|
988
|
+
...(run.videoChoice
|
|
989
|
+
? {
|
|
990
|
+
videoChoice: {
|
|
991
|
+
videoModel: run.videoChoice.videoModel,
|
|
992
|
+
voiceMode: run.videoChoice.voiceMode,
|
|
993
|
+
},
|
|
994
|
+
}
|
|
995
|
+
: {}),
|
|
741
996
|
},
|
|
742
997
|
};
|
|
743
998
|
}
|
|
@@ -763,6 +1018,8 @@ export function markPullFailure(manifest, failure) {
|
|
|
763
1018
|
scene.revoiced = null;
|
|
764
1019
|
scene.speechTrimmed = null;
|
|
765
1020
|
scene.rawStorageId = null;
|
|
1021
|
+
scene.voiceMode = null;
|
|
1022
|
+
scene.voiceFirstBlockedBy = null;
|
|
766
1023
|
}
|
|
767
1024
|
if (scene.voice === failure.file)
|
|
768
1025
|
scene.voice = null;
|
|
@@ -868,7 +1125,7 @@ export async function startFlow(opts, deps) {
|
|
|
868
1125
|
showId: opts.showId,
|
|
869
1126
|
script,
|
|
870
1127
|
...(opts.voicePath ? { voicePath: opts.voicePath } : {}),
|
|
871
|
-
...(opts.music ===
|
|
1128
|
+
...(opts.music === undefined ? {} : { music: opts.music }),
|
|
872
1129
|
});
|
|
873
1130
|
if (!res.ok)
|
|
874
1131
|
return errorResult(res, opts.json);
|
|
@@ -897,13 +1154,287 @@ export async function startFlow(opts, deps) {
|
|
|
897
1154
|
lines: opts.json ? waited.lines : [`Started ad run ${runId}`, `Watch it: ${url}`, "", ...waited.lines],
|
|
898
1155
|
};
|
|
899
1156
|
}
|
|
1157
|
+
function nonBlank(value) {
|
|
1158
|
+
if (value === undefined || value.trim().length === 0)
|
|
1159
|
+
return undefined;
|
|
1160
|
+
return value;
|
|
1161
|
+
}
|
|
1162
|
+
function isVoiceMode(value) {
|
|
1163
|
+
return VOICE_MODES.some((mode) => mode === value);
|
|
1164
|
+
}
|
|
1165
|
+
function isConceit(value) {
|
|
1166
|
+
return CONCEIT_KEYS.some((key) => key === value);
|
|
1167
|
+
}
|
|
1168
|
+
export function planMusicChoice(occurrences) {
|
|
1169
|
+
let music;
|
|
1170
|
+
for (const { flag, value } of occurrences) {
|
|
1171
|
+
if (flag === "no-music") {
|
|
1172
|
+
music = false;
|
|
1173
|
+
continue;
|
|
1174
|
+
}
|
|
1175
|
+
if (flag !== "music")
|
|
1176
|
+
continue;
|
|
1177
|
+
if (value === undefined || value.startsWith("--")) {
|
|
1178
|
+
music = true;
|
|
1179
|
+
continue;
|
|
1180
|
+
}
|
|
1181
|
+
const word = value.trim().toLowerCase();
|
|
1182
|
+
const literal = word === "true" ? true : word === "false" ? false : undefined;
|
|
1183
|
+
if (literal === undefined) {
|
|
1184
|
+
return {
|
|
1185
|
+
ok: false,
|
|
1186
|
+
line: `video start --music takes true or false, or no value at all (got "${value}").`,
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
music = literal;
|
|
1190
|
+
}
|
|
1191
|
+
return { ok: true, music };
|
|
1192
|
+
}
|
|
1193
|
+
function planValueFlag(occurrences, name) {
|
|
1194
|
+
let value;
|
|
1195
|
+
for (const occurrence of occurrences) {
|
|
1196
|
+
if (occurrence.flag !== name)
|
|
1197
|
+
continue;
|
|
1198
|
+
const given = occurrence.value;
|
|
1199
|
+
if (given === undefined || given.startsWith("--") || given.trim().length === 0) {
|
|
1200
|
+
return { ok: false, line: `video start --${name} needs a value.` };
|
|
1201
|
+
}
|
|
1202
|
+
value = given.trim();
|
|
1203
|
+
}
|
|
1204
|
+
return { ok: true, value };
|
|
1205
|
+
}
|
|
1206
|
+
export function planVideoStart(flags, occurrences) {
|
|
1207
|
+
const showId = nonBlank(flagString(flags, "show"));
|
|
1208
|
+
const scriptFile = nonBlank(flagString(flags, "script"));
|
|
1209
|
+
const conceit = nonBlank(flagString(flags, "conceit"));
|
|
1210
|
+
const style = nonBlank(flagString(flags, "style"));
|
|
1211
|
+
if (showId && (conceit || style)) {
|
|
1212
|
+
return {
|
|
1213
|
+
kind: "usage",
|
|
1214
|
+
line: "Pass either --show <id>, or --conceit and --style. Not both.",
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
if (!scriptFile) {
|
|
1218
|
+
return {
|
|
1219
|
+
kind: "usage",
|
|
1220
|
+
line: "video start needs --script <file>, a text file of what the ad says.",
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
const musicChoice = planMusicChoice(occurrences);
|
|
1224
|
+
if (!musicChoice.ok)
|
|
1225
|
+
return { kind: "usage", line: musicChoice.line };
|
|
1226
|
+
const videoModelFlag = planValueFlag(occurrences, "video-model");
|
|
1227
|
+
if (!videoModelFlag.ok)
|
|
1228
|
+
return { kind: "usage", line: videoModelFlag.line };
|
|
1229
|
+
const voiceFlag = planValueFlag(occurrences, "voice");
|
|
1230
|
+
if (!voiceFlag.ok)
|
|
1231
|
+
return { kind: "usage", line: voiceFlag.line };
|
|
1232
|
+
const videoModel = videoModelFlag.value;
|
|
1233
|
+
const voiceMode = voiceFlag.value;
|
|
1234
|
+
const reviewStoryboard = occurrences.some((o) => o.flag === "review-storyboard");
|
|
1235
|
+
if (showId && reviewStoryboard) {
|
|
1236
|
+
return {
|
|
1237
|
+
kind: "usage",
|
|
1238
|
+
line: "--review-storyboard works with --conceit, not --show. A Show's ad always stops at the storyboard for you.",
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
if (showId && (videoModel || voiceMode)) {
|
|
1242
|
+
return {
|
|
1243
|
+
kind: "usage",
|
|
1244
|
+
line: "--video-model and --voice work with --conceit, not --show. A Show films on its own model.",
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
if (voiceMode && !isVoiceMode(voiceMode)) {
|
|
1248
|
+
return {
|
|
1249
|
+
kind: "usage",
|
|
1250
|
+
line: `video start --voice must be native or voice-first (got "${voiceMode}").`,
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
if (showId) {
|
|
1254
|
+
return {
|
|
1255
|
+
kind: "show",
|
|
1256
|
+
opts: {
|
|
1257
|
+
showId,
|
|
1258
|
+
scriptFile,
|
|
1259
|
+
voicePath: flagString(flags, "voice-path"),
|
|
1260
|
+
music: musicChoice.music,
|
|
1261
|
+
wait: flags["wait"] === true,
|
|
1262
|
+
json: flags["json"] === true,
|
|
1263
|
+
},
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
if (!conceit) {
|
|
1267
|
+
return {
|
|
1268
|
+
kind: "usage",
|
|
1269
|
+
line: `video start needs --conceit <${CONCEIT_KEYS.join("|")}>.`,
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
if (!isConceit(conceit)) {
|
|
1273
|
+
return {
|
|
1274
|
+
kind: "usage",
|
|
1275
|
+
line: `video start --conceit must be one of ${joinedOr(CONCEIT_KEYS)}.`,
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
if (!style) {
|
|
1279
|
+
return {
|
|
1280
|
+
kind: "usage",
|
|
1281
|
+
line: `video start needs --style <${STYLE_SLUGS.join("|")}>.`,
|
|
1282
|
+
};
|
|
1283
|
+
}
|
|
1284
|
+
const direction = nonBlank(flagString(flags, "direction"));
|
|
1285
|
+
const voicePath = nonBlank(flagString(flags, "voice-path"));
|
|
1286
|
+
const opts = {
|
|
1287
|
+
scriptFile,
|
|
1288
|
+
conceit,
|
|
1289
|
+
style,
|
|
1290
|
+
wait: flags["wait"] === true,
|
|
1291
|
+
json: flags["json"] === true,
|
|
1292
|
+
};
|
|
1293
|
+
if (direction)
|
|
1294
|
+
opts.direction = direction;
|
|
1295
|
+
if (voicePath)
|
|
1296
|
+
opts.voicePath = voicePath;
|
|
1297
|
+
if (videoModel)
|
|
1298
|
+
opts.videoModel = videoModel;
|
|
1299
|
+
if (voiceMode && isVoiceMode(voiceMode))
|
|
1300
|
+
opts.voiceMode = voiceMode;
|
|
1301
|
+
if (musicChoice.music !== undefined)
|
|
1302
|
+
opts.music = musicChoice.music;
|
|
1303
|
+
if (reviewStoryboard)
|
|
1304
|
+
opts.reviewStoryboard = true;
|
|
1305
|
+
return { kind: "script", opts };
|
|
1306
|
+
}
|
|
1307
|
+
function isRecord(value) {
|
|
1308
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1309
|
+
}
|
|
1310
|
+
function isResolvedCard(value) {
|
|
1311
|
+
if (!isRecord(value))
|
|
1312
|
+
return false;
|
|
1313
|
+
if (!isRecord(value.conceit) || !isRecord(value.style))
|
|
1314
|
+
return false;
|
|
1315
|
+
if (typeof value.conceit.key !== "string" || typeof value.conceit.label !== "string")
|
|
1316
|
+
return false;
|
|
1317
|
+
if (typeof value.family !== "string")
|
|
1318
|
+
return false;
|
|
1319
|
+
if (!("wrapper" in value))
|
|
1320
|
+
return false;
|
|
1321
|
+
if (value.wrapper !== null && typeof value.wrapper !== "string")
|
|
1322
|
+
return false;
|
|
1323
|
+
if (typeof value.style.slug !== "string" || typeof value.style.label !== "string")
|
|
1324
|
+
return false;
|
|
1325
|
+
return true;
|
|
1326
|
+
}
|
|
1327
|
+
function startedVideoChoice(value) {
|
|
1328
|
+
if (!isRecord(value))
|
|
1329
|
+
return null;
|
|
1330
|
+
if (typeof value.videoModel !== "string" || typeof value.voiceMode !== "string")
|
|
1331
|
+
return null;
|
|
1332
|
+
return { videoModel: value.videoModel, voiceMode: value.voiceMode };
|
|
1333
|
+
}
|
|
1334
|
+
function scriptStartedLines(runId, url, card, video) {
|
|
1335
|
+
return [
|
|
1336
|
+
`Started ad run ${runId}`,
|
|
1337
|
+
...(card
|
|
1338
|
+
? [
|
|
1339
|
+
`Conceit: ${card.conceit.label}`,
|
|
1340
|
+
`Family: ${card.family}`,
|
|
1341
|
+
`Wrapper: ${card.wrapper ?? ""}`,
|
|
1342
|
+
`Style: ${card.style.label}`,
|
|
1343
|
+
]
|
|
1344
|
+
: ["The server did not include a resolved card."]),
|
|
1345
|
+
...(video ? [`Video model: ${video.videoModel}`, `Voice: ${video.voiceMode}`] : []),
|
|
1346
|
+
`Watch it: ${url}`,
|
|
1347
|
+
];
|
|
1348
|
+
}
|
|
1349
|
+
function waitJsonWithCard(waited, card, video) {
|
|
1350
|
+
const first = waited.lines[0];
|
|
1351
|
+
if (typeof first !== "string")
|
|
1352
|
+
return waited;
|
|
1353
|
+
let parsed;
|
|
1354
|
+
try {
|
|
1355
|
+
parsed = JSON.parse(first);
|
|
1356
|
+
}
|
|
1357
|
+
catch {
|
|
1358
|
+
return waited;
|
|
1359
|
+
}
|
|
1360
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
1361
|
+
return waited;
|
|
1362
|
+
return {
|
|
1363
|
+
code: waited.code,
|
|
1364
|
+
lines: [JSON.stringify({ ...parsed, card, ...(video ? { videoChoice: video } : {}) })],
|
|
1365
|
+
};
|
|
1366
|
+
}
|
|
1367
|
+
export async function startScriptFlow(opts, deps) {
|
|
1368
|
+
let script;
|
|
1369
|
+
try {
|
|
1370
|
+
script = deps.readFile(opts.scriptFile);
|
|
1371
|
+
}
|
|
1372
|
+
catch (e) {
|
|
1373
|
+
return {
|
|
1374
|
+
code: 1,
|
|
1375
|
+
lines: [
|
|
1376
|
+
`Can't read the script file "${opts.scriptFile}": ${e instanceof Error ? e.message : String(e)}`,
|
|
1377
|
+
],
|
|
1378
|
+
};
|
|
1379
|
+
}
|
|
1380
|
+
if (script.trim().length === 0) {
|
|
1381
|
+
return { code: 1, lines: [`The script file "${opts.scriptFile}" is empty.`] };
|
|
1382
|
+
}
|
|
1383
|
+
const res = await deps.post(SCRIPT_RUNS_PATH, {
|
|
1384
|
+
script,
|
|
1385
|
+
conceit: opts.conceit,
|
|
1386
|
+
style: opts.style,
|
|
1387
|
+
...(opts.direction ? { direction: opts.direction } : {}),
|
|
1388
|
+
...(opts.voicePath ? { voicePath: opts.voicePath } : {}),
|
|
1389
|
+
...(opts.videoModel ? { videoModel: opts.videoModel } : {}),
|
|
1390
|
+
...(opts.voiceMode ? { voiceMode: opts.voiceMode } : {}),
|
|
1391
|
+
...(opts.music === true ? { music: true } : {}),
|
|
1392
|
+
...(opts.reviewStoryboard ? { reviewStoryboard: true } : {}),
|
|
1393
|
+
});
|
|
1394
|
+
if (!res.ok)
|
|
1395
|
+
return errorResult(res, opts.json);
|
|
1396
|
+
const started = res.data;
|
|
1397
|
+
if (!started.runId) {
|
|
1398
|
+
return { code: 1, lines: ["The server started the ad but did not say which run it is."] };
|
|
1399
|
+
}
|
|
1400
|
+
const runId = started.runId;
|
|
1401
|
+
const url = typeof started.url === "string" ? started.url : "";
|
|
1402
|
+
const card = isResolvedCard(started.card) ? started.card : null;
|
|
1403
|
+
const video = startedVideoChoice(started.videoChoice);
|
|
1404
|
+
if (!opts.wait) {
|
|
1405
|
+
return {
|
|
1406
|
+
code: 0,
|
|
1407
|
+
lines: opts.json
|
|
1408
|
+
? [JSON.stringify({ runId, url, card, ...(video ? { videoChoice: video } : {}) })]
|
|
1409
|
+
: [
|
|
1410
|
+
...scriptStartedLines(runId, url, card, video),
|
|
1411
|
+
"",
|
|
1412
|
+
...(opts.reviewStoryboard
|
|
1413
|
+
? ["Wait for the storyboard here instead: exodus video start … --wait"]
|
|
1414
|
+
: [
|
|
1415
|
+
"The app approves the storyboard itself if its checks pass. It only stops for you if they find a problem.",
|
|
1416
|
+
"Wait here until it needs you: exodus video start … --wait",
|
|
1417
|
+
]),
|
|
1418
|
+
`Or check in whenever: exodus video status ${runId}`,
|
|
1419
|
+
],
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
1422
|
+
const waited = await waitFlow(runId, { json: opts.json, url }, deps);
|
|
1423
|
+
if (opts.json)
|
|
1424
|
+
return waitJsonWithCard(waited, card, video);
|
|
1425
|
+
return {
|
|
1426
|
+
code: waited.code,
|
|
1427
|
+
lines: [...scriptStartedLines(runId, url, card, video), "", ...waited.lines],
|
|
1428
|
+
};
|
|
1429
|
+
}
|
|
900
1430
|
const POLL_INTERVAL_MS = 5000;
|
|
901
1431
|
const MAX_POLLS = 720;
|
|
902
1432
|
export async function waitFlow(runId, opts, deps) {
|
|
903
1433
|
const interval = opts.intervalMs ?? POLL_INTERVAL_MS;
|
|
904
1434
|
const maxPolls = opts.maxPolls ?? MAX_POLLS;
|
|
905
1435
|
const lines = [];
|
|
906
|
-
let
|
|
1436
|
+
let lastProgress = null;
|
|
1437
|
+
let redoSeen = false;
|
|
907
1438
|
for (let poll = 0; poll < maxPolls; poll++) {
|
|
908
1439
|
if (poll > 0)
|
|
909
1440
|
await deps.sleep(interval);
|
|
@@ -915,14 +1446,35 @@ export async function waitFlow(runId, opts, deps) {
|
|
|
915
1446
|
const run = asVideoRun(res.data);
|
|
916
1447
|
const stop = classifyRun(run);
|
|
917
1448
|
if (stop.at === "running") {
|
|
918
|
-
|
|
919
|
-
|
|
1449
|
+
const progress = stop.autoRedoScenes?.length
|
|
1450
|
+
? autoRedoLine(stop.autoRedoScenes)
|
|
1451
|
+
: `Working: ${stageWord(stop.stage)}…`;
|
|
1452
|
+
if (progress !== lastProgress) {
|
|
1453
|
+
lastProgress = progress;
|
|
920
1454
|
if (!opts.json)
|
|
921
|
-
lines.push(
|
|
1455
|
+
lines.push(progress);
|
|
922
1456
|
}
|
|
923
1457
|
continue;
|
|
924
1458
|
}
|
|
925
|
-
|
|
1459
|
+
let items = null;
|
|
1460
|
+
if (stop.at === "final-watch") {
|
|
1461
|
+
const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1462
|
+
items = itemsRes.ok ? (itemsRes.data.items ?? []) : null;
|
|
1463
|
+
if (items === null && redoSeen)
|
|
1464
|
+
continue;
|
|
1465
|
+
const redoing = items ? scenesBeingRedone(run, items) : [];
|
|
1466
|
+
redoSeen = redoing.length > 0;
|
|
1467
|
+
if (redoSeen) {
|
|
1468
|
+
const progress = `Working: redoing scene${redoing.length === 1 ? "" : "s"} ${redoing.join(", ")}…`;
|
|
1469
|
+
if (progress !== lastProgress) {
|
|
1470
|
+
lastProgress = progress;
|
|
1471
|
+
if (!opts.json)
|
|
1472
|
+
lines.push(progress);
|
|
1473
|
+
}
|
|
1474
|
+
continue;
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
const resolved = resolveStop(stop, items ? hasAttachedCut(items) : null);
|
|
926
1478
|
if (opts.json) {
|
|
927
1479
|
return {
|
|
928
1480
|
code: stop.at === "failed" ? 1 : 0,
|
|
@@ -956,6 +1508,11 @@ function itemWord(item) {
|
|
|
956
1508
|
return "flagged";
|
|
957
1509
|
return ITEM_STATUS_WORD[item.status] ?? item.status;
|
|
958
1510
|
}
|
|
1511
|
+
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.";
|
|
1512
|
+
function carryingOn(run) {
|
|
1513
|
+
return (run.status === "queued" &&
|
|
1514
|
+
run.nodes.some((n) => n.status !== "idle" && n.status !== "out-of-scope"));
|
|
1515
|
+
}
|
|
959
1516
|
export async function statusFlow(runId, json, deps) {
|
|
960
1517
|
const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
961
1518
|
if (!runRes.ok)
|
|
@@ -967,7 +1524,10 @@ export async function statusFlow(runId, json, deps) {
|
|
|
967
1524
|
const items = itemsRes.data.items ?? [];
|
|
968
1525
|
const hasFinal = hasAttachedCut(items);
|
|
969
1526
|
const stop = resolveStop(classifyRun(run), hasFinal);
|
|
970
|
-
const
|
|
1527
|
+
const failedStoryboard = failedStoryboardNode(run);
|
|
1528
|
+
const guidance = stopLines(stop.at === "failed" && errorEchoesReasons(stop.error, failedStoryboard?.planFailure)
|
|
1529
|
+
? { ...stop, error: undefined }
|
|
1530
|
+
: stop, runId, reviewUrl(deps.dashboardUrl, run));
|
|
971
1531
|
const warnings = run.nodes
|
|
972
1532
|
.filter((n) => n.warning)
|
|
973
1533
|
.map((n) => ({ nodeId: n.nodeId, step: n.kind, warning: n.warning }));
|
|
@@ -975,13 +1535,39 @@ export async function statusFlow(runId, json, deps) {
|
|
|
975
1535
|
return {
|
|
976
1536
|
code: 0,
|
|
977
1537
|
lines: [
|
|
978
|
-
JSON.stringify({
|
|
1538
|
+
JSON.stringify({
|
|
1539
|
+
runId,
|
|
1540
|
+
status: run.status,
|
|
1541
|
+
stop,
|
|
1542
|
+
items,
|
|
1543
|
+
hasFinal,
|
|
1544
|
+
warnings,
|
|
1545
|
+
guidance,
|
|
1546
|
+
...(run.musicBed === undefined ? {} : { musicBed: run.musicBed }),
|
|
1547
|
+
...(failedStoryboard?.planFailure ? { planFailure: failedStoryboard.planFailure } : {}),
|
|
1548
|
+
...(failedStoryboard?.planFailure
|
|
1549
|
+
? { storyboardNodeId: failedStoryboard.nodeId }
|
|
1550
|
+
: {}),
|
|
1551
|
+
...(failedStoryboard?.hasRejectedDraft === true ? { hasRejectedDraft: true } : {}),
|
|
1552
|
+
}),
|
|
979
1553
|
],
|
|
980
1554
|
};
|
|
981
1555
|
}
|
|
982
1556
|
const revoicedByScene = new Set();
|
|
1557
|
+
const voiceFirstByScene = new Set();
|
|
1558
|
+
const voiceFirstBlockedByScene = new Map();
|
|
983
1559
|
const timedOnOriginalVoice = new Set();
|
|
984
1560
|
for (const artifact of outputsOfNodeKind(run, "video")) {
|
|
1561
|
+
if (artifact.type === "video" &&
|
|
1562
|
+
artifact.voiceMode === "voice-first" &&
|
|
1563
|
+
typeof artifact.sceneIndex === "number") {
|
|
1564
|
+
voiceFirstByScene.add(artifact.sceneIndex);
|
|
1565
|
+
}
|
|
1566
|
+
if (artifact.type === "video" &&
|
|
1567
|
+
artifact.voiceFirstBlockedBy?.length &&
|
|
1568
|
+
typeof artifact.sceneIndex === "number") {
|
|
1569
|
+
voiceFirstBlockedByScene.set(artifact.sceneIndex, artifact.voiceFirstBlockedBy);
|
|
1570
|
+
}
|
|
985
1571
|
if (artifact.type === "video" && artifact.revoiced === true && typeof artifact.sceneIndex === "number") {
|
|
986
1572
|
revoicedByScene.add(artifact.sceneIndex);
|
|
987
1573
|
if ((artifact.words?.length ?? 0) > 0)
|
|
@@ -1007,8 +1593,10 @@ export async function statusFlow(runId, json, deps) {
|
|
|
1007
1593
|
: `Ad run ${runId} — ${displayRunStatus(run.status)}`;
|
|
1008
1594
|
const lines = [
|
|
1009
1595
|
headline,
|
|
1596
|
+
...(carryingOn(run) ? [CARRYING_ON_LINE] : []),
|
|
1010
1597
|
...guidance,
|
|
1011
1598
|
...(stop.at === "running" && run.pauseAhead ? [pauseAheadLine(run.pauseAhead)] : []),
|
|
1599
|
+
...planFailureLines(failedStoryboard, runId),
|
|
1012
1600
|
...warnings.map((w) => `Heads-up (${w.step}): ${w.warning}`),
|
|
1013
1601
|
];
|
|
1014
1602
|
if (byScene.size === 0) {
|
|
@@ -1019,10 +1607,19 @@ export async function statusFlow(runId, json, deps) {
|
|
|
1019
1607
|
for (const sceneIndex of [...byScene.keys()].sort((a, b) => a - b)) {
|
|
1020
1608
|
const row = byScene.get(sceneIndex);
|
|
1021
1609
|
lines.push(`${String(sceneIndex).padEnd(5)} ${itemWord(row.clip).padEnd(8)} ${itemWord(row.voiceover).padEnd(8)} ${itemWord(row.frame)}`);
|
|
1022
|
-
if (row.clip?.lastRedo)
|
|
1023
|
-
lines.push(` clip: ${row.clip.lastRedo
|
|
1024
|
-
|
|
1025
|
-
|
|
1610
|
+
if (row.clip?.lastRedo) {
|
|
1611
|
+
lines.push(` clip: ${redoLine(row.clip.lastRedo)}`);
|
|
1612
|
+
printDisplacedHistory(lines, row.clip.lastRedo);
|
|
1613
|
+
}
|
|
1614
|
+
if (row.clip?.lastRedo || row.clip?.flagged) {
|
|
1615
|
+
for (const line of renderQcTakeHistory(clipQcOf(row.clip))) {
|
|
1616
|
+
lines.push(` ${line}`);
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
if (row.voiceover?.lastRedo) {
|
|
1620
|
+
lines.push(` voice: ${redoLine(row.voiceover.lastRedo)}`);
|
|
1621
|
+
printDisplacedHistory(lines, row.voiceover.lastRedo);
|
|
1622
|
+
}
|
|
1026
1623
|
if (row.frame?.lastRedo)
|
|
1027
1624
|
lines.push(` picture: ${row.frame.lastRedo.label}`);
|
|
1028
1625
|
for (const finding of row.clip?.findings ?? []) {
|
|
@@ -1039,6 +1636,12 @@ export async function statusFlow(runId, json, deps) {
|
|
|
1039
1636
|
lines.push(` ${row.clip.error}`);
|
|
1040
1637
|
if (revoicedByScene.has(sceneIndex))
|
|
1041
1638
|
lines.push(" voice: cast voice applied");
|
|
1639
|
+
if (voiceFirstByScene.has(sceneIndex))
|
|
1640
|
+
lines.push(" clip: redone voice first");
|
|
1641
|
+
const blockedBy = voiceFirstBlockedByScene.get(sceneIndex);
|
|
1642
|
+
if (blockedBy) {
|
|
1643
|
+
lines.push(` clip: redone the usual way — ${cantSayNames(blockedBy)}`);
|
|
1644
|
+
}
|
|
1042
1645
|
if (timedOnOriginalVoice.has(sceneIndex)) {
|
|
1043
1646
|
lines.push(" word timings: measured on the original voice (close, not frame-exact)");
|
|
1044
1647
|
}
|
|
@@ -1057,6 +1660,45 @@ export async function statusFlow(runId, json, deps) {
|
|
|
1057
1660
|
: "Final cut: not uploaded yet.");
|
|
1058
1661
|
return { code: 0, lines };
|
|
1059
1662
|
}
|
|
1663
|
+
export async function rejectedDraftFlow(runId, json, deps) {
|
|
1664
|
+
const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1665
|
+
if (!runRes.ok)
|
|
1666
|
+
return errorResult(runRes, json);
|
|
1667
|
+
const node = failedStoryboardNode(asVideoRun(runRes.data));
|
|
1668
|
+
if (!node) {
|
|
1669
|
+
const note = "The storyboard on this run didn't fail, so there is no rejected draft to read.";
|
|
1670
|
+
return {
|
|
1671
|
+
code: 0,
|
|
1672
|
+
lines: json ? [JSON.stringify({ runId, nodeId: null, draft: null })] : [note],
|
|
1673
|
+
};
|
|
1674
|
+
}
|
|
1675
|
+
const res = await deps.get(`${REJECTED_DRAFT_PATH}?runId=${encodeURIComponent(runId)}&nodeId=${encodeURIComponent(node.nodeId)}`);
|
|
1676
|
+
if (missingRouteLine(res, "exodus video status --rejected-draft")) {
|
|
1677
|
+
return {
|
|
1678
|
+
code: 1,
|
|
1679
|
+
lines: json
|
|
1680
|
+
? [JSON.stringify({ ok: false, status: 404, error: REJECTED_DRAFT_NOT_ON_THIS_SERVER })]
|
|
1681
|
+
: [REJECTED_DRAFT_NOT_ON_THIS_SERVER],
|
|
1682
|
+
};
|
|
1683
|
+
}
|
|
1684
|
+
if (!res.ok)
|
|
1685
|
+
return errorResult(res, json);
|
|
1686
|
+
const draft = res.data.draft ?? null;
|
|
1687
|
+
if (json)
|
|
1688
|
+
return { code: 0, lines: [JSON.stringify({ runId, nodeId: node.nodeId, draft })] };
|
|
1689
|
+
if (!draft)
|
|
1690
|
+
return { code: 0, lines: ["No rejected draft was kept for this attempt."] };
|
|
1691
|
+
return {
|
|
1692
|
+
code: 0,
|
|
1693
|
+
lines: [
|
|
1694
|
+
`REJECTED DRAFT — ad run ${runId}, step ${node.nodeId}`,
|
|
1695
|
+
"The system turned this draft down and threw it away. It is kept only as a record of what happened.",
|
|
1696
|
+
"Nothing in it is part of your ad. There is nothing here to act on or edit.",
|
|
1697
|
+
"",
|
|
1698
|
+
...draft.split("\n"),
|
|
1699
|
+
],
|
|
1700
|
+
};
|
|
1701
|
+
}
|
|
1060
1702
|
export async function storyboardFlow(runId, json, deps) {
|
|
1061
1703
|
const res = await deps.get(`${STORYBOARD_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1062
1704
|
if (!res.ok)
|
|
@@ -1108,7 +1750,14 @@ export async function storyboardFlow(runId, json, deps) {
|
|
|
1108
1750
|
}
|
|
1109
1751
|
}
|
|
1110
1752
|
}
|
|
1111
|
-
|
|
1753
|
+
const runRes = await deps
|
|
1754
|
+
.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`)
|
|
1755
|
+
.catch(() => null);
|
|
1756
|
+
const showAd = runRes?.ok === true && isShowAd(asVideoRun(runRes.data));
|
|
1757
|
+
lines.push("", `approve with: exodus video approve ${runId}`);
|
|
1758
|
+
if (showAd) {
|
|
1759
|
+
lines.push(`send it back: exodus video flag ${runId} --note "what's wrong"`);
|
|
1760
|
+
}
|
|
1112
1761
|
if (cards.framesNodeId) {
|
|
1113
1762
|
lines.push(`redo one frame: exodus video retry-frame ${runId} --node ${cards.framesNodeId} --scene <n>`);
|
|
1114
1763
|
}
|
|
@@ -1136,14 +1785,30 @@ function sceneCardLines(scene) {
|
|
|
1136
1785
|
lines.push(` picture: ${scene.frame.status}`);
|
|
1137
1786
|
return lines;
|
|
1138
1787
|
}
|
|
1139
|
-
export async function approveFlow(runId,
|
|
1140
|
-
const res = await deps.post(APPROVE_PATH, {
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1788
|
+
export async function approveFlow(runId, opts, deps) {
|
|
1789
|
+
const res = await deps.post(APPROVE_PATH, {
|
|
1790
|
+
runId,
|
|
1791
|
+
...(opts.approveStaleCut ? { approveStaleCut: true } : {}),
|
|
1792
|
+
});
|
|
1793
|
+
if (!res.ok) {
|
|
1794
|
+
const refusal = errorResult(res, opts.json);
|
|
1795
|
+
const staleCut = !opts.json && videoApiError(res).includes("approve this cut anyway");
|
|
1796
|
+
return staleCut
|
|
1797
|
+
? {
|
|
1798
|
+
...refusal,
|
|
1799
|
+
lines: [
|
|
1800
|
+
...refusal.lines,
|
|
1801
|
+
`To deliver it anyway: exodus video approve ${runId} --approve-stale-cut`,
|
|
1802
|
+
],
|
|
1803
|
+
}
|
|
1804
|
+
: refusal;
|
|
1805
|
+
}
|
|
1806
|
+
const data = res.data;
|
|
1807
|
+
const warnings = Array.isArray(data?.warnings) ? data.warnings : [];
|
|
1808
|
+
if (opts.json) {
|
|
1809
|
+
return { code: 0, lines: [JSON.stringify({ ok: true, runId, warnings, data: res.data })] };
|
|
1810
|
+
}
|
|
1811
|
+
const voices = data?.voices;
|
|
1147
1812
|
const summary = voices
|
|
1148
1813
|
? voiceSummaryLines(Array.isArray(voices.cast) ? voices.cast : [], Array.isArray(voices.notices) ? voices.notices : [])
|
|
1149
1814
|
: [];
|
|
@@ -1151,6 +1816,7 @@ export async function approveFlow(runId, json, deps) {
|
|
|
1151
1816
|
code: 0,
|
|
1152
1817
|
lines: [
|
|
1153
1818
|
"Approved.",
|
|
1819
|
+
...warnings,
|
|
1154
1820
|
...(summary.length > 0 ? ["", ...summary, ""] : []),
|
|
1155
1821
|
`See what happens next: exodus video status ${runId}`,
|
|
1156
1822
|
],
|
|
@@ -1208,8 +1874,9 @@ export async function retryFrameFlow(runId, nodeId, sceneIndex, note, json, deps
|
|
|
1208
1874
|
],
|
|
1209
1875
|
};
|
|
1210
1876
|
}
|
|
1211
|
-
const UPLOADED_CUT_WARNING = "The cut you already uploaded will not include the new clip.
|
|
1212
|
-
"re-cut
|
|
1877
|
+
const UPLOADED_CUT_WARNING = "The cut you already uploaded will not include the new clip. If this redo replaces the clip, " +
|
|
1878
|
+
"exodus video approve stops until you pull the pieces again, re-cut and upload again, " +
|
|
1879
|
+
"or pass --approve-stale-cut to deliver the cut you uploaded as it is.";
|
|
1213
1880
|
export function planClipRedo(run, items, target) {
|
|
1214
1881
|
const stop = classifyRun(run);
|
|
1215
1882
|
const found = findClipRow(run, items, target);
|
|
@@ -1218,7 +1885,10 @@ export function planClipRedo(run, items, target) {
|
|
|
1218
1885
|
const { row, uploadedCut } = found;
|
|
1219
1886
|
const redoable = row.status === "failed" ||
|
|
1220
1887
|
row.staleClaim === true ||
|
|
1221
|
-
(row.status === "done" &&
|
|
1888
|
+
(row.status === "done" &&
|
|
1889
|
+
(row.flagged === true ||
|
|
1890
|
+
stop.at === "final-watch" ||
|
|
1891
|
+
(stop.at === "running" && stop.autoRedoScenes !== undefined)));
|
|
1222
1892
|
if (!redoable) {
|
|
1223
1893
|
if (row.status === "done") {
|
|
1224
1894
|
return {
|
|
@@ -1275,7 +1945,7 @@ function findClipRow(run, items, target) {
|
|
|
1275
1945
|
}
|
|
1276
1946
|
return { row, uploadedCut };
|
|
1277
1947
|
}
|
|
1278
|
-
export async function retryClipFlow(runId, target, note, json, deps) {
|
|
1948
|
+
export async function retryClipFlow(runId, target, { note, voiceFirst = false }, json, deps) {
|
|
1279
1949
|
const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1280
1950
|
if (!runRes.ok)
|
|
1281
1951
|
return errorResult(runRes, json);
|
|
@@ -1291,12 +1961,20 @@ export async function retryClipFlow(runId, target, note, json, deps) {
|
|
|
1291
1961
|
lines: json ? [JSON.stringify({ ok: false, error: plan.reason })] : [plan.reason],
|
|
1292
1962
|
};
|
|
1293
1963
|
}
|
|
1294
|
-
const res = await deps.post(SCENE_RETRY_PATH, {
|
|
1964
|
+
const res = await deps.post(voiceFirst ? SCENE_VOICE_FIRST_PATH : SCENE_RETRY_PATH, {
|
|
1295
1965
|
runId,
|
|
1296
1966
|
nodeId: plan.nodeId,
|
|
1297
1967
|
sceneIndex: plan.sceneIndex,
|
|
1298
1968
|
...(note ? { note } : {}),
|
|
1299
1969
|
});
|
|
1970
|
+
if (voiceFirst && missingRouteLine(res, "exodus video retry-clip --voice-first")) {
|
|
1971
|
+
return {
|
|
1972
|
+
code: 1,
|
|
1973
|
+
lines: json
|
|
1974
|
+
? [JSON.stringify({ ok: false, status: 404, error: VOICE_FIRST_NOT_ON_THIS_SERVER })]
|
|
1975
|
+
: [VOICE_FIRST_NOT_ON_THIS_SERVER],
|
|
1976
|
+
};
|
|
1977
|
+
}
|
|
1300
1978
|
if (!res.ok)
|
|
1301
1979
|
return errorResult(res, json);
|
|
1302
1980
|
const triggerRunId = res.data.triggerRunId;
|
|
@@ -1315,6 +1993,7 @@ export async function retryClipFlow(runId, target, note, json, deps) {
|
|
|
1315
1993
|
reviewUrl: review,
|
|
1316
1994
|
warnings: plan.warnings,
|
|
1317
1995
|
...(note ? { note } : {}),
|
|
1996
|
+
...(voiceFirst ? { voiceMode: "voice-first" } : {}),
|
|
1318
1997
|
}),
|
|
1319
1998
|
],
|
|
1320
1999
|
};
|
|
@@ -1322,9 +2001,19 @@ export async function retryClipFlow(runId, target, note, json, deps) {
|
|
|
1322
2001
|
return {
|
|
1323
2002
|
code: 0,
|
|
1324
2003
|
lines: [
|
|
1325
|
-
|
|
2004
|
+
voiceFirst
|
|
2005
|
+
? `Redoing scene ${plan.sceneIndex}'s clip on ${plan.nodeId}, voice first: the line is ` +
|
|
2006
|
+
"recorded in the character's own voice, then the clip is made to match it. " +
|
|
2007
|
+
"If the line has a made-up name the recorded voice can't say, the clip may be made " +
|
|
2008
|
+
`the usual way instead, and exodus video status ${runId} will say so.`
|
|
2009
|
+
: `Redoing scene ${plan.sceneIndex}'s clip on ${plan.nodeId}.`,
|
|
1326
2010
|
`triggerRunId: ${triggerRunId ?? "-"}`,
|
|
1327
|
-
|
|
2011
|
+
...(voiceFirst
|
|
2012
|
+
? [
|
|
2013
|
+
"Every other scene stays as it is, and so do the pictures and the script.",
|
|
2014
|
+
"A speaking character with no voice yet gets one picked and saved for the whole run.",
|
|
2015
|
+
]
|
|
2016
|
+
: ["Every other scene stays as it is, and so do the pictures, the voices and the script."]),
|
|
1328
2017
|
...(note
|
|
1329
2018
|
? ["Your note steers how this clip moves. It never changes the words that are spoken."]
|
|
1330
2019
|
: []),
|
|
@@ -1641,6 +2330,13 @@ export function voiceSheetLines(sheet) {
|
|
|
1641
2330
|
: (sheet.whyNot ?? "Voices can no longer be changed on this run."));
|
|
1642
2331
|
return lines;
|
|
1643
2332
|
}
|
|
2333
|
+
function musicLines(manifest) {
|
|
2334
|
+
const lines = [`music: ${manifest.musicBed}`];
|
|
2335
|
+
if (manifest.musicHeardScenes.length > 0) {
|
|
2336
|
+
lines.push(`music heard in scene ${manifest.musicHeardScenes.join(", ")}`);
|
|
2337
|
+
}
|
|
2338
|
+
return lines;
|
|
2339
|
+
}
|
|
1644
2340
|
export async function pullFlow(runId, dir, json, deps) {
|
|
1645
2341
|
const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1646
2342
|
if (!runRes.ok)
|
|
@@ -1684,7 +2380,23 @@ export async function pullFlow(runId, dir, json, deps) {
|
|
|
1684
2380
|
const lines = [
|
|
1685
2381
|
`Pulled ${wrote} files into ${dir}`,
|
|
1686
2382
|
`Every piece is indexed in ${path.join(dir, "manifest.json")} — scene numbers there are the run's own.`,
|
|
2383
|
+
...musicLines(plan.manifest),
|
|
1687
2384
|
];
|
|
2385
|
+
for (const sceneIndex of plan.manifest.pendingRedo) {
|
|
2386
|
+
lines.push("", `Scene ${sceneIndex} is being redone right now; its file will change. Run \`exodus video wait ${runId}\`, then pull again.`);
|
|
2387
|
+
}
|
|
2388
|
+
const voiceFirst = plan.manifest.scenes.filter((s) => s.voiceMode === "voice-first");
|
|
2389
|
+
if (voiceFirst.length > 0) {
|
|
2390
|
+
lines.push("", `Redone voice first (the line recorded, then the clip made to match it): ` +
|
|
2391
|
+
`scene${voiceFirst.length === 1 ? "" : "s"} ${voiceFirst.map((s) => s.sceneIndex).join(", ")}`);
|
|
2392
|
+
}
|
|
2393
|
+
const redoneUsualWay = plan.manifest.scenes.filter((s) => s.voiceFirstBlockedBy?.length);
|
|
2394
|
+
if (redoneUsualWay.length > 0) {
|
|
2395
|
+
lines.push("", "Asked for voice first, but redone the usual way:");
|
|
2396
|
+
for (const scene of redoneUsualWay) {
|
|
2397
|
+
lines.push(` scene ${scene.sceneIndex}: ${cantSayNames(scene.voiceFirstBlockedBy ?? [])}`);
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
1688
2400
|
const flagged = plan.manifest.scenes.filter((s) => s.flagged);
|
|
1689
2401
|
if (flagged.length > 0) {
|
|
1690
2402
|
lines.push("", `${flagged.length} clip${flagged.length === 1 ? "" : "s"} came back flagged — usable, but look before you cut:`);
|
|
@@ -1940,7 +2652,12 @@ export async function uploadFlow(runId, filePath, durationFlag, json, deps) {
|
|
|
1940
2652
|
const VALUE_FLAGS = new Set([
|
|
1941
2653
|
"show",
|
|
1942
2654
|
"script",
|
|
2655
|
+
"conceit",
|
|
2656
|
+
"style",
|
|
2657
|
+
"direction",
|
|
1943
2658
|
"voice-path",
|
|
2659
|
+
"video-model",
|
|
2660
|
+
"voice",
|
|
1944
2661
|
"note",
|
|
1945
2662
|
"out",
|
|
1946
2663
|
"file",
|
|
@@ -1993,20 +2710,12 @@ export async function run(flags, occurrences) {
|
|
|
1993
2710
|
if (sub === "shows")
|
|
1994
2711
|
return printResult(await showsFlow(json, defaultDeps));
|
|
1995
2712
|
if (sub === "start") {
|
|
1996
|
-
const
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
return printResult(await startFlow({
|
|
2003
|
-
showId,
|
|
2004
|
-
scriptFile,
|
|
2005
|
-
voicePath: flagString(flags, "voice-path"),
|
|
2006
|
-
music: flags["music"] === false ? false : undefined,
|
|
2007
|
-
wait: flags["wait"] === true,
|
|
2008
|
-
json,
|
|
2009
|
-
}, defaultDeps));
|
|
2713
|
+
const plan = planVideoStart(flags, occurrences);
|
|
2714
|
+
if (plan.kind === "usage")
|
|
2715
|
+
usage(plan.line);
|
|
2716
|
+
if (plan.kind === "show")
|
|
2717
|
+
return printResult(await startFlow(plan.opts, defaultDeps));
|
|
2718
|
+
return printResult(await startScriptFlow(plan.opts, defaultDeps));
|
|
2010
2719
|
}
|
|
2011
2720
|
const runId = rest[0];
|
|
2012
2721
|
const needsRunId = [
|
|
@@ -2024,12 +2733,17 @@ export async function run(flags, occurrences) {
|
|
|
2024
2733
|
if (needsRunId.includes(sub) && !runId) {
|
|
2025
2734
|
usage(`video ${sub} needs a run id: exodus video ${sub} <runId>`);
|
|
2026
2735
|
}
|
|
2027
|
-
if (sub === "status")
|
|
2736
|
+
if (sub === "status") {
|
|
2737
|
+
if (flags["rejected-draft"] !== undefined && flags["rejected-draft"] !== false) {
|
|
2738
|
+
return printResult(await rejectedDraftFlow(runId, json, defaultDeps));
|
|
2739
|
+
}
|
|
2028
2740
|
return printResult(await statusFlow(runId, json, defaultDeps));
|
|
2741
|
+
}
|
|
2029
2742
|
if (sub === "storyboard")
|
|
2030
2743
|
return printResult(await storyboardFlow(runId, json, defaultDeps));
|
|
2031
|
-
if (sub === "approve")
|
|
2032
|
-
return printResult(await approveFlow(runId, json, defaultDeps));
|
|
2744
|
+
if (sub === "approve") {
|
|
2745
|
+
return printResult(await approveFlow(runId, { json, approveStaleCut: flags["approve-stale-cut"] === true }, defaultDeps));
|
|
2746
|
+
}
|
|
2033
2747
|
if (sub === "flag") {
|
|
2034
2748
|
const note = flagString(flags, "note");
|
|
2035
2749
|
if (!note)
|
|
@@ -2059,7 +2773,7 @@ export async function run(flags, occurrences) {
|
|
|
2059
2773
|
if (!Number.isInteger(sceneIndex)) {
|
|
2060
2774
|
usage(`video retry-clip --scene must be a whole scene number, not "${sceneRaw}".`);
|
|
2061
2775
|
}
|
|
2062
|
-
return printResult(await retryClipFlow(runId, { sceneIndex, nodeId: flagString(flags, "node") }, flagString(flags, "note"), json, defaultDeps));
|
|
2776
|
+
return printResult(await retryClipFlow(runId, { sceneIndex, nodeId: flagString(flags, "node") }, { note: flagString(flags, "note"), voiceFirst: flags["voice-first"] === true }, json, defaultDeps));
|
|
2063
2777
|
}
|
|
2064
2778
|
if (sub === "revoice") {
|
|
2065
2779
|
const sceneRaw = flagString(flags, "scene");
|