@aicopycoders/exodus 2026.9.2000-beta.0 → 2026.9.2000-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 +5 -9
- package/assets/skills/exodus-video/SKILL.md +105 -154
- package/assets/skills/exodus-video/scripts/first-cut.mjs +17 -30
- package/dist/commands/genesis.js +12 -133
- package/dist/commands/status.js +8 -18
- package/dist/commands/video.d.ts +15 -1
- package/dist/commands/video.js +75 -37
- package/dist/commands/workflow.js +3 -2
- 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
|
|
64
|
+
| **Video** | `exodus-video` | Make a video ad from a script by running a saved video workflow (`npx @aicopycoders/exodus workflow run`). 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
|
@@ -16,7 +16,7 @@ In Claude Code you don't click buttons. You say **"exodus"** plus what you want
|
|
|
16
16
|
| exodus-meme | Meme Ad Generator — recommend formats, one batched server-side run |
|
|
17
17
|
| exodus-workflow | Run/build saved multi-node workflows, resolve inbox gate/repair parks, continue sessions, fire triggers, read banks + promote winners |
|
|
18
18
|
| exodus-hooks | Read the Scout library of hook cards — composable filters, one-card detail, pattern matches, CSV export (read-only) |
|
|
19
|
-
| exodus-video | Make a video ad from a
|
|
19
|
+
| exodus-video | Make a video ad from a script by running a saved video workflow — storyboard gate, pull every piece + manifest, cut it in whatever editor you use, upload, approve (admin-only) |
|
|
20
20
|
| exodus-browse | View History and Surface the Right Run |
|
|
21
21
|
| exodus-drive | Google Drive, Docs, Sheets via the Dashboard's OAuth |
|
|
22
22
|
| exodus-winners | Mine your own Meta ad account for winners (needs the Meta Ads MCP) and import them into Exodus |
|
|
@@ -252,24 +252,20 @@ Returns: a table, --json data, or an exported CSV/JSON file
|
|
|
252
252
|
|
|
253
253
|
## exodus-video
|
|
254
254
|
|
|
255
|
-
**What it does:** Makes a video ad from a script, in pieces.
|
|
255
|
+
**What it does:** Makes a video ad from a script, in pieces. You run a saved video workflow (`exodus workflow run <name> --input <field>=@script.txt`). 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`. 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.
|
|
256
256
|
|
|
257
|
-
**Voices
|
|
257
|
+
**Voices.** 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 `--voice-treatment` 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
258
|
|
|
259
259
|
```operator-guide
|
|
260
|
-
Start
|
|
260
|
+
Start:
|
|
261
261
|
exodus workflow list / describe <name> find it and see what it needs
|
|
262
262
|
exodus workflow run <workflowId|name> --input <field>=@script.txt [--wait] start it; --wait stops at the storyboard gate
|
|
263
263
|
exodus workflow run <workflowId|name> --input <field>=@script.txt --voices @voices.json ... and give each speaker an ElevenLabs voice up front
|
|
264
264
|
exodus workflow run <workflowId|name> --input <field>=@script.txt --voice-treatment @voice-treatment.json ... or have the video model speak voices you WROTE
|
|
265
|
-
|
|
266
|
-
exodus video shows [--json] Shows and whether each is ready
|
|
267
|
-
exodus video start --show <id> --script <file> [--wait] [--json] start an ad run; --wait stops at the storyboard gate
|
|
268
|
-
Then, on either route:
|
|
265
|
+
Then:
|
|
269
266
|
exodus video storyboard <runId> [--json] the scene cards
|
|
270
267
|
exodus video voices <runId> [--set <who>=<voiceId>] [--clear <who>] [--from <f>] [--json] who speaks, with whose voice
|
|
271
268
|
exodus video approve <runId> [--json] approve the storyboard, or the uploaded cut
|
|
272
|
-
exodus video flag <runId> --note "<what is wrong>" [--json] send the storyboard back — SHOW RUNS ONLY
|
|
273
269
|
exodus video retry-frame <runId> --node <nodeId> --scene <n> [--note "<t>"] [--json] redo one still at the pixel gate
|
|
274
270
|
exodus video status <runId> [--json] the park and each scene's clip/voice/picture
|
|
275
271
|
exodus video pull <runId> --out <dir> [--json] every piece + manifest.json
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: exodus-video
|
|
3
|
-
description: Make a video ad with Exodus from the terminal. The main path is script-first: run a saved video workflow with `npx @aicopycoders/exodus workflow run
|
|
3
|
+
description: Make a video ad with Exodus from the terminal. The main path is script-first: run a saved video workflow with `npx @aicopycoders/exodus workflow run`. You read and approve the storyboard, pull every finished piece (per-scene clips, the narration track and per-scene voice, keyframes, cast identity stills, word timings, the music bed) into a folder with `exodus video pull`, and upload a finished cut for approval. Exodus makes the pieces; the cut is made outside it, in whatever editor the user wants — this skill hands over the pieces and the facts about them, it does not prescribe an editor. Use it whenever the user has invoked Exodus and wants anything to do with a video ad, an ad run's clips or storyboard, "pull the pieces", "stitch it together", "make the cut", "upload my cut", or checking on a video run ("exodus, make a video ad from this script", "exodus, is my video run done", "exodus, pull the clips for run X and cut them", "exodus, what's wrong with scene 3", "exodus video status"). Also use it when the user ran an `npx @aicopycoders/exodus video` command or the `exodus` hub skill routed here. Video is admin-only: "video isn't enabled for this key" means either the dashboard user lacks the admin role or the run id is wrong, so check the id and then say so; do not retry. Never claim generic video-editing asks ("edit this mp4", "add captions to my reel") without Exodus context; in shared folders those belong to the user's other tools. Running or authoring workflows generally is `exodus-workflow`; static image ads are `exodus-image`; copy is `exodus-write`.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Video: make the pieces, cut the ad, hand it back
|
|
@@ -21,29 +21,18 @@ Your job in this skill is to drive that loop, hand over the pieces with the
|
|
|
21
21
|
facts about them, and make whatever cut the user asked for. **How to edit is the
|
|
22
22
|
user's call, not this skill's** — see "Cutting the ad".
|
|
23
23
|
|
|
24
|
-
##
|
|
24
|
+
## How a run starts
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
**The Show route** starts from a locked Show — its cast, rooms and voices set up
|
|
31
|
-
on the dashboard beforehand — with `exodus video start --show`. It buys two
|
|
32
|
-
things the workflow route does not have: `video flag` for sending a storyboard
|
|
33
|
-
back with a note, and script-turn edits at the gate.
|
|
34
|
-
|
|
35
|
-
Everything after the storyboard is the same on both: the same nodes render, the
|
|
36
|
-
same `video status` reads the run, the same `video pull` writes the same folder,
|
|
37
|
-
the same `video upload` and `approve` finish it. Where a behaviour differs, this
|
|
38
|
-
skill says which route it belongs to. **Do not assume the routes are symmetric**
|
|
39
|
-
— see "What each route can and cannot do".
|
|
26
|
+
The ad is a saved workflow in the brand, started script-first with `exodus
|
|
27
|
+
workflow run`. From there the same nodes render, `video status` reads the run,
|
|
28
|
+
`video pull` writes the folder, and `video upload` and `approve` finish it.
|
|
40
29
|
|
|
41
30
|
## Before anything
|
|
42
31
|
|
|
43
32
|
- `npx @aicopycoders/exodus video --help` and `workflow --help` are the
|
|
44
33
|
authoritative flag lists. The `video` verb is admin-only and hidden from the
|
|
45
34
|
top-level `--help`; that is expected, not a broken install.
|
|
46
|
-
- **Video is admin-only
|
|
35
|
+
- **Video is admin-only**, and says so two different ways.
|
|
47
36
|
- "video isn't enabled for this key", from a `video` verb, has two causes the
|
|
48
37
|
CLI cannot tell apart: the user is not an admin on this brand, or the run id
|
|
49
38
|
does not exist. Check the id first; if it is right, they need the admin role.
|
|
@@ -58,17 +47,17 @@ skill says which route it belongs to. **Do not assume the routes are symmetric**
|
|
|
58
47
|
you must pass `--duration <sec>`. Editing tools have their own requirements on
|
|
59
48
|
top of that — the bundled script needs a full `ffmpeg` with `libx264` and
|
|
60
49
|
exits 2 if it is missing.
|
|
61
|
-
- The brand matters. A workflow
|
|
62
|
-
|
|
63
|
-
|
|
50
|
+
- The brand matters. A workflow belongs to one brand, so `exodus workflow list`
|
|
51
|
+
shows the active brand's only. Wrong list means wrong brand; `exodus brand use
|
|
52
|
+
<slug>`.
|
|
64
53
|
|
|
65
54
|
## The loop
|
|
66
55
|
|
|
67
56
|
Run each line as `npx @aicopycoders/exodus …`; `exodus` below is shorthand.
|
|
68
57
|
|
|
69
|
-
**Starting
|
|
70
|
-
|
|
71
|
-
|
|
58
|
+
**Starting a run.** The workflow is already saved in the brand — find it and
|
|
59
|
+
read what it wants before you run it, because the script's input field is named
|
|
60
|
+
by the workflow, not by this skill.
|
|
72
61
|
|
|
73
62
|
```operator-guide
|
|
74
63
|
exodus workflow list the brand's workflows
|
|
@@ -82,21 +71,13 @@ exodus workflow run <workflowId|name> --input <field>=@script.txt --voice-treatm
|
|
|
82
71
|
exodus video status <runId> where it is, per scene
|
|
83
72
|
```
|
|
84
73
|
|
|
85
|
-
**
|
|
86
|
-
|
|
87
|
-
```operator-guide
|
|
88
|
-
exodus video shows which Shows are ready
|
|
89
|
-
exodus video start --show <id> --script script.txt --wait start, wait for the storyboard gate
|
|
90
|
-
```
|
|
91
|
-
|
|
92
|
-
**From the storyboard on, both routes are the same.**
|
|
74
|
+
**From the storyboard on.**
|
|
93
75
|
|
|
94
76
|
```operator-guide
|
|
95
77
|
exodus video storyboard <runId> read the scene cards
|
|
96
78
|
exodus video voices <runId> who speaks and with whose voice
|
|
97
79
|
exodus video voices <runId> --set <character>=<voiceId> give a character a voice
|
|
98
80
|
exodus video approve <runId> keep going
|
|
99
|
-
exodus video flag <runId> --note "what's wrong" send it back — SHOW RUNS ONLY
|
|
100
81
|
exodus video retry-frame <runId> --node <nodeId> --scene <n> redo one still at the pixel gate
|
|
101
82
|
exodus video status <runId> where it is, per scene
|
|
102
83
|
exodus video pull <runId> --out ./ad-<runId> every piece + manifest.json
|
|
@@ -111,31 +92,35 @@ exodus video approve <runId> make it the ad
|
|
|
111
92
|
Every command takes `--json`. Read `--json` when you need to decide something
|
|
112
93
|
from the output; read the plain form when you are relaying it to the user.
|
|
113
94
|
|
|
114
|
-
**Read a run's position with `exodus video status
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
"What each route can and cannot do".
|
|
95
|
+
**Read a run's position with `exodus video status`.** It is the richest reader —
|
|
96
|
+
every scene's clip, voice and picture, with the findings. A `--wait` that landed
|
|
97
|
+
already printed the same stop block, and `workflow inbox` lists a parked video
|
|
98
|
+
run, but neither shows the per-scene detail, and the checkpoint verbs cannot
|
|
99
|
+
resolve a video park — see "What the CLI can and cannot do".
|
|
120
100
|
|
|
121
101
|
**Where a run can stop.** Five stops, each with the exact next command printed
|
|
122
102
|
under it:
|
|
123
103
|
|
|
124
|
-
- storyboard gate: needs `approve
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
104
|
+
- storyboard gate: needs `approve`. When the stop block prints a `Redo one
|
|
105
|
+
frame:` line with a node id, one still can be redone first with `retry-frame`;
|
|
106
|
+
a gate parked before the stills are drawn has nothing to redraw.
|
|
107
|
+
- final watch: every piece is made; needs `pull`, a cut, and `upload`. Once a
|
|
108
|
+
cut is uploaded the stop block leads with `approve` instead, and `status
|
|
109
|
+
--json` says which state it is in (`stop.cutAttached`, plus the same next-step
|
|
110
|
+
lines under `guidance`). It is also
|
|
111
|
+
the one park where a finished clip can be redone, with `retry-clip`. A run
|
|
112
|
+
only reaches this park if its video node sets `finalWatch: true` — otherwise
|
|
113
|
+
it skips straight to finished.
|
|
129
114
|
- paused: a builder checkpoint neither video verb resolves —
|
|
130
115
|
`exodus workflow checkpoint <runId>` is the verb for that one.
|
|
131
116
|
- failed: `status` shows how far it got, and a `--wait` that was running exits
|
|
132
117
|
1 with the error. Tell the user; do not start another run on your own.
|
|
133
118
|
- finished: pull what it made.
|
|
134
119
|
|
|
135
|
-
**`--wait` stops at both video parks
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
120
|
+
**`--wait` stops at both video parks** (#1818). `workflow run --wait` ends at
|
|
121
|
+
the storyboard gate and again at the final watch, exits 0, and closes with the
|
|
122
|
+
same `Parked:` block and next commands that `exodus video status` prints. Under
|
|
123
|
+
`--json` the line carries an
|
|
139
124
|
additive `stop` field in the same shape `video status --json` uses, so branch on
|
|
140
125
|
`stop.at` rather than re-deriving the park. Every other park still prints its
|
|
141
126
|
notice once and keeps polling, so a resolve from another shell carries the run
|
|
@@ -155,10 +140,10 @@ part: the voices and the clips. So a run that has not paused yet is already
|
|
|
155
140
|
spending a little, and a run still drawing pictures is healthy. `exodus workflow
|
|
156
141
|
status` and `exodus video status` both print "This run will pause for your
|
|
157
142
|
approval once the frames are ready" on a run that is headed for the gate. Do not
|
|
158
|
-
cancel it because the pause has not arrived. `
|
|
143
|
+
cancel it because the pause has not arrived. `workflow list`, `workflow describe`, `video status`,
|
|
159
144
|
`storyboard` and `pull` are free reads; run them as often as you like. Never
|
|
160
|
-
start a second run to "retry" without the user asking;
|
|
161
|
-
|
|
145
|
+
start a second run to "retry" without the user asking; tell them what failed
|
|
146
|
+
instead.
|
|
162
147
|
|
|
163
148
|
**`--auto-approve` does not cover either video gate.** It releases Checkpoint
|
|
164
149
|
boxes only (`convex/workflows.ts:7266` keys on `pauseReason === "checkpoint"`),
|
|
@@ -167,30 +152,11 @@ and the storyboard gate and the final watch carry no pause reason. An unattended
|
|
|
167
152
|
safe behaviour, since nothing should approve paid rendering or ship an uncut ad
|
|
168
153
|
on its own.
|
|
169
154
|
|
|
170
|
-
## What
|
|
171
|
-
|
|
172
|
-
Verified against the
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|---|---|---|
|
|
176
|
-
| `video shows` | nothing to list | lists the brand's Shows |
|
|
177
|
-
| `video start --show` | not the entry point | starts the run |
|
|
178
|
-
| `workflow run` | starts the run | not the entry point |
|
|
179
|
-
| `video status` | works | works |
|
|
180
|
-
| `video storyboard` | works | works |
|
|
181
|
-
| `video voices` (read) | works | works |
|
|
182
|
-
| `video voices --set` | works | **refused** — the Show owns its voices |
|
|
183
|
-
| `video approve` | works, both gates | works, both gates |
|
|
184
|
-
| `video flag --note` | **refused** | works |
|
|
185
|
-
| script-turn edits at the gate | **refused** | dashboard only |
|
|
186
|
-
| `video retry-frame` | works | works |
|
|
187
|
-
| `video retry-clip` | works | works |
|
|
188
|
-
| `video revoice` | works | works |
|
|
189
|
-
| `video pull` | works | works |
|
|
190
|
-
| `video upload` | only if the video node sets `finalWatch: true` | works |
|
|
191
|
-
| `workflow checkpoint approve` | **refused at a video gate** | **refused at a video gate** |
|
|
192
|
-
|
|
193
|
-
Five consequences worth knowing before you promise a user anything:
|
|
155
|
+
## What the CLI can and cannot do
|
|
156
|
+
|
|
157
|
+
Verified against the CLI and the server, not assumed. Everything here is
|
|
158
|
+
admin-gated on top. Five things worth knowing before you promise a user
|
|
159
|
+
anything:
|
|
194
160
|
|
|
195
161
|
- **A workflow only stops for your cut if its video node asks to.** `finalWatch`
|
|
196
162
|
defaults to `false` (`convex/lib/workflow/catalog.ts:780`), and the run only
|
|
@@ -210,22 +176,20 @@ Five consequences worth knowing before you promise a user anything:
|
|
|
210
176
|
`pauseReason === "checkpoint"` (`exodus/commands/workflow.ts:4487-4519`), and
|
|
211
177
|
both video gates park with no pause reason at all, so those verbs refuse
|
|
212
178
|
client-side with "Run … is not parked for a checkpoint approval — it is parked
|
|
213
|
-
at the cost gate (legacy)." `exodus video approve` posts straight through
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
script, or edit the workflow. Do not offer `flag` to a user on the workflow
|
|
228
|
-
route.
|
|
179
|
+
at the cost gate (legacy)." `exodus video approve` posts straight through.
|
|
180
|
+
`exodus workflow cancel` also works at a video gate; it has no preflight.
|
|
181
|
+
|
|
182
|
+
- **Nothing sends the storyboard back with a note.** There is **no** CLI path
|
|
183
|
+
that returns a run's storyboard for a rewrite, and none that rewrites its
|
|
184
|
+
scenes, script or prompts — the whole-envelope edit mutation is wired only to
|
|
185
|
+
the dashboard's gate screen, not to any API route. The one exception is
|
|
186
|
+
voices: `exodus video voices --set` writes the cast's voice choices in place
|
|
187
|
+
and nothing else (see "Giving the characters voices"). A single still can be
|
|
188
|
+
redrawn with `exodus video retry-frame`. For everything else the storyboard is
|
|
189
|
+
approve-or-cancel from the terminal: `exodus workflow cancel <runId>` and run
|
|
190
|
+
again with a better script, or edit the workflow.
|
|
191
|
+
The stop block a parked run prints may still offer a `Send back:` line. A
|
|
192
|
+
workflow ad refuses it, so never run it and never offer it to the user.
|
|
229
193
|
- **`--wait` lands on both video parks, but only those.** Since #1818 every
|
|
230
194
|
`--wait` caller — `workflow run`, `triggers fire`, `checkpoint approve|retry`,
|
|
231
195
|
`repair retry|skip` — ends at a storyboard gate or a final watch, exits 0, and
|
|
@@ -234,20 +198,16 @@ Five consequences worth knowing before you promise a user anything:
|
|
|
234
198
|
slots park is still not a landing: it prints its notice once and the loop
|
|
235
199
|
keeps polling, so a resolve from another shell carries the run on. Do not read
|
|
236
200
|
a park banner mid-poll as the wait having finished.
|
|
237
|
-
- **
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
link in every park block and the link `upload` prints are all minted that way,
|
|
243
|
-
so they agree on both routes. When all the CLI holds is a run id it mints
|
|
244
|
-
`/runs/<runId>`, the canonical forwarder, which lands on the same page.
|
|
201
|
+
- **Every link the CLI prints follows the run.** A run opens on
|
|
202
|
+
`/workflows/<workflowId>/runs/<runId>`. Since #1851 the manifest's
|
|
203
|
+
`dashboardUrl`, the link in every park block and the link `upload` prints are
|
|
204
|
+
all minted that way, so they agree. When all the CLI holds is a run id it
|
|
205
|
+
mints `/runs/<runId>`, the canonical forwarder, which lands on the same page.
|
|
245
206
|
|
|
246
207
|
## The script file
|
|
247
208
|
|
|
248
209
|
Plain text. A `CAST:` block naming the speakers, one line per turn, then a
|
|
249
|
-
`CTA:` block.
|
|
250
|
-
planner sizes every line at
|
|
210
|
+
`CTA:` block. The planner sizes every line at
|
|
251
211
|
2.5 words per second (`WORD_LADDER_WPS`), so a turn near 14 words plans a clip
|
|
252
212
|
near 6 seconds. If a line does get truncated in its clip, the manifest says so
|
|
253
213
|
(`speech-cutoff`).
|
|
@@ -273,25 +233,23 @@ in `manifest.json` under `provenance`.
|
|
|
273
233
|
|
|
274
234
|
## Reading the storyboard gate
|
|
275
235
|
|
|
276
|
-
`storyboard <runId>` prints one card per scene
|
|
236
|
+
`storyboard <runId>` prints one card per scene: who speaks, the
|
|
277
237
|
line, the planned duration, and whether its picture is drawn. Under the cards it
|
|
278
238
|
also lists the voices — one line per character who speaks or already has a voice
|
|
279
239
|
— so the user can see who sounds like whom before paying for the clips. Read it
|
|
280
240
|
for the user, then ask one question.
|
|
281
241
|
|
|
282
|
-
|
|
283
|
-
the
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
242
|
+
The question is approve or don't. If the storyboard is wrong, say so plainly and
|
|
243
|
+
let the user decide between approving anyway, redrawing a single still when
|
|
244
|
+
the stop block offers it (`exodus video retry-frame <runId> --node <nodeId>
|
|
245
|
+
--scene <n>`, with the node id that block prints), cancelling
|
|
246
|
+
(`exodus workflow cancel <runId>`) and re-running with a better script, or
|
|
247
|
+
changing the workflow. Approving is what releases the voices and the clips — the
|
|
248
|
+
expensive part — so do not approve a storyboard you have just told the user is
|
|
249
|
+
wrong.
|
|
288
250
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
("scene 2 should be a close-up of the softgel, not the eyes").
|
|
292
|
-
|
|
293
|
-
Either way, do not approve on the user's behalf unless they told you to run the
|
|
294
|
-
whole loop unattended.
|
|
251
|
+
Do not approve on the user's behalf unless they told you to run the whole loop
|
|
252
|
+
unattended.
|
|
295
253
|
|
|
296
254
|
## Giving the characters voices
|
|
297
255
|
|
|
@@ -374,7 +332,7 @@ nobody knows the speaker names yet. When the storyboard arrives, the voices are
|
|
|
374
332
|
already set and `exodus video voices <runId>` shows them.
|
|
375
333
|
|
|
376
334
|
**Choosing how the voices are made.** `--voice-treatment` on `workflow run` says
|
|
377
|
-
HOW a run makes its voices, and it is the only door
|
|
335
|
+
HOW a run makes its voices, and it is the only door. Two
|
|
378
336
|
kinds of answer.
|
|
379
337
|
|
|
380
338
|
A name on its own picks a way of working that plays a voice you already have,
|
|
@@ -447,8 +405,7 @@ person made at a keyboard for one run, and nobody made it for the sub-workflow.
|
|
|
447
405
|
**Written voices cannot be changed at the review.** They are chosen when the run
|
|
448
406
|
starts. `exodus video voices <runId>` on a described run names the treatment and
|
|
449
407
|
prints each written voice, and says plainly that `--voice-treatment` at launch is
|
|
450
|
-
what changes them. Do not
|
|
451
|
-
voice path in node config — neither is a door.
|
|
408
|
+
what changes them. Do not set a voice path in node config — it is not a door.
|
|
452
409
|
|
|
453
410
|
**Where it shows up afterwards.** The launch receipt and `exodus workflow status`
|
|
454
411
|
both print one line naming the treatment and the speakers who got a written
|
|
@@ -524,9 +481,7 @@ manifest.json the index below
|
|
|
524
481
|
|
|
525
482
|
```
|
|
526
483
|
runId, pulledAt, dashboardUrl the run and the page that opens it:
|
|
527
|
-
/
|
|
528
|
-
/workflows/<workflowId>/runs/<runId> for a
|
|
529
|
-
workflow run
|
|
484
|
+
/workflows/<workflowId>/runs/<runId>
|
|
530
485
|
storyboard, reference, music filenames, or null when not delivered
|
|
531
486
|
narration { file, timing }, or null on a per-scene run
|
|
532
487
|
cast[] one per identity still
|
|
@@ -564,8 +519,7 @@ provenance which saved rulebook this run followed:
|
|
|
564
519
|
{format: {via, nodeId, rigId, rigName,
|
|
565
520
|
rulesFrom, specVersion}}. Absent when the
|
|
566
521
|
run followed none. `nodeId` is the Rig box
|
|
567
|
-
that chose it
|
|
568
|
-
has no box); `rulesFrom` names the rig the
|
|
522
|
+
that chose it; `rulesFrom` names the rig the
|
|
569
523
|
rules were borrowed from when the picked
|
|
570
524
|
rig is a copy. Names, ids and a version
|
|
571
525
|
only — never the rules themselves
|
|
@@ -588,12 +542,10 @@ it is a picture-only takeover of the frame while the spoken track underneath
|
|
|
588
542
|
keeps running, so it adds no audio and takes no place in the running order. The
|
|
589
543
|
envelope also carries `overlays[]` (each with a `cueLineId` and a `placement`)
|
|
590
544
|
and `audio` (`roomTone`, `music`, `sfxCues[]`) — the planner's notes on what
|
|
591
|
-
should sit on top.
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
(`convex/workflows.ts
|
|
595
|
-
no `showId`, so an admin key gets them in full. Their absence on a Show run is
|
|
596
|
-
by design, not a broken pull.
|
|
545
|
+
should sit on top. The image and motion prompts (`framePrompt`, `videoPrompt`,
|
|
546
|
+
`referencePrompt`) reach an admin key in full. A caller who cannot use video on
|
|
547
|
+
this brand gets them stripped before the file arrives — they are style-pack IP
|
|
548
|
+
(`redactVideoIp` in `convex/workflows.ts`).
|
|
597
549
|
|
|
598
550
|
**The narration track** (`narration.mp3` + `narration.json`). Present when the
|
|
599
551
|
run recorded continuously: every narrated scene's `voText` joined into one
|
|
@@ -605,8 +557,7 @@ narration.mp3**. Runs go per-scene instead when any scene has on-camera
|
|
|
605
557
|
dialogue, when the voiceover node is configured `take: "per-scene"`, or on an
|
|
606
558
|
older storyboard carrying no `script[]`; then there is no `narration.mp3` and
|
|
607
559
|
each scene's voice is its own recording. That choice is made from the storyboard
|
|
608
|
-
and the node config (`planNarration`)
|
|
609
|
-
Show run reach it the same way.
|
|
560
|
+
and the node config (`planNarration`).
|
|
610
561
|
|
|
611
562
|
**The per-scene voice tracks** (`scene-NN.voice.<ext>`). On a continuous run
|
|
612
563
|
these are cut out of `narration.mp3` itself — the same audio, sliced at the
|
|
@@ -621,9 +572,8 @@ speech inside the clip — under the default voice treatment the video model's
|
|
|
621
572
|
invented voice is then replaced, in place, with the cast member's pinned
|
|
622
573
|
ElevenLabs voice (that is `revoiced: true`). Other treatments leave the render
|
|
623
574
|
owning its own audio, e.g. lip-sync retargeting a VO track onto the video. The
|
|
624
|
-
treatment is frozen on the run when it starts, and there
|
|
625
|
-
that
|
|
626
|
-
`--voice-treatment` on `workflow run` for a workflow run (see **Choosing how the
|
|
575
|
+
treatment is frozen on the run when it starts, and there is exactly one door
|
|
576
|
+
that sets it: `--voice-treatment` on `workflow run` (see **Choosing how the
|
|
627
577
|
voices are made**). Node config is never a door — nothing on the canvas sets a
|
|
628
578
|
voice treatment, and a run that was given none renders the default. Read
|
|
629
579
|
`wordsFrom` and `revoiced` per scene rather than assuming.
|
|
@@ -661,8 +611,8 @@ finding says `wrong-character`.
|
|
|
661
611
|
generated by Eleven Music after the last clip lands, at the landed clips' summed
|
|
662
612
|
length plus a 3-second safety pad. One track per ad, never per-scene. It is
|
|
663
613
|
never mixed into any clip and never levelled — gain, fade and ducking are the
|
|
664
|
-
cut-maker's decisions.
|
|
665
|
-
|
|
614
|
+
cut-maker's decisions. A music failure degrades to a warning rather than
|
|
615
|
+
blocking the run.
|
|
666
616
|
|
|
667
617
|
### Reading it before you cut
|
|
668
618
|
|
|
@@ -725,13 +675,16 @@ revoiced and trimmed the same way, with fresh findings and fresh word timings.
|
|
|
725
675
|
scene's motion prompt only (`withRedoDirection`,
|
|
726
676
|
`scout/src/trigger/workflow-scene-retry.ts`); the spoken line still comes from
|
|
727
677
|
the approved storyboard. Asking for different words in a note will not change
|
|
728
|
-
them. If the words are wrong, that is a
|
|
678
|
+
them. If the words are wrong, that is a new run.
|
|
729
679
|
|
|
730
680
|
**A redo whose take fails the checks keeps the original clip.** On a finished
|
|
731
681
|
clip the row stays `done` with the ORIGINAL take and only its findings are
|
|
732
682
|
refreshed, because a worse take must never replace a delivered one. So never
|
|
733
|
-
report that the clip changed
|
|
734
|
-
|
|
683
|
+
report that the clip changed without reading `status` again: under the scene it
|
|
684
|
+
prints what the redo did, `new take accepted (take N)` or `new take rejected,
|
|
685
|
+
original kept (take N)`, and `status --json` carries the same sentence on the
|
|
686
|
+
row as `lastRedo.label`. A row with no `lastRedo` was redone before outcomes
|
|
687
|
+
were recorded; for those, pull and compare the file before and after.
|
|
735
688
|
|
|
736
689
|
**Re-cut afterwards.** A cut you already uploaded still holds the old clip, and
|
|
737
690
|
the CLI says so when that is the case. Pull again, re-cut and upload again before
|
|
@@ -805,8 +758,7 @@ they want; if they have not said and are not around, say which you used and why.
|
|
|
805
758
|
- **The bundled script** — `node .claude/skills/exodus-video/scripts/first-cut.mjs
|
|
806
759
|
./ad-<runId>`, path from the workspace root (from a brand subfolder, prefix
|
|
807
760
|
`../`). A plain assembly with no editing decisions in it, for seeing the whole
|
|
808
|
-
ad end to end quickly. Read "What the bundled script does" before you run it
|
|
809
|
-
it changes the audio.
|
|
761
|
+
ad end to end quickly. Read "What the bundled script does" before you run it.
|
|
810
762
|
- **HyperFrames** — HTML/CSS compositions rendered to video. Suits an ad that
|
|
811
763
|
needs captions, title cards, overlays or motion graphics laid over the clips.
|
|
812
764
|
It is a separate skill pack; check that a `hyperframes` skill is installed
|
|
@@ -839,17 +791,15 @@ Read these out of `storyboard.json` before you build anything, in any tool:
|
|
|
839
791
|
|
|
840
792
|
### What the bundled script does
|
|
841
793
|
|
|
842
|
-
State this to the user before you run it,
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
- **It
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
#1814. If the user cares about a single consistent narrator pace, cut
|
|
852
|
-
elsewhere, or skip the scenes it stretches.
|
|
794
|
+
State this to the user before you run it, so they know what the cut does with
|
|
795
|
+
the pieces:
|
|
796
|
+
|
|
797
|
+
- **It cuts the picture to the voice, and never the other way round.** A
|
|
798
|
+
narrated scene's segment runs exactly as long as that scene's voice track plus
|
|
799
|
+
a beat of air. A clip that runs longer is trimmed; a clip that runs shorter
|
|
800
|
+
holds its last frame for the difference. The narration is never sped up,
|
|
801
|
+
slowed down or cut short, so every scene plays at the speed it was recorded,
|
|
802
|
+
and the word times are handed on as they were measured.
|
|
853
803
|
- **It ignores the continuous master.** It reads only `scene-NN.voice` files;
|
|
854
804
|
it never opens `narration.mp3` or `manifest.narration`. A tool that lays the
|
|
855
805
|
master down whole and cuts the pictures to it keeps the read intact.
|
|
@@ -866,7 +816,8 @@ cut tool to alter the performance:
|
|
|
866
816
|
- Beside the MP4 it writes `cut.words.json` (every spoken word of the finished
|
|
867
817
|
ad with its start and end) and `cut.srt` (the same words grouped into subtitle
|
|
868
818
|
lines), named from the `--out` basename. Those times are on the finished ad's
|
|
869
|
-
timeline
|
|
819
|
+
timeline, each scene's own word times offset by where that scene starts in the
|
|
820
|
+
cut and otherwise unaltered. Scenes with no word times leave
|
|
870
821
|
gaps and are named in the report; when no scene delivered any, neither file is
|
|
871
822
|
written. These sidecars are the script's own output — another tool will not
|
|
872
823
|
produce them.
|
|
@@ -890,8 +841,8 @@ give a real one.
|
|
|
890
841
|
|
|
891
842
|
**`upload` only works at the final watch.** The run has to be parked there with
|
|
892
843
|
its video step done, or the server answers "This ad isn't waiting for your final
|
|
893
|
-
cut right now."
|
|
894
|
-
`finalWatch: true
|
|
844
|
+
cut right now." That park exists only when the video node sets
|
|
845
|
+
`finalWatch: true`. Uploading again replaces the
|
|
895
846
|
previous cut in place rather than adding a second one. Upload does not move the
|
|
896
847
|
run; it stays parked until someone approves.
|
|
897
848
|
|
|
@@ -906,7 +857,7 @@ compares the cut against the script or the storyboard. The approved spoken copy
|
|
|
906
857
|
and the approved narration are therefore not the editor's to change — reordering
|
|
907
858
|
lines, re-recording narration, or cutting words out of the CTA will ship,
|
|
908
859
|
silently. Cut the pictures to the words, not the words to the pictures. If the
|
|
909
|
-
words are wrong, that is a
|
|
860
|
+
words are wrong, that is a new run, not an edit.
|
|
910
861
|
|
|
911
862
|
## Reporting to the user
|
|
912
863
|
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
// The audio track comes first. A narrated scene (the storyboard gives it
|
|
10
10
|
// narration and no on-camera line) plays its voice track and NOTHING the clip
|
|
11
11
|
// recorded: the voice replaces the clip's audio, and the picture is fitted to
|
|
12
|
-
// the voice, trimmed when the clip runs longer
|
|
13
|
-
//
|
|
12
|
+
// the voice, trimmed when the clip runs longer and held on its last frame when
|
|
13
|
+
// the voice runs longer. The voice itself is never altered. A dialogue scene
|
|
14
14
|
// keeps the audio it performed. A scene with no clip but a keyframe becomes a
|
|
15
15
|
// still for the length of its voice track, else the storyboard's planned
|
|
16
16
|
// duration. Every segment is loudness-normalized to -16 LUFS before the join.
|
|
@@ -35,8 +35,6 @@ const stereo = "aresample=48000,aformat=channel_layouts=stereo";
|
|
|
35
35
|
const LOUDNORM = "loudnorm=I=-16:TP=-1.5:LRA=11,aresample=48000";
|
|
36
36
|
/** Air after the last spoken word of a narrated scene, so the line lands. */
|
|
37
37
|
export const TAIL_AIR_SEC = 0.8;
|
|
38
|
-
/** The most a voice track may be sped up to fit its clip. */
|
|
39
|
-
export const ATEMPO_CAP = 1.26;
|
|
40
38
|
/** A continuous low bed under the whole ad, so the joins between clips do not read as dead air. */
|
|
41
39
|
const ROOM_TONE =
|
|
42
40
|
"anoisesrc=color=brown:amplitude=0.0035:r=48000,highpass=f=60,lowpass=f=900,aformat=channel_layouts=stereo";
|
|
@@ -91,27 +89,18 @@ export function scenePlans(storyboard) {
|
|
|
91
89
|
}
|
|
92
90
|
|
|
93
91
|
/**
|
|
94
|
-
* Fit a picture to its voice track. The segment runs as long as the
|
|
95
|
-
* a beat of air
|
|
96
|
-
* is
|
|
97
|
-
*
|
|
92
|
+
* Fit a picture to its voice track. The segment always runs as long as the
|
|
93
|
+
* voice plus a beat of air, because the voice is never altered. A clip that
|
|
94
|
+
* runs longer is trimmed; a clip that runs shorter holds its last frame for the
|
|
95
|
+
* difference. A still (no clip) simply runs the voice's length.
|
|
98
96
|
*/
|
|
99
97
|
export function fitNarration({ clipSeconds, voiceSeconds }) {
|
|
100
98
|
const target = voiceSeconds + TAIL_AIR_SEC;
|
|
101
|
-
if (clipSeconds === null) return { seconds: target,
|
|
99
|
+
if (clipSeconds === null) return { seconds: target, holdSec: 0, trimmedBy: 0 };
|
|
102
100
|
if (target <= clipSeconds) {
|
|
103
|
-
return { seconds: target,
|
|
101
|
+
return { seconds: target, holdSec: 0, trimmedBy: clipSeconds - target };
|
|
104
102
|
}
|
|
105
|
-
|
|
106
|
-
const atempo = Math.min(ATEMPO_CAP, voiceSeconds / room);
|
|
107
|
-
const fitted = voiceSeconds / atempo + TAIL_AIR_SEC;
|
|
108
|
-
if (fitted <= clipSeconds) return { seconds: fitted, atempo, holdSec: 0, trimmedBy: 0 };
|
|
109
|
-
return { seconds: fitted, atempo, holdSec: fitted - clipSeconds, trimmedBy: 0 };
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function scaleWords(words, atempo) {
|
|
113
|
-
if (!words || atempo === 1) return words;
|
|
114
|
-
return words.map((w) => ({ ...w, s: w.s / atempo, e: w.e / atempo }));
|
|
103
|
+
return { seconds: target, holdSec: target - clipSeconds, trimmedBy: 0 };
|
|
115
104
|
}
|
|
116
105
|
|
|
117
106
|
export function cueOffsetOnSpine(spine, counts, cueLineId) {
|
|
@@ -186,27 +175,26 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
|
|
|
186
175
|
const fit = fitNarration({ clipSeconds: clip.seconds, voiceSeconds: probe(voice).seconds });
|
|
187
176
|
const how = [];
|
|
188
177
|
if (fit.trimmedBy > 0.05) how.push(`clip trimmed ${clip.seconds.toFixed(1)}s -> ${fit.seconds.toFixed(1)}s`);
|
|
189
|
-
if (fit.atempo > 1.001) how.push(`voice sped up ${fit.atempo.toFixed(2)}x`);
|
|
190
178
|
if (fit.holdSec > 0.05) how.push(`last frame held ${fit.holdSec.toFixed(1)}s`);
|
|
191
179
|
spine.push({
|
|
192
|
-
...base, audio: "narration", voice, seconds: fit.seconds,
|
|
193
|
-
holdSec: fit.holdSec, words:
|
|
180
|
+
...base, audio: "narration", voice, seconds: fit.seconds,
|
|
181
|
+
holdSec: fit.holdSec, words: readWords(scene.words),
|
|
194
182
|
note: `${flag}, ${voice} replaces the clip's audio${how.length ? ` (${how.join(", ")})` : ""}`,
|
|
195
183
|
});
|
|
196
184
|
} else if (narrated) {
|
|
197
185
|
spine.push({
|
|
198
|
-
...base, audio: "silence", voice: null, seconds: clip.seconds,
|
|
186
|
+
...base, audio: "silence", voice: null, seconds: clip.seconds, holdSec: 0,
|
|
199
187
|
words: null,
|
|
200
188
|
note: `${flag}, narrated scene without its voice track: the clip's own audio is muted`,
|
|
201
189
|
});
|
|
202
190
|
} else if (clip.hasAudio) {
|
|
203
191
|
spine.push({
|
|
204
|
-
...base, audio: "clip", voice: null, seconds: clip.seconds,
|
|
192
|
+
...base, audio: "clip", voice: null, seconds: clip.seconds, holdSec: 0,
|
|
205
193
|
words: readWords(scene.words), note: `${flag}, its own dialogue`,
|
|
206
194
|
});
|
|
207
195
|
} else {
|
|
208
196
|
spine.push({
|
|
209
|
-
...base, audio: "silence", voice: null, seconds: clip.seconds,
|
|
197
|
+
...base, audio: "silence", voice: null, seconds: clip.seconds, holdSec: 0,
|
|
210
198
|
words: null, note: `${flag}, silent clip`,
|
|
211
199
|
});
|
|
212
200
|
}
|
|
@@ -216,10 +204,10 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
|
|
|
216
204
|
if (exists(scene.keyframe)) {
|
|
217
205
|
const fit = voice
|
|
218
206
|
? fitNarration({ clipSeconds: null, voiceSeconds: probe(voice).seconds })
|
|
219
|
-
: { seconds: plan.durationSec ?? 4,
|
|
207
|
+
: { seconds: plan.durationSec ?? 4, holdSec: 0 };
|
|
220
208
|
spine.push({
|
|
221
209
|
sceneIndex: n, source: "still", file: scene.keyframe, clipSeconds: null,
|
|
222
|
-
seconds: fit.seconds, audio: voice ? "narration" : "silence", voice,
|
|
210
|
+
seconds: fit.seconds, audio: voice ? "narration" : "silence", voice, holdSec: 0,
|
|
223
211
|
startSec, lineIds: plan.lineIds,
|
|
224
212
|
// #1689: on a narrated scene the words are the voice track's, and the
|
|
225
213
|
// voice starts where the segment does, so they need no offset here.
|
|
@@ -288,8 +276,7 @@ export function buildFfmpegArgs(timeline, { resolve, music, out }) {
|
|
|
288
276
|
filters.push(`${norm(`${i}:v`)}${fit.length ? `,${fit.join(",")}` : ""}[v${i}]`);
|
|
289
277
|
if (seg.audio === "narration") {
|
|
290
278
|
const v = addInput("-i", resolve(seg.voice));
|
|
291
|
-
|
|
292
|
-
filters.push(`[${v}:a]${stereo},${tempo}${LOUDNORM},apad,atrim=0:${sec(seg.seconds)}[a${i}]`);
|
|
279
|
+
filters.push(`[${v}:a]${stereo},${LOUDNORM},apad,atrim=0:${sec(seg.seconds)}[a${i}]`);
|
|
293
280
|
} else if (seg.audio === "clip") {
|
|
294
281
|
filters.push(`[${i}:a]${stereo},${LOUDNORM},apad,atrim=0:${sec(seg.seconds)}[a${i}]`);
|
|
295
282
|
} else {
|
package/dist/commands/genesis.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { readFileSync, existsSync } from "node:fs";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
|
-
import { createInterface } from "node:readline/promises";
|
|
4
3
|
import { apiGet, apiPost, getDashboardUrl } from "../lib/client.js";
|
|
5
4
|
import { pollUntilDone } from "../lib/poll.js";
|
|
6
5
|
import { displayRunStatus, formatGenesisRun, formatError, tickerRunStatus } from "../lib/format.js";
|
|
@@ -278,52 +277,13 @@ export async function run(flags) {
|
|
|
278
277
|
return submitGenesis(action.body, { noWait, cc });
|
|
279
278
|
}
|
|
280
279
|
}
|
|
281
|
-
async function listBank(
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
console.log(formatError(res));
|
|
285
|
-
process.exit(1);
|
|
286
|
-
}
|
|
287
|
-
const ideas = Array.isArray(res.data.ideas) ? res.data.ideas : [];
|
|
288
|
-
if (ideas.length === 0) {
|
|
289
|
-
console.log("Bank is empty.");
|
|
290
|
-
console.log('Fill it with: exodus genesis scrape (or paste a reel: exodus genesis --reel "<url>")');
|
|
291
|
-
return;
|
|
292
|
-
}
|
|
293
|
-
console.log(`## Idea Bank (${ideas.length} reel${ideas.length === 1 ? "" : "s"})`);
|
|
294
|
-
console.log("");
|
|
295
|
-
for (const i of ideas) {
|
|
296
|
-
const hook = i.hook ?? "(no hook)";
|
|
297
|
-
const user = typeof i.sourceUsername === "string" && i.sourceUsername.length > 0
|
|
298
|
-
? ` @${i.sourceUsername.replace(/^@+/, "")}`
|
|
299
|
-
: "";
|
|
300
|
-
const score = typeof i.relevanceScore === "number" ? ` score=${i.relevanceScore.toFixed(2)}` : "";
|
|
301
|
-
const used = typeof i.useCount === "number" && i.useCount > 0 ? " · used" : "";
|
|
302
|
-
console.log(` • ${hook}${user}${score}`);
|
|
303
|
-
console.log(` id: ${i._id ?? "?"} [${i.status ?? "new"}${used}]`);
|
|
304
|
-
if (i.sourceUrl)
|
|
305
|
-
console.log(` ${i.sourceUrl}`);
|
|
306
|
-
}
|
|
307
|
-
console.log("");
|
|
308
|
-
console.log("Write from one: exodus genesis --from-bank --idea <id>");
|
|
280
|
+
async function listBank(_limit) {
|
|
281
|
+
console.error(`This command has been retired. View the idea bank in the dashboard: ${getDashboardUrl()}/idea-bank`);
|
|
282
|
+
process.exit(1);
|
|
309
283
|
}
|
|
310
|
-
async function runFromBank(
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
console.log(formatError(res));
|
|
314
|
-
process.exit(1);
|
|
315
|
-
}
|
|
316
|
-
const idea = (res.data.ideas ?? [])[0];
|
|
317
|
-
if (!idea) {
|
|
318
|
-
console.error(`No banked idea found for id "${ideaId}". List the bank: exodus genesis --from-bank`);
|
|
319
|
-
process.exit(1);
|
|
320
|
-
}
|
|
321
|
-
if (!idea.sourceUrl) {
|
|
322
|
-
console.error(`Banked idea "${ideaId}" has no source reel URL to write from.`);
|
|
323
|
-
process.exit(1);
|
|
324
|
-
}
|
|
325
|
-
console.log(`Writing from banked reel: ${idea.sourceUrl}`);
|
|
326
|
-
return runReel([idea.sourceUrl], opts, rt);
|
|
284
|
+
async function runFromBank(_ideaId, _opts, _rt) {
|
|
285
|
+
console.error(`This command has been retired. Write from banked ideas in the dashboard: ${getDashboardUrl()}/idea-bank`);
|
|
286
|
+
process.exit(1);
|
|
327
287
|
}
|
|
328
288
|
async function runReel(urls, opts, rt) {
|
|
329
289
|
console.log(`Transcribing ${urls.length === 1 ? "the reel" : `${urls.length} reels`} → idea → Genesis…`);
|
|
@@ -613,92 +573,11 @@ function requireActiveBrand() {
|
|
|
613
573
|
}
|
|
614
574
|
return slug;
|
|
615
575
|
}
|
|
616
|
-
async function runConnectInstagram(
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
if (!start.ok || !start.data.liveViewUrl) {
|
|
620
|
-
console.log(formatError(start));
|
|
621
|
-
process.exit(1);
|
|
622
|
-
}
|
|
623
|
-
console.log(`\nConnecting an Instagram account to brand "${client}".`);
|
|
624
|
-
console.log("\n1. Open this link in your browser:\n");
|
|
625
|
-
console.log(` ${start.data.liveViewUrl}\n`);
|
|
626
|
-
console.log('2. Go to instagram.com and log into the account for THIS brand.');
|
|
627
|
-
console.log("3. Scroll your feed for a bit so the algorithm learns the niche.");
|
|
628
|
-
console.log("4. Come back here and press Enter.\n");
|
|
629
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
630
|
-
try {
|
|
631
|
-
await rl.question("Press Enter once you're logged in and done grooming... ");
|
|
632
|
-
let username = typeof flags["username"] === "string" ? flags["username"].trim() : "";
|
|
633
|
-
while (!username) {
|
|
634
|
-
username = (await rl.question("Instagram handle you just logged in as (e.g. mybrand): ")).trim();
|
|
635
|
-
}
|
|
636
|
-
const finish = await apiPost("/api/v2/genesis/instagram/connect/finish", { client, igUsername: username }, { ccCommand: cc });
|
|
637
|
-
if (!finish.ok) {
|
|
638
|
-
console.log(formatError(finish));
|
|
639
|
-
process.exit(1);
|
|
640
|
-
}
|
|
641
|
-
console.log(`\n✓ Instagram @${username.replace(/^@+/, "")} connected to "${client}".`);
|
|
642
|
-
console.log("Fill the idea bank now: exodus genesis scrape --organic");
|
|
643
|
-
}
|
|
644
|
-
finally {
|
|
645
|
-
rl.close();
|
|
646
|
-
}
|
|
576
|
+
async function runConnectInstagram(_flags, _cc) {
|
|
577
|
+
console.error(`This command has been retired. Connect Instagram accounts in the dashboard: ${getDashboardUrl()}/settings`);
|
|
578
|
+
process.exit(1);
|
|
647
579
|
}
|
|
648
|
-
async function runScrape(
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
if (flags["organic"] === true) {
|
|
652
|
-
const smoke = flags["smoke"] === true;
|
|
653
|
-
const res = await apiPost("/api/v2/scout/organic", { client, ...(smoke ? { smoke: true } : {}) }, { ccCommand: cc });
|
|
654
|
-
if (!res.ok || !res.data.runId) {
|
|
655
|
-
console.log(formatError(res));
|
|
656
|
-
process.exit(1);
|
|
657
|
-
}
|
|
658
|
-
console.log(`Organic scrape started for "${client}": ${res.data.runId}${smoke ? " (smoke)" : ""}`);
|
|
659
|
-
return pollScrape(res.data.runId, noWait, 1_800_000);
|
|
660
|
-
}
|
|
661
|
-
const term = typeof flags["term"] === "string" ? flags["term"] : undefined;
|
|
662
|
-
const body = { sourceMode: "fresh", client };
|
|
663
|
-
if (term)
|
|
664
|
-
body.term = term;
|
|
665
|
-
const res = await apiPost("/api/v2/scout", body, { ccCommand: cc });
|
|
666
|
-
if (!res.ok || !res.data.runId) {
|
|
667
|
-
console.log(formatError(res));
|
|
668
|
-
process.exit(1);
|
|
669
|
-
}
|
|
670
|
-
console.log(`Discovery scrape started for "${client}": ${res.data.runId}`);
|
|
671
|
-
return pollScrape(res.data.runId, noWait, 1_200_000);
|
|
672
|
-
}
|
|
673
|
-
async function pollScrape(runId, noWait, timeoutMs) {
|
|
674
|
-
if (noWait) {
|
|
675
|
-
console.log(`Run ID: ${runId}`);
|
|
676
|
-
console.log(`Check status: exodus status --type scout --id ${runId}`);
|
|
677
|
-
return;
|
|
678
|
-
}
|
|
679
|
-
console.log("Polling for completion...");
|
|
680
|
-
const result = await pollUntilDone({
|
|
681
|
-
path: `/api/v2/scout?runId=${runId}`,
|
|
682
|
-
intervalMs: 15_000,
|
|
683
|
-
timeoutMs,
|
|
684
|
-
onProgress: (data) => {
|
|
685
|
-
const status = data["status"];
|
|
686
|
-
const captured = data["organicCaptured"];
|
|
687
|
-
const qualified = data["organicQualified"];
|
|
688
|
-
const counts = captured !== undefined || qualified !== undefined
|
|
689
|
-
? ` captured=${captured ?? 0} qualified=${qualified ?? 0}`
|
|
690
|
-
: "";
|
|
691
|
-
if (status)
|
|
692
|
-
process.stdout.write(`\r status: ${tickerRunStatus(status)}${counts} `);
|
|
693
|
-
},
|
|
694
|
-
});
|
|
695
|
-
console.log();
|
|
696
|
-
if (result.timedOut) {
|
|
697
|
-
console.log(`Timed out waiting. Check later: exodus status --type scout --id ${runId}`);
|
|
698
|
-
}
|
|
699
|
-
else {
|
|
700
|
-
console.log(`Done. Banked reels are ready to write: exodus genesis --from-bank`);
|
|
701
|
-
}
|
|
702
|
-
if (!result.ok && !result.timedOut)
|
|
703
|
-
process.exit(1);
|
|
580
|
+
async function runScrape(_flags, _cc) {
|
|
581
|
+
console.error(`This command has been retired. Fill the idea bank from the Mining page in the dashboard: ${getDashboardUrl()}/mining`);
|
|
582
|
+
process.exit(1);
|
|
704
583
|
}
|
package/dist/commands/status.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { apiGet } from "../lib/client.js";
|
|
1
|
+
import { apiGet, getDashboardUrl } from "../lib/client.js";
|
|
2
2
|
import { formatGeneration, formatGenesisRun, formatError, displayRunStatus } from "../lib/format.js";
|
|
3
|
-
import { hydrateScoutIdeasCount } from "../lib/scout-hydrate.js";
|
|
4
3
|
import { formatIntelResult as formatIntelResultRich } from "../lib/intel-format.js";
|
|
5
4
|
export const helpText = `
|
|
6
5
|
exodus status — Check the status of a pipeline run
|
|
@@ -12,19 +11,13 @@ Required:
|
|
|
12
11
|
--id <runId> The generation or run ID returned by a prior exodus command
|
|
13
12
|
|
|
14
13
|
Options:
|
|
15
|
-
--type <pipeline> Pipeline type: generation (default) | genesis | intel | pulse
|
|
14
|
+
--type <pipeline> Pipeline type: generation (default) | genesis | intel | pulse
|
|
16
15
|
| creative | template
|
|
17
16
|
Spark, Viral, Mirror, Remix runs use --type generation (default).
|
|
18
|
-
Intel, Pulse
|
|
17
|
+
Intel, Pulse require --type intel|pulse.
|
|
19
18
|
Creative-suite (native/copy-derived/ref-match/meme)
|
|
20
19
|
use --type creative; Template uses --type template.
|
|
21
20
|
`.trim();
|
|
22
|
-
async function fetchScoutIdeasCount(runId) {
|
|
23
|
-
const res = await apiGet(`/api/v2/scout/ideas?runId=${runId}&limit=1`);
|
|
24
|
-
if (res.ok && typeof res.data.count === "number")
|
|
25
|
-
return res.data.count;
|
|
26
|
-
return null;
|
|
27
|
-
}
|
|
28
21
|
export function formatIntelResult(data) {
|
|
29
22
|
return formatIntelResultRich(data).replace("## Intel Analysis Result", "## Intel Status");
|
|
30
23
|
}
|
|
@@ -157,18 +150,21 @@ export async function run(flags) {
|
|
|
157
150
|
process.exit(1);
|
|
158
151
|
}
|
|
159
152
|
const type = flags["type"] ?? "generation";
|
|
153
|
+
if (type === "scout") {
|
|
154
|
+
console.error(`--type scout has been retired. Check run status in the dashboard: ${getDashboardUrl()}/runs`);
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
160
157
|
const pathMap = {
|
|
161
158
|
generation: `/api/v2/generations?id=${id}`,
|
|
162
159
|
genesis: `/api/v2/genesis?id=${id}`,
|
|
163
160
|
intel: `/api/v2/intel?id=${id}`,
|
|
164
161
|
pulse: `/api/v2/pulse?id=${id}`,
|
|
165
|
-
scout: `/api/v2/scout?runId=${id}`,
|
|
166
162
|
creative: `/api/v2/creative?runId=${id}`,
|
|
167
163
|
template: `/api/v2/template?runId=${id}`,
|
|
168
164
|
};
|
|
169
165
|
const path = pathMap[type];
|
|
170
166
|
if (!path) {
|
|
171
|
-
console.error(`Unknown type: "${type}". Valid types: generation, genesis, intel, pulse,
|
|
167
|
+
console.error(`Unknown type: "${type}". Valid types: generation, genesis, intel, pulse, creative, template`);
|
|
172
168
|
process.exit(1);
|
|
173
169
|
}
|
|
174
170
|
const res = await apiGet(path);
|
|
@@ -176,9 +172,6 @@ export async function run(flags) {
|
|
|
176
172
|
console.log(formatError(res));
|
|
177
173
|
process.exit(1);
|
|
178
174
|
}
|
|
179
|
-
if (type === "scout") {
|
|
180
|
-
await hydrateScoutIdeasCount(id, res.data, fetchScoutIdeasCount);
|
|
181
|
-
}
|
|
182
175
|
switch (type) {
|
|
183
176
|
case "generation":
|
|
184
177
|
console.log(formatGeneration(res.data));
|
|
@@ -192,9 +185,6 @@ export async function run(flags) {
|
|
|
192
185
|
case "pulse":
|
|
193
186
|
console.log(formatPulseResult(res.data));
|
|
194
187
|
break;
|
|
195
|
-
case "scout":
|
|
196
|
-
console.log(formatScoutResult(res.data));
|
|
197
|
-
break;
|
|
198
188
|
case "creative":
|
|
199
189
|
console.log(formatCreativeSuiteStatus("Creative Suite Status", res.data));
|
|
200
190
|
break;
|
package/dist/commands/video.d.ts
CHANGED
|
@@ -117,6 +117,11 @@ export interface NodeItem {
|
|
|
117
117
|
flagged?: boolean;
|
|
118
118
|
findings?: ClipFinding[];
|
|
119
119
|
staleClaim?: boolean;
|
|
120
|
+
lastRedo?: {
|
|
121
|
+
take: number;
|
|
122
|
+
outcome: string;
|
|
123
|
+
label: string;
|
|
124
|
+
};
|
|
120
125
|
artifact?: ArtifactSubset;
|
|
121
126
|
}
|
|
122
127
|
export interface ShowRow {
|
|
@@ -184,11 +189,20 @@ export type RunStop = {
|
|
|
184
189
|
at: "finished";
|
|
185
190
|
status: string;
|
|
186
191
|
};
|
|
192
|
+
export type ResolvedStop = Exclude<RunStop, {
|
|
193
|
+
at: "final-watch";
|
|
194
|
+
}> | {
|
|
195
|
+
at: "final-watch";
|
|
196
|
+
cutAttached: boolean | null;
|
|
197
|
+
};
|
|
187
198
|
export declare function classifyRun(run: VideoRun): RunStop;
|
|
199
|
+
export declare function hasAttachedCut(items: NodeItem[]): boolean;
|
|
200
|
+
export declare function resolveStop(stop: RunStop, cutAttached: boolean | null): ResolvedStop;
|
|
201
|
+
export declare function resolveStopAtPark(stop: RunStop, runId: string, deps: Pick<VideoDeps, "get">): Promise<ResolvedStop>;
|
|
188
202
|
export declare function stageWord(stage: string): string;
|
|
189
203
|
export declare function stepName(kind: string | undefined): string;
|
|
190
204
|
export declare function reviewUrl(dashboardUrl: string, run: Pick<VideoRun, "_id" | "workflowId" | "moduleOwned">): string;
|
|
191
|
-
export declare function stopLines(stop:
|
|
205
|
+
export declare function stopLines(stop: ResolvedStop, runId: string, runUrl: string): string[];
|
|
192
206
|
export interface PullDownload {
|
|
193
207
|
file: string;
|
|
194
208
|
url: string;
|
package/dist/commands/video.js
CHANGED
|
@@ -7,7 +7,7 @@ import { missingRouteLine } from "../lib/route-support.js";
|
|
|
7
7
|
import { hasBinary } from "../lib/preflight.js";
|
|
8
8
|
import { ASSET_UPLOAD_POLICY, pauseAheadLine, } from "./workflow.js";
|
|
9
9
|
export const helpText = `
|
|
10
|
-
exodus video — make
|
|
10
|
+
exodus video — make a video ad from a saved workflow, pull every piece, upload your cut
|
|
11
11
|
|
|
12
12
|
The dashboard makes the PIECES of an ad: the storyboard, one picture per scene,
|
|
13
13
|
one voice track per scene, one video clip per scene, plus the music bed. It does
|
|
@@ -16,28 +16,34 @@ with whatever editing tools you like, and upload the cut back.
|
|
|
16
16
|
|
|
17
17
|
The whole loop, in order:
|
|
18
18
|
|
|
19
|
-
1. exodus
|
|
20
|
-
Which
|
|
19
|
+
1. exodus workflow list
|
|
20
|
+
Which workflows this brand has saved. A video workflow is one that makes
|
|
21
|
+
ads.
|
|
21
22
|
|
|
22
|
-
2. exodus
|
|
23
|
-
|
|
23
|
+
2. exodus workflow describe <workflowId|name>
|
|
24
|
+
What that workflow needs before it can run, and what it gives back.
|
|
24
25
|
|
|
25
|
-
3. exodus
|
|
26
|
+
3. exodus workflow run <workflowId|name> --input <field>=@script.txt --wait
|
|
27
|
+
Starts the ad on your script and waits. It stops when the storyboard
|
|
28
|
+
needs your yes. "describe" names the field your script goes in. You can
|
|
29
|
+
also settle the voices here, at the launch, instead of at step 5 — the
|
|
30
|
+
launch flags all live in: exodus workflow --help
|
|
31
|
+
|
|
32
|
+
4. exodus video storyboard <runId>
|
|
26
33
|
The scene cards: what each scene says and the picture it will look like.
|
|
27
34
|
|
|
28
|
-
|
|
35
|
+
5. exodus video voices <runId>
|
|
29
36
|
Who is in the ad, which voice each one has, how the voices get applied
|
|
30
37
|
and who pays. Add --set to give someone a voice, before any clip is made.
|
|
31
38
|
|
|
32
|
-
|
|
33
|
-
exodus video flag <runId> --note "…" (something is wrong — send it back)
|
|
39
|
+
6. exodus video approve <runId> (looks right — keep going)
|
|
34
40
|
exodus video retry-frame <runId> --node <nodeId> --scene <n>
|
|
35
41
|
Redo ONE still at the pixel gate. Neighbours stay. The gate holds.
|
|
36
42
|
|
|
37
|
-
|
|
43
|
+
7. exodus video status <runId>
|
|
38
44
|
Where the run is and how each scene's clip turned out.
|
|
39
45
|
|
|
40
|
-
|
|
46
|
+
8. exodus video pull <runId> --out ./ad
|
|
41
47
|
Writes every piece to that folder plus a manifest.json index.
|
|
42
48
|
|
|
43
49
|
exodus video retry-clip <runId> --scene <n>
|
|
@@ -50,21 +56,18 @@ The whole loop, in order:
|
|
|
50
56
|
picture stays, no new video is made, and a clip whose voice pass
|
|
51
57
|
cannot finish is kept as it was. Use --scene <n> for one clip.
|
|
52
58
|
|
|
53
|
-
|
|
59
|
+
9. Make your cut from those files.
|
|
54
60
|
|
|
55
|
-
|
|
56
|
-
|
|
61
|
+
10. exodus video upload <runId> --file cut.mp4
|
|
62
|
+
Attaches your cut to the run and prints the page to approve it on.
|
|
57
63
|
|
|
58
|
-
|
|
59
|
-
Or click Approve on the page from step
|
|
64
|
+
11. exodus video approve <runId>
|
|
65
|
+
Or click Approve on the page from step 10.
|
|
60
66
|
|
|
61
67
|
Usage:
|
|
62
|
-
exodus video shows [--json]
|
|
63
|
-
exodus video start --show <id> --script <file> [--voice-path <v>] [--no-music] [--wait] [--json]
|
|
64
68
|
exodus video status <runId> [--json]
|
|
65
69
|
exodus video storyboard <runId> [--json]
|
|
66
70
|
exodus video approve <runId> [--json]
|
|
67
|
-
exodus video flag <runId> --note "<what is wrong>" [--json]
|
|
68
71
|
exodus video retry-frame <runId> --node <nodeId> --scene <n> [--note "..."] [--json]
|
|
69
72
|
exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "..."] [--json]
|
|
70
73
|
exodus video revoice <runId> (--scene <n> | --all) [--node <nodeId>] [--json]
|
|
@@ -73,18 +76,11 @@ Usage:
|
|
|
73
76
|
exodus video upload <runId> --file <cut.mp4> [--duration <sec>] [--json]
|
|
74
77
|
|
|
75
78
|
Options:
|
|
76
|
-
--show <id> Which Show the ad belongs to (from: exodus video shows)
|
|
77
|
-
--script <file> A text file with the words you want the ad to say
|
|
78
|
-
--voice-path <v> Override the Show's voice path for this ad
|
|
79
|
-
--no-music Skip the music bed
|
|
80
|
-
--wait Stay open and report as the run moves; stops at every
|
|
81
|
-
point that needs you
|
|
82
79
|
--out <dir> Folder to write the pulled pieces into (pull)
|
|
83
80
|
--file <cut.mp4> Your finished cut (upload). MP4, MOV or WebM, up to 200MB
|
|
84
81
|
--duration <sec> How long your cut is, in seconds. Only needed when the
|
|
85
82
|
length can't be read off the file itself
|
|
86
|
-
--note "<text>"
|
|
87
|
-
steer one redo (retry-frame, retry-clip)
|
|
83
|
+
--note "<text>" How to steer one redo (retry-frame, retry-clip)
|
|
88
84
|
--node <nodeId> Which scene-frames node holds the still (retry-frame).
|
|
89
85
|
Which video step holds the clip, when a scene has one on
|
|
90
86
|
more than one step (retry-clip, revoice)
|
|
@@ -99,14 +95,15 @@ Options:
|
|
|
99
95
|
--from <file.json> A file of characters and voice IDs (voices). --set and
|
|
100
96
|
--clear win over the same name in the file
|
|
101
97
|
--json Machine-readable output
|
|
102
|
-
--help, -h
|
|
98
|
+
--help, -h Print this help
|
|
103
99
|
|
|
104
100
|
Video is admin-only. If every command here answers "video isn't enabled for
|
|
105
101
|
this key", your dashboard user needs the admin role on this brand.
|
|
106
102
|
|
|
107
103
|
Examples:
|
|
108
|
-
exodus
|
|
109
|
-
exodus
|
|
104
|
+
exodus workflow list
|
|
105
|
+
exodus workflow describe "Video Ad"
|
|
106
|
+
exodus workflow run "Video Ad" --input script=@./script.txt --wait
|
|
110
107
|
exodus video storyboard run_123
|
|
111
108
|
exodus video approve run_123
|
|
112
109
|
exodus video retry-frame run_123 --node frames-1 --scene 2
|
|
@@ -257,6 +254,22 @@ export function classifyRun(run) {
|
|
|
257
254
|
const active = run.nodes.find((n) => n.status === "running");
|
|
258
255
|
return { at: "running", stage: active?.kind ?? "starting" };
|
|
259
256
|
}
|
|
257
|
+
export function hasAttachedCut(items) {
|
|
258
|
+
return items.some((i) => i.itemKind === "final" && i.status === "done");
|
|
259
|
+
}
|
|
260
|
+
export function resolveStop(stop, cutAttached) {
|
|
261
|
+
if (stop.at !== "final-watch")
|
|
262
|
+
return stop;
|
|
263
|
+
return { at: "final-watch", cutAttached };
|
|
264
|
+
}
|
|
265
|
+
export async function resolveStopAtPark(stop, runId, deps) {
|
|
266
|
+
if (stop.at !== "final-watch")
|
|
267
|
+
return stop;
|
|
268
|
+
const res = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
269
|
+
if (!res.ok)
|
|
270
|
+
return resolveStop(stop, null);
|
|
271
|
+
return resolveStop(stop, hasAttachedCut(res.data.items ?? []));
|
|
272
|
+
}
|
|
260
273
|
const STAGE_WORDS = {
|
|
261
274
|
brief: "reading the script",
|
|
262
275
|
storyboard: "writing the storyboard",
|
|
@@ -303,6 +316,21 @@ export function stopLines(stop, runId, runUrl) {
|
|
|
303
316
|
return lines;
|
|
304
317
|
}
|
|
305
318
|
if (stop.at === "final-watch") {
|
|
319
|
+
if (stop.cutAttached === null) {
|
|
320
|
+
return [
|
|
321
|
+
"Parked: every piece is made.",
|
|
322
|
+
`Couldn't check whether a cut is already uploaded. See where it stands: exodus video status ${runId}`,
|
|
323
|
+
`Watch it here: ${runUrl}`,
|
|
324
|
+
];
|
|
325
|
+
}
|
|
326
|
+
if (stop.cutAttached) {
|
|
327
|
+
return [
|
|
328
|
+
"Parked: your cut is uploaded and waiting for your approval.",
|
|
329
|
+
`Approve it: exodus video approve ${runId}`,
|
|
330
|
+
`Watch it here: ${runUrl}`,
|
|
331
|
+
"Changed your mind? You can still redo a clip and upload a new cut.",
|
|
332
|
+
];
|
|
333
|
+
}
|
|
306
334
|
return [
|
|
307
335
|
"Parked: every piece is made and the run is waiting for a cut.",
|
|
308
336
|
`Pull the pieces: exodus video pull ${runId} --out ./ad-${runId}`,
|
|
@@ -894,15 +922,16 @@ export async function waitFlow(runId, opts, deps) {
|
|
|
894
922
|
}
|
|
895
923
|
continue;
|
|
896
924
|
}
|
|
925
|
+
const resolved = await resolveStopAtPark(stop, runId, deps);
|
|
897
926
|
if (opts.json) {
|
|
898
927
|
return {
|
|
899
928
|
code: stop.at === "failed" ? 1 : 0,
|
|
900
|
-
lines: [JSON.stringify({ runId, stop, status: run.status, url: opts.url })],
|
|
929
|
+
lines: [JSON.stringify({ runId, stop: resolved, status: run.status, url: opts.url })],
|
|
901
930
|
};
|
|
902
931
|
}
|
|
903
932
|
return {
|
|
904
933
|
code: stop.at === "failed" ? 1 : 0,
|
|
905
|
-
lines: [...lines, ...stopLines(
|
|
934
|
+
lines: [...lines, ...stopLines(resolved, runId, reviewUrl(deps.dashboardUrl, run))],
|
|
906
935
|
};
|
|
907
936
|
}
|
|
908
937
|
const timeoutLine = `Still running after ${Math.round((maxPolls * interval) / 60000)} minutes. Check in with: exodus video status ${runId}`;
|
|
@@ -936,15 +965,18 @@ export async function statusFlow(runId, json, deps) {
|
|
|
936
965
|
if (!itemsRes.ok)
|
|
937
966
|
return errorResult(itemsRes, json);
|
|
938
967
|
const items = itemsRes.data.items ?? [];
|
|
939
|
-
const
|
|
940
|
-
const
|
|
968
|
+
const hasFinal = hasAttachedCut(items);
|
|
969
|
+
const stop = resolveStop(classifyRun(run), hasFinal);
|
|
970
|
+
const guidance = stopLines(stop, runId, reviewUrl(deps.dashboardUrl, run));
|
|
941
971
|
const warnings = run.nodes
|
|
942
972
|
.filter((n) => n.warning)
|
|
943
973
|
.map((n) => ({ nodeId: n.nodeId, step: n.kind, warning: n.warning }));
|
|
944
974
|
if (json) {
|
|
945
975
|
return {
|
|
946
976
|
code: 0,
|
|
947
|
-
lines: [
|
|
977
|
+
lines: [
|
|
978
|
+
JSON.stringify({ runId, status: run.status, stop, items, hasFinal, warnings, guidance }),
|
|
979
|
+
],
|
|
948
980
|
};
|
|
949
981
|
}
|
|
950
982
|
const revoicedByScene = new Set();
|
|
@@ -975,7 +1007,7 @@ export async function statusFlow(runId, json, deps) {
|
|
|
975
1007
|
: `Ad run ${runId} — ${displayRunStatus(run.status)}`;
|
|
976
1008
|
const lines = [
|
|
977
1009
|
headline,
|
|
978
|
-
...
|
|
1010
|
+
...guidance,
|
|
979
1011
|
...(stop.at === "running" && run.pauseAhead ? [pauseAheadLine(run.pauseAhead)] : []),
|
|
980
1012
|
...warnings.map((w) => `Heads-up (${w.step}): ${w.warning}`),
|
|
981
1013
|
];
|
|
@@ -987,6 +1019,12 @@ export async function statusFlow(runId, json, deps) {
|
|
|
987
1019
|
for (const sceneIndex of [...byScene.keys()].sort((a, b) => a - b)) {
|
|
988
1020
|
const row = byScene.get(sceneIndex);
|
|
989
1021
|
lines.push(`${String(sceneIndex).padEnd(5)} ${itemWord(row.clip).padEnd(8)} ${itemWord(row.voiceover).padEnd(8)} ${itemWord(row.frame)}`);
|
|
1022
|
+
if (row.clip?.lastRedo)
|
|
1023
|
+
lines.push(` clip: ${row.clip.lastRedo.label}`);
|
|
1024
|
+
if (row.voiceover?.lastRedo)
|
|
1025
|
+
lines.push(` voice: ${row.voiceover.lastRedo.label}`);
|
|
1026
|
+
if (row.frame?.lastRedo)
|
|
1027
|
+
lines.push(` picture: ${row.frame.lastRedo.label}`);
|
|
990
1028
|
for (const finding of row.clip?.findings ?? []) {
|
|
991
1029
|
lines.push(` ${finding.code} (${finding.severity}): ${finding.detail}`);
|
|
992
1030
|
const judgeSeverity = finding.judgeSeverity ?? finding.severity;
|
|
@@ -1204,7 +1242,7 @@ export function planClipRedo(run, items, target) {
|
|
|
1204
1242
|
};
|
|
1205
1243
|
}
|
|
1206
1244
|
function findClipRow(run, items, target) {
|
|
1207
|
-
const uploadedCut = items
|
|
1245
|
+
const uploadedCut = hasAttachedCut(items);
|
|
1208
1246
|
const rows = items.filter((i) => i.itemKind === "clip" &&
|
|
1209
1247
|
i.sceneIndex === target.sceneIndex &&
|
|
1210
1248
|
(target.nodeId === undefined || i.nodeId === target.nodeId));
|
|
@@ -10,7 +10,7 @@ import { runDisplayName, runHeadingWord, runTypeWord } from "../lib/runNames.js"
|
|
|
10
10
|
import { workflowToYaml, parseWorkflowText } from "../lib/workflowText.js";
|
|
11
11
|
import { missingRouteLine } from "../lib/route-support.js";
|
|
12
12
|
import { getChannel } from "../lib/channel.js";
|
|
13
|
-
import { asVideoRun, classifyRun, reviewUrl, stopLines, } from "./video.js";
|
|
13
|
+
import { asVideoRun, classifyRun, resolveStopAtPark, reviewUrl, stopLines, } from "./video.js";
|
|
14
14
|
export const helpText = `
|
|
15
15
|
exodus workflow — List, describe, run, inspect, import, and export saved workflows
|
|
16
16
|
|
|
@@ -2085,9 +2085,10 @@ async function waitForRun(runId, opts, deps) {
|
|
|
2085
2085
|
const saved = opts.out !== undefined && terminalRun
|
|
2086
2086
|
? await saveDeliveries(terminalRun, opts.out, deps)
|
|
2087
2087
|
: undefined;
|
|
2088
|
-
const
|
|
2088
|
+
const parkStop = !pollResult.timedOut && isRecord(pollResult.data)
|
|
2089
2089
|
? videoParkStop(pollResult.data)
|
|
2090
2090
|
: undefined;
|
|
2091
|
+
const videoStop = parkStop ? await resolveStopAtPark(parkStop, runId, deps) : undefined;
|
|
2091
2092
|
if (opts.json) {
|
|
2092
2093
|
return {
|
|
2093
2094
|
code: pollResult.ok ? 0 : 1,
|