@aicopycoders/exodus 2026.9.2000-beta.8 → 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 +265 -51
- 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/exodus-workflow/SKILL.md +1 -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 +156 -5
- package/dist/commands/video.js +945 -63
- 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,11 +7,30 @@ 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";
|
|
11
|
+
const CONCEIT_KEYS = ["podcast", "ugc", "stage", "street", "personification"];
|
|
12
|
+
const VOICE_MODES = ["native", "voice-first"];
|
|
13
|
+
const STYLE_SLUGS = [
|
|
14
|
+
"pixar-3d",
|
|
15
|
+
"claymation-stop-motion",
|
|
16
|
+
"lego-toy-brick",
|
|
17
|
+
"balloon",
|
|
18
|
+
"video-game-8bit",
|
|
19
|
+
];
|
|
20
|
+
function joinedOr(items) {
|
|
21
|
+
if (items.length <= 1)
|
|
22
|
+
return items[0] ?? "";
|
|
23
|
+
return `${items.slice(0, -1).join(", ")} or ${items[items.length - 1]}`;
|
|
24
|
+
}
|
|
25
|
+
function cantSayNames(names) {
|
|
26
|
+
return `the recorded voice can't say ${joinedOr(names.map((n) => `"${n}"`))}`;
|
|
27
|
+
}
|
|
10
28
|
export const helpText = `
|
|
11
29
|
exodus video — make a video ad from a saved workflow, pull every piece, upload your cut
|
|
12
30
|
|
|
13
31
|
The dashboard makes the PIECES of an ad: the storyboard, one picture per scene,
|
|
14
|
-
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
|
|
15
34
|
not make the finished ad. You pull the pieces to a folder, cut them together
|
|
16
35
|
with whatever editing tools you like, and upload the cut back.
|
|
17
36
|
|
|
@@ -55,7 +74,9 @@ The whole loop, in order:
|
|
|
55
74
|
|
|
56
75
|
exodus video retry-clip <runId> --scene <n>
|
|
57
76
|
Redo ONE finished clip while the run waits for the cut. Everything
|
|
58
|
-
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.
|
|
59
80
|
|
|
60
81
|
exodus video revoice <runId> --all
|
|
61
82
|
Redo only the VOICE on clips that kept the video model's own voice
|
|
@@ -66,7 +87,8 @@ The whole loop, in order:
|
|
|
66
87
|
9. Make your cut from those files.
|
|
67
88
|
|
|
68
89
|
10. exodus video upload <runId> --file cut.mp4
|
|
69
|
-
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.
|
|
70
92
|
|
|
71
93
|
11. exodus video approve <runId>
|
|
72
94
|
Or click Approve on the page from step 10. If you redid a clip after
|
|
@@ -77,12 +99,14 @@ Usage:
|
|
|
77
99
|
exodus video status <runId> [--rejected-draft] [--json]
|
|
78
100
|
exodus video storyboard <runId> [--json]
|
|
79
101
|
exodus video approve <runId> [--approve-stale-cut] [--json]
|
|
80
|
-
exodus video retry-frame <runId> --node <nodeId> --scene <n> [--note "..."] [--json]
|
|
81
|
-
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]
|
|
82
104
|
exodus video revoice <runId> (--scene <n> | --all) [--node <nodeId>] [--json]
|
|
83
105
|
exodus video voices <runId> [--set <character>=<voiceId>] [--clear <character>] [--from <file.json>] [--json]
|
|
84
106
|
exodus video pull <runId> --out <dir> [--json]
|
|
85
107
|
exodus video upload <runId> --file <cut.mp4> [--duration <sec>] [--json]
|
|
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]
|
|
86
110
|
|
|
87
111
|
Options:
|
|
88
112
|
--out <dir> Folder to write the pulled pieces into (pull)
|
|
@@ -90,13 +114,17 @@ Options:
|
|
|
90
114
|
--duration <sec> How long your cut is, in seconds. Only needed when the
|
|
91
115
|
length can't be read off the file itself
|
|
92
116
|
--note "<text>" How to steer one redo (retry-frame, retry-clip)
|
|
93
|
-
--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).
|
|
94
119
|
Which video step holds the clip, when a scene has one on
|
|
95
120
|
more than one step (retry-clip, revoice)
|
|
96
121
|
--scene <n> Which scene to redo. One scene only, a whole number
|
|
97
122
|
(retry-frame, retry-clip, revoice)
|
|
98
123
|
--all Every finished clip that kept the video model's voice
|
|
99
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
|
|
100
128
|
--approve-stale-cut Deliver the cut you uploaded even though a clip changed
|
|
101
129
|
after you uploaded it (approve). Without it, approving
|
|
102
130
|
stops and names the scenes that changed
|
|
@@ -108,11 +136,41 @@ Options:
|
|
|
108
136
|
--clear win over the same name in the file
|
|
109
137
|
--rejected-draft Print the storyboard draft the system turned down
|
|
110
138
|
(status). It was thrown away — a record only
|
|
139
|
+
--script <file> Text file of what the ad says (start)
|
|
140
|
+
--conceit <key> Kind of ad: ${joinedOr(CONCEIT_KEYS)} (start)
|
|
141
|
+
--style <slug> Look: ${joinedOr(STYLE_SLUGS)} (start)
|
|
142
|
+
--direction "<note>" Note for the whole ad (start)
|
|
143
|
+
--voice-path <path> How the voices are made (start)
|
|
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
|
|
111
161
|
--json Machine-readable output
|
|
112
162
|
--help, -h Print this help
|
|
113
163
|
|
|
114
|
-
|
|
115
|
-
|
|
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.
|
|
116
174
|
|
|
117
175
|
Examples:
|
|
118
176
|
exodus workflow list
|
|
@@ -122,17 +180,21 @@ Examples:
|
|
|
122
180
|
exodus video approve run_123
|
|
123
181
|
exodus video retry-frame run_123 --node frames-1 --scene 2
|
|
124
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
|
|
125
184
|
exodus video revoice run_123 --all
|
|
126
185
|
exodus video voices run_123
|
|
127
186
|
exodus video voices run_123 --set C1=abc123voiceid --set "HOST 2=def456voiceid"
|
|
128
187
|
exodus video voices run_123 --from voices.json
|
|
129
188
|
exodus video pull run_123 --out ./ad-run_123
|
|
130
189
|
exodus video upload run_123 --file ./cut.mp4
|
|
190
|
+
exodus video director run_123 on
|
|
131
191
|
`.trim();
|
|
132
192
|
const SHOWS_PATH = "/api/v2/shows";
|
|
133
193
|
const RUNS_PATH = "/api/v2/video/runs";
|
|
134
|
-
const
|
|
135
|
-
const
|
|
194
|
+
const SCRIPT_RUNS_PATH = "/api/v2/video/script-runs";
|
|
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";
|
|
136
198
|
const REJECTED_DRAFT_PATH = "/api/v2/workflow/rejected-draft";
|
|
137
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 " +
|
|
138
200
|
"with the next server update.";
|
|
@@ -141,12 +203,15 @@ const FLAG_PATH = "/api/v2/video/storyboard/flag";
|
|
|
141
203
|
const APPROVE_PATH = "/api/v2/workflow/approve";
|
|
142
204
|
const SCENE_RETRY_PATH = "/api/v2/workflow/scene/retry";
|
|
143
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.";
|
|
144
209
|
const REVOICE_NOT_ON_THIS_SERVER = "This Exodus server does not have the voice redo yet, so nothing was started and nothing " +
|
|
145
210
|
"was spent. It arrives with the next server update.";
|
|
146
|
-
const FINAL_PATH = "/api/v2/video/final";
|
|
211
|
+
export const FINAL_PATH = "/api/v2/video/final";
|
|
147
212
|
export const VOICES_PATH = "/api/v2/video/voices";
|
|
148
|
-
const ASSET_UPLOAD_URL_PATH = "/api/v2/workflows/asset-upload-url";
|
|
149
|
-
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";
|
|
150
215
|
function withoutJudgeFields({ judgeDetail: _d, judgeSeverity: _s, ...rest }) {
|
|
151
216
|
return rest;
|
|
152
217
|
}
|
|
@@ -155,7 +220,7 @@ export function qcWithoutJudgeWording(qc) {
|
|
|
155
220
|
return qc;
|
|
156
221
|
return {
|
|
157
222
|
...qc,
|
|
158
|
-
takes: qc.takes.map(({ judgeWording: _w, ...rest }) => rest),
|
|
223
|
+
takes: qc.takes.map(({ judgeWording: _w, heardText: _h, ...rest }) => rest),
|
|
159
224
|
};
|
|
160
225
|
}
|
|
161
226
|
export const NO_TAKE_HISTORY_LINE = "no history recorded for this clip";
|
|
@@ -184,6 +249,9 @@ export function renderQcTakeHistory(takes) {
|
|
|
184
249
|
});
|
|
185
250
|
return lines;
|
|
186
251
|
}
|
|
252
|
+
function redoLine(lastRedo) {
|
|
253
|
+
return lastRedo.reason ? `${lastRedo.label}: ${lastRedo.reason}` : lastRedo.label;
|
|
254
|
+
}
|
|
187
255
|
function printDisplacedHistory(lines, lastRedo) {
|
|
188
256
|
const history = lastRedo.displacedHistory;
|
|
189
257
|
if (!history)
|
|
@@ -248,7 +316,9 @@ export const defaultDeps = {
|
|
|
248
316
|
dashboardUrl: getDashboardUrl(),
|
|
249
317
|
};
|
|
250
318
|
export const VIDEO_NOT_FOUND_MESSAGE = "video isn't enabled for this key\n" +
|
|
251
|
-
"
|
|
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.";
|
|
252
322
|
export function videoApiError(res) {
|
|
253
323
|
const behind = missingRouteLine(res, "exodus video");
|
|
254
324
|
if (behind)
|
|
@@ -257,6 +327,9 @@ export function videoApiError(res) {
|
|
|
257
327
|
return VIDEO_NOT_FOUND_MESSAGE;
|
|
258
328
|
return formatApiError(res);
|
|
259
329
|
}
|
|
330
|
+
function adminOnlyResult(line, json) {
|
|
331
|
+
return { code: 1, lines: json ? [JSON.stringify({ ok: false, error: line })] : [line] };
|
|
332
|
+
}
|
|
260
333
|
function errorResult(res, json) {
|
|
261
334
|
return {
|
|
262
335
|
code: 1,
|
|
@@ -279,6 +352,14 @@ function parkedAtFinalWatch(run) {
|
|
|
279
352
|
return (!parkedByBuilderCheckpoint(run) &&
|
|
280
353
|
run.nodes.some((n) => n.kind === "video" && n.status === "done"));
|
|
281
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
|
+
}
|
|
282
363
|
export function classifyRun(run) {
|
|
283
364
|
if (run.status === "failed")
|
|
284
365
|
return { at: "failed", error: run.error };
|
|
@@ -298,6 +379,7 @@ export function classifyRun(run) {
|
|
|
298
379
|
}
|
|
299
380
|
const pausedNode = run.nodes.find((n) => n.nodeId === run.pausedNodeId);
|
|
300
381
|
if (pausedNode && parkedAtStoryboardGate(run, pausedNode)) {
|
|
382
|
+
const held = heldReason(run);
|
|
301
383
|
return {
|
|
302
384
|
at: "storyboard-gate",
|
|
303
385
|
nodeId: pausedNode.nodeId,
|
|
@@ -305,10 +387,31 @@ export function classifyRun(run) {
|
|
|
305
387
|
? { framesNodeId: pausedNode.nodeId }
|
|
306
388
|
: {}),
|
|
307
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 } : {}),
|
|
308
413
|
};
|
|
309
414
|
}
|
|
310
|
-
if (parkedAtFinalWatch(run))
|
|
311
|
-
return { at: "final-watch" };
|
|
312
415
|
return { at: "paused", nodeId: run.pausedNodeId, reason: run.pauseReason };
|
|
313
416
|
}
|
|
314
417
|
const active = run.nodes.find((n) => n.status === "running");
|
|
@@ -317,10 +420,22 @@ export function classifyRun(run) {
|
|
|
317
420
|
export function hasAttachedCut(items) {
|
|
318
421
|
return items.some((i) => i.itemKind === "final" && i.status === "done");
|
|
319
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
|
+
}
|
|
320
435
|
export function resolveStop(stop, cutAttached) {
|
|
321
436
|
if (stop.at !== "final-watch")
|
|
322
437
|
return stop;
|
|
323
|
-
return {
|
|
438
|
+
return { ...stop, cutAttached };
|
|
324
439
|
}
|
|
325
440
|
export async function resolveStopAtPark(stop, runId, deps) {
|
|
326
441
|
if (stop.at !== "final-watch")
|
|
@@ -359,15 +474,38 @@ export function isShowAd(run) {
|
|
|
359
474
|
return run.moduleOwned === true;
|
|
360
475
|
}
|
|
361
476
|
export function reviewUrl(dashboardUrl, run) {
|
|
362
|
-
if (isShowAd(run))
|
|
363
|
-
return `${dashboardUrl}/video?ad=${run._id}`;
|
|
364
477
|
if (run.workflowId)
|
|
365
478
|
return `${dashboardUrl}/workflows/${run.workflowId}/runs/${run._id}`;
|
|
366
479
|
return `${dashboardUrl}/runs/${run._id}`;
|
|
367
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
|
+
}
|
|
368
505
|
export function stopLines(stop, runId, runUrl) {
|
|
369
506
|
if (stop.at === "storyboard-gate") {
|
|
370
507
|
const lines = [
|
|
508
|
+
...(stop.heldReason ? [stop.heldReason] : []),
|
|
371
509
|
"Parked: the storyboard is waiting for your yes.",
|
|
372
510
|
`Read it: exodus video storyboard ${runId}`,
|
|
373
511
|
`Approve it: exodus video approve ${runId}`,
|
|
@@ -381,6 +519,9 @@ export function stopLines(stop, runId, runUrl) {
|
|
|
381
519
|
return lines;
|
|
382
520
|
}
|
|
383
521
|
if (stop.at === "final-watch") {
|
|
522
|
+
if (stop.missingScenes?.length) {
|
|
523
|
+
return missingSceneLines(stop.missingScenes, stop.framesNodeId, runId, runUrl);
|
|
524
|
+
}
|
|
384
525
|
if (stop.cutAttached === null) {
|
|
385
526
|
return [
|
|
386
527
|
"Parked: every piece is made.",
|
|
@@ -436,8 +577,28 @@ export function stopLines(stop, runId, runUrl) {
|
|
|
436
577
|
`Open it: ${runUrl}`,
|
|
437
578
|
];
|
|
438
579
|
}
|
|
580
|
+
if (stop.autoRedoScenes?.length)
|
|
581
|
+
return [autoRedoLine(stop.autoRedoScenes, stop.autoRedoReasons)];
|
|
439
582
|
return [`Working: ${stageWord(stop.stage)}.`];
|
|
440
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
|
+
}
|
|
441
602
|
export function failedStoryboardNode(run) {
|
|
442
603
|
return run.nodes.find((n) => n.kind === "storyboard" && n.status === "failed");
|
|
443
604
|
}
|
|
@@ -462,7 +623,13 @@ export function planFailureLines(node, runId) {
|
|
|
462
623
|
}
|
|
463
624
|
return lines;
|
|
464
625
|
}
|
|
626
|
+
export const MUSIC_HEARD_CODE = "music-heard";
|
|
465
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
|
+
}
|
|
466
633
|
function isPullSceneIndex(sceneIndex) {
|
|
467
634
|
return sceneIndex >= 0 && sceneIndex < HELPER_LEDGER_BASE;
|
|
468
635
|
}
|
|
@@ -504,6 +671,10 @@ function clipFromArtifact(sceneIndex, artifact) {
|
|
|
504
671
|
revoiced: artifact.revoiced === true,
|
|
505
672
|
speechTrimmed: artifact.speechTrimmed === true,
|
|
506
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,
|
|
507
678
|
};
|
|
508
679
|
}
|
|
509
680
|
function keyframeDownload(sceneIndex, imageUrl) {
|
|
@@ -655,14 +826,36 @@ export function planPull(run, items, opts) {
|
|
|
655
826
|
}
|
|
656
827
|
}
|
|
657
828
|
let reference = null;
|
|
829
|
+
let productSheet = null;
|
|
830
|
+
let setPlate = null;
|
|
831
|
+
const filledByStage = new Map();
|
|
658
832
|
for (const artifact of outputsOfNodeKind(run, "reference")) {
|
|
659
833
|
if (artifact.type !== "image" || !artifact.imageUrl)
|
|
660
834
|
continue;
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
+
}));
|
|
666
859
|
const keyframeByScene = new Map();
|
|
667
860
|
for (const artifact of run.nodes.flatMap((n) => n.outputs ?? [])) {
|
|
668
861
|
if (artifact.type !== "frames")
|
|
@@ -765,6 +958,9 @@ export function planPull(run, items, opts) {
|
|
|
765
958
|
continue;
|
|
766
959
|
keyframeByScene.set(sceneIndex, keyframeDownload(sceneIndex, artifact.imageUrl));
|
|
767
960
|
}
|
|
961
|
+
const pendingRedo = scenesBeingRedone(run, items);
|
|
962
|
+
for (const sceneIndex of pendingRedo)
|
|
963
|
+
clipByScene.delete(sceneIndex);
|
|
768
964
|
const sceneIndexes = [
|
|
769
965
|
...new Set([
|
|
770
966
|
...clipByScene.keys(),
|
|
@@ -813,6 +1009,10 @@ export function planPull(run, items, opts) {
|
|
|
813
1009
|
revoiced: clip ? clip.revoiced : null,
|
|
814
1010
|
speechTrimmed: clip ? clip.speechTrimmed : null,
|
|
815
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,
|
|
816
1016
|
clipStatus: item?.status ?? "missing",
|
|
817
1017
|
error: item?.error ?? null,
|
|
818
1018
|
flagged: item?.flagged === true,
|
|
@@ -823,6 +1023,9 @@ export function planPull(run, items, opts) {
|
|
|
823
1023
|
const { cast, downloads: castDownloads } = planCastRefs(run, items, castVoicePins(storyboardArtifact));
|
|
824
1024
|
const downloads = [
|
|
825
1025
|
...(reference ? [reference] : []),
|
|
1026
|
+
...(productSheet ? [productSheet] : []),
|
|
1027
|
+
...(setPlate ? [setPlate] : []),
|
|
1028
|
+
...filledPlates.map((p) => p.download),
|
|
826
1029
|
...keyframeByScene.values(),
|
|
827
1030
|
...[...voiceByScene.values()].map((v) => v.download),
|
|
828
1031
|
...(narrationDownload ? [narrationDownload] : []),
|
|
@@ -839,7 +1042,15 @@ export function planPull(run, items, opts) {
|
|
|
839
1042
|
dashboardUrl: reviewUrl(opts.dashboardUrl, run),
|
|
840
1043
|
storyboard,
|
|
841
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 } : {}) })),
|
|
842
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,
|
|
843
1054
|
cast,
|
|
844
1055
|
narration: narrationDownload
|
|
845
1056
|
? { file: narrationDownload.file, timing: "narration.json" }
|
|
@@ -849,6 +1060,14 @@ export function planPull(run, items, opts) {
|
|
|
849
1060
|
...(run.provenance?.format || run.provenance?.voice
|
|
850
1061
|
? { provenance: run.provenance }
|
|
851
1062
|
: {}),
|
|
1063
|
+
...(run.videoChoice
|
|
1064
|
+
? {
|
|
1065
|
+
videoChoice: {
|
|
1066
|
+
videoModel: run.videoChoice.videoModel,
|
|
1067
|
+
voiceMode: run.videoChoice.voiceMode,
|
|
1068
|
+
},
|
|
1069
|
+
}
|
|
1070
|
+
: {}),
|
|
852
1071
|
},
|
|
853
1072
|
};
|
|
854
1073
|
}
|
|
@@ -858,6 +1077,14 @@ export function markPullFailure(manifest, failure) {
|
|
|
858
1077
|
manifest.storyboard = null;
|
|
859
1078
|
if (manifest.reference === failure.file)
|
|
860
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
|
+
}
|
|
861
1088
|
if (manifest.music === failure.file)
|
|
862
1089
|
manifest.music = null;
|
|
863
1090
|
for (const ref of manifest.cast) {
|
|
@@ -874,6 +1101,10 @@ export function markPullFailure(manifest, failure) {
|
|
|
874
1101
|
scene.revoiced = null;
|
|
875
1102
|
scene.speechTrimmed = null;
|
|
876
1103
|
scene.rawStorageId = null;
|
|
1104
|
+
scene.voiceMode = null;
|
|
1105
|
+
scene.voiceFirstBlockedBy = null;
|
|
1106
|
+
scene.voicePitchHz = null;
|
|
1107
|
+
scene.voicePitchMethod = null;
|
|
877
1108
|
}
|
|
878
1109
|
if (scene.voice === failure.file)
|
|
879
1110
|
scene.voice = null;
|
|
@@ -979,7 +1210,7 @@ export async function startFlow(opts, deps) {
|
|
|
979
1210
|
showId: opts.showId,
|
|
980
1211
|
script,
|
|
981
1212
|
...(opts.voicePath ? { voicePath: opts.voicePath } : {}),
|
|
982
|
-
...(opts.music ===
|
|
1213
|
+
...(opts.music === undefined ? {} : { music: opts.music }),
|
|
983
1214
|
});
|
|
984
1215
|
if (!res.ok)
|
|
985
1216
|
return errorResult(res, opts.json);
|
|
@@ -1008,13 +1239,329 @@ export async function startFlow(opts, deps) {
|
|
|
1008
1239
|
lines: opts.json ? waited.lines : [`Started ad run ${runId}`, `Watch it: ${url}`, "", ...waited.lines],
|
|
1009
1240
|
};
|
|
1010
1241
|
}
|
|
1242
|
+
function nonBlank(value) {
|
|
1243
|
+
if (value === undefined || value.trim().length === 0)
|
|
1244
|
+
return undefined;
|
|
1245
|
+
return value;
|
|
1246
|
+
}
|
|
1247
|
+
function isVoiceMode(value) {
|
|
1248
|
+
return VOICE_MODES.some((mode) => mode === value);
|
|
1249
|
+
}
|
|
1250
|
+
function isConceit(value) {
|
|
1251
|
+
return CONCEIT_KEYS.some((key) => key === value);
|
|
1252
|
+
}
|
|
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) {
|
|
1292
|
+
const showId = nonBlank(flagString(flags, "show"));
|
|
1293
|
+
const scriptFile = nonBlank(flagString(flags, "script"));
|
|
1294
|
+
const conceit = nonBlank(flagString(flags, "conceit"));
|
|
1295
|
+
const style = nonBlank(flagString(flags, "style"));
|
|
1296
|
+
if (showId && (conceit || style)) {
|
|
1297
|
+
return {
|
|
1298
|
+
kind: "usage",
|
|
1299
|
+
line: "Pass either --show <id>, or --conceit and --style. Not both.",
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
if (!scriptFile) {
|
|
1303
|
+
return {
|
|
1304
|
+
kind: "usage",
|
|
1305
|
+
line: "video start needs --script <file>, a text file of what the ad says.",
|
|
1306
|
+
};
|
|
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
|
+
}
|
|
1352
|
+
if (showId) {
|
|
1353
|
+
return {
|
|
1354
|
+
kind: "show",
|
|
1355
|
+
opts: {
|
|
1356
|
+
showId,
|
|
1357
|
+
scriptFile,
|
|
1358
|
+
voicePath: flagString(flags, "voice-path"),
|
|
1359
|
+
music: musicChoice.music,
|
|
1360
|
+
wait: flags["wait"] === true,
|
|
1361
|
+
json: flags["json"] === true,
|
|
1362
|
+
},
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
if (!conceit) {
|
|
1366
|
+
return {
|
|
1367
|
+
kind: "usage",
|
|
1368
|
+
line: `video start needs --conceit <${CONCEIT_KEYS.join("|")}>.`,
|
|
1369
|
+
};
|
|
1370
|
+
}
|
|
1371
|
+
if (!isConceit(conceit)) {
|
|
1372
|
+
return {
|
|
1373
|
+
kind: "usage",
|
|
1374
|
+
line: `video start --conceit must be one of ${joinedOr(CONCEIT_KEYS)}.`,
|
|
1375
|
+
};
|
|
1376
|
+
}
|
|
1377
|
+
if (!style) {
|
|
1378
|
+
return {
|
|
1379
|
+
kind: "usage",
|
|
1380
|
+
line: `video start needs --style <${STYLE_SLUGS.join("|")}>.`,
|
|
1381
|
+
};
|
|
1382
|
+
}
|
|
1383
|
+
const direction = nonBlank(flagString(flags, "direction"));
|
|
1384
|
+
const voicePath = nonBlank(flagString(flags, "voice-path"));
|
|
1385
|
+
const opts = {
|
|
1386
|
+
scriptFile,
|
|
1387
|
+
conceit,
|
|
1388
|
+
style,
|
|
1389
|
+
wait: flags["wait"] === true,
|
|
1390
|
+
json: flags["json"] === true,
|
|
1391
|
+
};
|
|
1392
|
+
if (direction)
|
|
1393
|
+
opts.direction = direction;
|
|
1394
|
+
if (voicePath)
|
|
1395
|
+
opts.voicePath = voicePath;
|
|
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;
|
|
1406
|
+
return { kind: "script", opts };
|
|
1407
|
+
}
|
|
1408
|
+
function isRecord(value) {
|
|
1409
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1410
|
+
}
|
|
1411
|
+
function isResolvedCard(value) {
|
|
1412
|
+
if (!isRecord(value))
|
|
1413
|
+
return false;
|
|
1414
|
+
if (!isRecord(value.conceit) || !isRecord(value.style))
|
|
1415
|
+
return false;
|
|
1416
|
+
if (typeof value.conceit.key !== "string" || typeof value.conceit.label !== "string")
|
|
1417
|
+
return false;
|
|
1418
|
+
if (typeof value.family !== "string")
|
|
1419
|
+
return false;
|
|
1420
|
+
if (!("wrapper" in value))
|
|
1421
|
+
return false;
|
|
1422
|
+
if (value.wrapper !== null && typeof value.wrapper !== "string")
|
|
1423
|
+
return false;
|
|
1424
|
+
if (typeof value.style.slug !== "string" || typeof value.style.label !== "string")
|
|
1425
|
+
return false;
|
|
1426
|
+
return true;
|
|
1427
|
+
}
|
|
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) {
|
|
1436
|
+
return [
|
|
1437
|
+
`Started ad run ${runId}`,
|
|
1438
|
+
...(card
|
|
1439
|
+
? [
|
|
1440
|
+
`Conceit: ${card.conceit.label}`,
|
|
1441
|
+
`Family: ${card.family}`,
|
|
1442
|
+
`Wrapper: ${card.wrapper ?? ""}`,
|
|
1443
|
+
`Style: ${card.style.label}`,
|
|
1444
|
+
]
|
|
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"] : []),
|
|
1448
|
+
`Watch it: ${url}`,
|
|
1449
|
+
];
|
|
1450
|
+
}
|
|
1451
|
+
function waitJsonWithCard(waited, card, video) {
|
|
1452
|
+
const first = waited.lines[0];
|
|
1453
|
+
if (typeof first !== "string")
|
|
1454
|
+
return waited;
|
|
1455
|
+
let parsed;
|
|
1456
|
+
try {
|
|
1457
|
+
parsed = JSON.parse(first);
|
|
1458
|
+
}
|
|
1459
|
+
catch {
|
|
1460
|
+
return waited;
|
|
1461
|
+
}
|
|
1462
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
1463
|
+
return waited;
|
|
1464
|
+
return {
|
|
1465
|
+
code: waited.code,
|
|
1466
|
+
lines: [JSON.stringify({ ...parsed, card, ...(video ? { videoChoice: video } : {}) })],
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
export async function startScriptFlow(opts, deps) {
|
|
1470
|
+
let script;
|
|
1471
|
+
try {
|
|
1472
|
+
script = deps.readFile(opts.scriptFile);
|
|
1473
|
+
}
|
|
1474
|
+
catch (e) {
|
|
1475
|
+
return {
|
|
1476
|
+
code: 1,
|
|
1477
|
+
lines: [
|
|
1478
|
+
`Can't read the script file "${opts.scriptFile}": ${e instanceof Error ? e.message : String(e)}`,
|
|
1479
|
+
],
|
|
1480
|
+
};
|
|
1481
|
+
}
|
|
1482
|
+
if (script.trim().length === 0) {
|
|
1483
|
+
return { code: 1, lines: [`The script file "${opts.scriptFile}" is empty.`] };
|
|
1484
|
+
}
|
|
1485
|
+
const res = await deps.post(SCRIPT_RUNS_PATH, {
|
|
1486
|
+
script,
|
|
1487
|
+
conceit: opts.conceit,
|
|
1488
|
+
style: opts.style,
|
|
1489
|
+
...(opts.direction ? { direction: opts.direction } : {}),
|
|
1490
|
+
...(opts.voicePath ? { voicePath: opts.voicePath } : {}),
|
|
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 } : {}),
|
|
1496
|
+
});
|
|
1497
|
+
if (!res.ok)
|
|
1498
|
+
return errorResult(res, opts.json);
|
|
1499
|
+
const customerStart = customerAnswer(res.data);
|
|
1500
|
+
if (customerStart)
|
|
1501
|
+
return customerStartedResult(customerStart, opts, deps);
|
|
1502
|
+
const started = res.data;
|
|
1503
|
+
if (!started.runId) {
|
|
1504
|
+
return { code: 1, lines: ["The server started the ad but did not say which run it is."] };
|
|
1505
|
+
}
|
|
1506
|
+
const runId = started.runId;
|
|
1507
|
+
const url = typeof started.url === "string" ? started.url : "";
|
|
1508
|
+
const card = isResolvedCard(started.card) ? started.card : null;
|
|
1509
|
+
const video = startedVideoChoice(started.videoChoice);
|
|
1510
|
+
if (!opts.wait) {
|
|
1511
|
+
return {
|
|
1512
|
+
code: 0,
|
|
1513
|
+
lines: opts.json
|
|
1514
|
+
? [JSON.stringify({ runId, url, card, ...(video ? { videoChoice: video } : {}) })]
|
|
1515
|
+
: [
|
|
1516
|
+
...scriptStartedLines(runId, url, card, video, opts.director === true),
|
|
1517
|
+
"",
|
|
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
|
+
]),
|
|
1524
|
+
`Or check in whenever: exodus video status ${runId}`,
|
|
1525
|
+
],
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
1528
|
+
const waited = await waitFlow(runId, { json: opts.json, url }, deps);
|
|
1529
|
+
if (opts.json)
|
|
1530
|
+
return waitJsonWithCard(waited, card, video);
|
|
1531
|
+
return {
|
|
1532
|
+
code: waited.code,
|
|
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
|
+
],
|
|
1555
|
+
};
|
|
1556
|
+
}
|
|
1011
1557
|
const POLL_INTERVAL_MS = 5000;
|
|
1012
1558
|
const MAX_POLLS = 720;
|
|
1013
1559
|
export async function waitFlow(runId, opts, deps) {
|
|
1014
1560
|
const interval = opts.intervalMs ?? POLL_INTERVAL_MS;
|
|
1015
1561
|
const maxPolls = opts.maxPolls ?? MAX_POLLS;
|
|
1016
1562
|
const lines = [];
|
|
1017
|
-
let
|
|
1563
|
+
let lastProgress = null;
|
|
1564
|
+
let redoSeen = false;
|
|
1018
1565
|
for (let poll = 0; poll < maxPolls; poll++) {
|
|
1019
1566
|
if (poll > 0)
|
|
1020
1567
|
await deps.sleep(interval);
|
|
@@ -1023,17 +1570,57 @@ export async function waitFlow(runId, opts, deps) {
|
|
|
1023
1570
|
const failed = errorResult(res, opts.json);
|
|
1024
1571
|
return { code: failed.code, lines: [...lines, ...failed.lines] };
|
|
1025
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
|
+
}
|
|
1026
1592
|
const run = asVideoRun(res.data);
|
|
1027
1593
|
const stop = classifyRun(run);
|
|
1028
1594
|
if (stop.at === "running") {
|
|
1029
|
-
|
|
1030
|
-
|
|
1595
|
+
const progress = stop.autoRedoScenes?.length
|
|
1596
|
+
? autoRedoLine(stop.autoRedoScenes, stop.autoRedoReasons)
|
|
1597
|
+
: `Working: ${stageWord(stop.stage)}…`;
|
|
1598
|
+
if (progress !== lastProgress) {
|
|
1599
|
+
lastProgress = progress;
|
|
1031
1600
|
if (!opts.json)
|
|
1032
|
-
lines.push(
|
|
1601
|
+
lines.push(progress);
|
|
1033
1602
|
}
|
|
1034
1603
|
continue;
|
|
1035
1604
|
}
|
|
1036
|
-
|
|
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);
|
|
1037
1624
|
if (opts.json) {
|
|
1038
1625
|
return {
|
|
1039
1626
|
code: stop.at === "failed" ? 1 : 0,
|
|
@@ -1076,6 +1663,13 @@ export async function statusFlow(runId, json, deps) {
|
|
|
1076
1663
|
const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1077
1664
|
if (!runRes.ok)
|
|
1078
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
|
+
}
|
|
1079
1673
|
const run = asVideoRun(runRes.data);
|
|
1080
1674
|
const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1081
1675
|
if (!itemsRes.ok)
|
|
@@ -1102,18 +1696,32 @@ export async function statusFlow(runId, json, deps) {
|
|
|
1102
1696
|
hasFinal,
|
|
1103
1697
|
warnings,
|
|
1104
1698
|
guidance,
|
|
1699
|
+
...(run.musicBed === undefined ? {} : { musicBed: run.musicBed }),
|
|
1105
1700
|
...(failedStoryboard?.planFailure ? { planFailure: failedStoryboard.planFailure } : {}),
|
|
1106
1701
|
...(failedStoryboard?.planFailure
|
|
1107
1702
|
? { storyboardNodeId: failedStoryboard.nodeId }
|
|
1108
1703
|
: {}),
|
|
1109
1704
|
...(failedStoryboard?.hasRejectedDraft === true ? { hasRejectedDraft: true } : {}),
|
|
1705
|
+
...(run.director ? { director: run.director } : {}),
|
|
1110
1706
|
}),
|
|
1111
1707
|
],
|
|
1112
1708
|
};
|
|
1113
1709
|
}
|
|
1114
1710
|
const revoicedByScene = new Set();
|
|
1711
|
+
const voiceFirstByScene = new Set();
|
|
1712
|
+
const voiceFirstBlockedByScene = new Map();
|
|
1115
1713
|
const timedOnOriginalVoice = new Set();
|
|
1116
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
|
+
}
|
|
1117
1725
|
if (artifact.type === "video" && artifact.revoiced === true && typeof artifact.sceneIndex === "number") {
|
|
1118
1726
|
revoicedByScene.add(artifact.sceneIndex);
|
|
1119
1727
|
if ((artifact.words?.length ?? 0) > 0)
|
|
@@ -1144,6 +1752,7 @@ export async function statusFlow(runId, json, deps) {
|
|
|
1144
1752
|
...(stop.at === "running" && run.pauseAhead ? [pauseAheadLine(run.pauseAhead)] : []),
|
|
1145
1753
|
...planFailureLines(failedStoryboard, runId),
|
|
1146
1754
|
...warnings.map((w) => `Heads-up (${w.step}): ${w.warning}`),
|
|
1755
|
+
...(run.director ? ["", ...directorLines(run.director)] : []),
|
|
1147
1756
|
];
|
|
1148
1757
|
if (byScene.size === 0) {
|
|
1149
1758
|
lines.push("", "No scenes yet — this run hasn't made anything to look at.");
|
|
@@ -1154,7 +1763,7 @@ export async function statusFlow(runId, json, deps) {
|
|
|
1154
1763
|
const row = byScene.get(sceneIndex);
|
|
1155
1764
|
lines.push(`${String(sceneIndex).padEnd(5)} ${itemWord(row.clip).padEnd(8)} ${itemWord(row.voiceover).padEnd(8)} ${itemWord(row.frame)}`);
|
|
1156
1765
|
if (row.clip?.lastRedo) {
|
|
1157
|
-
lines.push(` clip: ${row.clip.lastRedo
|
|
1766
|
+
lines.push(` clip: ${redoLine(row.clip.lastRedo)}`);
|
|
1158
1767
|
printDisplacedHistory(lines, row.clip.lastRedo);
|
|
1159
1768
|
}
|
|
1160
1769
|
if (row.clip?.lastRedo || row.clip?.flagged) {
|
|
@@ -1163,7 +1772,7 @@ export async function statusFlow(runId, json, deps) {
|
|
|
1163
1772
|
}
|
|
1164
1773
|
}
|
|
1165
1774
|
if (row.voiceover?.lastRedo) {
|
|
1166
|
-
lines.push(` voice: ${row.voiceover.lastRedo
|
|
1775
|
+
lines.push(` voice: ${redoLine(row.voiceover.lastRedo)}`);
|
|
1167
1776
|
printDisplacedHistory(lines, row.voiceover.lastRedo);
|
|
1168
1777
|
}
|
|
1169
1778
|
if (row.frame?.lastRedo)
|
|
@@ -1182,6 +1791,12 @@ export async function statusFlow(runId, json, deps) {
|
|
|
1182
1791
|
lines.push(` ${row.clip.error}`);
|
|
1183
1792
|
if (revoicedByScene.has(sceneIndex))
|
|
1184
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
|
+
}
|
|
1185
1800
|
if (timedOnOriginalVoice.has(sceneIndex)) {
|
|
1186
1801
|
lines.push(" word timings: measured on the original voice (close, not frame-exact)");
|
|
1187
1802
|
}
|
|
@@ -1200,10 +1815,79 @@ export async function statusFlow(runId, json, deps) {
|
|
|
1200
1815
|
: "Final cut: not uploaded yet.");
|
|
1201
1816
|
return { code: 0, lines };
|
|
1202
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
|
+
}
|
|
1203
1884
|
export async function rejectedDraftFlow(runId, json, deps) {
|
|
1204
1885
|
const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1205
1886
|
if (!runRes.ok)
|
|
1206
1887
|
return errorResult(runRes, json);
|
|
1888
|
+
if (customerAnswer(runRes.data)) {
|
|
1889
|
+
return adminOnlyResult("exodus video status --rejected-draft is for admins.", json);
|
|
1890
|
+
}
|
|
1207
1891
|
const node = failedStoryboardNode(asVideoRun(runRes.data));
|
|
1208
1892
|
if (!node) {
|
|
1209
1893
|
const note = "The storyboard on this run didn't fail, so there is no rejected draft to read.";
|
|
@@ -1243,6 +1927,10 @@ export async function storyboardFlow(runId, json, deps) {
|
|
|
1243
1927
|
const res = await deps.get(`${STORYBOARD_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1244
1928
|
if (!res.ok)
|
|
1245
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
|
+
}
|
|
1246
1934
|
const cards = res.data;
|
|
1247
1935
|
if (json)
|
|
1248
1936
|
return { code: 0, lines: [JSON.stringify(cards)] };
|
|
@@ -1381,14 +2069,34 @@ export async function flagFlow(runId, note, json, deps) {
|
|
|
1381
2069
|
};
|
|
1382
2070
|
}
|
|
1383
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
|
+
}
|
|
1384
2080
|
const res = await deps.post(SCENE_RETRY_PATH, {
|
|
1385
2081
|
runId,
|
|
1386
|
-
nodeId,
|
|
2082
|
+
...(nodeId === undefined ? {} : { nodeId }),
|
|
1387
2083
|
sceneIndex,
|
|
1388
2084
|
...(note ? { note } : {}),
|
|
1389
2085
|
});
|
|
1390
2086
|
if (!res.ok)
|
|
1391
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
|
+
}
|
|
1392
2100
|
const triggerRunId = res.data.triggerRunId;
|
|
1393
2101
|
if (json) {
|
|
1394
2102
|
return {
|
|
@@ -1425,7 +2133,10 @@ export function planClipRedo(run, items, target) {
|
|
|
1425
2133
|
const { row, uploadedCut } = found;
|
|
1426
2134
|
const redoable = row.status === "failed" ||
|
|
1427
2135
|
row.staleClaim === true ||
|
|
1428
|
-
(row.status === "done" &&
|
|
2136
|
+
(row.status === "done" &&
|
|
2137
|
+
(row.flagged === true ||
|
|
2138
|
+
stop.at === "final-watch" ||
|
|
2139
|
+
(stop.at === "running" && stop.autoRedoScenes !== undefined)));
|
|
1429
2140
|
if (!redoable) {
|
|
1430
2141
|
if (row.status === "done") {
|
|
1431
2142
|
return {
|
|
@@ -1482,10 +2193,14 @@ function findClipRow(run, items, target) {
|
|
|
1482
2193
|
}
|
|
1483
2194
|
return { row, uploadedCut };
|
|
1484
2195
|
}
|
|
1485
|
-
export async function retryClipFlow(runId, target, note, json, deps) {
|
|
2196
|
+
export async function retryClipFlow(runId, target, { note, voiceFirst = false }, json, deps) {
|
|
1486
2197
|
const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1487
2198
|
if (!runRes.ok)
|
|
1488
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
|
+
}
|
|
1489
2204
|
const run = asVideoRun(runRes.data);
|
|
1490
2205
|
const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1491
2206
|
if (!itemsRes.ok)
|
|
@@ -1498,12 +2213,20 @@ export async function retryClipFlow(runId, target, note, json, deps) {
|
|
|
1498
2213
|
lines: json ? [JSON.stringify({ ok: false, error: plan.reason })] : [plan.reason],
|
|
1499
2214
|
};
|
|
1500
2215
|
}
|
|
1501
|
-
const res = await deps.post(SCENE_RETRY_PATH, {
|
|
2216
|
+
const res = await deps.post(voiceFirst ? SCENE_VOICE_FIRST_PATH : SCENE_RETRY_PATH, {
|
|
1502
2217
|
runId,
|
|
1503
2218
|
nodeId: plan.nodeId,
|
|
1504
2219
|
sceneIndex: plan.sceneIndex,
|
|
1505
2220
|
...(note ? { note } : {}),
|
|
1506
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
|
+
}
|
|
1507
2230
|
if (!res.ok)
|
|
1508
2231
|
return errorResult(res, json);
|
|
1509
2232
|
const triggerRunId = res.data.triggerRunId;
|
|
@@ -1522,6 +2245,7 @@ export async function retryClipFlow(runId, target, note, json, deps) {
|
|
|
1522
2245
|
reviewUrl: review,
|
|
1523
2246
|
warnings: plan.warnings,
|
|
1524
2247
|
...(note ? { note } : {}),
|
|
2248
|
+
...(voiceFirst ? { voiceMode: "voice-first" } : {}),
|
|
1525
2249
|
}),
|
|
1526
2250
|
],
|
|
1527
2251
|
};
|
|
@@ -1529,9 +2253,19 @@ export async function retryClipFlow(runId, target, note, json, deps) {
|
|
|
1529
2253
|
return {
|
|
1530
2254
|
code: 0,
|
|
1531
2255
|
lines: [
|
|
1532
|
-
|
|
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}.`,
|
|
1533
2262
|
`triggerRunId: ${triggerRunId ?? "-"}`,
|
|
1534
|
-
|
|
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."]),
|
|
1535
2269
|
...(note
|
|
1536
2270
|
? ["Your note steers how this clip moves. It never changes the words that are spoken."]
|
|
1537
2271
|
: []),
|
|
@@ -1600,6 +2334,9 @@ export async function revoiceFlow(runId, target, json, deps) {
|
|
|
1600
2334
|
const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1601
2335
|
if (!runRes.ok)
|
|
1602
2336
|
return errorResult(runRes, json);
|
|
2337
|
+
if (customerAnswer(runRes.data)) {
|
|
2338
|
+
return adminOnlyResult("exodus video revoice is for admins.", json);
|
|
2339
|
+
}
|
|
1603
2340
|
const run = asVideoRun(runRes.data);
|
|
1604
2341
|
const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1605
2342
|
if (!itemsRes.ok)
|
|
@@ -1766,6 +2503,10 @@ export async function voicesFlow(runId, voices, json, deps) {
|
|
|
1766
2503
|
: await deps.get(`${VOICES_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1767
2504
|
if (!res.ok)
|
|
1768
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
|
+
}
|
|
1769
2510
|
const sheet = res.data;
|
|
1770
2511
|
if (json)
|
|
1771
2512
|
return { code: 0, lines: [JSON.stringify({ ok: true, ...sheet })] };
|
|
@@ -1848,10 +2589,112 @@ export function voiceSheetLines(sheet) {
|
|
|
1848
2589
|
: (sheet.whyNot ?? "Voices can no longer be changed on this run."));
|
|
1849
2590
|
return lines;
|
|
1850
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
|
+
}
|
|
1851
2691
|
export async function pullFlow(runId, dir, json, deps) {
|
|
1852
2692
|
const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1853
2693
|
if (!runRes.ok)
|
|
1854
2694
|
return errorResult(runRes, json);
|
|
2695
|
+
const customer = customerAnswer(runRes.data);
|
|
2696
|
+
if (customer)
|
|
2697
|
+
return customerPullFlow(customer, dir, json, deps);
|
|
1855
2698
|
const run = asVideoRun(runRes.data);
|
|
1856
2699
|
const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1857
2700
|
if (!itemsRes.ok)
|
|
@@ -1891,7 +2734,23 @@ export async function pullFlow(runId, dir, json, deps) {
|
|
|
1891
2734
|
const lines = [
|
|
1892
2735
|
`Pulled ${wrote} files into ${dir}`,
|
|
1893
2736
|
`Every piece is indexed in ${path.join(dir, "manifest.json")} — scene numbers there are the run's own.`,
|
|
2737
|
+
...musicLines(plan.manifest),
|
|
1894
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
|
+
}
|
|
1895
2754
|
const flagged = plan.manifest.scenes.filter((s) => s.flagged);
|
|
1896
2755
|
if (flagged.length > 0) {
|
|
1897
2756
|
lines.push("", `${flagged.length} clip${flagged.length === 1 ? "" : "s"} came back flagged — usable, but look before you cut:`);
|
|
@@ -2127,27 +2986,53 @@ export async function uploadFlow(runId, filePath, durationFlag, json, deps) {
|
|
|
2127
2986
|
const attached = attaching.value;
|
|
2128
2987
|
if (!attached.ok)
|
|
2129
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
|
+
}
|
|
2130
3006
|
const final = attached.data;
|
|
2131
3007
|
const finalWatchUrl = final.finalWatchUrl ?? reviewUrl(deps.dashboardUrl, { _id: runId });
|
|
3008
|
+
const delivered = final.delivered === true;
|
|
2132
3009
|
if (json) {
|
|
2133
3010
|
return {
|
|
2134
3011
|
code: 0,
|
|
2135
|
-
lines: [JSON.stringify({ ok: true, runId, assetId, durationSec, finalWatchUrl })],
|
|
3012
|
+
lines: [JSON.stringify({ ok: true, runId, assetId, durationSec, finalWatchUrl, delivered })],
|
|
2136
3013
|
};
|
|
2137
3014
|
}
|
|
3015
|
+
const uploaded = `Uploaded your cut (${durationSec.toFixed(1)}s).`;
|
|
2138
3016
|
return {
|
|
2139
3017
|
code: 0,
|
|
2140
|
-
lines:
|
|
2141
|
-
`
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
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
|
+
],
|
|
2145
3025
|
};
|
|
2146
3026
|
}
|
|
2147
3027
|
const VALUE_FLAGS = new Set([
|
|
2148
3028
|
"show",
|
|
2149
3029
|
"script",
|
|
3030
|
+
"conceit",
|
|
3031
|
+
"style",
|
|
3032
|
+
"direction",
|
|
2150
3033
|
"voice-path",
|
|
3034
|
+
"video-model",
|
|
3035
|
+
"voice",
|
|
2151
3036
|
"note",
|
|
2152
3037
|
"out",
|
|
2153
3038
|
"file",
|
|
@@ -2200,20 +3085,12 @@ export async function run(flags, occurrences) {
|
|
|
2200
3085
|
if (sub === "shows")
|
|
2201
3086
|
return printResult(await showsFlow(json, defaultDeps));
|
|
2202
3087
|
if (sub === "start") {
|
|
2203
|
-
const
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
return printResult(await startFlow({
|
|
2210
|
-
showId,
|
|
2211
|
-
scriptFile,
|
|
2212
|
-
voicePath: flagString(flags, "voice-path"),
|
|
2213
|
-
music: flags["music"] === false ? false : undefined,
|
|
2214
|
-
wait: flags["wait"] === true,
|
|
2215
|
-
json,
|
|
2216
|
-
}, defaultDeps));
|
|
3088
|
+
const plan = planVideoStart(flags, occurrences);
|
|
3089
|
+
if (plan.kind === "usage")
|
|
3090
|
+
usage(plan.line);
|
|
3091
|
+
if (plan.kind === "show")
|
|
3092
|
+
return printResult(await startFlow(plan.opts, defaultDeps));
|
|
3093
|
+
return printResult(await startScriptFlow(plan.opts, defaultDeps));
|
|
2217
3094
|
}
|
|
2218
3095
|
const runId = rest[0];
|
|
2219
3096
|
const needsRunId = [
|
|
@@ -2227,6 +3104,7 @@ export async function run(flags, occurrences) {
|
|
|
2227
3104
|
"voices",
|
|
2228
3105
|
"pull",
|
|
2229
3106
|
"upload",
|
|
3107
|
+
"director",
|
|
2230
3108
|
];
|
|
2231
3109
|
if (needsRunId.includes(sub) && !runId) {
|
|
2232
3110
|
usage(`video ${sub} needs a run id: exodus video ${sub} <runId>`);
|
|
@@ -2239,6 +3117,12 @@ export async function run(flags, occurrences) {
|
|
|
2239
3117
|
}
|
|
2240
3118
|
if (sub === "storyboard")
|
|
2241
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
|
+
}
|
|
2242
3126
|
if (sub === "approve") {
|
|
2243
3127
|
return printResult(await approveFlow(runId, { json, approveStaleCut: flags["approve-stale-cut"] === true }, defaultDeps));
|
|
2244
3128
|
}
|
|
@@ -2249,9 +3133,7 @@ export async function run(flags, occurrences) {
|
|
|
2249
3133
|
return printResult(await flagFlow(runId, note, json, defaultDeps));
|
|
2250
3134
|
}
|
|
2251
3135
|
if (sub === "retry-frame") {
|
|
2252
|
-
const nodeId = flagString(flags, "node");
|
|
2253
|
-
if (!nodeId)
|
|
2254
|
-
usage("video retry-frame needs --node <nodeId>.");
|
|
3136
|
+
const nodeId = nonBlank(flagString(flags, "node"));
|
|
2255
3137
|
const sceneRaw = flagString(flags, "scene");
|
|
2256
3138
|
if (sceneRaw === undefined) {
|
|
2257
3139
|
usage("video retry-frame needs --scene <n>, the scene index to redo.");
|
|
@@ -2271,7 +3153,7 @@ export async function run(flags, occurrences) {
|
|
|
2271
3153
|
if (!Number.isInteger(sceneIndex)) {
|
|
2272
3154
|
usage(`video retry-clip --scene must be a whole scene number, not "${sceneRaw}".`);
|
|
2273
3155
|
}
|
|
2274
|
-
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));
|
|
2275
3157
|
}
|
|
2276
3158
|
if (sub === "revoice") {
|
|
2277
3159
|
const sceneRaw = flagString(flags, "scene");
|