@aicopycoders/exodus 2026.9.1800-beta.0 → 2026.9.1900-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/PIPELINES.md +3 -0
- package/assets/skills/exodus-video/SKILL.md +115 -11
- package/dist/commands/video.d.ts +24 -1
- package/dist/commands/video.js +256 -17
- package/dist/commands/workflow.d.ts +3 -0
- package/dist/commands/workflow.js +116 -4
- package/package.json +1 -1
package/assets/docs/PIPELINES.md
CHANGED
|
@@ -256,10 +256,13 @@ Start (Show route):
|
|
|
256
256
|
exodus video start --show <id> --script <file> [--wait] [--json] start an ad run; --wait stops at the storyboard gate
|
|
257
257
|
Then, on either route:
|
|
258
258
|
exodus video storyboard <runId> [--json] the scene cards
|
|
259
|
+
exodus video voices <runId> [--set <who>=<voiceId>] [--clear <who>] [--from <f>] [--json] who speaks, with whose voice
|
|
259
260
|
exodus video approve <runId> [--json] approve the storyboard, or the uploaded cut
|
|
260
261
|
exodus video flag <runId> --note "<what is wrong>" [--json] send the storyboard back — SHOW RUNS ONLY
|
|
262
|
+
exodus video retry-frame <runId> --node <nodeId> --scene <n> [--note "<t>"] [--json] redo one still at the pixel gate
|
|
261
263
|
exodus video status <runId> [--json] the park and each scene's clip/voice/picture
|
|
262
264
|
exodus video pull <runId> --out <dir> [--json] every piece + manifest.json
|
|
265
|
+
exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "<t>"] [--json] redo one clip at the final watch
|
|
263
266
|
exodus video upload <runId> --file <cut.mp4> [--duration <s>] [--json] attach the cut (MP4/MOV/WebM, 200 MB)
|
|
264
267
|
Cut: your choice of tool — Exodus does not stitch or mix. One option ships with the skill:
|
|
265
268
|
node .claude/skills/exodus-video/scripts/first-cut.mjs <dir> [--skip n,m] [--no-music] [--out f]
|
|
@@ -75,6 +75,8 @@ exodus workflow list the brand's workflows
|
|
|
75
75
|
exodus workflow describe <workflowId|name> what inputs it needs
|
|
76
76
|
exodus workflow run <workflowId|name> --input <field>=@script.txt --wait
|
|
77
77
|
start, stop at the storyboard gate
|
|
78
|
+
exodus workflow run <workflowId|name> --input <field>=@script.txt --voices @voices.json --wait
|
|
79
|
+
same, with the brand's voices already chosen
|
|
78
80
|
exodus video status <runId> where it is, per scene
|
|
79
81
|
```
|
|
80
82
|
|
|
@@ -96,6 +98,8 @@ exodus video flag <runId> --note "what's wrong" send it back — SHOW
|
|
|
96
98
|
exodus video retry-frame <runId> --node <nodeId> --scene <n> redo one still at the pixel gate
|
|
97
99
|
exodus video status <runId> where it is, per scene
|
|
98
100
|
exodus video pull <runId> --out ./ad-<runId> every piece + manifest.json
|
|
101
|
+
exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "…"]
|
|
102
|
+
redo one clip at the final watch
|
|
99
103
|
… make the cut (your choice of tool)
|
|
100
104
|
exodus video upload <runId> --file ./ad-<runId>/cut.mp4 attach it
|
|
101
105
|
exodus video approve <runId> make it the ad
|
|
@@ -115,7 +119,8 @@ per-scene detail, and the checkpoint verbs cannot resolve a video park — see
|
|
|
115
119
|
under it:
|
|
116
120
|
|
|
117
121
|
- storyboard gate: needs `approve`, or `flag` on a Show run.
|
|
118
|
-
- final watch: every piece is made; needs `pull`, a cut, and `upload`.
|
|
122
|
+
- final watch: every piece is made; needs `pull`, a cut, and `upload`. It is also
|
|
123
|
+
the one park where a finished clip can be redone, with `retry-clip`. A
|
|
119
124
|
workflow run only reaches this park if its video node sets `finalWatch: true`
|
|
120
125
|
— otherwise it skips straight to finished.
|
|
121
126
|
- paused: a builder checkpoint neither video verb resolves —
|
|
@@ -170,6 +175,7 @@ Verified against the routes, not assumed. Everything here is admin-gated on top.
|
|
|
170
175
|
| `video flag --note` | **refused** | works |
|
|
171
176
|
| script-turn edits at the gate | **refused** | dashboard only |
|
|
172
177
|
| `video retry-frame` | works | works |
|
|
178
|
+
| `video retry-clip` | works | works |
|
|
173
179
|
| `video pull` | works | works |
|
|
174
180
|
| `video upload` | only if the video node sets `finalWatch: true` | works |
|
|
175
181
|
| `workflow checkpoint approve` | **refused at a video gate** | **refused at a video gate** |
|
|
@@ -218,11 +224,14 @@ Five consequences worth knowing before you promise a user anything:
|
|
|
218
224
|
slots park is still not a landing: it prints its notice once and the loop
|
|
219
225
|
keeps polling, so a resolve from another shell carries the run on. Do not read
|
|
220
226
|
a park banner mid-poll as the wait having finished.
|
|
221
|
-
- **The run's page link differs
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
227
|
+
- **The run's page link differs, and every link the CLI prints follows the run.**
|
|
228
|
+
A Show ad opens on the `/video` page. A showless workflow run opens on
|
|
229
|
+
`/workflows/<workflowId>/runs/<runId>`, because `/video` answers "That ad isn't
|
|
230
|
+
here" for a run with no Show (`getAdDetail` rejects it;
|
|
231
|
+
`convex/http.ts:9204-9211`). Since #1851 the manifest's `dashboardUrl`, the
|
|
232
|
+
link in every park block and the link `upload` prints are all minted that way,
|
|
233
|
+
so they agree on both routes. When all the CLI holds is a run id it mints
|
|
234
|
+
`/runs/<runId>`, the canonical forwarder, which lands on the same page.
|
|
226
235
|
|
|
227
236
|
## The script file
|
|
228
237
|
|
|
@@ -248,8 +257,10 @@ THE SOFTGEL: Tap below and give your eyes ClearBlink.
|
|
|
248
257
|
## Reading the storyboard gate
|
|
249
258
|
|
|
250
259
|
`storyboard <runId>` prints one card per scene on both routes: who speaks, the
|
|
251
|
-
line, the planned duration, and whether its picture is drawn.
|
|
252
|
-
|
|
260
|
+
line, the planned duration, and whether its picture is drawn. Under the cards it
|
|
261
|
+
also lists the voices — one line per character who speaks or already has a voice
|
|
262
|
+
— so the user can see who sounds like whom before paying for the clips. Read it
|
|
263
|
+
for the user, then ask one question.
|
|
253
264
|
|
|
254
265
|
On a **workflow run** the question is approve or don't — there is no flag. If
|
|
255
266
|
the storyboard is wrong, say so plainly and let the user decide between
|
|
@@ -302,6 +313,17 @@ on that account. Each refusal says the plain reason and saves nothing. If
|
|
|
302
313
|
ElevenLabs simply cannot be reached, the choice IS saved and the output says the
|
|
303
314
|
check could not be made — a provider outage never blocks the run.
|
|
304
315
|
|
|
316
|
+
**Approving checks the voices one last time.** `video approve` re-checks every
|
|
317
|
+
chosen voice before the clips are paid for. If ElevenLabs is certain one of them
|
|
318
|
+
is gone — deleted from the account, or the account changed — the approve is
|
|
319
|
+
refused in one plain sentence, nothing is approved and the command exits 1. Fix
|
|
320
|
+
it by choosing another voice (`video voices <runId> --set <character>=<voiceId>`)
|
|
321
|
+
or clearing that character (`--clear <character>`, which lets the clip keep the
|
|
322
|
+
voice the video model makes), then approve again. When the approve goes through,
|
|
323
|
+
the receipt lists each speaking character and the voice they got, plus any
|
|
324
|
+
`Heads-up:` lines worth reading — that is the last moment a wrong voice is cheap
|
|
325
|
+
to fix.
|
|
326
|
+
|
|
305
327
|
**The voices file.** A plain JSON object, keyed by the names the script uses:
|
|
306
328
|
|
|
307
329
|
```json
|
|
@@ -319,6 +341,18 @@ flag says `C2`, that is one character named two ways, and the whole request is
|
|
|
319
341
|
refused with a sentence saying so. Voice IDs belong to the user's own ElevenLabs account and to that brand's
|
|
320
342
|
folder — never put a brand's voice IDs in a shared workflow or template.
|
|
321
343
|
|
|
344
|
+
**One file, two moments.** A brand keeps a single `voices.json` in its own
|
|
345
|
+
folder. Hand it to the run at the start with `exodus workflow run <workflow>
|
|
346
|
+
--voices @voices.json` when the script is one the user supplied (it names its
|
|
347
|
+
speakers, so `HOST 1` means something before the planner has run), or hand it in
|
|
348
|
+
at the storyboard review with `exodus video voices <runId> --from voices.json`.
|
|
349
|
+
Launching with voices refuses before the run is created — nothing is spent — if
|
|
350
|
+
a speaker is not in the script, the same speaker is named twice, or ElevenLabs
|
|
351
|
+
is certain it does not have one of the voices; a workflow whose script is
|
|
352
|
+
written by a bot is refused with a pointer at the review-time command, because
|
|
353
|
+
nobody knows the speaker names yet. When the storyboard arrives, the voices are
|
|
354
|
+
already set and `exodus video voices <runId>` shows them.
|
|
355
|
+
|
|
322
356
|
**The JSON receipt** (`--json`), the same shape for reading and for setting:
|
|
323
357
|
|
|
324
358
|
```
|
|
@@ -378,18 +412,28 @@ manifest.json the index below
|
|
|
378
412
|
`manifest.json`:
|
|
379
413
|
|
|
380
414
|
```
|
|
381
|
-
runId, pulledAt, dashboardUrl the run and
|
|
415
|
+
runId, pulledAt, dashboardUrl the run and the page that opens it:
|
|
416
|
+
/video?ad=<runId> for a Show ad,
|
|
417
|
+
/workflows/<workflowId>/runs/<runId> for a
|
|
418
|
+
workflow run
|
|
382
419
|
storyboard, reference, music filenames, or null when not delivered
|
|
383
420
|
narration { file, timing }, or null on a per-scene run
|
|
384
421
|
cast[] one per identity still
|
|
385
422
|
characterId, name from the run's cast lock; null for an
|
|
386
423
|
unclaimed ledger still
|
|
387
424
|
file, status, error
|
|
425
|
+
voiceId, voiceLabel the ElevenLabs voice this character was
|
|
426
|
+
given before the clips were made; null when
|
|
427
|
+
nobody chose one
|
|
388
428
|
scenes[] one per scene, in order
|
|
389
429
|
sceneIndex, durationSec the run's number and the clip's real length
|
|
390
430
|
clip, voice, keyframe, words filenames, or null
|
|
391
431
|
wordsFrom clip | voice — which file the word times are
|
|
392
432
|
measured against; null when none came
|
|
433
|
+
wordsDescribe this-file | original-performance — whether
|
|
434
|
+
those times were measured on the file you
|
|
435
|
+
have, or on the take a cast voice replaced;
|
|
436
|
+
null when no words came
|
|
393
437
|
clipStatus done | failed | running | pending | missing
|
|
394
438
|
error why the clip failed, when it did
|
|
395
439
|
flagged, findings[] QC verdict: {check, code, severity: fail|warn, detail}
|
|
@@ -466,6 +510,18 @@ re-based to the start of that scene's cut; on a per-scene run it is instead a
|
|
|
466
510
|
blind Scribe pass over that scene's own voice track. Nothing is aligned to the
|
|
467
511
|
finished ad — that timeline does not exist until you make it.
|
|
468
512
|
|
|
513
|
+
**How far to trust them** (`wordsDescribe`). `"this-file"` means the times were
|
|
514
|
+
measured on the very file `wordsFrom` names, so they hold to the frame: cut
|
|
515
|
+
captions and cue points straight off them. `"original-performance"` means the
|
|
516
|
+
scene's clip was re-voiced *after* its words were timed — the times describe the
|
|
517
|
+
take the cast voice replaced. The words and their order are right and the timing
|
|
518
|
+
is close, but nothing re-measures it, so check any cut that has to land on a
|
|
519
|
+
single frame. A clip carrying a `voice-timing-shifted` warning is the same thing
|
|
520
|
+
with a measured gap: the swapped audio came back a different length, so treat
|
|
521
|
+
that scene's times as a rough guide and watch it before you trust it. Nothing in
|
|
522
|
+
the pipeline speeds audio or video up or slows it down to make the two agree —
|
|
523
|
+
the timings are reported honestly instead.
|
|
524
|
+
|
|
469
525
|
**Cast identity stills** (`cast-*.<ext>`). A full-body identity still per cast
|
|
470
526
|
member, on a plain neutral background in the run's style, minted once and
|
|
471
527
|
frozen onto the run as its cast lock. Every scene picture was anchored to these,
|
|
@@ -487,14 +543,19 @@ music failure degrades to a warning rather than blocking the run.
|
|
|
487
543
|
- `wordsFrom: "voice"`, or a scene whose storyboard entry has `voText` and no
|
|
488
544
|
on-camera line: the clip is picture only and the voice track is the sound.
|
|
489
545
|
Do not use that clip's own audio.
|
|
546
|
+
- `wordsDescribe: "original-performance"`: the word times were measured before
|
|
547
|
+
that clip's cast voice was swapped in. Close, not frame-exact — say so if the
|
|
548
|
+
user is cutting captions to the frame.
|
|
490
549
|
- `manifest.narration` present: listen to `narration.mp3` end to end, then the
|
|
491
550
|
joins between adjacent `scene-NN.voice` files, before you trust the cut.
|
|
492
551
|
Matching voice IDs and a one-speaker diarization result do not prove
|
|
493
552
|
continuity (#1802).
|
|
494
553
|
- `flagged: true` is a warning, not a block. The clip was delivered anyway; the
|
|
495
554
|
findings say what the QC model saw (`wrong-character`, `eyeline-off`,
|
|
496
|
-
`set-drift`, `speech-cutoff`, `framing-off`). Tell the
|
|
497
|
-
flagged and why, and let them decide whether to keep,
|
|
555
|
+
`set-drift`, `speech-cutoff`, `framing-off`, `voice-timing-shifted`). Tell the
|
|
556
|
+
user which scenes are flagged and why, and let them decide whether to keep,
|
|
557
|
+
trim, drop or redo each one (`retry-clip`, see "Redoing one clip at the final
|
|
558
|
+
watch").
|
|
498
559
|
A `speech-cutoff` on the CTA scene is the one to worry about; the ad's last
|
|
499
560
|
words are missing. `status` shows the checker's own wording behind each
|
|
500
561
|
finding; the manifest carries only the verdict.
|
|
@@ -506,6 +567,49 @@ music failure degrades to a warning rather than blocking the run.
|
|
|
506
567
|
the run parks at the final watch, which means nothing is still rendering.
|
|
507
568
|
- `failed[]` non-empty: run the same `pull` again.
|
|
508
569
|
|
|
570
|
+
## Redoing one clip at the final watch
|
|
571
|
+
|
|
572
|
+
`retry-clip` re-renders ONE scene's clip on the run that already made it. There
|
|
573
|
+
is no second run, no new storyboard and no re-render of anything else.
|
|
574
|
+
|
|
575
|
+
```
|
|
576
|
+
exodus video retry-clip <runId> --scene 3
|
|
577
|
+
exodus video retry-clip <runId> --scene 3 --note "keep both hands in frame on the handshake"
|
|
578
|
+
exodus video retry-clip <runId> --scene 3 --node video-2
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
`--node` is only needed when a run has clips for that scene on two video steps;
|
|
582
|
+
the CLI asks for it by name when it does.
|
|
583
|
+
|
|
584
|
+
**When it is allowed.** A failed clip, once the step that made it has finished. A
|
|
585
|
+
finished clip, only while the run is parked at the final watch, or when QC
|
|
586
|
+
flagged it (`flagged: true` in `status` and in the manifest). Everything else is
|
|
587
|
+
refused in one plain sentence before any task is queued: a step still rendering
|
|
588
|
+
its other scenes or one that failed outright, a scene already being redone, a
|
|
589
|
+
finished clip on a run that is not at its final watch. An ad that is already
|
|
590
|
+
delivered is refused by the server, in its own sentence.
|
|
591
|
+
|
|
592
|
+
**What stays untouched.** Every other scene's clip, every keyframe, the cast
|
|
593
|
+
pins, the voices and the approved script. The redone clip re-renders from the
|
|
594
|
+
same keyframe with the same voice path the first take used, so it comes back
|
|
595
|
+
revoiced and trimmed the same way, with fresh findings and fresh word timings.
|
|
596
|
+
|
|
597
|
+
**A `--note` steers the motion, never the words.** It is folded into that
|
|
598
|
+
scene's motion prompt only (`withRedoDirection`,
|
|
599
|
+
`scout/src/trigger/workflow-scene-retry.ts`); the spoken line still comes from
|
|
600
|
+
the approved storyboard. Asking for different words in a note will not change
|
|
601
|
+
them. If the words are wrong, that is a storyboard flag or a new run.
|
|
602
|
+
|
|
603
|
+
**A redo whose take fails the checks keeps the original clip.** On a finished
|
|
604
|
+
clip the row stays `done` with the ORIGINAL take and only its findings are
|
|
605
|
+
refreshed, because a worse take must never replace a delivered one. So never
|
|
606
|
+
report that the clip changed. Read `status` again for the new findings, then pull
|
|
607
|
+
and compare the file before and after.
|
|
608
|
+
|
|
609
|
+
**Re-cut afterwards.** A cut you already uploaded still holds the old clip, and
|
|
610
|
+
the CLI says so when that is the case. Pull again, re-cut and upload again before
|
|
611
|
+
anyone approves.
|
|
612
|
+
|
|
509
613
|
## Cutting the ad
|
|
510
614
|
|
|
511
615
|
Editing is the part every user does differently, and the team is deliberately
|
package/dist/commands/video.d.ts
CHANGED
|
@@ -70,6 +70,7 @@ export interface VideoRunNode {
|
|
|
70
70
|
kind: string;
|
|
71
71
|
status: "idle" | "running" | "done" | "failed" | "skipped";
|
|
72
72
|
error?: string;
|
|
73
|
+
warning?: string;
|
|
73
74
|
outputs?: ArtifactSubset[];
|
|
74
75
|
}
|
|
75
76
|
export type BuilderPauseReason = "taste" | "repair" | "slots" | "call" | "checkpoint";
|
|
@@ -96,6 +97,8 @@ export interface VideoRun {
|
|
|
96
97
|
pausedNodeId?: string;
|
|
97
98
|
nodes: VideoRunNode[];
|
|
98
99
|
castLock?: PullCastLock | null;
|
|
100
|
+
workflowId?: string;
|
|
101
|
+
moduleOwned?: boolean;
|
|
99
102
|
}
|
|
100
103
|
export declare function asVideoRun(data: unknown): VideoRun;
|
|
101
104
|
export interface NodeItem {
|
|
@@ -177,7 +180,8 @@ export type RunStop = {
|
|
|
177
180
|
export declare function classifyRun(run: VideoRun): RunStop;
|
|
178
181
|
export declare function stageWord(stage: string): string;
|
|
179
182
|
export declare function stepName(kind: string | undefined): string;
|
|
180
|
-
export declare function
|
|
183
|
+
export declare function reviewUrl(dashboardUrl: string, run: Pick<VideoRun, "_id" | "workflowId" | "moduleOwned">): string;
|
|
184
|
+
export declare function stopLines(stop: RunStop, runId: string, runUrl: string): string[];
|
|
181
185
|
export interface PullDownload {
|
|
182
186
|
file: string;
|
|
183
187
|
url: string;
|
|
@@ -192,6 +196,7 @@ export interface ManifestScene {
|
|
|
192
196
|
clip: string | null;
|
|
193
197
|
words: string | null;
|
|
194
198
|
wordsFrom: "clip" | "voice" | null;
|
|
199
|
+
wordsDescribe: "original-performance" | "this-file" | null;
|
|
195
200
|
voice: string | null;
|
|
196
201
|
keyframe: string | null;
|
|
197
202
|
qc: ClipQc | null;
|
|
@@ -214,6 +219,8 @@ export interface ManifestCastRef {
|
|
|
214
219
|
file: string | null;
|
|
215
220
|
status: string;
|
|
216
221
|
error: string | null;
|
|
222
|
+
voiceId: string | null;
|
|
223
|
+
voiceLabel: string | null;
|
|
217
224
|
}
|
|
218
225
|
export interface VideoManifest {
|
|
219
226
|
runId: string;
|
|
@@ -265,6 +272,22 @@ export declare function storyboardFlow(runId: string, json: boolean, deps: Video
|
|
|
265
272
|
export declare function approveFlow(runId: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
|
|
266
273
|
export declare function flagFlow(runId: string, note: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
|
|
267
274
|
export declare function retryFrameFlow(runId: string, nodeId: string, sceneIndex: number, note: string | undefined, json: boolean, deps: VideoDeps): Promise<FlowResult>;
|
|
275
|
+
export type ClipRedoPlan = {
|
|
276
|
+
ok: true;
|
|
277
|
+
nodeId: string;
|
|
278
|
+
sceneIndex: number;
|
|
279
|
+
attempt: number;
|
|
280
|
+
warnings: string[];
|
|
281
|
+
} | {
|
|
282
|
+
ok: false;
|
|
283
|
+
reason: string;
|
|
284
|
+
};
|
|
285
|
+
export interface ClipRedoTarget {
|
|
286
|
+
sceneIndex: number;
|
|
287
|
+
nodeId?: string;
|
|
288
|
+
}
|
|
289
|
+
export declare function planClipRedo(run: VideoRun, items: NodeItem[], target: ClipRedoTarget): ClipRedoPlan;
|
|
290
|
+
export declare function retryClipFlow(runId: string, target: ClipRedoTarget, note: string | undefined, json: boolean, deps: VideoDeps): Promise<FlowResult>;
|
|
268
291
|
export interface CastVoiceRow {
|
|
269
292
|
characterId: string;
|
|
270
293
|
name: string;
|
package/dist/commands/video.js
CHANGED
|
@@ -40,6 +40,10 @@ The whole loop, in order:
|
|
|
40
40
|
7. exodus video pull <runId> --out ./ad
|
|
41
41
|
Writes every piece to that folder plus a manifest.json index.
|
|
42
42
|
|
|
43
|
+
exodus video retry-clip <runId> --scene <n>
|
|
44
|
+
Redo ONE finished clip while the run waits for the cut. Everything
|
|
45
|
+
else stays. A --note steers the motion, never the words.
|
|
46
|
+
|
|
43
47
|
8. Make your cut from those files.
|
|
44
48
|
|
|
45
49
|
9. exodus video upload <runId> --file cut.mp4
|
|
@@ -56,6 +60,7 @@ Usage:
|
|
|
56
60
|
exodus video approve <runId> [--json]
|
|
57
61
|
exodus video flag <runId> --note "<what is wrong>" [--json]
|
|
58
62
|
exodus video retry-frame <runId> --node <nodeId> --scene <n> [--note "..."] [--json]
|
|
63
|
+
exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "..."] [--json]
|
|
59
64
|
exodus video voices <runId> [--set <character>=<voiceId>] [--clear <character>] [--from <file.json>] [--json]
|
|
60
65
|
exodus video pull <runId> --out <dir> [--json]
|
|
61
66
|
exodus video upload <runId> --file <cut.mp4> [--duration <sec>] [--json]
|
|
@@ -72,9 +77,12 @@ Options:
|
|
|
72
77
|
--duration <sec> How long your cut is, in seconds. Only needed when the
|
|
73
78
|
length can't be read off the file itself
|
|
74
79
|
--note "<text>" What is wrong with the storyboard (flag), or how to
|
|
75
|
-
steer one
|
|
76
|
-
--node <nodeId> Which scene-frames node holds the still (retry-frame)
|
|
77
|
-
|
|
80
|
+
steer one redo (retry-frame, retry-clip)
|
|
81
|
+
--node <nodeId> Which scene-frames node holds the still (retry-frame).
|
|
82
|
+
Which video step holds the clip, when a scene has one on
|
|
83
|
+
more than one step (retry-clip)
|
|
84
|
+
--scene <n> Which scene to redo. One scene only, a whole number
|
|
85
|
+
(retry-frame, retry-clip)
|
|
78
86
|
--set <who>=<id> Give a character an ElevenLabs voice (voices). Name the
|
|
79
87
|
character by its ID or by the name your script uses.
|
|
80
88
|
Repeat it once per character
|
|
@@ -93,6 +101,7 @@ Examples:
|
|
|
93
101
|
exodus video storyboard run_123
|
|
94
102
|
exodus video approve run_123
|
|
95
103
|
exodus video retry-frame run_123 --node frames-1 --scene 2
|
|
104
|
+
exodus video retry-clip run_123 --scene 3 --note "keep the handshake in frame"
|
|
96
105
|
exodus video voices run_123
|
|
97
106
|
exodus video voices run_123 --set C1=abc123voiceid --set "HOST 2=def456voiceid"
|
|
98
107
|
exodus video voices run_123 --from voices.json
|
|
@@ -260,7 +269,14 @@ export function stepName(kind) {
|
|
|
260
269
|
return "A step in this run";
|
|
261
270
|
return STEP_NAMES[kind] ?? `The ${kind} step`;
|
|
262
271
|
}
|
|
263
|
-
export function
|
|
272
|
+
export function reviewUrl(dashboardUrl, run) {
|
|
273
|
+
if (run.moduleOwned === true)
|
|
274
|
+
return `${dashboardUrl}/video?ad=${run._id}`;
|
|
275
|
+
if (run.workflowId)
|
|
276
|
+
return `${dashboardUrl}/workflows/${run.workflowId}/runs/${run._id}`;
|
|
277
|
+
return `${dashboardUrl}/runs/${run._id}`;
|
|
278
|
+
}
|
|
279
|
+
export function stopLines(stop, runId, runUrl) {
|
|
264
280
|
if (stop.at === "storyboard-gate") {
|
|
265
281
|
const lines = [
|
|
266
282
|
"Parked: the storyboard is waiting for your yes.",
|
|
@@ -278,6 +294,8 @@ export function stopLines(stop, runId, dashboardUrl) {
|
|
|
278
294
|
"Parked: every piece is made and the run is waiting for a cut.",
|
|
279
295
|
`Pull the pieces: exodus video pull ${runId} --out ./ad-${runId}`,
|
|
280
296
|
`Then upload: exodus video upload ${runId} --file cut.mp4`,
|
|
297
|
+
`Redo one clip: exodus video retry-clip ${runId} --scene <n>`,
|
|
298
|
+
`Watch it here: ${runUrl}`,
|
|
281
299
|
];
|
|
282
300
|
}
|
|
283
301
|
if (stop.at === "failed") {
|
|
@@ -285,7 +303,7 @@ export function stopLines(stop, runId, dashboardUrl) {
|
|
|
285
303
|
return [
|
|
286
304
|
`${stepName(stop.step)} failed${stop.error ? `: ${stop.error}` : "."}`,
|
|
287
305
|
`Try that step again: exodus workflow repair ${runId} retry`,
|
|
288
|
-
`Or open the run: ${
|
|
306
|
+
`Or open the run: ${runUrl}`,
|
|
289
307
|
];
|
|
290
308
|
}
|
|
291
309
|
return [
|
|
@@ -302,7 +320,7 @@ export function stopLines(stop, runId, dashboardUrl) {
|
|
|
302
320
|
if (stop.at === "paused") {
|
|
303
321
|
return [
|
|
304
322
|
`Parked: this run is waiting on someone${stop.reason ? ` (${stop.reason})` : ""}.`,
|
|
305
|
-
`Open it: ${
|
|
323
|
+
`Open it: ${runUrl}`,
|
|
306
324
|
];
|
|
307
325
|
}
|
|
308
326
|
return [`Working: ${stageWord(stop.stage)}.`];
|
|
@@ -358,7 +376,31 @@ function reservedCastFrames(items) {
|
|
|
358
376
|
}
|
|
359
377
|
return byIndex;
|
|
360
378
|
}
|
|
361
|
-
function
|
|
379
|
+
function castVoicePins(artifact) {
|
|
380
|
+
const pins = new Map();
|
|
381
|
+
let envelope = artifact?.storyboard;
|
|
382
|
+
if (envelope === undefined && typeof artifact?.storyboardJson === "string") {
|
|
383
|
+
try {
|
|
384
|
+
envelope = JSON.parse(artifact.storyboardJson);
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
return pins;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
const cast = envelope?.cast;
|
|
391
|
+
if (!Array.isArray(cast))
|
|
392
|
+
return pins;
|
|
393
|
+
for (const member of cast) {
|
|
394
|
+
const characterId = typeof member.characterId === "string" ? member.characterId.trim() : "";
|
|
395
|
+
const voiceId = typeof member.voiceId === "string" ? member.voiceId.trim() : "";
|
|
396
|
+
if (!characterId || !voiceId)
|
|
397
|
+
continue;
|
|
398
|
+
const label = typeof member.voiceLabel === "string" ? member.voiceLabel.trim() : "";
|
|
399
|
+
pins.set(characterId, { voiceId, voiceLabel: label ? label : null });
|
|
400
|
+
}
|
|
401
|
+
return pins;
|
|
402
|
+
}
|
|
403
|
+
function planCastRefs(run, items, pins) {
|
|
362
404
|
const ledger = reservedCastFrames(items);
|
|
363
405
|
const ledgerRows = [...ledger.entries()].sort(([a], [b]) => a - b);
|
|
364
406
|
const locked = run.castLock?.cast;
|
|
@@ -422,12 +464,15 @@ function planCastRefs(run, items) {
|
|
|
422
464
|
const downloads = [];
|
|
423
465
|
for (const row of rows) {
|
|
424
466
|
const file = row.url ? `${row.stem}.${extFor(row.url, "image")}` : null;
|
|
467
|
+
const pin = row.characterId ? pins.get(row.characterId) : undefined;
|
|
425
468
|
cast.push({
|
|
426
469
|
characterId: row.characterId,
|
|
427
470
|
name: row.name,
|
|
428
471
|
file,
|
|
429
472
|
status: row.status,
|
|
430
473
|
error: row.error,
|
|
474
|
+
voiceId: pin?.voiceId ?? null,
|
|
475
|
+
voiceLabel: pin?.voiceLabel ?? null,
|
|
431
476
|
});
|
|
432
477
|
if (file && row.url)
|
|
433
478
|
downloads.push({ file, url: row.url });
|
|
@@ -598,6 +643,11 @@ export function planPull(run, items, opts) {
|
|
|
598
643
|
clip: clip?.download.file ?? null,
|
|
599
644
|
words,
|
|
600
645
|
wordsFrom,
|
|
646
|
+
wordsDescribe: wordsFrom === null
|
|
647
|
+
? null
|
|
648
|
+
: clip?.revoiced === true && wordsFrom === "clip"
|
|
649
|
+
? "original-performance"
|
|
650
|
+
: "this-file",
|
|
601
651
|
voice: voice?.download.file ?? null,
|
|
602
652
|
keyframe: keyframeByScene.get(sceneIndex)?.file ?? null,
|
|
603
653
|
qc: clip?.qc ?? null,
|
|
@@ -610,7 +660,7 @@ export function planPull(run, items, opts) {
|
|
|
610
660
|
keyframeFindings: frameItem?.findings?.map(withoutJudgeFields) ?? null,
|
|
611
661
|
};
|
|
612
662
|
});
|
|
613
|
-
const { cast, downloads: castDownloads } = planCastRefs(run, items);
|
|
663
|
+
const { cast, downloads: castDownloads } = planCastRefs(run, items, castVoicePins(storyboardArtifact));
|
|
614
664
|
const downloads = [
|
|
615
665
|
...(reference ? [reference] : []),
|
|
616
666
|
...keyframeByScene.values(),
|
|
@@ -626,7 +676,7 @@ export function planPull(run, items, opts) {
|
|
|
626
676
|
manifest: {
|
|
627
677
|
runId: run._id,
|
|
628
678
|
pulledAt: opts.pulledAt,
|
|
629
|
-
dashboardUrl:
|
|
679
|
+
dashboardUrl: reviewUrl(opts.dashboardUrl, run),
|
|
630
680
|
storyboard,
|
|
631
681
|
reference: reference?.file ?? null,
|
|
632
682
|
music: music?.file ?? null,
|
|
@@ -668,6 +718,7 @@ export function markPullFailure(manifest, failure) {
|
|
|
668
718
|
if (scene.words === failure.file || lostWordsSource) {
|
|
669
719
|
scene.words = null;
|
|
670
720
|
scene.wordsFrom = null;
|
|
721
|
+
scene.wordsDescribe = null;
|
|
671
722
|
}
|
|
672
723
|
}
|
|
673
724
|
}
|
|
@@ -773,7 +824,7 @@ export async function startFlow(opts, deps) {
|
|
|
773
824
|
return { code: 1, lines: ["The server started the ad but did not say which run it is."] };
|
|
774
825
|
}
|
|
775
826
|
const runId = started.runId;
|
|
776
|
-
const url = started.url ??
|
|
827
|
+
const url = started.url ?? reviewUrl(deps.dashboardUrl, { _id: runId });
|
|
777
828
|
if (!opts.wait) {
|
|
778
829
|
const lines = [
|
|
779
830
|
`Started ad run ${runId}`,
|
|
@@ -826,7 +877,7 @@ export async function waitFlow(runId, opts, deps) {
|
|
|
826
877
|
}
|
|
827
878
|
return {
|
|
828
879
|
code: stop.at === "failed" ? 1 : 0,
|
|
829
|
-
lines: [...lines, ...stopLines(stop, runId, deps.dashboardUrl)],
|
|
880
|
+
lines: [...lines, ...stopLines(stop, runId, reviewUrl(deps.dashboardUrl, run))],
|
|
830
881
|
};
|
|
831
882
|
}
|
|
832
883
|
const timeoutLine = `Still running after ${Math.round((maxPolls * interval) / 60000)} minutes. Check in with: exodus video status ${runId}`;
|
|
@@ -860,13 +911,22 @@ export async function statusFlow(runId, json, deps) {
|
|
|
860
911
|
const items = itemsRes.data.items ?? [];
|
|
861
912
|
const stop = classifyRun(run);
|
|
862
913
|
const hasFinal = items.some((i) => i.itemKind === "final" && i.status === "done");
|
|
914
|
+
const warnings = run.nodes
|
|
915
|
+
.filter((n) => n.warning)
|
|
916
|
+
.map((n) => ({ nodeId: n.nodeId, step: n.kind, warning: n.warning }));
|
|
863
917
|
if (json) {
|
|
864
|
-
return {
|
|
918
|
+
return {
|
|
919
|
+
code: 0,
|
|
920
|
+
lines: [JSON.stringify({ runId, status: run.status, stop, items, hasFinal, warnings })],
|
|
921
|
+
};
|
|
865
922
|
}
|
|
866
923
|
const revoicedByScene = new Set();
|
|
924
|
+
const timedOnOriginalVoice = new Set();
|
|
867
925
|
for (const artifact of outputsOfNodeKind(run, "video")) {
|
|
868
926
|
if (artifact.type === "video" && artifact.revoiced === true && typeof artifact.sceneIndex === "number") {
|
|
869
927
|
revoicedByScene.add(artifact.sceneIndex);
|
|
928
|
+
if ((artifact.words?.length ?? 0) > 0)
|
|
929
|
+
timedOnOriginalVoice.add(artifact.sceneIndex);
|
|
870
930
|
}
|
|
871
931
|
}
|
|
872
932
|
const byScene = new Map();
|
|
@@ -886,7 +946,11 @@ export async function statusFlow(runId, json, deps) {
|
|
|
886
946
|
const headline = stop.at === "failed" && stop.repair
|
|
887
947
|
? `Ad run ${runId} — Stopped, a step failed`
|
|
888
948
|
: `Ad run ${runId} — ${displayRunStatus(run.status)}`;
|
|
889
|
-
const lines = [
|
|
949
|
+
const lines = [
|
|
950
|
+
headline,
|
|
951
|
+
...stopLines(stop, runId, reviewUrl(deps.dashboardUrl, run)),
|
|
952
|
+
...warnings.map((w) => `Heads-up (${w.step}): ${w.warning}`),
|
|
953
|
+
];
|
|
890
954
|
if (byScene.size === 0) {
|
|
891
955
|
lines.push("", "No scenes yet — this run hasn't made anything to look at.");
|
|
892
956
|
}
|
|
@@ -909,6 +973,9 @@ export async function statusFlow(runId, json, deps) {
|
|
|
909
973
|
lines.push(` ${row.clip.error}`);
|
|
910
974
|
if (revoicedByScene.has(sceneIndex))
|
|
911
975
|
lines.push(" voice: cast voice applied");
|
|
976
|
+
if (timedOnOriginalVoice.has(sceneIndex)) {
|
|
977
|
+
lines.push(" word timings: measured on the original voice (close, not frame-exact)");
|
|
978
|
+
}
|
|
912
979
|
for (const finding of row.frame?.findings ?? []) {
|
|
913
980
|
lines.push(` picture: ${finding.code} (${finding.severity}): ${finding.detail}`);
|
|
914
981
|
}
|
|
@@ -958,6 +1025,23 @@ export async function storyboardFlow(runId, json, deps) {
|
|
|
958
1025
|
for (const scene of loose)
|
|
959
1026
|
lines.push(...sceneCardLines(scene));
|
|
960
1027
|
}
|
|
1028
|
+
const voiceRes = await deps
|
|
1029
|
+
.get(`${VOICES_PATH}?runId=${encodeURIComponent(runId)}`)
|
|
1030
|
+
.catch(() => null);
|
|
1031
|
+
if (voiceRes?.ok) {
|
|
1032
|
+
const sheet = (voiceRes.data ?? {});
|
|
1033
|
+
const cast = Array.isArray(sheet.cast) ? sheet.cast : [];
|
|
1034
|
+
const canChange = sheet.canChange !== false;
|
|
1035
|
+
if (canChange || cast.some((row) => row.voice !== null)) {
|
|
1036
|
+
const summary = voiceSummaryLines(cast, Array.isArray(sheet.notices) ? sheet.notices : []);
|
|
1037
|
+
if (summary.length > 0) {
|
|
1038
|
+
lines.push("", ...summary);
|
|
1039
|
+
if (canChange) {
|
|
1040
|
+
lines.push(`set voices with: exodus video voices ${runId} --set <character>=<voiceId>`);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
961
1045
|
lines.push("", `approve with: exodus video approve ${runId}`, `send it back: exodus video flag ${runId} --note "what's wrong"`);
|
|
962
1046
|
if (cards.framesNodeId) {
|
|
963
1047
|
lines.push(`redo one frame: exodus video retry-frame ${runId} --node ${cards.framesNodeId} --scene <n>`);
|
|
@@ -992,10 +1076,16 @@ export async function approveFlow(runId, json, deps) {
|
|
|
992
1076
|
return errorResult(res, json);
|
|
993
1077
|
if (json)
|
|
994
1078
|
return { code: 0, lines: [JSON.stringify({ ok: true, runId, data: res.data })] };
|
|
1079
|
+
const voices = res.data
|
|
1080
|
+
?.voices;
|
|
1081
|
+
const summary = voices
|
|
1082
|
+
? voiceSummaryLines(Array.isArray(voices.cast) ? voices.cast : [], Array.isArray(voices.notices) ? voices.notices : [])
|
|
1083
|
+
: [];
|
|
995
1084
|
return {
|
|
996
1085
|
code: 0,
|
|
997
1086
|
lines: [
|
|
998
1087
|
"Approved.",
|
|
1088
|
+
...(summary.length > 0 ? ["", ...summary, ""] : []),
|
|
999
1089
|
`See what happens next: exodus video status ${runId}`,
|
|
1000
1090
|
],
|
|
1001
1091
|
};
|
|
@@ -1052,6 +1142,127 @@ export async function retryFrameFlow(runId, nodeId, sceneIndex, note, json, deps
|
|
|
1052
1142
|
],
|
|
1053
1143
|
};
|
|
1054
1144
|
}
|
|
1145
|
+
const UPLOADED_CUT_WARNING = "The cut you already uploaded will not include the new clip. Pull the pieces again, " +
|
|
1146
|
+
"re-cut, and upload again.";
|
|
1147
|
+
export function planClipRedo(run, items, target) {
|
|
1148
|
+
const stop = classifyRun(run);
|
|
1149
|
+
const uploadedCut = items.some((i) => i.itemKind === "final" && i.status === "done");
|
|
1150
|
+
const rows = items.filter((i) => i.itemKind === "clip" &&
|
|
1151
|
+
i.sceneIndex === target.sceneIndex &&
|
|
1152
|
+
(target.nodeId === undefined || i.nodeId === target.nodeId));
|
|
1153
|
+
if (rows.length === 0) {
|
|
1154
|
+
return { ok: false, reason: `Scene ${target.sceneIndex} has no clip to redo.` };
|
|
1155
|
+
}
|
|
1156
|
+
const nodeIds = [...new Set(rows.map((r) => r.nodeId))];
|
|
1157
|
+
if (nodeIds.length > 1) {
|
|
1158
|
+
return {
|
|
1159
|
+
ok: false,
|
|
1160
|
+
reason: `Scene ${target.sceneIndex} has a clip on more than one step (${nodeIds.join(", ")}), ` +
|
|
1161
|
+
"so say which one: --node <nodeId>.",
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
const row = rows[0];
|
|
1165
|
+
const node = run.nodes.find((n) => n.nodeId === row.nodeId);
|
|
1166
|
+
if (node?.status === "running") {
|
|
1167
|
+
return {
|
|
1168
|
+
ok: false,
|
|
1169
|
+
reason: `The "${row.nodeId}" step is still making other scenes. Try again once it has finished ` +
|
|
1170
|
+
`(exodus video status ${run._id}).`,
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1173
|
+
if (node && node.status !== "done") {
|
|
1174
|
+
return {
|
|
1175
|
+
ok: false,
|
|
1176
|
+
reason: `The "${row.nodeId}" step did not finish (it is "${node.status}"), ` +
|
|
1177
|
+
"and one clip can only be redone on a step that finished.",
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
if (row.status === "running") {
|
|
1181
|
+
return { ok: false, reason: `Scene ${target.sceneIndex} is already being redone.` };
|
|
1182
|
+
}
|
|
1183
|
+
const redoable = row.status === "failed" ||
|
|
1184
|
+
(row.status === "done" && (row.flagged === true || stop.at === "final-watch"));
|
|
1185
|
+
if (!redoable) {
|
|
1186
|
+
if (row.status === "done") {
|
|
1187
|
+
return {
|
|
1188
|
+
ok: false,
|
|
1189
|
+
reason: `Scene ${target.sceneIndex}'s clip is finished and nothing flagged it. A finished clip ` +
|
|
1190
|
+
"can only be redone while the run is waiting for the final cut.",
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
const word = ITEM_STATUS_WORD[row.status] ?? row.status;
|
|
1194
|
+
return {
|
|
1195
|
+
ok: false,
|
|
1196
|
+
reason: `Scene ${target.sceneIndex}'s clip is ${word}, so there is nothing to redo yet.`,
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
return {
|
|
1200
|
+
ok: true,
|
|
1201
|
+
nodeId: row.nodeId,
|
|
1202
|
+
sceneIndex: target.sceneIndex,
|
|
1203
|
+
attempt: row.attempt ?? 0,
|
|
1204
|
+
warnings: uploadedCut ? [UPLOADED_CUT_WARNING] : [],
|
|
1205
|
+
};
|
|
1206
|
+
}
|
|
1207
|
+
export async function retryClipFlow(runId, target, note, json, deps) {
|
|
1208
|
+
const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1209
|
+
if (!runRes.ok)
|
|
1210
|
+
return errorResult(runRes, json);
|
|
1211
|
+
const run = asVideoRun(runRes.data);
|
|
1212
|
+
const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1213
|
+
if (!itemsRes.ok)
|
|
1214
|
+
return errorResult(itemsRes, json);
|
|
1215
|
+
const items = itemsRes.data.items ?? [];
|
|
1216
|
+
const plan = planClipRedo(run, items, target);
|
|
1217
|
+
if (!plan.ok) {
|
|
1218
|
+
return {
|
|
1219
|
+
code: 1,
|
|
1220
|
+
lines: json ? [JSON.stringify({ ok: false, error: plan.reason })] : [plan.reason],
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
const res = await deps.post(SCENE_RETRY_PATH, {
|
|
1224
|
+
runId,
|
|
1225
|
+
nodeId: plan.nodeId,
|
|
1226
|
+
sceneIndex: plan.sceneIndex,
|
|
1227
|
+
...(note ? { note } : {}),
|
|
1228
|
+
});
|
|
1229
|
+
if (!res.ok)
|
|
1230
|
+
return errorResult(res, json);
|
|
1231
|
+
const triggerRunId = res.data.triggerRunId;
|
|
1232
|
+
const review = reviewUrl(deps.dashboardUrl, run);
|
|
1233
|
+
if (json) {
|
|
1234
|
+
return {
|
|
1235
|
+
code: 0,
|
|
1236
|
+
lines: [
|
|
1237
|
+
JSON.stringify({
|
|
1238
|
+
ok: true,
|
|
1239
|
+
runId,
|
|
1240
|
+
nodeId: plan.nodeId,
|
|
1241
|
+
sceneIndex: plan.sceneIndex,
|
|
1242
|
+
triggerRunId,
|
|
1243
|
+
attemptBefore: plan.attempt,
|
|
1244
|
+
reviewUrl: review,
|
|
1245
|
+
warnings: plan.warnings,
|
|
1246
|
+
...(note ? { note } : {}),
|
|
1247
|
+
}),
|
|
1248
|
+
],
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
return {
|
|
1252
|
+
code: 0,
|
|
1253
|
+
lines: [
|
|
1254
|
+
`Redoing scene ${plan.sceneIndex}'s clip on ${plan.nodeId}.`,
|
|
1255
|
+
`triggerRunId: ${triggerRunId ?? "-"}`,
|
|
1256
|
+
"Every other scene stays as it is, and so do the pictures, the voices and the script.",
|
|
1257
|
+
...(note
|
|
1258
|
+
? ["Your note steers how this clip moves. It never changes the words that are spoken."]
|
|
1259
|
+
: []),
|
|
1260
|
+
`Watch it land: exodus video status ${runId}`,
|
|
1261
|
+
`Review the run: ${review}`,
|
|
1262
|
+
...plan.warnings,
|
|
1263
|
+
],
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1055
1266
|
export function parseVoiceFlags(argv, readFile) {
|
|
1056
1267
|
const fromFile = {};
|
|
1057
1268
|
const typed = {};
|
|
@@ -1138,14 +1349,30 @@ function availabilityWords(row) {
|
|
|
1138
1349
|
return "not checked";
|
|
1139
1350
|
}
|
|
1140
1351
|
}
|
|
1352
|
+
function voiceWords(voice) {
|
|
1353
|
+
if (!voice)
|
|
1354
|
+
return "—";
|
|
1355
|
+
return voice.label ? `${voice.label} (${voice.voiceId})` : voice.voiceId;
|
|
1356
|
+
}
|
|
1357
|
+
function voiceSummaryLines(cast, notices) {
|
|
1358
|
+
const rows = cast.filter((row) => row.spokenScenes > 0 || row.voice !== null);
|
|
1359
|
+
const width = Math.max(0, ...rows.map((row) => row.name.length));
|
|
1360
|
+
const lines = rows.length > 0 ? ["Voices"] : [];
|
|
1361
|
+
for (const row of rows) {
|
|
1362
|
+
lines.push(row.voice
|
|
1363
|
+
? ` ${row.name.padEnd(width)} → ${voiceWords(row.voice)} ${availabilityWords(row)}`
|
|
1364
|
+
: ` ${row.name.padEnd(width)} → no voice chosen (keeps the generated voice)`);
|
|
1365
|
+
}
|
|
1366
|
+
for (const notice of notices)
|
|
1367
|
+
lines.push(`Heads-up: ${notice}`);
|
|
1368
|
+
return lines;
|
|
1369
|
+
}
|
|
1141
1370
|
export function voiceSheetLines(sheet) {
|
|
1142
1371
|
const lines = [`Voices for run ${sheet.runId}`];
|
|
1143
1372
|
if (sheet.cast.length === 0)
|
|
1144
1373
|
lines.push(" Nobody is in this ad yet.");
|
|
1145
1374
|
for (const row of sheet.cast) {
|
|
1146
|
-
const voice = row.voice
|
|
1147
|
-
? `${row.voice.label ? `${row.voice.label} (${row.voice.voiceId})` : row.voice.voiceId}`
|
|
1148
|
-
: "—";
|
|
1375
|
+
const voice = voiceWords(row.voice);
|
|
1149
1376
|
lines.push(` ${row.characterId} ${row.name} ${voice} ${availabilityWords(row)} ` +
|
|
1150
1377
|
`speaks in ${row.spokenScenes} scenes, about ${row.spokenSeconds}s`);
|
|
1151
1378
|
}
|
|
@@ -1449,7 +1676,7 @@ export async function uploadFlow(runId, filePath, durationFlag, json, deps) {
|
|
|
1449
1676
|
if (!attached.ok)
|
|
1450
1677
|
return errorResult(attached, json);
|
|
1451
1678
|
const final = attached.data;
|
|
1452
|
-
const finalWatchUrl = final.finalWatchUrl ??
|
|
1679
|
+
const finalWatchUrl = final.finalWatchUrl ?? reviewUrl(deps.dashboardUrl, { _id: runId });
|
|
1453
1680
|
if (json) {
|
|
1454
1681
|
return {
|
|
1455
1682
|
code: 0,
|
|
@@ -1543,6 +1770,7 @@ export async function run(flags) {
|
|
|
1543
1770
|
"approve",
|
|
1544
1771
|
"flag",
|
|
1545
1772
|
"retry-frame",
|
|
1773
|
+
"retry-clip",
|
|
1546
1774
|
"voices",
|
|
1547
1775
|
"pull",
|
|
1548
1776
|
"upload",
|
|
@@ -1576,6 +1804,17 @@ export async function run(flags) {
|
|
|
1576
1804
|
}
|
|
1577
1805
|
return printResult(await retryFrameFlow(runId, nodeId, sceneIndex, flagString(flags, "note"), json, defaultDeps));
|
|
1578
1806
|
}
|
|
1807
|
+
if (sub === "retry-clip") {
|
|
1808
|
+
const sceneRaw = flagString(flags, "scene");
|
|
1809
|
+
if (sceneRaw === undefined) {
|
|
1810
|
+
usage("video retry-clip needs --scene <n>, the scene whose clip to redo.");
|
|
1811
|
+
}
|
|
1812
|
+
const sceneIndex = Number(sceneRaw);
|
|
1813
|
+
if (!Number.isInteger(sceneIndex)) {
|
|
1814
|
+
usage(`video retry-clip --scene must be a whole scene number, not "${sceneRaw}".`);
|
|
1815
|
+
}
|
|
1816
|
+
return printResult(await retryClipFlow(runId, { sceneIndex, nodeId: flagString(flags, "node") }, flagString(flags, "note"), json, defaultDeps));
|
|
1817
|
+
}
|
|
1579
1818
|
if (sub === "voices") {
|
|
1580
1819
|
let voices;
|
|
1581
1820
|
try {
|
|
@@ -3,6 +3,7 @@ import { type PollOptions, type PollResult } from "../lib/poll.js";
|
|
|
3
3
|
import { LEGACY_WORKFLOW_RUN_STATUS_VALUES, type RunStatus } from "../lib/runStatus.js";
|
|
4
4
|
import { type RunDeliverySummary } from "../lib/runVerdict.js";
|
|
5
5
|
import { type Channel } from "../lib/channel.js";
|
|
6
|
+
import { type VoiceMap } from "./video.js";
|
|
6
7
|
export declare const helpText: string;
|
|
7
8
|
export type WorkflowNodeKind = "brief" | "brief-form" | "asset" | "bot" | "primer" | "image" | "image-rig" | "rig" | "storyboard" | "reference" | "scene-frames" | "video" | "voiceover" | "output" | "call" | "show-set" | "show-cast" | "show-voices" | "product-truth" | "transform" | "formatter" | "splitter" | "collector" | "checkpoint" | "prompt";
|
|
8
9
|
export type WorkflowSlotState = "locked" | "auto" | "ask" | "inferred";
|
|
@@ -407,6 +408,7 @@ interface RunFlowOptions {
|
|
|
407
408
|
fill?: string;
|
|
408
409
|
autoApprove?: boolean;
|
|
409
410
|
imageRigOverrides?: Record<string, unknown>;
|
|
411
|
+
voices?: VoiceMap;
|
|
410
412
|
wait: boolean;
|
|
411
413
|
json: boolean;
|
|
412
414
|
out?: string;
|
|
@@ -427,6 +429,7 @@ export declare function serverWarningsToPrint(serverWarnings: unknown, alreadyPr
|
|
|
427
429
|
export declare function rejectTerminalFlag(args: string[]): void;
|
|
428
430
|
export declare function parseAutoApproveFlag(args: string[]): boolean;
|
|
429
431
|
export declare function parseRigOverridesFlag(args: string[], readFile?: (path: string) => string): Record<string, unknown> | undefined;
|
|
432
|
+
export declare function parseVoicesFlag(args: string[], readFile?: (path: string) => string): VoiceMap | undefined;
|
|
430
433
|
export declare function parseFillFlag(args: string[]): string | undefined;
|
|
431
434
|
export declare function formatWorkflowList(workflows: WorkflowListItem[]): string;
|
|
432
435
|
export declare function formatRecentRuns(runs: WorkflowRunProjection[]): string;
|
|
@@ -9,7 +9,7 @@ import { runVerdict } from "../lib/runVerdict.js";
|
|
|
9
9
|
import { workflowToYaml, parseWorkflowText } from "../lib/workflowText.js";
|
|
10
10
|
import { missingRouteLine } from "../lib/route-support.js";
|
|
11
11
|
import { getChannel } from "../lib/channel.js";
|
|
12
|
-
import { asVideoRun, classifyRun, stopLines } from "./video.js";
|
|
12
|
+
import { asVideoRun, classifyRun, reviewUrl, stopLines, } from "./video.js";
|
|
13
13
|
export const helpText = `
|
|
14
14
|
exodus workflow — List, describe, run, inspect, import, and export saved workflows
|
|
15
15
|
|
|
@@ -20,7 +20,7 @@ Usage:
|
|
|
20
20
|
exodus workflow templates [list] [--json]
|
|
21
21
|
exodus workflow templates export <key> [--out <file>] [--json]
|
|
22
22
|
exodus workflow schema [--kind <kind>] [--face <face>] [--json]
|
|
23
|
-
exodus workflow run <workflowId|name> [--fill <name>] [--input key=value ...] [--input <fileField>=<path> ...] [--rig-overrides <json|@file>] [--auto-approve] [--wait] [--out <dir>] [--json]
|
|
23
|
+
exodus workflow run <workflowId|name> [--fill <name>] [--input key=value ...] [--input <fileField>=<path> ...] [--rig-overrides <json|@file>] [--voices <json|@voices.json>] [--auto-approve] [--wait] [--out <dir>] [--json]
|
|
24
24
|
exodus workflow status [--json]
|
|
25
25
|
exodus workflow status --id <runId> [--out <dir>] [--json]
|
|
26
26
|
exodus workflow cancel <runId> [--reason "..."] [--json]
|
|
@@ -83,6 +83,25 @@ Flags:
|
|
|
83
83
|
before anything runs, and the error names it. On
|
|
84
84
|
"triggers fire" it REPLACES the schedule's own
|
|
85
85
|
overrides for that one test fire.
|
|
86
|
+
--voices <json> (run) Give the ad's characters their voices at the
|
|
87
|
+
moment the run starts, instead of at the storyboard
|
|
88
|
+
review. Keyed by the names the SCRIPT uses for its
|
|
89
|
+
speakers ("HOST 1") — the planner's character ids
|
|
90
|
+
(C1, C2) do not exist yet when a run starts. Takes a
|
|
91
|
+
JSON object, or @path to a .json file holding one:
|
|
92
|
+
--voices @voices.json
|
|
93
|
+
--voices '{"HOST 1":"<voiceId>"}'
|
|
94
|
+
Same file "exodus video voices <run> --from" reads at
|
|
95
|
+
the review, so a brand keeps ONE voices.json for both.
|
|
96
|
+
A speaker the script does not have, the same speaker
|
|
97
|
+
named twice, or a voice ElevenLabs does not have stops
|
|
98
|
+
the launch before the run is created — nothing is
|
|
99
|
+
spent, and the message says which one. A workflow whose
|
|
100
|
+
script is written by a bot (so nobody knows the speaker
|
|
101
|
+
names at the start) is told to use "exodus video voices
|
|
102
|
+
<run>" at the storyboard review instead. Voice ids
|
|
103
|
+
belong to the brand's own folder — never put them in a
|
|
104
|
+
shared workflow or template.
|
|
86
105
|
--auto-approve (run) Deliberately unattended launch: every Checkpoint
|
|
87
106
|
box this run stops at is approved automatically, with
|
|
88
107
|
whatever it was holding left exactly as it is, and the
|
|
@@ -174,6 +193,7 @@ Examples:
|
|
|
174
193
|
exodus workflow run "Launch Flow" --auto-approve --wait
|
|
175
194
|
exodus workflow run "Product Shots" --rig-overrides '{"rig_1":{"lines":{"line_1":{"count":3}}}}'
|
|
176
195
|
exodus workflow run "Product Shots" --rig-overrides @rig.json --wait
|
|
196
|
+
exodus workflow run "Podcast Ad" --input script=@script.txt --voices @voices.json --wait
|
|
177
197
|
exodus workflow run "Launch Flow" --wait --out ./deliverables
|
|
178
198
|
exodus workflow status # recent runs + their ids
|
|
179
199
|
exodus workflow status --id wr_123
|
|
@@ -235,6 +255,19 @@ Notes:
|
|
|
235
255
|
message says which key was wrong. A trigger can carry the same payload in its
|
|
236
256
|
YAML ("imageRigOverrides:" beside its schedule), so a Monday schedule and a
|
|
237
257
|
Friday one can fire the same workflow at different sizes.
|
|
258
|
+
"workflow run --voices" hands a video run its voices at the start, so a brand
|
|
259
|
+
that always uses the same hosts stops choosing them by hand on every run. The
|
|
260
|
+
file is keyed by the names the script uses for its speakers, because that is
|
|
261
|
+
all that exists before the planner has run — it is the SAME voices.json
|
|
262
|
+
"exodus video voices <run> --from" reads at the storyboard review, so one file
|
|
263
|
+
per brand serves both moments. A speaker the script does not have, the same
|
|
264
|
+
speaker named twice, or a voice ElevenLabs does not have stops the launch
|
|
265
|
+
before the run exists and nothing is spent. When the storyboard is planned,
|
|
266
|
+
each named speaker's character already has their voice, and "exodus video
|
|
267
|
+
voices <run>" shows them set. A workflow whose script is written by a bot has
|
|
268
|
+
no speaker names to key on at the start, so it is refused with a pointer at
|
|
269
|
+
the review-time command. Keep voice ids in the brand's own folder, never in a
|
|
270
|
+
shared workflow or template. (#1846)
|
|
238
271
|
workflow triggers are addressed by 1-based position — a trigger carries no id,
|
|
239
272
|
so the CLI reads the live list from the export contract and sends a fingerprint
|
|
240
273
|
of the trigger's fields as the guard; a concurrent edit fails loud rather than
|
|
@@ -320,6 +353,7 @@ const VALUE_FLAGS = new Set([
|
|
|
320
353
|
"slot",
|
|
321
354
|
"reject",
|
|
322
355
|
"rig-overrides",
|
|
356
|
+
"voices",
|
|
323
357
|
"note",
|
|
324
358
|
]);
|
|
325
359
|
function defaultMkdirp(dir) {
|
|
@@ -917,6 +951,63 @@ export function parseRigOverridesFlag(args, readFile) {
|
|
|
917
951
|
}
|
|
918
952
|
return parsed;
|
|
919
953
|
}
|
|
954
|
+
export function parseVoicesFlag(args, readFile) {
|
|
955
|
+
let raw;
|
|
956
|
+
for (let i = 0; i < args.length; i++) {
|
|
957
|
+
const arg = args[i];
|
|
958
|
+
let value;
|
|
959
|
+
if (arg === "--voices") {
|
|
960
|
+
value = args[i + 1];
|
|
961
|
+
i++;
|
|
962
|
+
}
|
|
963
|
+
else if (arg.startsWith("--voices=")) {
|
|
964
|
+
value = arg.slice("--voices=".length);
|
|
965
|
+
}
|
|
966
|
+
else {
|
|
967
|
+
continue;
|
|
968
|
+
}
|
|
969
|
+
if (value === undefined || value.startsWith("--")) {
|
|
970
|
+
throw new Error("--voices requires JSON or @path/to/voices.json");
|
|
971
|
+
}
|
|
972
|
+
raw = value;
|
|
973
|
+
}
|
|
974
|
+
if (raw === undefined)
|
|
975
|
+
return undefined;
|
|
976
|
+
let text = raw.trim();
|
|
977
|
+
if (!text)
|
|
978
|
+
throw new Error("--voices requires JSON or @path/to/voices.json");
|
|
979
|
+
if (text.startsWith("@@")) {
|
|
980
|
+
text = text.slice(1);
|
|
981
|
+
}
|
|
982
|
+
else if (text.startsWith("@")) {
|
|
983
|
+
const filePath = text.slice(1);
|
|
984
|
+
if (!filePath) {
|
|
985
|
+
throw new Error("--voices @<file> needs a file path after \"@\"");
|
|
986
|
+
}
|
|
987
|
+
if (!readFile) {
|
|
988
|
+
throw new Error(`--voices: cannot load @${filePath} here (no file access)`);
|
|
989
|
+
}
|
|
990
|
+
try {
|
|
991
|
+
text = readFile(filePath);
|
|
992
|
+
}
|
|
993
|
+
catch (e) {
|
|
994
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
995
|
+
throw new Error(`--voices: could not read file "${filePath}": ${msg}`);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
let parsed;
|
|
999
|
+
try {
|
|
1000
|
+
parsed = JSON.parse(text);
|
|
1001
|
+
}
|
|
1002
|
+
catch (e) {
|
|
1003
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1004
|
+
throw new Error(`--voices is not valid JSON: ${msg}`);
|
|
1005
|
+
}
|
|
1006
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
1007
|
+
throw new Error("--voices must be a JSON object keyed by the script's speaker names, e.g. {\"HOST 1\":\"<voiceId>\"}");
|
|
1008
|
+
}
|
|
1009
|
+
return parsed;
|
|
1010
|
+
}
|
|
920
1011
|
export function parseFillFlag(args) {
|
|
921
1012
|
let name;
|
|
922
1013
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -1723,6 +1814,20 @@ export async function botsFlow(opts, deps) {
|
|
|
1723
1814
|
return { code: 0, lines: [JSON.stringify(catalog)] };
|
|
1724
1815
|
return { code: 0, lines: [formatBotsList(catalog, opts.category)] };
|
|
1725
1816
|
}
|
|
1817
|
+
function launchVoicesLine(attached) {
|
|
1818
|
+
if (!Array.isArray(attached))
|
|
1819
|
+
return undefined;
|
|
1820
|
+
const said = [];
|
|
1821
|
+
for (const raw of attached) {
|
|
1822
|
+
if (!raw || typeof raw !== "object")
|
|
1823
|
+
continue;
|
|
1824
|
+
const { speaker, voiceId, label } = raw;
|
|
1825
|
+
if (typeof speaker !== "string" || typeof voiceId !== "string")
|
|
1826
|
+
continue;
|
|
1827
|
+
said.push(`${speaker} → ${typeof label === "string" && label !== "" ? label : voiceId}`);
|
|
1828
|
+
}
|
|
1829
|
+
return said.length > 0 ? `Voices: ${said.join(", ")}` : undefined;
|
|
1830
|
+
}
|
|
1726
1831
|
export async function runFlow(workflowRef, opts, deps) {
|
|
1727
1832
|
let workflowId;
|
|
1728
1833
|
try {
|
|
@@ -1765,6 +1870,7 @@ export async function runFlow(workflowRef, opts, deps) {
|
|
|
1765
1870
|
...(opts.fill && opts.fill.trim() !== "" ? { fill: opts.fill.trim() } : {}),
|
|
1766
1871
|
...(opts.autoApprove === true ? { autoApprove: true } : {}),
|
|
1767
1872
|
...(opts.imageRigOverrides ? { imageRigOverrides: opts.imageRigOverrides } : {}),
|
|
1873
|
+
...(opts.voices ? { voices: opts.voices } : {}),
|
|
1768
1874
|
launchedVia: "cli",
|
|
1769
1875
|
};
|
|
1770
1876
|
const start = await deps.post(RUN_PATH, body);
|
|
@@ -1778,6 +1884,7 @@ export async function runFlow(workflowRef, opts, deps) {
|
|
|
1778
1884
|
const base = { ...data, workflowId };
|
|
1779
1885
|
if (opts.json && !opts.wait)
|
|
1780
1886
|
return { code: 0, lines: [JSON.stringify(base)] };
|
|
1887
|
+
const voicesLine = launchVoicesLine(data?.launchVoices);
|
|
1781
1888
|
const lines = opts.json
|
|
1782
1889
|
? []
|
|
1783
1890
|
: [
|
|
@@ -1785,6 +1892,7 @@ export async function runFlow(workflowRef, opts, deps) {
|
|
|
1785
1892
|
"Workflow run started.",
|
|
1786
1893
|
`runId: ${data.runId}`,
|
|
1787
1894
|
`triggerRunId: ${data.triggerRunId}`,
|
|
1895
|
+
...(voicesLine ? [voicesLine] : []),
|
|
1788
1896
|
`Poll: exodus workflow status --id ${data.runId}`,
|
|
1789
1897
|
];
|
|
1790
1898
|
if (!opts.wait) {
|
|
@@ -1903,7 +2011,8 @@ async function waitForRun(runId, opts, deps) {
|
|
|
1903
2011
|
lines.push("", landOnPark.headline, ...formatPauseNotice(landOnPark.pauseReason, runId, dashboardUrl).slice(1));
|
|
1904
2012
|
}
|
|
1905
2013
|
if (videoStop) {
|
|
1906
|
-
|
|
2014
|
+
const review = reviewUrl(deps.dashboardUrl ?? getDashboardUrl(), asVideoRun(pollResult.data));
|
|
2015
|
+
lines.push("", ...stopLines(videoStop, runId, review));
|
|
1907
2016
|
}
|
|
1908
2017
|
return { code: pollResult.ok ? 0 : 1, lines };
|
|
1909
2018
|
}
|
|
@@ -2900,19 +3009,21 @@ export async function run(flags) {
|
|
|
2900
3009
|
const workflowRef = rest[0];
|
|
2901
3010
|
if (!workflowRef) {
|
|
2902
3011
|
console.error("Error: workflow run requires <workflowId|name>.");
|
|
2903
|
-
console.log("Usage: exodus workflow run <workflowId|name> [--fill <name>] [--input key=value ...] [--input <fileField>=./path/to/file ...] [--auto-approve] [--wait] [--json]");
|
|
3012
|
+
console.log("Usage: exodus workflow run <workflowId|name> [--fill <name>] [--input key=value ...] [--input <fileField>=./path/to/file ...] [--rig-overrides <json|@file>] [--voices <json|@voices.json>] [--auto-approve] [--wait] [--json]");
|
|
2904
3013
|
process.exit(1);
|
|
2905
3014
|
}
|
|
2906
3015
|
let inputs;
|
|
2907
3016
|
let fill;
|
|
2908
3017
|
let autoApprove;
|
|
2909
3018
|
let imageRigOverrides;
|
|
3019
|
+
let voices;
|
|
2910
3020
|
try {
|
|
2911
3021
|
inputs = parseRawInputFlags(process.argv.slice(3));
|
|
2912
3022
|
rejectTerminalFlag(process.argv.slice(3));
|
|
2913
3023
|
fill = parseFillFlag(process.argv.slice(3));
|
|
2914
3024
|
autoApprove = parseAutoApproveFlag(process.argv.slice(3));
|
|
2915
3025
|
imageRigOverrides = parseRigOverridesFlag(process.argv.slice(3), defaultDeps.readFile);
|
|
3026
|
+
voices = parseVoicesFlag(process.argv.slice(3), defaultDeps.readFile);
|
|
2916
3027
|
}
|
|
2917
3028
|
catch (e) {
|
|
2918
3029
|
console.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -2923,6 +3034,7 @@ export async function run(flags) {
|
|
|
2923
3034
|
fill,
|
|
2924
3035
|
autoApprove,
|
|
2925
3036
|
imageRigOverrides,
|
|
3037
|
+
voices,
|
|
2926
3038
|
wait: flags["wait"] === true,
|
|
2927
3039
|
json,
|
|
2928
3040
|
out: flagString(flags, "out"),
|