@aicopycoders/exodus 2026.9.2000 → 2026.10.1200-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,6 +10,8 @@ export type CustomerStopReason = {
10
10
  kind: "out-of-credits";
11
11
  } | {
12
12
  kind: "openrouter-out-of-credits";
13
+ } | {
14
+ kind: "plan-too-big";
13
15
  } | {
14
16
  kind: "keys-missing";
15
17
  keys: string[];
@@ -25,6 +27,7 @@ export interface CustomerRun {
25
27
  } | null;
26
28
  mode: "one-shot" | "guided";
27
29
  stage: CustomerRunStage;
30
+ awaitingSignOff?: boolean;
28
31
  waiting: {
29
32
  ahead: number;
30
33
  stepsDone: number;
@@ -39,6 +39,12 @@ function stoppedLines(run, page) {
39
39
  "Add credits to your OpenRouter account at openrouter.ai, then try again from the video's page. The OpenRouter key this video uses is in Settings → API Keys.",
40
40
  ];
41
41
  }
42
+ if (reason?.kind === "plan-too-big") {
43
+ return [
44
+ "This video's plan came out too big to make in one go.",
45
+ `Try again from the video's page and it will be planned fresh: ${page} If it stops here again, try a shorter script.`,
46
+ ];
47
+ }
42
48
  if (reason?.kind === "keys-missing") {
43
49
  return [
44
50
  `This video stopped because ${reason.keys.length === 1 ? "a key is" : "some keys are"} missing (${reason.keys.join(", ")}).`,
@@ -67,7 +73,12 @@ export function customerStageLines(run, page) {
67
73
  ];
68
74
  case "ready":
69
75
  return [
70
- `Your video is ready: ${run.video?.url ?? page}`,
76
+ ...(run.awaitingSignOff
77
+ ? [
78
+ `Your video is ready to watch: ${run.video?.url ?? page}`,
79
+ `When you're happy with it, finish it: exodus video approve ${id}`,
80
+ ]
81
+ : [`Your video is ready: ${run.video?.url ?? page}`]),
71
82
  ...(run.video?.wordsOff
72
83
  ? ["Some words may not match your script exactly. Watch it through before you post it."]
73
84
  : []),
@@ -2,9 +2,19 @@ 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
4
  import { type CustomerItem, type CustomerRun } from "./video-customer.js";
5
- declare const CONCEIT_KEYS: readonly ["podcast", "ugc", "stage", "street", "personification"];
6
5
  declare const VOICE_MODES: readonly ["native", "voice-first"];
7
6
  type VoiceMode = (typeof VOICE_MODES)[number];
7
+ export type ScriptRunOptions = {
8
+ conceits: {
9
+ key: string;
10
+ label: string;
11
+ }[];
12
+ styles: {
13
+ slug: string;
14
+ label: string;
15
+ }[];
16
+ };
17
+ export declare function scriptRunPickProblem(options: ScriptRunOptions, conceit: string, style: string): string | null;
8
18
  export declare const helpText: string;
9
19
  export declare const RUN_PATH = "/api/v2/workflow";
10
20
  export declare const ITEMS_PATH = "/api/v2/workflow/items";
@@ -168,6 +178,7 @@ export interface VideoRun {
168
178
  provenance?: RunProvenance;
169
179
  musicBed?: boolean;
170
180
  videoChoice?: RunVideoChoice;
181
+ shots?: RunShots;
171
182
  storyboardAutoApproval?: {
172
183
  outcome: "approved" | "held";
173
184
  at: number;
@@ -231,6 +242,7 @@ export interface ShowRow {
231
242
  export interface FlowResult {
232
243
  code: number;
233
244
  lines: string[];
245
+ usage?: true;
234
246
  }
235
247
  export interface VideoDeps {
236
248
  get: (path: string) => Promise<ApiResponse<unknown>>;
@@ -390,6 +402,11 @@ export interface VideoManifest {
390
402
  videoModel: string;
391
403
  voiceMode: string;
392
404
  };
405
+ shots?: RunShots;
406
+ }
407
+ export interface RunShots {
408
+ frame?: string;
409
+ pacing?: string;
393
410
  }
394
411
  export declare const CAST_LEDGER_BASE: number;
395
412
  export declare function musicBedState(run: VideoRun): MusicBedState;
@@ -417,7 +434,7 @@ export interface StartOptions {
417
434
  }
418
435
  export interface ScriptStartOptions {
419
436
  scriptFile: string;
420
- conceit: (typeof CONCEIT_KEYS)[number];
437
+ conceit: string;
421
438
  style: string;
422
439
  direction?: string;
423
440
  voicePath?: string;
@@ -426,6 +443,7 @@ export interface ScriptStartOptions {
426
443
  music?: boolean;
427
444
  reviewStoryboard?: true;
428
445
  director?: true;
446
+ shots?: RunShots;
429
447
  wait: boolean;
430
448
  json: boolean;
431
449
  }
@@ -574,6 +592,21 @@ export declare function pullFlow(runId: string, dir: string, json: boolean, deps
574
592
  export declare function parseMvhdDurationSec(bytes: Uint8Array): number | null;
575
593
  export declare const NO_DURATION_MESSAGE: string;
576
594
  export declare function describeFetchFailure(err: unknown): string;
595
+ export interface CutScene {
596
+ scene: number;
597
+ startSec: number;
598
+ seconds: number;
599
+ }
600
+ export interface CutWord {
601
+ text: string;
602
+ start: number;
603
+ end: number;
604
+ scene: number;
605
+ }
606
+ export declare function cutSidecars(filePath: string, deps: VideoDeps): {
607
+ cutScenes?: CutScene[];
608
+ cutWords?: CutWord[];
609
+ };
577
610
  export declare function uploadFlow(runId: string, filePath: string, durationFlag: string | undefined, json: boolean, deps: VideoDeps): Promise<FlowResult>;
578
611
  export declare function parsePositional(args?: string[]): string[];
579
612
  export declare function run(flags: Record<string, string | boolean>, occurrences: FlagOccurrence[]): Promise<void>;
@@ -8,20 +8,27 @@ 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
10
  import { customerAnswer, customerRunPage, customerStageIsWorking, customerStageLines, customerStatusLines, customerStoryboardLines, customerVoicesLines, } from "./video-customer.js";
11
- const CONCEIT_KEYS = ["podcast", "ugc", "stage", "street", "personification"];
12
11
  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
12
  function joinedOr(items) {
21
13
  if (items.length <= 1)
22
14
  return items[0] ?? "";
23
15
  return `${items.slice(0, -1).join(", ")} or ${items[items.length - 1]}`;
24
16
  }
17
+ export function scriptRunPickProblem(options, conceit, style) {
18
+ const keys = options.conceits.map((row) => row.key);
19
+ if (!keys.includes(conceit)) {
20
+ return keys.length === 0
21
+ ? "This Exodus server offers no kinds of ad yet."
22
+ : `video start --conceit must be one of ${joinedOr(keys)}.`;
23
+ }
24
+ const slugs = options.styles.map((row) => row.slug);
25
+ if (!slugs.includes(style)) {
26
+ return slugs.length === 0
27
+ ? "This Exodus server offers no looks yet."
28
+ : `video start --style must be one of ${joinedOr(slugs)}.`;
29
+ }
30
+ return null;
31
+ }
25
32
  function cantSayNames(names) {
26
33
  return `the recorded voice can't say ${joinedOr(names.map((n) => `"${n}"`))}`;
27
34
  }
@@ -105,7 +112,7 @@ Usage:
105
112
  exodus video voices <runId> [--set <character>=<voiceId>] [--clear <character>] [--from <file.json>] [--json]
106
113
  exodus video pull <runId> --out <dir> [--json]
107
114
  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]
115
+ exodus video start --script <file> --conceit <kind> --style <look> [--direction "<note>"] [--frame pov] [--pacing <jump-cuts|long-take|montage>] [--voice-path <path>] [--video-model <id>] [--voice <native|voice-first>] [--music] [--review-storyboard] [--director] [--wait] [--json]
109
116
  exodus video director <runId> <on|off|follow> [--json]
110
117
 
111
118
  Options:
@@ -137,9 +144,16 @@ Options:
137
144
  --rejected-draft Print the storyboard draft the system turned down
138
145
  (status). It was thrown away — a record only
139
146
  --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)
147
+ --conceit <kind> Kind of ad (start)
148
+ --style <look> Look (start)
149
+ The server lists the kinds and looks it offers. An unknown word prints them.
142
150
  --direction "<note>" Note for the whole ad (start)
151
+ --frame pov Camera-as-character POV: the camera is a person in the
152
+ scene (start, with --conceit). Leave it off for a
153
+ single frame. Conversation ads only
154
+ --pacing <pace> jump-cuts, long-take or montage (start, with
155
+ --conceit). Leave it off for natural pacing. Presenter
156
+ ads only
143
157
  --voice-path <path> How the voices are made (start)
144
158
  --video-model <id> Which video model films this ad, instead of the kind of
145
159
  ad's own default (start, with --conceit)
@@ -163,8 +177,8 @@ Options:
163
177
 
164
178
  Who can use what. Admins get every command here. Beta testers with video
165
179
  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
180
+ --style, --direction, --frame, --pacing, --music, --review-storyboard),
181
+ status, storyboard, approve (the storyboard), voices, retry-frame --scene <n> (redo one scene's
168
182
  picture while the storyboard waits), pull (the finished video, each scene's
169
183
  clip and voice, and the music) and upload. These stay admin-only:
170
184
  start --video-model / --voice / --voice-path / --director, director,
@@ -1068,6 +1082,7 @@ export function planPull(run, items, opts) {
1068
1082
  },
1069
1083
  }
1070
1084
  : {}),
1085
+ ...(run.shots?.frame || run.shots?.pacing ? { shots: run.shots } : {}),
1071
1086
  },
1072
1087
  };
1073
1088
  }
@@ -1247,9 +1262,6 @@ function nonBlank(value) {
1247
1262
  function isVoiceMode(value) {
1248
1263
  return VOICE_MODES.some((mode) => mode === value);
1249
1264
  }
1250
- function isConceit(value) {
1251
- return CONCEIT_KEYS.some((key) => key === value);
1252
- }
1253
1265
  export function planMusicChoice(occurrences) {
1254
1266
  let music;
1255
1267
  for (const { flag, value } of occurrences) {
@@ -1314,6 +1326,12 @@ export function planVideoStart(flags, occurrences) {
1314
1326
  const voiceFlag = planValueFlag(occurrences, "voice");
1315
1327
  if (!voiceFlag.ok)
1316
1328
  return { kind: "usage", line: voiceFlag.line };
1329
+ const frameFlag = planValueFlag(occurrences, "frame");
1330
+ if (!frameFlag.ok)
1331
+ return { kind: "usage", line: frameFlag.line };
1332
+ const pacingFlag = planValueFlag(occurrences, "pacing");
1333
+ if (!pacingFlag.ok)
1334
+ return { kind: "usage", line: pacingFlag.line };
1317
1335
  const videoModel = videoModelFlag.value;
1318
1336
  const voiceMode = voiceFlag.value;
1319
1337
  const reviewStoryboard = occurrences.some((o) => o.flag === "review-storyboard");
@@ -1337,6 +1355,12 @@ export function planVideoStart(flags, occurrences) {
1337
1355
  line: "--director works with --conceit, not --show.",
1338
1356
  };
1339
1357
  }
1358
+ if (showId && (frameFlag.value || pacingFlag.value)) {
1359
+ return {
1360
+ kind: "usage",
1361
+ line: "--frame and --pacing work with --conceit, not --show.",
1362
+ };
1363
+ }
1340
1364
  if (showId && (videoModel || voiceMode)) {
1341
1365
  return {
1342
1366
  kind: "usage",
@@ -1365,19 +1389,13 @@ export function planVideoStart(flags, occurrences) {
1365
1389
  if (!conceit) {
1366
1390
  return {
1367
1391
  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)}.`,
1392
+ line: "video start needs --conceit <kind>.",
1375
1393
  };
1376
1394
  }
1377
1395
  if (!style) {
1378
1396
  return {
1379
1397
  kind: "usage",
1380
- line: `video start needs --style <${STYLE_SLUGS.join("|")}>.`,
1398
+ line: "video start needs --style <look>.",
1381
1399
  };
1382
1400
  }
1383
1401
  const direction = nonBlank(flagString(flags, "direction"));
@@ -1403,11 +1421,35 @@ export function planVideoStart(flags, occurrences) {
1403
1421
  opts.reviewStoryboard = true;
1404
1422
  if (director)
1405
1423
  opts.director = true;
1424
+ if (frameFlag.value || pacingFlag.value) {
1425
+ opts.shots = {
1426
+ ...(frameFlag.value ? { frame: frameFlag.value } : {}),
1427
+ ...(pacingFlag.value ? { pacing: pacingFlag.value } : {}),
1428
+ };
1429
+ }
1406
1430
  return { kind: "script", opts };
1407
1431
  }
1408
1432
  function isRecord(value) {
1409
1433
  return typeof value === "object" && value !== null && !Array.isArray(value);
1410
1434
  }
1435
+ function scriptRunOptionsOf(data) {
1436
+ if (!isRecord(data) || !Array.isArray(data.conceits) || !Array.isArray(data.styles)) {
1437
+ return null;
1438
+ }
1439
+ const conceits = [];
1440
+ for (const row of data.conceits) {
1441
+ if (!isRecord(row) || typeof row.key !== "string" || typeof row.label !== "string")
1442
+ return null;
1443
+ conceits.push({ key: row.key, label: row.label });
1444
+ }
1445
+ const styles = [];
1446
+ for (const row of data.styles) {
1447
+ if (!isRecord(row) || typeof row.slug !== "string" || typeof row.label !== "string")
1448
+ return null;
1449
+ styles.push({ slug: row.slug, label: row.label });
1450
+ }
1451
+ return { conceits, styles };
1452
+ }
1411
1453
  function isResolvedCard(value) {
1412
1454
  if (!isRecord(value))
1413
1455
  return false;
@@ -1482,6 +1524,17 @@ export async function startScriptFlow(opts, deps) {
1482
1524
  if (script.trim().length === 0) {
1483
1525
  return { code: 1, lines: [`The script file "${opts.scriptFile}" is empty.`] };
1484
1526
  }
1527
+ const listed = await deps.get(SCRIPT_RUNS_PATH).catch(() => null);
1528
+ if (listed?.ok) {
1529
+ const options = scriptRunOptionsOf(listed.data);
1530
+ if (!options) {
1531
+ return adminOnlyResult("The server's list of kinds and looks could not be read.", opts.json);
1532
+ }
1533
+ const problem = scriptRunPickProblem(options, opts.conceit, opts.style);
1534
+ if (problem) {
1535
+ return opts.json ? adminOnlyResult(problem, true) : { code: 1, lines: [problem], usage: true };
1536
+ }
1537
+ }
1485
1538
  const res = await deps.post(SCRIPT_RUNS_PATH, {
1486
1539
  script,
1487
1540
  conceit: opts.conceit,
@@ -1493,6 +1546,7 @@ export async function startScriptFlow(opts, deps) {
1493
1546
  ...(opts.music === true ? { music: true } : {}),
1494
1547
  ...(opts.reviewStoryboard ? { reviewStoryboard: true } : {}),
1495
1548
  ...(opts.director ? { director: true } : {}),
1549
+ ...(opts.shots ? { shots: opts.shots } : {}),
1496
1550
  });
1497
1551
  if (!res.ok)
1498
1552
  return errorResult(res, opts.json);
@@ -2898,6 +2952,46 @@ function uploadRetryResult(failed, json) {
2898
2952
  ],
2899
2953
  };
2900
2954
  }
2955
+ const CUT_SCENES_MAX = 200;
2956
+ const CUT_WORDS_MAX = 2000;
2957
+ const isTime = (n) => typeof n === "number" && Number.isFinite(n) && n >= 0;
2958
+ function cutScene(row) {
2959
+ const r = row;
2960
+ return r && Number.isInteger(r.scene) && isTime(r.startSec) && isTime(r.seconds) && r.seconds > 0
2961
+ ? { scene: r.scene, startSec: r.startSec, seconds: r.seconds }
2962
+ : null;
2963
+ }
2964
+ function cutWord(row) {
2965
+ const r = row;
2966
+ return r &&
2967
+ typeof r.text === "string" &&
2968
+ r.text.trim() !== "" &&
2969
+ isTime(r.start) &&
2970
+ isTime(r.end) &&
2971
+ r.end >= r.start &&
2972
+ Number.isInteger(r.scene)
2973
+ ? { text: r.text, start: r.start, end: r.end, scene: r.scene }
2974
+ : null;
2975
+ }
2976
+ function readSidecar(file, parse, max, deps) {
2977
+ let rows;
2978
+ try {
2979
+ rows = JSON.parse(deps.readFile(file));
2980
+ }
2981
+ catch {
2982
+ return null;
2983
+ }
2984
+ if (!Array.isArray(rows) || rows.length === 0 || rows.length > max)
2985
+ return null;
2986
+ const parsed = rows.map(parse);
2987
+ return parsed.every((r) => r !== null) ? parsed : null;
2988
+ }
2989
+ export function cutSidecars(filePath, deps) {
2990
+ const base = path.join(path.dirname(filePath), path.basename(filePath, path.extname(filePath)));
2991
+ const cutScenes = readSidecar(`${base}.scenes.json`, cutScene, CUT_SCENES_MAX, deps);
2992
+ const cutWords = readSidecar(`${base}.words.json`, cutWord, CUT_WORDS_MAX, deps);
2993
+ return { ...(cutScenes ? { cutScenes } : {}), ...(cutWords ? { cutWords } : {}) };
2994
+ }
2901
2995
  export async function uploadFlow(runId, filePath, durationFlag, json, deps) {
2902
2996
  const stat = deps.statFile(filePath);
2903
2997
  if (!stat)
@@ -2940,6 +3034,7 @@ export async function uploadFlow(runId, filePath, durationFlag, json, deps) {
2940
3034
  const durationSec = asked ?? deps.probeDurationSec(filePath) ?? parseMvhdDurationSec(bytes);
2941
3035
  if (durationSec === null)
2942
3036
  return { code: 1, lines: [NO_DURATION_MESSAGE] };
3037
+ const timing = cutSidecars(filePath, deps);
2943
3038
  const minting = await withUploadRetry("mint", deps, () => deps.post(ASSET_UPLOAD_URL_PATH, {}));
2944
3039
  if (!minting.ok)
2945
3040
  return uploadRetryResult(minting, json);
@@ -2980,6 +3075,7 @@ export async function uploadFlow(runId, filePath, durationFlag, json, deps) {
2980
3075
  runId,
2981
3076
  assetId,
2982
3077
  durationSec,
3078
+ ...timing,
2983
3079
  }));
2984
3080
  if (!attaching.ok)
2985
3081
  return uploadRetryResult(attaching, json);
@@ -3033,6 +3129,8 @@ const VALUE_FLAGS = new Set([
3033
3129
  "voice-path",
3034
3130
  "video-model",
3035
3131
  "voice",
3132
+ "frame",
3133
+ "pacing",
3036
3134
  "note",
3037
3135
  "out",
3038
3136
  "file",
@@ -3090,7 +3188,10 @@ export async function run(flags, occurrences) {
3090
3188
  usage(plan.line);
3091
3189
  if (plan.kind === "show")
3092
3190
  return printResult(await startFlow(plan.opts, defaultDeps));
3093
- return printResult(await startScriptFlow(plan.opts, defaultDeps));
3191
+ const started = await startScriptFlow(plan.opts, defaultDeps);
3192
+ if (started.usage)
3193
+ usage(started.lines[0] ?? "");
3194
+ return printResult(started);
3094
3195
  }
3095
3196
  const runId = rest[0];
3096
3197
  const needsRunId = [
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@aicopycoders/exodus",
3
- "version": "2026.9.2000",
3
+ "version": "2026.10.1200-beta.1",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",
7
- "url": "git+https://github.com/aicopycoders/exodus.git"
7
+ "url": "git+https://github.com/aicopycoders/exodus-cli.git"
8
8
  },
9
- "homepage": "https://github.com/aicopycoders/exodus#readme",
9
+ "homepage": "https://github.com/aicopycoders/exodus-cli#readme",
10
10
  "type": "module",
11
11
  "bin": {
12
12
  "exodus": "dist/bin/exodus.js"