@aicopycoders/exodus 2026.10.2300-beta.1 → 2026.10.2400-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/skills/exodus-brand/SKILL.md +10 -0
- package/assets/skills/exodus-video/SKILL.md +11 -8
- package/assets/skills/exodus-video/scripts/first-cut.mjs +24 -13
- package/dist/commands/brand.d.ts +13 -0
- package/dist/commands/brand.js +202 -2
- package/dist/commands/video.js +15 -33
- package/dist/lib/client.d.ts +1 -0
- package/dist/lib/client.js +24 -28
- package/dist/lib/image-sniff.d.ts +9 -0
- package/dist/lib/image-sniff.js +12 -0
- package/dist/lib/preflight.d.ts +1 -0
- package/dist/lib/preflight.js +9 -0
- package/package.json +1 -1
package/assets/docs/CLAUDE.md
CHANGED
|
@@ -37,6 +37,7 @@ Any user can create and own unlimited brands — `npx @aicopycoders/exodus brand
|
|
|
37
37
|
- **List accessible brands:** `npx @aicopycoders/exodus brand list`
|
|
38
38
|
- **Create a new brand:** `npx @aicopycoders/exodus brand create "<name>"` — creates it server-side, switches you in, and sets up its local subfolder
|
|
39
39
|
- **Switch brand:** `npx @aicopycoders/exodus brand use <slug>` — or just work from inside that brand's subfolder
|
|
40
|
+
- **Business type (what it sells, what the ad promises):** `npx @aicopycoders/exodus brand show`; change with `npx @aicopycoders/exodus brand set --sells <product|service|app|expertise|finance|other> --promise <body|money|neither>`. The video picks its B-roll from these.
|
|
40
41
|
- **New brand created on the dashboard?** `npx @aicopycoders/exodus@latest init` creates its subfolder and pulls its profile
|
|
41
42
|
- **Update the CLI + skills:** `npx @aicopycoders/exodus@latest update` — the only command that refreshes `.claude/skills/` and the workspace docs (`doctor` reports health but never rewrites them)
|
|
42
43
|
- **Brand voice / ICP / offer:** the active brand's `state/brand-profile.md` (inside its subfolder; at this root on older single-brand installs) — read this at session start and after any switch. If it doesn't match the active brand, the exodus-strategist skill will prompt to refresh.
|
|
@@ -35,6 +35,7 @@ Don't surface a "list, switch, or inspect?" menu to the user — figure it out f
|
|
|
35
35
|
| "Create a new brand" / "Add a brand called X" / "Set up brand Y" | `create "<name>"` (no admin needed; switches you in) |
|
|
36
36
|
| "Switch to dlc" / "Change to brand X" | `use <slug>` |
|
|
37
37
|
| "Show me the foundation for brand X" / "What's brand Y configured with?" | `inspect <slug>` |
|
|
38
|
+
| "What kind of business is this brand?" / "Change the business type" / "It sells an app, not a product" | `show`, then `set --sells ... --promise ...` |
|
|
38
39
|
| "The new brand isn't showing up" | `list` first — if visible, suggest `use`; if not, the dashboard create didn't propagate |
|
|
39
40
|
|
|
40
41
|
## Common Commands
|
|
@@ -79,6 +80,15 @@ npx @aicopycoders/exodus brand inspect <slug>
|
|
|
79
80
|
|
|
80
81
|
Surfaces foundation completeness, primer status, connected ad account, and writing-mode defaults. Use this when the user asks "is brand X ready to run?" — don't guess.
|
|
81
82
|
|
|
83
|
+
### Business type (the two tags B-roll follows)
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
npx @aicopycoders/exodus brand show
|
|
87
|
+
npx @aicopycoders/exodus brand set --sells product --promise body
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Every brand has two tags: what it sells (`product`, `service`, `app`, `expertise`, `finance`, `other`) and what its ads promise (`body`, `money`, `neither`). The video uses them to pick its extra shots (B-roll). `show` prints both in plain words and who set them; when AI picked them with little to go on, it asks you to check. `set` takes either flag alone once both are set; a brand with no tags yet needs both. The same two dropdowns are on the dashboard's Brand info page.
|
|
91
|
+
|
|
82
92
|
### Clear the override
|
|
83
93
|
|
|
84
94
|
```bash
|
|
@@ -240,8 +240,8 @@ under it:
|
|
|
240
240
|
a gate parked before the stills are drawn has nothing to redraw. When the run
|
|
241
241
|
has B-roll (`storyboard` ends with a `change the B-roll shots:` line), offer
|
|
242
242
|
to add a shot on a line with a note, move one, or remove one with `video
|
|
243
|
-
broll`.
|
|
244
|
-
|
|
243
|
+
broll`. Each change redraws only the pictures it changes, so wait for it to
|
|
244
|
+
finish before you approve.
|
|
245
245
|
- final watch: every piece is made; needs `pull`, a cut, and `upload`. Once a
|
|
246
246
|
cut is uploaded the stop block leads with `approve` instead, and `status
|
|
247
247
|
--json` says which state it is in (`stop.cutAttached`, plus the same next-step
|
|
@@ -609,10 +609,10 @@ changes:
|
|
|
609
609
|
`--note`
|
|
610
610
|
- remove a shot: `exodus video broll remove <runId> --scene <n>`
|
|
611
611
|
|
|
612
|
-
An add or a move plans the shot on a worker
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
refuses another change. Run `video broll <runId>` again until it no longer says
|
|
612
|
+
An add or a move plans the shot on a worker, and then the storyboard pictures it
|
|
613
|
+
changes are redrawn. A remove saves at once, and the pictures it changes are
|
|
614
|
+
redrawn too. Every other picture comes back as it was. While a shot is being
|
|
615
|
+
planned, `video broll` says so and the server refuses another change. Run `video broll <runId>` again until it no longer says
|
|
616
616
|
it is planning, then read the storyboard again before you ask about approving.
|
|
617
617
|
If it says the last change didn't work, pass its sentence to the user as it
|
|
618
618
|
came. Do not approve while a shot is still being planned.
|
|
@@ -1237,9 +1237,12 @@ Read these out of `storyboard.json` before you build anything, in any tool:
|
|
|
1237
1237
|
running order. To find the moment, locate the A-roll scene whose `lineIds`
|
|
1238
1238
|
contain the cue line, then read the start time of the first word of that line
|
|
1239
1239
|
out of that scene's `words.json`. A B-roll cutaway (one with `broll`) starts
|
|
1240
|
-
|
|
1240
|
+
0.5 s before the first word of `broll.covers` instead (even if that reaches
|
|
1241
|
+
back into the scene before, but never into a reaction or before 0 s), and
|
|
1242
|
+
holds through its last word or for `broll.plannedSec`, whichever is longer,
|
|
1241
1243
|
0.8 s to 4 s; a shot planned longer than 4 s (a talking character) holds at
|
|
1242
|
-
least `broll.plannedSec`.
|
|
1244
|
+
least `broll.plannedSec`. The next cutaway, a reaction or the ad's end cuts
|
|
1245
|
+
it short.
|
|
1243
1246
|
- **Reactions.** A cutaway with `cutawayType: "reaction"` is the exception. Do
|
|
1244
1247
|
not lay it over the speech. Put it into the running order as its own beat
|
|
1245
1248
|
right after its cue line ends, play its own sound (the audience laughing),
|
|
@@ -112,9 +112,15 @@ export const JOIN_LEAD_NEW_SPEAKER_SEC = 0.25;
|
|
|
112
112
|
/** The gap a cut keeps from an extra heard sound it leaves out (#2541). */
|
|
113
113
|
const EXTRA_CLEARANCE_SEC = 0.05;
|
|
114
114
|
/**
|
|
115
|
-
* A B-roll shot
|
|
116
|
-
*
|
|
117
|
-
|
|
115
|
+
* A B-roll shot arrives this long before the first word it covers, the lead the
|
|
116
|
+
* top ads use and the planner teaches (#3936).
|
|
117
|
+
*/
|
|
118
|
+
export const BROLL_LEAD_SEC = 0.5;
|
|
119
|
+
/**
|
|
120
|
+
* A B-roll shot holds over the words it covers (#3912) or for its planned
|
|
121
|
+
* length, whichever runs longer (#3936), but never shorter than the eye needs
|
|
122
|
+
* to read it nor so long it drags; a shot planned past the longest hold (a
|
|
123
|
+
* talking character) keeps its planned length.
|
|
118
124
|
*/
|
|
119
125
|
export const BROLL_MIN_HOLD_SEC = 0.8;
|
|
120
126
|
export const BROLL_MAX_HOLD_SEC = 4;
|
|
@@ -821,12 +827,14 @@ function heardCovers(seg, texts, cueLineId, covers) {
|
|
|
821
827
|
}
|
|
822
828
|
|
|
823
829
|
/**
|
|
824
|
-
* #3912: a B-roll shot's place in its segment. It starts
|
|
825
|
-
* covers
|
|
826
|
-
*
|
|
827
|
-
*
|
|
828
|
-
* its planned length
|
|
829
|
-
*
|
|
830
|
+
* #3912: a B-roll shot's place in its segment. It starts BROLL_LEAD_SEC before
|
|
831
|
+
* the first word it covers, which can fall before the segment's own start
|
|
832
|
+
* (#3936), and holds through the last word or for its planned length,
|
|
833
|
+
* whichever is longer, between BROLL_MIN_HOLD_SEC and BROLL_MAX_HOLD_SEC, or
|
|
834
|
+
* for its planned length when that runs longer than the cap. Covered words the
|
|
835
|
+
* line does not say leave it at the line's start for its planned length.
|
|
836
|
+
* `texts` maps each line id to its script text. Null when the segment does not
|
|
837
|
+
* speak the cue line.
|
|
830
838
|
*/
|
|
831
839
|
export function brollWindow(seg, counts, cueLineId, texts, broll) {
|
|
832
840
|
const line = lineInSegment(seg, counts, cueLineId);
|
|
@@ -840,12 +848,13 @@ export function brollWindow(seg, counts, cueLineId, texts, broll) {
|
|
|
840
848
|
};
|
|
841
849
|
}
|
|
842
850
|
const heard = heardCovers(seg, texts, cueLineId, broll.covers);
|
|
843
|
-
const
|
|
851
|
+
const wordSec = heard?.startSec ?? wordTimeInSegment(seg, line.before + run.first, line.total, "s");
|
|
844
852
|
const endSec = heard?.endSec ?? wordTimeInSegment(seg, line.before + run.last, line.total, "e");
|
|
853
|
+
const startSec = wordSec - BROLL_LEAD_SEC;
|
|
845
854
|
const span = Math.max(0, endSec - startSec);
|
|
846
855
|
const holdSec = broll.plannedSec > BROLL_MAX_HOLD_SEC
|
|
847
856
|
? Math.max(span, broll.plannedSec)
|
|
848
|
-
: Math.min(Math.max(span, BROLL_MIN_HOLD_SEC), BROLL_MAX_HOLD_SEC);
|
|
857
|
+
: Math.min(Math.max(span, broll.plannedSec, BROLL_MIN_HOLD_SEC), BROLL_MAX_HOLD_SEC);
|
|
849
858
|
return { startSec, holdSec, method, found: true };
|
|
850
859
|
}
|
|
851
860
|
|
|
@@ -1221,6 +1230,7 @@ export function buildTimeline({
|
|
|
1221
1230
|
});
|
|
1222
1231
|
|
|
1223
1232
|
const lineTexts = new Map([...scriptLines].map(([id, line]) => [id, line?.text]));
|
|
1233
|
+
const beats = spine.filter((seg) => seg.reaction);
|
|
1224
1234
|
for (const c of waiting.filter((w) => !w.reaction)) {
|
|
1225
1235
|
const cue = c.cueLineId ? cueOffsetOnSpine(spine, counts, c.cueLineId) : null;
|
|
1226
1236
|
if (!cue) {
|
|
@@ -1233,7 +1243,9 @@ export function buildTimeline({
|
|
|
1233
1243
|
? brollWindow(seg, counts, c.cueLineId, lineTexts, c.broll)
|
|
1234
1244
|
: null;
|
|
1235
1245
|
if (win) {
|
|
1236
|
-
|
|
1246
|
+
// A shot's lead may reach back into the segment before, never into a reaction beat or before the ad.
|
|
1247
|
+
const floor = Math.max(0, ...beats.filter((b) => b.startSec < seg.startSec).map((b) => b.startSec + b.seconds));
|
|
1248
|
+
placed.startSec = Math.max(floor, seg.startSec + win.startSec);
|
|
1237
1249
|
placed.method = win.method;
|
|
1238
1250
|
placed.seconds = c.source === "clip" ? Math.min(win.holdSec, c.seconds) : win.holdSec;
|
|
1239
1251
|
placed.coversFound = win.found;
|
|
@@ -1241,7 +1253,6 @@ export function buildTimeline({
|
|
|
1241
1253
|
cutaways.push(placed);
|
|
1242
1254
|
}
|
|
1243
1255
|
cutaways.sort((a, b) => a.startSec - b.startSec);
|
|
1244
|
-
const beats = spine.filter((seg) => seg.reaction);
|
|
1245
1256
|
for (let i = 0; i < cutaways.length; i++) {
|
|
1246
1257
|
const c = cutaways[i];
|
|
1247
1258
|
const stops = [];
|
package/dist/commands/brand.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
declare const BROLL_KIT_KINDS: readonly ["screen", "result", "testimonial", "crew", "product", "legal"];
|
|
2
|
+
type BrollKitKind = (typeof BROLL_KIT_KINDS)[number];
|
|
1
3
|
export declare const helpText: string;
|
|
2
4
|
interface Brand {
|
|
3
5
|
id: string;
|
|
@@ -20,5 +22,16 @@ export declare function refreshBrandProfileMd(opts?: {
|
|
|
20
22
|
pathRel: string;
|
|
21
23
|
reason?: string;
|
|
22
24
|
}>;
|
|
25
|
+
export type BrollAddArgs = {
|
|
26
|
+
ok: true;
|
|
27
|
+
file: string;
|
|
28
|
+
kind: BrollKitKind;
|
|
29
|
+
what: string;
|
|
30
|
+
permission: boolean;
|
|
31
|
+
} | {
|
|
32
|
+
ok: false;
|
|
33
|
+
error: string;
|
|
34
|
+
};
|
|
35
|
+
export declare function parseBrollAdd(files: string[], flags: Record<string, string | boolean>): BrollAddArgs;
|
|
23
36
|
export declare function run(flags: Record<string, string | boolean>): Promise<void>;
|
|
24
37
|
export {};
|
package/dist/commands/brand.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { apiGetDashboard, apiPostDashboard } from "../lib/client.js";
|
|
4
|
-
import { sniffImageType, BRAND_IMAGE_MAX_BYTES } from "../lib/image-sniff.js";
|
|
3
|
+
import { apiDeleteDashboard, apiGetDashboard, apiPostDashboard } from "../lib/client.js";
|
|
4
|
+
import { sniffImageType, sniffBrollType, BRAND_IMAGE_MAX_BYTES, BROLL_KIT_MAX_BYTES, } from "../lib/image-sniff.js";
|
|
5
|
+
import { probeDurationSec } from "../lib/preflight.js";
|
|
5
6
|
import { getActiveBrand, setActiveBrand, clearActiveBrand, findWorkspaceRoot, } from "../lib/state.js";
|
|
6
7
|
import { detectLayout, ensureBrandDir, resolveActiveBrand, brandStateDir, brandDirFor, } from "../lib/layout.js";
|
|
7
8
|
import { promptYesNo } from "../lib/prompts.js";
|
|
8
9
|
import { pkgRef } from "../lib/channel.js";
|
|
9
10
|
const AUTO_END_MARKER = "<!-- exodus:auto-section-end — manual brand notes below survive `brand use` refreshes -->";
|
|
11
|
+
const BROLL_KIT_KINDS = ["screen", "result", "testimonial", "crew", "product", "legal"];
|
|
12
|
+
const BROLL_KIT_NEEDS_PERMISSION = ["result", "testimonial"];
|
|
13
|
+
const BROLL_KINDS_HELP = "screen (app or site screen recording), result (client result), testimonial,\n" +
|
|
14
|
+
" crew (crew or job-site footage), product (in hand or in use), legal";
|
|
10
15
|
export const helpText = `
|
|
11
16
|
exodus brand — list, switch, and inspect brands available to your CLI
|
|
12
17
|
|
|
@@ -20,6 +25,19 @@ Subcommands:
|
|
|
20
25
|
exodus brand images add <files...> --kind founder|product|reference
|
|
21
26
|
Upload brand-identity images (png/jpg/webp, max 15MB each)
|
|
22
27
|
exodus brand images list Show the active brand's uploaded identity images
|
|
28
|
+
exodus brand broll add <file> --kind <kind> --what "<what it shows>" [--permission]
|
|
29
|
+
Add a clip or picture to the brand's B-roll kit
|
|
30
|
+
(mp4/mov/webm up to 200MB, png/jpg/webp up to 15MB).
|
|
31
|
+
Kinds: ${BROLL_KINDS_HELP}
|
|
32
|
+
--permission (put it after the file) says you may show
|
|
33
|
+
the person; required for result and testimonial
|
|
34
|
+
exodus brand broll list Show the B-roll kit (your logo and product photos count too)
|
|
35
|
+
exodus brand broll remove <id>
|
|
36
|
+
Remove one kit item (ids come from broll list)
|
|
37
|
+
exodus brand show Show the active brand's two business-type tags
|
|
38
|
+
exodus brand set --sells <product|service|app|expertise|finance|other>
|
|
39
|
+
--promise <body|money|neither>
|
|
40
|
+
Change those tags (either flag alone is fine)
|
|
23
41
|
|
|
24
42
|
Any user can create and own unlimited brands (\`brand create\`) — you do NOT
|
|
25
43
|
need to be an admin, and brands are scoped to you (two users can have the
|
|
@@ -256,6 +274,73 @@ function runCurrent() {
|
|
|
256
274
|
console.log("(none — falling back to the key's default brand)");
|
|
257
275
|
}
|
|
258
276
|
}
|
|
277
|
+
const WHAT_YOU_SELL_LABELS = {
|
|
278
|
+
product: "Products we ship",
|
|
279
|
+
service: "A service our people do in person",
|
|
280
|
+
app: "An app or software",
|
|
281
|
+
expertise: "Coaching, courses or expert help",
|
|
282
|
+
finance: "Insurance, loans or investing",
|
|
283
|
+
other: "Something else",
|
|
284
|
+
};
|
|
285
|
+
const AD_PROMISE_LABELS = {
|
|
286
|
+
body: "A better body, health or looks",
|
|
287
|
+
money: "Money: income, a payout, debt relief or investment returns",
|
|
288
|
+
neither: "Neither",
|
|
289
|
+
};
|
|
290
|
+
const SET_HINT = "Change them with: exodus brand set --sells <product|service|app|expertise|finance|other> --promise <body|money|neither>";
|
|
291
|
+
function printTags(t) {
|
|
292
|
+
console.log(`Brand: ${t.name}`);
|
|
293
|
+
if (!t.whatYouSell || !t.adPromise) {
|
|
294
|
+
console.log("Business type: not set yet. The video will guess from your script.");
|
|
295
|
+
console.log(SET_HINT);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
console.log(`What you sell: ${WHAT_YOU_SELL_LABELS[t.whatYouSell]} (${t.whatYouSell})`);
|
|
299
|
+
console.log(`What the ad promises: ${AD_PROMISE_LABELS[t.adPromise]} (${t.adPromise})`);
|
|
300
|
+
if (t.setBy === "member") {
|
|
301
|
+
console.log("Set by: a person on your team");
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
console.log("Set by: AI, from your brand info");
|
|
305
|
+
if (t.unsure) {
|
|
306
|
+
console.log("Please check these. The AI had little to go on.");
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
console.log(SET_HINT);
|
|
310
|
+
}
|
|
311
|
+
function failResponse(status, data) {
|
|
312
|
+
const body = data;
|
|
313
|
+
fail(body?.hint ?? body?.detail ?? body?.error ?? `HTTP ${status}`);
|
|
314
|
+
}
|
|
315
|
+
async function runShow() {
|
|
316
|
+
const res = await apiGetDashboard("/api/brands/tags");
|
|
317
|
+
if (!res.ok)
|
|
318
|
+
failResponse(res.status, res.data);
|
|
319
|
+
printTags(res.data);
|
|
320
|
+
}
|
|
321
|
+
async function runSet(flags) {
|
|
322
|
+
const body = {};
|
|
323
|
+
if (flags.sells !== undefined) {
|
|
324
|
+
if (typeof flags.sells !== "string" || !Object.hasOwn(WHAT_YOU_SELL_LABELS, flags.sells)) {
|
|
325
|
+
fail(`--sells must be one of: ${Object.keys(WHAT_YOU_SELL_LABELS).join(", ")}`);
|
|
326
|
+
}
|
|
327
|
+
body.whatYouSell = flags.sells;
|
|
328
|
+
}
|
|
329
|
+
if (flags.promise !== undefined) {
|
|
330
|
+
if (typeof flags.promise !== "string" || !Object.hasOwn(AD_PROMISE_LABELS, flags.promise)) {
|
|
331
|
+
fail(`--promise must be one of: ${Object.keys(AD_PROMISE_LABELS).join(", ")}`);
|
|
332
|
+
}
|
|
333
|
+
body.adPromise = flags.promise;
|
|
334
|
+
}
|
|
335
|
+
if (!body.whatYouSell && !body.adPromise) {
|
|
336
|
+
fail(`nothing to change. ${SET_HINT}`);
|
|
337
|
+
}
|
|
338
|
+
const res = await apiPostDashboard("/api/brands/tags", body);
|
|
339
|
+
if (!res.ok)
|
|
340
|
+
failResponse(res.status, res.data);
|
|
341
|
+
console.log("✓ Saved.");
|
|
342
|
+
printTags(res.data);
|
|
343
|
+
}
|
|
259
344
|
function runClear() {
|
|
260
345
|
clearActiveBrand();
|
|
261
346
|
console.log("✓ Cleared local active brand. Falling back to the key's default.");
|
|
@@ -375,6 +460,112 @@ async function runImagesList() {
|
|
|
375
460
|
}
|
|
376
461
|
}
|
|
377
462
|
}
|
|
463
|
+
export function parseBrollAdd(files, flags) {
|
|
464
|
+
if (typeof flags["permission"] === "string") {
|
|
465
|
+
return { ok: false, error: "--permission takes no value; put it after the file" };
|
|
466
|
+
}
|
|
467
|
+
if (files.length !== 1) {
|
|
468
|
+
return { ok: false, error: "brand broll add takes exactly one file" };
|
|
469
|
+
}
|
|
470
|
+
const kind = flags["kind"];
|
|
471
|
+
if (typeof kind !== "string" || !BROLL_KIT_KINDS.includes(kind)) {
|
|
472
|
+
return {
|
|
473
|
+
ok: false,
|
|
474
|
+
error: `brand broll add requires --kind ${BROLL_KIT_KINDS.join("|")}` +
|
|
475
|
+
(kind === "logo" ? " (the logo is uploaded on the dashboard and is already in the kit)" : ""),
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
const what = typeof flags["what"] === "string" ? flags["what"].trim() : "";
|
|
479
|
+
if (!what) {
|
|
480
|
+
return { ok: false, error: 'brand broll add requires --what "<what it shows>"' };
|
|
481
|
+
}
|
|
482
|
+
const permission = flags["permission"] === true;
|
|
483
|
+
if (BROLL_KIT_NEEDS_PERMISSION.includes(kind) && !permission) {
|
|
484
|
+
return {
|
|
485
|
+
ok: false,
|
|
486
|
+
error: `--kind ${kind} needs --permission: you must have permission to show this person`,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
return { ok: true, file: files[0], kind: kind, what, permission };
|
|
490
|
+
}
|
|
491
|
+
async function runBrollAdd(files, flags) {
|
|
492
|
+
const parsed = parseBrollAdd(files, flags);
|
|
493
|
+
if (!parsed.ok)
|
|
494
|
+
fail(`${parsed.error}`);
|
|
495
|
+
const { file, kind, what, permission } = parsed;
|
|
496
|
+
let size;
|
|
497
|
+
try {
|
|
498
|
+
size = fs.statSync(file).size;
|
|
499
|
+
}
|
|
500
|
+
catch {
|
|
501
|
+
fail(`can't read ${file} (does the path exist?)`);
|
|
502
|
+
}
|
|
503
|
+
if (size > BROLL_KIT_MAX_BYTES.video) {
|
|
504
|
+
fail(`${path.basename(file)} is too large (${(size / 1024 / 1024).toFixed(1)}MB > 200MB)`);
|
|
505
|
+
}
|
|
506
|
+
const buf = fs.readFileSync(file);
|
|
507
|
+
const sniffed = sniffBrollType(buf);
|
|
508
|
+
if (!sniffed)
|
|
509
|
+
fail(`${path.basename(file)} is not an mp4/mov/webm video or png/jpg/webp picture`);
|
|
510
|
+
if (buf.length > BROLL_KIT_MAX_BYTES[sniffed.media]) {
|
|
511
|
+
fail(`${path.basename(file)} is too large (a ${sniffed.media} can be up to ${BROLL_KIT_MAX_BYTES[sniffed.media] / 1024 / 1024}MB)`);
|
|
512
|
+
}
|
|
513
|
+
const durationSec = sniffed.media === "video" ? probeDurationSec(file) : null;
|
|
514
|
+
const mint = await apiPostDashboard("/api/brands/broll/upload-url", {});
|
|
515
|
+
if (!mint.ok || !mint.data.url || !mint.data.receiptId)
|
|
516
|
+
fail(`${mint.data.error ?? `HTTP ${mint.status} getting an upload URL`}`);
|
|
517
|
+
const upload = await fetch(mint.data.url, {
|
|
518
|
+
method: "POST",
|
|
519
|
+
headers: { "Content-Type": sniffed.mime },
|
|
520
|
+
body: new Uint8Array(buf),
|
|
521
|
+
});
|
|
522
|
+
const uploaded = (await upload.json().catch(() => ({})));
|
|
523
|
+
if (!upload.ok || !uploaded.storageId)
|
|
524
|
+
fail(`storage upload failed (HTTP ${upload.status})`);
|
|
525
|
+
const add = await apiPostDashboard("/api/brands/broll", {
|
|
526
|
+
storageId: uploaded.storageId,
|
|
527
|
+
receiptId: mint.data.receiptId,
|
|
528
|
+
kind,
|
|
529
|
+
description: what,
|
|
530
|
+
...(permission ? { permission: true } : {}),
|
|
531
|
+
...(durationSec !== null ? { durationSec } : {}),
|
|
532
|
+
});
|
|
533
|
+
if (!add.ok)
|
|
534
|
+
fail(`${add.data.error ?? `HTTP ${add.status} adding to the kit`}`);
|
|
535
|
+
const length = durationSec !== null ? `, ${durationSec.toFixed(1)}s` : "";
|
|
536
|
+
console.log(`✓ added ${path.basename(file)} as ${kind} (${sniffed.media}${length}) — id ${add.data.id}`);
|
|
537
|
+
}
|
|
538
|
+
async function runBrollList() {
|
|
539
|
+
const res = await apiGetDashboard("/api/brands/broll");
|
|
540
|
+
if (!res.ok)
|
|
541
|
+
fail(`${res.data.error ?? `HTTP ${res.status}`}`);
|
|
542
|
+
const { brand, items, logo, productPhotos } = res.data;
|
|
543
|
+
console.log(`brand: ${brand}`);
|
|
544
|
+
console.log(`B-roll kit: ${items.length} item${items.length === 1 ? "" : "s"}`);
|
|
545
|
+
for (const item of items) {
|
|
546
|
+
const length = item.durationSec !== undefined ? ` ${item.durationSec.toFixed(1)}s` : "";
|
|
547
|
+
console.log(` ${item.id} ${item.kind} (${item.media}${length}) ${item.description}`);
|
|
548
|
+
}
|
|
549
|
+
console.log(`also in the kit: ${logo ? "logo, " : ""}${productPhotos} product photo${productPhotos === 1 ? "" : "s"}`);
|
|
550
|
+
}
|
|
551
|
+
async function runBrollRemove(id) {
|
|
552
|
+
if (!id)
|
|
553
|
+
fail("brand broll remove requires an item id (see brand broll list)");
|
|
554
|
+
const res = await apiDeleteDashboard(`/api/brands/broll?id=${encodeURIComponent(id)}`);
|
|
555
|
+
if (!res.ok)
|
|
556
|
+
fail(`${res.data.error ?? `HTTP ${res.status}`}`);
|
|
557
|
+
console.log(`✓ removed ${id}`);
|
|
558
|
+
}
|
|
559
|
+
async function runBroll(args, flags) {
|
|
560
|
+
const sub = args[0];
|
|
561
|
+
if (sub === "add")
|
|
562
|
+
return runBrollAdd(args.slice(1), flags);
|
|
563
|
+
if (sub === "list" || sub === undefined)
|
|
564
|
+
return runBrollList();
|
|
565
|
+
if (sub === "remove")
|
|
566
|
+
return runBrollRemove(args[1]);
|
|
567
|
+
fail(`broll: unknown subcommand "${sub}" (use add | list | remove)`);
|
|
568
|
+
}
|
|
378
569
|
function imagesPositionals() {
|
|
379
570
|
const rest = process.argv.slice(3);
|
|
380
571
|
const out = [];
|
|
@@ -425,6 +616,9 @@ export async function run(flags) {
|
|
|
425
616
|
case "images":
|
|
426
617
|
await runImages(imagesPositionals().slice(1), flags);
|
|
427
618
|
return;
|
|
619
|
+
case "broll":
|
|
620
|
+
await runBroll(imagesPositionals().slice(1), flags);
|
|
621
|
+
return;
|
|
428
622
|
case "list":
|
|
429
623
|
case undefined:
|
|
430
624
|
await runList();
|
|
@@ -444,6 +638,12 @@ export async function run(flags) {
|
|
|
444
638
|
case "clear":
|
|
445
639
|
runClear();
|
|
446
640
|
return;
|
|
641
|
+
case "show":
|
|
642
|
+
await runShow();
|
|
643
|
+
return;
|
|
644
|
+
case "set":
|
|
645
|
+
await runSet(flags);
|
|
646
|
+
return;
|
|
447
647
|
default:
|
|
448
648
|
console.error(`exodus brand: unknown subcommand "${sub}"\n`);
|
|
449
649
|
console.log(helpText);
|
package/dist/commands/video.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { spawnSync } from "node:child_process";
|
|
4
3
|
import { apiGet, apiPost, getDashboardUrl } from "../lib/client.js";
|
|
5
4
|
import { displayRunStatus, formatApiError } from "../lib/format.js";
|
|
6
5
|
import { isWorkerStoppedPark } from "../lib/runStatus.js";
|
|
7
6
|
import { missingRouteLine } from "../lib/route-support.js";
|
|
8
|
-
import {
|
|
7
|
+
import { probeDurationSec } from "../lib/preflight.js";
|
|
9
8
|
import { ASSET_UPLOAD_POLICY, pauseAheadLine, } from "./workflow.js";
|
|
10
9
|
import { HELPER_LEDGER_BASE, SET_OPTION_LEDGER_BASE } from "../lib/helperLedgerBase.js";
|
|
11
10
|
import { customerAnswer, customerRunPage, customerStageIsWorking, customerStageLines, customerStatusLines, customerStoryboardLines, customerVoicesLines, } from "./video-customer.js";
|
|
@@ -77,8 +76,8 @@ The whole loop, in order:
|
|
|
77
76
|
exodus video broll <runId>
|
|
78
77
|
On a run started with --broll: the B-roll shots and the script lines.
|
|
79
78
|
Add a shot on a line, move one, or remove one while the storyboard
|
|
80
|
-
waits.
|
|
81
|
-
|
|
79
|
+
waits. Each change redraws only the pictures it changes, and every
|
|
80
|
+
other picture stays as it was. Wait for it before you approve.
|
|
82
81
|
|
|
83
82
|
7. exodus video status <runId>
|
|
84
83
|
Where the run is and how each scene's clip turned out. If the storyboard
|
|
@@ -336,15 +335,6 @@ export function asVideoRun(data) {
|
|
|
336
335
|
const run = (data ?? {});
|
|
337
336
|
return { ...run, nodes: Array.isArray(run.nodes) ? run.nodes : [] };
|
|
338
337
|
}
|
|
339
|
-
function defaultProbeDurationSec(filePath) {
|
|
340
|
-
if (!hasBinary("ffprobe"))
|
|
341
|
-
return null;
|
|
342
|
-
const res = spawnSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", filePath], { encoding: "utf-8" });
|
|
343
|
-
if (res.error || res.status !== 0)
|
|
344
|
-
return null;
|
|
345
|
-
const seconds = Number.parseFloat((res.stdout ?? "").trim());
|
|
346
|
-
return Number.isFinite(seconds) && seconds > 0 ? seconds : null;
|
|
347
|
-
}
|
|
348
338
|
export const defaultDeps = {
|
|
349
339
|
get: (p) => apiGet(p),
|
|
350
340
|
post: (p, body) => apiPost(p, body),
|
|
@@ -382,7 +372,7 @@ export const defaultDeps = {
|
|
|
382
372
|
const parsed = (await res.json().catch(() => ({})));
|
|
383
373
|
return { ok: true, status: res.status, storageId: parsed.storageId };
|
|
384
374
|
},
|
|
385
|
-
probeDurationSec
|
|
375
|
+
probeDurationSec,
|
|
386
376
|
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
387
377
|
now: () => Date.now(),
|
|
388
378
|
dashboardUrl: getDashboardUrl(),
|
|
@@ -2608,11 +2598,9 @@ function brollEditLines(edit, now) {
|
|
|
2608
2598
|
if (now - edit.requestedAt >= BROLL_EDIT_STALE_MS) {
|
|
2609
2599
|
return [`The last B-roll change (${asked}) never finished. You can ask for it again.`];
|
|
2610
2600
|
}
|
|
2611
|
-
return [
|
|
2612
|
-
`Planning now: ${asked}. It takes about a minute, then the storyboard pictures are redrawn.`,
|
|
2613
|
-
"Wait for it to finish before you change another shot or approve the storyboard.",
|
|
2614
|
-
];
|
|
2601
|
+
return [`Planning now: ${asked}. Then the storyboard pictures it changes are redrawn.`, BROLL_WAIT];
|
|
2615
2602
|
}
|
|
2603
|
+
const BROLL_WAIT = "Wait for it to finish before you change another shot or approve the storyboard.";
|
|
2616
2604
|
export async function brollFlow(runId, json, deps) {
|
|
2617
2605
|
const res = await deps.get(`${STORYBOARD_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
2618
2606
|
if (!res.ok)
|
|
@@ -2672,25 +2660,19 @@ export async function brollEditFlow(runId, edit, json, deps) {
|
|
|
2672
2660
|
const res = await deps.post(BROLL_PATH, { runId, ...edit });
|
|
2673
2661
|
if (!res.ok)
|
|
2674
2662
|
return errorResult(res, json);
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
`See them: exodus video storyboard ${runId}`,
|
|
2683
|
-
],
|
|
2684
|
-
};
|
|
2663
|
+
const said = edit.op === "remove"
|
|
2664
|
+
? `B-roll scene ${edit.sceneIndex} is gone. The storyboard pictures it changes are being redrawn.`
|
|
2665
|
+
: `${edit.op === "add"
|
|
2666
|
+
? `Planning a B-roll shot on line ${edit.lineId}.`
|
|
2667
|
+
: `Moving B-roll scene ${edit.sceneIndex} to line ${edit.lineId}.`} Then the storyboard pictures it changes are redrawn.`;
|
|
2668
|
+
if (json) {
|
|
2669
|
+
return { code: 0, lines: [JSON.stringify({ runId, ...res.data, message: `${said} ${BROLL_WAIT}` })] };
|
|
2685
2670
|
}
|
|
2686
2671
|
return {
|
|
2687
2672
|
code: 0,
|
|
2688
2673
|
lines: [
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
: `Moving B-roll scene ${edit.sceneIndex} to line ${edit.lineId}.`,
|
|
2692
|
-
"It takes about a minute. Then the storyboard pictures are redrawn.",
|
|
2693
|
-
"Wait for it to finish before you approve the storyboard.",
|
|
2674
|
+
said,
|
|
2675
|
+
BROLL_WAIT,
|
|
2694
2676
|
`Check on it: exodus video broll ${runId}`,
|
|
2695
2677
|
`See the pictures: exodus video storyboard ${runId}`,
|
|
2696
2678
|
],
|
package/dist/lib/client.d.ts
CHANGED
package/dist/lib/client.js
CHANGED
|
@@ -160,6 +160,20 @@ export function getDashboardUrl() {
|
|
|
160
160
|
apiUrl: getConfig().apiUrl,
|
|
161
161
|
});
|
|
162
162
|
}
|
|
163
|
+
async function readDashboardJson(res, path) {
|
|
164
|
+
const text = await res.text();
|
|
165
|
+
try {
|
|
166
|
+
return JSON.parse(text);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
const snippet = text.replace(/\s+/g, " ").trim().slice(0, 300);
|
|
170
|
+
return {
|
|
171
|
+
error: `Non-JSON ${res.status} from ${path}: ${snippet}`,
|
|
172
|
+
httpStatus: res.status,
|
|
173
|
+
contentType: res.headers.get("content-type") ?? undefined,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
}
|
|
163
177
|
export async function apiGetDashboard(path, opts) {
|
|
164
178
|
const { apiKey } = getConfig();
|
|
165
179
|
const url = `${getDashboardUrl()}${path}`;
|
|
@@ -182,20 +196,7 @@ export async function apiGetDashboard(path, opts) {
|
|
|
182
196
|
if (timer)
|
|
183
197
|
clearTimeout(timer);
|
|
184
198
|
}
|
|
185
|
-
|
|
186
|
-
let data;
|
|
187
|
-
try {
|
|
188
|
-
data = JSON.parse(text);
|
|
189
|
-
}
|
|
190
|
-
catch {
|
|
191
|
-
const snippet = text.replace(/\s+/g, " ").trim().slice(0, 300);
|
|
192
|
-
data = {
|
|
193
|
-
error: `Non-JSON ${res.status} from ${path}: ${snippet}`,
|
|
194
|
-
httpStatus: res.status,
|
|
195
|
-
contentType: res.headers.get("content-type") ?? undefined,
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
return { ok: res.ok, status: res.status, data: data };
|
|
199
|
+
return { ok: res.ok, status: res.status, data: await readDashboardJson(res, path) };
|
|
199
200
|
}
|
|
200
201
|
export async function apiPostDashboard(path, body, opts) {
|
|
201
202
|
const { apiKey } = getConfig();
|
|
@@ -220,18 +221,13 @@ export async function apiPostDashboard(path, body, opts) {
|
|
|
220
221
|
if (timer)
|
|
221
222
|
clearTimeout(timer);
|
|
222
223
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
httpStatus: res.status,
|
|
233
|
-
contentType: res.headers.get("content-type") ?? undefined,
|
|
234
|
-
};
|
|
235
|
-
}
|
|
236
|
-
return { ok: res.ok, status: res.status, data: data };
|
|
224
|
+
return { ok: res.ok, status: res.status, data: await readDashboardJson(res, path) };
|
|
225
|
+
}
|
|
226
|
+
export async function apiDeleteDashboard(path) {
|
|
227
|
+
const { apiKey } = getConfig();
|
|
228
|
+
const res = await fetch(`${getDashboardUrl()}${path}`, {
|
|
229
|
+
method: "DELETE",
|
|
230
|
+
headers: buildHeaders(apiKey),
|
|
231
|
+
});
|
|
232
|
+
return { ok: res.ok, status: res.status, data: await readDashboardJson(res, path) };
|
|
237
233
|
}
|
|
@@ -4,3 +4,12 @@ export interface SniffedImageType {
|
|
|
4
4
|
mime: "image/png" | "image/jpeg" | "image/webp";
|
|
5
5
|
}
|
|
6
6
|
export declare function sniffImageType(buf: Uint8Array): SniffedImageType | null;
|
|
7
|
+
export interface SniffedBrollType {
|
|
8
|
+
media: "video" | "image";
|
|
9
|
+
mime: "video/mp4" | "video/quicktime" | "video/webm" | SniffedImageType["mime"];
|
|
10
|
+
}
|
|
11
|
+
export declare const BROLL_KIT_MAX_BYTES: {
|
|
12
|
+
readonly video: number;
|
|
13
|
+
readonly image: number;
|
|
14
|
+
};
|
|
15
|
+
export declare function sniffBrollType(buf: Uint8Array): SniffedBrollType | null;
|
package/dist/lib/image-sniff.js
CHANGED
|
@@ -16,3 +16,15 @@ export function sniffImageType(buf) {
|
|
|
16
16
|
}
|
|
17
17
|
return null;
|
|
18
18
|
}
|
|
19
|
+
export const BROLL_KIT_MAX_BYTES = { video: 200 * 1024 * 1024, image: BRAND_IMAGE_MAX_BYTES };
|
|
20
|
+
export function sniffBrollType(buf) {
|
|
21
|
+
const ascii = (from, to) => String.fromCharCode(...buf.subarray(from, to));
|
|
22
|
+
if (buf.length >= 12 && ascii(4, 8) === "ftyp") {
|
|
23
|
+
return { media: "video", mime: ascii(8, 12) === "qt " ? "video/quicktime" : "video/mp4" };
|
|
24
|
+
}
|
|
25
|
+
if (buf.length >= 4 && buf[0] === 0x1a && buf[1] === 0x45 && buf[2] === 0xdf && buf[3] === 0xa3) {
|
|
26
|
+
return ascii(0, Math.min(buf.length, 64)).includes("webm") ? { media: "video", mime: "video/webm" } : null;
|
|
27
|
+
}
|
|
28
|
+
const image = sniffImageType(buf);
|
|
29
|
+
return image ? { media: "image", mime: image.mime } : null;
|
|
30
|
+
}
|
package/dist/lib/preflight.d.ts
CHANGED
package/dist/lib/preflight.js
CHANGED
|
@@ -9,6 +9,15 @@ export function hasBinary(name) {
|
|
|
9
9
|
return false;
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
|
+
export function probeDurationSec(filePath) {
|
|
13
|
+
if (!hasBinary("ffprobe"))
|
|
14
|
+
return null;
|
|
15
|
+
const res = spawnSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", filePath], { encoding: "utf-8" });
|
|
16
|
+
if (res.error || res.status !== 0)
|
|
17
|
+
return null;
|
|
18
|
+
const seconds = Number.parseFloat((res.stdout ?? "").trim());
|
|
19
|
+
return Number.isFinite(seconds) && seconds > 0 ? seconds : null;
|
|
20
|
+
}
|
|
12
21
|
export async function syncKeysToEnv() {
|
|
13
22
|
let remote;
|
|
14
23
|
try {
|