@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.
- package/assets/docs/CLAUDE.md +1 -1
- package/assets/skills/exodus-video/SKILL.md +12 -2
- package/assets/skills/exodus-workflow/SKILL.md +2 -0
- package/assets/skills/find-competitors/SKILL.md +1 -1
- package/dist/commands/video.d.ts +48 -1
- package/dist/commands/video.js +275 -25
- package/package.json +1 -1
package/assets/docs/CLAUDE.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
package/dist/commands/video.d.ts
CHANGED
|
@@ -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;
|
package/dist/commands/video.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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" ||
|
|
467
|
+
if (artifact.type !== "audio" || !artifact.audioUrl)
|
|
335
468
|
continue;
|
|
336
|
-
if (
|
|
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
|
-
|
|
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
|
-
]
|
|
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)
|