@aicopycoders/exodus 2026.9.1900-beta.0 → 2026.9.1900-beta.2
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/docs/PIPELINES.md +12 -0
- package/assets/skills/exodus-video/SKILL.md +189 -13
- package/assets/skills/exodus-workflow/SKILL.md +33 -1
- package/dist/bin/exodus.js +3 -36
- package/dist/commands/video.d.ts +20 -2
- package/dist/commands/video.js +263 -62
- package/dist/commands/workflow.d.ts +52 -8
- package/dist/commands/workflow.js +188 -96
- package/dist/lib/args.d.ts +10 -0
- package/dist/lib/args.js +39 -0
- package/package.json +1 -1
package/assets/docs/CLAUDE.md
CHANGED
|
@@ -61,7 +61,7 @@ These are the **Exodus** pipelines — the automatic system that drives the Gene
|
|
|
61
61
|
| **Meme** | `exodus-meme` | Meme ads — recommends formats for a brief, then one batched run renders AI-image and classic Imgflip-template memes server-side. |
|
|
62
62
|
| **Workflow** | `exodus-workflow` | Run and build saved multi-node workflow automations from the CLI, resolve checkpoint/repair parks from the inbox, continue bot sessions, fire triggers, and read banks or promote winners. |
|
|
63
63
|
| **Hooks** | `exodus-hooks` | Read the Scout library — the hook cards captured from Instagram outliers. Compose filters (score, lane, validation status, language, hook type, recency), inspect one card, find cards sharing a hook pattern, export to CSV. Read-only. |
|
|
64
|
-
| **Video** | `exodus-video` | Make a video ad from a script. Main path: run a saved video workflow (`npx @aicopycoders/exodus workflow run`, no Show); starting from a Show (`video start --show`) is the other way in. Then read and approve the storyboard, `npx @aicopycoders/exodus video pull` every piece (per-scene clips, narration and per-scene voice, keyframes, cast stills, word timings, music bed) plus a manifest, cut them in whatever editor you use (a bundled script is one option), upload the cut and approve it. Admin-only. |
|
|
64
|
+
| **Video** | `exodus-video` | Make a video ad from a script. Main path: run a saved video workflow (`npx @aicopycoders/exodus workflow run`, no Show); starting from a Show (`video start --show`) is the other way in. Choose the voices as the run starts: `--voices` gives each speaker an ElevenLabs voice, `--voice-treatment` says how the voices are made (including having the video model speak a voice you wrote). Then read and approve the storyboard, `npx @aicopycoders/exodus video pull` every piece (per-scene clips, narration and per-scene voice, keyframes, cast stills, word timings, music bed) plus a manifest, cut them in whatever editor you use (a bundled script is one option), 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
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. |
|
package/assets/docs/PIPELINES.md
CHANGED
|
@@ -215,6 +215,13 @@ Representative commands:
|
|
|
215
215
|
exodus session list | show | chat
|
|
216
216
|
exodus bank list | show <key> | promote <key>
|
|
217
217
|
Every workflow/session/bank verb takes --json — that output is the machine API.
|
|
218
|
+
|
|
219
|
+
A video workflow whose Rig box points at a rig with saved format rules follows
|
|
220
|
+
them — how many speakers the script may have, which checks run, how the cast is
|
|
221
|
+
built. The start receipt and `workflow status` name the rulebook and its version;
|
|
222
|
+
`video pull` writes the same facts into manifest.json. Every Rig box in the
|
|
223
|
+
workflow has to point at the same saved rig, or the launch refuses and nothing
|
|
224
|
+
is spent.
|
|
218
225
|
```
|
|
219
226
|
|
|
220
227
|
---
|
|
@@ -247,10 +254,14 @@ Returns: a table, --json data, or an exported CSV/JSON file
|
|
|
247
254
|
|
|
248
255
|
**What it does:** Makes a video ad from a script, in pieces. Main path is a saved video workflow with no Show (`exodus workflow run <name> --input <field>=@script.txt`); a locked Show (`exodus video start --show`) is the other way in and is the only route with `video flag`. On the workflow route the run stops for your cut only if its video node sets `finalWatch: true` (defaults to off — check with `exodus workflow export`, not `describe`), and video gates are approved with `exodus video approve`, not `workflow checkpoint approve`. Either way the run writes the storyboard, draws a picture per scene, records the voice, renders a clip per scene and generates a music bed, then parks for a cut. The CLI pulls the pieces to a folder with a `manifest.json`; you cut them in whatever editor you use; the upload attaches the cut for approval. Admin-only.
|
|
249
256
|
|
|
257
|
+
**Voices, on the workflow route.** Two separate choices, both made when the run starts and both refused before the run is created if anything is wrong, so a mistake costs nothing. `--voices @voices.json` says WHICH ElevenLabs voice each speaker gets, keyed by the names the script uses (`HOST 1`) — the same file `exodus video voices <run> --from` reads at the review, so one file per brand serves both moments. `--voice-treatment` says HOW the voices are made: a name on its own for a way of working that plays a voice you picked, or a `voice-treatment.json` shaped `{"path": "...", "describe": {"HOST 1": "Dry, tired baritone."}}` for the ways where the video model speaks the CAST's lines itself in voices you WROTE. A written voice needs one description per speaker, and it cannot be changed at the storyboard review — that is what the flag at launch is for. The two never combine: written voices plus `--voices` is refused, because those ElevenLabs voices would be paid for and never heard. A written voice covers the CAST's lines only: a narrated scene is still recorded by ElevenLabs on the member's own key, so a script with `NARRATOR:` turns still needs the key and still costs ElevenLabs money. Ways of working beyond the two shipped ones are switched on only on the test stack while they are being tried out.
|
|
258
|
+
|
|
250
259
|
```operator-guide
|
|
251
260
|
Start (workflow route, no Show):
|
|
252
261
|
exodus workflow list / describe <name> find it and see what it needs
|
|
253
262
|
exodus workflow run <workflowId|name> --input <field>=@script.txt [--wait] start it; --wait stops at the storyboard gate
|
|
263
|
+
exodus workflow run <workflowId|name> --input <field>=@script.txt --voices @voices.json ... and give each speaker an ElevenLabs voice up front
|
|
264
|
+
exodus workflow run <workflowId|name> --input <field>=@script.txt --voice-treatment @voice-treatment.json ... or have the video model speak voices you WROTE
|
|
254
265
|
Start (Show route):
|
|
255
266
|
exodus video shows [--json] Shows and whether each is ready
|
|
256
267
|
exodus video start --show <id> --script <file> [--wait] [--json] start an ad run; --wait stops at the storyboard gate
|
|
@@ -263,6 +274,7 @@ Then, on either route:
|
|
|
263
274
|
exodus video status <runId> [--json] the park and each scene's clip/voice/picture
|
|
264
275
|
exodus video pull <runId> --out <dir> [--json] every piece + manifest.json
|
|
265
276
|
exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "<t>"] [--json] redo one clip at the final watch
|
|
277
|
+
exodus video revoice <runId> (--scene <n> | --all) [--node <nodeId>] [--json] redo only the voice on finished clips; no new video
|
|
266
278
|
exodus video upload <runId> --file <cut.mp4> [--duration <s>] [--json] attach the cut (MP4/MOV/WebM, 200 MB)
|
|
267
279
|
Cut: your choice of tool — Exodus does not stitch or mix. One option ships with the skill:
|
|
268
280
|
node .claude/skills/exodus-video/scripts/first-cut.mjs <dir> [--skip n,m] [--no-music] [--out f]
|
|
@@ -77,6 +77,8 @@ exodus workflow run <workflowId|name> --input <field>=@script.txt --wait
|
|
|
77
77
|
start, stop at the storyboard gate
|
|
78
78
|
exodus workflow run <workflowId|name> --input <field>=@script.txt --voices @voices.json --wait
|
|
79
79
|
same, with the brand's voices already chosen
|
|
80
|
+
exodus workflow run <workflowId|name> --input <field>=@script.txt --voice-treatment @voice-treatment.json --wait
|
|
81
|
+
same, with the video model speaking voices you WROTE
|
|
80
82
|
exodus video status <runId> where it is, per scene
|
|
81
83
|
```
|
|
82
84
|
|
|
@@ -100,6 +102,7 @@ exodus video status <runId> where it is, per scene
|
|
|
100
102
|
exodus video pull <runId> --out ./ad-<runId> every piece + manifest.json
|
|
101
103
|
exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "…"]
|
|
102
104
|
redo one clip at the final watch
|
|
105
|
+
exodus video revoice <runId> --all | --scene <n> redo only the voice; no new video
|
|
103
106
|
… make the cut (your choice of tool)
|
|
104
107
|
exodus video upload <runId> --file ./ad-<runId>/cut.mp4 attach it
|
|
105
108
|
exodus video approve <runId> make it the ad
|
|
@@ -176,6 +179,7 @@ Verified against the routes, not assumed. Everything here is admin-gated on top.
|
|
|
176
179
|
| script-turn edits at the gate | **refused** | dashboard only |
|
|
177
180
|
| `video retry-frame` | works | works |
|
|
178
181
|
| `video retry-clip` | works | works |
|
|
182
|
+
| `video revoice` | works | works |
|
|
179
183
|
| `video pull` | works | works |
|
|
180
184
|
| `video upload` | only if the video node sets `finalWatch: true` | works |
|
|
181
185
|
| `workflow checkpoint approve` | **refused at a video gate** | **refused at a video gate** |
|
|
@@ -254,6 +258,13 @@ CTA:
|
|
|
254
258
|
THE SOFTGEL: Tap below and give your eyes ClearBlink.
|
|
255
259
|
```
|
|
256
260
|
|
|
261
|
+
**How many speakers you may have depends on the rig.** Three by default. A rig
|
|
262
|
+
that carries saved format rules sets its own number: an ensemble rig seats more,
|
|
263
|
+
a talking-head rig seats one. Over the number, the launch refuses before
|
|
264
|
+
anything is spent and says both figures ("4 speakers — this format supports 3").
|
|
265
|
+
The rulebook the run followed is on the start receipt, on `workflow status`, and
|
|
266
|
+
in `manifest.json` under `provenance`.
|
|
267
|
+
|
|
257
268
|
## Reading the storyboard gate
|
|
258
269
|
|
|
259
270
|
`storyboard <runId>` prints one card per scene on both routes: who speaks, the
|
|
@@ -277,9 +288,11 @@ whole loop unattended.
|
|
|
277
288
|
|
|
278
289
|
## Giving the characters voices
|
|
279
290
|
|
|
280
|
-
By default a workflow run's characters have no voice
|
|
281
|
-
|
|
282
|
-
|
|
291
|
+
By default a workflow run's characters have no voice of their own. A character
|
|
292
|
+
without one gets the run's default voice (the workflow's narrator voice) when the
|
|
293
|
+
run has one; with no default either, the clip keeps the voice the video model
|
|
294
|
+
invents. `exodus video voices` shows which of the three applies and is where it
|
|
295
|
+
gets fixed, and it only works **before** the storyboard is approved — once clips start
|
|
283
296
|
it refuses.
|
|
284
297
|
|
|
285
298
|
```
|
|
@@ -353,6 +366,88 @@ written by a bot is refused with a pointer at the review-time command, because
|
|
|
353
366
|
nobody knows the speaker names yet. When the storyboard arrives, the voices are
|
|
354
367
|
already set and `exodus video voices <runId>` shows them.
|
|
355
368
|
|
|
369
|
+
**Choosing how the voices are made.** `--voice-treatment` on `workflow run` says
|
|
370
|
+
HOW a run makes its voices, and it is the only door on the workflow route. Two
|
|
371
|
+
kinds of answer.
|
|
372
|
+
|
|
373
|
+
A name on its own picks a way of working that plays a voice you already have,
|
|
374
|
+
and still pairs with `--voices`:
|
|
375
|
+
|
|
376
|
+
```
|
|
377
|
+
exodus workflow run "Podcast Ad" --input script=@script.txt \
|
|
378
|
+
--voice-treatment lipsync-retarget --voices @voices.json
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
The other kind has the video model speak the CAST's lines itself, in voices you
|
|
382
|
+
WRITE. Those take a `voice-treatment.json`, which is the whole request body:
|
|
383
|
+
|
|
384
|
+
```json
|
|
385
|
+
{
|
|
386
|
+
"path": "native-prompt",
|
|
387
|
+
"describe": {
|
|
388
|
+
"BUDGET": "Dry, tired middle-aged baritone. Slight rasp. Deadpan, unhurried.",
|
|
389
|
+
"PIGGY BANK": "Bright quick alto, a little smug, crisp consonants."
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
```
|
|
395
|
+
exodus workflow run "Podcast Ad" --input script=@script.txt \
|
|
396
|
+
--voice-treatment @voice-treatment.json
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
Every speaker in the script needs a description. One left out gets a voice the
|
|
400
|
+
model invents afresh on every clip, and that drift only shows up once the clips
|
|
401
|
+
are paid for, so the launch refuses instead.
|
|
402
|
+
|
|
403
|
+
**The narration is not part of this.** A describing treatment changes how the
|
|
404
|
+
CAST's lines are voiced. A scene with narration (`voText`) is still recorded by
|
|
405
|
+
ElevenLabs on the member's own key by the Voiceover step, exactly as it always
|
|
406
|
+
was — so a run with `NARRATOR:` turns still needs an ElevenLabs key, and still
|
|
407
|
+
costs ElevenLabs money. `exodus video voices <runId>` says which of the two
|
|
408
|
+
shapes a run is, and only claims nothing is sent to ElevenLabs when nothing is.
|
|
409
|
+
|
|
410
|
+
**What refuses, all before the run is created, so nothing is spent.**
|
|
411
|
+
|
|
412
|
+
- A treatment name that does not exist. The message lists the ones that do.
|
|
413
|
+
- A describing treatment with a speaker undescribed, a speaker the script does
|
|
414
|
+
not have, the same speaker described twice, or an empty description.
|
|
415
|
+
- Written voices plus `--voices`. The video model owns the voice on those runs,
|
|
416
|
+
so the ElevenLabs voices would be paid for and never heard.
|
|
417
|
+
- Descriptions on a treatment that plays voices you picked instead.
|
|
418
|
+
- A workflow whose script is written by a bot. Nobody knows the speaker names
|
|
419
|
+
yet, so there is nothing to describe.
|
|
420
|
+
- A workflow with no Video step, a Video step whose model cannot do what was
|
|
421
|
+
asked, or a Video step with nothing wired into its voiceover port when the
|
|
422
|
+
treatment needs one.
|
|
423
|
+
- No Google AI key on the account, for the treatment that renders through
|
|
424
|
+
Google directly.
|
|
425
|
+
- A rig that shows every line on screen as text. No clip speaks, so there is no
|
|
426
|
+
voice to treat.
|
|
427
|
+
|
|
428
|
+
**Still being tested.** Everything beyond the two shipped ways of working is
|
|
429
|
+
switched on only on the test stack while it is being tried out. On the live
|
|
430
|
+
stack those names refuse by name, and the message says which two to use
|
|
431
|
+
instead. That is a property of the stack, not of the workflow, so the same
|
|
432
|
+
saved workflow behaves differently on each — and a retry asks again rather than
|
|
433
|
+
copying, so a run retried after a treatment was switched off refuses.
|
|
434
|
+
|
|
435
|
+
**A sub-workflow chooses none of this.** When a Call box runs another workflow
|
|
436
|
+
inside this one, the child run carries no voice treatment: it renders the
|
|
437
|
+
shipped default, whatever the parent was started with. A treatment is a choice a
|
|
438
|
+
person made at a keyboard for one run, and nobody made it for the sub-workflow.
|
|
439
|
+
|
|
440
|
+
**Written voices cannot be changed at the review.** They are chosen when the run
|
|
441
|
+
starts. `exodus video voices <runId>` on a described run names the treatment and
|
|
442
|
+
prints each written voice, and says plainly that `--voice-treatment` at launch is
|
|
443
|
+
what changes them. Do not tell anyone to create a Show for this, and do not set a
|
|
444
|
+
voice path in node config — neither is a door.
|
|
445
|
+
|
|
446
|
+
**Where it shows up afterwards.** The launch receipt and `exodus workflow status`
|
|
447
|
+
both print one line naming the treatment and the speakers who got a written
|
|
448
|
+
voice. `manifest.json` from `exodus video pull` carries the same under
|
|
449
|
+
`provenance.voice`, and each cast entry carries its `voiceDescription`.
|
|
450
|
+
|
|
356
451
|
**The JSON receipt** (`--json`), the same shape for reading and for setting:
|
|
357
452
|
|
|
358
453
|
```
|
|
@@ -361,9 +456,15 @@ runId
|
|
|
361
456
|
changed[] the characters whose voice actually moved (set only)
|
|
362
457
|
canChange false once the run has left the storyboard review
|
|
363
458
|
whyNot why, in one sentence, when canChange is false
|
|
459
|
+
treatment.path which way of making voices this run froze, or null
|
|
460
|
+
treatment.kind "performance-then-conversion" or "render-owns-voice"
|
|
364
461
|
cast[] one per character
|
|
365
462
|
characterId, name
|
|
366
|
-
voice { voiceId, label }
|
|
463
|
+
voice { voiceId, label }: the voice this character's clips are
|
|
464
|
+
converted to. null only when they have no voice of their
|
|
465
|
+
own AND the run has no default voice
|
|
466
|
+
voiceFrom "own-pin" or "run-default"; absent when voice is null
|
|
467
|
+
description the WRITTEN voice, when the run was given one
|
|
367
468
|
availability { state: "available" | "missing" | "not-checked", … }
|
|
368
469
|
spokenScenes how many scenes this character speaks in
|
|
369
470
|
spokenSeconds those scenes' planned seconds — what ElevenLabs bills on
|
|
@@ -378,8 +479,8 @@ A failure is the same shape every `video` verb uses:
|
|
|
378
479
|
`{ "ok": false, "status": 400, "error": "<one plain sentence>" }`, exit 1.
|
|
379
480
|
|
|
380
481
|
**Before you approve, read `notices`.** The ones that cost the user something:
|
|
381
|
-
a character who speaks but has no voice
|
|
382
|
-
voice), no ElevenLabs key saved on the run (no voice can be applied at all), and
|
|
482
|
+
a character who speaks but has no voice of their own and no run default to fall
|
|
483
|
+
back on (those clips keep their generated voice), no ElevenLabs key saved on the run (no voice can be applied at all), and
|
|
383
484
|
a voice ElevenLabs no longer has.
|
|
384
485
|
|
|
385
486
|
`estimatedCostUsd` is always null. Exodus cannot price a voice conversion yet;
|
|
@@ -445,6 +546,19 @@ scenes[] one per scene, in order
|
|
|
445
546
|
speechTrimmed the clip was re-cut to its spoken words
|
|
446
547
|
(0.5 s before the first, 0.8 s after the
|
|
447
548
|
last); null when there is no clip
|
|
549
|
+
rawStorageId the stored file the video model made, kept
|
|
550
|
+
on record when the voice pass replaced the
|
|
551
|
+
clip with a new file; null when the clip IS
|
|
552
|
+
that file, or there is no clip
|
|
553
|
+
provenance which saved rulebook this run followed:
|
|
554
|
+
{format: {via, nodeId, rigId, rigName,
|
|
555
|
+
rulesFrom, specVersion}}. Absent when the
|
|
556
|
+
run followed none. `nodeId` is the Rig box
|
|
557
|
+
that chose it (absent on a Show ad, which
|
|
558
|
+
has no box); `rulesFrom` names the rig the
|
|
559
|
+
rules were borrowed from when the picked
|
|
560
|
+
rig is a copy. Names, ids and a version
|
|
561
|
+
only — never the rules themselves
|
|
448
562
|
failed[] files that did not download: {file, url, error}
|
|
449
563
|
```
|
|
450
564
|
|
|
@@ -493,13 +607,16 @@ own ElevenLabs render.
|
|
|
493
607
|
**Which scenes carry their voice where.** A scene whose storyboard entry has a
|
|
494
608
|
non-empty `voText` is a narrator scene: its clip is rendered silent and the
|
|
495
609
|
voice arrives as a separate track. A scene with on-camera `dialogue[]` has the
|
|
496
|
-
speech inside the clip — under the default voice
|
|
497
|
-
voice is then replaced, in place, with the cast member's pinned
|
|
498
|
-
(that is `revoiced: true`). Other
|
|
499
|
-
audio, e.g. lip-sync retargeting a VO track onto the video. The
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
610
|
+
speech inside the clip — under the default voice treatment the video model's
|
|
611
|
+
invented voice is then replaced, in place, with the cast member's pinned
|
|
612
|
+
ElevenLabs voice (that is `revoiced: true`). Other treatments leave the render
|
|
613
|
+
owning its own audio, e.g. lip-sync retargeting a VO track onto the video. The
|
|
614
|
+
treatment is frozen on the run when it starts, and there are exactly two doors
|
|
615
|
+
that set one: `--voice-path` on `video start` for a Show run, and
|
|
616
|
+
`--voice-treatment` on `workflow run` for a workflow run (see **Choosing how the
|
|
617
|
+
voices are made**). Node config is never a door — nothing on the canvas sets a
|
|
618
|
+
voice treatment, and a run that was given none renders the default. Read
|
|
619
|
+
`wordsFrom` and `revoiced` per scene rather than assuming.
|
|
503
620
|
|
|
504
621
|
**Word timings** (`scene-NN.words.json`). Seconds from the start of the file
|
|
505
622
|
`wordsFrom` names, always. `wordsFrom: "clip"` is an ElevenLabs Scribe
|
|
@@ -610,6 +727,65 @@ and compare the file before and after.
|
|
|
610
727
|
the CLI says so when that is the case. Pull again, re-cut and upload again before
|
|
611
728
|
anyone approves.
|
|
612
729
|
|
|
730
|
+
## Redoing only the voice on finished clips
|
|
731
|
+
|
|
732
|
+
`revoice` puts clips that are already paid for back through the voice pass: the
|
|
733
|
+
spoken words are checked against the script, the clip is trimmed to the speech,
|
|
734
|
+
and the chosen voice is applied. It makes no new video, so the picture cannot
|
|
735
|
+
change and no video credits are spent. ElevenLabs bills the voice work to the
|
|
736
|
+
member's own key.
|
|
737
|
+
|
|
738
|
+
```
|
|
739
|
+
exodus video revoice <runId> --all
|
|
740
|
+
exodus video revoice <runId> --scene 3
|
|
741
|
+
```
|
|
742
|
+
|
|
743
|
+
**When to reach for it.** `status` shows talking clips with `voice-unpinned` or
|
|
744
|
+
`voice-not-applied` (usually next to `speech-check-skipped`), and the manifest
|
|
745
|
+
shows `revoiced: false` with no words file. The usual cause is a run that had no ElevenLabs key when its
|
|
746
|
+
clips were made. `voices <runId>` says so: "This run has no ElevenLabs key saved".
|
|
747
|
+
|
|
748
|
+
**It finds the key by itself.** Before queuing anything, the server gives the run
|
|
749
|
+
the ElevenLabs key saved on the run owner's account, if the run has none. When
|
|
750
|
+
there is no saved key either, nothing is queued and the refusal says where to add
|
|
751
|
+
one (Settings → Keys). Approving a storyboard does the same top-up, so an older
|
|
752
|
+
run parked at its storyboard is fixed before any clip is paid for.
|
|
753
|
+
|
|
754
|
+
**`--all` picks the clips that need it.** Every finished clip whose file is still
|
|
755
|
+
the video model's own and whose findings carry `voice-unpinned` or
|
|
756
|
+
`voice-not-applied`, the two codes that mean a voice change should have happened
|
|
757
|
+
and did not. `speech-check-skipped` alone does not qualify: it also lands on
|
|
758
|
+
clips whose sound the video model or a later step owns, where there is no voice
|
|
759
|
+
to change and a pass would bill a transcription for nothing. `--scene <n>` is
|
|
760
|
+
not filtered this way; the server decides. It sends one request per scene, tries
|
|
761
|
+
every one, and reports each. Scenes refused with the same sentence share one
|
|
762
|
+
line (a run with no key prints that sentence once); `--json` keeps one entry per
|
|
763
|
+
scene.
|
|
764
|
+
|
|
765
|
+
**What is refused.** A clip that already went through the voice pass (`revoiced`,
|
|
766
|
+
`speechTrimmed` or a trimmed tail): the pass would trim or convert it twice. Use
|
|
767
|
+
`retry-clip` to make that clip again from scratch. A failed clip, a scene already
|
|
768
|
+
being redone, a step still rendering, and an ad already delivered.
|
|
769
|
+
|
|
770
|
+
**An older server starts nothing.** The voice redo has its own server route and
|
|
771
|
+
its own worker task. A server or worker from before it existed cannot run it, and
|
|
772
|
+
the CLI never falls back to `retry-clip`'s route, which would make new paid video.
|
|
773
|
+
You get one sentence saying the server does not have the voice redo yet, and
|
|
774
|
+
nothing is queued or spent.
|
|
775
|
+
|
|
776
|
+
**A clip is never lost.** The clip stays on its row the whole time. If the voice
|
|
777
|
+
pass cannot finish, the clip, its findings and its cost are left as they were and
|
|
778
|
+
one warning, `revoice-not-finished`, is added. Run it again. If ElevenLabs refuses
|
|
779
|
+
the key, the clip comes back unchanged with `voice-not-applied`.
|
|
780
|
+
|
|
781
|
+
**If the word check now fails.** The voiced clip is still delivered, and the scene
|
|
782
|
+
is flagged with the finding, because the picture is the same one the ad already
|
|
783
|
+
had. Decide from `status`: keep it, or `retry-clip` it.
|
|
784
|
+
|
|
785
|
+
**Afterwards.** Pull again. Each voiced scene now has `revoiced: true`, a words
|
|
786
|
+
file, and `rawStorageId` naming the original file. Re-cut and upload again if a
|
|
787
|
+
cut was already uploaded; the CLI says so when that is the case.
|
|
788
|
+
|
|
613
789
|
## Cutting the ad
|
|
614
790
|
|
|
615
791
|
Editing is the part every user does differently, and the team is deliberately
|
|
@@ -8,7 +8,7 @@ Core — discover & run:
|
|
|
8
8
|
exodus workflow list [--json] List the brand's saved workflows
|
|
9
9
|
exodus workflow describe <workflowId|name> [--json] Inputs, prerequisites, outputs
|
|
10
10
|
exodus workflow bots [--category <cat>] [--slug <slug>] [--json] Bot catalog / one bot's spec
|
|
11
|
-
exodus workflow run <workflowId|name> [--input key=value ...] [--fill <name>] [--auto-approve] [--wait] [--out <dir>] [--json] Run it
|
|
11
|
+
exodus workflow run <workflowId|name> [--input key=value ...] [--fill <name>] [--voices <json|@file>] [--voice-treatment <name|json|@file>] [--auto-approve] [--wait] [--out <dir>] [--json] Run it
|
|
12
12
|
exodus workflow status [--id <runId>] [--out <dir>] [--json] Poll a run / read its outputs (no --id → the brand's 25 newest runs + their ids)
|
|
13
13
|
exodus workflow cancel <runId> [--reason "..."] [--json] Stop a run that hasn't finished — queued, running, or parked
|
|
14
14
|
exodus workflow export <workflowId|name> [--version <n>] [--out <file>] [--json] Dump the contract (YAML)
|
|
@@ -51,6 +51,24 @@ Flag notes that add agent-level guidance (everything else is in --help):
|
|
|
51
51
|
earlier upload is relayed as-is. Accepted: image PNG/JPEG/
|
|
52
52
|
WebP/GIF ≤15MB, video MP4/MOV/WebM ≤200MB, audio MP3/M4A/
|
|
53
53
|
WAV/OGG ≤50MB, document PDF/TXT/MD/DOC/DOCX ≤25MB.
|
|
54
|
+
--voices <v> (run, video workflows) Give each speaker in the script an
|
|
55
|
+
ElevenLabs voice from the moment the run starts, instead
|
|
56
|
+
of at the storyboard review. A JSON object keyed by the
|
|
57
|
+
names the SCRIPT uses ("HOST 1"), or @path to a file
|
|
58
|
+
holding one — the same voices.json "exodus video voices
|
|
59
|
+
<run> --from" reads, so a brand keeps one file for both.
|
|
60
|
+
--voice-treatment <v> (run, video workflows) Say HOW the run makes its voices.
|
|
61
|
+
A name on its own picks a way of working that plays a
|
|
62
|
+
voice you already have (and still takes --voices); a JSON
|
|
63
|
+
object, or @path to one, shaped {"path": "...",
|
|
64
|
+
"describe": {"HOST 1": "..."}} has the video model speak
|
|
65
|
+
the lines itself in a voice you WROTE, one description per
|
|
66
|
+
speaker. Written voices and --voices are refused together:
|
|
67
|
+
the ElevenLabs voices would be paid for and never heard.
|
|
68
|
+
Chosen at launch only — the storyboard review can show a
|
|
69
|
+
written voice but cannot change it. Anything the run could
|
|
70
|
+
not do stops the launch before the run exists. The full
|
|
71
|
+
flow is in the exodus-video skill.
|
|
54
72
|
--auto-approve (run) Send this ONE run off unattended: every Checkpoint it
|
|
55
73
|
reaches is approved for you, exactly as it stands, and the
|
|
56
74
|
run records that nobody looked. Without it a Checkpoint
|
|
@@ -145,6 +163,20 @@ Fill every required input. For a free-text brief you can compose a **bespoke bri
|
|
|
145
163
|
|
|
146
164
|
**A brief wired straight into a Storyboard is a script, not a prompt.** On a video workflow whose Storyboard takes its `script` port from a Brief node, the text you pass for that brief boards VERBATIM, the same way a Show ad's pasted script does: one speaker per scene, every line rendered once in order, long lines split across clips, narrated lines rendered silent under the voice track. So it must be in the script form the `exodus-video` skill documents (an optional `CAST:` block of `NAME — description` lines, then `NAME:` turns, then a `CTA:` block; `NARRATOR:` lines only for Narrated Story and Explainer). A brief that is not a script, or one that does not fit the wired rig's family, is refused at launch with the parser's own message and nothing is spent. Write the script to a file and pass it with `--input script=@script.txt`. A Storyboard fed by a bot is unaffected.
|
|
147
165
|
|
|
166
|
+
**A saved rig can bring a rulebook with it.** Some saved rigs carry saved format rules: how many speakers the script may have, which checks run on the clips, how the cast is built. Point a Rig box at one of those rigs and the run follows its rules — an ensemble rig seats more speakers than the usual three, a talking-head rig seats one. The start receipt says which rulebook ran:
|
|
167
|
+
|
|
168
|
+
```
|
|
169
|
+
Format rules: Ensemble Talking Head, version ensemble-e1-provisional-2026-07 (from the "rig_1" Rig box).
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`workflow status` says the same line later, and `video pull` writes the same facts into `manifest.json` under `provenance`. Nothing prints the rules themselves — they are the house's, not the brand's — only whose they were and which version ran.
|
|
173
|
+
|
|
174
|
+
Three things to know before you wire one:
|
|
175
|
+
|
|
176
|
+
- **One run, one rulebook.** If any Rig box's rig carries rules, every Rig box in the workflow has to point at that same saved rig. A second box on a different rig, or a box with its knobs set in place, refuses the launch with a sentence naming every box. Nothing is spent.
|
|
177
|
+
- **Some rulebooks aren't released yet.** A rig that is still being built refuses a member's launch and names itself. Only an admin can start a run on one. Duplicating that rig doesn't get around it — the copy still reads the original's rules, so the answer is the same.
|
|
178
|
+
- **The rig's video model is advice, not a change.** When the rig is normally filmed with a different video model than the one your Video step is set to, the receipt says so in one line and the run uses YOUR setting. Change the Video step yourself if you want the rig's.
|
|
179
|
+
|
|
148
180
|
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:
|
|
149
181
|
|
|
150
182
|
```bash
|
package/dist/bin/exodus.js
CHANGED
|
@@ -5,6 +5,7 @@ import { readdirSync } from "node:fs";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { dirname, join } from "node:path";
|
|
7
7
|
import { getVersion } from "../lib/version.js";
|
|
8
|
+
import { parseArgs } from "../lib/args.js";
|
|
8
9
|
const OTHER_COMMANDS = new Set([
|
|
9
10
|
"browse",
|
|
10
11
|
"status",
|
|
@@ -113,42 +114,8 @@ async function buildHelp() {
|
|
|
113
114
|
];
|
|
114
115
|
return sections.filter((s) => s.trim() !== "").join("\n\n");
|
|
115
116
|
}
|
|
116
|
-
function parseArgs(argv) {
|
|
117
|
-
const [, , rawCommand = "help", ...rest] = argv;
|
|
118
|
-
const command = ["--help", "-h", "-help"].includes(rawCommand) ? "help" : rawCommand;
|
|
119
|
-
const flags = {};
|
|
120
|
-
let i = 0;
|
|
121
|
-
while (i < rest.length) {
|
|
122
|
-
const arg = rest[i];
|
|
123
|
-
if (arg === "--help" || arg === "-h") {
|
|
124
|
-
flags["help"] = true;
|
|
125
|
-
i++;
|
|
126
|
-
continue;
|
|
127
|
-
}
|
|
128
|
-
if (arg.startsWith("--")) {
|
|
129
|
-
const key = arg.slice(2);
|
|
130
|
-
const next = rest[i + 1];
|
|
131
|
-
if (key.startsWith("no-")) {
|
|
132
|
-
flags[key.slice(3)] = false;
|
|
133
|
-
i++;
|
|
134
|
-
}
|
|
135
|
-
else if (next !== undefined && !next.startsWith("--")) {
|
|
136
|
-
flags[key] = next;
|
|
137
|
-
i += 2;
|
|
138
|
-
}
|
|
139
|
-
else {
|
|
140
|
-
flags[key] = true;
|
|
141
|
-
i++;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
else {
|
|
145
|
-
i++;
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
return { command, flags };
|
|
149
|
-
}
|
|
150
117
|
async function main() {
|
|
151
|
-
const { command, flags } = parseArgs(process.argv);
|
|
118
|
+
const { command, flags, occurrences } = parseArgs(process.argv);
|
|
152
119
|
if (["--version", "-v", "-V", "version"].includes(command)) {
|
|
153
120
|
console.log(getVersion());
|
|
154
121
|
process.exit(0);
|
|
@@ -180,7 +147,7 @@ async function main() {
|
|
|
180
147
|
console.error(`Command "${command}" does not export a run() function.`);
|
|
181
148
|
process.exit(1);
|
|
182
149
|
}
|
|
183
|
-
await commandModule.run(flags);
|
|
150
|
+
await commandModule.run(flags, occurrences);
|
|
184
151
|
}
|
|
185
152
|
main().catch((err) => {
|
|
186
153
|
console.error("Fatal error:", err instanceof Error ? err.message : String(err));
|
package/dist/commands/video.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { type ApiResponse } from "../lib/client.js";
|
|
2
|
+
import type { FlagOccurrence } from "../lib/args.js";
|
|
3
|
+
import { type RunProvenance } from "./workflow.js";
|
|
2
4
|
export declare const helpText: string;
|
|
3
5
|
export declare const VOICES_PATH = "/api/v2/video/voices";
|
|
4
6
|
export interface ClipWord {
|
|
@@ -43,6 +45,8 @@ export type ArtifactSubset = {
|
|
|
43
45
|
final?: boolean;
|
|
44
46
|
revoiced?: boolean;
|
|
45
47
|
speechTrimmed?: boolean;
|
|
48
|
+
tailTrimmed?: boolean;
|
|
49
|
+
rawStorageId?: string;
|
|
46
50
|
} | {
|
|
47
51
|
type: "audio";
|
|
48
52
|
sceneIndex?: number;
|
|
@@ -97,6 +101,7 @@ export interface VideoRun {
|
|
|
97
101
|
pausedNodeId?: string;
|
|
98
102
|
nodes: VideoRunNode[];
|
|
99
103
|
castLock?: PullCastLock | null;
|
|
104
|
+
provenance?: RunProvenance;
|
|
100
105
|
workflowId?: string;
|
|
101
106
|
moduleOwned?: boolean;
|
|
102
107
|
}
|
|
@@ -202,6 +207,7 @@ export interface ManifestScene {
|
|
|
202
207
|
qc: ClipQc | null;
|
|
203
208
|
revoiced: boolean | null;
|
|
204
209
|
speechTrimmed: boolean | null;
|
|
210
|
+
rawStorageId: string | null;
|
|
205
211
|
clipStatus: string;
|
|
206
212
|
error: string | null;
|
|
207
213
|
flagged: boolean;
|
|
@@ -221,6 +227,7 @@ export interface ManifestCastRef {
|
|
|
221
227
|
error: string | null;
|
|
222
228
|
voiceId: string | null;
|
|
223
229
|
voiceLabel: string | null;
|
|
230
|
+
voiceDescription: string | null;
|
|
224
231
|
}
|
|
225
232
|
export interface VideoManifest {
|
|
226
233
|
runId: string;
|
|
@@ -236,6 +243,7 @@ export interface VideoManifest {
|
|
|
236
243
|
} | null;
|
|
237
244
|
scenes: ManifestScene[];
|
|
238
245
|
failed: PullFailure[];
|
|
246
|
+
provenance?: RunProvenance;
|
|
239
247
|
}
|
|
240
248
|
export declare const CAST_LEDGER_BASE = 910000;
|
|
241
249
|
export interface PullPlan {
|
|
@@ -288,6 +296,11 @@ export interface ClipRedoTarget {
|
|
|
288
296
|
}
|
|
289
297
|
export declare function planClipRedo(run: VideoRun, items: NodeItem[], target: ClipRedoTarget): ClipRedoPlan;
|
|
290
298
|
export declare function retryClipFlow(runId: string, target: ClipRedoTarget, note: string | undefined, json: boolean, deps: VideoDeps): Promise<FlowResult>;
|
|
299
|
+
export declare function planClipRevoice(run: VideoRun, items: NodeItem[], target: ClipRedoTarget): ClipRedoPlan;
|
|
300
|
+
export type RevoiceTarget = ClipRedoTarget | {
|
|
301
|
+
all: true;
|
|
302
|
+
};
|
|
303
|
+
export declare function revoiceFlow(runId: string, target: RevoiceTarget, json: boolean, deps: VideoDeps): Promise<FlowResult>;
|
|
291
304
|
export interface CastVoiceRow {
|
|
292
305
|
characterId: string;
|
|
293
306
|
name: string;
|
|
@@ -295,6 +308,7 @@ export interface CastVoiceRow {
|
|
|
295
308
|
voiceId: string;
|
|
296
309
|
label?: string;
|
|
297
310
|
} | null;
|
|
311
|
+
voiceFrom?: "own-pin" | "run-default";
|
|
298
312
|
availability?: {
|
|
299
313
|
state: "available";
|
|
300
314
|
providerName: string;
|
|
@@ -304,6 +318,7 @@ export interface CastVoiceRow {
|
|
|
304
318
|
state: "not-checked";
|
|
305
319
|
why: string;
|
|
306
320
|
};
|
|
321
|
+
description?: string;
|
|
307
322
|
spokenScenes: number;
|
|
308
323
|
spokenSeconds: number;
|
|
309
324
|
}
|
|
@@ -317,6 +332,9 @@ export interface CastVoiceSheet {
|
|
|
317
332
|
label?: string;
|
|
318
333
|
} | null;
|
|
319
334
|
treatment: {
|
|
335
|
+
kind?: string;
|
|
336
|
+
path?: string | null;
|
|
337
|
+
usesElevenLabs?: boolean;
|
|
320
338
|
summary: string;
|
|
321
339
|
provider: string;
|
|
322
340
|
model: string;
|
|
@@ -334,7 +352,7 @@ export type VoiceMap = Record<string, string | {
|
|
|
334
352
|
voiceId: string;
|
|
335
353
|
label?: string;
|
|
336
354
|
} | null>;
|
|
337
|
-
export declare function parseVoiceFlags(
|
|
355
|
+
export declare function parseVoiceFlags(occurrences: FlagOccurrence[], readFile: (path: string) => string): VoiceMap | null;
|
|
338
356
|
export declare function voicesFlow(runId: string, voices: VoiceMap | null, json: boolean, deps: VideoDeps): Promise<FlowResult>;
|
|
339
357
|
export declare function voiceSheetLines(sheet: CastVoiceSheet): string[];
|
|
340
358
|
export declare function pullFlow(runId: string, dir: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
|
|
@@ -343,4 +361,4 @@ export declare const NO_DURATION_MESSAGE: string;
|
|
|
343
361
|
export declare function describeFetchFailure(err: unknown): string;
|
|
344
362
|
export declare function uploadFlow(runId: string, filePath: string, durationFlag: string | undefined, json: boolean, deps: VideoDeps): Promise<FlowResult>;
|
|
345
363
|
export declare function parsePositional(args?: string[]): string[];
|
|
346
|
-
export declare function run(flags: Record<string, string | boolean
|
|
364
|
+
export declare function run(flags: Record<string, string | boolean>, occurrences: FlagOccurrence[]): Promise<void>;
|