@aicopycoders/exodus 2026.9.200 → 2026.9.1100-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/README.md +11 -0
- package/assets/docs/CLAUDE.md +3 -1
- package/assets/docs/PIPELINES.md +22 -0
- package/assets/skills/exodus/SKILL.md +3 -1
- package/assets/skills/exodus-ads/SKILL.md +165 -0
- package/assets/skills/exodus-video/SKILL.md +233 -0
- package/assets/skills/exodus-video/scripts/first-cut.mjs +469 -0
- package/assets/skills/exodus-winners/SKILL.md +65 -17
- package/dist/bin/exodus.js +5 -0
- package/dist/commands/ads.d.ts +50 -0
- package/dist/commands/ads.js +414 -0
- package/dist/commands/comments.d.ts +28 -0
- package/dist/commands/comments.js +198 -0
- package/dist/commands/video.d.ts +213 -0
- package/dist/commands/video.js +1038 -0
- package/dist/commands/winners.d.ts +50 -0
- package/dist/commands/winners.js +175 -2
- package/dist/lib/meta-ads.d.ts +108 -0
- package/dist/lib/meta-ads.js +156 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1038 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { apiGet, apiPost, getDashboardUrl } from "../lib/client.js";
|
|
5
|
+
import { displayRunStatus, formatApiError } from "../lib/format.js";
|
|
6
|
+
import { missingRouteLine } from "../lib/route-support.js";
|
|
7
|
+
import { hasBinary } from "../lib/preflight.js";
|
|
8
|
+
import { ASSET_UPLOAD_POLICY } from "./workflow.js";
|
|
9
|
+
export const helpText = `
|
|
10
|
+
exodus video — make an ad from a Show, pull every piece, upload your cut
|
|
11
|
+
|
|
12
|
+
The dashboard makes the PIECES of an ad: the storyboard, one picture per scene,
|
|
13
|
+
one voice track per scene, one video clip per scene, plus the music bed. It does
|
|
14
|
+
not make the finished ad. You pull the pieces to a folder, cut them together
|
|
15
|
+
with whatever editing tools you like, and upload the cut back.
|
|
16
|
+
|
|
17
|
+
The whole loop, in order:
|
|
18
|
+
|
|
19
|
+
1. exodus video shows
|
|
20
|
+
Which Shows exist and which are ready to make ads.
|
|
21
|
+
|
|
22
|
+
2. exodus video start --show <id> --script script.txt --wait
|
|
23
|
+
Starts an ad and waits. It stops when the storyboard needs your yes.
|
|
24
|
+
|
|
25
|
+
3. exodus video storyboard <runId>
|
|
26
|
+
The scene cards: what each scene says and the picture it will look like.
|
|
27
|
+
|
|
28
|
+
4. exodus video approve <runId> (looks right — keep going)
|
|
29
|
+
exodus video flag <runId> --note "…" (something is wrong — send it back)
|
|
30
|
+
|
|
31
|
+
5. exodus video status <runId>
|
|
32
|
+
Where the run is and how each scene's clip turned out.
|
|
33
|
+
|
|
34
|
+
6. exodus video pull <runId> --out ./ad
|
|
35
|
+
Writes every piece to that folder plus a manifest.json index.
|
|
36
|
+
|
|
37
|
+
7. Make your cut from those files.
|
|
38
|
+
|
|
39
|
+
8. exodus video upload <runId> --file cut.mp4
|
|
40
|
+
Attaches your cut to the run and prints the page to approve it on.
|
|
41
|
+
|
|
42
|
+
9. exodus video approve <runId>
|
|
43
|
+
Or click Approve on the page from step 8.
|
|
44
|
+
|
|
45
|
+
Usage:
|
|
46
|
+
exodus video shows [--json]
|
|
47
|
+
exodus video start --show <id> --script <file> [--voice-path <v>] [--no-music] [--wait] [--json]
|
|
48
|
+
exodus video status <runId> [--json]
|
|
49
|
+
exodus video storyboard <runId> [--json]
|
|
50
|
+
exodus video approve <runId> [--json]
|
|
51
|
+
exodus video flag <runId> --note "<what is wrong>" [--json]
|
|
52
|
+
exodus video pull <runId> --out <dir> [--json]
|
|
53
|
+
exodus video upload <runId> --file <cut.mp4> [--duration <sec>] [--json]
|
|
54
|
+
|
|
55
|
+
Options:
|
|
56
|
+
--show <id> Which Show the ad belongs to (from: exodus video shows)
|
|
57
|
+
--script <file> A text file with the words you want the ad to say
|
|
58
|
+
--voice-path <v> Override the Show's voice path for this ad
|
|
59
|
+
--no-music Skip the music bed
|
|
60
|
+
--wait Stay open and report as the run moves; stops at every
|
|
61
|
+
point that needs you
|
|
62
|
+
--out <dir> Folder to write the pulled pieces into (pull)
|
|
63
|
+
--file <cut.mp4> Your finished cut (upload). MP4, MOV or WebM, up to 200MB
|
|
64
|
+
--duration <sec> How long your cut is, in seconds. Only needed when the
|
|
65
|
+
length can't be read off the file itself
|
|
66
|
+
--note "<text>" What is wrong with the storyboard (flag)
|
|
67
|
+
--json Machine-readable output
|
|
68
|
+
--help, -h Show this help
|
|
69
|
+
|
|
70
|
+
Video is admin-only. If every command here answers "video isn't enabled for
|
|
71
|
+
this key", your dashboard user needs the admin role on this brand.
|
|
72
|
+
|
|
73
|
+
Examples:
|
|
74
|
+
exodus video shows
|
|
75
|
+
exodus video start --show k57abc --script ./script.txt --wait
|
|
76
|
+
exodus video storyboard run_123
|
|
77
|
+
exodus video approve run_123
|
|
78
|
+
exodus video pull run_123 --out ./ad-run_123
|
|
79
|
+
exodus video upload run_123 --file ./cut.mp4
|
|
80
|
+
`.trim();
|
|
81
|
+
const SHOWS_PATH = "/api/v2/shows";
|
|
82
|
+
const RUNS_PATH = "/api/v2/video/runs";
|
|
83
|
+
const RUN_PATH = "/api/v2/workflow";
|
|
84
|
+
const ITEMS_PATH = "/api/v2/workflow/items";
|
|
85
|
+
const STORYBOARD_PATH = "/api/v2/video/storyboard";
|
|
86
|
+
const FLAG_PATH = "/api/v2/video/storyboard/flag";
|
|
87
|
+
const APPROVE_PATH = "/api/v2/workflow/approve";
|
|
88
|
+
const FINAL_PATH = "/api/v2/video/final";
|
|
89
|
+
const ASSET_UPLOAD_URL_PATH = "/api/v2/workflows/asset-upload-url";
|
|
90
|
+
const ASSETS_PATH = "/api/v2/workflows/assets";
|
|
91
|
+
export function asVideoRun(data) {
|
|
92
|
+
const run = (data ?? {});
|
|
93
|
+
return { ...run, nodes: Array.isArray(run.nodes) ? run.nodes : [] };
|
|
94
|
+
}
|
|
95
|
+
function defaultProbeDurationSec(filePath) {
|
|
96
|
+
if (!hasBinary("ffprobe"))
|
|
97
|
+
return null;
|
|
98
|
+
const res = spawnSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", filePath], { encoding: "utf-8" });
|
|
99
|
+
if (res.error || res.status !== 0)
|
|
100
|
+
return null;
|
|
101
|
+
const seconds = Number.parseFloat((res.stdout ?? "").trim());
|
|
102
|
+
return Number.isFinite(seconds) && seconds > 0 ? seconds : null;
|
|
103
|
+
}
|
|
104
|
+
export const defaultDeps = {
|
|
105
|
+
get: (p) => apiGet(p),
|
|
106
|
+
post: (p, body) => apiPost(p, body),
|
|
107
|
+
mkdirp: (dir) => {
|
|
108
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
109
|
+
},
|
|
110
|
+
writeFile: (filePath, text) => fs.writeFileSync(filePath, text, "utf-8"),
|
|
111
|
+
downloadToFile: async (url, filePath) => {
|
|
112
|
+
const res = await fetch(url);
|
|
113
|
+
if (!res.ok)
|
|
114
|
+
throw new Error(`HTTP ${res.status}`);
|
|
115
|
+
fs.writeFileSync(filePath, Buffer.from(await res.arrayBuffer()));
|
|
116
|
+
},
|
|
117
|
+
readFile: (filePath) => fs.readFileSync(filePath, "utf-8"),
|
|
118
|
+
readFileBytes: (filePath) => fs.readFileSync(filePath),
|
|
119
|
+
statFile: (filePath) => {
|
|
120
|
+
try {
|
|
121
|
+
const stat = fs.statSync(filePath);
|
|
122
|
+
return stat.isFile() ? { size: stat.size } : null;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
uploadBytes: async (uploadUrl, contentType, bytes) => {
|
|
129
|
+
const res = await fetch(uploadUrl, {
|
|
130
|
+
method: "POST",
|
|
131
|
+
headers: { "Content-Type": contentType },
|
|
132
|
+
body: new Blob([bytes]),
|
|
133
|
+
});
|
|
134
|
+
if (!res.ok) {
|
|
135
|
+
const body = await res.text().catch(() => "");
|
|
136
|
+
return { ok: false, status: res.status, body };
|
|
137
|
+
}
|
|
138
|
+
const parsed = (await res.json().catch(() => ({})));
|
|
139
|
+
return { ok: true, status: res.status, storageId: parsed.storageId };
|
|
140
|
+
},
|
|
141
|
+
probeDurationSec: defaultProbeDurationSec,
|
|
142
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
143
|
+
now: () => Date.now(),
|
|
144
|
+
dashboardUrl: getDashboardUrl(),
|
|
145
|
+
};
|
|
146
|
+
export const VIDEO_NOT_FOUND_MESSAGE = "video isn't enabled for this key\n" +
|
|
147
|
+
"Either your dashboard user needs the admin role on this brand (video is admin-only), or that run id doesn't exist here. Check the id, then ask Brad about the role.";
|
|
148
|
+
export function videoApiError(res) {
|
|
149
|
+
const behind = missingRouteLine(res, "exodus video");
|
|
150
|
+
if (behind)
|
|
151
|
+
return behind;
|
|
152
|
+
if (res.status === 404)
|
|
153
|
+
return VIDEO_NOT_FOUND_MESSAGE;
|
|
154
|
+
return formatApiError(res);
|
|
155
|
+
}
|
|
156
|
+
function errorResult(res, json) {
|
|
157
|
+
return {
|
|
158
|
+
code: 1,
|
|
159
|
+
lines: json
|
|
160
|
+
? [JSON.stringify({ ok: false, status: res.status, error: videoApiError(res) })]
|
|
161
|
+
: [videoApiError(res)],
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const GATE_NODE_KINDS = new Set(["scene-frames", "storyboard"]);
|
|
165
|
+
function isAwaitingApproval(status) {
|
|
166
|
+
return status === "awaiting-approval" || status === "awaiting-review";
|
|
167
|
+
}
|
|
168
|
+
function parkedByBuilderCheckpoint(run) {
|
|
169
|
+
return run.pauseReason !== undefined;
|
|
170
|
+
}
|
|
171
|
+
function parkedAtStoryboardGate(run, pausedNode) {
|
|
172
|
+
return !parkedByBuilderCheckpoint(run) && GATE_NODE_KINDS.has(pausedNode.kind);
|
|
173
|
+
}
|
|
174
|
+
function parkedAtFinalWatch(run) {
|
|
175
|
+
return (!parkedByBuilderCheckpoint(run) &&
|
|
176
|
+
run.nodes.some((n) => n.kind === "video" && n.status === "done"));
|
|
177
|
+
}
|
|
178
|
+
export function classifyRun(run) {
|
|
179
|
+
if (run.status === "failed")
|
|
180
|
+
return { at: "failed", error: run.error };
|
|
181
|
+
if (run.isTerminal)
|
|
182
|
+
return { at: "finished", status: run.status };
|
|
183
|
+
if (isAwaitingApproval(run.status)) {
|
|
184
|
+
const pausedNode = run.nodes.find((n) => n.nodeId === run.pausedNodeId);
|
|
185
|
+
if (pausedNode && parkedAtStoryboardGate(run, pausedNode)) {
|
|
186
|
+
return { at: "storyboard-gate", nodeId: pausedNode.nodeId };
|
|
187
|
+
}
|
|
188
|
+
if (parkedAtFinalWatch(run))
|
|
189
|
+
return { at: "final-watch" };
|
|
190
|
+
return { at: "paused", nodeId: run.pausedNodeId, reason: run.pauseReason };
|
|
191
|
+
}
|
|
192
|
+
const active = run.nodes.find((n) => n.status === "running");
|
|
193
|
+
return { at: "running", stage: active?.kind ?? "starting" };
|
|
194
|
+
}
|
|
195
|
+
const STAGE_WORDS = {
|
|
196
|
+
brief: "reading the script",
|
|
197
|
+
storyboard: "writing the storyboard",
|
|
198
|
+
reference: "drawing the reference still",
|
|
199
|
+
"scene-frames": "drawing one picture per scene",
|
|
200
|
+
voiceover: "recording the voices",
|
|
201
|
+
video: "rendering the clips",
|
|
202
|
+
starting: "starting up",
|
|
203
|
+
};
|
|
204
|
+
export function stageWord(stage) {
|
|
205
|
+
return STAGE_WORDS[stage] ?? stage;
|
|
206
|
+
}
|
|
207
|
+
export function stopLines(stop, runId, dashboardUrl) {
|
|
208
|
+
if (stop.at === "storyboard-gate") {
|
|
209
|
+
return [
|
|
210
|
+
"Parked: the storyboard is waiting for your yes.",
|
|
211
|
+
`Read it: exodus video storyboard ${runId}`,
|
|
212
|
+
`Approve it: exodus video approve ${runId}`,
|
|
213
|
+
`Send back: exodus video flag ${runId} --note "what's wrong"`,
|
|
214
|
+
];
|
|
215
|
+
}
|
|
216
|
+
if (stop.at === "final-watch") {
|
|
217
|
+
return [
|
|
218
|
+
"Parked: every piece is made and the run is waiting for a cut.",
|
|
219
|
+
`Pull the pieces: exodus video pull ${runId} --out ./ad-${runId}`,
|
|
220
|
+
`Then upload: exodus video upload ${runId} --file cut.mp4`,
|
|
221
|
+
];
|
|
222
|
+
}
|
|
223
|
+
if (stop.at === "failed") {
|
|
224
|
+
return [
|
|
225
|
+
`This run failed${stop.error ? `: ${stop.error}` : "."}`,
|
|
226
|
+
`See how far it got: exodus video status ${runId}`,
|
|
227
|
+
];
|
|
228
|
+
}
|
|
229
|
+
if (stop.at === "finished") {
|
|
230
|
+
return [
|
|
231
|
+
`This run is finished (${displayRunStatus(stop.status)}).`,
|
|
232
|
+
`Pull what it made: exodus video pull ${runId} --out ./ad-${runId}`,
|
|
233
|
+
];
|
|
234
|
+
}
|
|
235
|
+
if (stop.at === "paused") {
|
|
236
|
+
return [
|
|
237
|
+
`Parked: this run is waiting on someone${stop.reason ? ` (${stop.reason})` : ""}.`,
|
|
238
|
+
`Open it: ${dashboardUrl}/video?ad=${runId}`,
|
|
239
|
+
];
|
|
240
|
+
}
|
|
241
|
+
return [`Working: ${stageWord(stop.stage)}.`];
|
|
242
|
+
}
|
|
243
|
+
const FALLBACK_EXT = {
|
|
244
|
+
image: "png",
|
|
245
|
+
video: "mp4",
|
|
246
|
+
audio: "mp3",
|
|
247
|
+
};
|
|
248
|
+
export function extensionFromUrl(url) {
|
|
249
|
+
const withoutQuery = url.split(/[?#]/)[0];
|
|
250
|
+
const match = withoutQuery.match(/\.([a-z0-9]{1,5})$/i);
|
|
251
|
+
return match ? match[1].toLowerCase() : undefined;
|
|
252
|
+
}
|
|
253
|
+
function extFor(url, family) {
|
|
254
|
+
return extensionFromUrl(url) ?? FALLBACK_EXT[family];
|
|
255
|
+
}
|
|
256
|
+
export function scenePrefix(sceneIndex) {
|
|
257
|
+
return `scene-${String(sceneIndex).padStart(2, "0")}`;
|
|
258
|
+
}
|
|
259
|
+
function outputsOfNodeKind(run, kind) {
|
|
260
|
+
return run.nodes.filter((n) => n.kind === kind).flatMap((n) => n.outputs ?? []);
|
|
261
|
+
}
|
|
262
|
+
export function planPull(run, items, opts) {
|
|
263
|
+
const texts = [];
|
|
264
|
+
const storyboardArtifact = run.nodes
|
|
265
|
+
.flatMap((n) => n.outputs ?? [])
|
|
266
|
+
.find((a) => a.type === "storyboard");
|
|
267
|
+
let storyboard = null;
|
|
268
|
+
if (storyboardArtifact) {
|
|
269
|
+
const raw = typeof storyboardArtifact.storyboardJson === "string"
|
|
270
|
+
? storyboardArtifact.storyboardJson
|
|
271
|
+
: storyboardArtifact.storyboard !== undefined
|
|
272
|
+
? JSON.stringify(storyboardArtifact.storyboard, null, 2)
|
|
273
|
+
: undefined;
|
|
274
|
+
if (raw !== undefined) {
|
|
275
|
+
storyboard = "storyboard.json";
|
|
276
|
+
texts.push({ file: storyboard, body: raw.endsWith("\n") ? raw : `${raw}\n` });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
let reference = null;
|
|
280
|
+
for (const artifact of outputsOfNodeKind(run, "reference")) {
|
|
281
|
+
if (artifact.type !== "image" || !artifact.imageUrl)
|
|
282
|
+
continue;
|
|
283
|
+
reference = {
|
|
284
|
+
file: `reference.${extFor(artifact.imageUrl, "image")}`,
|
|
285
|
+
url: artifact.imageUrl,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
const keyframeByScene = new Map();
|
|
289
|
+
for (const artifact of run.nodes.flatMap((n) => n.outputs ?? [])) {
|
|
290
|
+
if (artifact.type !== "frames")
|
|
291
|
+
continue;
|
|
292
|
+
for (const frame of artifact.frames ?? []) {
|
|
293
|
+
if (!frame.imageUrl)
|
|
294
|
+
continue;
|
|
295
|
+
keyframeByScene.set(frame.sceneIndex, {
|
|
296
|
+
file: `${scenePrefix(frame.sceneIndex)}.keyframe.${extFor(frame.imageUrl, "image")}`,
|
|
297
|
+
url: frame.imageUrl,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const voiceByScene = new Map();
|
|
302
|
+
for (const artifact of outputsOfNodeKind(run, "voiceover")) {
|
|
303
|
+
if (artifact.type !== "audio" || typeof artifact.sceneIndex !== "number")
|
|
304
|
+
continue;
|
|
305
|
+
if (!artifact.audioUrl)
|
|
306
|
+
continue;
|
|
307
|
+
voiceByScene.set(artifact.sceneIndex, {
|
|
308
|
+
file: `${scenePrefix(artifact.sceneIndex)}.voice.${extFor(artifact.audioUrl, "audio")}`,
|
|
309
|
+
url: artifact.audioUrl,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
const clipByScene = new Map();
|
|
313
|
+
let music = null;
|
|
314
|
+
for (const artifact of outputsOfNodeKind(run, "video")) {
|
|
315
|
+
if (artifact.type === "audio" && artifact.sceneIndex === undefined && artifact.audioUrl) {
|
|
316
|
+
music = { file: `music.${extFor(artifact.audioUrl, "audio")}`, url: artifact.audioUrl };
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (artifact.type !== "video" || typeof artifact.sceneIndex !== "number")
|
|
320
|
+
continue;
|
|
321
|
+
if (artifact.final || !artifact.videoUrl)
|
|
322
|
+
continue;
|
|
323
|
+
clipByScene.set(artifact.sceneIndex, {
|
|
324
|
+
download: {
|
|
325
|
+
file: `${scenePrefix(artifact.sceneIndex)}.${extFor(artifact.videoUrl, "video")}`,
|
|
326
|
+
url: artifact.videoUrl,
|
|
327
|
+
},
|
|
328
|
+
durationSec: artifact.durationSec ?? null,
|
|
329
|
+
qc: artifact.qc ?? null,
|
|
330
|
+
words: artifact.words ?? null,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
const clipItemByScene = new Map();
|
|
334
|
+
for (const item of items) {
|
|
335
|
+
if (item.itemKind === "clip")
|
|
336
|
+
clipItemByScene.set(item.sceneIndex, item);
|
|
337
|
+
}
|
|
338
|
+
const sceneIndexes = [
|
|
339
|
+
...new Set([
|
|
340
|
+
...clipByScene.keys(),
|
|
341
|
+
...voiceByScene.keys(),
|
|
342
|
+
...keyframeByScene.keys(),
|
|
343
|
+
...clipItemByScene.keys(),
|
|
344
|
+
]),
|
|
345
|
+
].sort((a, b) => a - b);
|
|
346
|
+
const scenes = sceneIndexes.map((sceneIndex) => {
|
|
347
|
+
const clip = clipByScene.get(sceneIndex);
|
|
348
|
+
const item = clipItemByScene.get(sceneIndex);
|
|
349
|
+
let words = null;
|
|
350
|
+
if (clip?.words && clip.words.length > 0) {
|
|
351
|
+
words = `${scenePrefix(sceneIndex)}.words.json`;
|
|
352
|
+
texts.push({ file: words, body: `${JSON.stringify(clip.words, null, 2)}\n` });
|
|
353
|
+
}
|
|
354
|
+
return {
|
|
355
|
+
sceneIndex,
|
|
356
|
+
durationSec: clip?.durationSec ?? null,
|
|
357
|
+
clip: clip?.download.file ?? null,
|
|
358
|
+
words,
|
|
359
|
+
voice: voiceByScene.get(sceneIndex)?.file ?? null,
|
|
360
|
+
keyframe: keyframeByScene.get(sceneIndex)?.file ?? null,
|
|
361
|
+
qc: clip?.qc ?? null,
|
|
362
|
+
clipStatus: item?.status ?? "missing",
|
|
363
|
+
error: item?.error ?? null,
|
|
364
|
+
flagged: item?.flagged === true,
|
|
365
|
+
findings: item?.findings ?? [],
|
|
366
|
+
};
|
|
367
|
+
});
|
|
368
|
+
const downloads = [
|
|
369
|
+
...(reference ? [reference] : []),
|
|
370
|
+
...keyframeByScene.values(),
|
|
371
|
+
...voiceByScene.values(),
|
|
372
|
+
...[...clipByScene.values()].map((c) => c.download),
|
|
373
|
+
...(music ? [music] : []),
|
|
374
|
+
];
|
|
375
|
+
return {
|
|
376
|
+
downloads,
|
|
377
|
+
texts,
|
|
378
|
+
manifest: {
|
|
379
|
+
runId: run._id,
|
|
380
|
+
pulledAt: opts.pulledAt,
|
|
381
|
+
dashboardUrl: `${opts.dashboardUrl}/video?ad=${run._id}`,
|
|
382
|
+
storyboard,
|
|
383
|
+
reference: reference?.file ?? null,
|
|
384
|
+
music: music?.file ?? null,
|
|
385
|
+
scenes,
|
|
386
|
+
failed: [],
|
|
387
|
+
},
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
export function markPullFailure(manifest, failure) {
|
|
391
|
+
manifest.failed.push(failure);
|
|
392
|
+
if (manifest.storyboard === failure.file)
|
|
393
|
+
manifest.storyboard = null;
|
|
394
|
+
if (manifest.reference === failure.file)
|
|
395
|
+
manifest.reference = null;
|
|
396
|
+
if (manifest.music === failure.file)
|
|
397
|
+
manifest.music = null;
|
|
398
|
+
for (const scene of manifest.scenes) {
|
|
399
|
+
if (scene.clip === failure.file)
|
|
400
|
+
scene.clip = null;
|
|
401
|
+
if (scene.voice === failure.file)
|
|
402
|
+
scene.voice = null;
|
|
403
|
+
if (scene.keyframe === failure.file)
|
|
404
|
+
scene.keyframe = null;
|
|
405
|
+
if (scene.words === failure.file)
|
|
406
|
+
scene.words = null;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
const PULL_CONCURRENCY = 4;
|
|
410
|
+
async function runPool(jobs, size) {
|
|
411
|
+
const results = new Array(jobs.length);
|
|
412
|
+
let next = 0;
|
|
413
|
+
const worker = async () => {
|
|
414
|
+
for (;;) {
|
|
415
|
+
const index = next++;
|
|
416
|
+
if (index >= jobs.length)
|
|
417
|
+
return;
|
|
418
|
+
results[index] = await jobs[index]();
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
await Promise.all(Array.from({ length: Math.min(size, jobs.length) }, worker));
|
|
422
|
+
return results;
|
|
423
|
+
}
|
|
424
|
+
const DOWNLOAD_ATTEMPTS = 2;
|
|
425
|
+
async function downloadWithRetry(download, dir, deps) {
|
|
426
|
+
let lastError = "";
|
|
427
|
+
for (let attempt = 0; attempt < DOWNLOAD_ATTEMPTS; attempt++) {
|
|
428
|
+
try {
|
|
429
|
+
await deps.downloadToFile(download.url, path.join(dir, download.file));
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
catch (e) {
|
|
433
|
+
lastError = e instanceof Error ? e.message : String(e);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return { file: download.file, url: download.url, error: lastError };
|
|
437
|
+
}
|
|
438
|
+
const SETUP_LABELS = [
|
|
439
|
+
{ key: "set", label: "Set" },
|
|
440
|
+
{ key: "cast", label: "Cast" },
|
|
441
|
+
{ key: "voices", label: "Voices" },
|
|
442
|
+
];
|
|
443
|
+
export function missingSetupLabels(progress) {
|
|
444
|
+
if (!progress)
|
|
445
|
+
return [];
|
|
446
|
+
return SETUP_LABELS.filter(({ key }) => progress[key] !== true).map(({ label }) => label);
|
|
447
|
+
}
|
|
448
|
+
export async function showsFlow(json, deps) {
|
|
449
|
+
const res = await deps.get(SHOWS_PATH);
|
|
450
|
+
if (!res.ok)
|
|
451
|
+
return errorResult(res, json);
|
|
452
|
+
const shows = (res.data.shows ?? []).slice();
|
|
453
|
+
if (json)
|
|
454
|
+
return { code: 0, lines: [JSON.stringify({ shows })] };
|
|
455
|
+
if (shows.length === 0) {
|
|
456
|
+
return {
|
|
457
|
+
code: 0,
|
|
458
|
+
lines: [
|
|
459
|
+
"No Shows on this brand yet.",
|
|
460
|
+
"A Show is the set, cast and voices your ads reuse. Make one in the dashboard under Video.",
|
|
461
|
+
],
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
const idWidth = Math.max(2, ...shows.map((s) => s.id.length));
|
|
465
|
+
const nameWidth = Math.max(4, ...shows.map((s) => s.name.length));
|
|
466
|
+
const lines = [
|
|
467
|
+
`${"id".padEnd(idWidth)} ${"name".padEnd(nameWidth)} state`,
|
|
468
|
+
...shows.map((show) => {
|
|
469
|
+
const missing = missingSetupLabels(show.setupProgress);
|
|
470
|
+
const state = show.ready === true
|
|
471
|
+
? "ready"
|
|
472
|
+
: missing.length > 0
|
|
473
|
+
? `not ready — still needs ${missing.join(", ")}`
|
|
474
|
+
: "not ready";
|
|
475
|
+
return `${show.id.padEnd(idWidth)} ${show.name.padEnd(nameWidth)} ${state}`;
|
|
476
|
+
}),
|
|
477
|
+
"",
|
|
478
|
+
"Start an ad on a ready Show: exodus video start --show <id> --script script.txt",
|
|
479
|
+
];
|
|
480
|
+
return { code: 0, lines };
|
|
481
|
+
}
|
|
482
|
+
export async function startFlow(opts, deps) {
|
|
483
|
+
let script;
|
|
484
|
+
try {
|
|
485
|
+
script = deps.readFile(opts.scriptFile);
|
|
486
|
+
}
|
|
487
|
+
catch (e) {
|
|
488
|
+
return {
|
|
489
|
+
code: 1,
|
|
490
|
+
lines: [
|
|
491
|
+
`Can't read the script file "${opts.scriptFile}": ${e instanceof Error ? e.message : String(e)}`,
|
|
492
|
+
],
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
if (script.trim().length === 0) {
|
|
496
|
+
return { code: 1, lines: [`The script file "${opts.scriptFile}" is empty.`] };
|
|
497
|
+
}
|
|
498
|
+
const res = await deps.post(RUNS_PATH, {
|
|
499
|
+
showId: opts.showId,
|
|
500
|
+
script,
|
|
501
|
+
...(opts.voicePath ? { voicePath: opts.voicePath } : {}),
|
|
502
|
+
...(opts.music === false ? { music: false } : {}),
|
|
503
|
+
});
|
|
504
|
+
if (!res.ok)
|
|
505
|
+
return errorResult(res, opts.json);
|
|
506
|
+
const started = res.data;
|
|
507
|
+
if (!started.runId) {
|
|
508
|
+
return { code: 1, lines: ["The server started the ad but did not say which run it is."] };
|
|
509
|
+
}
|
|
510
|
+
const runId = started.runId;
|
|
511
|
+
const url = started.url ?? `${deps.dashboardUrl}/video?ad=${runId}`;
|
|
512
|
+
if (!opts.wait) {
|
|
513
|
+
const lines = [
|
|
514
|
+
`Started ad run ${runId}`,
|
|
515
|
+
`Watch it: ${url}`,
|
|
516
|
+
"",
|
|
517
|
+
`Wait for the storyboard here instead: exodus video start … --wait`,
|
|
518
|
+
`Or check in whenever: exodus video status ${runId}`,
|
|
519
|
+
];
|
|
520
|
+
return {
|
|
521
|
+
code: 0,
|
|
522
|
+
lines: opts.json ? [JSON.stringify({ runId, url })] : lines,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
const waited = await waitFlow(runId, { json: opts.json, url }, deps);
|
|
526
|
+
return {
|
|
527
|
+
code: waited.code,
|
|
528
|
+
lines: opts.json ? waited.lines : [`Started ad run ${runId}`, `Watch it: ${url}`, "", ...waited.lines],
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
const POLL_INTERVAL_MS = 5000;
|
|
532
|
+
const MAX_POLLS = 720;
|
|
533
|
+
export async function waitFlow(runId, opts, deps) {
|
|
534
|
+
const interval = opts.intervalMs ?? POLL_INTERVAL_MS;
|
|
535
|
+
const maxPolls = opts.maxPolls ?? MAX_POLLS;
|
|
536
|
+
const lines = [];
|
|
537
|
+
let lastStage = null;
|
|
538
|
+
for (let poll = 0; poll < maxPolls; poll++) {
|
|
539
|
+
if (poll > 0)
|
|
540
|
+
await deps.sleep(interval);
|
|
541
|
+
const res = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
542
|
+
if (!res.ok) {
|
|
543
|
+
const failed = errorResult(res, opts.json);
|
|
544
|
+
return { code: failed.code, lines: [...lines, ...failed.lines] };
|
|
545
|
+
}
|
|
546
|
+
const run = asVideoRun(res.data);
|
|
547
|
+
const stop = classifyRun(run);
|
|
548
|
+
if (stop.at === "running") {
|
|
549
|
+
if (stop.stage !== lastStage) {
|
|
550
|
+
lastStage = stop.stage;
|
|
551
|
+
if (!opts.json)
|
|
552
|
+
lines.push(`Working: ${stageWord(stop.stage)}…`);
|
|
553
|
+
}
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
if (opts.json) {
|
|
557
|
+
return {
|
|
558
|
+
code: stop.at === "failed" ? 1 : 0,
|
|
559
|
+
lines: [JSON.stringify({ runId, stop, status: run.status, url: opts.url })],
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
return {
|
|
563
|
+
code: stop.at === "failed" ? 1 : 0,
|
|
564
|
+
lines: [...lines, ...stopLines(stop, runId, deps.dashboardUrl)],
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
const timeoutLine = `Still running after ${Math.round((maxPolls * interval) / 60000)} minutes. Check in with: exodus video status ${runId}`;
|
|
568
|
+
return {
|
|
569
|
+
code: 0,
|
|
570
|
+
lines: opts.json ? [JSON.stringify({ runId, stop: { at: "running" }, timedOut: true })] : [...lines, timeoutLine],
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
const ITEM_STATUS_WORD = {
|
|
574
|
+
done: "done",
|
|
575
|
+
failed: "failed",
|
|
576
|
+
running: "working",
|
|
577
|
+
idle: "waiting",
|
|
578
|
+
skipped: "skipped",
|
|
579
|
+
};
|
|
580
|
+
function itemWord(item) {
|
|
581
|
+
if (!item)
|
|
582
|
+
return "—";
|
|
583
|
+
if (item.flagged === true)
|
|
584
|
+
return "flagged";
|
|
585
|
+
return ITEM_STATUS_WORD[item.status] ?? item.status;
|
|
586
|
+
}
|
|
587
|
+
export async function statusFlow(runId, json, deps) {
|
|
588
|
+
const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
589
|
+
if (!runRes.ok)
|
|
590
|
+
return errorResult(runRes, json);
|
|
591
|
+
const run = asVideoRun(runRes.data);
|
|
592
|
+
const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
593
|
+
if (!itemsRes.ok)
|
|
594
|
+
return errorResult(itemsRes, json);
|
|
595
|
+
const items = itemsRes.data.items ?? [];
|
|
596
|
+
const stop = classifyRun(run);
|
|
597
|
+
const hasFinal = items.some((i) => i.itemKind === "final" && i.status === "done");
|
|
598
|
+
if (json) {
|
|
599
|
+
return { code: 0, lines: [JSON.stringify({ runId, status: run.status, stop, items, hasFinal })] };
|
|
600
|
+
}
|
|
601
|
+
const byScene = new Map();
|
|
602
|
+
for (const item of items) {
|
|
603
|
+
if (item.itemKind !== "clip" && item.itemKind !== "voiceover" && item.itemKind !== "frame") {
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
const row = byScene.get(item.sceneIndex) ?? {};
|
|
607
|
+
row[item.itemKind] = item;
|
|
608
|
+
byScene.set(item.sceneIndex, row);
|
|
609
|
+
}
|
|
610
|
+
const lines = [`Ad run ${runId} — ${displayRunStatus(run.status)}`, ...stopLines(stop, runId, deps.dashboardUrl)];
|
|
611
|
+
if (byScene.size === 0) {
|
|
612
|
+
lines.push("", "No scenes yet — this run hasn't made anything to look at.");
|
|
613
|
+
}
|
|
614
|
+
else {
|
|
615
|
+
lines.push("", "Scene Clip Voice Picture");
|
|
616
|
+
for (const sceneIndex of [...byScene.keys()].sort((a, b) => a - b)) {
|
|
617
|
+
const row = byScene.get(sceneIndex);
|
|
618
|
+
lines.push(`${String(sceneIndex).padEnd(5)} ${itemWord(row.clip).padEnd(8)} ${itemWord(row.voiceover).padEnd(8)} ${itemWord(row.frame)}`);
|
|
619
|
+
for (const finding of row.clip?.findings ?? []) {
|
|
620
|
+
lines.push(` ${finding.code} (${finding.severity}): ${finding.detail}`);
|
|
621
|
+
}
|
|
622
|
+
if (row.clip?.error)
|
|
623
|
+
lines.push(` ${row.clip.error}`);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
lines.push("", hasFinal && stop.at === "finished"
|
|
627
|
+
? "Final cut: approved and delivered."
|
|
628
|
+
: hasFinal
|
|
629
|
+
? `Final cut: uploaded. Approve it with: exodus video approve ${runId}`
|
|
630
|
+
: "Final cut: not uploaded yet.");
|
|
631
|
+
return { code: 0, lines };
|
|
632
|
+
}
|
|
633
|
+
export async function storyboardFlow(runId, json, deps) {
|
|
634
|
+
const res = await deps.get(`${STORYBOARD_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
635
|
+
if (!res.ok)
|
|
636
|
+
return errorResult(res, json);
|
|
637
|
+
const cards = res.data;
|
|
638
|
+
if (json)
|
|
639
|
+
return { code: 0, lines: [JSON.stringify(cards)] };
|
|
640
|
+
if (!cards.ready) {
|
|
641
|
+
return {
|
|
642
|
+
code: 0,
|
|
643
|
+
lines: [
|
|
644
|
+
"No storyboard yet — this run hasn't written one.",
|
|
645
|
+
`Check where it is: exodus video status ${runId}`,
|
|
646
|
+
],
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
const lines = [cards.title ? `Storyboard — ${cards.title}` : "Storyboard"];
|
|
650
|
+
for (const warning of cards.warnings ?? [])
|
|
651
|
+
lines.push(`Heads-up: ${warning}`);
|
|
652
|
+
const blocks = cards.blocks ?? [];
|
|
653
|
+
for (const block of blocks) {
|
|
654
|
+
const label = block.family ? `${block.family} block` : `block ${block.blockId}`;
|
|
655
|
+
lines.push("", block.pass === false ? `${label} — needs a look` : label);
|
|
656
|
+
for (const warning of block.warnings ?? [])
|
|
657
|
+
lines.push(` Heads-up: ${warning}`);
|
|
658
|
+
for (const scene of block.scenes ?? [])
|
|
659
|
+
lines.push(...sceneCardLines(scene));
|
|
660
|
+
}
|
|
661
|
+
const loose = cards.looseScenes ?? [];
|
|
662
|
+
if (loose.length > 0) {
|
|
663
|
+
lines.push("", "Scenes outside any block");
|
|
664
|
+
for (const scene of loose)
|
|
665
|
+
lines.push(...sceneCardLines(scene));
|
|
666
|
+
}
|
|
667
|
+
lines.push("", `approve with: exodus video approve ${runId}`, `send it back: exodus video flag ${runId} --note "what's wrong"`);
|
|
668
|
+
return { code: 0, lines };
|
|
669
|
+
}
|
|
670
|
+
function sceneCardLines(scene) {
|
|
671
|
+
const head = [
|
|
672
|
+
` Scene ${scene.sceneIndex}`,
|
|
673
|
+
scene.kind ? `(${scene.kind})` : "",
|
|
674
|
+
typeof scene.durationSec === "number" ? `${scene.durationSec}s` : "",
|
|
675
|
+
]
|
|
676
|
+
.filter(Boolean)
|
|
677
|
+
.join(" ");
|
|
678
|
+
const lines = [head];
|
|
679
|
+
for (const turn of scene.dialogue ?? []) {
|
|
680
|
+
if (!turn.line)
|
|
681
|
+
continue;
|
|
682
|
+
lines.push(turn.speaker ? ` ${turn.speaker}: ${turn.line}` : ` ${turn.line}`);
|
|
683
|
+
}
|
|
684
|
+
if (!scene.dialogue?.length && scene.voText)
|
|
685
|
+
lines.push(` "${scene.voText}"`);
|
|
686
|
+
if (scene.frame?.imageUrl)
|
|
687
|
+
lines.push(` picture: ${scene.frame.imageUrl}`);
|
|
688
|
+
else if (scene.frame?.status)
|
|
689
|
+
lines.push(` picture: ${scene.frame.status}`);
|
|
690
|
+
return lines;
|
|
691
|
+
}
|
|
692
|
+
export async function approveFlow(runId, json, deps) {
|
|
693
|
+
const res = await deps.post(APPROVE_PATH, { runId });
|
|
694
|
+
if (!res.ok)
|
|
695
|
+
return errorResult(res, json);
|
|
696
|
+
if (json)
|
|
697
|
+
return { code: 0, lines: [JSON.stringify({ ok: true, runId, data: res.data })] };
|
|
698
|
+
return {
|
|
699
|
+
code: 0,
|
|
700
|
+
lines: [
|
|
701
|
+
"Approved.",
|
|
702
|
+
`See what happens next: exodus video status ${runId}`,
|
|
703
|
+
],
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
export async function flagFlow(runId, note, json, deps) {
|
|
707
|
+
const res = await deps.post(FLAG_PATH, { runId, note });
|
|
708
|
+
if (!res.ok)
|
|
709
|
+
return errorResult(res, json);
|
|
710
|
+
const data = res.data;
|
|
711
|
+
if (json)
|
|
712
|
+
return { code: 0, lines: [JSON.stringify({ ok: true, runId, ...data })] };
|
|
713
|
+
return {
|
|
714
|
+
code: 0,
|
|
715
|
+
lines: [
|
|
716
|
+
"Sent back with your note.",
|
|
717
|
+
data.strategistLooking
|
|
718
|
+
? "Someone is rewriting the storyboard now."
|
|
719
|
+
: "Nobody is rewriting it yet — the note is on the run.",
|
|
720
|
+
`Check back with: exodus video storyboard ${runId}`,
|
|
721
|
+
],
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
export async function pullFlow(runId, dir, json, deps) {
|
|
725
|
+
const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
726
|
+
if (!runRes.ok)
|
|
727
|
+
return errorResult(runRes, json);
|
|
728
|
+
const run = asVideoRun(runRes.data);
|
|
729
|
+
const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
730
|
+
if (!itemsRes.ok)
|
|
731
|
+
return errorResult(itemsRes, json);
|
|
732
|
+
const items = itemsRes.data.items ?? [];
|
|
733
|
+
const plan = planPull(run, items, {
|
|
734
|
+
pulledAt: new Date(deps.now()).toISOString(),
|
|
735
|
+
dashboardUrl: deps.dashboardUrl,
|
|
736
|
+
});
|
|
737
|
+
deps.mkdirp(dir);
|
|
738
|
+
if (plan.downloads.length === 0 && plan.texts.length === 0) {
|
|
739
|
+
deps.writeFile(path.join(dir, "manifest.json"), `${JSON.stringify(plan.manifest, null, 2)}\n`);
|
|
740
|
+
const lines = [
|
|
741
|
+
"Nothing to pull yet — this run hasn't made any pieces.",
|
|
742
|
+
`Wrote ${path.join(dir, "manifest.json")} anyway, so you can see what it knows.`,
|
|
743
|
+
`Check where it is: exodus video status ${runId}`,
|
|
744
|
+
];
|
|
745
|
+
return { code: 0, lines: json ? [JSON.stringify({ runId, files: [], manifest: plan.manifest })] : lines };
|
|
746
|
+
}
|
|
747
|
+
for (const text of plan.texts) {
|
|
748
|
+
deps.writeFile(path.join(dir, text.file), text.body);
|
|
749
|
+
}
|
|
750
|
+
const failures = await runPool(plan.downloads.map((download) => () => downloadWithRetry(download, dir, deps)), PULL_CONCURRENCY);
|
|
751
|
+
for (const failure of failures) {
|
|
752
|
+
if (failure)
|
|
753
|
+
markPullFailure(plan.manifest, failure);
|
|
754
|
+
}
|
|
755
|
+
deps.writeFile(path.join(dir, "manifest.json"), `${JSON.stringify(plan.manifest, null, 2)}\n`);
|
|
756
|
+
const failed = plan.manifest.failed;
|
|
757
|
+
const wrote = plan.downloads.length + plan.texts.length - failed.length + 1;
|
|
758
|
+
if (json) {
|
|
759
|
+
return {
|
|
760
|
+
code: failed.length > 0 ? 1 : 0,
|
|
761
|
+
lines: [JSON.stringify({ runId, dir, wrote, manifest: plan.manifest })],
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
const lines = [
|
|
765
|
+
`Pulled ${wrote} files into ${dir}`,
|
|
766
|
+
`Every piece is indexed in ${path.join(dir, "manifest.json")} — scene numbers there are the run's own.`,
|
|
767
|
+
];
|
|
768
|
+
const flagged = plan.manifest.scenes.filter((s) => s.flagged);
|
|
769
|
+
if (flagged.length > 0) {
|
|
770
|
+
lines.push("", `${flagged.length} clip${flagged.length === 1 ? "" : "s"} came back flagged — usable, but look before you cut:`);
|
|
771
|
+
for (const scene of flagged) {
|
|
772
|
+
lines.push(` scene ${scene.sceneIndex}: ${scene.findings.map((f) => f.code).join(", ") || "flagged"}`);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
if (failed.length > 0) {
|
|
776
|
+
lines.push("", `${failed.length} file${failed.length === 1 ? "" : "s"} did not download:`);
|
|
777
|
+
for (const failure of failed)
|
|
778
|
+
lines.push(` ${failure.file}: ${failure.error}`);
|
|
779
|
+
lines.push("Run the same command again to retry just those.");
|
|
780
|
+
}
|
|
781
|
+
lines.push("", `When your cut is ready: exodus video upload ${runId} --file cut.mp4`);
|
|
782
|
+
return { code: failed.length > 0 ? 1 : 0, lines };
|
|
783
|
+
}
|
|
784
|
+
const BOX_HEADER = 8;
|
|
785
|
+
const BOX_HEADER_64 = 16;
|
|
786
|
+
const FULL_BOX_PREAMBLE = 4;
|
|
787
|
+
const MVHD_FIELDS = {
|
|
788
|
+
0: { timescale: 8, duration: 12, end: 16 },
|
|
789
|
+
1: { timescale: 16, duration: 20, end: 28 },
|
|
790
|
+
};
|
|
791
|
+
const MVHD_UNKNOWN_V0 = 0xffffffff;
|
|
792
|
+
const MVHD_UNKNOWN_V1 = 0xffffffffffffffffn;
|
|
793
|
+
const MAX_BOX_DEPTH = 4;
|
|
794
|
+
export function parseMvhdDurationSec(bytes) {
|
|
795
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
796
|
+
const walk = (start, end, depth) => {
|
|
797
|
+
if (depth > MAX_BOX_DEPTH)
|
|
798
|
+
return null;
|
|
799
|
+
let p = start;
|
|
800
|
+
while (p + BOX_HEADER <= end) {
|
|
801
|
+
let size = view.getUint32(p);
|
|
802
|
+
let header = BOX_HEADER;
|
|
803
|
+
const type = String.fromCharCode(bytes[p + 4], bytes[p + 5], bytes[p + 6], bytes[p + 7]);
|
|
804
|
+
if (size === 1) {
|
|
805
|
+
if (p + BOX_HEADER_64 > end)
|
|
806
|
+
return null;
|
|
807
|
+
size = Number(view.getBigUint64(p + BOX_HEADER));
|
|
808
|
+
header = BOX_HEADER_64;
|
|
809
|
+
}
|
|
810
|
+
else if (size === 0) {
|
|
811
|
+
size = end - p;
|
|
812
|
+
}
|
|
813
|
+
if (size < header || p + size > end)
|
|
814
|
+
return null;
|
|
815
|
+
if (type === "mvhd") {
|
|
816
|
+
const version = bytes[p + header];
|
|
817
|
+
if (version !== 0 && version !== 1)
|
|
818
|
+
return null;
|
|
819
|
+
const fields = MVHD_FIELDS[version];
|
|
820
|
+
const base = p + header + FULL_BOX_PREAMBLE;
|
|
821
|
+
if (base + fields.end > p + size)
|
|
822
|
+
return null;
|
|
823
|
+
const timescale = view.getUint32(base + fields.timescale);
|
|
824
|
+
if (timescale === 0)
|
|
825
|
+
return null;
|
|
826
|
+
if (version === 1) {
|
|
827
|
+
const raw = view.getBigUint64(base + fields.duration);
|
|
828
|
+
if (raw === 0n || raw === MVHD_UNKNOWN_V1)
|
|
829
|
+
return null;
|
|
830
|
+
return Number(raw) / timescale;
|
|
831
|
+
}
|
|
832
|
+
const raw = view.getUint32(base + fields.duration);
|
|
833
|
+
if (raw === 0 || raw === MVHD_UNKNOWN_V0)
|
|
834
|
+
return null;
|
|
835
|
+
return raw / timescale;
|
|
836
|
+
}
|
|
837
|
+
if (type === "moov") {
|
|
838
|
+
const found = walk(p + header, p + size, depth + 1);
|
|
839
|
+
if (found !== null)
|
|
840
|
+
return found;
|
|
841
|
+
}
|
|
842
|
+
p += size;
|
|
843
|
+
}
|
|
844
|
+
return null;
|
|
845
|
+
};
|
|
846
|
+
return walk(0, bytes.byteLength, 0);
|
|
847
|
+
}
|
|
848
|
+
export const NO_DURATION_MESSAGE = "Can't tell how long this cut is.\n" +
|
|
849
|
+
"Install ffmpeg (which brings ffprobe), or pass the length yourself: --duration <seconds>";
|
|
850
|
+
const MB = 1024 * 1024;
|
|
851
|
+
export async function uploadFlow(runId, filePath, durationFlag, json, deps) {
|
|
852
|
+
const stat = deps.statFile(filePath);
|
|
853
|
+
if (!stat)
|
|
854
|
+
return { code: 1, lines: [`No such file: ${filePath}`] };
|
|
855
|
+
const policy = ASSET_UPLOAD_POLICY.video;
|
|
856
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
857
|
+
const mime = policy.mimeByExtension[ext];
|
|
858
|
+
if (!mime) {
|
|
859
|
+
return {
|
|
860
|
+
code: 1,
|
|
861
|
+
lines: [`"${path.basename(filePath)}" isn't a video file the dashboard accepts. It takes ${policy.accepts}.`],
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
if (stat.size > policy.maxBytes) {
|
|
865
|
+
return {
|
|
866
|
+
code: 1,
|
|
867
|
+
lines: [
|
|
868
|
+
`"${path.basename(filePath)}" is ${(stat.size / MB).toFixed(1)}MB — the limit is ${Math.round(policy.maxBytes / MB)}MB.`,
|
|
869
|
+
"Export it smaller and try again.",
|
|
870
|
+
],
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
let asked = null;
|
|
874
|
+
if (durationFlag !== undefined) {
|
|
875
|
+
asked = Number.parseFloat(durationFlag);
|
|
876
|
+
if (!Number.isFinite(asked) || asked <= 0) {
|
|
877
|
+
return { code: 1, lines: [`--duration must be a number of seconds, not "${durationFlag}".`] };
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
let bytes;
|
|
881
|
+
try {
|
|
882
|
+
bytes = deps.readFileBytes(filePath);
|
|
883
|
+
}
|
|
884
|
+
catch (e) {
|
|
885
|
+
return {
|
|
886
|
+
code: 1,
|
|
887
|
+
lines: [`Can't read "${filePath}": ${e instanceof Error ? e.message : String(e)}`],
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
const durationSec = asked ?? deps.probeDurationSec(filePath) ?? parseMvhdDurationSec(bytes);
|
|
891
|
+
if (durationSec === null)
|
|
892
|
+
return { code: 1, lines: [NO_DURATION_MESSAGE] };
|
|
893
|
+
const mint = await deps.post(ASSET_UPLOAD_URL_PATH, {});
|
|
894
|
+
if (!mint.ok)
|
|
895
|
+
return errorResult(mint, json);
|
|
896
|
+
const minted = mint.data;
|
|
897
|
+
if (!minted.uploadUrl || !minted.receiptId) {
|
|
898
|
+
return { code: 1, lines: ["The server did not hand back a place to upload to."] };
|
|
899
|
+
}
|
|
900
|
+
const put = await deps.uploadBytes(minted.uploadUrl, mime, bytes);
|
|
901
|
+
if (!put.ok || !put.storageId) {
|
|
902
|
+
const detail = put.body ? `: ${put.body.slice(0, 200)}` : "";
|
|
903
|
+
return { code: 1, lines: [`Upload failed (HTTP ${put.status})${detail}`] };
|
|
904
|
+
}
|
|
905
|
+
const registered = await deps.post(ASSETS_PATH, {
|
|
906
|
+
storageId: put.storageId,
|
|
907
|
+
receiptId: minted.receiptId,
|
|
908
|
+
filename: path.basename(filePath),
|
|
909
|
+
});
|
|
910
|
+
if (!registered.ok)
|
|
911
|
+
return errorResult(registered, json);
|
|
912
|
+
const asset = registered.data;
|
|
913
|
+
if (!asset.assetId) {
|
|
914
|
+
return { code: 1, lines: ["The server stored the file but did not say what to call it."] };
|
|
915
|
+
}
|
|
916
|
+
const attached = await deps.post(FINAL_PATH, {
|
|
917
|
+
runId,
|
|
918
|
+
assetId: asset.assetId,
|
|
919
|
+
durationSec,
|
|
920
|
+
});
|
|
921
|
+
if (!attached.ok)
|
|
922
|
+
return errorResult(attached, json);
|
|
923
|
+
const final = attached.data;
|
|
924
|
+
const finalWatchUrl = final.finalWatchUrl ?? `${deps.dashboardUrl}/video?ad=${runId}`;
|
|
925
|
+
if (json) {
|
|
926
|
+
return {
|
|
927
|
+
code: 0,
|
|
928
|
+
lines: [JSON.stringify({ ok: true, runId, assetId: asset.assetId, durationSec, finalWatchUrl })],
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
return {
|
|
932
|
+
code: 0,
|
|
933
|
+
lines: [
|
|
934
|
+
`Uploaded your cut (${durationSec.toFixed(1)}s).`,
|
|
935
|
+
`Watch it here: ${finalWatchUrl}`,
|
|
936
|
+
`Approve it there, or run: exodus video approve ${runId}`,
|
|
937
|
+
],
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
const VALUE_FLAGS = new Set([
|
|
941
|
+
"show",
|
|
942
|
+
"script",
|
|
943
|
+
"voice-path",
|
|
944
|
+
"note",
|
|
945
|
+
"out",
|
|
946
|
+
"file",
|
|
947
|
+
"duration",
|
|
948
|
+
]);
|
|
949
|
+
export function parsePositional(args = process.argv.slice(3)) {
|
|
950
|
+
const out = [];
|
|
951
|
+
let i = 0;
|
|
952
|
+
while (i < args.length) {
|
|
953
|
+
const arg = args[i];
|
|
954
|
+
if (arg.startsWith("--")) {
|
|
955
|
+
const key = arg.slice(2).split("=", 1)[0] ?? "";
|
|
956
|
+
if (!arg.includes("=") && VALUE_FLAGS.has(key))
|
|
957
|
+
i += 2;
|
|
958
|
+
else
|
|
959
|
+
i++;
|
|
960
|
+
continue;
|
|
961
|
+
}
|
|
962
|
+
out.push(arg);
|
|
963
|
+
i++;
|
|
964
|
+
}
|
|
965
|
+
return out;
|
|
966
|
+
}
|
|
967
|
+
function flagString(flags, name) {
|
|
968
|
+
const value = flags[name];
|
|
969
|
+
return typeof value === "string" ? value : undefined;
|
|
970
|
+
}
|
|
971
|
+
async function printResult(result) {
|
|
972
|
+
for (const line of result.lines)
|
|
973
|
+
console.log(line);
|
|
974
|
+
if (result.code !== 0)
|
|
975
|
+
process.exit(result.code);
|
|
976
|
+
}
|
|
977
|
+
function usage(line) {
|
|
978
|
+
console.error(`Error: ${line}`);
|
|
979
|
+
process.exit(1);
|
|
980
|
+
}
|
|
981
|
+
export async function run(flags) {
|
|
982
|
+
const [sub, ...rest] = parsePositional();
|
|
983
|
+
const json = flags["json"] === true;
|
|
984
|
+
if (!sub) {
|
|
985
|
+
console.log(helpText);
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
if (sub === "shows")
|
|
989
|
+
return printResult(await showsFlow(json, defaultDeps));
|
|
990
|
+
if (sub === "start") {
|
|
991
|
+
const showId = flagString(flags, "show");
|
|
992
|
+
const scriptFile = flagString(flags, "script");
|
|
993
|
+
if (!showId)
|
|
994
|
+
usage("video start needs --show <id>. List them with: exodus video shows");
|
|
995
|
+
if (!scriptFile)
|
|
996
|
+
usage("video start needs --script <file>, a text file of what the ad says.");
|
|
997
|
+
return printResult(await startFlow({
|
|
998
|
+
showId,
|
|
999
|
+
scriptFile,
|
|
1000
|
+
voicePath: flagString(flags, "voice-path"),
|
|
1001
|
+
music: flags["music"] === false ? false : undefined,
|
|
1002
|
+
wait: flags["wait"] === true,
|
|
1003
|
+
json,
|
|
1004
|
+
}, defaultDeps));
|
|
1005
|
+
}
|
|
1006
|
+
const runId = rest[0];
|
|
1007
|
+
const needsRunId = ["status", "storyboard", "approve", "flag", "pull", "upload"];
|
|
1008
|
+
if (needsRunId.includes(sub) && !runId) {
|
|
1009
|
+
usage(`video ${sub} needs a run id: exodus video ${sub} <runId>`);
|
|
1010
|
+
}
|
|
1011
|
+
if (sub === "status")
|
|
1012
|
+
return printResult(await statusFlow(runId, json, defaultDeps));
|
|
1013
|
+
if (sub === "storyboard")
|
|
1014
|
+
return printResult(await storyboardFlow(runId, json, defaultDeps));
|
|
1015
|
+
if (sub === "approve")
|
|
1016
|
+
return printResult(await approveFlow(runId, json, defaultDeps));
|
|
1017
|
+
if (sub === "flag") {
|
|
1018
|
+
const note = flagString(flags, "note");
|
|
1019
|
+
if (!note)
|
|
1020
|
+
usage('video flag needs --note "<what is wrong>".');
|
|
1021
|
+
return printResult(await flagFlow(runId, note, json, defaultDeps));
|
|
1022
|
+
}
|
|
1023
|
+
if (sub === "pull") {
|
|
1024
|
+
const dir = flagString(flags, "out");
|
|
1025
|
+
if (!dir)
|
|
1026
|
+
usage("video pull needs --out <dir>, the folder to write the pieces into.");
|
|
1027
|
+
return printResult(await pullFlow(runId, dir, json, defaultDeps));
|
|
1028
|
+
}
|
|
1029
|
+
if (sub === "upload") {
|
|
1030
|
+
const file = flagString(flags, "file");
|
|
1031
|
+
if (!file)
|
|
1032
|
+
usage("video upload needs --file <cut.mp4>.");
|
|
1033
|
+
return printResult(await uploadFlow(runId, file, flagString(flags, "duration"), json, defaultDeps));
|
|
1034
|
+
}
|
|
1035
|
+
console.error(`Unknown subcommand: "${sub}"\n`);
|
|
1036
|
+
console.log(helpText);
|
|
1037
|
+
process.exit(1);
|
|
1038
|
+
}
|