@aicopycoders/exodus 2026.9.2000-beta.9 → 2026.10.1200-beta.1
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 +203 -47
- package/assets/skills/exodus-video/scripts/first-cut.d.mts +44 -0
- package/assets/skills/exodus-video/scripts/first-cut.mjs +1140 -92
- 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 +101 -0
- package/dist/commands/video-customer.js +158 -0
- package/dist/commands/video.d.ts +169 -9
- package/dist/commands/video.js +877 -87
- 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 +3 -3
package/dist/commands/video.js
CHANGED
|
@@ -7,24 +7,37 @@ 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
|
-
|
|
11
|
-
const
|
|
12
|
-
"pixar-3d",
|
|
13
|
-
"claymation-stop-motion",
|
|
14
|
-
"lego-toy-brick",
|
|
15
|
-
"balloon",
|
|
16
|
-
"video-game-8bit",
|
|
17
|
-
];
|
|
10
|
+
import { customerAnswer, customerRunPage, customerStageIsWorking, customerStageLines, customerStatusLines, customerStoryboardLines, customerVoicesLines, } from "./video-customer.js";
|
|
11
|
+
const VOICE_MODES = ["native", "voice-first"];
|
|
18
12
|
function joinedOr(items) {
|
|
19
13
|
if (items.length <= 1)
|
|
20
14
|
return items[0] ?? "";
|
|
21
15
|
return `${items.slice(0, -1).join(", ")} or ${items[items.length - 1]}`;
|
|
22
16
|
}
|
|
17
|
+
export function scriptRunPickProblem(options, conceit, style) {
|
|
18
|
+
const keys = options.conceits.map((row) => row.key);
|
|
19
|
+
if (!keys.includes(conceit)) {
|
|
20
|
+
return keys.length === 0
|
|
21
|
+
? "This Exodus server offers no kinds of ad yet."
|
|
22
|
+
: `video start --conceit must be one of ${joinedOr(keys)}.`;
|
|
23
|
+
}
|
|
24
|
+
const slugs = options.styles.map((row) => row.slug);
|
|
25
|
+
if (!slugs.includes(style)) {
|
|
26
|
+
return slugs.length === 0
|
|
27
|
+
? "This Exodus server offers no looks yet."
|
|
28
|
+
: `video start --style must be one of ${joinedOr(slugs)}.`;
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
function cantSayNames(names) {
|
|
33
|
+
return `the recorded voice can't say ${joinedOr(names.map((n) => `"${n}"`))}`;
|
|
34
|
+
}
|
|
23
35
|
export const helpText = `
|
|
24
36
|
exodus video — make a video ad from a saved workflow, pull every piece, upload your cut
|
|
25
37
|
|
|
26
38
|
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,
|
|
39
|
+
one voice track per scene, one video clip per scene, and a music bed if you
|
|
40
|
+
asked for one with --music. It does
|
|
28
41
|
not make the finished ad. You pull the pieces to a folder, cut them together
|
|
29
42
|
with whatever editing tools you like, and upload the cut back.
|
|
30
43
|
|
|
@@ -68,7 +81,9 @@ The whole loop, in order:
|
|
|
68
81
|
|
|
69
82
|
exodus video retry-clip <runId> --scene <n>
|
|
70
83
|
Redo ONE finished clip while the run waits for the cut. Everything
|
|
71
|
-
else stays. A --note steers the motion, never the words.
|
|
84
|
+
else stays. A --note steers the motion, never the words. Add
|
|
85
|
+
--voice-first when the words came out wrong: the line is recorded in
|
|
86
|
+
the character's own voice first, then the clip is made to match it.
|
|
72
87
|
|
|
73
88
|
exodus video revoice <runId> --all
|
|
74
89
|
Redo only the VOICE on clips that kept the video model's own voice
|
|
@@ -79,7 +94,8 @@ The whole loop, in order:
|
|
|
79
94
|
9. Make your cut from those files.
|
|
80
95
|
|
|
81
96
|
10. exodus video upload <runId> --file cut.mp4
|
|
82
|
-
Attaches your cut to the run and prints the page to
|
|
97
|
+
Attaches your cut to the run and prints the page to watch it on. On a
|
|
98
|
+
One-shot run the upload delivers the ad, so step 11 is not needed.
|
|
83
99
|
|
|
84
100
|
11. exodus video approve <runId>
|
|
85
101
|
Or click Approve on the page from step 10. If you redid a clip after
|
|
@@ -90,13 +106,14 @@ Usage:
|
|
|
90
106
|
exodus video status <runId> [--rejected-draft] [--json]
|
|
91
107
|
exodus video storyboard <runId> [--json]
|
|
92
108
|
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]
|
|
109
|
+
exodus video retry-frame <runId> [--node <nodeId>] --scene <n> [--note "..."] [--json]
|
|
110
|
+
exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "..."] [--voice-first] [--json]
|
|
95
111
|
exodus video revoice <runId> (--scene <n> | --all) [--node <nodeId>] [--json]
|
|
96
112
|
exodus video voices <runId> [--set <character>=<voiceId>] [--clear <character>] [--from <file.json>] [--json]
|
|
97
113
|
exodus video pull <runId> --out <dir> [--json]
|
|
98
114
|
exodus video upload <runId> --file <cut.mp4> [--duration <sec>] [--json]
|
|
99
|
-
exodus video start --script <file> --conceit
|
|
115
|
+
exodus video start --script <file> --conceit <kind> --style <look> [--direction "<note>"] [--frame pov] [--pacing <jump-cuts|long-take|montage>] [--voice-path <path>] [--video-model <id>] [--voice <native|voice-first>] [--music] [--review-storyboard] [--director] [--wait] [--json]
|
|
116
|
+
exodus video director <runId> <on|off|follow> [--json]
|
|
100
117
|
|
|
101
118
|
Options:
|
|
102
119
|
--out <dir> Folder to write the pulled pieces into (pull)
|
|
@@ -104,13 +121,17 @@ Options:
|
|
|
104
121
|
--duration <sec> How long your cut is, in seconds. Only needed when the
|
|
105
122
|
length can't be read off the file itself
|
|
106
123
|
--note "<text>" How to steer one redo (retry-frame, retry-clip)
|
|
107
|
-
--node <nodeId> Which scene-frames node holds the still (retry-frame
|
|
124
|
+
--node <nodeId> Which scene-frames node holds the still (retry-frame;
|
|
125
|
+
admins pass it, beta testers leave it off).
|
|
108
126
|
Which video step holds the clip, when a scene has one on
|
|
109
127
|
more than one step (retry-clip, revoice)
|
|
110
128
|
--scene <n> Which scene to redo. One scene only, a whole number
|
|
111
129
|
(retry-frame, retry-clip, revoice)
|
|
112
130
|
--all Every finished clip that kept the video model's voice
|
|
113
131
|
(revoice)
|
|
132
|
+
--voice-first Record the scene's line in the character's own voice,
|
|
133
|
+
then make the clip to match it (retry-clip). For a clip
|
|
134
|
+
whose words came out wrong
|
|
114
135
|
--approve-stale-cut Deliver the cut you uploaded even though a clip changed
|
|
115
136
|
after you uploaded it (approve). Without it, approving
|
|
116
137
|
stops and names the scenes that changed
|
|
@@ -123,17 +144,47 @@ Options:
|
|
|
123
144
|
--rejected-draft Print the storyboard draft the system turned down
|
|
124
145
|
(status). It was thrown away — a record only
|
|
125
146
|
--script <file> Text file of what the ad says (start)
|
|
126
|
-
--conceit <
|
|
127
|
-
--style <
|
|
147
|
+
--conceit <kind> Kind of ad (start)
|
|
148
|
+
--style <look> Look (start)
|
|
149
|
+
The server lists the kinds and looks it offers. An unknown word prints them.
|
|
128
150
|
--direction "<note>" Note for the whole ad (start)
|
|
151
|
+
--frame pov Camera-as-character POV: the camera is a person in the
|
|
152
|
+
scene (start, with --conceit). Leave it off for a
|
|
153
|
+
single frame. Conversation ads only
|
|
154
|
+
--pacing <pace> jump-cuts, long-take or montage (start, with
|
|
155
|
+
--conceit). Leave it off for natural pacing. Presenter
|
|
156
|
+
ads only
|
|
129
157
|
--voice-path <path> How the voices are made (start)
|
|
130
|
-
--
|
|
131
|
-
|
|
158
|
+
--video-model <id> Which video model films this ad, instead of the kind of
|
|
159
|
+
ad's own default (start, with --conceit)
|
|
160
|
+
--voice <mode> voice-first (the default): the lines are recorded
|
|
161
|
+
first. native: the video model speaks the lines
|
|
162
|
+
(start, with --conceit)
|
|
163
|
+
--music Put a music bed under the ad (start). An ad has no music
|
|
164
|
+
unless you ask for it here
|
|
165
|
+
--no-music Leave the music bed off (start), which it already is
|
|
166
|
+
--review-storyboard Stop at the storyboard so you can approve it yourself
|
|
167
|
+
(start, with --conceit). By default the app approves its
|
|
168
|
+
own storyboard when its checks pass
|
|
169
|
+
--director Turn the trial director on for this ad (start, with
|
|
170
|
+
--conceit). Admins only. To switch it on or off later:
|
|
171
|
+
exodus video director <runId> on|off|follow, where
|
|
172
|
+
follow goes back to the brand's own setting
|
|
173
|
+
--wait Wait until the run needs you (start): at the
|
|
174
|
+
storyboard, or once every piece is made
|
|
132
175
|
--json Machine-readable output
|
|
133
176
|
--help, -h Print this help
|
|
134
177
|
|
|
135
|
-
|
|
136
|
-
|
|
178
|
+
Who can use what. Admins get every command here. Beta testers with video
|
|
179
|
+
switched on get these, on their own brands: start (--script, --conceit,
|
|
180
|
+
--style, --direction, --frame, --pacing, --music, --review-storyboard),
|
|
181
|
+
status, storyboard, approve (the storyboard), voices, retry-frame --scene <n> (redo one scene's
|
|
182
|
+
picture while the storyboard waits), pull (the finished video, each scene's
|
|
183
|
+
clip and voice, and the music) and upload. These stay admin-only:
|
|
184
|
+
start --video-model / --voice / --voice-path / --director, director,
|
|
185
|
+
status --rejected-draft, retry-clip and revoice.
|
|
186
|
+
If a command answers "video isn't enabled for this key", the run id is wrong,
|
|
187
|
+
the command is admin-only, or video isn't switched on for your dashboard user.
|
|
137
188
|
|
|
138
189
|
Examples:
|
|
139
190
|
exodus workflow list
|
|
@@ -143,18 +194,21 @@ Examples:
|
|
|
143
194
|
exodus video approve run_123
|
|
144
195
|
exodus video retry-frame run_123 --node frames-1 --scene 2
|
|
145
196
|
exodus video retry-clip run_123 --scene 3 --note "keep the handshake in frame"
|
|
197
|
+
exodus video retry-clip run_123 --scene 3 --voice-first
|
|
146
198
|
exodus video revoice run_123 --all
|
|
147
199
|
exodus video voices run_123
|
|
148
200
|
exodus video voices run_123 --set C1=abc123voiceid --set "HOST 2=def456voiceid"
|
|
149
201
|
exodus video voices run_123 --from voices.json
|
|
150
202
|
exodus video pull run_123 --out ./ad-run_123
|
|
151
203
|
exodus video upload run_123 --file ./cut.mp4
|
|
204
|
+
exodus video director run_123 on
|
|
152
205
|
`.trim();
|
|
153
206
|
const SHOWS_PATH = "/api/v2/shows";
|
|
154
207
|
const RUNS_PATH = "/api/v2/video/runs";
|
|
155
208
|
const SCRIPT_RUNS_PATH = "/api/v2/video/script-runs";
|
|
156
|
-
const
|
|
157
|
-
const
|
|
209
|
+
const DIRECTOR_PATH = "/api/v2/video/director";
|
|
210
|
+
export const RUN_PATH = "/api/v2/workflow";
|
|
211
|
+
export const ITEMS_PATH = "/api/v2/workflow/items";
|
|
158
212
|
const REJECTED_DRAFT_PATH = "/api/v2/workflow/rejected-draft";
|
|
159
213
|
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
214
|
"with the next server update.";
|
|
@@ -163,12 +217,15 @@ const FLAG_PATH = "/api/v2/video/storyboard/flag";
|
|
|
163
217
|
const APPROVE_PATH = "/api/v2/workflow/approve";
|
|
164
218
|
const SCENE_RETRY_PATH = "/api/v2/workflow/scene/retry";
|
|
165
219
|
const SCENE_REVOICE_PATH = "/api/v2/workflow/scene/revoice";
|
|
220
|
+
const SCENE_VOICE_FIRST_PATH = "/api/v2/workflow/scene/voice-first";
|
|
221
|
+
const VOICE_FIRST_NOT_ON_THIS_SERVER = "This Exodus server cannot redo a clip voice first yet, so nothing was started and nothing " +
|
|
222
|
+
"was spent. It arrives with the next server update.";
|
|
166
223
|
const REVOICE_NOT_ON_THIS_SERVER = "This Exodus server does not have the voice redo yet, so nothing was started and nothing " +
|
|
167
224
|
"was spent. It arrives with the next server update.";
|
|
168
|
-
const FINAL_PATH = "/api/v2/video/final";
|
|
225
|
+
export const FINAL_PATH = "/api/v2/video/final";
|
|
169
226
|
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";
|
|
227
|
+
export const ASSET_UPLOAD_URL_PATH = "/api/v2/workflows/asset-upload-url";
|
|
228
|
+
export const ASSETS_PATH = "/api/v2/workflows/assets";
|
|
172
229
|
function withoutJudgeFields({ judgeDetail: _d, judgeSeverity: _s, ...rest }) {
|
|
173
230
|
return rest;
|
|
174
231
|
}
|
|
@@ -177,7 +234,7 @@ export function qcWithoutJudgeWording(qc) {
|
|
|
177
234
|
return qc;
|
|
178
235
|
return {
|
|
179
236
|
...qc,
|
|
180
|
-
takes: qc.takes.map(({ judgeWording: _w, ...rest }) => rest),
|
|
237
|
+
takes: qc.takes.map(({ judgeWording: _w, heardText: _h, ...rest }) => rest),
|
|
181
238
|
};
|
|
182
239
|
}
|
|
183
240
|
export const NO_TAKE_HISTORY_LINE = "no history recorded for this clip";
|
|
@@ -206,6 +263,9 @@ export function renderQcTakeHistory(takes) {
|
|
|
206
263
|
});
|
|
207
264
|
return lines;
|
|
208
265
|
}
|
|
266
|
+
function redoLine(lastRedo) {
|
|
267
|
+
return lastRedo.reason ? `${lastRedo.label}: ${lastRedo.reason}` : lastRedo.label;
|
|
268
|
+
}
|
|
209
269
|
function printDisplacedHistory(lines, lastRedo) {
|
|
210
270
|
const history = lastRedo.displacedHistory;
|
|
211
271
|
if (!history)
|
|
@@ -270,7 +330,9 @@ export const defaultDeps = {
|
|
|
270
330
|
dashboardUrl: getDashboardUrl(),
|
|
271
331
|
};
|
|
272
332
|
export const VIDEO_NOT_FOUND_MESSAGE = "video isn't enabled for this key\n" +
|
|
273
|
-
"
|
|
333
|
+
"One of three things: that run id doesn't exist on this brand, this command is for admins only " +
|
|
334
|
+
"(director, status --rejected-draft, retry-clip, revoice), or video isn't " +
|
|
335
|
+
"switched on for your dashboard user on this brand. Check the id first, then ask Brad.";
|
|
274
336
|
export function videoApiError(res) {
|
|
275
337
|
const behind = missingRouteLine(res, "exodus video");
|
|
276
338
|
if (behind)
|
|
@@ -279,6 +341,9 @@ export function videoApiError(res) {
|
|
|
279
341
|
return VIDEO_NOT_FOUND_MESSAGE;
|
|
280
342
|
return formatApiError(res);
|
|
281
343
|
}
|
|
344
|
+
function adminOnlyResult(line, json) {
|
|
345
|
+
return { code: 1, lines: json ? [JSON.stringify({ ok: false, error: line })] : [line] };
|
|
346
|
+
}
|
|
282
347
|
function errorResult(res, json) {
|
|
283
348
|
return {
|
|
284
349
|
code: 1,
|
|
@@ -301,6 +366,14 @@ function parkedAtFinalWatch(run) {
|
|
|
301
366
|
return (!parkedByBuilderCheckpoint(run) &&
|
|
302
367
|
run.nodes.some((n) => n.kind === "video" && n.status === "done"));
|
|
303
368
|
}
|
|
369
|
+
function heldReason(run) {
|
|
370
|
+
const verdict = run.storyboardAutoApproval;
|
|
371
|
+
if (verdict?.outcome !== "held")
|
|
372
|
+
return undefined;
|
|
373
|
+
const reason = verdict.reason?.trim() ||
|
|
374
|
+
"The app didn't approve this storyboard by itself because its checks found a problem.";
|
|
375
|
+
return `${reason} (checked ${new Date(verdict.at).toISOString()})`;
|
|
376
|
+
}
|
|
304
377
|
export function classifyRun(run) {
|
|
305
378
|
if (run.status === "failed")
|
|
306
379
|
return { at: "failed", error: run.error };
|
|
@@ -320,6 +393,7 @@ export function classifyRun(run) {
|
|
|
320
393
|
}
|
|
321
394
|
const pausedNode = run.nodes.find((n) => n.nodeId === run.pausedNodeId);
|
|
322
395
|
if (pausedNode && parkedAtStoryboardGate(run, pausedNode)) {
|
|
396
|
+
const held = heldReason(run);
|
|
323
397
|
return {
|
|
324
398
|
at: "storyboard-gate",
|
|
325
399
|
nodeId: pausedNode.nodeId,
|
|
@@ -327,10 +401,31 @@ export function classifyRun(run) {
|
|
|
327
401
|
? { framesNodeId: pausedNode.nodeId }
|
|
328
402
|
: {}),
|
|
329
403
|
...(isShowAd(run) ? { showAd: true } : {}),
|
|
404
|
+
...(held ? { heldReason: held } : {}),
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
if (parkedAtFinalWatch(run)) {
|
|
408
|
+
const videoNodes = run.nodes.filter((n) => n.kind === "video");
|
|
409
|
+
const autoRedoScenes = videoNodes.flatMap((n) => n.autoRedoScenes ?? []);
|
|
410
|
+
const autoRedoReasons = videoNodes.flatMap((n) => n.autoRedoReasons ?? []);
|
|
411
|
+
if (autoRedoScenes.length > 0) {
|
|
412
|
+
return {
|
|
413
|
+
at: "running",
|
|
414
|
+
stage: "video",
|
|
415
|
+
autoRedoScenes,
|
|
416
|
+
...(autoRedoReasons.length > 0 ? { autoRedoReasons } : {}),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
const missingScenes = run.nodes
|
|
420
|
+
.filter((n) => n.kind === "video")
|
|
421
|
+
.flatMap((n) => n.missingScenes ?? []);
|
|
422
|
+
const framesNodeId = run.nodes.find((n) => n.kind === "scene-frames")?.nodeId;
|
|
423
|
+
return {
|
|
424
|
+
at: "final-watch",
|
|
425
|
+
...(missingScenes.length > 0 ? { missingScenes } : {}),
|
|
426
|
+
...(missingScenes.length > 0 && framesNodeId ? { framesNodeId } : {}),
|
|
330
427
|
};
|
|
331
428
|
}
|
|
332
|
-
if (parkedAtFinalWatch(run))
|
|
333
|
-
return { at: "final-watch" };
|
|
334
429
|
return { at: "paused", nodeId: run.pausedNodeId, reason: run.pauseReason };
|
|
335
430
|
}
|
|
336
431
|
const active = run.nodes.find((n) => n.status === "running");
|
|
@@ -339,10 +434,22 @@ export function classifyRun(run) {
|
|
|
339
434
|
export function hasAttachedCut(items) {
|
|
340
435
|
return items.some((i) => i.itemKind === "final" && i.status === "done");
|
|
341
436
|
}
|
|
437
|
+
export function scenesBeingRedone(run, items) {
|
|
438
|
+
const scenes = new Set(run.nodes.filter((n) => n.kind === "video").flatMap((n) => n.autoRedoScenes ?? []));
|
|
439
|
+
for (const i of items) {
|
|
440
|
+
if (i.itemKind === "clip" &&
|
|
441
|
+
i.status === "running" &&
|
|
442
|
+
(i.attempt ?? 1) > 1 &&
|
|
443
|
+
i.staleClaim !== true) {
|
|
444
|
+
scenes.add(i.sceneIndex);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return [...scenes].sort((a, b) => a - b);
|
|
448
|
+
}
|
|
342
449
|
export function resolveStop(stop, cutAttached) {
|
|
343
450
|
if (stop.at !== "final-watch")
|
|
344
451
|
return stop;
|
|
345
|
-
return {
|
|
452
|
+
return { ...stop, cutAttached };
|
|
346
453
|
}
|
|
347
454
|
export async function resolveStopAtPark(stop, runId, deps) {
|
|
348
455
|
if (stop.at !== "final-watch")
|
|
@@ -381,15 +488,38 @@ export function isShowAd(run) {
|
|
|
381
488
|
return run.moduleOwned === true;
|
|
382
489
|
}
|
|
383
490
|
export function reviewUrl(dashboardUrl, run) {
|
|
384
|
-
if (isShowAd(run))
|
|
385
|
-
return `${dashboardUrl}/video?ad=${run._id}`;
|
|
386
491
|
if (run.workflowId)
|
|
387
492
|
return `${dashboardUrl}/workflows/${run.workflowId}/runs/${run._id}`;
|
|
388
493
|
return `${dashboardUrl}/runs/${run._id}`;
|
|
389
494
|
}
|
|
495
|
+
function scenesPhrase(scenes) {
|
|
496
|
+
if (scenes.length === 1)
|
|
497
|
+
return `scene ${scenes[0]}`;
|
|
498
|
+
return `scenes ${scenes.slice(0, -1).join(", ")} and ${scenes[scenes.length - 1]}`;
|
|
499
|
+
}
|
|
500
|
+
function missingSceneLines(missing, framesNodeId, runId, runUrl) {
|
|
501
|
+
const scenes = missing.map((m) => m.sceneIndex);
|
|
502
|
+
const lines = [
|
|
503
|
+
`Parked: ${scenesPhrase(scenes)} ${scenes.length === 1 ? "has" : "have"} no clip yet, so the ad isn't complete.`,
|
|
504
|
+
];
|
|
505
|
+
for (const { sceneIndex, pictureRefused } of missing) {
|
|
506
|
+
if (!pictureRefused) {
|
|
507
|
+
lines.push(`Redo clip ${sceneIndex}: exodus video retry-clip ${runId} --scene ${sceneIndex}`);
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
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.`);
|
|
511
|
+
lines.push(framesNodeId
|
|
512
|
+
? `Redraw frame ${sceneIndex}: exodus video retry-frame ${runId} --node ${framesNodeId} --scene ${sceneIndex} --note "<what to change>"`
|
|
513
|
+
: `Redraw frame ${sceneIndex}: on the run page, ${runUrl}`);
|
|
514
|
+
lines.push(`Then redo clip ${sceneIndex}: exodus video retry-clip ${runId} --scene ${sceneIndex}`);
|
|
515
|
+
}
|
|
516
|
+
lines.push("The ad can't be approved until every scene has a clip.", `Watch it here: ${runUrl}`);
|
|
517
|
+
return lines;
|
|
518
|
+
}
|
|
390
519
|
export function stopLines(stop, runId, runUrl) {
|
|
391
520
|
if (stop.at === "storyboard-gate") {
|
|
392
521
|
const lines = [
|
|
522
|
+
...(stop.heldReason ? [stop.heldReason] : []),
|
|
393
523
|
"Parked: the storyboard is waiting for your yes.",
|
|
394
524
|
`Read it: exodus video storyboard ${runId}`,
|
|
395
525
|
`Approve it: exodus video approve ${runId}`,
|
|
@@ -403,6 +533,9 @@ export function stopLines(stop, runId, runUrl) {
|
|
|
403
533
|
return lines;
|
|
404
534
|
}
|
|
405
535
|
if (stop.at === "final-watch") {
|
|
536
|
+
if (stop.missingScenes?.length) {
|
|
537
|
+
return missingSceneLines(stop.missingScenes, stop.framesNodeId, runId, runUrl);
|
|
538
|
+
}
|
|
406
539
|
if (stop.cutAttached === null) {
|
|
407
540
|
return [
|
|
408
541
|
"Parked: every piece is made.",
|
|
@@ -458,8 +591,28 @@ export function stopLines(stop, runId, runUrl) {
|
|
|
458
591
|
`Open it: ${runUrl}`,
|
|
459
592
|
];
|
|
460
593
|
}
|
|
594
|
+
if (stop.autoRedoScenes?.length)
|
|
595
|
+
return [autoRedoLine(stop.autoRedoScenes, stop.autoRedoReasons)];
|
|
461
596
|
return [`Working: ${stageWord(stop.stage)}.`];
|
|
462
597
|
}
|
|
598
|
+
function autoRedoLine(scenes, reasons = []) {
|
|
599
|
+
const reasonOf = new Map(reasons.map((r) => [r.sceneIndex, r.reason]));
|
|
600
|
+
const groups = new Map();
|
|
601
|
+
for (const scene of [...scenes].sort((a, b) => a - b)) {
|
|
602
|
+
const reason = reasonOf.get(scene);
|
|
603
|
+
groups.set(reason, [...(groups.get(reason) ?? []), scene]);
|
|
604
|
+
}
|
|
605
|
+
const one = scenes.length === 1;
|
|
606
|
+
const clauses = [...groups].map(([reason, group], i) => {
|
|
607
|
+
const own = i === 0 ? ` on ${group.length === 1 ? "its" : "their"} own` : "";
|
|
608
|
+
return `${scenesPhrase(group)}${own}${reason ? ` because ${reason}` : ""}`;
|
|
609
|
+
});
|
|
610
|
+
const listed = clauses.length === 1
|
|
611
|
+
? clauses[0]
|
|
612
|
+
: `${clauses.slice(0, -1).join(", ")}, and ${clauses[clauses.length - 1]}`;
|
|
613
|
+
return (`Working: the app is redoing ${listed}. ` +
|
|
614
|
+
`The ad isn't ready to watch until ${one ? "it lands" : "they land"}.`);
|
|
615
|
+
}
|
|
463
616
|
export function failedStoryboardNode(run) {
|
|
464
617
|
return run.nodes.find((n) => n.kind === "storyboard" && n.status === "failed");
|
|
465
618
|
}
|
|
@@ -484,7 +637,13 @@ export function planFailureLines(node, runId) {
|
|
|
484
637
|
}
|
|
485
638
|
return lines;
|
|
486
639
|
}
|
|
640
|
+
export const MUSIC_HEARD_CODE = "music-heard";
|
|
487
641
|
export const CAST_LEDGER_BASE = HELPER_LEDGER_BASE + 10000;
|
|
642
|
+
export function musicBedState(run) {
|
|
643
|
+
if (run.musicBed === undefined)
|
|
644
|
+
return "unknown";
|
|
645
|
+
return run.musicBed ? "on" : "off";
|
|
646
|
+
}
|
|
488
647
|
function isPullSceneIndex(sceneIndex) {
|
|
489
648
|
return sceneIndex >= 0 && sceneIndex < HELPER_LEDGER_BASE;
|
|
490
649
|
}
|
|
@@ -526,6 +685,10 @@ function clipFromArtifact(sceneIndex, artifact) {
|
|
|
526
685
|
revoiced: artifact.revoiced === true,
|
|
527
686
|
speechTrimmed: artifact.speechTrimmed === true,
|
|
528
687
|
rawStorageId: artifact.rawStorageId ?? null,
|
|
688
|
+
voiceMode: artifact.voiceMode ?? null,
|
|
689
|
+
voiceFirstBlockedBy: artifact.voiceFirstBlockedBy ?? null,
|
|
690
|
+
voicePitchHz: artifact.voicePitchHz ?? null,
|
|
691
|
+
voicePitchMethod: artifact.voicePitchMethod ?? null,
|
|
529
692
|
};
|
|
530
693
|
}
|
|
531
694
|
function keyframeDownload(sceneIndex, imageUrl) {
|
|
@@ -677,14 +840,36 @@ export function planPull(run, items, opts) {
|
|
|
677
840
|
}
|
|
678
841
|
}
|
|
679
842
|
let reference = null;
|
|
843
|
+
let productSheet = null;
|
|
844
|
+
let setPlate = null;
|
|
845
|
+
const filledByStage = new Map();
|
|
680
846
|
for (const artifact of outputsOfNodeKind(run, "reference")) {
|
|
681
847
|
if (artifact.type !== "image" || !artifact.imageUrl)
|
|
682
848
|
continue;
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
849
|
+
if (artifact.anchor === "filled-plate") {
|
|
850
|
+
const holds = artifact.holds ?? [];
|
|
851
|
+
const lid = artifact.lid;
|
|
852
|
+
filledByStage.set(JSON.stringify([holds, lid ?? null]), { holds, url: artifact.imageUrl, ...(lid ? { lid } : {}) });
|
|
853
|
+
continue;
|
|
854
|
+
}
|
|
855
|
+
const stem = artifact.anchor ?? "reference";
|
|
856
|
+
const download = { file: `${stem}.${extFor(artifact.imageUrl, "image")}`, url: artifact.imageUrl };
|
|
857
|
+
if (artifact.anchor === "product-sheet")
|
|
858
|
+
productSheet = download;
|
|
859
|
+
else if (artifact.anchor === "set-plate")
|
|
860
|
+
setPlate = download;
|
|
861
|
+
else if (!artifact.anchor)
|
|
862
|
+
reference = download;
|
|
863
|
+
}
|
|
864
|
+
const stageKeys = [...new Set([...filledByStage.values()].map((plate) => JSON.stringify(plate.holds)))];
|
|
865
|
+
const filledPlates = [...filledByStage.values()].map((plate) => ({
|
|
866
|
+
holds: plate.holds,
|
|
867
|
+
...(plate.lid ? { lid: plate.lid } : {}),
|
|
868
|
+
download: {
|
|
869
|
+
file: `filled-plate-${stageKeys.indexOf(JSON.stringify(plate.holds)) + 1}${plate.lid ? "-lid-closed" : ""}.${extFor(plate.url, "image")}`,
|
|
870
|
+
url: plate.url,
|
|
871
|
+
},
|
|
872
|
+
}));
|
|
688
873
|
const keyframeByScene = new Map();
|
|
689
874
|
for (const artifact of run.nodes.flatMap((n) => n.outputs ?? [])) {
|
|
690
875
|
if (artifact.type !== "frames")
|
|
@@ -787,6 +972,9 @@ export function planPull(run, items, opts) {
|
|
|
787
972
|
continue;
|
|
788
973
|
keyframeByScene.set(sceneIndex, keyframeDownload(sceneIndex, artifact.imageUrl));
|
|
789
974
|
}
|
|
975
|
+
const pendingRedo = scenesBeingRedone(run, items);
|
|
976
|
+
for (const sceneIndex of pendingRedo)
|
|
977
|
+
clipByScene.delete(sceneIndex);
|
|
790
978
|
const sceneIndexes = [
|
|
791
979
|
...new Set([
|
|
792
980
|
...clipByScene.keys(),
|
|
@@ -835,6 +1023,10 @@ export function planPull(run, items, opts) {
|
|
|
835
1023
|
revoiced: clip ? clip.revoiced : null,
|
|
836
1024
|
speechTrimmed: clip ? clip.speechTrimmed : null,
|
|
837
1025
|
rawStorageId: clip ? clip.rawStorageId : null,
|
|
1026
|
+
voiceMode: clip ? clip.voiceMode : null,
|
|
1027
|
+
voiceFirstBlockedBy: clip ? clip.voiceFirstBlockedBy : null,
|
|
1028
|
+
voicePitchHz: clip ? clip.voicePitchHz : null,
|
|
1029
|
+
voicePitchMethod: clip ? clip.voicePitchMethod : null,
|
|
838
1030
|
clipStatus: item?.status ?? "missing",
|
|
839
1031
|
error: item?.error ?? null,
|
|
840
1032
|
flagged: item?.flagged === true,
|
|
@@ -845,6 +1037,9 @@ export function planPull(run, items, opts) {
|
|
|
845
1037
|
const { cast, downloads: castDownloads } = planCastRefs(run, items, castVoicePins(storyboardArtifact));
|
|
846
1038
|
const downloads = [
|
|
847
1039
|
...(reference ? [reference] : []),
|
|
1040
|
+
...(productSheet ? [productSheet] : []),
|
|
1041
|
+
...(setPlate ? [setPlate] : []),
|
|
1042
|
+
...filledPlates.map((p) => p.download),
|
|
848
1043
|
...keyframeByScene.values(),
|
|
849
1044
|
...[...voiceByScene.values()].map((v) => v.download),
|
|
850
1045
|
...(narrationDownload ? [narrationDownload] : []),
|
|
@@ -861,7 +1056,15 @@ export function planPull(run, items, opts) {
|
|
|
861
1056
|
dashboardUrl: reviewUrl(opts.dashboardUrl, run),
|
|
862
1057
|
storyboard,
|
|
863
1058
|
reference: reference?.file ?? null,
|
|
1059
|
+
productSheet: productSheet?.file ?? null,
|
|
1060
|
+
setPlate: setPlate?.file ?? null,
|
|
1061
|
+
filledPlates: filledPlates.map((p) => ({ file: p.download.file, holds: p.holds, ...(p.lid ? { lid: p.lid } : {}) })),
|
|
864
1062
|
music: music?.file ?? null,
|
|
1063
|
+
musicBed: musicBedState(run),
|
|
1064
|
+
musicHeardScenes: scenes
|
|
1065
|
+
.filter((scene) => scene.findings.some((f) => f.code === MUSIC_HEARD_CODE))
|
|
1066
|
+
.map((scene) => scene.sceneIndex),
|
|
1067
|
+
pendingRedo,
|
|
865
1068
|
cast,
|
|
866
1069
|
narration: narrationDownload
|
|
867
1070
|
? { file: narrationDownload.file, timing: "narration.json" }
|
|
@@ -871,6 +1074,15 @@ export function planPull(run, items, opts) {
|
|
|
871
1074
|
...(run.provenance?.format || run.provenance?.voice
|
|
872
1075
|
? { provenance: run.provenance }
|
|
873
1076
|
: {}),
|
|
1077
|
+
...(run.videoChoice
|
|
1078
|
+
? {
|
|
1079
|
+
videoChoice: {
|
|
1080
|
+
videoModel: run.videoChoice.videoModel,
|
|
1081
|
+
voiceMode: run.videoChoice.voiceMode,
|
|
1082
|
+
},
|
|
1083
|
+
}
|
|
1084
|
+
: {}),
|
|
1085
|
+
...(run.shots?.frame || run.shots?.pacing ? { shots: run.shots } : {}),
|
|
874
1086
|
},
|
|
875
1087
|
};
|
|
876
1088
|
}
|
|
@@ -880,6 +1092,14 @@ export function markPullFailure(manifest, failure) {
|
|
|
880
1092
|
manifest.storyboard = null;
|
|
881
1093
|
if (manifest.reference === failure.file)
|
|
882
1094
|
manifest.reference = null;
|
|
1095
|
+
if (manifest.productSheet === failure.file)
|
|
1096
|
+
manifest.productSheet = null;
|
|
1097
|
+
if (manifest.setPlate === failure.file)
|
|
1098
|
+
manifest.setPlate = null;
|
|
1099
|
+
for (const plate of manifest.filledPlates) {
|
|
1100
|
+
if (plate.file === failure.file)
|
|
1101
|
+
plate.file = null;
|
|
1102
|
+
}
|
|
883
1103
|
if (manifest.music === failure.file)
|
|
884
1104
|
manifest.music = null;
|
|
885
1105
|
for (const ref of manifest.cast) {
|
|
@@ -896,6 +1116,10 @@ export function markPullFailure(manifest, failure) {
|
|
|
896
1116
|
scene.revoiced = null;
|
|
897
1117
|
scene.speechTrimmed = null;
|
|
898
1118
|
scene.rawStorageId = null;
|
|
1119
|
+
scene.voiceMode = null;
|
|
1120
|
+
scene.voiceFirstBlockedBy = null;
|
|
1121
|
+
scene.voicePitchHz = null;
|
|
1122
|
+
scene.voicePitchMethod = null;
|
|
899
1123
|
}
|
|
900
1124
|
if (scene.voice === failure.file)
|
|
901
1125
|
scene.voice = null;
|
|
@@ -1001,7 +1225,7 @@ export async function startFlow(opts, deps) {
|
|
|
1001
1225
|
showId: opts.showId,
|
|
1002
1226
|
script,
|
|
1003
1227
|
...(opts.voicePath ? { voicePath: opts.voicePath } : {}),
|
|
1004
|
-
...(opts.music ===
|
|
1228
|
+
...(opts.music === undefined ? {} : { music: opts.music }),
|
|
1005
1229
|
});
|
|
1006
1230
|
if (!res.ok)
|
|
1007
1231
|
return errorResult(res, opts.json);
|
|
@@ -1035,10 +1259,48 @@ function nonBlank(value) {
|
|
|
1035
1259
|
return undefined;
|
|
1036
1260
|
return value;
|
|
1037
1261
|
}
|
|
1038
|
-
function
|
|
1039
|
-
return
|
|
1262
|
+
function isVoiceMode(value) {
|
|
1263
|
+
return VOICE_MODES.some((mode) => mode === value);
|
|
1264
|
+
}
|
|
1265
|
+
export function planMusicChoice(occurrences) {
|
|
1266
|
+
let music;
|
|
1267
|
+
for (const { flag, value } of occurrences) {
|
|
1268
|
+
if (flag === "no-music") {
|
|
1269
|
+
music = false;
|
|
1270
|
+
continue;
|
|
1271
|
+
}
|
|
1272
|
+
if (flag !== "music")
|
|
1273
|
+
continue;
|
|
1274
|
+
if (value === undefined || value.startsWith("--")) {
|
|
1275
|
+
music = true;
|
|
1276
|
+
continue;
|
|
1277
|
+
}
|
|
1278
|
+
const word = value.trim().toLowerCase();
|
|
1279
|
+
const literal = word === "true" ? true : word === "false" ? false : undefined;
|
|
1280
|
+
if (literal === undefined) {
|
|
1281
|
+
return {
|
|
1282
|
+
ok: false,
|
|
1283
|
+
line: `video start --music takes true or false, or no value at all (got "${value}").`,
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
1286
|
+
music = literal;
|
|
1287
|
+
}
|
|
1288
|
+
return { ok: true, music };
|
|
1289
|
+
}
|
|
1290
|
+
function planValueFlag(occurrences, name) {
|
|
1291
|
+
let value;
|
|
1292
|
+
for (const occurrence of occurrences) {
|
|
1293
|
+
if (occurrence.flag !== name)
|
|
1294
|
+
continue;
|
|
1295
|
+
const given = occurrence.value;
|
|
1296
|
+
if (given === undefined || given.startsWith("--") || given.trim().length === 0) {
|
|
1297
|
+
return { ok: false, line: `video start --${name} needs a value.` };
|
|
1298
|
+
}
|
|
1299
|
+
value = given.trim();
|
|
1300
|
+
}
|
|
1301
|
+
return { ok: true, value };
|
|
1040
1302
|
}
|
|
1041
|
-
export function planVideoStart(flags) {
|
|
1303
|
+
export function planVideoStart(flags, occurrences) {
|
|
1042
1304
|
const showId = nonBlank(flagString(flags, "show"));
|
|
1043
1305
|
const scriptFile = nonBlank(flagString(flags, "script"));
|
|
1044
1306
|
const conceit = nonBlank(flagString(flags, "conceit"));
|
|
@@ -1055,6 +1317,62 @@ export function planVideoStart(flags) {
|
|
|
1055
1317
|
line: "video start needs --script <file>, a text file of what the ad says.",
|
|
1056
1318
|
};
|
|
1057
1319
|
}
|
|
1320
|
+
const musicChoice = planMusicChoice(occurrences);
|
|
1321
|
+
if (!musicChoice.ok)
|
|
1322
|
+
return { kind: "usage", line: musicChoice.line };
|
|
1323
|
+
const videoModelFlag = planValueFlag(occurrences, "video-model");
|
|
1324
|
+
if (!videoModelFlag.ok)
|
|
1325
|
+
return { kind: "usage", line: videoModelFlag.line };
|
|
1326
|
+
const voiceFlag = planValueFlag(occurrences, "voice");
|
|
1327
|
+
if (!voiceFlag.ok)
|
|
1328
|
+
return { kind: "usage", line: voiceFlag.line };
|
|
1329
|
+
const frameFlag = planValueFlag(occurrences, "frame");
|
|
1330
|
+
if (!frameFlag.ok)
|
|
1331
|
+
return { kind: "usage", line: frameFlag.line };
|
|
1332
|
+
const pacingFlag = planValueFlag(occurrences, "pacing");
|
|
1333
|
+
if (!pacingFlag.ok)
|
|
1334
|
+
return { kind: "usage", line: pacingFlag.line };
|
|
1335
|
+
const videoModel = videoModelFlag.value;
|
|
1336
|
+
const voiceMode = voiceFlag.value;
|
|
1337
|
+
const reviewStoryboard = occurrences.some((o) => o.flag === "review-storyboard");
|
|
1338
|
+
if (showId && reviewStoryboard) {
|
|
1339
|
+
return {
|
|
1340
|
+
kind: "usage",
|
|
1341
|
+
line: "--review-storyboard works with --conceit, not --show. A Show's ad always stops at the storyboard for you.",
|
|
1342
|
+
};
|
|
1343
|
+
}
|
|
1344
|
+
const directorFlags = occurrences.filter((o) => o.flag === "director");
|
|
1345
|
+
if (directorFlags.some((o) => o.value !== undefined && !o.value.startsWith("--"))) {
|
|
1346
|
+
return {
|
|
1347
|
+
kind: "usage",
|
|
1348
|
+
line: "--director takes no value. Leave it off to start without the director.",
|
|
1349
|
+
};
|
|
1350
|
+
}
|
|
1351
|
+
const director = directorFlags.length > 0;
|
|
1352
|
+
if (showId && director) {
|
|
1353
|
+
return {
|
|
1354
|
+
kind: "usage",
|
|
1355
|
+
line: "--director works with --conceit, not --show.",
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
if (showId && (frameFlag.value || pacingFlag.value)) {
|
|
1359
|
+
return {
|
|
1360
|
+
kind: "usage",
|
|
1361
|
+
line: "--frame and --pacing work with --conceit, not --show.",
|
|
1362
|
+
};
|
|
1363
|
+
}
|
|
1364
|
+
if (showId && (videoModel || voiceMode)) {
|
|
1365
|
+
return {
|
|
1366
|
+
kind: "usage",
|
|
1367
|
+
line: "--video-model and --voice work with --conceit, not --show. A Show films on its own model.",
|
|
1368
|
+
};
|
|
1369
|
+
}
|
|
1370
|
+
if (voiceMode && !isVoiceMode(voiceMode)) {
|
|
1371
|
+
return {
|
|
1372
|
+
kind: "usage",
|
|
1373
|
+
line: `video start --voice must be native or voice-first (got "${voiceMode}").`,
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1058
1376
|
if (showId) {
|
|
1059
1377
|
return {
|
|
1060
1378
|
kind: "show",
|
|
@@ -1062,7 +1380,7 @@ export function planVideoStart(flags) {
|
|
|
1062
1380
|
showId,
|
|
1063
1381
|
scriptFile,
|
|
1064
1382
|
voicePath: flagString(flags, "voice-path"),
|
|
1065
|
-
music:
|
|
1383
|
+
music: musicChoice.music,
|
|
1066
1384
|
wait: flags["wait"] === true,
|
|
1067
1385
|
json: flags["json"] === true,
|
|
1068
1386
|
},
|
|
@@ -1071,19 +1389,13 @@ export function planVideoStart(flags) {
|
|
|
1071
1389
|
if (!conceit) {
|
|
1072
1390
|
return {
|
|
1073
1391
|
kind: "usage",
|
|
1074
|
-
line:
|
|
1075
|
-
};
|
|
1076
|
-
}
|
|
1077
|
-
if (!isConceit(conceit)) {
|
|
1078
|
-
return {
|
|
1079
|
-
kind: "usage",
|
|
1080
|
-
line: `video start --conceit must be one of ${joinedOr(CONCEIT_KEYS)}.`,
|
|
1392
|
+
line: "video start needs --conceit <kind>.",
|
|
1081
1393
|
};
|
|
1082
1394
|
}
|
|
1083
1395
|
if (!style) {
|
|
1084
1396
|
return {
|
|
1085
1397
|
kind: "usage",
|
|
1086
|
-
line:
|
|
1398
|
+
line: "video start needs --style <look>.",
|
|
1087
1399
|
};
|
|
1088
1400
|
}
|
|
1089
1401
|
const direction = nonBlank(flagString(flags, "direction"));
|
|
@@ -1099,13 +1411,45 @@ export function planVideoStart(flags) {
|
|
|
1099
1411
|
opts.direction = direction;
|
|
1100
1412
|
if (voicePath)
|
|
1101
1413
|
opts.voicePath = voicePath;
|
|
1102
|
-
if (
|
|
1103
|
-
opts.
|
|
1414
|
+
if (videoModel)
|
|
1415
|
+
opts.videoModel = videoModel;
|
|
1416
|
+
if (voiceMode && isVoiceMode(voiceMode))
|
|
1417
|
+
opts.voiceMode = voiceMode;
|
|
1418
|
+
if (musicChoice.music !== undefined)
|
|
1419
|
+
opts.music = musicChoice.music;
|
|
1420
|
+
if (reviewStoryboard)
|
|
1421
|
+
opts.reviewStoryboard = true;
|
|
1422
|
+
if (director)
|
|
1423
|
+
opts.director = true;
|
|
1424
|
+
if (frameFlag.value || pacingFlag.value) {
|
|
1425
|
+
opts.shots = {
|
|
1426
|
+
...(frameFlag.value ? { frame: frameFlag.value } : {}),
|
|
1427
|
+
...(pacingFlag.value ? { pacing: pacingFlag.value } : {}),
|
|
1428
|
+
};
|
|
1429
|
+
}
|
|
1104
1430
|
return { kind: "script", opts };
|
|
1105
1431
|
}
|
|
1106
1432
|
function isRecord(value) {
|
|
1107
1433
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1108
1434
|
}
|
|
1435
|
+
function scriptRunOptionsOf(data) {
|
|
1436
|
+
if (!isRecord(data) || !Array.isArray(data.conceits) || !Array.isArray(data.styles)) {
|
|
1437
|
+
return null;
|
|
1438
|
+
}
|
|
1439
|
+
const conceits = [];
|
|
1440
|
+
for (const row of data.conceits) {
|
|
1441
|
+
if (!isRecord(row) || typeof row.key !== "string" || typeof row.label !== "string")
|
|
1442
|
+
return null;
|
|
1443
|
+
conceits.push({ key: row.key, label: row.label });
|
|
1444
|
+
}
|
|
1445
|
+
const styles = [];
|
|
1446
|
+
for (const row of data.styles) {
|
|
1447
|
+
if (!isRecord(row) || typeof row.slug !== "string" || typeof row.label !== "string")
|
|
1448
|
+
return null;
|
|
1449
|
+
styles.push({ slug: row.slug, label: row.label });
|
|
1450
|
+
}
|
|
1451
|
+
return { conceits, styles };
|
|
1452
|
+
}
|
|
1109
1453
|
function isResolvedCard(value) {
|
|
1110
1454
|
if (!isRecord(value))
|
|
1111
1455
|
return false;
|
|
@@ -1123,7 +1467,14 @@ function isResolvedCard(value) {
|
|
|
1123
1467
|
return false;
|
|
1124
1468
|
return true;
|
|
1125
1469
|
}
|
|
1126
|
-
function
|
|
1470
|
+
function startedVideoChoice(value) {
|
|
1471
|
+
if (!isRecord(value))
|
|
1472
|
+
return null;
|
|
1473
|
+
if (typeof value.videoModel !== "string" || typeof value.voiceMode !== "string")
|
|
1474
|
+
return null;
|
|
1475
|
+
return { videoModel: value.videoModel, voiceMode: value.voiceMode };
|
|
1476
|
+
}
|
|
1477
|
+
function scriptStartedLines(runId, url, card, video, director) {
|
|
1127
1478
|
return [
|
|
1128
1479
|
`Started ad run ${runId}`,
|
|
1129
1480
|
...(card
|
|
@@ -1134,10 +1485,12 @@ function scriptStartedLines(runId, url, card) {
|
|
|
1134
1485
|
`Style: ${card.style.label}`,
|
|
1135
1486
|
]
|
|
1136
1487
|
: ["The server did not include a resolved card."]),
|
|
1488
|
+
...(video ? [`Video model: ${video.videoModel}`, `Voice: ${video.voiceMode}`] : []),
|
|
1489
|
+
...(director ? ["Director: on for this ad"] : []),
|
|
1137
1490
|
`Watch it: ${url}`,
|
|
1138
1491
|
];
|
|
1139
1492
|
}
|
|
1140
|
-
function waitJsonWithCard(waited, card) {
|
|
1493
|
+
function waitJsonWithCard(waited, card, video) {
|
|
1141
1494
|
const first = waited.lines[0];
|
|
1142
1495
|
if (typeof first !== "string")
|
|
1143
1496
|
return waited;
|
|
@@ -1150,7 +1503,10 @@ function waitJsonWithCard(waited, card) {
|
|
|
1150
1503
|
}
|
|
1151
1504
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
1152
1505
|
return waited;
|
|
1153
|
-
return {
|
|
1506
|
+
return {
|
|
1507
|
+
code: waited.code,
|
|
1508
|
+
lines: [JSON.stringify({ ...parsed, card, ...(video ? { videoChoice: video } : {}) })],
|
|
1509
|
+
};
|
|
1154
1510
|
}
|
|
1155
1511
|
export async function startScriptFlow(opts, deps) {
|
|
1156
1512
|
let script;
|
|
@@ -1168,16 +1524,35 @@ export async function startScriptFlow(opts, deps) {
|
|
|
1168
1524
|
if (script.trim().length === 0) {
|
|
1169
1525
|
return { code: 1, lines: [`The script file "${opts.scriptFile}" is empty.`] };
|
|
1170
1526
|
}
|
|
1527
|
+
const listed = await deps.get(SCRIPT_RUNS_PATH).catch(() => null);
|
|
1528
|
+
if (listed?.ok) {
|
|
1529
|
+
const options = scriptRunOptionsOf(listed.data);
|
|
1530
|
+
if (!options) {
|
|
1531
|
+
return adminOnlyResult("The server's list of kinds and looks could not be read.", opts.json);
|
|
1532
|
+
}
|
|
1533
|
+
const problem = scriptRunPickProblem(options, opts.conceit, opts.style);
|
|
1534
|
+
if (problem) {
|
|
1535
|
+
return opts.json ? adminOnlyResult(problem, true) : { code: 1, lines: [problem], usage: true };
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1171
1538
|
const res = await deps.post(SCRIPT_RUNS_PATH, {
|
|
1172
1539
|
script,
|
|
1173
1540
|
conceit: opts.conceit,
|
|
1174
1541
|
style: opts.style,
|
|
1175
1542
|
...(opts.direction ? { direction: opts.direction } : {}),
|
|
1176
1543
|
...(opts.voicePath ? { voicePath: opts.voicePath } : {}),
|
|
1177
|
-
...(opts.
|
|
1544
|
+
...(opts.videoModel ? { videoModel: opts.videoModel } : {}),
|
|
1545
|
+
...(opts.voiceMode ? { voiceMode: opts.voiceMode } : {}),
|
|
1546
|
+
...(opts.music === true ? { music: true } : {}),
|
|
1547
|
+
...(opts.reviewStoryboard ? { reviewStoryboard: true } : {}),
|
|
1548
|
+
...(opts.director ? { director: true } : {}),
|
|
1549
|
+
...(opts.shots ? { shots: opts.shots } : {}),
|
|
1178
1550
|
});
|
|
1179
1551
|
if (!res.ok)
|
|
1180
1552
|
return errorResult(res, opts.json);
|
|
1553
|
+
const customerStart = customerAnswer(res.data);
|
|
1554
|
+
if (customerStart)
|
|
1555
|
+
return customerStartedResult(customerStart, opts, deps);
|
|
1181
1556
|
const started = res.data;
|
|
1182
1557
|
if (!started.runId) {
|
|
1183
1558
|
return { code: 1, lines: ["The server started the ad but did not say which run it is."] };
|
|
@@ -1185,25 +1560,52 @@ export async function startScriptFlow(opts, deps) {
|
|
|
1185
1560
|
const runId = started.runId;
|
|
1186
1561
|
const url = typeof started.url === "string" ? started.url : "";
|
|
1187
1562
|
const card = isResolvedCard(started.card) ? started.card : null;
|
|
1563
|
+
const video = startedVideoChoice(started.videoChoice);
|
|
1188
1564
|
if (!opts.wait) {
|
|
1189
1565
|
return {
|
|
1190
1566
|
code: 0,
|
|
1191
1567
|
lines: opts.json
|
|
1192
|
-
? [JSON.stringify({ runId, url, card })]
|
|
1568
|
+
? [JSON.stringify({ runId, url, card, ...(video ? { videoChoice: video } : {}) })]
|
|
1193
1569
|
: [
|
|
1194
|
-
...scriptStartedLines(runId, url, card),
|
|
1570
|
+
...scriptStartedLines(runId, url, card, video, opts.director === true),
|
|
1195
1571
|
"",
|
|
1196
|
-
|
|
1572
|
+
...(opts.reviewStoryboard
|
|
1573
|
+
? ["Wait for the storyboard here instead: exodus video start … --wait"]
|
|
1574
|
+
: [
|
|
1575
|
+
"The app approves the storyboard itself if its checks pass. It only stops for you if they find a problem.",
|
|
1576
|
+
"Wait here until it needs you: exodus video start … --wait",
|
|
1577
|
+
]),
|
|
1197
1578
|
`Or check in whenever: exodus video status ${runId}`,
|
|
1198
1579
|
],
|
|
1199
1580
|
};
|
|
1200
1581
|
}
|
|
1201
1582
|
const waited = await waitFlow(runId, { json: opts.json, url }, deps);
|
|
1202
1583
|
if (opts.json)
|
|
1203
|
-
return waitJsonWithCard(waited, card);
|
|
1584
|
+
return waitJsonWithCard(waited, card, video);
|
|
1204
1585
|
return {
|
|
1205
1586
|
code: waited.code,
|
|
1206
|
-
lines: [...scriptStartedLines(runId, url, card), "", ...waited.lines],
|
|
1587
|
+
lines: [...scriptStartedLines(runId, url, card, video, opts.director === true), "", ...waited.lines],
|
|
1588
|
+
};
|
|
1589
|
+
}
|
|
1590
|
+
async function customerStartedResult(started, opts, deps) {
|
|
1591
|
+
const { runId, dashboardUrl } = started;
|
|
1592
|
+
const head = [`Started video ${runId}`, `Watch it: ${dashboardUrl}`];
|
|
1593
|
+
if (opts.wait) {
|
|
1594
|
+
const waited = await waitFlow(runId, { json: opts.json, url: dashboardUrl }, deps);
|
|
1595
|
+
return opts.json ? waited : { code: waited.code, lines: [...head, "", ...waited.lines] };
|
|
1596
|
+
}
|
|
1597
|
+
if (opts.json)
|
|
1598
|
+
return { code: 0, lines: [JSON.stringify(started)] };
|
|
1599
|
+
return {
|
|
1600
|
+
code: 0,
|
|
1601
|
+
lines: [
|
|
1602
|
+
...head,
|
|
1603
|
+
"",
|
|
1604
|
+
opts.reviewStoryboard
|
|
1605
|
+
? "It stops at the storyboard for you to approve."
|
|
1606
|
+
: "The app approves the storyboard itself if its checks pass. It only stops for you if they find a problem.",
|
|
1607
|
+
`Check in whenever: exodus video status ${runId}`,
|
|
1608
|
+
],
|
|
1207
1609
|
};
|
|
1208
1610
|
}
|
|
1209
1611
|
const POLL_INTERVAL_MS = 5000;
|
|
@@ -1212,7 +1614,8 @@ export async function waitFlow(runId, opts, deps) {
|
|
|
1212
1614
|
const interval = opts.intervalMs ?? POLL_INTERVAL_MS;
|
|
1213
1615
|
const maxPolls = opts.maxPolls ?? MAX_POLLS;
|
|
1214
1616
|
const lines = [];
|
|
1215
|
-
let
|
|
1617
|
+
let lastProgress = null;
|
|
1618
|
+
let redoSeen = false;
|
|
1216
1619
|
for (let poll = 0; poll < maxPolls; poll++) {
|
|
1217
1620
|
if (poll > 0)
|
|
1218
1621
|
await deps.sleep(interval);
|
|
@@ -1221,17 +1624,57 @@ export async function waitFlow(runId, opts, deps) {
|
|
|
1221
1624
|
const failed = errorResult(res, opts.json);
|
|
1222
1625
|
return { code: failed.code, lines: [...lines, ...failed.lines] };
|
|
1223
1626
|
}
|
|
1627
|
+
const customer = customerAnswer(res.data);
|
|
1628
|
+
if (customer) {
|
|
1629
|
+
const page = customerRunPage(deps.dashboardUrl, runId);
|
|
1630
|
+
if (customerStageIsWorking(customer.stage)) {
|
|
1631
|
+
const progress = customerStageLines(customer, page)[0];
|
|
1632
|
+
if (progress !== lastProgress) {
|
|
1633
|
+
lastProgress = progress;
|
|
1634
|
+
if (!opts.json)
|
|
1635
|
+
lines.push(progress);
|
|
1636
|
+
}
|
|
1637
|
+
continue;
|
|
1638
|
+
}
|
|
1639
|
+
return {
|
|
1640
|
+
code: customer.stage === "failed" ? 1 : 0,
|
|
1641
|
+
lines: opts.json
|
|
1642
|
+
? [JSON.stringify(customer)]
|
|
1643
|
+
: [...lines, ...customerStageLines(customer, page), `Watch it on the dashboard: ${page}`],
|
|
1644
|
+
};
|
|
1645
|
+
}
|
|
1224
1646
|
const run = asVideoRun(res.data);
|
|
1225
1647
|
const stop = classifyRun(run);
|
|
1226
1648
|
if (stop.at === "running") {
|
|
1227
|
-
|
|
1228
|
-
|
|
1649
|
+
const progress = stop.autoRedoScenes?.length
|
|
1650
|
+
? autoRedoLine(stop.autoRedoScenes, stop.autoRedoReasons)
|
|
1651
|
+
: `Working: ${stageWord(stop.stage)}…`;
|
|
1652
|
+
if (progress !== lastProgress) {
|
|
1653
|
+
lastProgress = progress;
|
|
1229
1654
|
if (!opts.json)
|
|
1230
|
-
lines.push(
|
|
1655
|
+
lines.push(progress);
|
|
1231
1656
|
}
|
|
1232
1657
|
continue;
|
|
1233
1658
|
}
|
|
1234
|
-
|
|
1659
|
+
let items = null;
|
|
1660
|
+
if (stop.at === "final-watch") {
|
|
1661
|
+
const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1662
|
+
items = itemsRes.ok ? (itemsRes.data.items ?? []) : null;
|
|
1663
|
+
if (items === null && redoSeen)
|
|
1664
|
+
continue;
|
|
1665
|
+
const redoing = items ? scenesBeingRedone(run, items) : [];
|
|
1666
|
+
redoSeen = redoing.length > 0;
|
|
1667
|
+
if (redoSeen) {
|
|
1668
|
+
const progress = `Working: redoing scene${redoing.length === 1 ? "" : "s"} ${redoing.join(", ")}…`;
|
|
1669
|
+
if (progress !== lastProgress) {
|
|
1670
|
+
lastProgress = progress;
|
|
1671
|
+
if (!opts.json)
|
|
1672
|
+
lines.push(progress);
|
|
1673
|
+
}
|
|
1674
|
+
continue;
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
const resolved = resolveStop(stop, items ? hasAttachedCut(items) : null);
|
|
1235
1678
|
if (opts.json) {
|
|
1236
1679
|
return {
|
|
1237
1680
|
code: stop.at === "failed" ? 1 : 0,
|
|
@@ -1274,6 +1717,13 @@ export async function statusFlow(runId, json, deps) {
|
|
|
1274
1717
|
const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1275
1718
|
if (!runRes.ok)
|
|
1276
1719
|
return errorResult(runRes, json);
|
|
1720
|
+
const customer = customerAnswer(runRes.data);
|
|
1721
|
+
if (customer) {
|
|
1722
|
+
return {
|
|
1723
|
+
code: 0,
|
|
1724
|
+
lines: json ? [JSON.stringify(customer)] : customerStatusLines(customer, deps.dashboardUrl),
|
|
1725
|
+
};
|
|
1726
|
+
}
|
|
1277
1727
|
const run = asVideoRun(runRes.data);
|
|
1278
1728
|
const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1279
1729
|
if (!itemsRes.ok)
|
|
@@ -1300,18 +1750,32 @@ export async function statusFlow(runId, json, deps) {
|
|
|
1300
1750
|
hasFinal,
|
|
1301
1751
|
warnings,
|
|
1302
1752
|
guidance,
|
|
1753
|
+
...(run.musicBed === undefined ? {} : { musicBed: run.musicBed }),
|
|
1303
1754
|
...(failedStoryboard?.planFailure ? { planFailure: failedStoryboard.planFailure } : {}),
|
|
1304
1755
|
...(failedStoryboard?.planFailure
|
|
1305
1756
|
? { storyboardNodeId: failedStoryboard.nodeId }
|
|
1306
1757
|
: {}),
|
|
1307
1758
|
...(failedStoryboard?.hasRejectedDraft === true ? { hasRejectedDraft: true } : {}),
|
|
1759
|
+
...(run.director ? { director: run.director } : {}),
|
|
1308
1760
|
}),
|
|
1309
1761
|
],
|
|
1310
1762
|
};
|
|
1311
1763
|
}
|
|
1312
1764
|
const revoicedByScene = new Set();
|
|
1765
|
+
const voiceFirstByScene = new Set();
|
|
1766
|
+
const voiceFirstBlockedByScene = new Map();
|
|
1313
1767
|
const timedOnOriginalVoice = new Set();
|
|
1314
1768
|
for (const artifact of outputsOfNodeKind(run, "video")) {
|
|
1769
|
+
if (artifact.type === "video" &&
|
|
1770
|
+
artifact.voiceMode === "voice-first" &&
|
|
1771
|
+
typeof artifact.sceneIndex === "number") {
|
|
1772
|
+
voiceFirstByScene.add(artifact.sceneIndex);
|
|
1773
|
+
}
|
|
1774
|
+
if (artifact.type === "video" &&
|
|
1775
|
+
artifact.voiceFirstBlockedBy?.length &&
|
|
1776
|
+
typeof artifact.sceneIndex === "number") {
|
|
1777
|
+
voiceFirstBlockedByScene.set(artifact.sceneIndex, artifact.voiceFirstBlockedBy);
|
|
1778
|
+
}
|
|
1315
1779
|
if (artifact.type === "video" && artifact.revoiced === true && typeof artifact.sceneIndex === "number") {
|
|
1316
1780
|
revoicedByScene.add(artifact.sceneIndex);
|
|
1317
1781
|
if ((artifact.words?.length ?? 0) > 0)
|
|
@@ -1342,6 +1806,7 @@ export async function statusFlow(runId, json, deps) {
|
|
|
1342
1806
|
...(stop.at === "running" && run.pauseAhead ? [pauseAheadLine(run.pauseAhead)] : []),
|
|
1343
1807
|
...planFailureLines(failedStoryboard, runId),
|
|
1344
1808
|
...warnings.map((w) => `Heads-up (${w.step}): ${w.warning}`),
|
|
1809
|
+
...(run.director ? ["", ...directorLines(run.director)] : []),
|
|
1345
1810
|
];
|
|
1346
1811
|
if (byScene.size === 0) {
|
|
1347
1812
|
lines.push("", "No scenes yet — this run hasn't made anything to look at.");
|
|
@@ -1352,7 +1817,7 @@ export async function statusFlow(runId, json, deps) {
|
|
|
1352
1817
|
const row = byScene.get(sceneIndex);
|
|
1353
1818
|
lines.push(`${String(sceneIndex).padEnd(5)} ${itemWord(row.clip).padEnd(8)} ${itemWord(row.voiceover).padEnd(8)} ${itemWord(row.frame)}`);
|
|
1354
1819
|
if (row.clip?.lastRedo) {
|
|
1355
|
-
lines.push(` clip: ${row.clip.lastRedo
|
|
1820
|
+
lines.push(` clip: ${redoLine(row.clip.lastRedo)}`);
|
|
1356
1821
|
printDisplacedHistory(lines, row.clip.lastRedo);
|
|
1357
1822
|
}
|
|
1358
1823
|
if (row.clip?.lastRedo || row.clip?.flagged) {
|
|
@@ -1361,7 +1826,7 @@ export async function statusFlow(runId, json, deps) {
|
|
|
1361
1826
|
}
|
|
1362
1827
|
}
|
|
1363
1828
|
if (row.voiceover?.lastRedo) {
|
|
1364
|
-
lines.push(` voice: ${row.voiceover.lastRedo
|
|
1829
|
+
lines.push(` voice: ${redoLine(row.voiceover.lastRedo)}`);
|
|
1365
1830
|
printDisplacedHistory(lines, row.voiceover.lastRedo);
|
|
1366
1831
|
}
|
|
1367
1832
|
if (row.frame?.lastRedo)
|
|
@@ -1380,6 +1845,12 @@ export async function statusFlow(runId, json, deps) {
|
|
|
1380
1845
|
lines.push(` ${row.clip.error}`);
|
|
1381
1846
|
if (revoicedByScene.has(sceneIndex))
|
|
1382
1847
|
lines.push(" voice: cast voice applied");
|
|
1848
|
+
if (voiceFirstByScene.has(sceneIndex))
|
|
1849
|
+
lines.push(" clip: redone voice first");
|
|
1850
|
+
const blockedBy = voiceFirstBlockedByScene.get(sceneIndex);
|
|
1851
|
+
if (blockedBy) {
|
|
1852
|
+
lines.push(` clip: redone the usual way — ${cantSayNames(blockedBy)}`);
|
|
1853
|
+
}
|
|
1383
1854
|
if (timedOnOriginalVoice.has(sceneIndex)) {
|
|
1384
1855
|
lines.push(" word timings: measured on the original voice (close, not frame-exact)");
|
|
1385
1856
|
}
|
|
@@ -1398,10 +1869,79 @@ export async function statusFlow(runId, json, deps) {
|
|
|
1398
1869
|
: "Final cut: not uploaded yet.");
|
|
1399
1870
|
return { code: 0, lines };
|
|
1400
1871
|
}
|
|
1872
|
+
const DIRECTOR_STATUS_WORDS = {
|
|
1873
|
+
reviewing: "Watching this run",
|
|
1874
|
+
waiting: "Waiting for redos to finish",
|
|
1875
|
+
done: "Done: ready for your approval",
|
|
1876
|
+
stopped: "Stopped: see the last note",
|
|
1877
|
+
};
|
|
1878
|
+
const DIRECTOR_PHASE_WORDS = {
|
|
1879
|
+
storyboard: "Storyboard",
|
|
1880
|
+
"final-watch": "Final watch",
|
|
1881
|
+
};
|
|
1882
|
+
const DIRECTOR_LOG_LINES = 3;
|
|
1883
|
+
export function directorLines(director) {
|
|
1884
|
+
const switchWords = director.runSetting === null
|
|
1885
|
+
? `following the brand's setting (${director.workspaceOn ? "on" : "off"})`
|
|
1886
|
+
: "switched for this ad";
|
|
1887
|
+
const log = director.log.slice(-DIRECTOR_LOG_LINES);
|
|
1888
|
+
return [
|
|
1889
|
+
`Director: ${director.on ? "on" : "off"}, ${switchWords}`,
|
|
1890
|
+
...(director.on
|
|
1891
|
+
? [` ${DIRECTOR_STATUS_WORDS[director.status ?? "reviewing"]}`]
|
|
1892
|
+
: []),
|
|
1893
|
+
` Redos: ${director.redosUsed} of ${director.redoCap}. Spent: $${director.spendUsd.toFixed(2)} of $${director.spendCapUsd.toFixed(2)}`,
|
|
1894
|
+
...(log.length > 0
|
|
1895
|
+
? [
|
|
1896
|
+
" Latest notes:",
|
|
1897
|
+
...log.map((entry) => ` ${DIRECTOR_PHASE_WORDS[entry.phase]}: ${entry.text}`),
|
|
1898
|
+
]
|
|
1899
|
+
: []),
|
|
1900
|
+
];
|
|
1901
|
+
}
|
|
1902
|
+
export function parseDirectorSwitch(word) {
|
|
1903
|
+
if (word === "on")
|
|
1904
|
+
return { on: true };
|
|
1905
|
+
if (word === "off")
|
|
1906
|
+
return { on: false };
|
|
1907
|
+
if (word === "follow")
|
|
1908
|
+
return { on: null };
|
|
1909
|
+
return null;
|
|
1910
|
+
}
|
|
1911
|
+
export async function directorFlow(runId, on, json, deps) {
|
|
1912
|
+
const res = await deps.post(DIRECTOR_PATH, { runId, on });
|
|
1913
|
+
const behind = missingRouteLine(res, "exodus video director");
|
|
1914
|
+
if (behind) {
|
|
1915
|
+
return {
|
|
1916
|
+
code: 1,
|
|
1917
|
+
lines: json ? [JSON.stringify({ ok: false, status: 404, error: behind })] : [behind],
|
|
1918
|
+
};
|
|
1919
|
+
}
|
|
1920
|
+
if (!res.ok)
|
|
1921
|
+
return errorResult(res, json);
|
|
1922
|
+
if (json)
|
|
1923
|
+
return { code: 0, lines: [JSON.stringify({ runId, on })] };
|
|
1924
|
+
const said = on === null
|
|
1925
|
+
? `The director on ad run ${runId} now follows the brand's own setting.`
|
|
1926
|
+
: `The director is ${on ? "on" : "off"} for ad run ${runId}.`;
|
|
1927
|
+
return {
|
|
1928
|
+
code: 0,
|
|
1929
|
+
lines: [
|
|
1930
|
+
said,
|
|
1931
|
+
...(on === true
|
|
1932
|
+
? ["If the ad is already waiting at the storyboard or the final watch, a review starts now."]
|
|
1933
|
+
: []),
|
|
1934
|
+
`See what it is doing: exodus video status ${runId}`,
|
|
1935
|
+
],
|
|
1936
|
+
};
|
|
1937
|
+
}
|
|
1401
1938
|
export async function rejectedDraftFlow(runId, json, deps) {
|
|
1402
1939
|
const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1403
1940
|
if (!runRes.ok)
|
|
1404
1941
|
return errorResult(runRes, json);
|
|
1942
|
+
if (customerAnswer(runRes.data)) {
|
|
1943
|
+
return adminOnlyResult("exodus video status --rejected-draft is for admins.", json);
|
|
1944
|
+
}
|
|
1405
1945
|
const node = failedStoryboardNode(asVideoRun(runRes.data));
|
|
1406
1946
|
if (!node) {
|
|
1407
1947
|
const note = "The storyboard on this run didn't fail, so there is no rejected draft to read.";
|
|
@@ -1441,6 +1981,10 @@ export async function storyboardFlow(runId, json, deps) {
|
|
|
1441
1981
|
const res = await deps.get(`${STORYBOARD_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1442
1982
|
if (!res.ok)
|
|
1443
1983
|
return errorResult(res, json);
|
|
1984
|
+
const board = customerAnswer(res.data);
|
|
1985
|
+
if (board) {
|
|
1986
|
+
return { code: 0, lines: json ? [JSON.stringify(board)] : customerStoryboardLines(board) };
|
|
1987
|
+
}
|
|
1444
1988
|
const cards = res.data;
|
|
1445
1989
|
if (json)
|
|
1446
1990
|
return { code: 0, lines: [JSON.stringify(cards)] };
|
|
@@ -1579,14 +2123,34 @@ export async function flagFlow(runId, note, json, deps) {
|
|
|
1579
2123
|
};
|
|
1580
2124
|
}
|
|
1581
2125
|
export async function retryFrameFlow(runId, nodeId, sceneIndex, note, json, deps) {
|
|
2126
|
+
if (nodeId === undefined) {
|
|
2127
|
+
const board = await deps.get(`${STORYBOARD_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
2128
|
+
if (!board.ok)
|
|
2129
|
+
return errorResult(board, json);
|
|
2130
|
+
if (!customerAnswer(board.data)) {
|
|
2131
|
+
return { code: 1, lines: ["video retry-frame needs --node <nodeId>."] };
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
1582
2134
|
const res = await deps.post(SCENE_RETRY_PATH, {
|
|
1583
2135
|
runId,
|
|
1584
|
-
nodeId,
|
|
2136
|
+
...(nodeId === undefined ? {} : { nodeId }),
|
|
1585
2137
|
sceneIndex,
|
|
1586
2138
|
...(note ? { note } : {}),
|
|
1587
2139
|
});
|
|
1588
2140
|
if (!res.ok)
|
|
1589
2141
|
return errorResult(res, json);
|
|
2142
|
+
if (customerAnswer(res.data)) {
|
|
2143
|
+
if (json)
|
|
2144
|
+
return { code: 0, lines: [JSON.stringify({ ok: true, runId, sceneIndex })] };
|
|
2145
|
+
return {
|
|
2146
|
+
code: 0,
|
|
2147
|
+
lines: [
|
|
2148
|
+
`Redrawing scene ${sceneIndex}'s picture. The other scenes stay as they are.`,
|
|
2149
|
+
...(note ? ["Your note isn't used on this redo."] : []),
|
|
2150
|
+
`See it when it's done: exodus video storyboard ${runId}`,
|
|
2151
|
+
],
|
|
2152
|
+
};
|
|
2153
|
+
}
|
|
1590
2154
|
const triggerRunId = res.data.triggerRunId;
|
|
1591
2155
|
if (json) {
|
|
1592
2156
|
return {
|
|
@@ -1623,7 +2187,10 @@ export function planClipRedo(run, items, target) {
|
|
|
1623
2187
|
const { row, uploadedCut } = found;
|
|
1624
2188
|
const redoable = row.status === "failed" ||
|
|
1625
2189
|
row.staleClaim === true ||
|
|
1626
|
-
(row.status === "done" &&
|
|
2190
|
+
(row.status === "done" &&
|
|
2191
|
+
(row.flagged === true ||
|
|
2192
|
+
stop.at === "final-watch" ||
|
|
2193
|
+
(stop.at === "running" && stop.autoRedoScenes !== undefined)));
|
|
1627
2194
|
if (!redoable) {
|
|
1628
2195
|
if (row.status === "done") {
|
|
1629
2196
|
return {
|
|
@@ -1680,10 +2247,14 @@ function findClipRow(run, items, target) {
|
|
|
1680
2247
|
}
|
|
1681
2248
|
return { row, uploadedCut };
|
|
1682
2249
|
}
|
|
1683
|
-
export async function retryClipFlow(runId, target, note, json, deps) {
|
|
2250
|
+
export async function retryClipFlow(runId, target, { note, voiceFirst = false }, json, deps) {
|
|
1684
2251
|
const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1685
2252
|
if (!runRes.ok)
|
|
1686
2253
|
return errorResult(runRes, json);
|
|
2254
|
+
if (customerAnswer(runRes.data)) {
|
|
2255
|
+
return adminOnlyResult("Redoing a finished clip is for admins. To redo a scene's picture while your storyboard " +
|
|
2256
|
+
`waits for you: exodus video retry-frame ${runId} --scene <n>`, json);
|
|
2257
|
+
}
|
|
1687
2258
|
const run = asVideoRun(runRes.data);
|
|
1688
2259
|
const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1689
2260
|
if (!itemsRes.ok)
|
|
@@ -1696,12 +2267,20 @@ export async function retryClipFlow(runId, target, note, json, deps) {
|
|
|
1696
2267
|
lines: json ? [JSON.stringify({ ok: false, error: plan.reason })] : [plan.reason],
|
|
1697
2268
|
};
|
|
1698
2269
|
}
|
|
1699
|
-
const res = await deps.post(SCENE_RETRY_PATH, {
|
|
2270
|
+
const res = await deps.post(voiceFirst ? SCENE_VOICE_FIRST_PATH : SCENE_RETRY_PATH, {
|
|
1700
2271
|
runId,
|
|
1701
2272
|
nodeId: plan.nodeId,
|
|
1702
2273
|
sceneIndex: plan.sceneIndex,
|
|
1703
2274
|
...(note ? { note } : {}),
|
|
1704
2275
|
});
|
|
2276
|
+
if (voiceFirst && missingRouteLine(res, "exodus video retry-clip --voice-first")) {
|
|
2277
|
+
return {
|
|
2278
|
+
code: 1,
|
|
2279
|
+
lines: json
|
|
2280
|
+
? [JSON.stringify({ ok: false, status: 404, error: VOICE_FIRST_NOT_ON_THIS_SERVER })]
|
|
2281
|
+
: [VOICE_FIRST_NOT_ON_THIS_SERVER],
|
|
2282
|
+
};
|
|
2283
|
+
}
|
|
1705
2284
|
if (!res.ok)
|
|
1706
2285
|
return errorResult(res, json);
|
|
1707
2286
|
const triggerRunId = res.data.triggerRunId;
|
|
@@ -1720,6 +2299,7 @@ export async function retryClipFlow(runId, target, note, json, deps) {
|
|
|
1720
2299
|
reviewUrl: review,
|
|
1721
2300
|
warnings: plan.warnings,
|
|
1722
2301
|
...(note ? { note } : {}),
|
|
2302
|
+
...(voiceFirst ? { voiceMode: "voice-first" } : {}),
|
|
1723
2303
|
}),
|
|
1724
2304
|
],
|
|
1725
2305
|
};
|
|
@@ -1727,9 +2307,19 @@ export async function retryClipFlow(runId, target, note, json, deps) {
|
|
|
1727
2307
|
return {
|
|
1728
2308
|
code: 0,
|
|
1729
2309
|
lines: [
|
|
1730
|
-
|
|
2310
|
+
voiceFirst
|
|
2311
|
+
? `Redoing scene ${plan.sceneIndex}'s clip on ${plan.nodeId}, voice first: the line is ` +
|
|
2312
|
+
"recorded in the character's own voice, then the clip is made to match it. " +
|
|
2313
|
+
"If the line has a made-up name the recorded voice can't say, the clip may be made " +
|
|
2314
|
+
`the usual way instead, and exodus video status ${runId} will say so.`
|
|
2315
|
+
: `Redoing scene ${plan.sceneIndex}'s clip on ${plan.nodeId}.`,
|
|
1731
2316
|
`triggerRunId: ${triggerRunId ?? "-"}`,
|
|
1732
|
-
|
|
2317
|
+
...(voiceFirst
|
|
2318
|
+
? [
|
|
2319
|
+
"Every other scene stays as it is, and so do the pictures and the script.",
|
|
2320
|
+
"A speaking character with no voice yet gets one picked and saved for the whole run.",
|
|
2321
|
+
]
|
|
2322
|
+
: ["Every other scene stays as it is, and so do the pictures, the voices and the script."]),
|
|
1733
2323
|
...(note
|
|
1734
2324
|
? ["Your note steers how this clip moves. It never changes the words that are spoken."]
|
|
1735
2325
|
: []),
|
|
@@ -1798,6 +2388,9 @@ export async function revoiceFlow(runId, target, json, deps) {
|
|
|
1798
2388
|
const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1799
2389
|
if (!runRes.ok)
|
|
1800
2390
|
return errorResult(runRes, json);
|
|
2391
|
+
if (customerAnswer(runRes.data)) {
|
|
2392
|
+
return adminOnlyResult("exodus video revoice is for admins.", json);
|
|
2393
|
+
}
|
|
1801
2394
|
const run = asVideoRun(runRes.data);
|
|
1802
2395
|
const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1803
2396
|
if (!itemsRes.ok)
|
|
@@ -1964,6 +2557,10 @@ export async function voicesFlow(runId, voices, json, deps) {
|
|
|
1964
2557
|
: await deps.get(`${VOICES_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1965
2558
|
if (!res.ok)
|
|
1966
2559
|
return errorResult(res, json);
|
|
2560
|
+
const customer = customerAnswer(res.data);
|
|
2561
|
+
if (customer) {
|
|
2562
|
+
return { code: 0, lines: json ? [JSON.stringify(customer)] : customerVoicesLines(customer) };
|
|
2563
|
+
}
|
|
1967
2564
|
const sheet = res.data;
|
|
1968
2565
|
if (json)
|
|
1969
2566
|
return { code: 0, lines: [JSON.stringify({ ok: true, ...sheet })] };
|
|
@@ -2046,10 +2643,112 @@ export function voiceSheetLines(sheet) {
|
|
|
2046
2643
|
: (sheet.whyNot ?? "Voices can no longer be changed on this run."));
|
|
2047
2644
|
return lines;
|
|
2048
2645
|
}
|
|
2646
|
+
function musicLines(manifest) {
|
|
2647
|
+
const lines = [`music: ${manifest.musicBed}`];
|
|
2648
|
+
if (manifest.musicHeardScenes.length > 0) {
|
|
2649
|
+
lines.push(`music heard in scene ${manifest.musicHeardScenes.join(", ")}`);
|
|
2650
|
+
}
|
|
2651
|
+
return lines;
|
|
2652
|
+
}
|
|
2653
|
+
export function planCustomerPull(run, items, meta) {
|
|
2654
|
+
const downloads = [];
|
|
2655
|
+
const take = (file, url) => {
|
|
2656
|
+
downloads.push({ file, url });
|
|
2657
|
+
return file;
|
|
2658
|
+
};
|
|
2659
|
+
const scenes = new Map();
|
|
2660
|
+
let music = null;
|
|
2661
|
+
for (const item of items) {
|
|
2662
|
+
if (item.kind === "music") {
|
|
2663
|
+
if (music === null && item.url)
|
|
2664
|
+
music = take(`music.${extFor(item.url, "audio")}`, item.url);
|
|
2665
|
+
continue;
|
|
2666
|
+
}
|
|
2667
|
+
if (!isPullSceneIndex(item.sceneIndex))
|
|
2668
|
+
continue;
|
|
2669
|
+
const scene = scenes.get(item.sceneIndex) ?? { index: item.sceneIndex, clip: null, voice: null };
|
|
2670
|
+
if (item.url && item.kind === "clip" && scene.clip === null) {
|
|
2671
|
+
scene.clip = take(`${scenePrefix(item.sceneIndex)}.${extFor(item.url, "video")}`, item.url);
|
|
2672
|
+
}
|
|
2673
|
+
if (item.url && item.kind === "voice" && scene.voice === null) {
|
|
2674
|
+
scene.voice = take(`${scenePrefix(item.sceneIndex)}.voice.${extFor(item.url, "audio")}`, item.url);
|
|
2675
|
+
}
|
|
2676
|
+
scenes.set(item.sceneIndex, scene);
|
|
2677
|
+
}
|
|
2678
|
+
const videoUrl = run.video?.url;
|
|
2679
|
+
const video = videoUrl ? take(`video.${extFor(videoUrl, "video")}`, videoUrl) : null;
|
|
2680
|
+
return {
|
|
2681
|
+
downloads,
|
|
2682
|
+
manifest: {
|
|
2683
|
+
runId: run.runId,
|
|
2684
|
+
pulledAt: meta.pulledAt,
|
|
2685
|
+
dashboardUrl: customerRunPage(meta.dashboardUrl, run.runId),
|
|
2686
|
+
scenes: [...scenes.values()].sort((a, b) => a.index - b.index),
|
|
2687
|
+
music,
|
|
2688
|
+
video,
|
|
2689
|
+
},
|
|
2690
|
+
};
|
|
2691
|
+
}
|
|
2692
|
+
async function customerPullFlow(run, dir, json, deps) {
|
|
2693
|
+
const runId = run.runId;
|
|
2694
|
+
const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
2695
|
+
if (!itemsRes.ok)
|
|
2696
|
+
return errorResult(itemsRes, json);
|
|
2697
|
+
const items = customerAnswer(itemsRes.data)?.items ?? [];
|
|
2698
|
+
const { downloads, manifest } = planCustomerPull(run, items, {
|
|
2699
|
+
pulledAt: new Date(deps.now()).toISOString(),
|
|
2700
|
+
dashboardUrl: deps.dashboardUrl,
|
|
2701
|
+
});
|
|
2702
|
+
deps.mkdirp(dir);
|
|
2703
|
+
const failures = (await runPool(downloads.map((download) => () => downloadWithRetry(download, dir, deps)), PULL_CONCURRENCY)).filter((f) => f !== null);
|
|
2704
|
+
const landed = (file) => file !== null && !failures.some((f) => f.file === file) ? file : null;
|
|
2705
|
+
for (const scene of manifest.scenes) {
|
|
2706
|
+
scene.clip = landed(scene.clip);
|
|
2707
|
+
scene.voice = landed(scene.voice);
|
|
2708
|
+
}
|
|
2709
|
+
manifest.music = landed(manifest.music);
|
|
2710
|
+
manifest.video = landed(manifest.video);
|
|
2711
|
+
const manifestPath = path.join(dir, "manifest.json");
|
|
2712
|
+
deps.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
2713
|
+
const code = failures.length > 0 ? 1 : 0;
|
|
2714
|
+
if (json) {
|
|
2715
|
+
return { code, lines: [JSON.stringify({ runId, dir, wrote: downloads.length - failures.length + 1, manifest })] };
|
|
2716
|
+
}
|
|
2717
|
+
if (downloads.length === 0) {
|
|
2718
|
+
return {
|
|
2719
|
+
code: 0,
|
|
2720
|
+
lines: [
|
|
2721
|
+
"Nothing to download yet. This video hasn't made any pieces.",
|
|
2722
|
+
`Check where it is: exodus video status ${runId}`,
|
|
2723
|
+
],
|
|
2724
|
+
};
|
|
2725
|
+
}
|
|
2726
|
+
return {
|
|
2727
|
+
code,
|
|
2728
|
+
lines: [
|
|
2729
|
+
`Pulled ${downloads.length - failures.length + 1} files into ${dir}`,
|
|
2730
|
+
`Every piece is listed in ${manifestPath}.`,
|
|
2731
|
+
...(manifest.video ? [] : ["The finished video isn't ready yet, so only the scene pieces came down."]),
|
|
2732
|
+
...(failures.length > 0
|
|
2733
|
+
? [
|
|
2734
|
+
"",
|
|
2735
|
+
`${failures.length} file${failures.length === 1 ? "" : "s"} did not download:`,
|
|
2736
|
+
...failures.map((f) => ` ${f.file}: ${f.error}`),
|
|
2737
|
+
"Run the same command again to retry.",
|
|
2738
|
+
]
|
|
2739
|
+
: []),
|
|
2740
|
+
"",
|
|
2741
|
+
`When your cut is ready: exodus video upload ${runId} --file cut.mp4`,
|
|
2742
|
+
],
|
|
2743
|
+
};
|
|
2744
|
+
}
|
|
2049
2745
|
export async function pullFlow(runId, dir, json, deps) {
|
|
2050
2746
|
const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
2051
2747
|
if (!runRes.ok)
|
|
2052
2748
|
return errorResult(runRes, json);
|
|
2749
|
+
const customer = customerAnswer(runRes.data);
|
|
2750
|
+
if (customer)
|
|
2751
|
+
return customerPullFlow(customer, dir, json, deps);
|
|
2053
2752
|
const run = asVideoRun(runRes.data);
|
|
2054
2753
|
const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
2055
2754
|
if (!itemsRes.ok)
|
|
@@ -2089,7 +2788,23 @@ export async function pullFlow(runId, dir, json, deps) {
|
|
|
2089
2788
|
const lines = [
|
|
2090
2789
|
`Pulled ${wrote} files into ${dir}`,
|
|
2091
2790
|
`Every piece is indexed in ${path.join(dir, "manifest.json")} — scene numbers there are the run's own.`,
|
|
2791
|
+
...musicLines(plan.manifest),
|
|
2092
2792
|
];
|
|
2793
|
+
for (const sceneIndex of plan.manifest.pendingRedo) {
|
|
2794
|
+
lines.push("", `Scene ${sceneIndex} is being redone right now; its file will change. Run \`exodus video wait ${runId}\`, then pull again.`);
|
|
2795
|
+
}
|
|
2796
|
+
const voiceFirst = plan.manifest.scenes.filter((s) => s.voiceMode === "voice-first");
|
|
2797
|
+
if (voiceFirst.length > 0) {
|
|
2798
|
+
lines.push("", `Redone voice first (the line recorded, then the clip made to match it): ` +
|
|
2799
|
+
`scene${voiceFirst.length === 1 ? "" : "s"} ${voiceFirst.map((s) => s.sceneIndex).join(", ")}`);
|
|
2800
|
+
}
|
|
2801
|
+
const redoneUsualWay = plan.manifest.scenes.filter((s) => s.voiceFirstBlockedBy?.length);
|
|
2802
|
+
if (redoneUsualWay.length > 0) {
|
|
2803
|
+
lines.push("", "Asked for voice first, but redone the usual way:");
|
|
2804
|
+
for (const scene of redoneUsualWay) {
|
|
2805
|
+
lines.push(` scene ${scene.sceneIndex}: ${cantSayNames(scene.voiceFirstBlockedBy ?? [])}`);
|
|
2806
|
+
}
|
|
2807
|
+
}
|
|
2093
2808
|
const flagged = plan.manifest.scenes.filter((s) => s.flagged);
|
|
2094
2809
|
if (flagged.length > 0) {
|
|
2095
2810
|
lines.push("", `${flagged.length} clip${flagged.length === 1 ? "" : "s"} came back flagged — usable, but look before you cut:`);
|
|
@@ -2237,6 +2952,46 @@ function uploadRetryResult(failed, json) {
|
|
|
2237
2952
|
],
|
|
2238
2953
|
};
|
|
2239
2954
|
}
|
|
2955
|
+
const CUT_SCENES_MAX = 200;
|
|
2956
|
+
const CUT_WORDS_MAX = 2000;
|
|
2957
|
+
const isTime = (n) => typeof n === "number" && Number.isFinite(n) && n >= 0;
|
|
2958
|
+
function cutScene(row) {
|
|
2959
|
+
const r = row;
|
|
2960
|
+
return r && Number.isInteger(r.scene) && isTime(r.startSec) && isTime(r.seconds) && r.seconds > 0
|
|
2961
|
+
? { scene: r.scene, startSec: r.startSec, seconds: r.seconds }
|
|
2962
|
+
: null;
|
|
2963
|
+
}
|
|
2964
|
+
function cutWord(row) {
|
|
2965
|
+
const r = row;
|
|
2966
|
+
return r &&
|
|
2967
|
+
typeof r.text === "string" &&
|
|
2968
|
+
r.text.trim() !== "" &&
|
|
2969
|
+
isTime(r.start) &&
|
|
2970
|
+
isTime(r.end) &&
|
|
2971
|
+
r.end >= r.start &&
|
|
2972
|
+
Number.isInteger(r.scene)
|
|
2973
|
+
? { text: r.text, start: r.start, end: r.end, scene: r.scene }
|
|
2974
|
+
: null;
|
|
2975
|
+
}
|
|
2976
|
+
function readSidecar(file, parse, max, deps) {
|
|
2977
|
+
let rows;
|
|
2978
|
+
try {
|
|
2979
|
+
rows = JSON.parse(deps.readFile(file));
|
|
2980
|
+
}
|
|
2981
|
+
catch {
|
|
2982
|
+
return null;
|
|
2983
|
+
}
|
|
2984
|
+
if (!Array.isArray(rows) || rows.length === 0 || rows.length > max)
|
|
2985
|
+
return null;
|
|
2986
|
+
const parsed = rows.map(parse);
|
|
2987
|
+
return parsed.every((r) => r !== null) ? parsed : null;
|
|
2988
|
+
}
|
|
2989
|
+
export function cutSidecars(filePath, deps) {
|
|
2990
|
+
const base = path.join(path.dirname(filePath), path.basename(filePath, path.extname(filePath)));
|
|
2991
|
+
const cutScenes = readSidecar(`${base}.scenes.json`, cutScene, CUT_SCENES_MAX, deps);
|
|
2992
|
+
const cutWords = readSidecar(`${base}.words.json`, cutWord, CUT_WORDS_MAX, deps);
|
|
2993
|
+
return { ...(cutScenes ? { cutScenes } : {}), ...(cutWords ? { cutWords } : {}) };
|
|
2994
|
+
}
|
|
2240
2995
|
export async function uploadFlow(runId, filePath, durationFlag, json, deps) {
|
|
2241
2996
|
const stat = deps.statFile(filePath);
|
|
2242
2997
|
if (!stat)
|
|
@@ -2279,6 +3034,7 @@ export async function uploadFlow(runId, filePath, durationFlag, json, deps) {
|
|
|
2279
3034
|
const durationSec = asked ?? deps.probeDurationSec(filePath) ?? parseMvhdDurationSec(bytes);
|
|
2280
3035
|
if (durationSec === null)
|
|
2281
3036
|
return { code: 1, lines: [NO_DURATION_MESSAGE] };
|
|
3037
|
+
const timing = cutSidecars(filePath, deps);
|
|
2282
3038
|
const minting = await withUploadRetry("mint", deps, () => deps.post(ASSET_UPLOAD_URL_PATH, {}));
|
|
2283
3039
|
if (!minting.ok)
|
|
2284
3040
|
return uploadRetryResult(minting, json);
|
|
@@ -2319,27 +3075,49 @@ export async function uploadFlow(runId, filePath, durationFlag, json, deps) {
|
|
|
2319
3075
|
runId,
|
|
2320
3076
|
assetId,
|
|
2321
3077
|
durationSec,
|
|
3078
|
+
...timing,
|
|
2322
3079
|
}));
|
|
2323
3080
|
if (!attaching.ok)
|
|
2324
3081
|
return uploadRetryResult(attaching, json);
|
|
2325
3082
|
const attached = attaching.value;
|
|
2326
3083
|
if (!attached.ok)
|
|
2327
3084
|
return errorResult(attached, json);
|
|
3085
|
+
const customerFinal = customerAnswer(attached.data);
|
|
3086
|
+
if (customerFinal) {
|
|
3087
|
+
const { finalWatchUrl, delivered } = customerFinal;
|
|
3088
|
+
if (json) {
|
|
3089
|
+
return {
|
|
3090
|
+
code: 0,
|
|
3091
|
+
lines: [JSON.stringify({ ok: true, runId, assetId, durationSec, finalWatchUrl, delivered })],
|
|
3092
|
+
};
|
|
3093
|
+
}
|
|
3094
|
+
return {
|
|
3095
|
+
code: 0,
|
|
3096
|
+
lines: [
|
|
3097
|
+
`Uploaded your cut (${durationSec.toFixed(1)}s).${delivered ? " Your video is delivered." : ""}`,
|
|
3098
|
+
`Watch it here: ${finalWatchUrl}`,
|
|
3099
|
+
],
|
|
3100
|
+
};
|
|
3101
|
+
}
|
|
2328
3102
|
const final = attached.data;
|
|
2329
3103
|
const finalWatchUrl = final.finalWatchUrl ?? reviewUrl(deps.dashboardUrl, { _id: runId });
|
|
3104
|
+
const delivered = final.delivered === true;
|
|
2330
3105
|
if (json) {
|
|
2331
3106
|
return {
|
|
2332
3107
|
code: 0,
|
|
2333
|
-
lines: [JSON.stringify({ ok: true, runId, assetId, durationSec, finalWatchUrl })],
|
|
3108
|
+
lines: [JSON.stringify({ ok: true, runId, assetId, durationSec, finalWatchUrl, delivered })],
|
|
2334
3109
|
};
|
|
2335
3110
|
}
|
|
3111
|
+
const uploaded = `Uploaded your cut (${durationSec.toFixed(1)}s).`;
|
|
2336
3112
|
return {
|
|
2337
3113
|
code: 0,
|
|
2338
|
-
lines:
|
|
2339
|
-
`
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
3114
|
+
lines: delivered
|
|
3115
|
+
? [`${uploaded} Your ad is delivered.`, `Watch it here: ${finalWatchUrl}`]
|
|
3116
|
+
: [
|
|
3117
|
+
uploaded,
|
|
3118
|
+
`Watch it here: ${finalWatchUrl}`,
|
|
3119
|
+
`Approve it there, or run: exodus video approve ${runId}`,
|
|
3120
|
+
],
|
|
2343
3121
|
};
|
|
2344
3122
|
}
|
|
2345
3123
|
const VALUE_FLAGS = new Set([
|
|
@@ -2349,6 +3127,10 @@ const VALUE_FLAGS = new Set([
|
|
|
2349
3127
|
"style",
|
|
2350
3128
|
"direction",
|
|
2351
3129
|
"voice-path",
|
|
3130
|
+
"video-model",
|
|
3131
|
+
"voice",
|
|
3132
|
+
"frame",
|
|
3133
|
+
"pacing",
|
|
2352
3134
|
"note",
|
|
2353
3135
|
"out",
|
|
2354
3136
|
"file",
|
|
@@ -2401,12 +3183,15 @@ export async function run(flags, occurrences) {
|
|
|
2401
3183
|
if (sub === "shows")
|
|
2402
3184
|
return printResult(await showsFlow(json, defaultDeps));
|
|
2403
3185
|
if (sub === "start") {
|
|
2404
|
-
const plan = planVideoStart(flags);
|
|
3186
|
+
const plan = planVideoStart(flags, occurrences);
|
|
2405
3187
|
if (plan.kind === "usage")
|
|
2406
3188
|
usage(plan.line);
|
|
2407
3189
|
if (plan.kind === "show")
|
|
2408
3190
|
return printResult(await startFlow(plan.opts, defaultDeps));
|
|
2409
|
-
|
|
3191
|
+
const started = await startScriptFlow(plan.opts, defaultDeps);
|
|
3192
|
+
if (started.usage)
|
|
3193
|
+
usage(started.lines[0] ?? "");
|
|
3194
|
+
return printResult(started);
|
|
2410
3195
|
}
|
|
2411
3196
|
const runId = rest[0];
|
|
2412
3197
|
const needsRunId = [
|
|
@@ -2420,6 +3205,7 @@ export async function run(flags, occurrences) {
|
|
|
2420
3205
|
"voices",
|
|
2421
3206
|
"pull",
|
|
2422
3207
|
"upload",
|
|
3208
|
+
"director",
|
|
2423
3209
|
];
|
|
2424
3210
|
if (needsRunId.includes(sub) && !runId) {
|
|
2425
3211
|
usage(`video ${sub} needs a run id: exodus video ${sub} <runId>`);
|
|
@@ -2432,6 +3218,12 @@ export async function run(flags, occurrences) {
|
|
|
2432
3218
|
}
|
|
2433
3219
|
if (sub === "storyboard")
|
|
2434
3220
|
return printResult(await storyboardFlow(runId, json, defaultDeps));
|
|
3221
|
+
if (sub === "director") {
|
|
3222
|
+
const choice = parseDirectorSwitch(rest[1]);
|
|
3223
|
+
if (!choice)
|
|
3224
|
+
usage(`video director needs on, off or follow: exodus video director ${runId} on`);
|
|
3225
|
+
return printResult(await directorFlow(runId, choice.on, json, defaultDeps));
|
|
3226
|
+
}
|
|
2435
3227
|
if (sub === "approve") {
|
|
2436
3228
|
return printResult(await approveFlow(runId, { json, approveStaleCut: flags["approve-stale-cut"] === true }, defaultDeps));
|
|
2437
3229
|
}
|
|
@@ -2442,9 +3234,7 @@ export async function run(flags, occurrences) {
|
|
|
2442
3234
|
return printResult(await flagFlow(runId, note, json, defaultDeps));
|
|
2443
3235
|
}
|
|
2444
3236
|
if (sub === "retry-frame") {
|
|
2445
|
-
const nodeId = flagString(flags, "node");
|
|
2446
|
-
if (!nodeId)
|
|
2447
|
-
usage("video retry-frame needs --node <nodeId>.");
|
|
3237
|
+
const nodeId = nonBlank(flagString(flags, "node"));
|
|
2448
3238
|
const sceneRaw = flagString(flags, "scene");
|
|
2449
3239
|
if (sceneRaw === undefined) {
|
|
2450
3240
|
usage("video retry-frame needs --scene <n>, the scene index to redo.");
|
|
@@ -2464,7 +3254,7 @@ export async function run(flags, occurrences) {
|
|
|
2464
3254
|
if (!Number.isInteger(sceneIndex)) {
|
|
2465
3255
|
usage(`video retry-clip --scene must be a whole scene number, not "${sceneRaw}".`);
|
|
2466
3256
|
}
|
|
2467
|
-
return printResult(await retryClipFlow(runId, { sceneIndex, nodeId: flagString(flags, "node") }, flagString(flags, "note"), json, defaultDeps));
|
|
3257
|
+
return printResult(await retryClipFlow(runId, { sceneIndex, nodeId: flagString(flags, "node") }, { note: flagString(flags, "note"), voiceFirst: flags["voice-first"] === true }, json, defaultDeps));
|
|
2468
3258
|
}
|
|
2469
3259
|
if (sub === "revoice") {
|
|
2470
3260
|
const sceneRaw = flagString(flags, "scene");
|