@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.** Five stops, each with the exact next command printed
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. A `CAST:` block naming the speakers, one line per turn, then a
215
- `CTA:` block. The planner sizes every line at
216
- 2.5 words per second (`WORD_LADDER_WPS`), so a turn near 14 words plans a clip
217
- near 6 seconds. If a line does get truncated in its clip, the manifest says so
218
- (`speech-cutoff`).
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 it
265
- gets fixed, and it only works **before** the storyboard is approved — once clips start
266
- it refuses.
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 door. Two
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 — it is not a door.
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 — they are style-pack IP
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 door
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 is never a door — nothing on the canvas sets a
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
 
@@ -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 {};
@@ -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 showId = flagString(flags, "show");
2204
- const scriptFile = flagString(flags, "script");
2205
- if (!showId)
2206
- usage("video start needs --show <id>. List them with: exodus video shows");
2207
- if (!scriptFile)
2208
- usage("video start needs --script <file>, a text file of what the ad says.");
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 = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicopycoders/exodus",
3
- "version": "2026.9.2000-beta.7",
3
+ "version": "2026.9.2000-beta.9",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",