@aicopycoders/exodus 2026.9.1400-beta.1 → 2026.9.1700-beta.0

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.
@@ -64,7 +64,7 @@ These are the **Exodus** pipelines — the automatic system that drives the Gene
64
64
  | **Video** | `exodus-video` | Make a video ad from a Show: start a run from a script, read and approve the storyboard, `npx @aicopycoders/exodus video pull` every piece (per-scene clips, voices, keyframes, word timings, music bed) plus a manifest, cut them with the bundled first-cut script, upload the cut and approve it. Admin-only. |
65
65
  | **Browse** | `exodus-browse` | View history and retrieve past outputs |
66
66
  | **Drive** | `exodus-drive` | Read/write Google Docs, Sheets, and Drive files via `npx @aicopycoders/exodus drive` (uses the dashboard's Google OAuth — no local CLI) |
67
- | **Swipe / Mining** | (no skill; CLI only) | The competitor watch list behind the Mining page: `npx @aicopycoders/exodus swipe brands list`, `swipe brands bulk-import <csv>` (checks each brand and starts collecting its ads), `swipe brands add "<Name>" --fb <pageId>` (adds without starting research, #1366), `swipe run` / `swipe mine`. There is no `competitors` verb. |
67
+ | **Swipe / Mining** | (no skill; CLI only) | The competitor watch list behind the Mining page: `npx @aicopycoders/exodus swipe brands list`, `swipe brands bulk-import <csv>` (checks each brand and starts collecting its ads), `swipe brands add "<Name>" --fb <pageId>` (adds one brand and starts checking it the same way), `swipe run` / `swipe mine`. There is no `competitors` verb. |
68
68
  | **Ads** | `exodus-ads` | Read your own live Meta ads once the account is connected on the dashboard — `npx @aicopycoders/exodus ads list` (sort by cost per result, spend or CTR), `ads show <adId>` (copy, both windows, media links, comment counts), `npx @aicopycoders/exodus comments list` (already commenter-redacted), and `npx @aicopycoders/exodus winners definition` (what this brand means by a winner). Read-only; no Meta login or MCP needed. |
69
69
  | **Winners** | `exodus-winners` | Mine your own Meta ad account for winning ads and import them into Exodus as generative fuel — interview, visual confirmation, `npx @aicopycoders/exodus winners import`. A brand whose Meta account is connected reads its ads and its winner definition straight from Exodus; only an unconnected brand needs the Meta Ads MCP. |
70
70
 
@@ -43,6 +43,7 @@ exodus video start --show <id> --script script.txt --wait start, wait for the s
43
43
  exodus video storyboard <runId> read the scene cards
44
44
  exodus video approve <runId> keep going
45
45
  exodus video flag <runId> --note "what's wrong" or send it back
46
+ exodus video retry-frame <runId> --node <nodeId> --scene <n> redo one still at the pixel gate
46
47
  exodus video status <runId> where it is, per scene
47
48
  exodus video pull <runId> --out ./ad-<runId> every piece + manifest.json
48
49
  node .claude/skills/exodus-video/scripts/first-cut.mjs ./ad-<runId> a plain cut (path from the workspace root)
@@ -115,13 +116,19 @@ storyboard.json scene text, order, planned durations, cast looks
115
116
  reference.<ext> the reference still the pictures were drawn from
116
117
  scene-NN.keyframe.<ext> the picture for scene NN
117
118
  scene-NN.voice.<ext> the voice track for scene NN, when one was delivered
118
- (a Show whose voices live inside the clips delivers none)
119
+ (a Show whose voices live inside the clips delivers none;
120
+ on a continuous-narrator run this is a cut of narration.mp3)
121
+ narration.mp3 one continuous narrator take for the whole script (#1802),
122
+ when the run used continuous narration
123
+ narration.json takeHash, voice/model/speed, master-timeline words, scene
124
+ cut ranges into narration.mp3
119
125
  scene-NN.<ext> the clip for scene NN, usually .mp4, voice included
120
126
  scene-NN.words.json [{w, s, e}] word timings for the speech in that scene, from
121
127
  the clip's own dialogue or, on a narrated scene, from the
122
128
  voice track (seconds from the start of that file)
123
129
  music.<ext> the music bed, composed to the clips' total length
124
- manifest.json the index below
130
+ manifest.json the index below (includes narration { file, timing } when
131
+ a continuous master landed)
125
132
  ```
126
133
 
127
134
  `manifest.json`:
@@ -147,6 +154,9 @@ How to read it before cutting:
147
154
  - `wordsFrom: "voice"` (or narration in the storyboard with no on-camera line):
148
155
  a narrated scene. Its clip is picture only; the voice track is the sound.
149
156
  Never cut a narrated scene with the clip's own audio.
157
+ - Continuous master present (`manifest.narration`): listen to `narration.mp3`
158
+ and the joins between adjacent scene voice files before approving the cut.
159
+ Voice-ID match alone does not prove continuity (#1802).
150
160
  - `flagged: true` is a warning, not a block. The clip was delivered anyway; the
151
161
  findings say what the QC model saw (`wrong-character`, `eyeline-off`,
152
162
  `set-drift`, `speech-cutoff`, `framing-off`). Tell the user which scenes are
@@ -143,6 +143,8 @@ Accepts a workflow id **or** a name. It reports three things:
143
143
 
144
144
  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.
145
145
 
146
+ **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.
147
+
146
148
  For an **`asset` (file) input, pass the path to a local file** and the CLI does the uploading for you — mint, push the bytes, register, then start the run with the stored asset:
147
149
 
148
150
  ```bash
@@ -128,7 +128,7 @@ Thresholds used: <…>. Verdict: FOLLOW 3+ · WATCH 2 · SKIP 0–1.
128
128
 
129
129
  The sheet lives in the folder. The FOLLOW rows become the watch list: an Ad Library page link per brand (the shape above) saved as a links file the rip can consume, and the brands added to Atria's followed list by hand in the app. When the brand is set up in Exodus, the Scout feed covers the Instagram side.
130
130
 
131
- **Push the watch list into Exodus (the Mining page).** The CLI verb is `npx @aicopycoders/exodus swipe brands`, not `competitors`. Write the FOLLOW rows as a CSV (`name,igHandle,fbPageId,category[,website][,youtubeHandle]`; the Ad Library `view_all_page_id` is the `fbPageId`) and run `npx @aicopycoders/exodus swipe brands bulk-import <file.csv>`: it checks each brand's page on the member's own Scrape Creators key and starts collecting its ads. `swipe brands add "<Name>" --fb <pageId>` adds one brand but does not start the research yet (open issue #1366), so prefer the CSV route even for one brand. `swipe brands list` shows what is on the page.
131
+ **Push the watch list into Exodus (the Mining page).** The CLI verb is `npx @aicopycoders/exodus swipe brands`, not `competitors`. Write the FOLLOW rows as a CSV (`name,igHandle,fbPageId,category[,website][,youtubeHandle]`; the Ad Library `view_all_page_id` is the `fbPageId`) and run `npx @aicopycoders/exodus swipe brands bulk-import <file.csv>`: it checks each brand's page on the member's own Scrape Creators key and starts collecting its ads. `swipe brands add "<Name>" --fb <pageId>` adds one brand and starts the same check on it; the CSV route is only for lists. `swipe brands list` shows what is on the page.
132
132
 
133
133
  ## Hand-offs
134
134
 
@@ -15,6 +15,8 @@ export interface ClipFinding {
15
15
  code: string;
16
16
  severity: "fail" | "warn";
17
17
  detail: string;
18
+ judgeDetail?: string;
19
+ judgeSeverity?: "fail" | "warn";
18
20
  }
19
21
  export type ArtifactSubset = {
20
22
  type: "storyboard";
@@ -29,6 +31,7 @@ export type ArtifactSubset = {
29
31
  } | {
30
32
  type: "image";
31
33
  imageUrl?: string;
34
+ storageId?: string;
32
35
  } | {
33
36
  type: "video";
34
37
  sceneIndex?: number;
@@ -45,6 +48,19 @@ export type ArtifactSubset = {
45
48
  audioUrl?: string;
46
49
  durationSec?: number;
47
50
  words?: ClipWord[];
51
+ narration?: {
52
+ takeHash: string;
53
+ voiceId?: string;
54
+ modelId?: string;
55
+ speed?: number;
56
+ alignment?: "elevenlabs-timestamps";
57
+ cuts?: Array<{
58
+ sceneIndex: number;
59
+ startSec: number;
60
+ durationSec: number;
61
+ takeHash: string;
62
+ }>;
63
+ };
48
64
  } | {
49
65
  type: "text" | "primer" | "session" | "document";
50
66
  };
@@ -56,6 +72,20 @@ export interface VideoRunNode {
56
72
  outputs?: ArtifactSubset[];
57
73
  }
58
74
  export type BuilderPauseReason = "taste" | "repair" | "slots" | "call" | "checkpoint";
75
+ export interface PullCastLockMember {
76
+ characterId: string;
77
+ name: string;
78
+ look?: string;
79
+ identityRefs?: Array<{
80
+ storageId?: string;
81
+ imageUrl?: string;
82
+ }>;
83
+ }
84
+ export interface PullCastLock {
85
+ mintedAt: number;
86
+ styleSlug?: string;
87
+ cast: PullCastLockMember[];
88
+ }
59
89
  export interface VideoRun {
60
90
  _id: string;
61
91
  status: string;
@@ -64,6 +94,7 @@ export interface VideoRun {
64
94
  pauseReason?: BuilderPauseReason;
65
95
  pausedNodeId?: string;
66
96
  nodes: VideoRunNode[];
97
+ castLock?: PullCastLock | null;
67
98
  }
68
99
  export declare function asVideoRun(data: unknown): VideoRun;
69
100
  export interface NodeItem {
@@ -75,6 +106,7 @@ export interface NodeItem {
75
106
  attempt?: number;
76
107
  flagged?: boolean;
77
108
  findings?: ClipFinding[];
109
+ artifact?: ArtifactSubset;
78
110
  }
79
111
  export interface ShowRow {
80
112
  id: string;
@@ -124,6 +156,7 @@ export type RunStop = {
124
156
  } | {
125
157
  at: "storyboard-gate";
126
158
  nodeId?: string;
159
+ framesNodeId?: string;
127
160
  } | {
128
161
  at: "final-watch";
129
162
  } | {
@@ -167,13 +200,20 @@ export interface ManifestScene {
167
200
  error: string | null;
168
201
  flagged: boolean;
169
202
  findings: ClipFinding[];
170
- keyframeFindings: ClipFinding[];
203
+ keyframeFindings: ClipFinding[] | null;
171
204
  }
172
205
  export interface PullFailure {
173
206
  file: string;
174
207
  url: string;
175
208
  error: string;
176
209
  }
210
+ export interface ManifestCastRef {
211
+ characterId: string | null;
212
+ name: string | null;
213
+ file: string | null;
214
+ status: string;
215
+ error: string | null;
216
+ }
177
217
  export interface VideoManifest {
178
218
  runId: string;
179
219
  pulledAt: string;
@@ -181,9 +221,15 @@ export interface VideoManifest {
181
221
  storyboard: string | null;
182
222
  reference: string | null;
183
223
  music: string | null;
224
+ cast: ManifestCastRef[];
225
+ narration: {
226
+ file: string;
227
+ timing: string;
228
+ } | null;
184
229
  scenes: ManifestScene[];
185
230
  failed: PullFailure[];
186
231
  }
232
+ export declare const CAST_LEDGER_BASE = 910000;
187
233
  export interface PullPlan {
188
234
  downloads: PullDownload[];
189
235
  texts: PullTextFile[];
@@ -217,6 +263,7 @@ export declare function statusFlow(runId: string, json: boolean, deps: VideoDeps
217
263
  export declare function storyboardFlow(runId: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
218
264
  export declare function approveFlow(runId: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
219
265
  export declare function flagFlow(runId: string, note: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
266
+ export declare function retryFrameFlow(runId: string, nodeId: string, sceneIndex: number, note: string | undefined, json: boolean, deps: VideoDeps): Promise<FlowResult>;
220
267
  export declare function pullFlow(runId: string, dir: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
221
268
  export declare function parseMvhdDurationSec(bytes: Uint8Array): number | null;
222
269
  export declare const NO_DURATION_MESSAGE: string;
@@ -27,6 +27,8 @@ The whole loop, in order:
27
27
 
28
28
  4. exodus video approve <runId> (looks right — keep going)
29
29
  exodus video flag <runId> --note "…" (something is wrong — send it back)
30
+ exodus video retry-frame <runId> --node <nodeId> --scene <n>
31
+ Redo ONE still at the pixel gate. Neighbours stay. The gate holds.
30
32
 
31
33
  5. exodus video status <runId>
32
34
  Where the run is and how each scene's clip turned out.
@@ -49,6 +51,7 @@ Usage:
49
51
  exodus video storyboard <runId> [--json]
50
52
  exodus video approve <runId> [--json]
51
53
  exodus video flag <runId> --note "<what is wrong>" [--json]
54
+ exodus video retry-frame <runId> --node <nodeId> --scene <n> [--note "..."] [--json]
52
55
  exodus video pull <runId> --out <dir> [--json]
53
56
  exodus video upload <runId> --file <cut.mp4> [--duration <sec>] [--json]
54
57
 
@@ -63,7 +66,10 @@ Options:
63
66
  --file <cut.mp4> Your finished cut (upload). MP4, MOV or WebM, up to 200MB
64
67
  --duration <sec> How long your cut is, in seconds. Only needed when the
65
68
  length can't be read off the file itself
66
- --note "<text>" What is wrong with the storyboard (flag)
69
+ --note "<text>" What is wrong with the storyboard (flag), or how to
70
+ steer one frame redo (retry-frame)
71
+ --node <nodeId> Which scene-frames node holds the still (retry-frame)
72
+ --scene <n> Which scene's frame to redo. One scene only (retry-frame)
67
73
  --json Machine-readable output
68
74
  --help, -h Show this help
69
75
 
@@ -75,6 +81,7 @@ Examples:
75
81
  exodus video start --show k57abc --script ./script.txt --wait
76
82
  exodus video storyboard run_123
77
83
  exodus video approve run_123
84
+ exodus video retry-frame run_123 --node frames-1 --scene 2
78
85
  exodus video pull run_123 --out ./ad-run_123
79
86
  exodus video upload run_123 --file ./cut.mp4
80
87
  `.trim();
@@ -85,9 +92,13 @@ const ITEMS_PATH = "/api/v2/workflow/items";
85
92
  const STORYBOARD_PATH = "/api/v2/video/storyboard";
86
93
  const FLAG_PATH = "/api/v2/video/storyboard/flag";
87
94
  const APPROVE_PATH = "/api/v2/workflow/approve";
95
+ const SCENE_RETRY_PATH = "/api/v2/workflow/scene/retry";
88
96
  const FINAL_PATH = "/api/v2/video/final";
89
97
  const ASSET_UPLOAD_URL_PATH = "/api/v2/workflows/asset-upload-url";
90
98
  const ASSETS_PATH = "/api/v2/workflows/assets";
99
+ function withoutJudgeFields({ judgeDetail: _d, judgeSeverity: _s, ...rest }) {
100
+ return rest;
101
+ }
91
102
  export function asVideoRun(data) {
92
103
  const run = (data ?? {});
93
104
  return { ...run, nodes: Array.isArray(run.nodes) ? run.nodes : [] };
@@ -194,7 +205,13 @@ export function classifyRun(run) {
194
205
  }
195
206
  const pausedNode = run.nodes.find((n) => n.nodeId === run.pausedNodeId);
196
207
  if (pausedNode && parkedAtStoryboardGate(run, pausedNode)) {
197
- return { at: "storyboard-gate", nodeId: pausedNode.nodeId };
208
+ return {
209
+ at: "storyboard-gate",
210
+ nodeId: pausedNode.nodeId,
211
+ ...(pausedNode.kind === "scene-frames"
212
+ ? { framesNodeId: pausedNode.nodeId }
213
+ : {}),
214
+ };
198
215
  }
199
216
  if (parkedAtFinalWatch(run))
200
217
  return { at: "final-watch" };
@@ -230,12 +247,16 @@ export function stepName(kind) {
230
247
  }
231
248
  export function stopLines(stop, runId, dashboardUrl) {
232
249
  if (stop.at === "storyboard-gate") {
233
- return [
250
+ const lines = [
234
251
  "Parked: the storyboard is waiting for your yes.",
235
252
  `Read it: exodus video storyboard ${runId}`,
236
253
  `Approve it: exodus video approve ${runId}`,
237
254
  `Send back: exodus video flag ${runId} --note "what's wrong"`,
238
255
  ];
256
+ if (stop.framesNodeId) {
257
+ lines.push(`Redo one frame: exodus video retry-frame ${runId} --node ${stop.framesNodeId} --scene <n>`);
258
+ }
259
+ return lines;
239
260
  }
240
261
  if (stop.at === "final-watch") {
241
262
  return [
@@ -271,6 +292,7 @@ export function stopLines(stop, runId, dashboardUrl) {
271
292
  }
272
293
  return [`Working: ${stageWord(stop.stage)}.`];
273
294
  }
295
+ export const CAST_LEDGER_BASE = 910000;
274
296
  const FALLBACK_EXT = {
275
297
  image: "png",
276
298
  video: "mp4",
@@ -290,6 +312,118 @@ export function scenePrefix(sceneIndex) {
290
312
  function outputsOfNodeKind(run, kind) {
291
313
  return run.nodes.filter((n) => n.kind === kind).flatMap((n) => n.outputs ?? []);
292
314
  }
315
+ function clipFromArtifact(sceneIndex, artifact) {
316
+ if (!artifact || artifact.type !== "video" || artifact.final || !artifact.videoUrl) {
317
+ return null;
318
+ }
319
+ return {
320
+ download: {
321
+ file: `${scenePrefix(sceneIndex)}.${extFor(artifact.videoUrl, "video")}`,
322
+ url: artifact.videoUrl,
323
+ },
324
+ durationSec: artifact.durationSec ?? null,
325
+ qc: artifact.qc ?? null,
326
+ words: artifact.words ?? null,
327
+ revoiced: artifact.revoiced === true,
328
+ speechTrimmed: artifact.speechTrimmed === true,
329
+ };
330
+ }
331
+ function keyframeDownload(sceneIndex, imageUrl) {
332
+ return {
333
+ file: `${scenePrefix(sceneIndex)}.keyframe.${extFor(imageUrl, "image")}`,
334
+ url: imageUrl,
335
+ };
336
+ }
337
+ function reservedCastFrames(items) {
338
+ const byIndex = new Map();
339
+ for (const item of items) {
340
+ if (item.itemKind === "frame" && item.sceneIndex >= CAST_LEDGER_BASE) {
341
+ byIndex.set(item.sceneIndex, item);
342
+ }
343
+ }
344
+ return byIndex;
345
+ }
346
+ function planCastRefs(run, items) {
347
+ const ledger = reservedCastFrames(items);
348
+ const ledgerRows = [...ledger.entries()].sort(([a], [b]) => a - b);
349
+ const locked = run.castLock?.cast;
350
+ const rows = [];
351
+ if (locked !== undefined && locked.length > 0) {
352
+ const claimedStorageIds = new Set();
353
+ for (const member of locked) {
354
+ const storageId = member.identityRefs?.[0]?.storageId;
355
+ const item = typeof storageId === "string" && storageId.length > 0
356
+ ? ledgerRows.find(([, row]) => {
357
+ const art = row.artifact;
358
+ return art?.type === "image" && art.storageId === storageId;
359
+ })?.[1]
360
+ : undefined;
361
+ if (typeof storageId === "string" && storageId.length > 0) {
362
+ claimedStorageIds.add(storageId);
363
+ }
364
+ const art = item?.artifact;
365
+ const url = (art?.type === "image" ? art.imageUrl : undefined) ??
366
+ member.identityRefs?.[0]?.imageUrl;
367
+ rows.push({
368
+ characterId: member.characterId,
369
+ name: member.name,
370
+ url,
371
+ status: item?.status ?? (url ? "done" : "missing"),
372
+ error: item?.error ?? null,
373
+ stem: `cast-${member.characterId}`,
374
+ });
375
+ }
376
+ for (const [sceneIndex, item] of ledgerRows) {
377
+ const art = item.artifact;
378
+ const sid = art?.type === "image" ? art.storageId : undefined;
379
+ if (typeof sid === "string" && claimedStorageIds.has(sid))
380
+ continue;
381
+ const url = art?.type === "image" ? art.imageUrl : undefined;
382
+ rows.push({
383
+ characterId: null,
384
+ name: null,
385
+ url,
386
+ status: item.status,
387
+ error: item.error ?? null,
388
+ stem: `cast-ref-${String(sceneIndex - CAST_LEDGER_BASE).padStart(2, "0")}`,
389
+ });
390
+ }
391
+ }
392
+ else {
393
+ for (const [sceneIndex, item] of ledgerRows) {
394
+ const art = item.artifact;
395
+ const url = art?.type === "image" ? art.imageUrl : undefined;
396
+ rows.push({
397
+ characterId: null,
398
+ name: null,
399
+ url,
400
+ status: item.status,
401
+ error: item.error ?? null,
402
+ stem: `cast-ref-${String(sceneIndex - CAST_LEDGER_BASE).padStart(2, "0")}`,
403
+ });
404
+ }
405
+ }
406
+ const cast = [];
407
+ const downloads = [];
408
+ for (const row of rows) {
409
+ const file = row.url ? `${row.stem}.${extFor(row.url, "image")}` : null;
410
+ cast.push({
411
+ characterId: row.characterId,
412
+ name: row.name,
413
+ file,
414
+ status: row.status,
415
+ error: row.error,
416
+ });
417
+ if (file && row.url)
418
+ downloads.push({ file, url: row.url });
419
+ }
420
+ return { cast, downloads };
421
+ }
422
+ function isNarrationMaster(artifact) {
423
+ return (artifact.type === "audio" &&
424
+ artifact.sceneIndex === undefined &&
425
+ Array.isArray(artifact.narration?.cuts));
426
+ }
293
427
  export function planPull(run, items, opts) {
294
428
  const texts = [];
295
429
  const storyboardArtifact = run.nodes
@@ -323,17 +457,24 @@ export function planPull(run, items, opts) {
323
457
  for (const frame of artifact.frames ?? []) {
324
458
  if (!frame.imageUrl)
325
459
  continue;
326
- keyframeByScene.set(frame.sceneIndex, {
327
- file: `${scenePrefix(frame.sceneIndex)}.keyframe.${extFor(frame.imageUrl, "image")}`,
328
- url: frame.imageUrl,
329
- });
460
+ keyframeByScene.set(frame.sceneIndex, keyframeDownload(frame.sceneIndex, frame.imageUrl));
330
461
  }
331
462
  }
332
463
  const voiceByScene = new Map();
464
+ let narrationDownload = null;
465
+ let narrationMaster = null;
333
466
  for (const artifact of outputsOfNodeKind(run, "voiceover")) {
334
- if (artifact.type !== "audio" || typeof artifact.sceneIndex !== "number")
467
+ if (artifact.type !== "audio" || !artifact.audioUrl)
335
468
  continue;
336
- if (!artifact.audioUrl)
469
+ if (isNarrationMaster(artifact)) {
470
+ narrationMaster = artifact;
471
+ narrationDownload = {
472
+ file: `narration.${extFor(artifact.audioUrl, "audio")}`,
473
+ url: artifact.audioUrl,
474
+ };
475
+ continue;
476
+ }
477
+ if (typeof artifact.sceneIndex !== "number" || artifact.sceneIndex < 0)
337
478
  continue;
338
479
  voiceByScene.set(artifact.sceneIndex, {
339
480
  download: {
@@ -343,6 +484,28 @@ export function planPull(run, items, opts) {
343
484
  words: artifact.words ?? null,
344
485
  });
345
486
  }
487
+ if (narrationMaster && narrationDownload) {
488
+ const timing = {
489
+ takeHash: narrationMaster.narration?.takeHash,
490
+ voiceId: narrationMaster.narration?.voiceId,
491
+ modelId: narrationMaster.narration?.modelId,
492
+ speed: narrationMaster.narration?.speed,
493
+ alignment: narrationMaster.narration?.alignment,
494
+ words: narrationMaster.words ?? [],
495
+ cuts: (narrationMaster.narration?.cuts ?? []).map((cut) => ({
496
+ sceneIndex: cut.sceneIndex,
497
+ startSec: cut.startSec,
498
+ durationSec: cut.durationSec,
499
+ takeHash: cut.takeHash,
500
+ file: voiceByScene.get(cut.sceneIndex)?.download.file ??
501
+ `${scenePrefix(cut.sceneIndex)}.voice.mp3`,
502
+ })),
503
+ };
504
+ texts.push({
505
+ file: "narration.json",
506
+ body: `${JSON.stringify(timing, null, 2)}\n`,
507
+ });
508
+ }
346
509
  const clipByScene = new Map();
347
510
  let music = null;
348
511
  for (const artifact of outputsOfNodeKind(run, "video")) {
@@ -352,19 +515,10 @@ export function planPull(run, items, opts) {
352
515
  }
353
516
  if (artifact.type !== "video" || typeof artifact.sceneIndex !== "number")
354
517
  continue;
355
- if (artifact.final || !artifact.videoUrl)
518
+ const clip = clipFromArtifact(artifact.sceneIndex, artifact);
519
+ if (!clip)
356
520
  continue;
357
- clipByScene.set(artifact.sceneIndex, {
358
- download: {
359
- file: `${scenePrefix(artifact.sceneIndex)}.${extFor(artifact.videoUrl, "video")}`,
360
- url: artifact.videoUrl,
361
- },
362
- durationSec: artifact.durationSec ?? null,
363
- qc: artifact.qc ?? null,
364
- words: artifact.words ?? null,
365
- revoiced: artifact.revoiced === true,
366
- speechTrimmed: artifact.speechTrimmed === true,
367
- });
521
+ clipByScene.set(artifact.sceneIndex, clip);
368
522
  }
369
523
  const clipItemByScene = new Map();
370
524
  const frameItemByScene = new Map();
@@ -376,6 +530,22 @@ export function planPull(run, items, opts) {
376
530
  frameItemByScene.set(item.sceneIndex, item);
377
531
  }
378
532
  }
533
+ for (const [sceneIndex, item] of clipItemByScene) {
534
+ if (item.status !== "done" || clipByScene.has(sceneIndex))
535
+ continue;
536
+ const clip = clipFromArtifact(sceneIndex, item.artifact);
537
+ if (!clip)
538
+ continue;
539
+ clipByScene.set(sceneIndex, clip);
540
+ }
541
+ for (const [sceneIndex, item] of frameItemByScene) {
542
+ if (item.status !== "done" || keyframeByScene.has(sceneIndex))
543
+ continue;
544
+ const artifact = item.artifact;
545
+ if (!artifact || artifact.type !== "image" || !artifact.imageUrl)
546
+ continue;
547
+ keyframeByScene.set(sceneIndex, keyframeDownload(sceneIndex, artifact.imageUrl));
548
+ }
379
549
  const sceneIndexes = [
380
550
  ...new Set([
381
551
  ...clipByScene.keys(),
@@ -384,7 +554,9 @@ export function planPull(run, items, opts) {
384
554
  ...clipItemByScene.keys(),
385
555
  ...frameItemByScene.keys(),
386
556
  ]),
387
- ].sort((a, b) => a - b);
557
+ ]
558
+ .filter((sceneIndex) => sceneIndex >= 0 && sceneIndex < CAST_LEDGER_BASE)
559
+ .sort((a, b) => a - b);
388
560
  const scenes = sceneIndexes.map((sceneIndex) => {
389
561
  const clip = clipByScene.get(sceneIndex);
390
562
  const voice = voiceByScene.get(sceneIndex);
@@ -419,16 +591,19 @@ export function planPull(run, items, opts) {
419
591
  clipStatus: item?.status ?? "missing",
420
592
  error: item?.error ?? null,
421
593
  flagged: item?.flagged === true,
422
- findings: item?.findings ?? [],
423
- keyframeFindings: frameItem?.findings ?? [],
594
+ findings: (item?.findings ?? []).map(withoutJudgeFields),
595
+ keyframeFindings: frameItem?.findings?.map(withoutJudgeFields) ?? null,
424
596
  };
425
597
  });
598
+ const { cast, downloads: castDownloads } = planCastRefs(run, items);
426
599
  const downloads = [
427
600
  ...(reference ? [reference] : []),
428
601
  ...keyframeByScene.values(),
429
602
  ...[...voiceByScene.values()].map((v) => v.download),
603
+ ...(narrationDownload ? [narrationDownload] : []),
430
604
  ...[...clipByScene.values()].map((c) => c.download),
431
605
  ...(music ? [music] : []),
606
+ ...castDownloads,
432
607
  ];
433
608
  return {
434
609
  downloads,
@@ -440,6 +615,10 @@ export function planPull(run, items, opts) {
440
615
  storyboard,
441
616
  reference: reference?.file ?? null,
442
617
  music: music?.file ?? null,
618
+ cast,
619
+ narration: narrationDownload
620
+ ? { file: narrationDownload.file, timing: "narration.json" }
621
+ : null,
443
622
  scenes,
444
623
  failed: [],
445
624
  },
@@ -453,6 +632,12 @@ export function markPullFailure(manifest, failure) {
453
632
  manifest.reference = null;
454
633
  if (manifest.music === failure.file)
455
634
  manifest.music = null;
635
+ for (const ref of manifest.cast) {
636
+ if (ref.file === failure.file)
637
+ ref.file = null;
638
+ }
639
+ if (manifest.narration?.file === failure.file)
640
+ manifest.narration = null;
456
641
  for (const scene of manifest.scenes) {
457
642
  const lostWordsSource = (scene.clip === failure.file && scene.wordsFrom === "clip") ||
458
643
  (scene.voice === failure.file && scene.wordsFrom === "voice");
@@ -675,6 +860,8 @@ export async function statusFlow(runId, json, deps) {
675
860
  if (item.itemKind !== "clip" && item.itemKind !== "voiceover" && item.itemKind !== "frame") {
676
861
  continue;
677
862
  }
863
+ if (item.sceneIndex < 0)
864
+ continue;
678
865
  if (item.itemKind === "frame" && !sceneFrameNodeIds.has(item.nodeId))
679
866
  continue;
680
867
  const row = byScene.get(item.sceneIndex) ?? {};
@@ -695,6 +882,13 @@ export async function statusFlow(runId, json, deps) {
695
882
  lines.push(`${String(sceneIndex).padEnd(5)} ${itemWord(row.clip).padEnd(8)} ${itemWord(row.voiceover).padEnd(8)} ${itemWord(row.frame)}`);
696
883
  for (const finding of row.clip?.findings ?? []) {
697
884
  lines.push(` ${finding.code} (${finding.severity}): ${finding.detail}`);
885
+ const judgeSeverity = finding.judgeSeverity ?? finding.severity;
886
+ if (finding.judgeDetail) {
887
+ lines.push(` checker said (${judgeSeverity}): ${finding.judgeDetail}`);
888
+ }
889
+ else if (judgeSeverity !== finding.severity) {
890
+ lines.push(` checker rated it ${judgeSeverity}`);
891
+ }
698
892
  }
699
893
  if (row.clip?.error)
700
894
  lines.push(` ${row.clip.error}`);
@@ -704,6 +898,9 @@ export async function statusFlow(runId, json, deps) {
704
898
  lines.push(` picture: ${finding.code} (${finding.severity}): ${finding.detail}`);
705
899
  }
706
900
  }
901
+ if (outputsOfNodeKind(run, "voiceover").some(isNarrationMaster)) {
902
+ lines.push("Narration master take");
903
+ }
707
904
  }
708
905
  lines.push("", hasFinal && stop.at === "finished"
709
906
  ? "Final cut: approved and delivered."
@@ -747,6 +944,9 @@ export async function storyboardFlow(runId, json, deps) {
747
944
  lines.push(...sceneCardLines(scene));
748
945
  }
749
946
  lines.push("", `approve with: exodus video approve ${runId}`, `send it back: exodus video flag ${runId} --note "what's wrong"`);
947
+ if (cards.framesNodeId) {
948
+ lines.push(`redo one frame: exodus video retry-frame ${runId} --node ${cards.framesNodeId} --scene <n>`);
949
+ }
750
950
  return { code: 0, lines };
751
951
  }
752
952
  function sceneCardLines(scene) {
@@ -803,6 +1003,40 @@ export async function flagFlow(runId, note, json, deps) {
803
1003
  ],
804
1004
  };
805
1005
  }
1006
+ export async function retryFrameFlow(runId, nodeId, sceneIndex, note, json, deps) {
1007
+ const res = await deps.post(SCENE_RETRY_PATH, {
1008
+ runId,
1009
+ nodeId,
1010
+ sceneIndex,
1011
+ ...(note ? { note } : {}),
1012
+ });
1013
+ if (!res.ok)
1014
+ return errorResult(res, json);
1015
+ const triggerRunId = res.data.triggerRunId;
1016
+ if (json) {
1017
+ return {
1018
+ code: 0,
1019
+ lines: [
1020
+ JSON.stringify({
1021
+ ok: true,
1022
+ runId,
1023
+ nodeId,
1024
+ sceneIndex,
1025
+ triggerRunId,
1026
+ ...(note ? { note } : {}),
1027
+ }),
1028
+ ],
1029
+ };
1030
+ }
1031
+ return {
1032
+ code: 0,
1033
+ lines: [
1034
+ `Redoing scene ${sceneIndex} on ${nodeId}.`,
1035
+ `triggerRunId: ${triggerRunId ?? "-"}`,
1036
+ "Neighbours stay. The pixel gate holds.",
1037
+ ],
1038
+ };
1039
+ }
806
1040
  export async function pullFlow(runId, dir, json, deps) {
807
1041
  const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
808
1042
  if (!runRes.ok)
@@ -1107,6 +1341,8 @@ const VALUE_FLAGS = new Set([
1107
1341
  "out",
1108
1342
  "file",
1109
1343
  "duration",
1344
+ "node",
1345
+ "scene",
1110
1346
  ]);
1111
1347
  export function parsePositional(args = process.argv.slice(3)) {
1112
1348
  const out = [];
@@ -1166,7 +1402,7 @@ export async function run(flags) {
1166
1402
  }, defaultDeps));
1167
1403
  }
1168
1404
  const runId = rest[0];
1169
- const needsRunId = ["status", "storyboard", "approve", "flag", "pull", "upload"];
1405
+ const needsRunId = ["status", "storyboard", "approve", "flag", "retry-frame", "pull", "upload"];
1170
1406
  if (needsRunId.includes(sub) && !runId) {
1171
1407
  usage(`video ${sub} needs a run id: exodus video ${sub} <runId>`);
1172
1408
  }
@@ -1182,6 +1418,20 @@ export async function run(flags) {
1182
1418
  usage('video flag needs --note "<what is wrong>".');
1183
1419
  return printResult(await flagFlow(runId, note, json, defaultDeps));
1184
1420
  }
1421
+ if (sub === "retry-frame") {
1422
+ const nodeId = flagString(flags, "node");
1423
+ if (!nodeId)
1424
+ usage("video retry-frame needs --node <nodeId>.");
1425
+ const sceneRaw = flagString(flags, "scene");
1426
+ if (sceneRaw === undefined) {
1427
+ usage("video retry-frame needs --scene <n>, the scene index to redo.");
1428
+ }
1429
+ const sceneIndex = Number(sceneRaw);
1430
+ if (!Number.isFinite(sceneIndex)) {
1431
+ usage("video retry-frame --scene must be a number.");
1432
+ }
1433
+ return printResult(await retryFrameFlow(runId, nodeId, sceneIndex, flagString(flags, "note"), json, defaultDeps));
1434
+ }
1185
1435
  if (sub === "pull") {
1186
1436
  const dir = flagString(flags, "out");
1187
1437
  if (!dir)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicopycoders/exodus",
3
- "version": "2026.9.1400-beta.1",
3
+ "version": "2026.9.1700-beta.0",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",