@aicopycoders/exodus 2026.9.2000-beta.9 → 2026.9.2000
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 -0
- package/assets/skills/exodus-primer/SKILL.md +10 -5
- package/assets/skills/exodus-video/SKILL.md +168 -38
- package/assets/skills/exodus-video/scripts/first-cut.d.mts +27 -0
- package/assets/skills/exodus-video/scripts/first-cut.mjs +766 -59
- package/assets/skills/exodus-winners/SKILL.md +13 -1
- package/assets/skills/swipe/SKILL.md +78 -0
- package/dist/commands/ads.js +1 -1
- package/dist/commands/doctor.js +1 -1
- package/dist/commands/primer.js +84 -12
- package/dist/commands/video-customer.d.ts +98 -0
- package/dist/commands/video-customer.js +147 -0
- package/dist/commands/video.d.ts +134 -7
- package/dist/commands/video.js +755 -66
- package/dist/commands/whoami.js +2 -1
- package/dist/commands/workflow.d.ts +2 -1
- package/dist/commands/workflow.js +16 -2
- package/dist/lib/meta-ads.d.ts +1 -0
- package/package.json +1 -1
package/dist/commands/video.js
CHANGED
|
@@ -7,7 +7,9 @@ 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
9
|
import { HELPER_LEDGER_BASE, SET_OPTION_LEDGER_BASE } from "../lib/helperLedgerBase.js";
|
|
10
|
+
import { customerAnswer, customerRunPage, customerStageIsWorking, customerStageLines, customerStatusLines, customerStoryboardLines, customerVoicesLines, } from "./video-customer.js";
|
|
10
11
|
const CONCEIT_KEYS = ["podcast", "ugc", "stage", "street", "personification"];
|
|
12
|
+
const VOICE_MODES = ["native", "voice-first"];
|
|
11
13
|
const STYLE_SLUGS = [
|
|
12
14
|
"pixar-3d",
|
|
13
15
|
"claymation-stop-motion",
|
|
@@ -20,11 +22,15 @@ function joinedOr(items) {
|
|
|
20
22
|
return items[0] ?? "";
|
|
21
23
|
return `${items.slice(0, -1).join(", ")} or ${items[items.length - 1]}`;
|
|
22
24
|
}
|
|
25
|
+
function cantSayNames(names) {
|
|
26
|
+
return `the recorded voice can't say ${joinedOr(names.map((n) => `"${n}"`))}`;
|
|
27
|
+
}
|
|
23
28
|
export const helpText = `
|
|
24
29
|
exodus video — make a video ad from a saved workflow, pull every piece, upload your cut
|
|
25
30
|
|
|
26
31
|
The dashboard makes the PIECES of an ad: the storyboard, one picture per scene,
|
|
27
|
-
one voice track per scene, one video clip per scene,
|
|
32
|
+
one voice track per scene, one video clip per scene, and a music bed if you
|
|
33
|
+
asked for one with --music. It does
|
|
28
34
|
not make the finished ad. You pull the pieces to a folder, cut them together
|
|
29
35
|
with whatever editing tools you like, and upload the cut back.
|
|
30
36
|
|
|
@@ -68,7 +74,9 @@ The whole loop, in order:
|
|
|
68
74
|
|
|
69
75
|
exodus video retry-clip <runId> --scene <n>
|
|
70
76
|
Redo ONE finished clip while the run waits for the cut. Everything
|
|
71
|
-
else stays. A --note steers the motion, never the words.
|
|
77
|
+
else stays. A --note steers the motion, never the words. Add
|
|
78
|
+
--voice-first when the words came out wrong: the line is recorded in
|
|
79
|
+
the character's own voice first, then the clip is made to match it.
|
|
72
80
|
|
|
73
81
|
exodus video revoice <runId> --all
|
|
74
82
|
Redo only the VOICE on clips that kept the video model's own voice
|
|
@@ -79,7 +87,8 @@ The whole loop, in order:
|
|
|
79
87
|
9. Make your cut from those files.
|
|
80
88
|
|
|
81
89
|
10. exodus video upload <runId> --file cut.mp4
|
|
82
|
-
Attaches your cut to the run and prints the page to
|
|
90
|
+
Attaches your cut to the run and prints the page to watch it on. On a
|
|
91
|
+
One-shot run the upload delivers the ad, so step 11 is not needed.
|
|
83
92
|
|
|
84
93
|
11. exodus video approve <runId>
|
|
85
94
|
Or click Approve on the page from step 10. If you redid a clip after
|
|
@@ -90,13 +99,14 @@ Usage:
|
|
|
90
99
|
exodus video status <runId> [--rejected-draft] [--json]
|
|
91
100
|
exodus video storyboard <runId> [--json]
|
|
92
101
|
exodus video approve <runId> [--approve-stale-cut] [--json]
|
|
93
|
-
exodus video retry-frame <runId> --node <nodeId> --scene <n> [--note "..."] [--json]
|
|
94
|
-
exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "..."] [--json]
|
|
102
|
+
exodus video retry-frame <runId> [--node <nodeId>] --scene <n> [--note "..."] [--json]
|
|
103
|
+
exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "..."] [--voice-first] [--json]
|
|
95
104
|
exodus video revoice <runId> (--scene <n> | --all) [--node <nodeId>] [--json]
|
|
96
105
|
exodus video voices <runId> [--set <character>=<voiceId>] [--clear <character>] [--from <file.json>] [--json]
|
|
97
106
|
exodus video pull <runId> --out <dir> [--json]
|
|
98
107
|
exodus video upload <runId> --file <cut.mp4> [--duration <sec>] [--json]
|
|
99
|
-
exodus video start --script <file> --conceit <${CONCEIT_KEYS.join("|")}> --style <${STYLE_SLUGS.join("|")}> [--direction "<note>"] [--voice-path <path>] [--
|
|
108
|
+
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] [--director] [--wait] [--json]
|
|
109
|
+
exodus video director <runId> <on|off|follow> [--json]
|
|
100
110
|
|
|
101
111
|
Options:
|
|
102
112
|
--out <dir> Folder to write the pulled pieces into (pull)
|
|
@@ -104,13 +114,17 @@ Options:
|
|
|
104
114
|
--duration <sec> How long your cut is, in seconds. Only needed when the
|
|
105
115
|
length can't be read off the file itself
|
|
106
116
|
--note "<text>" How to steer one redo (retry-frame, retry-clip)
|
|
107
|
-
--node <nodeId> Which scene-frames node holds the still (retry-frame
|
|
117
|
+
--node <nodeId> Which scene-frames node holds the still (retry-frame;
|
|
118
|
+
admins pass it, beta testers leave it off).
|
|
108
119
|
Which video step holds the clip, when a scene has one on
|
|
109
120
|
more than one step (retry-clip, revoice)
|
|
110
121
|
--scene <n> Which scene to redo. One scene only, a whole number
|
|
111
122
|
(retry-frame, retry-clip, revoice)
|
|
112
123
|
--all Every finished clip that kept the video model's voice
|
|
113
124
|
(revoice)
|
|
125
|
+
--voice-first Record the scene's line in the character's own voice,
|
|
126
|
+
then make the clip to match it (retry-clip). For a clip
|
|
127
|
+
whose words came out wrong
|
|
114
128
|
--approve-stale-cut Deliver the cut you uploaded even though a clip changed
|
|
115
129
|
after you uploaded it (approve). Without it, approving
|
|
116
130
|
stops and names the scenes that changed
|
|
@@ -127,13 +141,36 @@ Options:
|
|
|
127
141
|
--style <slug> Look: ${joinedOr(STYLE_SLUGS)} (start)
|
|
128
142
|
--direction "<note>" Note for the whole ad (start)
|
|
129
143
|
--voice-path <path> How the voices are made (start)
|
|
130
|
-
--
|
|
131
|
-
|
|
144
|
+
--video-model <id> Which video model films this ad, instead of the kind of
|
|
145
|
+
ad's own default (start, with --conceit)
|
|
146
|
+
--voice <mode> voice-first (the default): the lines are recorded
|
|
147
|
+
first. native: the video model speaks the lines
|
|
148
|
+
(start, with --conceit)
|
|
149
|
+
--music Put a music bed under the ad (start). An ad has no music
|
|
150
|
+
unless you ask for it here
|
|
151
|
+
--no-music Leave the music bed off (start), which it already is
|
|
152
|
+
--review-storyboard Stop at the storyboard so you can approve it yourself
|
|
153
|
+
(start, with --conceit). By default the app approves its
|
|
154
|
+
own storyboard when its checks pass
|
|
155
|
+
--director Turn the trial director on for this ad (start, with
|
|
156
|
+
--conceit). Admins only. To switch it on or off later:
|
|
157
|
+
exodus video director <runId> on|off|follow, where
|
|
158
|
+
follow goes back to the brand's own setting
|
|
159
|
+
--wait Wait until the run needs you (start): at the
|
|
160
|
+
storyboard, or once every piece is made
|
|
132
161
|
--json Machine-readable output
|
|
133
162
|
--help, -h Print this help
|
|
134
163
|
|
|
135
|
-
|
|
136
|
-
|
|
164
|
+
Who can use what. Admins get every command here. Beta testers with video
|
|
165
|
+
switched on get these, on their own brands: start (--script, --conceit,
|
|
166
|
+
--style, --direction, --music, --review-storyboard), status, storyboard,
|
|
167
|
+
approve (the storyboard), voices, retry-frame --scene <n> (redo one scene's
|
|
168
|
+
picture while the storyboard waits), pull (the finished video, each scene's
|
|
169
|
+
clip and voice, and the music) and upload. These stay admin-only:
|
|
170
|
+
start --video-model / --voice / --voice-path / --director, director,
|
|
171
|
+
status --rejected-draft, retry-clip and revoice.
|
|
172
|
+
If a command answers "video isn't enabled for this key", the run id is wrong,
|
|
173
|
+
the command is admin-only, or video isn't switched on for your dashboard user.
|
|
137
174
|
|
|
138
175
|
Examples:
|
|
139
176
|
exodus workflow list
|
|
@@ -143,18 +180,21 @@ Examples:
|
|
|
143
180
|
exodus video approve run_123
|
|
144
181
|
exodus video retry-frame run_123 --node frames-1 --scene 2
|
|
145
182
|
exodus video retry-clip run_123 --scene 3 --note "keep the handshake in frame"
|
|
183
|
+
exodus video retry-clip run_123 --scene 3 --voice-first
|
|
146
184
|
exodus video revoice run_123 --all
|
|
147
185
|
exodus video voices run_123
|
|
148
186
|
exodus video voices run_123 --set C1=abc123voiceid --set "HOST 2=def456voiceid"
|
|
149
187
|
exodus video voices run_123 --from voices.json
|
|
150
188
|
exodus video pull run_123 --out ./ad-run_123
|
|
151
189
|
exodus video upload run_123 --file ./cut.mp4
|
|
190
|
+
exodus video director run_123 on
|
|
152
191
|
`.trim();
|
|
153
192
|
const SHOWS_PATH = "/api/v2/shows";
|
|
154
193
|
const RUNS_PATH = "/api/v2/video/runs";
|
|
155
194
|
const SCRIPT_RUNS_PATH = "/api/v2/video/script-runs";
|
|
156
|
-
const
|
|
157
|
-
const
|
|
195
|
+
const DIRECTOR_PATH = "/api/v2/video/director";
|
|
196
|
+
export const RUN_PATH = "/api/v2/workflow";
|
|
197
|
+
export const ITEMS_PATH = "/api/v2/workflow/items";
|
|
158
198
|
const REJECTED_DRAFT_PATH = "/api/v2/workflow/rejected-draft";
|
|
159
199
|
const REJECTED_DRAFT_NOT_ON_THIS_SERVER = "This Exodus server does not keep rejected drafts yet, so there is nothing to read. It arrives " +
|
|
160
200
|
"with the next server update.";
|
|
@@ -163,12 +203,15 @@ const FLAG_PATH = "/api/v2/video/storyboard/flag";
|
|
|
163
203
|
const APPROVE_PATH = "/api/v2/workflow/approve";
|
|
164
204
|
const SCENE_RETRY_PATH = "/api/v2/workflow/scene/retry";
|
|
165
205
|
const SCENE_REVOICE_PATH = "/api/v2/workflow/scene/revoice";
|
|
206
|
+
const SCENE_VOICE_FIRST_PATH = "/api/v2/workflow/scene/voice-first";
|
|
207
|
+
const VOICE_FIRST_NOT_ON_THIS_SERVER = "This Exodus server cannot redo a clip voice first yet, so nothing was started and nothing " +
|
|
208
|
+
"was spent. It arrives with the next server update.";
|
|
166
209
|
const REVOICE_NOT_ON_THIS_SERVER = "This Exodus server does not have the voice redo yet, so nothing was started and nothing " +
|
|
167
210
|
"was spent. It arrives with the next server update.";
|
|
168
|
-
const FINAL_PATH = "/api/v2/video/final";
|
|
211
|
+
export const FINAL_PATH = "/api/v2/video/final";
|
|
169
212
|
export const VOICES_PATH = "/api/v2/video/voices";
|
|
170
|
-
const ASSET_UPLOAD_URL_PATH = "/api/v2/workflows/asset-upload-url";
|
|
171
|
-
const ASSETS_PATH = "/api/v2/workflows/assets";
|
|
213
|
+
export const ASSET_UPLOAD_URL_PATH = "/api/v2/workflows/asset-upload-url";
|
|
214
|
+
export const ASSETS_PATH = "/api/v2/workflows/assets";
|
|
172
215
|
function withoutJudgeFields({ judgeDetail: _d, judgeSeverity: _s, ...rest }) {
|
|
173
216
|
return rest;
|
|
174
217
|
}
|
|
@@ -177,7 +220,7 @@ export function qcWithoutJudgeWording(qc) {
|
|
|
177
220
|
return qc;
|
|
178
221
|
return {
|
|
179
222
|
...qc,
|
|
180
|
-
takes: qc.takes.map(({ judgeWording: _w, ...rest }) => rest),
|
|
223
|
+
takes: qc.takes.map(({ judgeWording: _w, heardText: _h, ...rest }) => rest),
|
|
181
224
|
};
|
|
182
225
|
}
|
|
183
226
|
export const NO_TAKE_HISTORY_LINE = "no history recorded for this clip";
|
|
@@ -206,6 +249,9 @@ export function renderQcTakeHistory(takes) {
|
|
|
206
249
|
});
|
|
207
250
|
return lines;
|
|
208
251
|
}
|
|
252
|
+
function redoLine(lastRedo) {
|
|
253
|
+
return lastRedo.reason ? `${lastRedo.label}: ${lastRedo.reason}` : lastRedo.label;
|
|
254
|
+
}
|
|
209
255
|
function printDisplacedHistory(lines, lastRedo) {
|
|
210
256
|
const history = lastRedo.displacedHistory;
|
|
211
257
|
if (!history)
|
|
@@ -270,7 +316,9 @@ export const defaultDeps = {
|
|
|
270
316
|
dashboardUrl: getDashboardUrl(),
|
|
271
317
|
};
|
|
272
318
|
export const VIDEO_NOT_FOUND_MESSAGE = "video isn't enabled for this key\n" +
|
|
273
|
-
"
|
|
319
|
+
"One of three things: that run id doesn't exist on this brand, this command is for admins only " +
|
|
320
|
+
"(director, status --rejected-draft, retry-clip, revoice), or video isn't " +
|
|
321
|
+
"switched on for your dashboard user on this brand. Check the id first, then ask Brad.";
|
|
274
322
|
export function videoApiError(res) {
|
|
275
323
|
const behind = missingRouteLine(res, "exodus video");
|
|
276
324
|
if (behind)
|
|
@@ -279,6 +327,9 @@ export function videoApiError(res) {
|
|
|
279
327
|
return VIDEO_NOT_FOUND_MESSAGE;
|
|
280
328
|
return formatApiError(res);
|
|
281
329
|
}
|
|
330
|
+
function adminOnlyResult(line, json) {
|
|
331
|
+
return { code: 1, lines: json ? [JSON.stringify({ ok: false, error: line })] : [line] };
|
|
332
|
+
}
|
|
282
333
|
function errorResult(res, json) {
|
|
283
334
|
return {
|
|
284
335
|
code: 1,
|
|
@@ -301,6 +352,14 @@ function parkedAtFinalWatch(run) {
|
|
|
301
352
|
return (!parkedByBuilderCheckpoint(run) &&
|
|
302
353
|
run.nodes.some((n) => n.kind === "video" && n.status === "done"));
|
|
303
354
|
}
|
|
355
|
+
function heldReason(run) {
|
|
356
|
+
const verdict = run.storyboardAutoApproval;
|
|
357
|
+
if (verdict?.outcome !== "held")
|
|
358
|
+
return undefined;
|
|
359
|
+
const reason = verdict.reason?.trim() ||
|
|
360
|
+
"The app didn't approve this storyboard by itself because its checks found a problem.";
|
|
361
|
+
return `${reason} (checked ${new Date(verdict.at).toISOString()})`;
|
|
362
|
+
}
|
|
304
363
|
export function classifyRun(run) {
|
|
305
364
|
if (run.status === "failed")
|
|
306
365
|
return { at: "failed", error: run.error };
|
|
@@ -320,6 +379,7 @@ export function classifyRun(run) {
|
|
|
320
379
|
}
|
|
321
380
|
const pausedNode = run.nodes.find((n) => n.nodeId === run.pausedNodeId);
|
|
322
381
|
if (pausedNode && parkedAtStoryboardGate(run, pausedNode)) {
|
|
382
|
+
const held = heldReason(run);
|
|
323
383
|
return {
|
|
324
384
|
at: "storyboard-gate",
|
|
325
385
|
nodeId: pausedNode.nodeId,
|
|
@@ -327,10 +387,31 @@ export function classifyRun(run) {
|
|
|
327
387
|
? { framesNodeId: pausedNode.nodeId }
|
|
328
388
|
: {}),
|
|
329
389
|
...(isShowAd(run) ? { showAd: true } : {}),
|
|
390
|
+
...(held ? { heldReason: held } : {}),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
if (parkedAtFinalWatch(run)) {
|
|
394
|
+
const videoNodes = run.nodes.filter((n) => n.kind === "video");
|
|
395
|
+
const autoRedoScenes = videoNodes.flatMap((n) => n.autoRedoScenes ?? []);
|
|
396
|
+
const autoRedoReasons = videoNodes.flatMap((n) => n.autoRedoReasons ?? []);
|
|
397
|
+
if (autoRedoScenes.length > 0) {
|
|
398
|
+
return {
|
|
399
|
+
at: "running",
|
|
400
|
+
stage: "video",
|
|
401
|
+
autoRedoScenes,
|
|
402
|
+
...(autoRedoReasons.length > 0 ? { autoRedoReasons } : {}),
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
const missingScenes = run.nodes
|
|
406
|
+
.filter((n) => n.kind === "video")
|
|
407
|
+
.flatMap((n) => n.missingScenes ?? []);
|
|
408
|
+
const framesNodeId = run.nodes.find((n) => n.kind === "scene-frames")?.nodeId;
|
|
409
|
+
return {
|
|
410
|
+
at: "final-watch",
|
|
411
|
+
...(missingScenes.length > 0 ? { missingScenes } : {}),
|
|
412
|
+
...(missingScenes.length > 0 && framesNodeId ? { framesNodeId } : {}),
|
|
330
413
|
};
|
|
331
414
|
}
|
|
332
|
-
if (parkedAtFinalWatch(run))
|
|
333
|
-
return { at: "final-watch" };
|
|
334
415
|
return { at: "paused", nodeId: run.pausedNodeId, reason: run.pauseReason };
|
|
335
416
|
}
|
|
336
417
|
const active = run.nodes.find((n) => n.status === "running");
|
|
@@ -339,10 +420,22 @@ export function classifyRun(run) {
|
|
|
339
420
|
export function hasAttachedCut(items) {
|
|
340
421
|
return items.some((i) => i.itemKind === "final" && i.status === "done");
|
|
341
422
|
}
|
|
423
|
+
export function scenesBeingRedone(run, items) {
|
|
424
|
+
const scenes = new Set(run.nodes.filter((n) => n.kind === "video").flatMap((n) => n.autoRedoScenes ?? []));
|
|
425
|
+
for (const i of items) {
|
|
426
|
+
if (i.itemKind === "clip" &&
|
|
427
|
+
i.status === "running" &&
|
|
428
|
+
(i.attempt ?? 1) > 1 &&
|
|
429
|
+
i.staleClaim !== true) {
|
|
430
|
+
scenes.add(i.sceneIndex);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return [...scenes].sort((a, b) => a - b);
|
|
434
|
+
}
|
|
342
435
|
export function resolveStop(stop, cutAttached) {
|
|
343
436
|
if (stop.at !== "final-watch")
|
|
344
437
|
return stop;
|
|
345
|
-
return {
|
|
438
|
+
return { ...stop, cutAttached };
|
|
346
439
|
}
|
|
347
440
|
export async function resolveStopAtPark(stop, runId, deps) {
|
|
348
441
|
if (stop.at !== "final-watch")
|
|
@@ -381,15 +474,38 @@ export function isShowAd(run) {
|
|
|
381
474
|
return run.moduleOwned === true;
|
|
382
475
|
}
|
|
383
476
|
export function reviewUrl(dashboardUrl, run) {
|
|
384
|
-
if (isShowAd(run))
|
|
385
|
-
return `${dashboardUrl}/video?ad=${run._id}`;
|
|
386
477
|
if (run.workflowId)
|
|
387
478
|
return `${dashboardUrl}/workflows/${run.workflowId}/runs/${run._id}`;
|
|
388
479
|
return `${dashboardUrl}/runs/${run._id}`;
|
|
389
480
|
}
|
|
481
|
+
function scenesPhrase(scenes) {
|
|
482
|
+
if (scenes.length === 1)
|
|
483
|
+
return `scene ${scenes[0]}`;
|
|
484
|
+
return `scenes ${scenes.slice(0, -1).join(", ")} and ${scenes[scenes.length - 1]}`;
|
|
485
|
+
}
|
|
486
|
+
function missingSceneLines(missing, framesNodeId, runId, runUrl) {
|
|
487
|
+
const scenes = missing.map((m) => m.sceneIndex);
|
|
488
|
+
const lines = [
|
|
489
|
+
`Parked: ${scenesPhrase(scenes)} ${scenes.length === 1 ? "has" : "have"} no clip yet, so the ad isn't complete.`,
|
|
490
|
+
];
|
|
491
|
+
for (const { sceneIndex, pictureRefused } of missing) {
|
|
492
|
+
if (!pictureRefused) {
|
|
493
|
+
lines.push(`Redo clip ${sceneIndex}: exodus video retry-clip ${runId} --scene ${sceneIndex}`);
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
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.`);
|
|
497
|
+
lines.push(framesNodeId
|
|
498
|
+
? `Redraw frame ${sceneIndex}: exodus video retry-frame ${runId} --node ${framesNodeId} --scene ${sceneIndex} --note "<what to change>"`
|
|
499
|
+
: `Redraw frame ${sceneIndex}: on the run page, ${runUrl}`);
|
|
500
|
+
lines.push(`Then redo clip ${sceneIndex}: exodus video retry-clip ${runId} --scene ${sceneIndex}`);
|
|
501
|
+
}
|
|
502
|
+
lines.push("The ad can't be approved until every scene has a clip.", `Watch it here: ${runUrl}`);
|
|
503
|
+
return lines;
|
|
504
|
+
}
|
|
390
505
|
export function stopLines(stop, runId, runUrl) {
|
|
391
506
|
if (stop.at === "storyboard-gate") {
|
|
392
507
|
const lines = [
|
|
508
|
+
...(stop.heldReason ? [stop.heldReason] : []),
|
|
393
509
|
"Parked: the storyboard is waiting for your yes.",
|
|
394
510
|
`Read it: exodus video storyboard ${runId}`,
|
|
395
511
|
`Approve it: exodus video approve ${runId}`,
|
|
@@ -403,6 +519,9 @@ export function stopLines(stop, runId, runUrl) {
|
|
|
403
519
|
return lines;
|
|
404
520
|
}
|
|
405
521
|
if (stop.at === "final-watch") {
|
|
522
|
+
if (stop.missingScenes?.length) {
|
|
523
|
+
return missingSceneLines(stop.missingScenes, stop.framesNodeId, runId, runUrl);
|
|
524
|
+
}
|
|
406
525
|
if (stop.cutAttached === null) {
|
|
407
526
|
return [
|
|
408
527
|
"Parked: every piece is made.",
|
|
@@ -458,8 +577,28 @@ export function stopLines(stop, runId, runUrl) {
|
|
|
458
577
|
`Open it: ${runUrl}`,
|
|
459
578
|
];
|
|
460
579
|
}
|
|
580
|
+
if (stop.autoRedoScenes?.length)
|
|
581
|
+
return [autoRedoLine(stop.autoRedoScenes, stop.autoRedoReasons)];
|
|
461
582
|
return [`Working: ${stageWord(stop.stage)}.`];
|
|
462
583
|
}
|
|
584
|
+
function autoRedoLine(scenes, reasons = []) {
|
|
585
|
+
const reasonOf = new Map(reasons.map((r) => [r.sceneIndex, r.reason]));
|
|
586
|
+
const groups = new Map();
|
|
587
|
+
for (const scene of [...scenes].sort((a, b) => a - b)) {
|
|
588
|
+
const reason = reasonOf.get(scene);
|
|
589
|
+
groups.set(reason, [...(groups.get(reason) ?? []), scene]);
|
|
590
|
+
}
|
|
591
|
+
const one = scenes.length === 1;
|
|
592
|
+
const clauses = [...groups].map(([reason, group], i) => {
|
|
593
|
+
const own = i === 0 ? ` on ${group.length === 1 ? "its" : "their"} own` : "";
|
|
594
|
+
return `${scenesPhrase(group)}${own}${reason ? ` because ${reason}` : ""}`;
|
|
595
|
+
});
|
|
596
|
+
const listed = clauses.length === 1
|
|
597
|
+
? clauses[0]
|
|
598
|
+
: `${clauses.slice(0, -1).join(", ")}, and ${clauses[clauses.length - 1]}`;
|
|
599
|
+
return (`Working: the app is redoing ${listed}. ` +
|
|
600
|
+
`The ad isn't ready to watch until ${one ? "it lands" : "they land"}.`);
|
|
601
|
+
}
|
|
463
602
|
export function failedStoryboardNode(run) {
|
|
464
603
|
return run.nodes.find((n) => n.kind === "storyboard" && n.status === "failed");
|
|
465
604
|
}
|
|
@@ -484,7 +623,13 @@ export function planFailureLines(node, runId) {
|
|
|
484
623
|
}
|
|
485
624
|
return lines;
|
|
486
625
|
}
|
|
626
|
+
export const MUSIC_HEARD_CODE = "music-heard";
|
|
487
627
|
export const CAST_LEDGER_BASE = HELPER_LEDGER_BASE + 10000;
|
|
628
|
+
export function musicBedState(run) {
|
|
629
|
+
if (run.musicBed === undefined)
|
|
630
|
+
return "unknown";
|
|
631
|
+
return run.musicBed ? "on" : "off";
|
|
632
|
+
}
|
|
488
633
|
function isPullSceneIndex(sceneIndex) {
|
|
489
634
|
return sceneIndex >= 0 && sceneIndex < HELPER_LEDGER_BASE;
|
|
490
635
|
}
|
|
@@ -526,6 +671,10 @@ function clipFromArtifact(sceneIndex, artifact) {
|
|
|
526
671
|
revoiced: artifact.revoiced === true,
|
|
527
672
|
speechTrimmed: artifact.speechTrimmed === true,
|
|
528
673
|
rawStorageId: artifact.rawStorageId ?? null,
|
|
674
|
+
voiceMode: artifact.voiceMode ?? null,
|
|
675
|
+
voiceFirstBlockedBy: artifact.voiceFirstBlockedBy ?? null,
|
|
676
|
+
voicePitchHz: artifact.voicePitchHz ?? null,
|
|
677
|
+
voicePitchMethod: artifact.voicePitchMethod ?? null,
|
|
529
678
|
};
|
|
530
679
|
}
|
|
531
680
|
function keyframeDownload(sceneIndex, imageUrl) {
|
|
@@ -677,14 +826,36 @@ export function planPull(run, items, opts) {
|
|
|
677
826
|
}
|
|
678
827
|
}
|
|
679
828
|
let reference = null;
|
|
829
|
+
let productSheet = null;
|
|
830
|
+
let setPlate = null;
|
|
831
|
+
const filledByStage = new Map();
|
|
680
832
|
for (const artifact of outputsOfNodeKind(run, "reference")) {
|
|
681
833
|
if (artifact.type !== "image" || !artifact.imageUrl)
|
|
682
834
|
continue;
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
835
|
+
if (artifact.anchor === "filled-plate") {
|
|
836
|
+
const holds = artifact.holds ?? [];
|
|
837
|
+
const lid = artifact.lid;
|
|
838
|
+
filledByStage.set(JSON.stringify([holds, lid ?? null]), { holds, url: artifact.imageUrl, ...(lid ? { lid } : {}) });
|
|
839
|
+
continue;
|
|
840
|
+
}
|
|
841
|
+
const stem = artifact.anchor ?? "reference";
|
|
842
|
+
const download = { file: `${stem}.${extFor(artifact.imageUrl, "image")}`, url: artifact.imageUrl };
|
|
843
|
+
if (artifact.anchor === "product-sheet")
|
|
844
|
+
productSheet = download;
|
|
845
|
+
else if (artifact.anchor === "set-plate")
|
|
846
|
+
setPlate = download;
|
|
847
|
+
else if (!artifact.anchor)
|
|
848
|
+
reference = download;
|
|
849
|
+
}
|
|
850
|
+
const stageKeys = [...new Set([...filledByStage.values()].map((plate) => JSON.stringify(plate.holds)))];
|
|
851
|
+
const filledPlates = [...filledByStage.values()].map((plate) => ({
|
|
852
|
+
holds: plate.holds,
|
|
853
|
+
...(plate.lid ? { lid: plate.lid } : {}),
|
|
854
|
+
download: {
|
|
855
|
+
file: `filled-plate-${stageKeys.indexOf(JSON.stringify(plate.holds)) + 1}${plate.lid ? "-lid-closed" : ""}.${extFor(plate.url, "image")}`,
|
|
856
|
+
url: plate.url,
|
|
857
|
+
},
|
|
858
|
+
}));
|
|
688
859
|
const keyframeByScene = new Map();
|
|
689
860
|
for (const artifact of run.nodes.flatMap((n) => n.outputs ?? [])) {
|
|
690
861
|
if (artifact.type !== "frames")
|
|
@@ -787,6 +958,9 @@ export function planPull(run, items, opts) {
|
|
|
787
958
|
continue;
|
|
788
959
|
keyframeByScene.set(sceneIndex, keyframeDownload(sceneIndex, artifact.imageUrl));
|
|
789
960
|
}
|
|
961
|
+
const pendingRedo = scenesBeingRedone(run, items);
|
|
962
|
+
for (const sceneIndex of pendingRedo)
|
|
963
|
+
clipByScene.delete(sceneIndex);
|
|
790
964
|
const sceneIndexes = [
|
|
791
965
|
...new Set([
|
|
792
966
|
...clipByScene.keys(),
|
|
@@ -835,6 +1009,10 @@ export function planPull(run, items, opts) {
|
|
|
835
1009
|
revoiced: clip ? clip.revoiced : null,
|
|
836
1010
|
speechTrimmed: clip ? clip.speechTrimmed : null,
|
|
837
1011
|
rawStorageId: clip ? clip.rawStorageId : null,
|
|
1012
|
+
voiceMode: clip ? clip.voiceMode : null,
|
|
1013
|
+
voiceFirstBlockedBy: clip ? clip.voiceFirstBlockedBy : null,
|
|
1014
|
+
voicePitchHz: clip ? clip.voicePitchHz : null,
|
|
1015
|
+
voicePitchMethod: clip ? clip.voicePitchMethod : null,
|
|
838
1016
|
clipStatus: item?.status ?? "missing",
|
|
839
1017
|
error: item?.error ?? null,
|
|
840
1018
|
flagged: item?.flagged === true,
|
|
@@ -845,6 +1023,9 @@ export function planPull(run, items, opts) {
|
|
|
845
1023
|
const { cast, downloads: castDownloads } = planCastRefs(run, items, castVoicePins(storyboardArtifact));
|
|
846
1024
|
const downloads = [
|
|
847
1025
|
...(reference ? [reference] : []),
|
|
1026
|
+
...(productSheet ? [productSheet] : []),
|
|
1027
|
+
...(setPlate ? [setPlate] : []),
|
|
1028
|
+
...filledPlates.map((p) => p.download),
|
|
848
1029
|
...keyframeByScene.values(),
|
|
849
1030
|
...[...voiceByScene.values()].map((v) => v.download),
|
|
850
1031
|
...(narrationDownload ? [narrationDownload] : []),
|
|
@@ -861,7 +1042,15 @@ export function planPull(run, items, opts) {
|
|
|
861
1042
|
dashboardUrl: reviewUrl(opts.dashboardUrl, run),
|
|
862
1043
|
storyboard,
|
|
863
1044
|
reference: reference?.file ?? null,
|
|
1045
|
+
productSheet: productSheet?.file ?? null,
|
|
1046
|
+
setPlate: setPlate?.file ?? null,
|
|
1047
|
+
filledPlates: filledPlates.map((p) => ({ file: p.download.file, holds: p.holds, ...(p.lid ? { lid: p.lid } : {}) })),
|
|
864
1048
|
music: music?.file ?? null,
|
|
1049
|
+
musicBed: musicBedState(run),
|
|
1050
|
+
musicHeardScenes: scenes
|
|
1051
|
+
.filter((scene) => scene.findings.some((f) => f.code === MUSIC_HEARD_CODE))
|
|
1052
|
+
.map((scene) => scene.sceneIndex),
|
|
1053
|
+
pendingRedo,
|
|
865
1054
|
cast,
|
|
866
1055
|
narration: narrationDownload
|
|
867
1056
|
? { file: narrationDownload.file, timing: "narration.json" }
|
|
@@ -871,6 +1060,14 @@ export function planPull(run, items, opts) {
|
|
|
871
1060
|
...(run.provenance?.format || run.provenance?.voice
|
|
872
1061
|
? { provenance: run.provenance }
|
|
873
1062
|
: {}),
|
|
1063
|
+
...(run.videoChoice
|
|
1064
|
+
? {
|
|
1065
|
+
videoChoice: {
|
|
1066
|
+
videoModel: run.videoChoice.videoModel,
|
|
1067
|
+
voiceMode: run.videoChoice.voiceMode,
|
|
1068
|
+
},
|
|
1069
|
+
}
|
|
1070
|
+
: {}),
|
|
874
1071
|
},
|
|
875
1072
|
};
|
|
876
1073
|
}
|
|
@@ -880,6 +1077,14 @@ export function markPullFailure(manifest, failure) {
|
|
|
880
1077
|
manifest.storyboard = null;
|
|
881
1078
|
if (manifest.reference === failure.file)
|
|
882
1079
|
manifest.reference = null;
|
|
1080
|
+
if (manifest.productSheet === failure.file)
|
|
1081
|
+
manifest.productSheet = null;
|
|
1082
|
+
if (manifest.setPlate === failure.file)
|
|
1083
|
+
manifest.setPlate = null;
|
|
1084
|
+
for (const plate of manifest.filledPlates) {
|
|
1085
|
+
if (plate.file === failure.file)
|
|
1086
|
+
plate.file = null;
|
|
1087
|
+
}
|
|
883
1088
|
if (manifest.music === failure.file)
|
|
884
1089
|
manifest.music = null;
|
|
885
1090
|
for (const ref of manifest.cast) {
|
|
@@ -896,6 +1101,10 @@ export function markPullFailure(manifest, failure) {
|
|
|
896
1101
|
scene.revoiced = null;
|
|
897
1102
|
scene.speechTrimmed = null;
|
|
898
1103
|
scene.rawStorageId = null;
|
|
1104
|
+
scene.voiceMode = null;
|
|
1105
|
+
scene.voiceFirstBlockedBy = null;
|
|
1106
|
+
scene.voicePitchHz = null;
|
|
1107
|
+
scene.voicePitchMethod = null;
|
|
899
1108
|
}
|
|
900
1109
|
if (scene.voice === failure.file)
|
|
901
1110
|
scene.voice = null;
|
|
@@ -1001,7 +1210,7 @@ export async function startFlow(opts, deps) {
|
|
|
1001
1210
|
showId: opts.showId,
|
|
1002
1211
|
script,
|
|
1003
1212
|
...(opts.voicePath ? { voicePath: opts.voicePath } : {}),
|
|
1004
|
-
...(opts.music ===
|
|
1213
|
+
...(opts.music === undefined ? {} : { music: opts.music }),
|
|
1005
1214
|
});
|
|
1006
1215
|
if (!res.ok)
|
|
1007
1216
|
return errorResult(res, opts.json);
|
|
@@ -1035,10 +1244,51 @@ function nonBlank(value) {
|
|
|
1035
1244
|
return undefined;
|
|
1036
1245
|
return value;
|
|
1037
1246
|
}
|
|
1247
|
+
function isVoiceMode(value) {
|
|
1248
|
+
return VOICE_MODES.some((mode) => mode === value);
|
|
1249
|
+
}
|
|
1038
1250
|
function isConceit(value) {
|
|
1039
1251
|
return CONCEIT_KEYS.some((key) => key === value);
|
|
1040
1252
|
}
|
|
1041
|
-
export function
|
|
1253
|
+
export function planMusicChoice(occurrences) {
|
|
1254
|
+
let music;
|
|
1255
|
+
for (const { flag, value } of occurrences) {
|
|
1256
|
+
if (flag === "no-music") {
|
|
1257
|
+
music = false;
|
|
1258
|
+
continue;
|
|
1259
|
+
}
|
|
1260
|
+
if (flag !== "music")
|
|
1261
|
+
continue;
|
|
1262
|
+
if (value === undefined || value.startsWith("--")) {
|
|
1263
|
+
music = true;
|
|
1264
|
+
continue;
|
|
1265
|
+
}
|
|
1266
|
+
const word = value.trim().toLowerCase();
|
|
1267
|
+
const literal = word === "true" ? true : word === "false" ? false : undefined;
|
|
1268
|
+
if (literal === undefined) {
|
|
1269
|
+
return {
|
|
1270
|
+
ok: false,
|
|
1271
|
+
line: `video start --music takes true or false, or no value at all (got "${value}").`,
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
music = literal;
|
|
1275
|
+
}
|
|
1276
|
+
return { ok: true, music };
|
|
1277
|
+
}
|
|
1278
|
+
function planValueFlag(occurrences, name) {
|
|
1279
|
+
let value;
|
|
1280
|
+
for (const occurrence of occurrences) {
|
|
1281
|
+
if (occurrence.flag !== name)
|
|
1282
|
+
continue;
|
|
1283
|
+
const given = occurrence.value;
|
|
1284
|
+
if (given === undefined || given.startsWith("--") || given.trim().length === 0) {
|
|
1285
|
+
return { ok: false, line: `video start --${name} needs a value.` };
|
|
1286
|
+
}
|
|
1287
|
+
value = given.trim();
|
|
1288
|
+
}
|
|
1289
|
+
return { ok: true, value };
|
|
1290
|
+
}
|
|
1291
|
+
export function planVideoStart(flags, occurrences) {
|
|
1042
1292
|
const showId = nonBlank(flagString(flags, "show"));
|
|
1043
1293
|
const scriptFile = nonBlank(flagString(flags, "script"));
|
|
1044
1294
|
const conceit = nonBlank(flagString(flags, "conceit"));
|
|
@@ -1055,6 +1305,50 @@ export function planVideoStart(flags) {
|
|
|
1055
1305
|
line: "video start needs --script <file>, a text file of what the ad says.",
|
|
1056
1306
|
};
|
|
1057
1307
|
}
|
|
1308
|
+
const musicChoice = planMusicChoice(occurrences);
|
|
1309
|
+
if (!musicChoice.ok)
|
|
1310
|
+
return { kind: "usage", line: musicChoice.line };
|
|
1311
|
+
const videoModelFlag = planValueFlag(occurrences, "video-model");
|
|
1312
|
+
if (!videoModelFlag.ok)
|
|
1313
|
+
return { kind: "usage", line: videoModelFlag.line };
|
|
1314
|
+
const voiceFlag = planValueFlag(occurrences, "voice");
|
|
1315
|
+
if (!voiceFlag.ok)
|
|
1316
|
+
return { kind: "usage", line: voiceFlag.line };
|
|
1317
|
+
const videoModel = videoModelFlag.value;
|
|
1318
|
+
const voiceMode = voiceFlag.value;
|
|
1319
|
+
const reviewStoryboard = occurrences.some((o) => o.flag === "review-storyboard");
|
|
1320
|
+
if (showId && reviewStoryboard) {
|
|
1321
|
+
return {
|
|
1322
|
+
kind: "usage",
|
|
1323
|
+
line: "--review-storyboard works with --conceit, not --show. A Show's ad always stops at the storyboard for you.",
|
|
1324
|
+
};
|
|
1325
|
+
}
|
|
1326
|
+
const directorFlags = occurrences.filter((o) => o.flag === "director");
|
|
1327
|
+
if (directorFlags.some((o) => o.value !== undefined && !o.value.startsWith("--"))) {
|
|
1328
|
+
return {
|
|
1329
|
+
kind: "usage",
|
|
1330
|
+
line: "--director takes no value. Leave it off to start without the director.",
|
|
1331
|
+
};
|
|
1332
|
+
}
|
|
1333
|
+
const director = directorFlags.length > 0;
|
|
1334
|
+
if (showId && director) {
|
|
1335
|
+
return {
|
|
1336
|
+
kind: "usage",
|
|
1337
|
+
line: "--director works with --conceit, not --show.",
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
1340
|
+
if (showId && (videoModel || voiceMode)) {
|
|
1341
|
+
return {
|
|
1342
|
+
kind: "usage",
|
|
1343
|
+
line: "--video-model and --voice work with --conceit, not --show. A Show films on its own model.",
|
|
1344
|
+
};
|
|
1345
|
+
}
|
|
1346
|
+
if (voiceMode && !isVoiceMode(voiceMode)) {
|
|
1347
|
+
return {
|
|
1348
|
+
kind: "usage",
|
|
1349
|
+
line: `video start --voice must be native or voice-first (got "${voiceMode}").`,
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1058
1352
|
if (showId) {
|
|
1059
1353
|
return {
|
|
1060
1354
|
kind: "show",
|
|
@@ -1062,7 +1356,7 @@ export function planVideoStart(flags) {
|
|
|
1062
1356
|
showId,
|
|
1063
1357
|
scriptFile,
|
|
1064
1358
|
voicePath: flagString(flags, "voice-path"),
|
|
1065
|
-
music:
|
|
1359
|
+
music: musicChoice.music,
|
|
1066
1360
|
wait: flags["wait"] === true,
|
|
1067
1361
|
json: flags["json"] === true,
|
|
1068
1362
|
},
|
|
@@ -1099,8 +1393,16 @@ export function planVideoStart(flags) {
|
|
|
1099
1393
|
opts.direction = direction;
|
|
1100
1394
|
if (voicePath)
|
|
1101
1395
|
opts.voicePath = voicePath;
|
|
1102
|
-
if (
|
|
1103
|
-
opts.
|
|
1396
|
+
if (videoModel)
|
|
1397
|
+
opts.videoModel = videoModel;
|
|
1398
|
+
if (voiceMode && isVoiceMode(voiceMode))
|
|
1399
|
+
opts.voiceMode = voiceMode;
|
|
1400
|
+
if (musicChoice.music !== undefined)
|
|
1401
|
+
opts.music = musicChoice.music;
|
|
1402
|
+
if (reviewStoryboard)
|
|
1403
|
+
opts.reviewStoryboard = true;
|
|
1404
|
+
if (director)
|
|
1405
|
+
opts.director = true;
|
|
1104
1406
|
return { kind: "script", opts };
|
|
1105
1407
|
}
|
|
1106
1408
|
function isRecord(value) {
|
|
@@ -1123,7 +1425,14 @@ function isResolvedCard(value) {
|
|
|
1123
1425
|
return false;
|
|
1124
1426
|
return true;
|
|
1125
1427
|
}
|
|
1126
|
-
function
|
|
1428
|
+
function startedVideoChoice(value) {
|
|
1429
|
+
if (!isRecord(value))
|
|
1430
|
+
return null;
|
|
1431
|
+
if (typeof value.videoModel !== "string" || typeof value.voiceMode !== "string")
|
|
1432
|
+
return null;
|
|
1433
|
+
return { videoModel: value.videoModel, voiceMode: value.voiceMode };
|
|
1434
|
+
}
|
|
1435
|
+
function scriptStartedLines(runId, url, card, video, director) {
|
|
1127
1436
|
return [
|
|
1128
1437
|
`Started ad run ${runId}`,
|
|
1129
1438
|
...(card
|
|
@@ -1134,10 +1443,12 @@ function scriptStartedLines(runId, url, card) {
|
|
|
1134
1443
|
`Style: ${card.style.label}`,
|
|
1135
1444
|
]
|
|
1136
1445
|
: ["The server did not include a resolved card."]),
|
|
1446
|
+
...(video ? [`Video model: ${video.videoModel}`, `Voice: ${video.voiceMode}`] : []),
|
|
1447
|
+
...(director ? ["Director: on for this ad"] : []),
|
|
1137
1448
|
`Watch it: ${url}`,
|
|
1138
1449
|
];
|
|
1139
1450
|
}
|
|
1140
|
-
function waitJsonWithCard(waited, card) {
|
|
1451
|
+
function waitJsonWithCard(waited, card, video) {
|
|
1141
1452
|
const first = waited.lines[0];
|
|
1142
1453
|
if (typeof first !== "string")
|
|
1143
1454
|
return waited;
|
|
@@ -1150,7 +1461,10 @@ function waitJsonWithCard(waited, card) {
|
|
|
1150
1461
|
}
|
|
1151
1462
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
1152
1463
|
return waited;
|
|
1153
|
-
return {
|
|
1464
|
+
return {
|
|
1465
|
+
code: waited.code,
|
|
1466
|
+
lines: [JSON.stringify({ ...parsed, card, ...(video ? { videoChoice: video } : {}) })],
|
|
1467
|
+
};
|
|
1154
1468
|
}
|
|
1155
1469
|
export async function startScriptFlow(opts, deps) {
|
|
1156
1470
|
let script;
|
|
@@ -1174,10 +1488,17 @@ export async function startScriptFlow(opts, deps) {
|
|
|
1174
1488
|
style: opts.style,
|
|
1175
1489
|
...(opts.direction ? { direction: opts.direction } : {}),
|
|
1176
1490
|
...(opts.voicePath ? { voicePath: opts.voicePath } : {}),
|
|
1177
|
-
...(opts.
|
|
1491
|
+
...(opts.videoModel ? { videoModel: opts.videoModel } : {}),
|
|
1492
|
+
...(opts.voiceMode ? { voiceMode: opts.voiceMode } : {}),
|
|
1493
|
+
...(opts.music === true ? { music: true } : {}),
|
|
1494
|
+
...(opts.reviewStoryboard ? { reviewStoryboard: true } : {}),
|
|
1495
|
+
...(opts.director ? { director: true } : {}),
|
|
1178
1496
|
});
|
|
1179
1497
|
if (!res.ok)
|
|
1180
1498
|
return errorResult(res, opts.json);
|
|
1499
|
+
const customerStart = customerAnswer(res.data);
|
|
1500
|
+
if (customerStart)
|
|
1501
|
+
return customerStartedResult(customerStart, opts, deps);
|
|
1181
1502
|
const started = res.data;
|
|
1182
1503
|
if (!started.runId) {
|
|
1183
1504
|
return { code: 1, lines: ["The server started the ad but did not say which run it is."] };
|
|
@@ -1185,25 +1506,52 @@ export async function startScriptFlow(opts, deps) {
|
|
|
1185
1506
|
const runId = started.runId;
|
|
1186
1507
|
const url = typeof started.url === "string" ? started.url : "";
|
|
1187
1508
|
const card = isResolvedCard(started.card) ? started.card : null;
|
|
1509
|
+
const video = startedVideoChoice(started.videoChoice);
|
|
1188
1510
|
if (!opts.wait) {
|
|
1189
1511
|
return {
|
|
1190
1512
|
code: 0,
|
|
1191
1513
|
lines: opts.json
|
|
1192
|
-
? [JSON.stringify({ runId, url, card })]
|
|
1514
|
+
? [JSON.stringify({ runId, url, card, ...(video ? { videoChoice: video } : {}) })]
|
|
1193
1515
|
: [
|
|
1194
|
-
...scriptStartedLines(runId, url, card),
|
|
1516
|
+
...scriptStartedLines(runId, url, card, video, opts.director === true),
|
|
1195
1517
|
"",
|
|
1196
|
-
|
|
1518
|
+
...(opts.reviewStoryboard
|
|
1519
|
+
? ["Wait for the storyboard here instead: exodus video start … --wait"]
|
|
1520
|
+
: [
|
|
1521
|
+
"The app approves the storyboard itself if its checks pass. It only stops for you if they find a problem.",
|
|
1522
|
+
"Wait here until it needs you: exodus video start … --wait",
|
|
1523
|
+
]),
|
|
1197
1524
|
`Or check in whenever: exodus video status ${runId}`,
|
|
1198
1525
|
],
|
|
1199
1526
|
};
|
|
1200
1527
|
}
|
|
1201
1528
|
const waited = await waitFlow(runId, { json: opts.json, url }, deps);
|
|
1202
1529
|
if (opts.json)
|
|
1203
|
-
return waitJsonWithCard(waited, card);
|
|
1530
|
+
return waitJsonWithCard(waited, card, video);
|
|
1204
1531
|
return {
|
|
1205
1532
|
code: waited.code,
|
|
1206
|
-
lines: [...scriptStartedLines(runId, url, card), "", ...waited.lines],
|
|
1533
|
+
lines: [...scriptStartedLines(runId, url, card, video, opts.director === true), "", ...waited.lines],
|
|
1534
|
+
};
|
|
1535
|
+
}
|
|
1536
|
+
async function customerStartedResult(started, opts, deps) {
|
|
1537
|
+
const { runId, dashboardUrl } = started;
|
|
1538
|
+
const head = [`Started video ${runId}`, `Watch it: ${dashboardUrl}`];
|
|
1539
|
+
if (opts.wait) {
|
|
1540
|
+
const waited = await waitFlow(runId, { json: opts.json, url: dashboardUrl }, deps);
|
|
1541
|
+
return opts.json ? waited : { code: waited.code, lines: [...head, "", ...waited.lines] };
|
|
1542
|
+
}
|
|
1543
|
+
if (opts.json)
|
|
1544
|
+
return { code: 0, lines: [JSON.stringify(started)] };
|
|
1545
|
+
return {
|
|
1546
|
+
code: 0,
|
|
1547
|
+
lines: [
|
|
1548
|
+
...head,
|
|
1549
|
+
"",
|
|
1550
|
+
opts.reviewStoryboard
|
|
1551
|
+
? "It stops at the storyboard for you to approve."
|
|
1552
|
+
: "The app approves the storyboard itself if its checks pass. It only stops for you if they find a problem.",
|
|
1553
|
+
`Check in whenever: exodus video status ${runId}`,
|
|
1554
|
+
],
|
|
1207
1555
|
};
|
|
1208
1556
|
}
|
|
1209
1557
|
const POLL_INTERVAL_MS = 5000;
|
|
@@ -1212,7 +1560,8 @@ export async function waitFlow(runId, opts, deps) {
|
|
|
1212
1560
|
const interval = opts.intervalMs ?? POLL_INTERVAL_MS;
|
|
1213
1561
|
const maxPolls = opts.maxPolls ?? MAX_POLLS;
|
|
1214
1562
|
const lines = [];
|
|
1215
|
-
let
|
|
1563
|
+
let lastProgress = null;
|
|
1564
|
+
let redoSeen = false;
|
|
1216
1565
|
for (let poll = 0; poll < maxPolls; poll++) {
|
|
1217
1566
|
if (poll > 0)
|
|
1218
1567
|
await deps.sleep(interval);
|
|
@@ -1221,17 +1570,57 @@ export async function waitFlow(runId, opts, deps) {
|
|
|
1221
1570
|
const failed = errorResult(res, opts.json);
|
|
1222
1571
|
return { code: failed.code, lines: [...lines, ...failed.lines] };
|
|
1223
1572
|
}
|
|
1573
|
+
const customer = customerAnswer(res.data);
|
|
1574
|
+
if (customer) {
|
|
1575
|
+
const page = customerRunPage(deps.dashboardUrl, runId);
|
|
1576
|
+
if (customerStageIsWorking(customer.stage)) {
|
|
1577
|
+
const progress = customerStageLines(customer, page)[0];
|
|
1578
|
+
if (progress !== lastProgress) {
|
|
1579
|
+
lastProgress = progress;
|
|
1580
|
+
if (!opts.json)
|
|
1581
|
+
lines.push(progress);
|
|
1582
|
+
}
|
|
1583
|
+
continue;
|
|
1584
|
+
}
|
|
1585
|
+
return {
|
|
1586
|
+
code: customer.stage === "failed" ? 1 : 0,
|
|
1587
|
+
lines: opts.json
|
|
1588
|
+
? [JSON.stringify(customer)]
|
|
1589
|
+
: [...lines, ...customerStageLines(customer, page), `Watch it on the dashboard: ${page}`],
|
|
1590
|
+
};
|
|
1591
|
+
}
|
|
1224
1592
|
const run = asVideoRun(res.data);
|
|
1225
1593
|
const stop = classifyRun(run);
|
|
1226
1594
|
if (stop.at === "running") {
|
|
1227
|
-
|
|
1228
|
-
|
|
1595
|
+
const progress = stop.autoRedoScenes?.length
|
|
1596
|
+
? autoRedoLine(stop.autoRedoScenes, stop.autoRedoReasons)
|
|
1597
|
+
: `Working: ${stageWord(stop.stage)}…`;
|
|
1598
|
+
if (progress !== lastProgress) {
|
|
1599
|
+
lastProgress = progress;
|
|
1229
1600
|
if (!opts.json)
|
|
1230
|
-
lines.push(
|
|
1601
|
+
lines.push(progress);
|
|
1231
1602
|
}
|
|
1232
1603
|
continue;
|
|
1233
1604
|
}
|
|
1234
|
-
|
|
1605
|
+
let items = null;
|
|
1606
|
+
if (stop.at === "final-watch") {
|
|
1607
|
+
const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1608
|
+
items = itemsRes.ok ? (itemsRes.data.items ?? []) : null;
|
|
1609
|
+
if (items === null && redoSeen)
|
|
1610
|
+
continue;
|
|
1611
|
+
const redoing = items ? scenesBeingRedone(run, items) : [];
|
|
1612
|
+
redoSeen = redoing.length > 0;
|
|
1613
|
+
if (redoSeen) {
|
|
1614
|
+
const progress = `Working: redoing scene${redoing.length === 1 ? "" : "s"} ${redoing.join(", ")}…`;
|
|
1615
|
+
if (progress !== lastProgress) {
|
|
1616
|
+
lastProgress = progress;
|
|
1617
|
+
if (!opts.json)
|
|
1618
|
+
lines.push(progress);
|
|
1619
|
+
}
|
|
1620
|
+
continue;
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
const resolved = resolveStop(stop, items ? hasAttachedCut(items) : null);
|
|
1235
1624
|
if (opts.json) {
|
|
1236
1625
|
return {
|
|
1237
1626
|
code: stop.at === "failed" ? 1 : 0,
|
|
@@ -1274,6 +1663,13 @@ export async function statusFlow(runId, json, deps) {
|
|
|
1274
1663
|
const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1275
1664
|
if (!runRes.ok)
|
|
1276
1665
|
return errorResult(runRes, json);
|
|
1666
|
+
const customer = customerAnswer(runRes.data);
|
|
1667
|
+
if (customer) {
|
|
1668
|
+
return {
|
|
1669
|
+
code: 0,
|
|
1670
|
+
lines: json ? [JSON.stringify(customer)] : customerStatusLines(customer, deps.dashboardUrl),
|
|
1671
|
+
};
|
|
1672
|
+
}
|
|
1277
1673
|
const run = asVideoRun(runRes.data);
|
|
1278
1674
|
const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1279
1675
|
if (!itemsRes.ok)
|
|
@@ -1300,18 +1696,32 @@ export async function statusFlow(runId, json, deps) {
|
|
|
1300
1696
|
hasFinal,
|
|
1301
1697
|
warnings,
|
|
1302
1698
|
guidance,
|
|
1699
|
+
...(run.musicBed === undefined ? {} : { musicBed: run.musicBed }),
|
|
1303
1700
|
...(failedStoryboard?.planFailure ? { planFailure: failedStoryboard.planFailure } : {}),
|
|
1304
1701
|
...(failedStoryboard?.planFailure
|
|
1305
1702
|
? { storyboardNodeId: failedStoryboard.nodeId }
|
|
1306
1703
|
: {}),
|
|
1307
1704
|
...(failedStoryboard?.hasRejectedDraft === true ? { hasRejectedDraft: true } : {}),
|
|
1705
|
+
...(run.director ? { director: run.director } : {}),
|
|
1308
1706
|
}),
|
|
1309
1707
|
],
|
|
1310
1708
|
};
|
|
1311
1709
|
}
|
|
1312
1710
|
const revoicedByScene = new Set();
|
|
1711
|
+
const voiceFirstByScene = new Set();
|
|
1712
|
+
const voiceFirstBlockedByScene = new Map();
|
|
1313
1713
|
const timedOnOriginalVoice = new Set();
|
|
1314
1714
|
for (const artifact of outputsOfNodeKind(run, "video")) {
|
|
1715
|
+
if (artifact.type === "video" &&
|
|
1716
|
+
artifact.voiceMode === "voice-first" &&
|
|
1717
|
+
typeof artifact.sceneIndex === "number") {
|
|
1718
|
+
voiceFirstByScene.add(artifact.sceneIndex);
|
|
1719
|
+
}
|
|
1720
|
+
if (artifact.type === "video" &&
|
|
1721
|
+
artifact.voiceFirstBlockedBy?.length &&
|
|
1722
|
+
typeof artifact.sceneIndex === "number") {
|
|
1723
|
+
voiceFirstBlockedByScene.set(artifact.sceneIndex, artifact.voiceFirstBlockedBy);
|
|
1724
|
+
}
|
|
1315
1725
|
if (artifact.type === "video" && artifact.revoiced === true && typeof artifact.sceneIndex === "number") {
|
|
1316
1726
|
revoicedByScene.add(artifact.sceneIndex);
|
|
1317
1727
|
if ((artifact.words?.length ?? 0) > 0)
|
|
@@ -1342,6 +1752,7 @@ export async function statusFlow(runId, json, deps) {
|
|
|
1342
1752
|
...(stop.at === "running" && run.pauseAhead ? [pauseAheadLine(run.pauseAhead)] : []),
|
|
1343
1753
|
...planFailureLines(failedStoryboard, runId),
|
|
1344
1754
|
...warnings.map((w) => `Heads-up (${w.step}): ${w.warning}`),
|
|
1755
|
+
...(run.director ? ["", ...directorLines(run.director)] : []),
|
|
1345
1756
|
];
|
|
1346
1757
|
if (byScene.size === 0) {
|
|
1347
1758
|
lines.push("", "No scenes yet — this run hasn't made anything to look at.");
|
|
@@ -1352,7 +1763,7 @@ export async function statusFlow(runId, json, deps) {
|
|
|
1352
1763
|
const row = byScene.get(sceneIndex);
|
|
1353
1764
|
lines.push(`${String(sceneIndex).padEnd(5)} ${itemWord(row.clip).padEnd(8)} ${itemWord(row.voiceover).padEnd(8)} ${itemWord(row.frame)}`);
|
|
1354
1765
|
if (row.clip?.lastRedo) {
|
|
1355
|
-
lines.push(` clip: ${row.clip.lastRedo
|
|
1766
|
+
lines.push(` clip: ${redoLine(row.clip.lastRedo)}`);
|
|
1356
1767
|
printDisplacedHistory(lines, row.clip.lastRedo);
|
|
1357
1768
|
}
|
|
1358
1769
|
if (row.clip?.lastRedo || row.clip?.flagged) {
|
|
@@ -1361,7 +1772,7 @@ export async function statusFlow(runId, json, deps) {
|
|
|
1361
1772
|
}
|
|
1362
1773
|
}
|
|
1363
1774
|
if (row.voiceover?.lastRedo) {
|
|
1364
|
-
lines.push(` voice: ${row.voiceover.lastRedo
|
|
1775
|
+
lines.push(` voice: ${redoLine(row.voiceover.lastRedo)}`);
|
|
1365
1776
|
printDisplacedHistory(lines, row.voiceover.lastRedo);
|
|
1366
1777
|
}
|
|
1367
1778
|
if (row.frame?.lastRedo)
|
|
@@ -1380,6 +1791,12 @@ export async function statusFlow(runId, json, deps) {
|
|
|
1380
1791
|
lines.push(` ${row.clip.error}`);
|
|
1381
1792
|
if (revoicedByScene.has(sceneIndex))
|
|
1382
1793
|
lines.push(" voice: cast voice applied");
|
|
1794
|
+
if (voiceFirstByScene.has(sceneIndex))
|
|
1795
|
+
lines.push(" clip: redone voice first");
|
|
1796
|
+
const blockedBy = voiceFirstBlockedByScene.get(sceneIndex);
|
|
1797
|
+
if (blockedBy) {
|
|
1798
|
+
lines.push(` clip: redone the usual way — ${cantSayNames(blockedBy)}`);
|
|
1799
|
+
}
|
|
1383
1800
|
if (timedOnOriginalVoice.has(sceneIndex)) {
|
|
1384
1801
|
lines.push(" word timings: measured on the original voice (close, not frame-exact)");
|
|
1385
1802
|
}
|
|
@@ -1398,10 +1815,79 @@ export async function statusFlow(runId, json, deps) {
|
|
|
1398
1815
|
: "Final cut: not uploaded yet.");
|
|
1399
1816
|
return { code: 0, lines };
|
|
1400
1817
|
}
|
|
1818
|
+
const DIRECTOR_STATUS_WORDS = {
|
|
1819
|
+
reviewing: "Watching this run",
|
|
1820
|
+
waiting: "Waiting for redos to finish",
|
|
1821
|
+
done: "Done: ready for your approval",
|
|
1822
|
+
stopped: "Stopped: see the last note",
|
|
1823
|
+
};
|
|
1824
|
+
const DIRECTOR_PHASE_WORDS = {
|
|
1825
|
+
storyboard: "Storyboard",
|
|
1826
|
+
"final-watch": "Final watch",
|
|
1827
|
+
};
|
|
1828
|
+
const DIRECTOR_LOG_LINES = 3;
|
|
1829
|
+
export function directorLines(director) {
|
|
1830
|
+
const switchWords = director.runSetting === null
|
|
1831
|
+
? `following the brand's setting (${director.workspaceOn ? "on" : "off"})`
|
|
1832
|
+
: "switched for this ad";
|
|
1833
|
+
const log = director.log.slice(-DIRECTOR_LOG_LINES);
|
|
1834
|
+
return [
|
|
1835
|
+
`Director: ${director.on ? "on" : "off"}, ${switchWords}`,
|
|
1836
|
+
...(director.on
|
|
1837
|
+
? [` ${DIRECTOR_STATUS_WORDS[director.status ?? "reviewing"]}`]
|
|
1838
|
+
: []),
|
|
1839
|
+
` Redos: ${director.redosUsed} of ${director.redoCap}. Spent: $${director.spendUsd.toFixed(2)} of $${director.spendCapUsd.toFixed(2)}`,
|
|
1840
|
+
...(log.length > 0
|
|
1841
|
+
? [
|
|
1842
|
+
" Latest notes:",
|
|
1843
|
+
...log.map((entry) => ` ${DIRECTOR_PHASE_WORDS[entry.phase]}: ${entry.text}`),
|
|
1844
|
+
]
|
|
1845
|
+
: []),
|
|
1846
|
+
];
|
|
1847
|
+
}
|
|
1848
|
+
export function parseDirectorSwitch(word) {
|
|
1849
|
+
if (word === "on")
|
|
1850
|
+
return { on: true };
|
|
1851
|
+
if (word === "off")
|
|
1852
|
+
return { on: false };
|
|
1853
|
+
if (word === "follow")
|
|
1854
|
+
return { on: null };
|
|
1855
|
+
return null;
|
|
1856
|
+
}
|
|
1857
|
+
export async function directorFlow(runId, on, json, deps) {
|
|
1858
|
+
const res = await deps.post(DIRECTOR_PATH, { runId, on });
|
|
1859
|
+
const behind = missingRouteLine(res, "exodus video director");
|
|
1860
|
+
if (behind) {
|
|
1861
|
+
return {
|
|
1862
|
+
code: 1,
|
|
1863
|
+
lines: json ? [JSON.stringify({ ok: false, status: 404, error: behind })] : [behind],
|
|
1864
|
+
};
|
|
1865
|
+
}
|
|
1866
|
+
if (!res.ok)
|
|
1867
|
+
return errorResult(res, json);
|
|
1868
|
+
if (json)
|
|
1869
|
+
return { code: 0, lines: [JSON.stringify({ runId, on })] };
|
|
1870
|
+
const said = on === null
|
|
1871
|
+
? `The director on ad run ${runId} now follows the brand's own setting.`
|
|
1872
|
+
: `The director is ${on ? "on" : "off"} for ad run ${runId}.`;
|
|
1873
|
+
return {
|
|
1874
|
+
code: 0,
|
|
1875
|
+
lines: [
|
|
1876
|
+
said,
|
|
1877
|
+
...(on === true
|
|
1878
|
+
? ["If the ad is already waiting at the storyboard or the final watch, a review starts now."]
|
|
1879
|
+
: []),
|
|
1880
|
+
`See what it is doing: exodus video status ${runId}`,
|
|
1881
|
+
],
|
|
1882
|
+
};
|
|
1883
|
+
}
|
|
1401
1884
|
export async function rejectedDraftFlow(runId, json, deps) {
|
|
1402
1885
|
const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1403
1886
|
if (!runRes.ok)
|
|
1404
1887
|
return errorResult(runRes, json);
|
|
1888
|
+
if (customerAnswer(runRes.data)) {
|
|
1889
|
+
return adminOnlyResult("exodus video status --rejected-draft is for admins.", json);
|
|
1890
|
+
}
|
|
1405
1891
|
const node = failedStoryboardNode(asVideoRun(runRes.data));
|
|
1406
1892
|
if (!node) {
|
|
1407
1893
|
const note = "The storyboard on this run didn't fail, so there is no rejected draft to read.";
|
|
@@ -1441,6 +1927,10 @@ export async function storyboardFlow(runId, json, deps) {
|
|
|
1441
1927
|
const res = await deps.get(`${STORYBOARD_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1442
1928
|
if (!res.ok)
|
|
1443
1929
|
return errorResult(res, json);
|
|
1930
|
+
const board = customerAnswer(res.data);
|
|
1931
|
+
if (board) {
|
|
1932
|
+
return { code: 0, lines: json ? [JSON.stringify(board)] : customerStoryboardLines(board) };
|
|
1933
|
+
}
|
|
1444
1934
|
const cards = res.data;
|
|
1445
1935
|
if (json)
|
|
1446
1936
|
return { code: 0, lines: [JSON.stringify(cards)] };
|
|
@@ -1579,14 +2069,34 @@ export async function flagFlow(runId, note, json, deps) {
|
|
|
1579
2069
|
};
|
|
1580
2070
|
}
|
|
1581
2071
|
export async function retryFrameFlow(runId, nodeId, sceneIndex, note, json, deps) {
|
|
2072
|
+
if (nodeId === undefined) {
|
|
2073
|
+
const board = await deps.get(`${STORYBOARD_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
2074
|
+
if (!board.ok)
|
|
2075
|
+
return errorResult(board, json);
|
|
2076
|
+
if (!customerAnswer(board.data)) {
|
|
2077
|
+
return { code: 1, lines: ["video retry-frame needs --node <nodeId>."] };
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
1582
2080
|
const res = await deps.post(SCENE_RETRY_PATH, {
|
|
1583
2081
|
runId,
|
|
1584
|
-
nodeId,
|
|
2082
|
+
...(nodeId === undefined ? {} : { nodeId }),
|
|
1585
2083
|
sceneIndex,
|
|
1586
2084
|
...(note ? { note } : {}),
|
|
1587
2085
|
});
|
|
1588
2086
|
if (!res.ok)
|
|
1589
2087
|
return errorResult(res, json);
|
|
2088
|
+
if (customerAnswer(res.data)) {
|
|
2089
|
+
if (json)
|
|
2090
|
+
return { code: 0, lines: [JSON.stringify({ ok: true, runId, sceneIndex })] };
|
|
2091
|
+
return {
|
|
2092
|
+
code: 0,
|
|
2093
|
+
lines: [
|
|
2094
|
+
`Redrawing scene ${sceneIndex}'s picture. The other scenes stay as they are.`,
|
|
2095
|
+
...(note ? ["Your note isn't used on this redo."] : []),
|
|
2096
|
+
`See it when it's done: exodus video storyboard ${runId}`,
|
|
2097
|
+
],
|
|
2098
|
+
};
|
|
2099
|
+
}
|
|
1590
2100
|
const triggerRunId = res.data.triggerRunId;
|
|
1591
2101
|
if (json) {
|
|
1592
2102
|
return {
|
|
@@ -1623,7 +2133,10 @@ export function planClipRedo(run, items, target) {
|
|
|
1623
2133
|
const { row, uploadedCut } = found;
|
|
1624
2134
|
const redoable = row.status === "failed" ||
|
|
1625
2135
|
row.staleClaim === true ||
|
|
1626
|
-
(row.status === "done" &&
|
|
2136
|
+
(row.status === "done" &&
|
|
2137
|
+
(row.flagged === true ||
|
|
2138
|
+
stop.at === "final-watch" ||
|
|
2139
|
+
(stop.at === "running" && stop.autoRedoScenes !== undefined)));
|
|
1627
2140
|
if (!redoable) {
|
|
1628
2141
|
if (row.status === "done") {
|
|
1629
2142
|
return {
|
|
@@ -1680,10 +2193,14 @@ function findClipRow(run, items, target) {
|
|
|
1680
2193
|
}
|
|
1681
2194
|
return { row, uploadedCut };
|
|
1682
2195
|
}
|
|
1683
|
-
export async function retryClipFlow(runId, target, note, json, deps) {
|
|
2196
|
+
export async function retryClipFlow(runId, target, { note, voiceFirst = false }, json, deps) {
|
|
1684
2197
|
const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1685
2198
|
if (!runRes.ok)
|
|
1686
2199
|
return errorResult(runRes, json);
|
|
2200
|
+
if (customerAnswer(runRes.data)) {
|
|
2201
|
+
return adminOnlyResult("Redoing a finished clip is for admins. To redo a scene's picture while your storyboard " +
|
|
2202
|
+
`waits for you: exodus video retry-frame ${runId} --scene <n>`, json);
|
|
2203
|
+
}
|
|
1687
2204
|
const run = asVideoRun(runRes.data);
|
|
1688
2205
|
const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1689
2206
|
if (!itemsRes.ok)
|
|
@@ -1696,12 +2213,20 @@ export async function retryClipFlow(runId, target, note, json, deps) {
|
|
|
1696
2213
|
lines: json ? [JSON.stringify({ ok: false, error: plan.reason })] : [plan.reason],
|
|
1697
2214
|
};
|
|
1698
2215
|
}
|
|
1699
|
-
const res = await deps.post(SCENE_RETRY_PATH, {
|
|
2216
|
+
const res = await deps.post(voiceFirst ? SCENE_VOICE_FIRST_PATH : SCENE_RETRY_PATH, {
|
|
1700
2217
|
runId,
|
|
1701
2218
|
nodeId: plan.nodeId,
|
|
1702
2219
|
sceneIndex: plan.sceneIndex,
|
|
1703
2220
|
...(note ? { note } : {}),
|
|
1704
2221
|
});
|
|
2222
|
+
if (voiceFirst && missingRouteLine(res, "exodus video retry-clip --voice-first")) {
|
|
2223
|
+
return {
|
|
2224
|
+
code: 1,
|
|
2225
|
+
lines: json
|
|
2226
|
+
? [JSON.stringify({ ok: false, status: 404, error: VOICE_FIRST_NOT_ON_THIS_SERVER })]
|
|
2227
|
+
: [VOICE_FIRST_NOT_ON_THIS_SERVER],
|
|
2228
|
+
};
|
|
2229
|
+
}
|
|
1705
2230
|
if (!res.ok)
|
|
1706
2231
|
return errorResult(res, json);
|
|
1707
2232
|
const triggerRunId = res.data.triggerRunId;
|
|
@@ -1720,6 +2245,7 @@ export async function retryClipFlow(runId, target, note, json, deps) {
|
|
|
1720
2245
|
reviewUrl: review,
|
|
1721
2246
|
warnings: plan.warnings,
|
|
1722
2247
|
...(note ? { note } : {}),
|
|
2248
|
+
...(voiceFirst ? { voiceMode: "voice-first" } : {}),
|
|
1723
2249
|
}),
|
|
1724
2250
|
],
|
|
1725
2251
|
};
|
|
@@ -1727,9 +2253,19 @@ export async function retryClipFlow(runId, target, note, json, deps) {
|
|
|
1727
2253
|
return {
|
|
1728
2254
|
code: 0,
|
|
1729
2255
|
lines: [
|
|
1730
|
-
|
|
2256
|
+
voiceFirst
|
|
2257
|
+
? `Redoing scene ${plan.sceneIndex}'s clip on ${plan.nodeId}, voice first: the line is ` +
|
|
2258
|
+
"recorded in the character's own voice, then the clip is made to match it. " +
|
|
2259
|
+
"If the line has a made-up name the recorded voice can't say, the clip may be made " +
|
|
2260
|
+
`the usual way instead, and exodus video status ${runId} will say so.`
|
|
2261
|
+
: `Redoing scene ${plan.sceneIndex}'s clip on ${plan.nodeId}.`,
|
|
1731
2262
|
`triggerRunId: ${triggerRunId ?? "-"}`,
|
|
1732
|
-
|
|
2263
|
+
...(voiceFirst
|
|
2264
|
+
? [
|
|
2265
|
+
"Every other scene stays as it is, and so do the pictures and the script.",
|
|
2266
|
+
"A speaking character with no voice yet gets one picked and saved for the whole run.",
|
|
2267
|
+
]
|
|
2268
|
+
: ["Every other scene stays as it is, and so do the pictures, the voices and the script."]),
|
|
1733
2269
|
...(note
|
|
1734
2270
|
? ["Your note steers how this clip moves. It never changes the words that are spoken."]
|
|
1735
2271
|
: []),
|
|
@@ -1798,6 +2334,9 @@ export async function revoiceFlow(runId, target, json, deps) {
|
|
|
1798
2334
|
const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1799
2335
|
if (!runRes.ok)
|
|
1800
2336
|
return errorResult(runRes, json);
|
|
2337
|
+
if (customerAnswer(runRes.data)) {
|
|
2338
|
+
return adminOnlyResult("exodus video revoice is for admins.", json);
|
|
2339
|
+
}
|
|
1801
2340
|
const run = asVideoRun(runRes.data);
|
|
1802
2341
|
const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1803
2342
|
if (!itemsRes.ok)
|
|
@@ -1964,6 +2503,10 @@ export async function voicesFlow(runId, voices, json, deps) {
|
|
|
1964
2503
|
: await deps.get(`${VOICES_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1965
2504
|
if (!res.ok)
|
|
1966
2505
|
return errorResult(res, json);
|
|
2506
|
+
const customer = customerAnswer(res.data);
|
|
2507
|
+
if (customer) {
|
|
2508
|
+
return { code: 0, lines: json ? [JSON.stringify(customer)] : customerVoicesLines(customer) };
|
|
2509
|
+
}
|
|
1967
2510
|
const sheet = res.data;
|
|
1968
2511
|
if (json)
|
|
1969
2512
|
return { code: 0, lines: [JSON.stringify({ ok: true, ...sheet })] };
|
|
@@ -2046,10 +2589,112 @@ export function voiceSheetLines(sheet) {
|
|
|
2046
2589
|
: (sheet.whyNot ?? "Voices can no longer be changed on this run."));
|
|
2047
2590
|
return lines;
|
|
2048
2591
|
}
|
|
2592
|
+
function musicLines(manifest) {
|
|
2593
|
+
const lines = [`music: ${manifest.musicBed}`];
|
|
2594
|
+
if (manifest.musicHeardScenes.length > 0) {
|
|
2595
|
+
lines.push(`music heard in scene ${manifest.musicHeardScenes.join(", ")}`);
|
|
2596
|
+
}
|
|
2597
|
+
return lines;
|
|
2598
|
+
}
|
|
2599
|
+
export function planCustomerPull(run, items, meta) {
|
|
2600
|
+
const downloads = [];
|
|
2601
|
+
const take = (file, url) => {
|
|
2602
|
+
downloads.push({ file, url });
|
|
2603
|
+
return file;
|
|
2604
|
+
};
|
|
2605
|
+
const scenes = new Map();
|
|
2606
|
+
let music = null;
|
|
2607
|
+
for (const item of items) {
|
|
2608
|
+
if (item.kind === "music") {
|
|
2609
|
+
if (music === null && item.url)
|
|
2610
|
+
music = take(`music.${extFor(item.url, "audio")}`, item.url);
|
|
2611
|
+
continue;
|
|
2612
|
+
}
|
|
2613
|
+
if (!isPullSceneIndex(item.sceneIndex))
|
|
2614
|
+
continue;
|
|
2615
|
+
const scene = scenes.get(item.sceneIndex) ?? { index: item.sceneIndex, clip: null, voice: null };
|
|
2616
|
+
if (item.url && item.kind === "clip" && scene.clip === null) {
|
|
2617
|
+
scene.clip = take(`${scenePrefix(item.sceneIndex)}.${extFor(item.url, "video")}`, item.url);
|
|
2618
|
+
}
|
|
2619
|
+
if (item.url && item.kind === "voice" && scene.voice === null) {
|
|
2620
|
+
scene.voice = take(`${scenePrefix(item.sceneIndex)}.voice.${extFor(item.url, "audio")}`, item.url);
|
|
2621
|
+
}
|
|
2622
|
+
scenes.set(item.sceneIndex, scene);
|
|
2623
|
+
}
|
|
2624
|
+
const videoUrl = run.video?.url;
|
|
2625
|
+
const video = videoUrl ? take(`video.${extFor(videoUrl, "video")}`, videoUrl) : null;
|
|
2626
|
+
return {
|
|
2627
|
+
downloads,
|
|
2628
|
+
manifest: {
|
|
2629
|
+
runId: run.runId,
|
|
2630
|
+
pulledAt: meta.pulledAt,
|
|
2631
|
+
dashboardUrl: customerRunPage(meta.dashboardUrl, run.runId),
|
|
2632
|
+
scenes: [...scenes.values()].sort((a, b) => a.index - b.index),
|
|
2633
|
+
music,
|
|
2634
|
+
video,
|
|
2635
|
+
},
|
|
2636
|
+
};
|
|
2637
|
+
}
|
|
2638
|
+
async function customerPullFlow(run, dir, json, deps) {
|
|
2639
|
+
const runId = run.runId;
|
|
2640
|
+
const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
2641
|
+
if (!itemsRes.ok)
|
|
2642
|
+
return errorResult(itemsRes, json);
|
|
2643
|
+
const items = customerAnswer(itemsRes.data)?.items ?? [];
|
|
2644
|
+
const { downloads, manifest } = planCustomerPull(run, items, {
|
|
2645
|
+
pulledAt: new Date(deps.now()).toISOString(),
|
|
2646
|
+
dashboardUrl: deps.dashboardUrl,
|
|
2647
|
+
});
|
|
2648
|
+
deps.mkdirp(dir);
|
|
2649
|
+
const failures = (await runPool(downloads.map((download) => () => downloadWithRetry(download, dir, deps)), PULL_CONCURRENCY)).filter((f) => f !== null);
|
|
2650
|
+
const landed = (file) => file !== null && !failures.some((f) => f.file === file) ? file : null;
|
|
2651
|
+
for (const scene of manifest.scenes) {
|
|
2652
|
+
scene.clip = landed(scene.clip);
|
|
2653
|
+
scene.voice = landed(scene.voice);
|
|
2654
|
+
}
|
|
2655
|
+
manifest.music = landed(manifest.music);
|
|
2656
|
+
manifest.video = landed(manifest.video);
|
|
2657
|
+
const manifestPath = path.join(dir, "manifest.json");
|
|
2658
|
+
deps.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
2659
|
+
const code = failures.length > 0 ? 1 : 0;
|
|
2660
|
+
if (json) {
|
|
2661
|
+
return { code, lines: [JSON.stringify({ runId, dir, wrote: downloads.length - failures.length + 1, manifest })] };
|
|
2662
|
+
}
|
|
2663
|
+
if (downloads.length === 0) {
|
|
2664
|
+
return {
|
|
2665
|
+
code: 0,
|
|
2666
|
+
lines: [
|
|
2667
|
+
"Nothing to download yet. This video hasn't made any pieces.",
|
|
2668
|
+
`Check where it is: exodus video status ${runId}`,
|
|
2669
|
+
],
|
|
2670
|
+
};
|
|
2671
|
+
}
|
|
2672
|
+
return {
|
|
2673
|
+
code,
|
|
2674
|
+
lines: [
|
|
2675
|
+
`Pulled ${downloads.length - failures.length + 1} files into ${dir}`,
|
|
2676
|
+
`Every piece is listed in ${manifestPath}.`,
|
|
2677
|
+
...(manifest.video ? [] : ["The finished video isn't ready yet, so only the scene pieces came down."]),
|
|
2678
|
+
...(failures.length > 0
|
|
2679
|
+
? [
|
|
2680
|
+
"",
|
|
2681
|
+
`${failures.length} file${failures.length === 1 ? "" : "s"} did not download:`,
|
|
2682
|
+
...failures.map((f) => ` ${f.file}: ${f.error}`),
|
|
2683
|
+
"Run the same command again to retry.",
|
|
2684
|
+
]
|
|
2685
|
+
: []),
|
|
2686
|
+
"",
|
|
2687
|
+
`When your cut is ready: exodus video upload ${runId} --file cut.mp4`,
|
|
2688
|
+
],
|
|
2689
|
+
};
|
|
2690
|
+
}
|
|
2049
2691
|
export async function pullFlow(runId, dir, json, deps) {
|
|
2050
2692
|
const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
2051
2693
|
if (!runRes.ok)
|
|
2052
2694
|
return errorResult(runRes, json);
|
|
2695
|
+
const customer = customerAnswer(runRes.data);
|
|
2696
|
+
if (customer)
|
|
2697
|
+
return customerPullFlow(customer, dir, json, deps);
|
|
2053
2698
|
const run = asVideoRun(runRes.data);
|
|
2054
2699
|
const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
2055
2700
|
if (!itemsRes.ok)
|
|
@@ -2089,7 +2734,23 @@ export async function pullFlow(runId, dir, json, deps) {
|
|
|
2089
2734
|
const lines = [
|
|
2090
2735
|
`Pulled ${wrote} files into ${dir}`,
|
|
2091
2736
|
`Every piece is indexed in ${path.join(dir, "manifest.json")} — scene numbers there are the run's own.`,
|
|
2737
|
+
...musicLines(plan.manifest),
|
|
2092
2738
|
];
|
|
2739
|
+
for (const sceneIndex of plan.manifest.pendingRedo) {
|
|
2740
|
+
lines.push("", `Scene ${sceneIndex} is being redone right now; its file will change. Run \`exodus video wait ${runId}\`, then pull again.`);
|
|
2741
|
+
}
|
|
2742
|
+
const voiceFirst = plan.manifest.scenes.filter((s) => s.voiceMode === "voice-first");
|
|
2743
|
+
if (voiceFirst.length > 0) {
|
|
2744
|
+
lines.push("", `Redone voice first (the line recorded, then the clip made to match it): ` +
|
|
2745
|
+
`scene${voiceFirst.length === 1 ? "" : "s"} ${voiceFirst.map((s) => s.sceneIndex).join(", ")}`);
|
|
2746
|
+
}
|
|
2747
|
+
const redoneUsualWay = plan.manifest.scenes.filter((s) => s.voiceFirstBlockedBy?.length);
|
|
2748
|
+
if (redoneUsualWay.length > 0) {
|
|
2749
|
+
lines.push("", "Asked for voice first, but redone the usual way:");
|
|
2750
|
+
for (const scene of redoneUsualWay) {
|
|
2751
|
+
lines.push(` scene ${scene.sceneIndex}: ${cantSayNames(scene.voiceFirstBlockedBy ?? [])}`);
|
|
2752
|
+
}
|
|
2753
|
+
}
|
|
2093
2754
|
const flagged = plan.manifest.scenes.filter((s) => s.flagged);
|
|
2094
2755
|
if (flagged.length > 0) {
|
|
2095
2756
|
lines.push("", `${flagged.length} clip${flagged.length === 1 ? "" : "s"} came back flagged — usable, but look before you cut:`);
|
|
@@ -2325,21 +2986,42 @@ export async function uploadFlow(runId, filePath, durationFlag, json, deps) {
|
|
|
2325
2986
|
const attached = attaching.value;
|
|
2326
2987
|
if (!attached.ok)
|
|
2327
2988
|
return errorResult(attached, json);
|
|
2989
|
+
const customerFinal = customerAnswer(attached.data);
|
|
2990
|
+
if (customerFinal) {
|
|
2991
|
+
const { finalWatchUrl, delivered } = customerFinal;
|
|
2992
|
+
if (json) {
|
|
2993
|
+
return {
|
|
2994
|
+
code: 0,
|
|
2995
|
+
lines: [JSON.stringify({ ok: true, runId, assetId, durationSec, finalWatchUrl, delivered })],
|
|
2996
|
+
};
|
|
2997
|
+
}
|
|
2998
|
+
return {
|
|
2999
|
+
code: 0,
|
|
3000
|
+
lines: [
|
|
3001
|
+
`Uploaded your cut (${durationSec.toFixed(1)}s).${delivered ? " Your video is delivered." : ""}`,
|
|
3002
|
+
`Watch it here: ${finalWatchUrl}`,
|
|
3003
|
+
],
|
|
3004
|
+
};
|
|
3005
|
+
}
|
|
2328
3006
|
const final = attached.data;
|
|
2329
3007
|
const finalWatchUrl = final.finalWatchUrl ?? reviewUrl(deps.dashboardUrl, { _id: runId });
|
|
3008
|
+
const delivered = final.delivered === true;
|
|
2330
3009
|
if (json) {
|
|
2331
3010
|
return {
|
|
2332
3011
|
code: 0,
|
|
2333
|
-
lines: [JSON.stringify({ ok: true, runId, assetId, durationSec, finalWatchUrl })],
|
|
3012
|
+
lines: [JSON.stringify({ ok: true, runId, assetId, durationSec, finalWatchUrl, delivered })],
|
|
2334
3013
|
};
|
|
2335
3014
|
}
|
|
3015
|
+
const uploaded = `Uploaded your cut (${durationSec.toFixed(1)}s).`;
|
|
2336
3016
|
return {
|
|
2337
3017
|
code: 0,
|
|
2338
|
-
lines:
|
|
2339
|
-
`
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
3018
|
+
lines: delivered
|
|
3019
|
+
? [`${uploaded} Your ad is delivered.`, `Watch it here: ${finalWatchUrl}`]
|
|
3020
|
+
: [
|
|
3021
|
+
uploaded,
|
|
3022
|
+
`Watch it here: ${finalWatchUrl}`,
|
|
3023
|
+
`Approve it there, or run: exodus video approve ${runId}`,
|
|
3024
|
+
],
|
|
2343
3025
|
};
|
|
2344
3026
|
}
|
|
2345
3027
|
const VALUE_FLAGS = new Set([
|
|
@@ -2349,6 +3031,8 @@ const VALUE_FLAGS = new Set([
|
|
|
2349
3031
|
"style",
|
|
2350
3032
|
"direction",
|
|
2351
3033
|
"voice-path",
|
|
3034
|
+
"video-model",
|
|
3035
|
+
"voice",
|
|
2352
3036
|
"note",
|
|
2353
3037
|
"out",
|
|
2354
3038
|
"file",
|
|
@@ -2401,7 +3085,7 @@ export async function run(flags, occurrences) {
|
|
|
2401
3085
|
if (sub === "shows")
|
|
2402
3086
|
return printResult(await showsFlow(json, defaultDeps));
|
|
2403
3087
|
if (sub === "start") {
|
|
2404
|
-
const plan = planVideoStart(flags);
|
|
3088
|
+
const plan = planVideoStart(flags, occurrences);
|
|
2405
3089
|
if (plan.kind === "usage")
|
|
2406
3090
|
usage(plan.line);
|
|
2407
3091
|
if (plan.kind === "show")
|
|
@@ -2420,6 +3104,7 @@ export async function run(flags, occurrences) {
|
|
|
2420
3104
|
"voices",
|
|
2421
3105
|
"pull",
|
|
2422
3106
|
"upload",
|
|
3107
|
+
"director",
|
|
2423
3108
|
];
|
|
2424
3109
|
if (needsRunId.includes(sub) && !runId) {
|
|
2425
3110
|
usage(`video ${sub} needs a run id: exodus video ${sub} <runId>`);
|
|
@@ -2432,6 +3117,12 @@ export async function run(flags, occurrences) {
|
|
|
2432
3117
|
}
|
|
2433
3118
|
if (sub === "storyboard")
|
|
2434
3119
|
return printResult(await storyboardFlow(runId, json, defaultDeps));
|
|
3120
|
+
if (sub === "director") {
|
|
3121
|
+
const choice = parseDirectorSwitch(rest[1]);
|
|
3122
|
+
if (!choice)
|
|
3123
|
+
usage(`video director needs on, off or follow: exodus video director ${runId} on`);
|
|
3124
|
+
return printResult(await directorFlow(runId, choice.on, json, defaultDeps));
|
|
3125
|
+
}
|
|
2435
3126
|
if (sub === "approve") {
|
|
2436
3127
|
return printResult(await approveFlow(runId, { json, approveStaleCut: flags["approve-stale-cut"] === true }, defaultDeps));
|
|
2437
3128
|
}
|
|
@@ -2442,9 +3133,7 @@ export async function run(flags, occurrences) {
|
|
|
2442
3133
|
return printResult(await flagFlow(runId, note, json, defaultDeps));
|
|
2443
3134
|
}
|
|
2444
3135
|
if (sub === "retry-frame") {
|
|
2445
|
-
const nodeId = flagString(flags, "node");
|
|
2446
|
-
if (!nodeId)
|
|
2447
|
-
usage("video retry-frame needs --node <nodeId>.");
|
|
3136
|
+
const nodeId = nonBlank(flagString(flags, "node"));
|
|
2448
3137
|
const sceneRaw = flagString(flags, "scene");
|
|
2449
3138
|
if (sceneRaw === undefined) {
|
|
2450
3139
|
usage("video retry-frame needs --scene <n>, the scene index to redo.");
|
|
@@ -2464,7 +3153,7 @@ export async function run(flags, occurrences) {
|
|
|
2464
3153
|
if (!Number.isInteger(sceneIndex)) {
|
|
2465
3154
|
usage(`video retry-clip --scene must be a whole scene number, not "${sceneRaw}".`);
|
|
2466
3155
|
}
|
|
2467
|
-
return printResult(await retryClipFlow(runId, { sceneIndex, nodeId: flagString(flags, "node") }, flagString(flags, "note"), json, defaultDeps));
|
|
3156
|
+
return printResult(await retryClipFlow(runId, { sceneIndex, nodeId: flagString(flags, "node") }, { note: flagString(flags, "note"), voiceFirst: flags["voice-first"] === true }, json, defaultDeps));
|
|
2468
3157
|
}
|
|
2469
3158
|
if (sub === "revoice") {
|
|
2470
3159
|
const sceneRaw = flagString(flags, "scene");
|