@aicopycoders/exodus 2026.9.2000-beta.7 → 2026.9.2000-beta.9
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.
|
@@ -21,12 +21,27 @@ Your job in this skill is to drive that loop, hand over the pieces with the
|
|
|
21
21
|
facts about them, and make whatever cut the user asked for. **How to edit is the
|
|
22
22
|
user's call, not this skill's** — see "Cutting the ad".
|
|
23
23
|
|
|
24
|
+
This file ships on the member's machine. It holds commands, the manifest's
|
|
25
|
+
shape, and recipes for the cut. It does not hold prompt text, house primers,
|
|
26
|
+
judge wording, or model instructions.
|
|
27
|
+
|
|
24
28
|
## How a run starts
|
|
25
29
|
|
|
26
30
|
The ad is a saved workflow in the brand, started script-first with `exodus
|
|
27
31
|
workflow run`. From there the same nodes render, `video status` reads the run,
|
|
28
32
|
`video pull` writes the folder, and `video upload` and `approve` finish it.
|
|
29
33
|
|
|
34
|
+
A Show is no longer required. `exodus video start` starts from a script, a
|
|
35
|
+
conceit, and a style, and the same parks follow. The full line is under
|
|
36
|
+
"Starting a run". An optional `--direction` note is whole-ad rules, and that
|
|
37
|
+
note cannot contain a colon. "The script file" shows the right form and the
|
|
38
|
+
wrong one.
|
|
39
|
+
|
|
40
|
+
The Rig box on that workflow is where the run's Conceit and Style come from.
|
|
41
|
+
Conceit is the family plus Wrapper, Narrator Person, and Delivery. Style is the
|
|
42
|
+
look. Narrated Story is the voice-over family and Acted Story is the dialogue
|
|
43
|
+
family (`familyTree.ts`). Those family names stay the code's names.
|
|
44
|
+
|
|
30
45
|
## Before anything
|
|
31
46
|
|
|
32
47
|
- `npx @aicopycoders/exodus video --help` and `workflow --help` are the
|
|
@@ -69,6 +84,8 @@ exodus workflow run <workflowId|name> --input <field>=@script.txt --voices @voic
|
|
|
69
84
|
exodus workflow run <workflowId|name> --input <field>=@script.txt --voice-treatment @voice-treatment.json --wait
|
|
70
85
|
same, with the video model speaking voices you WROTE
|
|
71
86
|
exodus video status <runId> where it is, per scene
|
|
87
|
+
exodus video start --script <file> --conceit <podcast|ugc|stage|street|personification> --style <slug> [--direction "…"] [--voice-path <path>] [--no-music] [--wait]
|
|
88
|
+
start from a script, a conceit and a look
|
|
72
89
|
```
|
|
73
90
|
|
|
74
91
|
**From the storyboard on.**
|
|
@@ -98,7 +115,15 @@ already printed the same stop block, and `workflow inbox` lists a parked video
|
|
|
98
115
|
run, but neither shows the per-scene detail, and the checkpoint verbs cannot
|
|
99
116
|
resolve a video park — see "What the CLI can and cannot do".
|
|
100
117
|
|
|
101
|
-
**Where a run can stop.**
|
|
118
|
+
**Where a run can stop.** The two video parks come in order when the run has
|
|
119
|
+
both. The storyboard gate is first. You read the cards there and you set the
|
|
120
|
+
voices there. Approving that gate releases the voices and the clips. That is the
|
|
121
|
+
expensive part. The final watch is next, after the pieces exist, and only when
|
|
122
|
+
the video node sets `finalWatch: true`. That park is for the cut. You pull, you
|
|
123
|
+
cut, and you upload. Approving the upload makes it the ad. A node with
|
|
124
|
+
`finalWatch` off skips the second park and the run finishes.
|
|
125
|
+
|
|
126
|
+
Five stops, each with the exact next command printed
|
|
102
127
|
under it:
|
|
103
128
|
|
|
104
129
|
- storyboard gate: needs `approve`. When the stop block prints a `Redo one
|
|
@@ -211,11 +236,42 @@ anything:
|
|
|
211
236
|
|
|
212
237
|
## The script file
|
|
213
238
|
|
|
214
|
-
Plain text.
|
|
215
|
-
`
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
239
|
+
Plain text. An optional `DIRECTION:` block may open the file, before `CAST:` or
|
|
240
|
+
the first spoken line. Then a `CAST:` block naming the speakers, one line per
|
|
241
|
+
turn, then a `CTA:` block.
|
|
242
|
+
|
|
243
|
+
`DIRECTION:` carries whole-ad rules and no spoken words. The header is its own
|
|
244
|
+
line. The lines under it are the rules. The block ends at a blank line, at
|
|
245
|
+
`CAST:`, at `CTA:`, or at the first line that names a speaker. A second
|
|
246
|
+
`DIRECTION:` block is refused. So is a `DIRECTION:` after the first spoken line.
|
|
247
|
+
An empty block is refused too. The error code is `malformed-direction`.
|
|
248
|
+
|
|
249
|
+
Do not put a colon inside direction prose. The reader splits a line at its first
|
|
250
|
+
colon to find a speaker, so a colon in that prose is read as a name.
|
|
251
|
+
|
|
252
|
+
Right.
|
|
253
|
+
|
|
254
|
+
```
|
|
255
|
+
DIRECTION:
|
|
256
|
+
Nobody looks at the camera. The product stays on the table until the last line.
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Wrong.
|
|
260
|
+
|
|
261
|
+
```
|
|
262
|
+
DIRECTION:
|
|
263
|
+
Rule: nobody looks at the camera.
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
The wrong line is not direction. `Rule` is read as the speaker, and the
|
|
267
|
+
block above it is empty, so the file is refused.
|
|
268
|
+
|
|
269
|
+
The planner sizes every line at 2.5 words per second (`WORD_LADDER_WPS`), so a
|
|
270
|
+
turn near 14 words plans a clip near 6 seconds. If a line does get truncated in
|
|
271
|
+
its clip, the manifest says so (`speech-cutoff`). That finding means the clip
|
|
272
|
+
cut the words off. Redo the clip. Do not speed the speech or cut it short to
|
|
273
|
+
hide the finding. The product rule is under "What the product actually
|
|
274
|
+
requires".
|
|
219
275
|
|
|
220
276
|
```
|
|
221
277
|
CAST:
|
|
@@ -229,6 +285,15 @@ CTA:
|
|
|
229
285
|
THE SOFTGEL: Tap below and give your eyes ClearBlink.
|
|
230
286
|
```
|
|
231
287
|
|
|
288
|
+
The dash in a `CAST:` line is the parser's own separator. The example above
|
|
289
|
+
already shows that dash. A cast line may separate the name from the description
|
|
290
|
+
with an em dash, an en dash, or a hyphen with a space on each side. Keep that
|
|
291
|
+
dash on cast lines. A reader who strips it makes the line fail.
|
|
292
|
+
|
|
293
|
+
Do not use a long dash in words a character says, or in words shown on screen.
|
|
294
|
+
Synthesised speech reads a long dash oddly, and so does burned-in text. A spoken
|
|
295
|
+
line stays `NAME: what they say`.
|
|
296
|
+
|
|
232
297
|
**How many speakers you may have depends on the rig.** Three by default. A rig
|
|
233
298
|
that carries saved format rules sets its own number: an ensemble rig seats more,
|
|
234
299
|
a talking-head rig seats one. Over the number, the launch refuses before
|
|
@@ -261,9 +326,13 @@ unattended.
|
|
|
261
326
|
By default a workflow run's characters have no voice of their own. A character
|
|
262
327
|
without one gets the run's default voice (the workflow's narrator voice) when the
|
|
263
328
|
run has one; with no default either, the clip keeps the voice the video model
|
|
264
|
-
invents. `exodus video voices` shows which of the three applies and is where
|
|
265
|
-
|
|
266
|
-
|
|
329
|
+
invents. `exodus video voices` shows which of the three applies, and it is where you
|
|
330
|
+
fix the mapping. Fix it before you approve the storyboard gate. Approving that
|
|
331
|
+
gate locks the mapping, and `exodus video voices` refuses afterwards. On a
|
|
332
|
+
finished clip whose voice was never applied, the later command is `exodus video
|
|
333
|
+
revoice`, and that pass bills again. A clip that already went through the voice
|
|
334
|
+
pass is refused there. Use `retry-clip` for that one. See "Redoing only the
|
|
335
|
+
voice on finished clips".
|
|
267
336
|
|
|
268
337
|
```
|
|
269
338
|
exodus video voices <runId>
|
|
@@ -337,7 +406,7 @@ nobody knows the speaker names yet. When the storyboard arrives, the voices are
|
|
|
337
406
|
already set and `exodus video voices <runId>` shows them.
|
|
338
407
|
|
|
339
408
|
**Choosing how the voices are made.** `--voice-treatment` on `workflow run` says
|
|
340
|
-
HOW a run makes its voices, and it is the only
|
|
409
|
+
HOW a run makes its voices, and it is the only way. Two
|
|
341
410
|
kinds of answer.
|
|
342
411
|
|
|
343
412
|
A name on its own picks a way of working that plays a voice you already have,
|
|
@@ -410,7 +479,8 @@ person made at a keyboard for one run, and nobody made it for the sub-workflow.
|
|
|
410
479
|
**Written voices cannot be changed at the review.** They are chosen when the run
|
|
411
480
|
starts. `exodus video voices <runId>` on a described run names the treatment and
|
|
412
481
|
prints each written voice, and says plainly that `--voice-treatment` at launch is
|
|
413
|
-
what changes them. Do not set a voice path in node config
|
|
482
|
+
what changes them. Do not set a voice path in node config. Node config does
|
|
483
|
+
not set the treatment.
|
|
414
484
|
|
|
415
485
|
**Where it shows up afterwards.** The launch receipt and `exodus workflow status`
|
|
416
486
|
both print one line naming the treatment and the speakers who got a written
|
|
@@ -549,8 +619,9 @@ envelope also carries `overlays[]` (each with a `cueLineId` and a `placement`)
|
|
|
549
619
|
and `audio` (`roomTone`, `music`, `sfxCues[]`) — the planner's notes on what
|
|
550
620
|
should sit on top. The image and motion prompts (`framePrompt`, `videoPrompt`,
|
|
551
621
|
`referencePrompt`) reach an admin key in full. A caller who cannot use video on
|
|
552
|
-
this brand gets them stripped before the file arrives
|
|
553
|
-
(`redactVideoIp` in `convex/workflows.ts`).
|
|
622
|
+
this brand gets them stripped before the file arrives. They are private Style
|
|
623
|
+
fragment IP (`redactVideoIp` in `convex/workflows.ts`). Style pack is the
|
|
624
|
+
retired name.
|
|
554
625
|
|
|
555
626
|
**The narration track** (`narration.mp3` + `narration.json`). Present when the
|
|
556
627
|
run recorded continuously: every narrated scene's `voText` joined into one
|
|
@@ -577,9 +648,9 @@ speech inside the clip — under the default voice treatment the video model's
|
|
|
577
648
|
invented voice is then replaced, in place, with the cast member's pinned
|
|
578
649
|
ElevenLabs voice (that is `revoiced: true`). Other treatments leave the render
|
|
579
650
|
owning its own audio, e.g. lip-sync retargeting a VO track onto the video. The
|
|
580
|
-
treatment is frozen on the run when it starts, and there is exactly one
|
|
651
|
+
treatment is frozen on the run when it starts, and there is exactly one flag
|
|
581
652
|
that sets it: `--voice-treatment` on `workflow run` (see **Choosing how the
|
|
582
|
-
voices are made**). Node config
|
|
653
|
+
voices are made**). Node config never sets it. Nothing on the canvas sets a
|
|
583
654
|
voice treatment, and a run that was given none renders the default. Read
|
|
584
655
|
`wordsFrom` and `revoiced` per scene rather than assuming.
|
|
585
656
|
|
|
@@ -641,6 +712,11 @@ blocking the run.
|
|
|
641
712
|
A `speech-cutoff` on the CTA scene is the one to worry about; the ad's last
|
|
642
713
|
words are missing. `status` shows the checker's own wording behind each
|
|
643
714
|
finding; the manifest carries only the verdict.
|
|
715
|
+
- A `pass` verdict is not a review. The checks are often optimistic. Watch the
|
|
716
|
+
frames and listen to the audio before you call the run good. Two checks fail
|
|
717
|
+
the wrong way. A cast-count check can reject a silent
|
|
718
|
+
listener who belongs in the shot. A clip check can flag a clip that is fine.
|
|
719
|
+
Read the finding, then watch the scene.
|
|
644
720
|
- `clipStatus: "failed"` or `"missing"` with a `keyframe`: `durationSec` is null
|
|
645
721
|
(it comes from the clip), but the scene can still hold its place as a still —
|
|
646
722
|
for the length of its voice track, or the storyboard's planned duration when
|
|
@@ -767,7 +843,10 @@ they want; if they have not said and are not around, say which you used and why.
|
|
|
767
843
|
- **HyperFrames** — HTML/CSS compositions rendered to video. Suits an ad that
|
|
768
844
|
needs captions, title cards, overlays or motion graphics laid over the clips.
|
|
769
845
|
It is a separate skill pack; check that a `hyperframes` skill is installed
|
|
770
|
-
before offering it.
|
|
846
|
+
before offering it. The cut is made outside Exodus. `exodus video pull` is the
|
|
847
|
+
handover. The folder it writes is what the next tool reads. When a HyperFrames
|
|
848
|
+
skill pack is installed, that pack is the route for an HTML and CSS
|
|
849
|
+
composition. Do not look for a stitch inside Exodus.
|
|
771
850
|
- **An NLE over MCP** — DaVinci Resolve publishes an MCP server, so a timeline
|
|
772
851
|
can be built in a real editor and the user can carry on by hand afterwards.
|
|
773
852
|
Suits a user who wants to keep editing after you stop. Check what MCP servers
|
|
@@ -864,6 +943,11 @@ lines, re-recording narration, or cutting words out of the CTA will ship,
|
|
|
864
943
|
silently. Cut the pictures to the words, not the words to the pictures. If the
|
|
865
944
|
words are wrong, that is a new run, not an edit.
|
|
866
945
|
|
|
946
|
+
Never stretch speech, speed it up, slow it down, or cut it short so it fits a
|
|
947
|
+
clip. That is a product rule, and it holds for every tool that makes the cut,
|
|
948
|
+
including the bundled script. If the audio does not fit, redo the clip with
|
|
949
|
+
`exodus video retry-clip`. Do not repair the fit in the editor.
|
|
950
|
+
|
|
867
951
|
## Reporting to the user
|
|
868
952
|
|
|
869
953
|
Lead with where the run is and what it needs from them. Then, when you pulled
|
|
@@ -161,7 +161,7 @@ Accepts a workflow id **or** a name. It reports three things:
|
|
|
161
161
|
|
|
162
162
|
Fill every required input. For a free-text brief you can compose a **bespoke brief from in-session research** — the winning angle you just worked out, the swipe you analyzed — and pass it inline (`--input brief="..."`) or, if it's long, write it to a project file and pass it with `@file` (see Chain, below). For URL/swipe sources, pass the value the source expects.
|
|
163
163
|
|
|
164
|
-
**A brief wired straight into a Storyboard is a script, not a prompt.** On a video workflow whose Storyboard takes its `script` port from a Brief node, the text you pass for that brief boards VERBATIM, the same way a Show ad's pasted script does: one speaker per scene, every line rendered once in order, long lines split across clips, narrated lines rendered silent under the voice track. So it must be in the script form the `exodus-video` skill documents (an optional `CAST:` block of `NAME — description` lines, then `NAME:` turns, then a `CTA:` block; `NARRATOR:` lines only for Narrated Story and Explainer). A brief that is not a script, or one that does not fit the wired rig's family, is refused at launch with the parser's own message and nothing is spent. Write the script to a file and pass it with `--input script=@script.txt`. A Storyboard fed by a bot is unaffected.
|
|
164
|
+
**A brief wired straight into a Storyboard is a script, not a prompt.** On a video workflow whose Storyboard takes its `script` port from a Brief node, the text you pass for that brief boards VERBATIM, the same way a Show ad's pasted script does: one speaker per scene, every line rendered once in order, long lines split across clips, narrated lines rendered silent under the voice track. So it must be in the script form the `exodus-video` skill documents (an optional `DIRECTION:` block with no colon in its prose, then an optional `CAST:` block of `NAME — description` lines, then `NAME:` turns, then a `CTA:` block; `NARRATOR:` lines only for Narrated Story and Explainer). A brief that is not a script, or one that does not fit the wired rig's family, is refused at launch with the parser's own message and nothing is spent. Write the script to a file and pass it with `--input script=@script.txt`. A Storyboard fed by a bot is unaffected.
|
|
165
165
|
|
|
166
166
|
**A saved rig can bring a rulebook with it.** Some saved rigs carry saved format rules: how many speakers the script may have, which checks run on the clips, how the cast is built. Point a Rig box at one of those rigs and the run follows its rules — an ensemble rig seats more speakers than the usual three, a talking-head rig seats one. The start receipt says which rulebook ran:
|
|
167
167
|
|
package/dist/commands/video.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type ApiResponse } from "../lib/client.js";
|
|
2
2
|
import type { FlagOccurrence } from "../lib/args.js";
|
|
3
3
|
import { type RunProvenance, type WorkflowRun } from "./workflow.js";
|
|
4
|
+
declare const CONCEIT_KEYS: readonly ["podcast", "ugc", "stage", "street", "personification"];
|
|
4
5
|
export declare const helpText: string;
|
|
5
6
|
export declare const VOICES_PATH = "/api/v2/video/voices";
|
|
6
7
|
export interface ClipWord {
|
|
@@ -328,7 +329,29 @@ export interface StartOptions {
|
|
|
328
329
|
wait: boolean;
|
|
329
330
|
json: boolean;
|
|
330
331
|
}
|
|
332
|
+
export interface ScriptStartOptions {
|
|
333
|
+
scriptFile: string;
|
|
334
|
+
conceit: (typeof CONCEIT_KEYS)[number];
|
|
335
|
+
style: string;
|
|
336
|
+
direction?: string;
|
|
337
|
+
voicePath?: string;
|
|
338
|
+
music?: false;
|
|
339
|
+
wait: boolean;
|
|
340
|
+
json: boolean;
|
|
341
|
+
}
|
|
342
|
+
export type VideoStartPlan = {
|
|
343
|
+
kind: "usage";
|
|
344
|
+
line: string;
|
|
345
|
+
} | {
|
|
346
|
+
kind: "show";
|
|
347
|
+
opts: StartOptions;
|
|
348
|
+
} | {
|
|
349
|
+
kind: "script";
|
|
350
|
+
opts: ScriptStartOptions;
|
|
351
|
+
};
|
|
331
352
|
export declare function startFlow(opts: StartOptions, deps: VideoDeps): Promise<FlowResult>;
|
|
353
|
+
export declare function planVideoStart(flags: Record<string, string | boolean>): VideoStartPlan;
|
|
354
|
+
export declare function startScriptFlow(opts: ScriptStartOptions, deps: VideoDeps): Promise<FlowResult>;
|
|
332
355
|
export declare function waitFlow(runId: string, opts: {
|
|
333
356
|
json: boolean;
|
|
334
357
|
url?: string;
|
|
@@ -427,3 +450,4 @@ export declare function describeFetchFailure(err: unknown): string;
|
|
|
427
450
|
export declare function uploadFlow(runId: string, filePath: string, durationFlag: string | undefined, json: boolean, deps: VideoDeps): Promise<FlowResult>;
|
|
428
451
|
export declare function parsePositional(args?: string[]): string[];
|
|
429
452
|
export declare function run(flags: Record<string, string | boolean>, occurrences: FlagOccurrence[]): Promise<void>;
|
|
453
|
+
export {};
|
package/dist/commands/video.js
CHANGED
|
@@ -7,6 +7,19 @@ 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
|
+
const CONCEIT_KEYS = ["podcast", "ugc", "stage", "street", "personification"];
|
|
11
|
+
const STYLE_SLUGS = [
|
|
12
|
+
"pixar-3d",
|
|
13
|
+
"claymation-stop-motion",
|
|
14
|
+
"lego-toy-brick",
|
|
15
|
+
"balloon",
|
|
16
|
+
"video-game-8bit",
|
|
17
|
+
];
|
|
18
|
+
function joinedOr(items) {
|
|
19
|
+
if (items.length <= 1)
|
|
20
|
+
return items[0] ?? "";
|
|
21
|
+
return `${items.slice(0, -1).join(", ")} or ${items[items.length - 1]}`;
|
|
22
|
+
}
|
|
10
23
|
export const helpText = `
|
|
11
24
|
exodus video — make a video ad from a saved workflow, pull every piece, upload your cut
|
|
12
25
|
|
|
@@ -83,6 +96,7 @@ Usage:
|
|
|
83
96
|
exodus video voices <runId> [--set <character>=<voiceId>] [--clear <character>] [--from <file.json>] [--json]
|
|
84
97
|
exodus video pull <runId> --out <dir> [--json]
|
|
85
98
|
exodus video upload <runId> --file <cut.mp4> [--duration <sec>] [--json]
|
|
99
|
+
exodus video start --script <file> --conceit <${CONCEIT_KEYS.join("|")}> --style <${STYLE_SLUGS.join("|")}> [--direction "<note>"] [--voice-path <path>] [--no-music] [--wait] [--json]
|
|
86
100
|
|
|
87
101
|
Options:
|
|
88
102
|
--out <dir> Folder to write the pulled pieces into (pull)
|
|
@@ -108,6 +122,13 @@ Options:
|
|
|
108
122
|
--clear win over the same name in the file
|
|
109
123
|
--rejected-draft Print the storyboard draft the system turned down
|
|
110
124
|
(status). It was thrown away — a record only
|
|
125
|
+
--script <file> Text file of what the ad says (start)
|
|
126
|
+
--conceit <key> Kind of ad: ${joinedOr(CONCEIT_KEYS)} (start)
|
|
127
|
+
--style <slug> Look: ${joinedOr(STYLE_SLUGS)} (start)
|
|
128
|
+
--direction "<note>" Note for the whole ad (start)
|
|
129
|
+
--voice-path <path> How the voices are made (start)
|
|
130
|
+
--no-music Leave the music bed off (start)
|
|
131
|
+
--wait Wait until the storyboard needs a yes (start)
|
|
111
132
|
--json Machine-readable output
|
|
112
133
|
--help, -h Print this help
|
|
113
134
|
|
|
@@ -131,6 +152,7 @@ Examples:
|
|
|
131
152
|
`.trim();
|
|
132
153
|
const SHOWS_PATH = "/api/v2/shows";
|
|
133
154
|
const RUNS_PATH = "/api/v2/video/runs";
|
|
155
|
+
const SCRIPT_RUNS_PATH = "/api/v2/video/script-runs";
|
|
134
156
|
const RUN_PATH = "/api/v2/workflow";
|
|
135
157
|
const ITEMS_PATH = "/api/v2/workflow/items";
|
|
136
158
|
const REJECTED_DRAFT_PATH = "/api/v2/workflow/rejected-draft";
|
|
@@ -1008,6 +1030,182 @@ export async function startFlow(opts, deps) {
|
|
|
1008
1030
|
lines: opts.json ? waited.lines : [`Started ad run ${runId}`, `Watch it: ${url}`, "", ...waited.lines],
|
|
1009
1031
|
};
|
|
1010
1032
|
}
|
|
1033
|
+
function nonBlank(value) {
|
|
1034
|
+
if (value === undefined || value.trim().length === 0)
|
|
1035
|
+
return undefined;
|
|
1036
|
+
return value;
|
|
1037
|
+
}
|
|
1038
|
+
function isConceit(value) {
|
|
1039
|
+
return CONCEIT_KEYS.some((key) => key === value);
|
|
1040
|
+
}
|
|
1041
|
+
export function planVideoStart(flags) {
|
|
1042
|
+
const showId = nonBlank(flagString(flags, "show"));
|
|
1043
|
+
const scriptFile = nonBlank(flagString(flags, "script"));
|
|
1044
|
+
const conceit = nonBlank(flagString(flags, "conceit"));
|
|
1045
|
+
const style = nonBlank(flagString(flags, "style"));
|
|
1046
|
+
if (showId && (conceit || style)) {
|
|
1047
|
+
return {
|
|
1048
|
+
kind: "usage",
|
|
1049
|
+
line: "Pass either --show <id>, or --conceit and --style. Not both.",
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
if (!scriptFile) {
|
|
1053
|
+
return {
|
|
1054
|
+
kind: "usage",
|
|
1055
|
+
line: "video start needs --script <file>, a text file of what the ad says.",
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
if (showId) {
|
|
1059
|
+
return {
|
|
1060
|
+
kind: "show",
|
|
1061
|
+
opts: {
|
|
1062
|
+
showId,
|
|
1063
|
+
scriptFile,
|
|
1064
|
+
voicePath: flagString(flags, "voice-path"),
|
|
1065
|
+
music: flags["music"] === false ? false : undefined,
|
|
1066
|
+
wait: flags["wait"] === true,
|
|
1067
|
+
json: flags["json"] === true,
|
|
1068
|
+
},
|
|
1069
|
+
};
|
|
1070
|
+
}
|
|
1071
|
+
if (!conceit) {
|
|
1072
|
+
return {
|
|
1073
|
+
kind: "usage",
|
|
1074
|
+
line: `video start needs --conceit <${CONCEIT_KEYS.join("|")}>.`,
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
if (!isConceit(conceit)) {
|
|
1078
|
+
return {
|
|
1079
|
+
kind: "usage",
|
|
1080
|
+
line: `video start --conceit must be one of ${joinedOr(CONCEIT_KEYS)}.`,
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
if (!style) {
|
|
1084
|
+
return {
|
|
1085
|
+
kind: "usage",
|
|
1086
|
+
line: `video start needs --style <${STYLE_SLUGS.join("|")}>.`,
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
const direction = nonBlank(flagString(flags, "direction"));
|
|
1090
|
+
const voicePath = nonBlank(flagString(flags, "voice-path"));
|
|
1091
|
+
const opts = {
|
|
1092
|
+
scriptFile,
|
|
1093
|
+
conceit,
|
|
1094
|
+
style,
|
|
1095
|
+
wait: flags["wait"] === true,
|
|
1096
|
+
json: flags["json"] === true,
|
|
1097
|
+
};
|
|
1098
|
+
if (direction)
|
|
1099
|
+
opts.direction = direction;
|
|
1100
|
+
if (voicePath)
|
|
1101
|
+
opts.voicePath = voicePath;
|
|
1102
|
+
if (flags["music"] === false)
|
|
1103
|
+
opts.music = false;
|
|
1104
|
+
return { kind: "script", opts };
|
|
1105
|
+
}
|
|
1106
|
+
function isRecord(value) {
|
|
1107
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1108
|
+
}
|
|
1109
|
+
function isResolvedCard(value) {
|
|
1110
|
+
if (!isRecord(value))
|
|
1111
|
+
return false;
|
|
1112
|
+
if (!isRecord(value.conceit) || !isRecord(value.style))
|
|
1113
|
+
return false;
|
|
1114
|
+
if (typeof value.conceit.key !== "string" || typeof value.conceit.label !== "string")
|
|
1115
|
+
return false;
|
|
1116
|
+
if (typeof value.family !== "string")
|
|
1117
|
+
return false;
|
|
1118
|
+
if (!("wrapper" in value))
|
|
1119
|
+
return false;
|
|
1120
|
+
if (value.wrapper !== null && typeof value.wrapper !== "string")
|
|
1121
|
+
return false;
|
|
1122
|
+
if (typeof value.style.slug !== "string" || typeof value.style.label !== "string")
|
|
1123
|
+
return false;
|
|
1124
|
+
return true;
|
|
1125
|
+
}
|
|
1126
|
+
function scriptStartedLines(runId, url, card) {
|
|
1127
|
+
return [
|
|
1128
|
+
`Started ad run ${runId}`,
|
|
1129
|
+
...(card
|
|
1130
|
+
? [
|
|
1131
|
+
`Conceit: ${card.conceit.label}`,
|
|
1132
|
+
`Family: ${card.family}`,
|
|
1133
|
+
`Wrapper: ${card.wrapper ?? ""}`,
|
|
1134
|
+
`Style: ${card.style.label}`,
|
|
1135
|
+
]
|
|
1136
|
+
: ["The server did not include a resolved card."]),
|
|
1137
|
+
`Watch it: ${url}`,
|
|
1138
|
+
];
|
|
1139
|
+
}
|
|
1140
|
+
function waitJsonWithCard(waited, card) {
|
|
1141
|
+
const first = waited.lines[0];
|
|
1142
|
+
if (typeof first !== "string")
|
|
1143
|
+
return waited;
|
|
1144
|
+
let parsed;
|
|
1145
|
+
try {
|
|
1146
|
+
parsed = JSON.parse(first);
|
|
1147
|
+
}
|
|
1148
|
+
catch {
|
|
1149
|
+
return waited;
|
|
1150
|
+
}
|
|
1151
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
1152
|
+
return waited;
|
|
1153
|
+
return { code: waited.code, lines: [JSON.stringify({ ...parsed, card })] };
|
|
1154
|
+
}
|
|
1155
|
+
export async function startScriptFlow(opts, deps) {
|
|
1156
|
+
let script;
|
|
1157
|
+
try {
|
|
1158
|
+
script = deps.readFile(opts.scriptFile);
|
|
1159
|
+
}
|
|
1160
|
+
catch (e) {
|
|
1161
|
+
return {
|
|
1162
|
+
code: 1,
|
|
1163
|
+
lines: [
|
|
1164
|
+
`Can't read the script file "${opts.scriptFile}": ${e instanceof Error ? e.message : String(e)}`,
|
|
1165
|
+
],
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
if (script.trim().length === 0) {
|
|
1169
|
+
return { code: 1, lines: [`The script file "${opts.scriptFile}" is empty.`] };
|
|
1170
|
+
}
|
|
1171
|
+
const res = await deps.post(SCRIPT_RUNS_PATH, {
|
|
1172
|
+
script,
|
|
1173
|
+
conceit: opts.conceit,
|
|
1174
|
+
style: opts.style,
|
|
1175
|
+
...(opts.direction ? { direction: opts.direction } : {}),
|
|
1176
|
+
...(opts.voicePath ? { voicePath: opts.voicePath } : {}),
|
|
1177
|
+
...(opts.music === false ? { music: false } : {}),
|
|
1178
|
+
});
|
|
1179
|
+
if (!res.ok)
|
|
1180
|
+
return errorResult(res, opts.json);
|
|
1181
|
+
const started = res.data;
|
|
1182
|
+
if (!started.runId) {
|
|
1183
|
+
return { code: 1, lines: ["The server started the ad but did not say which run it is."] };
|
|
1184
|
+
}
|
|
1185
|
+
const runId = started.runId;
|
|
1186
|
+
const url = typeof started.url === "string" ? started.url : "";
|
|
1187
|
+
const card = isResolvedCard(started.card) ? started.card : null;
|
|
1188
|
+
if (!opts.wait) {
|
|
1189
|
+
return {
|
|
1190
|
+
code: 0,
|
|
1191
|
+
lines: opts.json
|
|
1192
|
+
? [JSON.stringify({ runId, url, card })]
|
|
1193
|
+
: [
|
|
1194
|
+
...scriptStartedLines(runId, url, card),
|
|
1195
|
+
"",
|
|
1196
|
+
"Wait for the storyboard here instead: exodus video start … --wait",
|
|
1197
|
+
`Or check in whenever: exodus video status ${runId}`,
|
|
1198
|
+
],
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
const waited = await waitFlow(runId, { json: opts.json, url }, deps);
|
|
1202
|
+
if (opts.json)
|
|
1203
|
+
return waitJsonWithCard(waited, card);
|
|
1204
|
+
return {
|
|
1205
|
+
code: waited.code,
|
|
1206
|
+
lines: [...scriptStartedLines(runId, url, card), "", ...waited.lines],
|
|
1207
|
+
};
|
|
1208
|
+
}
|
|
1011
1209
|
const POLL_INTERVAL_MS = 5000;
|
|
1012
1210
|
const MAX_POLLS = 720;
|
|
1013
1211
|
export async function waitFlow(runId, opts, deps) {
|
|
@@ -2147,6 +2345,9 @@ export async function uploadFlow(runId, filePath, durationFlag, json, deps) {
|
|
|
2147
2345
|
const VALUE_FLAGS = new Set([
|
|
2148
2346
|
"show",
|
|
2149
2347
|
"script",
|
|
2348
|
+
"conceit",
|
|
2349
|
+
"style",
|
|
2350
|
+
"direction",
|
|
2150
2351
|
"voice-path",
|
|
2151
2352
|
"note",
|
|
2152
2353
|
"out",
|
|
@@ -2200,20 +2401,12 @@ export async function run(flags, occurrences) {
|
|
|
2200
2401
|
if (sub === "shows")
|
|
2201
2402
|
return printResult(await showsFlow(json, defaultDeps));
|
|
2202
2403
|
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));
|
|
2404
|
+
const plan = planVideoStart(flags);
|
|
2405
|
+
if (plan.kind === "usage")
|
|
2406
|
+
usage(plan.line);
|
|
2407
|
+
if (plan.kind === "show")
|
|
2408
|
+
return printResult(await startFlow(plan.opts, defaultDeps));
|
|
2409
|
+
return printResult(await startScriptFlow(plan.opts, defaultDeps));
|
|
2217
2410
|
}
|
|
2218
2411
|
const runId = rest[0];
|
|
2219
2412
|
const needsRunId = [
|