@aicopycoders/exodus 2026.9.1700-beta.0 → 2026.9.1700-beta.1
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
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
|
|
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. |
|
|
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 Show — start, storyboard gate, pull every piece + manifest,
|
|
19
|
+
| exodus-video | Make a video ad from a Show — start, 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 |
|
|
@@ -245,21 +245,25 @@ Returns: a table, --json data, or an exported CSV/JSON file
|
|
|
245
245
|
|
|
246
246
|
## exodus-video
|
|
247
247
|
|
|
248
|
-
**What it does:** Makes a video ad from a
|
|
248
|
+
**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
249
|
|
|
250
250
|
```operator-guide
|
|
251
|
-
|
|
251
|
+
Start (workflow route, no Show):
|
|
252
|
+
exodus workflow list / describe <name> find it and see what it needs
|
|
253
|
+
exodus workflow run <workflowId|name> --input <field>=@script.txt [--wait] start it; --wait stops at the storyboard gate
|
|
254
|
+
Start (Show route):
|
|
252
255
|
exodus video shows [--json] Shows and whether each is ready
|
|
253
|
-
exodus video start --show <id> --script <file> [--wait] [--json] start an ad run; --wait
|
|
256
|
+
exodus video start --show <id> --script <file> [--wait] [--json] start an ad run; --wait stops at the storyboard gate
|
|
257
|
+
Then, on either route:
|
|
254
258
|
exodus video storyboard <runId> [--json] the scene cards
|
|
255
259
|
exodus video approve <runId> [--json] approve the storyboard, or the uploaded cut
|
|
256
|
-
exodus video flag <runId> --note "<what is wrong>" [--json] send the storyboard back
|
|
260
|
+
exodus video flag <runId> --note "<what is wrong>" [--json] send the storyboard back — SHOW RUNS ONLY
|
|
257
261
|
exodus video status <runId> [--json] the park and each scene's clip/voice/picture
|
|
258
262
|
exodus video pull <runId> --out <dir> [--json] every piece + manifest.json
|
|
259
263
|
exodus video upload <runId> --file <cut.mp4> [--duration <s>] [--json] attach the cut (MP4/MOV/WebM, 200 MB)
|
|
260
|
-
Cut:
|
|
264
|
+
Cut: your choice of tool — Exodus does not stitch or mix. One option ships with the skill:
|
|
261
265
|
node .claude/skills/exodus-video/scripts/first-cut.mjs <dir> [--skip n,m] [--no-music] [--out f]
|
|
262
|
-
Returns: park lines with the exact next command; pull writes files + manifest.json
|
|
266
|
+
Returns: park lines with the exact next command (--wait ends on either video park, exit 0); pull writes files + manifest.json
|
|
263
267
|
```
|
|
264
268
|
|
|
265
269
|
---
|
|
@@ -1,52 +1,100 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: exodus-video
|
|
3
|
-
description: Make a video ad with Exodus from the terminal.
|
|
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`, no Show involved. Starting from a Show with `exodus video start --show` is the other way in. Either way 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, a Show, "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
|
|
7
7
|
|
|
8
|
-
Exodus makes the PIECES of a video ad, not the finished ad. From a
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
parks and waits for a cut. You pull the pieces, cut them into one video with
|
|
13
|
-
ffmpeg, and upload the cut. Approving the cut is what makes it the ad.
|
|
8
|
+
Exodus makes the PIECES of a video ad, not the finished ad. From a script, a run
|
|
9
|
+
writes a storyboard, draws one picture per scene, records the voice, renders one
|
|
10
|
+
video clip per scene, and generates a music bed. Then it parks and waits for a
|
|
11
|
+
cut.
|
|
14
12
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
There is no server-side stitch and no server-side mix. Nothing joins the clips,
|
|
14
|
+
nothing sets levels, nothing burns in captions
|
|
15
|
+
(`scout/src/workflow/video/music-bed.ts` states the ruling: "the cut, and the
|
|
16
|
+
mix, happen outside from the pieces the CLI pulls"). You pull the pieces, cut
|
|
17
|
+
them wherever you like, and upload the result. Approving the upload is what
|
|
18
|
+
makes it the ad.
|
|
19
|
+
|
|
20
|
+
Your job in this skill is to drive that loop, hand over the pieces with the
|
|
21
|
+
facts about them, and make whatever cut the user asked for. **How to edit is the
|
|
22
|
+
user's call, not this skill's** — see "Cutting the ad".
|
|
23
|
+
|
|
24
|
+
## Two routes in, one set of pieces out
|
|
25
|
+
|
|
26
|
+
**The workflow route (no Show) is the main path.** The ad is a saved workflow in
|
|
27
|
+
the brand, started script-first with `exodus workflow run`. This is where the
|
|
28
|
+
real work happens and what to assume unless the user says otherwise.
|
|
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".
|
|
18
40
|
|
|
19
41
|
## Before anything
|
|
20
42
|
|
|
21
|
-
- `npx @aicopycoders/exodus video --help`
|
|
22
|
-
`video` verb is admin-only and hidden from the
|
|
23
|
-
expected, not a broken install.
|
|
24
|
-
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
43
|
+
- `npx @aicopycoders/exodus video --help` and `workflow --help` are the
|
|
44
|
+
authoritative flag lists. The `video` verb is admin-only and hidden from the
|
|
45
|
+
top-level `--help`; that is expected, not a broken install.
|
|
46
|
+
- **Video is admin-only on both routes**, and says so two different ways.
|
|
47
|
+
- "video isn't enabled for this key", from a `video` verb, has two causes the
|
|
48
|
+
CLI cannot tell apart: the user is not an admin on this brand, or the run id
|
|
49
|
+
does not exist. Check the id first; if it is right, they need the admin role.
|
|
50
|
+
- "Video workflows aren't available on your account. Ask an admin if you need
|
|
51
|
+
video.", from `workflow run`, is unambiguous — `startRun` refuses any graph
|
|
52
|
+
containing a video node for a non-admin (`convex/workflows.ts:6471`).
|
|
53
|
+
|
|
54
|
+
No flag or retry fixes either one.
|
|
28
55
|
- Other failures: `npx @aicopycoders/exodus doctor` first, then follow what it prints.
|
|
29
|
-
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
56
|
+
- `ffprobe` on the PATH lets `upload` read the cut's length by itself. Without
|
|
57
|
+
it, `upload` falls back to parsing an MP4's own header, and for anything else
|
|
58
|
+
you must pass `--duration <sec>`. Editing tools have their own requirements on
|
|
59
|
+
top of that — the bundled script needs a full `ffmpeg` with `libx264` and
|
|
60
|
+
exits 2 if it is missing.
|
|
61
|
+
- The brand matters. A workflow and a Show both belong to one brand, so
|
|
62
|
+
`exodus workflow list` and `exodus video shows` show the active brand's only.
|
|
63
|
+
Wrong list means wrong brand; `exodus brand use <slug>`.
|
|
35
64
|
|
|
36
65
|
## The loop
|
|
37
66
|
|
|
38
|
-
Run each line as `npx @aicopycoders/exodus
|
|
67
|
+
Run each line as `npx @aicopycoders/exodus …`; `exodus` below is shorthand.
|
|
68
|
+
|
|
69
|
+
**Starting on the workflow route (the main path).** The workflow is already
|
|
70
|
+
saved in the brand — find it and read what it wants before you run it, because
|
|
71
|
+
the script's input field is named by the workflow, not by this skill.
|
|
72
|
+
|
|
73
|
+
```operator-guide
|
|
74
|
+
exodus workflow list the brand's workflows
|
|
75
|
+
exodus workflow describe <workflowId|name> what inputs it needs
|
|
76
|
+
exodus workflow run <workflowId|name> --input <field>=@script.txt --wait
|
|
77
|
+
start, stop at the storyboard gate
|
|
78
|
+
exodus video status <runId> where it is, per scene
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**Starting on the Show route.**
|
|
39
82
|
|
|
40
83
|
```operator-guide
|
|
41
84
|
exodus video shows which Shows are ready
|
|
42
85
|
exodus video start --show <id> --script script.txt --wait start, wait for the storyboard gate
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**From the storyboard on, both routes are the same.**
|
|
89
|
+
|
|
90
|
+
```operator-guide
|
|
43
91
|
exodus video storyboard <runId> read the scene cards
|
|
44
92
|
exodus video approve <runId> keep going
|
|
45
|
-
exodus video flag <runId> --note "what's wrong"
|
|
93
|
+
exodus video flag <runId> --note "what's wrong" send it back — SHOW RUNS ONLY
|
|
46
94
|
exodus video retry-frame <runId> --node <nodeId> --scene <n> redo one still at the pixel gate
|
|
47
95
|
exodus video status <runId> where it is, per scene
|
|
48
96
|
exodus video pull <runId> --out ./ad-<runId> every piece + manifest.json
|
|
49
|
-
|
|
97
|
+
… make the cut (your choice of tool)
|
|
50
98
|
exodus video upload <runId> --file ./ad-<runId>/cut.mp4 attach it
|
|
51
99
|
exodus video approve <runId> make it the ad
|
|
52
100
|
```
|
|
@@ -54,35 +102,129 @@ exodus video approve <runId> make it the ad
|
|
|
54
102
|
Every command takes `--json`. Read `--json` when you need to decide something
|
|
55
103
|
from the output; read the plain form when you are relaying it to the user.
|
|
56
104
|
|
|
57
|
-
**
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
105
|
+
**Read a run's position with `exodus video status`, on either route.** It is the
|
|
106
|
+
richest reader — every scene's clip, voice and picture, with the findings. A
|
|
107
|
+
`--wait` that landed already printed the same stop block, and `workflow inbox`
|
|
108
|
+
lists a parked video run (its row carries `hasShow`), but neither shows the
|
|
109
|
+
per-scene detail, and the checkpoint verbs cannot resolve a video park — see
|
|
110
|
+
"What each route can and cannot do".
|
|
111
|
+
|
|
112
|
+
**Where a run can stop.** Five stops, each with the exact next command printed
|
|
113
|
+
under it:
|
|
114
|
+
|
|
115
|
+
- storyboard gate: needs `approve`, or `flag` on a Show run.
|
|
116
|
+
- final watch: every piece is made; needs `pull`, a cut, and `upload`. A
|
|
117
|
+
workflow run only reaches this park if its video node sets `finalWatch: true`
|
|
118
|
+
— otherwise it skips straight to finished.
|
|
119
|
+
- paused: a builder checkpoint neither video verb resolves —
|
|
120
|
+
`exodus workflow checkpoint <runId>` is the verb for that one.
|
|
121
|
+
- failed: `status` shows how far it got, and a `--wait` that was running exits
|
|
122
|
+
1 with the error. Tell the user; do not start another run on your own.
|
|
66
123
|
- finished: pull what it made.
|
|
67
124
|
|
|
68
|
-
`
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
125
|
+
**`--wait` stops at both video parks on either route** (#1818). `workflow run
|
|
126
|
+
--wait` and `video start --wait` both end at the storyboard gate and again at
|
|
127
|
+
the final watch, exit 0, and close with the same `Parked:` block and next
|
|
128
|
+
commands that `exodus video status` prints. Under `--json` the line carries an
|
|
129
|
+
additive `stop` field in the same shape `video status --json` uses, so branch on
|
|
130
|
+
`stop.at` rather than re-deriving the park. Every other park still prints its
|
|
131
|
+
notice once and keeps polling, so a resolve from another shell carries the run
|
|
132
|
+
on — a Checkpoint park is `exodus workflow checkpoint <runId>`, not a video verb.
|
|
73
133
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
134
|
+
`exodus video status` prints these stop lines any time, so an agent that lost a
|
|
135
|
+
`--wait` terminal picks up from `status` alone. `--wait` gives up after 60
|
|
136
|
+
minutes with "Still running after 60 minutes" and exit 0; that is not done, so
|
|
137
|
+
check `status` before treating it as finished. Do not poll `status` in a tight
|
|
138
|
+
loop; a full run is several minutes.
|
|
139
|
+
|
|
140
|
+
**What costs money.** Starting the run creates the storyboard. Approving the
|
|
141
|
+
storyboard is the cost gate: it releases the paid rendering of every picture,
|
|
142
|
+
voice and clip. `shows`, `workflow list`, `workflow describe`, `video status`,
|
|
143
|
+
`storyboard` and `pull` are free reads; run them as often as you like. Never
|
|
144
|
+
start a second run to "retry" without the user asking; flag the storyboard (Show
|
|
145
|
+
runs) or tell them what failed instead.
|
|
146
|
+
|
|
147
|
+
**`--auto-approve` does not cover either video gate.** It releases Checkpoint
|
|
148
|
+
boxes only (`convex/workflows.ts:7266` keys on `pauseReason === "checkpoint"`),
|
|
149
|
+
and the storyboard gate and the final watch carry no pause reason. An unattended
|
|
150
|
+
`workflow run --auto-approve` still stops dead at the cost gate — which is the
|
|
151
|
+
safe behaviour, since nothing should approve paid rendering or ship an uncut ad
|
|
152
|
+
on its own.
|
|
153
|
+
|
|
154
|
+
## What each route can and cannot do
|
|
155
|
+
|
|
156
|
+
Verified against the routes, not assumed. Everything here is admin-gated on top.
|
|
157
|
+
|
|
158
|
+
| Verb | Workflow run (no Show) | Show run |
|
|
159
|
+
|---|---|---|
|
|
160
|
+
| `video shows` | nothing to list | lists the brand's Shows |
|
|
161
|
+
| `video start --show` | not the entry point | starts the run |
|
|
162
|
+
| `workflow run` | starts the run | not the entry point |
|
|
163
|
+
| `video status` | works | works |
|
|
164
|
+
| `video storyboard` | works | works |
|
|
165
|
+
| `video approve` | works, both gates | works, both gates |
|
|
166
|
+
| `video flag --note` | **refused** | works |
|
|
167
|
+
| script-turn edits at the gate | **refused** | dashboard only |
|
|
168
|
+
| `video retry-frame` | works | works |
|
|
169
|
+
| `video pull` | works | works |
|
|
170
|
+
| `video upload` | only if the video node sets `finalWatch: true` | works |
|
|
171
|
+
| `workflow checkpoint approve` | **refused at a video gate** | **refused at a video gate** |
|
|
172
|
+
|
|
173
|
+
Five consequences worth knowing before you promise a user anything:
|
|
174
|
+
|
|
175
|
+
- **A workflow only stops for your cut if its video node asks to.** `finalWatch`
|
|
176
|
+
defaults to `false` (`convex/lib/workflow/catalog.ts:780`), and the run only
|
|
177
|
+
parks for a final watch when it is `true`. Without it the run renders every
|
|
178
|
+
piece and runs straight to the end, and `video upload` answers "This ad isn't
|
|
179
|
+
waiting for your final cut right now." You can still `pull` the pieces and cut
|
|
180
|
+
them, but there is nothing to attach the cut to. **Check this before you
|
|
181
|
+
promise a user an upload**, with `exodus workflow export <workflowId|name>`:
|
|
182
|
+
the export carries every node's `config` verbatim, so look for the `video`
|
|
183
|
+
node and a `finalWatch: true` under it. The key is optional, so *absent* means
|
|
184
|
+
off just as much as `false` does. `exodus workflow describe` does NOT answer
|
|
185
|
+
this — it returns only name, inputs, prerequisites and outputs, no node
|
|
186
|
+
config. If `finalWatch` is off the workflow needs editing (`exodus-workflow`);
|
|
187
|
+
no flag on `workflow run` turns it on.
|
|
188
|
+
- **`exodus video approve` is the verb at a video gate, not the checkpoint
|
|
189
|
+
cluster.** `workflow checkpoint approve|edit|retry` preflight on
|
|
190
|
+
`pauseReason === "checkpoint"` (`exodus/commands/workflow.ts:4487-4519`), and
|
|
191
|
+
both video gates park with no pause reason at all, so those verbs refuse
|
|
192
|
+
client-side with "Run … is not parked for a checkpoint approval — it is parked
|
|
193
|
+
at the cost gate (legacy)." `exodus video approve` posts straight through and
|
|
194
|
+
works on either route. `exodus workflow cancel` also works at a video gate; it
|
|
195
|
+
has no preflight.
|
|
196
|
+
|
|
197
|
+
- **`flag` is Show-only and fails loudly.** `exodus video flag` on a workflow run
|
|
198
|
+
answers "Flag-with-note is only available on Video-module ads."
|
|
199
|
+
(`convex/videoModule.ts:2208`, guarded on `!run.showId`). Script-turn edits
|
|
200
|
+
refuse the same way (`:2334`). There is **no** CLI path that sends a workflow
|
|
201
|
+
run's storyboard back with a note — the direct-edit mutation exists but is
|
|
202
|
+
wired only to the dashboard's gate screen, not to any API route. From the
|
|
203
|
+
terminal the storyboard is approve-or-cancel: `exodus workflow cancel <runId>`
|
|
204
|
+
and run again with a better script, or edit the workflow. Do not offer `flag`
|
|
205
|
+
to a user on the workflow route.
|
|
206
|
+
- **`--wait` lands on both video parks, but only those.** Since #1818 every
|
|
207
|
+
`--wait` caller — `workflow run`, `triggers fire`, `checkpoint approve|retry`,
|
|
208
|
+
`repair retry|skip` — ends at a storyboard gate or a final watch, exits 0, and
|
|
209
|
+
prints the same `Parked:` block `video status` does
|
|
210
|
+
(`videoParkStop`, `exodus/commands/workflow.ts`). A Checkpoint, repair or
|
|
211
|
+
slots park is still not a landing: it prints its notice once and the loop
|
|
212
|
+
keeps polling, so a resolve from another shell carries the run on. Do not read
|
|
213
|
+
a park banner mid-poll as the wait having finished.
|
|
214
|
+
- **The run's page link differs.** A Show run's `dashboardUrl` opens
|
|
215
|
+
`/video?show=…&ad=…`; a showless run gets `/runs/<runId>`, because the `/video`
|
|
216
|
+
page cannot render one (`convex/http.ts:9204-9211`). The manifest's
|
|
217
|
+
`dashboardUrl` is always `/video?ad=<runId>`, which is the Show-route shape —
|
|
218
|
+
on a workflow run, hand the user the link `upload` prints, not the manifest's.
|
|
79
219
|
|
|
80
220
|
## The script file
|
|
81
221
|
|
|
82
|
-
Plain text. A `CAST:` block
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
222
|
+
Plain text. A `CAST:` block naming the speakers, one line per turn, then a
|
|
223
|
+
`CTA:` block. On a Show run the names must match what the Show locked. The
|
|
224
|
+
planner sizes every line at
|
|
225
|
+
2.5 words per second (`WORD_LADDER_WPS`), so a turn near 14 words plans a clip
|
|
226
|
+
near 6 seconds. If a line does get truncated in its clip, the manifest says so
|
|
227
|
+
(`speech-cutoff`).
|
|
86
228
|
|
|
87
229
|
```
|
|
88
230
|
CAST:
|
|
@@ -98,161 +240,293 @@ THE SOFTGEL: Tap below and give your eyes ClearBlink.
|
|
|
98
240
|
|
|
99
241
|
## Reading the storyboard gate
|
|
100
242
|
|
|
101
|
-
`storyboard <runId>` prints one card per scene: who speaks, the
|
|
102
|
-
planned duration, and whether its picture is drawn. Read it for the
|
|
103
|
-
ask one question
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
243
|
+
`storyboard <runId>` prints one card per scene on both routes: who speaks, the
|
|
244
|
+
line, the planned duration, and whether its picture is drawn. Read it for the
|
|
245
|
+
user, then ask one question.
|
|
246
|
+
|
|
247
|
+
On a **workflow run** the question is approve or don't — there is no flag. If
|
|
248
|
+
the storyboard is wrong, say so plainly and let the user decide between
|
|
249
|
+
approving anyway, cancelling (`exodus workflow cancel <runId>`) and re-running
|
|
250
|
+
with a better script, or changing the workflow. Approving is the cost gate, so
|
|
251
|
+
do not approve a storyboard you have just told the user is wrong.
|
|
252
|
+
|
|
253
|
+
On a **Show run** you can also send it back: `video flag <runId> --note "…"`.
|
|
254
|
+
The note is read by the model that rewrites the storyboard, so make it concrete
|
|
255
|
+
("scene 2 should be a close-up of the softgel, not the eyes").
|
|
107
256
|
|
|
108
|
-
|
|
257
|
+
Either way, do not approve on the user's behalf unless they told you to run the
|
|
258
|
+
whole loop unattended.
|
|
259
|
+
|
|
260
|
+
## The handover: what `pull` gives you
|
|
109
261
|
|
|
110
262
|
`pull` writes a flat folder and a `manifest.json` that indexes it. The manifest
|
|
111
|
-
is the data you decide from; the filenames are its values. Scene numbers are
|
|
112
|
-
|
|
263
|
+
is the data you decide from; the filenames are its values. Scene numbers are the
|
|
264
|
+
run's own. Every `pull` re-downloads and overwrites every file, so running it
|
|
265
|
+
again is always safe.
|
|
113
266
|
|
|
114
267
|
```
|
|
115
|
-
storyboard.json
|
|
116
|
-
reference.<ext> the reference still the pictures were drawn from
|
|
117
|
-
|
|
268
|
+
storyboard.json the whole storyboard envelope (see below)
|
|
269
|
+
reference.<ext> the reference still the scene pictures were drawn from
|
|
270
|
+
cast-<characterId>.<ext> one identity still per locked cast member
|
|
271
|
+
cast-ref-NN.<ext> an identity still the cast lock does not claim
|
|
272
|
+
scene-NN.keyframe.<ext> the picture scene NN's clip was animated from
|
|
273
|
+
scene-NN.<ext> the clip for scene NN, usually .mp4
|
|
118
274
|
scene-NN.voice.<ext> the voice track for scene NN, when one was delivered
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
narration.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
scene-NN.<ext> the clip for scene NN, usually .mp4, voice included
|
|
126
|
-
scene-NN.words.json [{w, s, e}] word timings for the speech in that scene, from
|
|
127
|
-
the clip's own dialogue or, on a narrated scene, from the
|
|
128
|
-
voice track (seconds from the start of that file)
|
|
129
|
-
music.<ext> the music bed, composed to the clips' total length
|
|
130
|
-
manifest.json the index below (includes narration { file, timing } when
|
|
131
|
-
a continuous master landed)
|
|
275
|
+
scene-NN.words.json [{w, s, e}] word timings for that scene's speech
|
|
276
|
+
narration.mp3 one continuous narrator take of the whole script
|
|
277
|
+
narration.json that take's settings, master-timeline words, and the
|
|
278
|
+
per-scene ranges it was cut into
|
|
279
|
+
music.<ext> the music bed
|
|
280
|
+
manifest.json the index below
|
|
132
281
|
```
|
|
133
282
|
|
|
134
283
|
`manifest.json`:
|
|
135
284
|
|
|
136
285
|
```
|
|
137
|
-
runId, pulledAt, dashboardUrl the run and its page
|
|
286
|
+
runId, pulledAt, dashboardUrl the run and its page (/video?ad=<runId>)
|
|
138
287
|
storyboard, reference, music filenames, or null when not delivered
|
|
288
|
+
narration { file, timing }, or null on a per-scene run
|
|
289
|
+
cast[] one per identity still
|
|
290
|
+
characterId, name from the run's cast lock; null for an
|
|
291
|
+
unclaimed ledger still
|
|
292
|
+
file, status, error
|
|
139
293
|
scenes[] one per scene, in order
|
|
140
294
|
sceneIndex, durationSec the run's number and the clip's real length
|
|
141
295
|
clip, voice, keyframe, words filenames, or null
|
|
142
|
-
wordsFrom clip | voice
|
|
143
|
-
|
|
296
|
+
wordsFrom clip | voice — which file the word times are
|
|
297
|
+
measured against; null when none came
|
|
144
298
|
clipStatus done | failed | running | pending | missing
|
|
145
299
|
error why the clip failed, when it did
|
|
146
300
|
flagged, findings[] QC verdict: {check, code, severity: fail|warn, detail}
|
|
301
|
+
keyframeFindings[] the same, from the picture check; null when
|
|
302
|
+
nobody checked the picture
|
|
147
303
|
qc {verdict, attempts} from the renderer
|
|
304
|
+
revoiced the cast's pinned voice replaced the clip's
|
|
305
|
+
generated voice; null when there is no clip
|
|
306
|
+
speechTrimmed the clip was re-cut to its spoken words
|
|
307
|
+
(0.5 s before the first, 0.8 s after the
|
|
308
|
+
last); null when there is no clip
|
|
148
309
|
failed[] files that did not download: {file, url, error}
|
|
149
310
|
```
|
|
150
311
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
312
|
+
### What each piece is
|
|
313
|
+
|
|
314
|
+
**The clips** (`scene-NN.<ext>`). The finished take, already trimmed and
|
|
315
|
+
revoiced where the run did that — not a raw render you have to repair.
|
|
316
|
+
`durationSec` is its real measured length. Whether the clip carries sound
|
|
317
|
+
depends on the scene; see "Which scenes carry their voice where" below.
|
|
318
|
+
|
|
319
|
+
**The storyboard** (`storyboard.json`). The intended cut, in the planner's own
|
|
320
|
+
terms. `script[]` is the ad's spoken words in order — `{lineId, speakerId, text}`,
|
|
321
|
+
with `speakerId: null` meaning narrator VO. Each scene carries `sceneIndex`,
|
|
322
|
+
`durationSec`, `dialogue[]`, `voText`, `notes`, and `lineIds` (the script lines
|
|
323
|
+
that scene renders). A scene with `kind: "cutaway"` carries `cueLineId` instead:
|
|
324
|
+
it is a picture-only takeover of the frame while the spoken track underneath
|
|
325
|
+
keeps running, so it adds no audio and takes no place in the running order. The
|
|
326
|
+
envelope also carries `overlays[]` (each with a `cueLineId` and a `placement`)
|
|
327
|
+
and `audio` (`roomTone`, `music`, `sfxCues[]`) — the planner's notes on what
|
|
328
|
+
should sit on top. **Route difference:** on a Show run the image and motion
|
|
329
|
+
prompts (`framePrompt`, `videoPrompt`, `referencePrompt`) are stripped before
|
|
330
|
+
the file reaches you, for everyone including admins — they are style-pack IP
|
|
331
|
+
(`convex/workflows.ts:13847`, `isModuleRun = !!run.showId`). A workflow run has
|
|
332
|
+
no `showId`, so an admin key gets them in full. Their absence on a Show run is
|
|
333
|
+
by design, not a broken pull.
|
|
334
|
+
|
|
335
|
+
**The narration track** (`narration.mp3` + `narration.json`). Present when the
|
|
336
|
+
run recorded continuously: every narrated scene's `voText` joined into one
|
|
337
|
+
script and read by ElevenLabs in a single pass at speed 1.15 — one take, one
|
|
338
|
+
pace, start to finish. `narration.json` carries `takeHash`, `voiceId`,
|
|
339
|
+
`modelId`, `speed`, `alignment: "elevenlabs-timestamps"`, the master's word
|
|
340
|
+
timings, and `cuts[]`: for each scene, `startSec` and `durationSec` **into
|
|
341
|
+
narration.mp3**. Runs go per-scene instead when any scene has on-camera
|
|
342
|
+
dialogue, when the voiceover node is configured `take: "per-scene"`, or on an
|
|
343
|
+
older storyboard carrying no `script[]`; then there is no `narration.mp3` and
|
|
344
|
+
each scene's voice is its own recording. That choice is made from the storyboard
|
|
345
|
+
and the node config (`planNarration`), not from the route — a workflow run and a
|
|
346
|
+
Show run reach it the same way.
|
|
347
|
+
|
|
348
|
+
**The per-scene voice tracks** (`scene-NN.voice.<ext>`). On a continuous run
|
|
349
|
+
these are cut out of `narration.mp3` itself — the same audio, sliced at the
|
|
350
|
+
ranges in `narration.json` with a 0.2 s pad and a boundary at the midpoint of
|
|
351
|
+
each gap. They are not separate performances. On a per-scene run each one is its
|
|
352
|
+
own ElevenLabs render.
|
|
353
|
+
|
|
354
|
+
**Which scenes carry their voice where.** A scene whose storyboard entry has a
|
|
355
|
+
non-empty `voText` is a narrator scene: its clip is rendered silent and the
|
|
356
|
+
voice arrives as a separate track. A scene with on-camera `dialogue[]` has the
|
|
357
|
+
speech inside the clip — under the default voice path the video model's invented
|
|
358
|
+
voice is then replaced, in place, with the cast member's pinned ElevenLabs voice
|
|
359
|
+
(that is `revoiced: true`). Other voice paths leave the render owning its own
|
|
360
|
+
audio, e.g. lip-sync retargeting a VO track onto the video. The path is frozen
|
|
361
|
+
on the run — `--voice-path` on `video start` sets it on a Show run, the node
|
|
362
|
+
config sets it on a workflow run. Read `wordsFrom` and `revoiced` per scene
|
|
363
|
+
rather than assuming.
|
|
364
|
+
|
|
365
|
+
**Word timings** (`scene-NN.words.json`). Seconds from the start of the file
|
|
366
|
+
`wordsFrom` names, always. `wordsFrom: "clip"` is an ElevenLabs Scribe
|
|
367
|
+
(`scribe_v1`) transcription of that clip's own audio, taken blind — the model
|
|
368
|
+
never sees the script — and re-based if the clip was speech-trimmed.
|
|
369
|
+
`wordsFrom: "voice"` is the ElevenLabs alignment of the continuous master,
|
|
370
|
+
re-based to the start of that scene's cut; on a per-scene run it is instead a
|
|
371
|
+
blind Scribe pass over that scene's own voice track. Nothing is aligned to the
|
|
372
|
+
finished ad — that timeline does not exist until you make it.
|
|
373
|
+
|
|
374
|
+
**Cast identity stills** (`cast-*.<ext>`). A full-body identity still per cast
|
|
375
|
+
member, on a plain neutral background in the run's style, minted once and
|
|
376
|
+
frozen onto the run as its cast lock. Every scene picture was anchored to these,
|
|
377
|
+
which is why the same character holds across scenes. `cast-<characterId>` is a
|
|
378
|
+
locked member; `cast-ref-NN` is a still from the same minting the lock does not
|
|
379
|
+
claim. Useful as reference when you cut, and as the thing to point at when a QC
|
|
380
|
+
finding says `wrong-character`.
|
|
381
|
+
|
|
382
|
+
**The music bed** (`music.<ext>`). One continuous track for the whole ad,
|
|
383
|
+
generated by Eleven Music after the last clip lands, at the landed clips' summed
|
|
384
|
+
length plus a 3-second safety pad. One track per ad, never per-scene. It is
|
|
385
|
+
never mixed into any clip and never levelled — gain, fade and ducking are the
|
|
386
|
+
cut-maker's decisions. `--no-music` on `start` means no artifact at all, and a
|
|
387
|
+
music failure degrades to a warning rather than blocking the run.
|
|
388
|
+
|
|
389
|
+
### Reading it before you cut
|
|
390
|
+
|
|
391
|
+
- `clipStatus: "done"` with a `clip` filename: the scene is cuttable.
|
|
392
|
+
- `wordsFrom: "voice"`, or a scene whose storyboard entry has `voText` and no
|
|
393
|
+
on-camera line: the clip is picture only and the voice track is the sound.
|
|
394
|
+
Do not use that clip's own audio.
|
|
395
|
+
- `manifest.narration` present: listen to `narration.mp3` end to end, then the
|
|
396
|
+
joins between adjacent `scene-NN.voice` files, before you trust the cut.
|
|
397
|
+
Matching voice IDs and a one-speaker diarization result do not prove
|
|
398
|
+
continuity (#1802).
|
|
160
399
|
- `flagged: true` is a warning, not a block. The clip was delivered anyway; the
|
|
161
400
|
findings say what the QC model saw (`wrong-character`, `eyeline-off`,
|
|
162
401
|
`set-drift`, `speech-cutoff`, `framing-off`). Tell the user which scenes are
|
|
163
402
|
flagged and why, and let them decide whether to keep, trim, or drop each one.
|
|
164
403
|
A `speech-cutoff` on the CTA scene is the one to worry about; the ad's last
|
|
165
|
-
words are missing.
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
404
|
+
words are missing. `status` shows the checker's own wording behind each
|
|
405
|
+
finding; the manifest carries only the verdict.
|
|
406
|
+
- `clipStatus: "failed"` or `"missing"` with a `keyframe`: `durationSec` is null
|
|
407
|
+
(it comes from the clip), but the scene can still hold its place as a still —
|
|
408
|
+
for the length of its voice track, or the storyboard's planned duration when
|
|
409
|
+
there is no voice.
|
|
170
410
|
- `clipStatus: "running"` or `"pending"`: pull again later. `status` says when
|
|
171
411
|
the run parks at the final watch, which means nothing is still rendering.
|
|
172
|
-
- `failed[]` non-empty: run the same `pull` again.
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
is
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
###
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
the
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
412
|
+
- `failed[]` non-empty: run the same `pull` again.
|
|
413
|
+
|
|
414
|
+
## Cutting the ad
|
|
415
|
+
|
|
416
|
+
Editing is the part every user does differently, and the team is deliberately
|
|
417
|
+
still finding out what works. Nothing below is the house way. Ask the user which
|
|
418
|
+
they want; if they have not said and are not around, say which you used and why.
|
|
419
|
+
|
|
420
|
+
- **The bundled script** — `node .claude/skills/exodus-video/scripts/first-cut.mjs
|
|
421
|
+
./ad-<runId>`, path from the workspace root (from a brand subfolder, prefix
|
|
422
|
+
`../`). A plain assembly with no editing decisions in it, for seeing the whole
|
|
423
|
+
ad end to end quickly. Read "What the bundled script does" before you run it:
|
|
424
|
+
it changes the audio.
|
|
425
|
+
- **HyperFrames** — HTML/CSS compositions rendered to video. Suits an ad that
|
|
426
|
+
needs captions, title cards, overlays or motion graphics laid over the clips.
|
|
427
|
+
It is a separate skill pack; check that a `hyperframes` skill is installed
|
|
428
|
+
before offering it.
|
|
429
|
+
- **An NLE over MCP** — DaVinci Resolve publishes an MCP server, so a timeline
|
|
430
|
+
can be built in a real editor and the user can carry on by hand afterwards.
|
|
431
|
+
Suits a user who wants to keep editing after you stop. Check what MCP servers
|
|
432
|
+
are actually connected first; do not assume one is there.
|
|
433
|
+
- **ffmpeg by hand** — full control with nothing extra to install. Suits a trim,
|
|
434
|
+
a reorder, an aspect change, or anything the others make awkward.
|
|
435
|
+
|
|
436
|
+
Whatever you use, the only thing the product requires of the output is that
|
|
437
|
+
`upload` accepts it (see below).
|
|
438
|
+
|
|
439
|
+
### What the storyboard asks the cut to do
|
|
440
|
+
|
|
441
|
+
Read these out of `storyboard.json` before you build anything, in any tool:
|
|
442
|
+
|
|
443
|
+
- **Running order.** A-roll scenes (no `kind`, or `kind: "aroll"`) in
|
|
444
|
+
`sceneIndex` order are the spine. Their summed length is the ad's length.
|
|
445
|
+
- **Cutaways.** `kind: "cutaway"` + `cueLineId`: lay the picture over the spine
|
|
446
|
+
at the moment that script line is spoken, taking no audio and no place in the
|
|
447
|
+
running order. To find the moment, locate the A-roll scene whose `lineIds`
|
|
448
|
+
contain the cue line, then read the start time of the first word of that line
|
|
449
|
+
out of that scene's `words.json`.
|
|
450
|
+
- **Overlays and SFX.** `overlays[]` and `audio.sfxCues[]` are cued the same
|
|
451
|
+
way, by `cueLineId`. Nothing renders them for you.
|
|
452
|
+
- **Captions.** Nothing is burned into any clip. Captions belong in post; the
|
|
453
|
+
word timings are what an editor imports to build them.
|
|
454
|
+
|
|
455
|
+
### What the bundled script does
|
|
456
|
+
|
|
457
|
+
State this to the user before you run it, because an editor does not expect a
|
|
458
|
+
cut tool to alter the performance:
|
|
459
|
+
|
|
460
|
+
- **It changes the narration's speed.** When a narrated scene's voice runs
|
|
461
|
+
longer than its clip, it speeds that scene's voice up — to at most 1.26×
|
|
462
|
+
(`ATEMPO_CAP`, `scripts/first-cut.mjs:39`) — and holds the last frame for the
|
|
463
|
+
remainder. The amount differs per scene, so one ad can play scene 1 at normal
|
|
464
|
+
speed and scene 3 a quarter faster. **This is the script's behaviour, not the
|
|
465
|
+
system's**; nothing in the pipeline does it. It is the defect tracked in
|
|
466
|
+
#1814. If the user cares about a single consistent narrator pace, cut
|
|
467
|
+
elsewhere, or skip the scenes it stretches.
|
|
468
|
+
- **It ignores the continuous master.** It reads only `scene-NN.voice` files;
|
|
469
|
+
it never opens `narration.mp3` or `manifest.narration`. A tool that lays the
|
|
470
|
+
master down whole and cuts the pictures to it keeps the read intact.
|
|
471
|
+
- Otherwise: A-roll in manifest order as the spine, each cutaway laid over the
|
|
472
|
+
spine at its cued word (falling back to a proportional estimate when the scene
|
|
473
|
+
has no word times, and saying which method it used), a narrated scene's voice
|
|
474
|
+
replacing whatever the clip recorded, a dialogue scene keeping its performed
|
|
475
|
+
line, a clip-less scene becoming a still, everything normalized to 1080×1920
|
|
476
|
+
at 24 fps and −16 LUFS per segment, a generated room-tone bed under the whole
|
|
477
|
+
ad, and the music bed underneath at low level. Output is H.264 + AAC in an MP4
|
|
478
|
+
with `-movflags +faststart`.
|
|
479
|
+
- Flags: `--out <file>`, `--skip 2,5`, `--no-music`. It prints what went in,
|
|
480
|
+
what was left out and why, and the upload command.
|
|
481
|
+
- Beside the MP4 it writes `cut.words.json` (every spoken word of the finished
|
|
482
|
+
ad with its start and end) and `cut.srt` (the same words grouped into subtitle
|
|
483
|
+
lines), named from the `--out` basename. Those times are on the finished ad's
|
|
484
|
+
timeline and include the speed changes above. Scenes with no word times leave
|
|
485
|
+
gaps and are named in the report; when no scene delivered any, neither file is
|
|
486
|
+
written. These sidecars are the script's own output — another tool will not
|
|
487
|
+
produce them.
|
|
488
|
+
|
|
489
|
+
Before uploading anything, from any tool, check what you made:
|
|
490
|
+
`ffprobe -v error -show_entries format=duration -of default=nw=1:nk=1 cut.mp4`
|
|
491
|
+
should be close to the sum of the scene durations you kept. A cut that is a few
|
|
492
|
+
seconds long when five clips went in means a filter dropped inputs; do not
|
|
493
|
+
upload it.
|
|
494
|
+
|
|
495
|
+
## What the product actually requires
|
|
496
|
+
|
|
497
|
+
These are the system's rules. They hold no matter which tool made the cut.
|
|
498
|
+
|
|
499
|
+
**`upload` accepts** `.mp4`, `.m4v`, `.mov` or `.webm`, up to 200 MB. The server
|
|
500
|
+
re-derives the type from the stored bytes and rejects anything that is not video.
|
|
501
|
+
The length comes from `--duration` if you pass it, else `ffprobe`, else the MP4
|
|
502
|
+
header; when none of those works the CLI says so and asks for `--duration`. The
|
|
503
|
+
length you give is recorded as stated and never measured against the file, so
|
|
504
|
+
give a real one.
|
|
505
|
+
|
|
506
|
+
**`upload` only works at the final watch.** The run has to be parked there with
|
|
507
|
+
its video step done, or the server answers "This ad isn't waiting for your final
|
|
508
|
+
cut right now." On a workflow run that park exists only when the video node sets
|
|
509
|
+
`finalWatch: true`; a Show run always has it. Uploading again replaces the
|
|
510
|
+
previous cut in place rather than adding a second one. Upload does not move the
|
|
511
|
+
run; it stays parked until someone approves.
|
|
512
|
+
|
|
513
|
+
**Approving is what makes it the ad, and it is one-way.** `approve` at the final
|
|
514
|
+
watch stamps the run delivered and resumes it. After that the server refuses any
|
|
515
|
+
further upload ("This ad is already delivered — nothing to change"). So approve
|
|
516
|
+
only when the user has seen the cut or told you to run unattended.
|
|
517
|
+
|
|
518
|
+
**Nothing checks that the cut matches the approved ad.** The server verifies
|
|
519
|
+
that the file is a video and that the run is at the right gate. It never
|
|
520
|
+
compares the cut against the script or the storyboard. The approved spoken copy
|
|
521
|
+
and the approved narration are therefore not the editor's to change — reordering
|
|
522
|
+
lines, re-recording narration, or cutting words out of the CTA will ship,
|
|
523
|
+
silently. Cut the pictures to the words, not the words to the pictures. If the
|
|
524
|
+
words are wrong, that is a storyboard flag or a new run, not an edit.
|
|
252
525
|
|
|
253
526
|
## Reporting to the user
|
|
254
527
|
|
|
255
528
|
Lead with where the run is and what it needs from them. Then, when you pulled
|
|
256
529
|
or cut: how many scenes, which are flagged and why in one line each, what you
|
|
257
|
-
left out,
|
|
258
|
-
Never paste the manifest;
|
|
530
|
+
left out, which tool you cut with and anything it changed, the cut's length, and
|
|
531
|
+
the exact command or link for the next step. Never paste the manifest;
|
|
532
|
+
summarize it.
|
|
@@ -110,7 +110,7 @@ The end-to-end narrative, every step naming its verb. You rarely walk all of it
|
|
|
110
110
|
3. **Import** — `workflow import <file>` to create, or `import <file> --update <id>` to edit in place.
|
|
111
111
|
4. **Describe** — `workflow describe` to confirm inputs, prerequisites (✓/✗ primers), and outputs before spending a run.
|
|
112
112
|
5. **Run** — `workflow run … --wait` (streams progress, prints outputs) or omit `--wait` and poll `workflow status --id <runId>`. Inputs come from `--input key=value` flags, from `--fill <name>` (a saved fill — a named, reusable set of launch answers saved from the app's Run dialog; flags win per key when you pass both), or a mix. Launches that don't satisfy the workflow's entry contract are rejected at the door with the gap named — `describe` shows each input's type, required/optional, and accepted values. Add `--auto-approve` only when the run is meant to go start-to-finish with nobody watching: it approves every Checkpoint the run hits, unchanged, instead of stopping for a verdict. It's a per-launch choice, not a setting on the workflow, and the run keeps a record of each stop it waved through — so confirm with the user before you pass it.
|
|
113
|
-
6. **Notice the park** — a run can stop and wait on you. There are no webhooks in v1; you notice a park one of three ways: `run --wait` prints a pause notice naming the verb to use (the command keeps waiting — resolve from another shell, or Ctrl-C and pick it up via `inbox`), `workflow inbox` lists it, or `workflow status --id <runId>` prints `status: Awaiting approval`.
|
|
113
|
+
6. **Notice the park** — a run can stop and wait on you. There are no webhooks in v1; you notice a park one of three ways: `run --wait` prints a pause notice naming the verb to use (the command keeps waiting — resolve from another shell, or Ctrl-C and pick it up via `inbox`), `workflow inbox` lists it, or `workflow status --id <runId>` prints `status: Awaiting approval`. One exception: a **video** workflow's storyboard gate and final watch END the wait (exit 0) and print the same `Parked: …` block `exodus video status` prints, with the next command under it — resolve with the `exodus video` verbs it names (see the `exodus-video` skill).
|
|
114
114
|
7. **Resolve** — `workflow checkpoint <runId> …` (the run reached a Checkpoint box), `workflow repair <runId> …` (a stalled collector), or `workflow answer <runId> …` (a nested workflow's slots).
|
|
115
115
|
8. **Harvest** — read deliverables with `workflow status --id <runId> --json`; read banked deposits with `bank show <key>`; keep thinking with a bot via `session chat`.
|
|
116
116
|
9. **Promote** — `bank promote <key>` lands the winner **and** emits the `winner-promoted` event, so any workflow with an **enabled** matching trigger starts a background run. To find out whether one actually started, run `workflow status` with **no** `--id` — the recent-run list. Don't send the user to `inbox` for this: `inbox` only holds runs waiting on a person, and a flywheel run with no approval step will never appear there (§5).
|
|
@@ -192,7 +192,7 @@ A run doesn't always finish on its own — it can **park** and wait for a human
|
|
|
192
192
|
- **repair** — a require-all collector stalled on a dead input and needs a decision.
|
|
193
193
|
- **slots** — a nested sub-workflow is waiting on inputs (slot answers).
|
|
194
194
|
|
|
195
|
-
**No webhooks in v1 — nothing parks silently, but nothing pings you either.** You find parks three ways: `run --wait` prints a pause notice naming the verb to use (a park is not terminal — the command keeps polling so it can resume after you resolve; resolve from another shell, or Ctrl-C and pick it up via `inbox`); `workflow inbox` lists every parked run badged by kind (`checkpoint`/`repair`/`slots`; the badge `gate` shows up only on a pre-2.0 run parked at the retired Gate node — those can't be resumed, cancel and run the workflow again) and how it started (`bg`, `trig:<event>`); or `workflow status --id <runId>` prints `status: Awaiting approval`.
|
|
195
|
+
**No webhooks in v1 — nothing parks silently, but nothing pings you either.** You find parks three ways: `run --wait` prints a pause notice naming the verb to use (a builder park is not terminal — the command keeps polling so it can resume after you resolve; resolve from another shell, or Ctrl-C and pick it up via `inbox`. A **video** workflow's storyboard gate and final watch are the exception: they end the wait with exit 0 and the `Parked: …` block `exodus video status` prints, next command included); `workflow inbox` lists every parked run badged by kind (`checkpoint`/`repair`/`slots`; the badge `gate` shows up only on a pre-2.0 run parked at the retired Gate node — those can't be resumed, cancel and run the workflow again) and how it started (`bg`, `trig:<event>`); or `workflow status --id <runId>` prints `status: Awaiting approval`.
|
|
196
196
|
|
|
197
197
|
### `inbox` vs the run list — two different questions
|
|
198
198
|
|
|
@@ -9,6 +9,7 @@ import { runVerdict } from "../lib/runVerdict.js";
|
|
|
9
9
|
import { workflowToYaml, parseWorkflowText } from "../lib/workflowText.js";
|
|
10
10
|
import { missingRouteLine } from "../lib/route-support.js";
|
|
11
11
|
import { getChannel } from "../lib/channel.js";
|
|
12
|
+
import { asVideoRun, classifyRun, stopLines } from "./video.js";
|
|
12
13
|
export const helpText = `
|
|
13
14
|
exodus workflow — List, describe, run, inspect, import, and export saved workflows
|
|
14
15
|
|
|
@@ -90,6 +91,13 @@ Flags:
|
|
|
90
91
|
person's verdict. This is a choice you make for ONE
|
|
91
92
|
launch — it is never a setting on the workflow itself.
|
|
92
93
|
--wait Poll until the workflow run reaches a terminal status.
|
|
94
|
+
A video workflow's storyboard gate and final watch END
|
|
95
|
+
the wait too (exit 0): the run is parked on a decision
|
|
96
|
+
only you can make, so the command prints the same
|
|
97
|
+
"Parked: …" block and next commands "exodus video
|
|
98
|
+
status" prints. A Checkpoint / repair / slots park
|
|
99
|
+
prints its pause notice once and keeps polling so a
|
|
100
|
+
resolve from another shell carries the run on.
|
|
93
101
|
(checkpoint retry) Re-runs the step feeding the
|
|
94
102
|
Checkpoint box and waits until its fresh output is
|
|
95
103
|
ready and the run parks again — that IS its finish
|
|
@@ -1796,30 +1804,36 @@ export async function runFlow(workflowRef, opts, deps) {
|
|
|
1796
1804
|
return { code: waited.code, lines: [...lines, ...waited.lines] };
|
|
1797
1805
|
}
|
|
1798
1806
|
const WAIT_TERMINAL_STATUSES = TERMINAL_RUN_STATUSES.flatMap((s) => storedWorkflowStatusForms(s));
|
|
1807
|
+
function isParkedSnapshot(raw) {
|
|
1808
|
+
const status = raw["status"];
|
|
1809
|
+
return typeof status === "string" && normalizeRunStatus(status) === "awaiting-approval";
|
|
1810
|
+
}
|
|
1811
|
+
function videoParkStop(raw) {
|
|
1812
|
+
if (!isParkedSnapshot(raw))
|
|
1813
|
+
return undefined;
|
|
1814
|
+
const stop = classifyRun(asVideoRun(raw));
|
|
1815
|
+
return stop.at === "storyboard-gate" || stop.at === "final-watch" ? stop : undefined;
|
|
1816
|
+
}
|
|
1799
1817
|
async function waitForRun(runId, opts, deps) {
|
|
1800
1818
|
const seen = new Map();
|
|
1801
1819
|
let pausedNotified = false;
|
|
1802
1820
|
const landOnPark = opts.landOnPark;
|
|
1821
|
+
const isLandingPark = (raw) => (landOnPark !== undefined && raw["pauseReason"] === landOnPark.pauseReason) ||
|
|
1822
|
+
videoParkStop(raw) !== undefined;
|
|
1803
1823
|
const pollResult = await deps.poll({
|
|
1804
1824
|
path: `${STATUS_PATH}?runId=${encodeURIComponent(runId)}`,
|
|
1805
1825
|
intervalMs: 3_000,
|
|
1806
1826
|
timeoutMs: 60 * 60 * 1000,
|
|
1807
|
-
terminalStatuses:
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
isDone: (raw) => !(typeof raw["status"] === "string" &&
|
|
1813
|
-
normalizeRunStatus(raw["status"]) === "awaiting-approval") || raw["pauseReason"] === landOnPark.pauseReason,
|
|
1814
|
-
}
|
|
1815
|
-
: {}),
|
|
1827
|
+
terminalStatuses: [
|
|
1828
|
+
...WAIT_TERMINAL_STATUSES,
|
|
1829
|
+
...storedWorkflowStatusForms("awaiting-approval"),
|
|
1830
|
+
],
|
|
1831
|
+
isDone: (raw) => !isParkedSnapshot(raw) || isLandingPark(raw),
|
|
1816
1832
|
onProgress: (raw) => {
|
|
1817
1833
|
if (opts.json || !opts.onProgressLine)
|
|
1818
1834
|
return;
|
|
1819
|
-
const
|
|
1820
|
-
const
|
|
1821
|
-
normalizeRunStatus(rawStatus) === "awaiting-approval";
|
|
1822
|
-
const isLanding = landOnPark !== undefined && raw["pauseReason"] === landOnPark.pauseReason;
|
|
1835
|
+
const parked = isParkedSnapshot(raw);
|
|
1836
|
+
const isLanding = isLandingPark(raw);
|
|
1823
1837
|
if (parked && !pausedNotified && !isLanding) {
|
|
1824
1838
|
pausedNotified = true;
|
|
1825
1839
|
const dashboardUrl = deps.dashboardUrl ?? getDashboardUrl();
|
|
@@ -1848,6 +1862,9 @@ async function waitForRun(runId, opts, deps) {
|
|
|
1848
1862
|
const saved = opts.out !== undefined && terminalRun
|
|
1849
1863
|
? await saveDeliveries(terminalRun, opts.out, deps)
|
|
1850
1864
|
: undefined;
|
|
1865
|
+
const videoStop = !pollResult.timedOut && isRecord(pollResult.data)
|
|
1866
|
+
? videoParkStop(pollResult.data)
|
|
1867
|
+
: undefined;
|
|
1851
1868
|
if (opts.json) {
|
|
1852
1869
|
return {
|
|
1853
1870
|
code: pollResult.ok ? 0 : 1,
|
|
@@ -1856,6 +1873,7 @@ async function waitForRun(runId, opts, deps) {
|
|
|
1856
1873
|
...opts.jsonBase,
|
|
1857
1874
|
result: pollResult.data,
|
|
1858
1875
|
timedOut: pollResult.timedOut,
|
|
1876
|
+
...(videoStop ? { stop: videoStop } : {}),
|
|
1859
1877
|
...(saved ? { saved: saved.paths } : {}),
|
|
1860
1878
|
}),
|
|
1861
1879
|
],
|
|
@@ -1884,6 +1902,9 @@ async function waitForRun(runId, opts, deps) {
|
|
|
1884
1902
|
const dashboardUrl = deps.dashboardUrl ?? getDashboardUrl();
|
|
1885
1903
|
lines.push("", landOnPark.headline, ...formatPauseNotice(landOnPark.pauseReason, runId, dashboardUrl).slice(1));
|
|
1886
1904
|
}
|
|
1905
|
+
if (videoStop) {
|
|
1906
|
+
lines.push("", ...stopLines(videoStop, runId, deps.dashboardUrl ?? getDashboardUrl()));
|
|
1907
|
+
}
|
|
1887
1908
|
return { code: pollResult.ok ? 0 : 1, lines };
|
|
1888
1909
|
}
|
|
1889
1910
|
export async function statusFlow(opts, deps) {
|