@aicopycoders/exodus 2026.9.1100-beta.0 → 2026.9.1100-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 -0
- package/assets/docs/PIPELINES.md +22 -0
- package/assets/skills/exodus/SKILL.md +1 -0
- package/assets/skills/exodus-video/SKILL.md +240 -0
- package/assets/skills/exodus-video/scripts/first-cut.mjs +471 -0
- package/dist/commands/video.d.ts +7 -0
- package/dist/commands/video.js +153 -20
- package/package.json +1 -1
package/assets/docs/CLAUDE.md
CHANGED
|
@@ -61,6 +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 Show: start a run from a script, read and approve the storyboard, `npx @aicopycoders/exodus video pull` every piece (per-scene clips, voices, keyframes, word timings, music bed) plus a manifest, cut them with the bundled first-cut script, upload the cut and approve it. Admin-only. |
|
|
64
65
|
| **Browse** | `exodus-browse` | View history and retrieve past outputs |
|
|
65
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) |
|
|
66
67
|
| **Ads** | `exodus-ads` | Read your own live Meta ads once the account is connected on the dashboard — `npx @aicopycoders/exodus ads list` (sort by cost per result, spend or CTR), `ads show <adId>` (copy, both windows, media links, comment counts), `npx @aicopycoders/exodus comments list` (already commenter-redacted), and `npx @aicopycoders/exodus winners definition` (what this brand means by a winner). Read-only; no Meta login or MCP needed. |
|
package/assets/docs/PIPELINES.md
CHANGED
|
@@ -16,6 +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, first cut, upload, approve (admin-only) |
|
|
19
20
|
| exodus-browse | View History and Surface the Right Run |
|
|
20
21
|
| exodus-drive | Google Drive, Docs, Sheets via the Dashboard's OAuth |
|
|
21
22
|
| exodus-winners | Mine your own Meta ad account for winners (needs the Meta Ads MCP) and import them into Exodus |
|
|
@@ -222,6 +223,27 @@ Returns: a table, --json data, or an exported CSV/JSON file
|
|
|
222
223
|
|
|
223
224
|
---
|
|
224
225
|
|
|
226
|
+
## exodus-video
|
|
227
|
+
|
|
228
|
+
**What it does:** Makes a video ad from a locked Show, in pieces. The run writes the storyboard, draws a picture per scene, records a voice per scene, renders a clip per scene and composes a music bed, then parks for a cut. The CLI pulls the pieces to a folder with a `manifest.json`, the bundled script makes a plain cut, and the upload attaches it for approval. Admin-only.
|
|
229
|
+
|
|
230
|
+
```operator-guide
|
|
231
|
+
Commands:
|
|
232
|
+
exodus video shows [--json] Shows and whether each is ready
|
|
233
|
+
exodus video start --show <id> --script <file> [--wait] [--json] start an ad run; --wait parks at the storyboard gate
|
|
234
|
+
exodus video storyboard <runId> [--json] the scene cards
|
|
235
|
+
exodus video approve <runId> [--json] approve the storyboard, or the uploaded cut
|
|
236
|
+
exodus video flag <runId> --note "<what is wrong>" [--json] send the storyboard back
|
|
237
|
+
exodus video status <runId> [--json] the park and each scene's clip/voice/picture
|
|
238
|
+
exodus video pull <runId> --out <dir> [--json] every piece + manifest.json
|
|
239
|
+
exodus video upload <runId> --file <cut.mp4> [--duration <s>] [--json] attach the cut (MP4/MOV/WebM, 200 MB)
|
|
240
|
+
Cut:
|
|
241
|
+
node .claude/skills/exodus-video/scripts/first-cut.mjs <dir> [--skip n,m] [--no-music] [--out f]
|
|
242
|
+
Returns: park lines with the exact next command; pull writes files + manifest.json
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
225
247
|
## exodus-browse
|
|
226
248
|
|
|
227
249
|
**What it does:** View past pipeline runs and pull outputs from previous sessions.
|
|
@@ -25,6 +25,7 @@ This skill ROUTES. Read what the user wants, name the destination skill, and han
|
|
|
25
25
|
| Onboard a new brand / build the primer | `exodus-primer` (with winning ads) or `exodus-foundation` (no ads yet) |
|
|
26
26
|
| List/switch/troubleshoot brands | `exodus-brand` |
|
|
27
27
|
| Read the Scout hook library — "what do my 15×+ hooks have in common", "show me my Spanish on-screen hooks", "why did that reel score 22×", "export my hooks to a spreadsheet" | `exodus-hooks` (read-only; promoting a card is a dashboard act) |
|
|
28
|
+
| Make a video ad, or anything about a Show or a video run — "start a video ad from this script", "is my video run done", "pull the clips and cut them", "upload my cut", "what's wrong with scene 3" (`exodus video`) | `exodus-video` (admin-only on the dashboard; it drives start → storyboard → pull → cut → upload) |
|
|
28
29
|
| See past runs / find an output | `exodus-browse` |
|
|
29
30
|
| Open or create Google Docs/Sheets | `exodus-drive` |
|
|
30
31
|
| Read their OWN live Meta ads and their numbers — "what are my cheapest ads", "where is my spend going", "show me that ad", "what are people saying in the comments on my ads" (`exodus ads` / `exodus comments`) | `exodus-ads` (read-only; already synced by the dashboard, no Meta MCP needed) |
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: exodus-video
|
|
3
|
+
description: Make a video ad with Exodus from the terminal. Start an ad from a Show, read and approve its storyboard, pull every finished piece (per-scene clips, voice tracks, keyframes, word timings, the music bed) to a folder, cut them into one video, and upload the cut back for approval, all through the `npx @aicopycoders/exodus video` command family. Use it whenever the user has invoked Exodus and wants anything to do with a video ad, a Show, 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, start 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. Static image ads are `exodus-image`; copy is `exodus-write`.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Video: make the pieces, cut the ad, hand it back
|
|
7
|
+
|
|
8
|
+
Exodus makes the PIECES of a video ad, not the finished ad. From a locked Show
|
|
9
|
+
(its cast, rooms and voices, set up on the dashboard) and a script, the run
|
|
10
|
+
writes a storyboard, draws one picture per scene, records one voice track per
|
|
11
|
+
scene, renders one video clip per scene, and composes a music bed. Then it
|
|
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.
|
|
14
|
+
|
|
15
|
+
Your job in this skill is to drive that loop and to make a sound first cut.
|
|
16
|
+
Editing craft (captions, pacing, b-roll, sound design) is not here yet; a plain,
|
|
17
|
+
correct assembly is the bar.
|
|
18
|
+
|
|
19
|
+
## Before anything
|
|
20
|
+
|
|
21
|
+
- `npx @aicopycoders/exodus video --help` is the authoritative flag list. The
|
|
22
|
+
`video` verb is admin-only and hidden from the top-level `--help`; that is
|
|
23
|
+
expected, not a broken install.
|
|
24
|
+
- "video isn't enabled for this key" has two causes the CLI cannot tell apart:
|
|
25
|
+
the dashboard user is not an admin on this brand, or the run id does not
|
|
26
|
+
exist. Check the id first; if it is right, the user needs the admin role.
|
|
27
|
+
No flag or retry fixes either.
|
|
28
|
+
- Other failures: `npx @aicopycoders/exodus doctor` first, then follow what it prints.
|
|
29
|
+
- Cutting needs a full `ffmpeg` build with `ffprobe` and the `libx264`
|
|
30
|
+
encoder on the PATH (`brew install ffmpeg` / `apt install ffmpeg`; the
|
|
31
|
+
script checks and says so). Check before you pull, so the user is not left
|
|
32
|
+
with a folder they cannot use.
|
|
33
|
+
- The brand matters: a Show belongs to a brand, so `exodus video shows` lists
|
|
34
|
+
the active brand's Shows. Wrong list means wrong brand; `exodus brand use <slug>`.
|
|
35
|
+
|
|
36
|
+
## The loop
|
|
37
|
+
|
|
38
|
+
Run each line as `npx @aicopycoders/exodus video …`; `exodus` below is shorthand.
|
|
39
|
+
|
|
40
|
+
```operator-guide
|
|
41
|
+
exodus video shows which Shows are ready
|
|
42
|
+
exodus video start --show <id> --script script.txt --wait start, wait for the storyboard gate
|
|
43
|
+
exodus video storyboard <runId> read the scene cards
|
|
44
|
+
exodus video approve <runId> keep going
|
|
45
|
+
exodus video flag <runId> --note "what's wrong" or send it back
|
|
46
|
+
exodus video status <runId> where it is, per scene
|
|
47
|
+
exodus video pull <runId> --out ./ad-<runId> every piece + manifest.json
|
|
48
|
+
node .claude/skills/exodus-video/scripts/first-cut.mjs ./ad-<runId> a plain cut (path from the workspace root)
|
|
49
|
+
exodus video upload <runId> --file ./ad-<runId>/cut.mp4 attach it
|
|
50
|
+
exodus video approve <runId> make it the ad
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Every command takes `--json`. Read `--json` when you need to decide something
|
|
54
|
+
from the output; read the plain form when you are relaying it to the user.
|
|
55
|
+
|
|
56
|
+
**Where a run can stop.** `start --wait` is silent while it polls, then prints
|
|
57
|
+
the stage lines and the stop in one burst. Five stops, each with the exact next
|
|
58
|
+
command printed under it:
|
|
59
|
+
|
|
60
|
+
- storyboard gate: needs `approve` or `flag`.
|
|
61
|
+
- final watch: every piece is made; needs `pull`, a cut, and `upload`.
|
|
62
|
+
- paused: a builder checkpoint the CLI cannot resolve; open the run's page.
|
|
63
|
+
- failed: the run's error is printed and the command exits 1; `status` shows
|
|
64
|
+
how far it got. Tell the user; do not start another run on your own.
|
|
65
|
+
- finished: pull what it made.
|
|
66
|
+
|
|
67
|
+
`status` prints the same stop lines any time, so an agent that lost the
|
|
68
|
+
`--wait` terminal picks up from `status` alone. `--wait` gives up after 60
|
|
69
|
+
minutes with "Still running after 60 minutes" and exit 0; that is not done,
|
|
70
|
+
so check `status` before treating it as finished. Do not poll `status` in a
|
|
71
|
+
tight loop; a full run is several minutes.
|
|
72
|
+
|
|
73
|
+
**What costs money.** `start` creates the run and the storyboard. Approving
|
|
74
|
+
the storyboard is the cost gate: it releases the paid rendering of every
|
|
75
|
+
picture, voice and clip. `shows`, `status`, `storyboard` and `pull` are free
|
|
76
|
+
reads; run them as often as you like. Never start a second run to "retry"
|
|
77
|
+
without the user asking; flag the storyboard or tell them what failed instead.
|
|
78
|
+
|
|
79
|
+
## The script file
|
|
80
|
+
|
|
81
|
+
Plain text. A `CAST:` block that names the speakers exactly as the Show locked
|
|
82
|
+
them, one line per turn, then a `CTA:` block. Keep each turn near 14 words so
|
|
83
|
+
a clip stays near 6 seconds. If a line does get truncated in its clip, the
|
|
84
|
+
manifest says so (`speech-cutoff`).
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
CAST:
|
|
88
|
+
THE EYES — a pair of tired cartoon eyeballs who stare at screens all day
|
|
89
|
+
THE SOFTGEL — a cheerful little capsule who knows how to fix that
|
|
90
|
+
|
|
91
|
+
THE EYES: Ten hours of screens today. Everything looks blurry and dry.
|
|
92
|
+
THE SOFTGEL: That's screen strain. Your eyes are begging for a break.
|
|
93
|
+
|
|
94
|
+
CTA:
|
|
95
|
+
THE SOFTGEL: Tap below and give your eyes ClearBlink.
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Reading the storyboard gate
|
|
99
|
+
|
|
100
|
+
`storyboard <runId>` prints one card per scene: who speaks, the line, the
|
|
101
|
+
planned duration, and whether its picture is drawn. Read it for the user, then
|
|
102
|
+
ask one question: approve, or flag with a note. A flag note is read by the
|
|
103
|
+
model that rewrites the storyboard, so make it concrete ("scene 2 should be a
|
|
104
|
+
close-up of the softgel, not the eyes"). Do not approve on the user's behalf
|
|
105
|
+
unless they told you to run the whole loop unattended.
|
|
106
|
+
|
|
107
|
+
## What `pull` writes, and what the manifest means
|
|
108
|
+
|
|
109
|
+
`pull` writes a flat folder and a `manifest.json` that indexes it. The manifest
|
|
110
|
+
is the data you decide from; the filenames are its values. Scene numbers are
|
|
111
|
+
the run's own (they start at 1; scene 0 in `status` is the reference still).
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
storyboard.json scene text, order, planned durations, cast looks
|
|
115
|
+
reference.<ext> the reference still the pictures were drawn from
|
|
116
|
+
scene-NN.keyframe.<ext> the picture for scene NN
|
|
117
|
+
scene-NN.voice.<ext> the voice track for scene NN, when one was delivered
|
|
118
|
+
(a Show whose voices live inside the clips delivers none)
|
|
119
|
+
scene-NN.<ext> the clip for scene NN, usually .mp4, voice included
|
|
120
|
+
scene-NN.words.json [{w, s, e}] word timings for the speech in that scene, from
|
|
121
|
+
the clip's own dialogue or, on a narrated scene, from the
|
|
122
|
+
voice track (seconds from the start of that file)
|
|
123
|
+
music.<ext> the music bed, composed to the clips' total length
|
|
124
|
+
manifest.json the index below
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`manifest.json`:
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
runId, pulledAt, dashboardUrl the run and its page
|
|
131
|
+
storyboard, reference, music filenames, or null when not delivered
|
|
132
|
+
scenes[] one per scene, in order
|
|
133
|
+
sceneIndex, durationSec the run's number and the clip's real length
|
|
134
|
+
clip, voice, keyframe, words filenames, or null
|
|
135
|
+
wordsFrom clip | voice, which file the word times are
|
|
136
|
+
timed against; null when none came
|
|
137
|
+
clipStatus done | failed | running | pending | missing
|
|
138
|
+
error why the clip failed, when it did
|
|
139
|
+
flagged, findings[] QC verdict: {check, code, severity: fail|warn, detail}
|
|
140
|
+
qc {verdict, attempts} from the renderer
|
|
141
|
+
failed[] files that did not download: {file, url, error}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
How to read it before cutting:
|
|
145
|
+
|
|
146
|
+
- `clipStatus: "done"` and a `clip` filename: the scene is cuttable.
|
|
147
|
+
- `flagged: true` is a warning, not a block. The clip was delivered anyway; the
|
|
148
|
+
findings say what the QC model saw (`wrong-character`, `eyeline-off`,
|
|
149
|
+
`set-drift`, `speech-cutoff`, `framing-off`). Tell the user which scenes are
|
|
150
|
+
flagged and why, and let them decide whether to keep, trim, or drop each one.
|
|
151
|
+
A `speech-cutoff` on the CTA scene is the one to worry about; the ad's last
|
|
152
|
+
words are missing.
|
|
153
|
+
- `clipStatus: "failed"` or `"missing"` with a `keyframe`: `durationSec` is
|
|
154
|
+
null (it comes from the clip), but the scene can still hold its place as a
|
|
155
|
+
still picture for the length of its voice track, or the storyboard's planned
|
|
156
|
+
duration when there is no voice. The first-cut script does this by itself.
|
|
157
|
+
- `clipStatus: "running"` or `"pending"`: pull again later. `status` says when
|
|
158
|
+
the run parks at the final watch, which means nothing is still rendering.
|
|
159
|
+
- `failed[]` non-empty: run the same `pull` again. It fetches every file again
|
|
160
|
+
and overwrites, so nothing is lost; the new manifest says what still failed.
|
|
161
|
+
|
|
162
|
+
## Making the first cut
|
|
163
|
+
|
|
164
|
+
Use the bundled script. It reads the manifest, puts every A-roll scene in order
|
|
165
|
+
as the spine, lays each cutaway over the spine at the moment its line is spoken,
|
|
166
|
+
normalizes everything to 1080x1920 at 24 fps, fills a silent clip with the
|
|
167
|
+
scene's voice track, lays the music bed underneath at a low level, and writes one
|
|
168
|
+
MP4 with faststart for upload. The path is from the workspace root; from a brand
|
|
169
|
+
subfolder, prefix `../`:
|
|
170
|
+
|
|
171
|
+
```
|
|
172
|
+
node .claude/skills/exodus-video/scripts/first-cut.mjs ./ad-<runId>
|
|
173
|
+
node .claude/skills/exodus-video/scripts/first-cut.mjs ./ad-<runId> --skip 2,5 --no-music --out ./v2.mp4
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
It prints what went in, what was left out and why, and the upload command.
|
|
177
|
+
Reach for raw ffmpeg only when the user wants something the script does not do
|
|
178
|
+
(a trim, a reorder, a different aspect). When you do, keep the script's output
|
|
179
|
+
contract: H.264 + AAC in an MP4, an even-sized frame, `-movflags +faststart`.
|
|
180
|
+
|
|
181
|
+
### The word times that come out with the cut
|
|
182
|
+
|
|
183
|
+
Nothing burns captions into the video. Captions belong in post, in whatever tool
|
|
184
|
+
the editor uses, so the script hands those tools the timings instead. Beside the
|
|
185
|
+
MP4 it writes two files named from the same basename. `cut.words.json` is every
|
|
186
|
+
spoken word of the finished ad with its start and end in seconds. `cut.srt` is
|
|
187
|
+
the same words grouped into short subtitle lines. `--out v2.mp4` names them
|
|
188
|
+
`v2.words.json` and `v2.srt`.
|
|
189
|
+
|
|
190
|
+
The times are on the finished ad's timeline, so a word's time is where you hear
|
|
191
|
+
it in the MP4. Cutaways contribute nothing, because their audio never plays. A
|
|
192
|
+
narrated scene counts even when it is only a picture and a voice track: its word
|
|
193
|
+
times come from the voice track, and `manifest.json` says `wordsFrom: "clip"` or
|
|
194
|
+
`"voice"` per scene so you can tell which file a scene's times were read off. A
|
|
195
|
+
scene that came with no word times leaves a gap, and the report names it. When no
|
|
196
|
+
scene delivered word times the script writes neither file and says so. Tell the
|
|
197
|
+
user the two files exist and that they are what the editor imports for captions.
|
|
198
|
+
|
|
199
|
+
### Where a cutaway lands
|
|
200
|
+
|
|
201
|
+
The storyboard marks some scenes `kind: "cutaway"` and gives each one a
|
|
202
|
+
`cueLineId`, the script line the cutaway belongs to. A cutaway is a picture-only
|
|
203
|
+
takeover. It replaces the picture for its own length while the spoken track
|
|
204
|
+
underneath keeps running, so it never takes a place in the running order and
|
|
205
|
+
never adds audio. That is why the cut is as long as the A-roll scenes alone. A
|
|
206
|
+
cutaway is cut short where the next cutaway starts or where the ad ends, and
|
|
207
|
+
the report says so.
|
|
208
|
+
|
|
209
|
+
The script places each cutaway at the real time its cued line is spoken. It
|
|
210
|
+
finds the A-roll scene whose `lineIds` hold the cue line, counts the words of
|
|
211
|
+
the lines spoken before it in that scene, and reads that word's start time out
|
|
212
|
+
of `scene-NN.words.json`. When the scene has no word times, it falls back to a
|
|
213
|
+
proportion, the share of the scene's words that come before the cue line scaled
|
|
214
|
+
to the scene's real length. The proportion is an estimate and can land a beat
|
|
215
|
+
early or late, so the report names which of the two methods placed each cutaway.
|
|
216
|
+
|
|
217
|
+
A cutaway whose cue line is spoken by no scene in the cut cannot be placed, so
|
|
218
|
+
the script leaves it out and says so. Skipping the A-roll scene that speaks the
|
|
219
|
+
cue line does that, and so does a storyboard that never claims the line.
|
|
220
|
+
|
|
221
|
+
Before uploading, look at what you made: `ffprobe -v error -show_entries
|
|
222
|
+
format=duration -of default=nw=1:nk=1 cut.mp4` should be close to the sum of
|
|
223
|
+
the scene durations you kept. A cut that is a few seconds long when five clips
|
|
224
|
+
went in means a filter dropped inputs; do not upload it.
|
|
225
|
+
|
|
226
|
+
## Uploading and approving
|
|
227
|
+
|
|
228
|
+
`upload <runId> --file cut.mp4` accepts MP4, MOV or WebM up to 200 MB. It reads
|
|
229
|
+
the length off the file (MP4 headers, or ffprobe for the rest); when it cannot,
|
|
230
|
+
it says so and you pass `--duration <sec>`.
|
|
231
|
+
It prints the run's page, where the user can watch the cut, and the approve
|
|
232
|
+
command. Approve only when the user has seen the cut or told you to run
|
|
233
|
+
unattended; `approve` marks the cut as the ad and moves the run to delivery.
|
|
234
|
+
|
|
235
|
+
## Reporting to the user
|
|
236
|
+
|
|
237
|
+
Lead with where the run is and what it needs from them. Then, when you pulled
|
|
238
|
+
or cut: how many scenes, which are flagged and why in one line each, what you
|
|
239
|
+
left out, the cut's length, and the exact command or link for the next step.
|
|
240
|
+
Never paste the manifest; summarize it.
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// A plain first cut from a folder written by `exodus video pull`: the A-roll
|
|
3
|
+
// scenes in the manifest's order make the spine, each cutaway lays over the
|
|
4
|
+
// spine at the moment its cued line is spoken, the music bed goes underneath,
|
|
5
|
+
// and one MP4 comes out ready to upload.
|
|
6
|
+
//
|
|
7
|
+
// node first-cut.mjs <pulled-dir> [--out cut.mp4] [--skip 2,5] [--no-music]
|
|
8
|
+
//
|
|
9
|
+
// A scene with a clip contributes the clip. A clip with no audio stream gets
|
|
10
|
+
// the scene's voice track, or silence. A scene with no clip but a keyframe
|
|
11
|
+
// becomes a still for the length of its voice track, else the storyboard's
|
|
12
|
+
// planned duration, with the voice if one was delivered. --skip drops scenes.
|
|
13
|
+
//
|
|
14
|
+
// After the render it writes the whole ad's word times beside the MP4, as
|
|
15
|
+
// <out>.words.json and <out>.srt, for captioning in post.
|
|
16
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
17
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { pathToFileURL } from "node:url";
|
|
20
|
+
|
|
21
|
+
const USAGE = "usage: first-cut.mjs <pulled-dir> [--out cut.mp4] [--skip 2,5] [--no-music]";
|
|
22
|
+
const WIDTH = 1080;
|
|
23
|
+
const HEIGHT = 1920;
|
|
24
|
+
const FPS = 24;
|
|
25
|
+
const norm = (label) =>
|
|
26
|
+
`[${label}]scale=${WIDTH}:${HEIGHT}:force_original_aspect_ratio=decrease,` +
|
|
27
|
+
`pad=${WIDTH}:${HEIGHT}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=${FPS},format=yuv420p`;
|
|
28
|
+
const stereo = "aresample=48000,aformat=channel_layouts=stereo";
|
|
29
|
+
|
|
30
|
+
/** The word rule the planner sized every line with (scout models.ts wordCount). */
|
|
31
|
+
export function wordCount(text) {
|
|
32
|
+
const trimmed = (text ?? "").trim();
|
|
33
|
+
return trimmed ? trimmed.split(/\s+/).length : 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function lineWordCounts(storyboard) {
|
|
37
|
+
const counts = new Map();
|
|
38
|
+
for (const line of storyboard?.script ?? []) {
|
|
39
|
+
if (typeof line?.lineId === "string") counts.set(line.lineId, wordCount(line.text));
|
|
40
|
+
}
|
|
41
|
+
return counts;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const CUTAWAY_KINDS = new Set(["cutaway", "insert"]);
|
|
45
|
+
|
|
46
|
+
export function scenePlans(storyboard) {
|
|
47
|
+
const plans = new Map();
|
|
48
|
+
for (const scene of storyboard?.scenes ?? []) {
|
|
49
|
+
if (typeof scene?.sceneIndex !== "number") continue;
|
|
50
|
+
plans.set(scene.sceneIndex, {
|
|
51
|
+
cutaway: CUTAWAY_KINDS.has(scene.kind),
|
|
52
|
+
lineIds: Array.isArray(scene.lineIds) ? scene.lineIds : [],
|
|
53
|
+
cueLineId: typeof scene.cueLineId === "string" ? scene.cueLineId : null,
|
|
54
|
+
durationSec: typeof scene.durationSec === "number" ? scene.durationSec : null,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return plans;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function cueOffsetOnSpine(spine, counts, cueLineId) {
|
|
61
|
+
for (const seg of spine) {
|
|
62
|
+
const idx = seg.lineIds.indexOf(cueLineId);
|
|
63
|
+
if (idx === -1) continue;
|
|
64
|
+
let precedingWords = 0;
|
|
65
|
+
for (let i = 0; i < idx; i++) precedingWords += counts.get(seg.lineIds[i]) ?? 0;
|
|
66
|
+
let totalWords = 0;
|
|
67
|
+
for (const id of seg.lineIds) totalWords += counts.get(id) ?? 0;
|
|
68
|
+
|
|
69
|
+
let within = 0;
|
|
70
|
+
let method = "proportion";
|
|
71
|
+
if (seg.words && seg.words.length > 0) {
|
|
72
|
+
within = Math.max(0, seg.words[Math.min(precedingWords, seg.words.length - 1)]?.s ?? 0);
|
|
73
|
+
method = "words";
|
|
74
|
+
} else if (totalWords > 0) {
|
|
75
|
+
within = (precedingWords / totalWords) * seg.seconds;
|
|
76
|
+
}
|
|
77
|
+
within = Math.min(within, seg.seconds);
|
|
78
|
+
return { startSec: seg.startSec + within, method, spineSceneIndex: seg.sceneIndex };
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function buildTimeline({ manifest, storyboard, skip, probe, exists, readWords }) {
|
|
84
|
+
const plans = scenePlans(storyboard);
|
|
85
|
+
const counts = lineWordCounts(storyboard);
|
|
86
|
+
const spine = [];
|
|
87
|
+
const cutaways = [];
|
|
88
|
+
const waiting = [];
|
|
89
|
+
const dropped = [];
|
|
90
|
+
let startSec = 0;
|
|
91
|
+
|
|
92
|
+
for (const scene of manifest?.scenes ?? []) {
|
|
93
|
+
const n = scene.sceneIndex;
|
|
94
|
+
const plan = plans.get(n) ?? { cutaway: false, lineIds: [], cueLineId: null, durationSec: null };
|
|
95
|
+
if (skip.has(n)) {
|
|
96
|
+
dropped.push({ n, reason: `scene ${n}: skipped by --skip` });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const nothing = `nothing to cut with (clip ${scene.clipStatus}${scene.error ? `: ${scene.error}` : ""})`;
|
|
100
|
+
|
|
101
|
+
if (plan.cutaway) {
|
|
102
|
+
if (exists(scene.clip)) {
|
|
103
|
+
waiting.push({
|
|
104
|
+
sceneIndex: n, source: "clip", file: scene.clip,
|
|
105
|
+
seconds: probe(scene.clip).seconds, cueLineId: plan.cueLineId,
|
|
106
|
+
});
|
|
107
|
+
} else if (exists(scene.keyframe)) {
|
|
108
|
+
waiting.push({
|
|
109
|
+
sceneIndex: n, source: "still", file: scene.keyframe,
|
|
110
|
+
seconds: plan.durationSec ?? 4, cueLineId: plan.cueLineId,
|
|
111
|
+
});
|
|
112
|
+
} else {
|
|
113
|
+
dropped.push({ n, reason: `cutaway ${n}: ${nothing}` });
|
|
114
|
+
}
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (exists(scene.clip)) {
|
|
119
|
+
const clip = probe(scene.clip);
|
|
120
|
+
const voice = clip.hasAudio || !exists(scene.voice) ? null : scene.voice;
|
|
121
|
+
const flag = scene.flagged ? " (flagged)" : "";
|
|
122
|
+
const silent = clip.hasAudio ? "" : voice ? `, silent clip + ${voice}` : ", silent clip";
|
|
123
|
+
spine.push({
|
|
124
|
+
sceneIndex: n, source: "clip", file: scene.clip, seconds: clip.seconds,
|
|
125
|
+
hasAudio: clip.hasAudio, voice, startSec, lineIds: plan.lineIds,
|
|
126
|
+
words: readWords(scene.words), note: `${flag}${silent}`,
|
|
127
|
+
});
|
|
128
|
+
startSec += clip.seconds;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (exists(scene.keyframe)) {
|
|
132
|
+
const voice = exists(scene.voice) ? scene.voice : null;
|
|
133
|
+
const seconds = voice ? probe(voice).seconds : (plan.durationSec ?? 4);
|
|
134
|
+
spine.push({
|
|
135
|
+
sceneIndex: n, source: "still", file: scene.keyframe, seconds,
|
|
136
|
+
hasAudio: false, voice, startSec, lineIds: plan.lineIds,
|
|
137
|
+
// #1689: on a narrated scene the words are the voice track's, and the
|
|
138
|
+
// voice starts where the segment does, so they need no offset here.
|
|
139
|
+
words: voice ? readWords(scene.words) : null,
|
|
140
|
+
note: `${voice ? ` + ${voice}` : ", silent"} (${seconds.toFixed(1)}s, clip ${scene.clipStatus})`,
|
|
141
|
+
});
|
|
142
|
+
startSec += seconds;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
dropped.push({ n, reason: `scene ${n}: ${nothing}` });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
for (const c of waiting) {
|
|
149
|
+
const cue = c.cueLineId ? cueOffsetOnSpine(spine, counts, c.cueLineId) : null;
|
|
150
|
+
if (!cue) {
|
|
151
|
+
const why = c.cueLineId
|
|
152
|
+
? `${c.cueLineId} is spoken by no scene in the cut`
|
|
153
|
+
: "the storyboard names no cue line";
|
|
154
|
+
dropped.push({ n: c.sceneIndex, reason: `cutaway ${c.sceneIndex}: ${why}` });
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
cutaways.push({ ...c, startSec: cue.startSec, method: cue.method, spineSceneIndex: cue.spineSceneIndex });
|
|
158
|
+
}
|
|
159
|
+
cutaways.sort((a, b) => a.startSec - b.startSec);
|
|
160
|
+
for (let i = 0; i < cutaways.length; i++) {
|
|
161
|
+
const c = cutaways[i];
|
|
162
|
+
const next = cutaways[i + 1];
|
|
163
|
+
const end = Math.min(startSec, next ? next.startSec : Infinity);
|
|
164
|
+
if (c.startSec + c.seconds > end) {
|
|
165
|
+
c.seconds = Math.max(0, end - c.startSec);
|
|
166
|
+
c.cutShortBy = next && end === next.startSec ? `cutaway ${next.sceneIndex} starts` : "the ad ends";
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
spine,
|
|
172
|
+
cutaways,
|
|
173
|
+
left: dropped.sort((a, b) => a.n - b.n).map((d) => d.reason),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function sec(n) {
|
|
178
|
+
return (Number.isFinite(n) ? Math.max(0, n) : 0).toFixed(3);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function buildFfmpegArgs(timeline, { resolve, music, out }) {
|
|
182
|
+
const inputs = [];
|
|
183
|
+
let count = 0;
|
|
184
|
+
const addInput = (...args) => {
|
|
185
|
+
inputs.push(...args);
|
|
186
|
+
return count++;
|
|
187
|
+
};
|
|
188
|
+
const filters = [];
|
|
189
|
+
const pairs = [];
|
|
190
|
+
|
|
191
|
+
for (const seg of timeline.spine) {
|
|
192
|
+
const i =
|
|
193
|
+
seg.source === "clip"
|
|
194
|
+
? addInput("-i", resolve(seg.file))
|
|
195
|
+
: addInput("-loop", "1", "-t", String(seg.seconds), "-i", resolve(seg.file));
|
|
196
|
+
filters.push(`${norm(`${i}:v`)}[v${i}]`);
|
|
197
|
+
if (seg.hasAudio) {
|
|
198
|
+
filters.push(`[${i}:a]${stereo}[a${i}]`);
|
|
199
|
+
} else if (seg.voice) {
|
|
200
|
+
const v = addInput("-i", resolve(seg.voice));
|
|
201
|
+
filters.push(`[${v}:a]${stereo},apad,atrim=0:${seg.seconds}[a${i}]`);
|
|
202
|
+
} else {
|
|
203
|
+
filters.push(`anullsrc=r=48000:cl=stereo,atrim=0:${seg.seconds}[a${i}]`);
|
|
204
|
+
}
|
|
205
|
+
pairs.push(`[v${i}][a${i}]`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// A clip whose audio ends before its video would otherwise end the whole cut
|
|
209
|
+
// there under -shortest; padding lets the video decide the length.
|
|
210
|
+
filters.push(`${pairs.join("")}concat=n=${pairs.length}:v=1:a=1[vcat][voicecat]`);
|
|
211
|
+
filters.push(`[voicecat]apad[voice]`);
|
|
212
|
+
|
|
213
|
+
let videoLabel = "vcat";
|
|
214
|
+
timeline.cutaways.forEach((c, j) => {
|
|
215
|
+
const i =
|
|
216
|
+
c.source === "clip"
|
|
217
|
+
? addInput("-i", resolve(c.file))
|
|
218
|
+
: addInput("-loop", "1", "-t", String(c.seconds), "-i", resolve(c.file));
|
|
219
|
+
const end = c.startSec + c.seconds;
|
|
220
|
+
filters.push(
|
|
221
|
+
`${norm(`${i}:v`)},trim=0:${sec(c.seconds)},setpts=PTS-STARTPTS+${sec(c.startSec)}/TB[cv${j}]`,
|
|
222
|
+
);
|
|
223
|
+
// eof_action=pass keeps the spine showing once the cutaway's frames end.
|
|
224
|
+
filters.push(
|
|
225
|
+
`[${videoLabel}][cv${j}]overlay=enable=between(t\\,${sec(c.startSec)}\\,${sec(end)}):eof_action=pass[ov${j}]`,
|
|
226
|
+
);
|
|
227
|
+
videoLabel = `ov${j}`;
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
let audioLabel = "voice";
|
|
231
|
+
if (music) {
|
|
232
|
+
const m = addInput("-stream_loop", "-1", "-i", music);
|
|
233
|
+
filters.push(`[${m}:a]${stereo},volume=0.18[bed]`);
|
|
234
|
+
filters.push(`[voice][bed]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[mix]`);
|
|
235
|
+
audioLabel = "mix";
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return [
|
|
239
|
+
"-y", "-hide_banner", "-loglevel", "error",
|
|
240
|
+
...inputs,
|
|
241
|
+
"-filter_complex", filters.join(";"),
|
|
242
|
+
"-map", `[${videoLabel}]`, "-map", `[${audioLabel}]`,
|
|
243
|
+
"-c:v", "libx264", "-preset", "medium", "-crf", "20", "-r", String(FPS),
|
|
244
|
+
"-c:a", "aac", "-b:a", "192k",
|
|
245
|
+
"-movflags", "+faststart", "-shortest",
|
|
246
|
+
out,
|
|
247
|
+
];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function wordRows(timeline) {
|
|
251
|
+
const ms = (n) => Math.round(n * 1000) / 1000;
|
|
252
|
+
const rows = [];
|
|
253
|
+
for (const seg of timeline.spine) {
|
|
254
|
+
for (const w of seg.words ?? []) {
|
|
255
|
+
const text = (w.w ?? "").trim();
|
|
256
|
+
const start = seg.startSec + w.s;
|
|
257
|
+
const end = seg.startSec + w.e;
|
|
258
|
+
if (text && end > start) {
|
|
259
|
+
rows.push({ text, start: ms(start), end: ms(end), scene: seg.sceneIndex });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return rows;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const SRT_MAX_WORDS = 5;
|
|
267
|
+
const SRT_GAP_SEC = 1.0;
|
|
268
|
+
|
|
269
|
+
export function srtCues(rows) {
|
|
270
|
+
const cues = [];
|
|
271
|
+
let cue = [];
|
|
272
|
+
for (let i = 0; i < rows.length; i++) {
|
|
273
|
+
cue.push(rows[i]);
|
|
274
|
+
const next = rows[i + 1];
|
|
275
|
+
const ends =
|
|
276
|
+
!next ||
|
|
277
|
+
/[.!?,;:]$/.test(rows[i].text) ||
|
|
278
|
+
cue.length >= SRT_MAX_WORDS ||
|
|
279
|
+
next.scene !== rows[i].scene ||
|
|
280
|
+
next.start - rows[i].end > SRT_GAP_SEC;
|
|
281
|
+
if (ends) {
|
|
282
|
+
cues.push(cue);
|
|
283
|
+
cue = [];
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return cues;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function srtTime(seconds) {
|
|
290
|
+
const ms = Math.max(0, Math.round(seconds * 1000));
|
|
291
|
+
const pad = (n, width) => String(n).padStart(width, "0");
|
|
292
|
+
return (
|
|
293
|
+
`${pad(Math.floor(ms / 3600000), 2)}:${pad(Math.floor(ms / 60000) % 60, 2)}:` +
|
|
294
|
+
`${pad(Math.floor(ms / 1000) % 60, 2)},${pad(ms % 1000, 3)}`
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function toSrt(rows) {
|
|
299
|
+
return srtCues(rows)
|
|
300
|
+
.map((cue, i) => {
|
|
301
|
+
const text = cue.map((r) => r.text).join(" ");
|
|
302
|
+
return `${i + 1}\n${srtTime(cue[0].start)} --> ${srtTime(cue[cue.length - 1].end)}\n${text}\n`;
|
|
303
|
+
})
|
|
304
|
+
.join("\n");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function sidecarPaths(outPath) {
|
|
308
|
+
const dir = path.dirname(outPath);
|
|
309
|
+
const base = path.basename(outPath, path.extname(outPath));
|
|
310
|
+
return { words: path.join(dir, `${base}.words.json`), srt: path.join(dir, `${base}.srt`) };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function inTheCut(timeline) {
|
|
314
|
+
const rows = [];
|
|
315
|
+
for (const seg of timeline.spine) {
|
|
316
|
+
const label = seg.source === "clip" ? seg.file : `still ${seg.file}`;
|
|
317
|
+
rows.push({ n: seg.sceneIndex, line: `scene ${seg.sceneIndex}: ${label}${seg.note}` });
|
|
318
|
+
}
|
|
319
|
+
for (const c of timeline.cutaways) {
|
|
320
|
+
const label = c.source === "clip" ? c.file : `still ${c.file}`;
|
|
321
|
+
const how =
|
|
322
|
+
c.method === "words"
|
|
323
|
+
? "word times"
|
|
324
|
+
: `proportion: scene ${c.spineSceneIndex} has no word times`;
|
|
325
|
+
const cut = c.cutShortBy ? `, cut short where ${c.cutShortBy}` : "";
|
|
326
|
+
rows.push({
|
|
327
|
+
n: c.sceneIndex,
|
|
328
|
+
line:
|
|
329
|
+
`cutaway ${c.sceneIndex}: ${label} at ${c.startSec.toFixed(2)}s for ${c.seconds.toFixed(1)}s, ` +
|
|
330
|
+
`cued to ${c.cueLineId} in scene ${c.spineSceneIndex} (${how})${cut}`,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
return rows.sort((a, b) => a.n - b.n).map((r) => r.line);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function noWordTimes(timeline) {
|
|
337
|
+
const rows = timeline.spine
|
|
338
|
+
.filter((seg) => !seg.words || seg.words.length === 0)
|
|
339
|
+
.map((seg) => `scene ${seg.sceneIndex} (no words.json)`);
|
|
340
|
+
for (const c of timeline.cutaways) rows.push(`scene ${c.sceneIndex} (cutaway)`);
|
|
341
|
+
return rows;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function fail(code, message) {
|
|
345
|
+
console.error(message);
|
|
346
|
+
process.exit(code);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function parseArgs(argv) {
|
|
350
|
+
const opts = { dir: undefined, out: undefined, skip: new Set(), music: true };
|
|
351
|
+
for (let i = 0; i < argv.length; i++) {
|
|
352
|
+
const a = argv[i];
|
|
353
|
+
if (a === "--out") opts.out = argv[++i];
|
|
354
|
+
else if (a === "--skip") opts.skip = new Set((argv[++i] ?? "").split(",").filter(Boolean).map(Number));
|
|
355
|
+
else if (a === "--no-music") opts.music = false;
|
|
356
|
+
else if (a.startsWith("--")) fail(2, `unknown flag ${a}\n${USAGE}`);
|
|
357
|
+
else if (opts.dir === undefined) opts.dir = a;
|
|
358
|
+
else fail(2, `unexpected argument ${a}\n${USAGE}`);
|
|
359
|
+
}
|
|
360
|
+
if (!opts.dir) fail(2, USAGE);
|
|
361
|
+
return opts;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function main() {
|
|
365
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
366
|
+
|
|
367
|
+
for (const bin of ["ffmpeg", "ffprobe"]) {
|
|
368
|
+
if (spawnSync(bin, ["-version"]).status !== 0) {
|
|
369
|
+
fail(2, `${bin} is not installed. Install ffmpeg (brew install ffmpeg / apt install ffmpeg).`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
const encoders = spawnSync("ffmpeg", ["-hide_banner", "-encoders"]).stdout?.toString() ?? "";
|
|
373
|
+
if (!/\blibx264\b/.test(encoders)) {
|
|
374
|
+
fail(2, "This ffmpeg has no libx264 encoder (H.264). Install a full ffmpeg build: brew install ffmpeg / apt install ffmpeg.");
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const dir = opts.dir;
|
|
378
|
+
const manifestPath = path.join(dir, "manifest.json");
|
|
379
|
+
if (!existsSync(manifestPath)) {
|
|
380
|
+
fail(2, `${manifestPath} not found. Run: exodus video pull <runId> --out ${dir}`);
|
|
381
|
+
}
|
|
382
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
383
|
+
const out = path.resolve(opts.out ?? path.join(dir, "cut.mp4"));
|
|
384
|
+
const here = (file) => path.join(dir, file);
|
|
385
|
+
const exists = (file) => Boolean(file) && existsSync(here(file));
|
|
386
|
+
|
|
387
|
+
const readJson = (file) => {
|
|
388
|
+
if (!exists(file)) return null;
|
|
389
|
+
try {
|
|
390
|
+
return JSON.parse(readFileSync(here(file), "utf8"));
|
|
391
|
+
} catch {
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
const probe = (file) => {
|
|
396
|
+
const raw = execFileSync("ffprobe", [
|
|
397
|
+
"-v", "error", "-show_entries", "stream=codec_type:format=duration", "-of", "json", here(file),
|
|
398
|
+
]).toString();
|
|
399
|
+
const info = JSON.parse(raw);
|
|
400
|
+
return {
|
|
401
|
+
seconds: Number(info.format?.duration ?? 0),
|
|
402
|
+
hasAudio: (info.streams ?? []).some((s) => s.codec_type === "audio"),
|
|
403
|
+
};
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
const storyboard = readJson(manifest.storyboard);
|
|
407
|
+
if (!storyboard) {
|
|
408
|
+
console.error(`Warning: ${manifest.storyboard ?? "storyboard.json"} is missing or unreadable, so every scene is cut as A-roll and no cutaway is placed.`);
|
|
409
|
+
}
|
|
410
|
+
const timeline = buildTimeline({
|
|
411
|
+
manifest,
|
|
412
|
+
storyboard,
|
|
413
|
+
skip: opts.skip,
|
|
414
|
+
probe,
|
|
415
|
+
exists,
|
|
416
|
+
readWords: (file) => {
|
|
417
|
+
const rows = readJson(file);
|
|
418
|
+
return Array.isArray(rows) && rows.length > 0 ? rows : null;
|
|
419
|
+
},
|
|
420
|
+
});
|
|
421
|
+
if (timeline.spine.length === 0) {
|
|
422
|
+
fail(1, `No scene has a clip or a keyframe. Check: exodus video status ${manifest.runId}`);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const music = opts.music && exists(manifest.music) ? manifest.music : null;
|
|
426
|
+
const ffmpegArgs = buildFfmpegArgs(timeline, {
|
|
427
|
+
resolve: here,
|
|
428
|
+
music: music ? here(music) : null,
|
|
429
|
+
out,
|
|
430
|
+
});
|
|
431
|
+
const res = spawnSync("ffmpeg", ffmpegArgs, { stdio: "inherit" });
|
|
432
|
+
if (res.status !== 0) {
|
|
433
|
+
const quoted = ffmpegArgs.map((a) => (/[\s\[\];]/.test(a) ? `'${a}'` : a)).join(" ");
|
|
434
|
+
fail(1, `ffmpeg failed. The command it ran:\n ffmpeg ${quoted}`);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const rows = wordRows(timeline);
|
|
438
|
+
const sidecars = sidecarPaths(out);
|
|
439
|
+
if (rows.length > 0) {
|
|
440
|
+
writeFileSync(sidecars.words, `${JSON.stringify(rows, null, 2)}\n`);
|
|
441
|
+
writeFileSync(sidecars.srt, toSrt(rows));
|
|
442
|
+
} else {
|
|
443
|
+
rmSync(sidecars.words, { force: true });
|
|
444
|
+
rmSync(sidecars.srt, { force: true });
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const duration = execFileSync("ffprobe", [
|
|
448
|
+
"-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", out,
|
|
449
|
+
]).toString().trim();
|
|
450
|
+
console.log(`Wrote ${out} (${Number(duration).toFixed(1)}s, ${WIDTH}x${HEIGHT})`);
|
|
451
|
+
console.log("In the cut:");
|
|
452
|
+
for (const line of inTheCut(timeline)) console.log(" " + line);
|
|
453
|
+
if (timeline.left.length > 0) {
|
|
454
|
+
console.log("Left out:");
|
|
455
|
+
for (const line of timeline.left) console.log(" " + line);
|
|
456
|
+
}
|
|
457
|
+
if (rows.length > 0) {
|
|
458
|
+
const scenes = [...new Set(rows.map((r) => r.scene))].join(", ");
|
|
459
|
+
console.log(
|
|
460
|
+
`Words: ${rows.length} words from scenes ${scenes} -> ${path.basename(sidecars.words)}, ${path.basename(sidecars.srt)}`,
|
|
461
|
+
);
|
|
462
|
+
} else {
|
|
463
|
+
console.log("Words: none delivered, no caption files written");
|
|
464
|
+
}
|
|
465
|
+
const silent = noWordTimes(timeline);
|
|
466
|
+
if (silent.length > 0) console.log(`No word times: ${silent.join(", ")}`);
|
|
467
|
+
console.log(`Music bed: ${music ?? "none"}`);
|
|
468
|
+
console.log(`Upload it: exodus video upload ${manifest.runId} --file ${out}`);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();
|
package/dist/commands/video.d.ts
CHANGED
|
@@ -41,6 +41,7 @@ export type ArtifactSubset = {
|
|
|
41
41
|
sceneIndex?: number;
|
|
42
42
|
audioUrl?: string;
|
|
43
43
|
durationSec?: number;
|
|
44
|
+
words?: ClipWord[];
|
|
44
45
|
} | {
|
|
45
46
|
type: "text" | "primer" | "session" | "document";
|
|
46
47
|
};
|
|
@@ -129,12 +130,16 @@ export type RunStop = {
|
|
|
129
130
|
} | {
|
|
130
131
|
at: "failed";
|
|
131
132
|
error?: string;
|
|
133
|
+
repair?: true;
|
|
134
|
+
nodeId?: string;
|
|
135
|
+
step?: string;
|
|
132
136
|
} | {
|
|
133
137
|
at: "finished";
|
|
134
138
|
status: string;
|
|
135
139
|
};
|
|
136
140
|
export declare function classifyRun(run: VideoRun): RunStop;
|
|
137
141
|
export declare function stageWord(stage: string): string;
|
|
142
|
+
export declare function stepName(kind: string | undefined): string;
|
|
138
143
|
export declare function stopLines(stop: RunStop, runId: string, dashboardUrl: string): string[];
|
|
139
144
|
export interface PullDownload {
|
|
140
145
|
file: string;
|
|
@@ -149,6 +154,7 @@ export interface ManifestScene {
|
|
|
149
154
|
durationSec: number | null;
|
|
150
155
|
clip: string | null;
|
|
151
156
|
words: string | null;
|
|
157
|
+
wordsFrom: "clip" | "voice" | null;
|
|
152
158
|
voice: string | null;
|
|
153
159
|
keyframe: string | null;
|
|
154
160
|
qc: ClipQc | null;
|
|
@@ -208,6 +214,7 @@ export declare function flagFlow(runId: string, note: string, json: boolean, dep
|
|
|
208
214
|
export declare function pullFlow(runId: string, dir: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
|
|
209
215
|
export declare function parseMvhdDurationSec(bytes: Uint8Array): number | null;
|
|
210
216
|
export declare const NO_DURATION_MESSAGE: string;
|
|
217
|
+
export declare function describeFetchFailure(err: unknown): string;
|
|
211
218
|
export declare function uploadFlow(runId: string, filePath: string, durationFlag: string | undefined, json: boolean, deps: VideoDeps): Promise<FlowResult>;
|
|
212
219
|
export declare function parsePositional(args?: string[]): string[];
|
|
213
220
|
export declare function run(flags: Record<string, string | boolean>): Promise<void>;
|
package/dist/commands/video.js
CHANGED
|
@@ -181,6 +181,17 @@ export function classifyRun(run) {
|
|
|
181
181
|
if (run.isTerminal)
|
|
182
182
|
return { at: "finished", status: run.status };
|
|
183
183
|
if (isAwaitingApproval(run.status)) {
|
|
184
|
+
if (run.pauseReason === "repair") {
|
|
185
|
+
const deadNode = run.nodes.find((n) => n.status === "failed");
|
|
186
|
+
const why = deadNode?.error ?? run.error;
|
|
187
|
+
return {
|
|
188
|
+
at: "failed",
|
|
189
|
+
repair: true,
|
|
190
|
+
nodeId: deadNode?.nodeId ?? run.pausedNodeId,
|
|
191
|
+
...(deadNode?.kind ? { step: deadNode.kind } : {}),
|
|
192
|
+
...(why ? { error: why } : {}),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
184
195
|
const pausedNode = run.nodes.find((n) => n.nodeId === run.pausedNodeId);
|
|
185
196
|
if (pausedNode && parkedAtStoryboardGate(run, pausedNode)) {
|
|
186
197
|
return { at: "storyboard-gate", nodeId: pausedNode.nodeId };
|
|
@@ -204,6 +215,19 @@ const STAGE_WORDS = {
|
|
|
204
215
|
export function stageWord(stage) {
|
|
205
216
|
return STAGE_WORDS[stage] ?? stage;
|
|
206
217
|
}
|
|
218
|
+
const STEP_NAMES = {
|
|
219
|
+
brief: "The script read",
|
|
220
|
+
storyboard: "The storyboard",
|
|
221
|
+
reference: "The reference still",
|
|
222
|
+
"scene-frames": "The scene pictures",
|
|
223
|
+
voiceover: "The voices",
|
|
224
|
+
video: "The clips",
|
|
225
|
+
};
|
|
226
|
+
export function stepName(kind) {
|
|
227
|
+
if (!kind)
|
|
228
|
+
return "A step in this run";
|
|
229
|
+
return STEP_NAMES[kind] ?? `The ${kind} step`;
|
|
230
|
+
}
|
|
207
231
|
export function stopLines(stop, runId, dashboardUrl) {
|
|
208
232
|
if (stop.at === "storyboard-gate") {
|
|
209
233
|
return [
|
|
@@ -221,6 +245,13 @@ export function stopLines(stop, runId, dashboardUrl) {
|
|
|
221
245
|
];
|
|
222
246
|
}
|
|
223
247
|
if (stop.at === "failed") {
|
|
248
|
+
if (stop.repair) {
|
|
249
|
+
return [
|
|
250
|
+
`${stepName(stop.step)} failed${stop.error ? `: ${stop.error}` : "."}`,
|
|
251
|
+
`Try that step again: exodus workflow repair ${runId} retry`,
|
|
252
|
+
`Or open the run: ${dashboardUrl}/video?ad=${runId}`,
|
|
253
|
+
];
|
|
254
|
+
}
|
|
224
255
|
return [
|
|
225
256
|
`This run failed${stop.error ? `: ${stop.error}` : "."}`,
|
|
226
257
|
`See how far it got: exodus video status ${runId}`,
|
|
@@ -305,8 +336,11 @@ export function planPull(run, items, opts) {
|
|
|
305
336
|
if (!artifact.audioUrl)
|
|
306
337
|
continue;
|
|
307
338
|
voiceByScene.set(artifact.sceneIndex, {
|
|
308
|
-
|
|
309
|
-
|
|
339
|
+
download: {
|
|
340
|
+
file: `${scenePrefix(artifact.sceneIndex)}.voice.${extFor(artifact.audioUrl, "audio")}`,
|
|
341
|
+
url: artifact.audioUrl,
|
|
342
|
+
},
|
|
343
|
+
words: artifact.words ?? null,
|
|
310
344
|
});
|
|
311
345
|
}
|
|
312
346
|
const clipByScene = new Map();
|
|
@@ -345,18 +379,30 @@ export function planPull(run, items, opts) {
|
|
|
345
379
|
].sort((a, b) => a - b);
|
|
346
380
|
const scenes = sceneIndexes.map((sceneIndex) => {
|
|
347
381
|
const clip = clipByScene.get(sceneIndex);
|
|
382
|
+
const voice = voiceByScene.get(sceneIndex);
|
|
348
383
|
const item = clipItemByScene.get(sceneIndex);
|
|
349
|
-
let
|
|
384
|
+
let source = null;
|
|
385
|
+
let wordsFrom = null;
|
|
350
386
|
if (clip?.words && clip.words.length > 0) {
|
|
387
|
+
source = clip.words;
|
|
388
|
+
wordsFrom = "clip";
|
|
389
|
+
}
|
|
390
|
+
else if (voice?.words && voice.words.length > 0) {
|
|
391
|
+
source = voice.words;
|
|
392
|
+
wordsFrom = "voice";
|
|
393
|
+
}
|
|
394
|
+
let words = null;
|
|
395
|
+
if (source) {
|
|
351
396
|
words = `${scenePrefix(sceneIndex)}.words.json`;
|
|
352
|
-
texts.push({ file: words, body: `${JSON.stringify(
|
|
397
|
+
texts.push({ file: words, body: `${JSON.stringify(source, null, 2)}\n` });
|
|
353
398
|
}
|
|
354
399
|
return {
|
|
355
400
|
sceneIndex,
|
|
356
401
|
durationSec: clip?.durationSec ?? null,
|
|
357
402
|
clip: clip?.download.file ?? null,
|
|
358
403
|
words,
|
|
359
|
-
|
|
404
|
+
wordsFrom,
|
|
405
|
+
voice: voice?.download.file ?? null,
|
|
360
406
|
keyframe: keyframeByScene.get(sceneIndex)?.file ?? null,
|
|
361
407
|
qc: clip?.qc ?? null,
|
|
362
408
|
clipStatus: item?.status ?? "missing",
|
|
@@ -368,7 +414,7 @@ export function planPull(run, items, opts) {
|
|
|
368
414
|
const downloads = [
|
|
369
415
|
...(reference ? [reference] : []),
|
|
370
416
|
...keyframeByScene.values(),
|
|
371
|
-
...voiceByScene.values(),
|
|
417
|
+
...[...voiceByScene.values()].map((v) => v.download),
|
|
372
418
|
...[...clipByScene.values()].map((c) => c.download),
|
|
373
419
|
...(music ? [music] : []),
|
|
374
420
|
];
|
|
@@ -396,14 +442,18 @@ export function markPullFailure(manifest, failure) {
|
|
|
396
442
|
if (manifest.music === failure.file)
|
|
397
443
|
manifest.music = null;
|
|
398
444
|
for (const scene of manifest.scenes) {
|
|
445
|
+
const lostWordsSource = (scene.clip === failure.file && scene.wordsFrom === "clip") ||
|
|
446
|
+
(scene.voice === failure.file && scene.wordsFrom === "voice");
|
|
399
447
|
if (scene.clip === failure.file)
|
|
400
448
|
scene.clip = null;
|
|
401
449
|
if (scene.voice === failure.file)
|
|
402
450
|
scene.voice = null;
|
|
403
451
|
if (scene.keyframe === failure.file)
|
|
404
452
|
scene.keyframe = null;
|
|
405
|
-
if (scene.words === failure.file)
|
|
453
|
+
if (scene.words === failure.file || lostWordsSource) {
|
|
406
454
|
scene.words = null;
|
|
455
|
+
scene.wordsFrom = null;
|
|
456
|
+
}
|
|
407
457
|
}
|
|
408
458
|
}
|
|
409
459
|
const PULL_CONCURRENCY = 4;
|
|
@@ -607,7 +657,10 @@ export async function statusFlow(runId, json, deps) {
|
|
|
607
657
|
row[item.itemKind] = item;
|
|
608
658
|
byScene.set(item.sceneIndex, row);
|
|
609
659
|
}
|
|
610
|
-
const
|
|
660
|
+
const headline = stop.at === "failed" && stop.repair
|
|
661
|
+
? `Ad run ${runId} — Stopped, a step failed`
|
|
662
|
+
: `Ad run ${runId} — ${displayRunStatus(run.status)}`;
|
|
663
|
+
const lines = [headline, ...stopLines(stop, runId, deps.dashboardUrl)];
|
|
611
664
|
if (byScene.size === 0) {
|
|
612
665
|
lines.push("", "No scenes yet — this run hasn't made anything to look at.");
|
|
613
666
|
}
|
|
@@ -848,6 +901,70 @@ export function parseMvhdDurationSec(bytes) {
|
|
|
848
901
|
export const NO_DURATION_MESSAGE = "Can't tell how long this cut is.\n" +
|
|
849
902
|
"Install ffmpeg (which brings ffprobe), or pass the length yourself: --duration <seconds>";
|
|
850
903
|
const MB = 1024 * 1024;
|
|
904
|
+
const UPLOAD_ATTEMPTS = 3;
|
|
905
|
+
const UPLOAD_BACKOFF_MS = [500, 1500];
|
|
906
|
+
const UPLOAD_STEP_LABEL = {
|
|
907
|
+
mint: "asking the server for an upload slot",
|
|
908
|
+
store: "sending the file to storage",
|
|
909
|
+
register: "registering the uploaded file",
|
|
910
|
+
attach: "attaching the cut to the run",
|
|
911
|
+
};
|
|
912
|
+
export function describeFetchFailure(err) {
|
|
913
|
+
if (!(err instanceof Error))
|
|
914
|
+
return String(err);
|
|
915
|
+
const parts = [];
|
|
916
|
+
let cause = err.cause;
|
|
917
|
+
for (let depth = 0; depth < 2 && cause && typeof cause === "object"; depth++) {
|
|
918
|
+
const level = cause;
|
|
919
|
+
const code = typeof level.code === "string" && level.code ? level.code : undefined;
|
|
920
|
+
const message = typeof level.message === "string" && level.message ? level.message : undefined;
|
|
921
|
+
if (code && message)
|
|
922
|
+
parts.push(`${code}: ${message}`);
|
|
923
|
+
else if (code)
|
|
924
|
+
parts.push(code);
|
|
925
|
+
else if (message)
|
|
926
|
+
parts.push(message);
|
|
927
|
+
cause = level.cause;
|
|
928
|
+
}
|
|
929
|
+
return parts.length > 0 ? `${err.message} (${parts.join("; ")})` : err.message;
|
|
930
|
+
}
|
|
931
|
+
async function withUploadRetry(step, deps, fn) {
|
|
932
|
+
let cause = "";
|
|
933
|
+
for (let attempt = 1; attempt <= UPLOAD_ATTEMPTS; attempt++) {
|
|
934
|
+
try {
|
|
935
|
+
return { ok: true, value: await fn() };
|
|
936
|
+
}
|
|
937
|
+
catch (e) {
|
|
938
|
+
cause = describeFetchFailure(e);
|
|
939
|
+
const wait = UPLOAD_BACKOFF_MS[attempt - 1];
|
|
940
|
+
if (attempt < UPLOAD_ATTEMPTS && wait !== undefined)
|
|
941
|
+
await deps.sleep(wait);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
return { ok: false, step, attempts: UPLOAD_ATTEMPTS, cause };
|
|
945
|
+
}
|
|
946
|
+
function uploadRetryResult(failed, json) {
|
|
947
|
+
if (json) {
|
|
948
|
+
return {
|
|
949
|
+
code: 1,
|
|
950
|
+
lines: [
|
|
951
|
+
JSON.stringify({
|
|
952
|
+
ok: false,
|
|
953
|
+
step: failed.step,
|
|
954
|
+
attempts: failed.attempts,
|
|
955
|
+
error: failed.cause,
|
|
956
|
+
}),
|
|
957
|
+
],
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
return {
|
|
961
|
+
code: 1,
|
|
962
|
+
lines: [
|
|
963
|
+
`Upload failed while ${UPLOAD_STEP_LABEL[failed.step]} (${failed.attempts} tries): ${failed.cause}`,
|
|
964
|
+
"Check your connection and run the same command again.",
|
|
965
|
+
],
|
|
966
|
+
};
|
|
967
|
+
}
|
|
851
968
|
export async function uploadFlow(runId, filePath, durationFlag, json, deps) {
|
|
852
969
|
const stat = deps.statFile(filePath);
|
|
853
970
|
if (!stat)
|
|
@@ -890,34 +1007,50 @@ export async function uploadFlow(runId, filePath, durationFlag, json, deps) {
|
|
|
890
1007
|
const durationSec = asked ?? deps.probeDurationSec(filePath) ?? parseMvhdDurationSec(bytes);
|
|
891
1008
|
if (durationSec === null)
|
|
892
1009
|
return { code: 1, lines: [NO_DURATION_MESSAGE] };
|
|
893
|
-
const
|
|
1010
|
+
const minting = await withUploadRetry("mint", deps, () => deps.post(ASSET_UPLOAD_URL_PATH, {}));
|
|
1011
|
+
if (!minting.ok)
|
|
1012
|
+
return uploadRetryResult(minting, json);
|
|
1013
|
+
const mint = minting.value;
|
|
894
1014
|
if (!mint.ok)
|
|
895
1015
|
return errorResult(mint, json);
|
|
896
1016
|
const minted = mint.data;
|
|
897
|
-
|
|
1017
|
+
const uploadUrl = minted.uploadUrl;
|
|
1018
|
+
const receiptId = minted.receiptId;
|
|
1019
|
+
if (!uploadUrl || !receiptId) {
|
|
898
1020
|
return { code: 1, lines: ["The server did not hand back a place to upload to."] };
|
|
899
1021
|
}
|
|
900
|
-
const
|
|
1022
|
+
const storing = await withUploadRetry("store", deps, () => deps.uploadBytes(uploadUrl, mime, bytes));
|
|
1023
|
+
if (!storing.ok)
|
|
1024
|
+
return uploadRetryResult(storing, json);
|
|
1025
|
+
const put = storing.value;
|
|
901
1026
|
if (!put.ok || !put.storageId) {
|
|
902
1027
|
const detail = put.body ? `: ${put.body.slice(0, 200)}` : "";
|
|
903
1028
|
return { code: 1, lines: [`Upload failed (HTTP ${put.status})${detail}`] };
|
|
904
1029
|
}
|
|
905
|
-
const
|
|
906
|
-
|
|
907
|
-
|
|
1030
|
+
const storageId = put.storageId;
|
|
1031
|
+
const registering = await withUploadRetry("register", deps, () => deps.post(ASSETS_PATH, {
|
|
1032
|
+
storageId,
|
|
1033
|
+
receiptId,
|
|
908
1034
|
filename: path.basename(filePath),
|
|
909
|
-
});
|
|
1035
|
+
}));
|
|
1036
|
+
if (!registering.ok)
|
|
1037
|
+
return uploadRetryResult(registering, json);
|
|
1038
|
+
const registered = registering.value;
|
|
910
1039
|
if (!registered.ok)
|
|
911
1040
|
return errorResult(registered, json);
|
|
912
1041
|
const asset = registered.data;
|
|
913
|
-
|
|
1042
|
+
const assetId = asset.assetId;
|
|
1043
|
+
if (!assetId) {
|
|
914
1044
|
return { code: 1, lines: ["The server stored the file but did not say what to call it."] };
|
|
915
1045
|
}
|
|
916
|
-
const
|
|
1046
|
+
const attaching = await withUploadRetry("attach", deps, () => deps.post(FINAL_PATH, {
|
|
917
1047
|
runId,
|
|
918
|
-
assetId
|
|
1048
|
+
assetId,
|
|
919
1049
|
durationSec,
|
|
920
|
-
});
|
|
1050
|
+
}));
|
|
1051
|
+
if (!attaching.ok)
|
|
1052
|
+
return uploadRetryResult(attaching, json);
|
|
1053
|
+
const attached = attaching.value;
|
|
921
1054
|
if (!attached.ok)
|
|
922
1055
|
return errorResult(attached, json);
|
|
923
1056
|
const final = attached.data;
|
|
@@ -925,7 +1058,7 @@ export async function uploadFlow(runId, filePath, durationFlag, json, deps) {
|
|
|
925
1058
|
if (json) {
|
|
926
1059
|
return {
|
|
927
1060
|
code: 0,
|
|
928
|
-
lines: [JSON.stringify({ ok: true, runId, assetId
|
|
1061
|
+
lines: [JSON.stringify({ ok: true, runId, assetId, durationSec, finalWatchUrl })],
|
|
929
1062
|
};
|
|
930
1063
|
}
|
|
931
1064
|
return {
|