@aicopycoders/exodus 2026.10.2400-beta.1 → 2026.10.2400-beta.3
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 +6 -6
- package/dist/commands/brand.d.ts +13 -0
- package/dist/commands/brand.js +202 -2
- package/dist/commands/video.d.ts +6 -0
- package/dist/commands/video.js +58 -42
- 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.
|
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.d.ts
CHANGED
|
@@ -65,6 +65,11 @@ export interface ClipFinding {
|
|
|
65
65
|
export declare function qcWithoutJudgeWording(qc: ClipQc): ClipQc;
|
|
66
66
|
export declare const NO_TAKE_HISTORY_LINE = "no history recorded for this clip";
|
|
67
67
|
export declare function renderQcTakeHistory(takes: ClipQcTake[] | undefined): string[];
|
|
68
|
+
export interface ReplannedIdea {
|
|
69
|
+
framePrompt?: string;
|
|
70
|
+
videoPrompt?: string;
|
|
71
|
+
imageUrl: string;
|
|
72
|
+
}
|
|
68
73
|
export type ArtifactSubset = {
|
|
69
74
|
type: "storyboard";
|
|
70
75
|
storyboard?: unknown;
|
|
@@ -100,6 +105,7 @@ export type ArtifactSubset = {
|
|
|
100
105
|
voiceFirstBlockedBy?: string[];
|
|
101
106
|
voicePitchHz?: number;
|
|
102
107
|
voicePitchMethod?: string;
|
|
108
|
+
replannedIdea?: ReplannedIdea;
|
|
103
109
|
} | {
|
|
104
110
|
type: "audio";
|
|
105
111
|
sceneIndex?: number;
|
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(),
|
|
@@ -757,6 +747,7 @@ function clipFromArtifact(sceneIndex, artifact) {
|
|
|
757
747
|
voiceFirstBlockedBy: artifact.voiceFirstBlockedBy ?? null,
|
|
758
748
|
voicePitchHz: artifact.voicePitchHz ?? null,
|
|
759
749
|
voicePitchMethod: artifact.voicePitchMethod ?? null,
|
|
750
|
+
replannedIdea: artifact.replannedIdea ?? null,
|
|
760
751
|
};
|
|
761
752
|
}
|
|
762
753
|
function keyframeDownload(sceneIndex, imageUrl) {
|
|
@@ -913,23 +904,37 @@ function isNarrationMaster(artifact) {
|
|
|
913
904
|
artifact.sceneIndex === undefined &&
|
|
914
905
|
Array.isArray(artifact.narration?.cuts));
|
|
915
906
|
}
|
|
907
|
+
function withoutLookRider(prompt) {
|
|
908
|
+
return prompt.replace(/\n\nCUTAWAY STYLE: [\s\S]*$/, "");
|
|
909
|
+
}
|
|
910
|
+
function withReplannedPrompts(raw, replanned) {
|
|
911
|
+
let board;
|
|
912
|
+
try {
|
|
913
|
+
board = JSON.parse(raw);
|
|
914
|
+
}
|
|
915
|
+
catch {
|
|
916
|
+
return raw;
|
|
917
|
+
}
|
|
918
|
+
if (!Array.isArray(board.scenes))
|
|
919
|
+
return raw;
|
|
920
|
+
const scenes = board.scenes.map((scene) => {
|
|
921
|
+
const idea = typeof scene.sceneIndex === "number" ? replanned.get(scene.sceneIndex) : undefined;
|
|
922
|
+
return idea ? { ...scene, ...idea } : scene;
|
|
923
|
+
});
|
|
924
|
+
return JSON.stringify({ ...board, scenes }, null, 2);
|
|
925
|
+
}
|
|
916
926
|
export function planPull(run, items, opts) {
|
|
917
927
|
const texts = [];
|
|
918
928
|
const storyboardArtifact = run.nodes
|
|
919
929
|
.flatMap((n) => n.outputs ?? [])
|
|
920
930
|
.find((a) => a.type === "storyboard");
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
const raw = typeof storyboardArtifact.storyboardJson === "string"
|
|
931
|
+
const storyboardRaw = storyboardArtifact
|
|
932
|
+
? typeof storyboardArtifact.storyboardJson === "string"
|
|
924
933
|
? storyboardArtifact.storyboardJson
|
|
925
934
|
: storyboardArtifact.storyboard !== undefined
|
|
926
935
|
? JSON.stringify(storyboardArtifact.storyboard, null, 2)
|
|
927
|
-
: undefined
|
|
928
|
-
|
|
929
|
-
storyboard = "storyboard.json";
|
|
930
|
-
texts.push({ file: storyboard, body: raw.endsWith("\n") ? raw : `${raw}\n` });
|
|
931
|
-
}
|
|
932
|
-
}
|
|
936
|
+
: undefined
|
|
937
|
+
: undefined;
|
|
933
938
|
let reference = null;
|
|
934
939
|
let productSheet = null;
|
|
935
940
|
let setPlate = null;
|
|
@@ -1066,6 +1071,21 @@ export function planPull(run, items, opts) {
|
|
|
1066
1071
|
const pendingRedo = scenesBeingRedone(run, items);
|
|
1067
1072
|
for (const sceneIndex of pendingRedo)
|
|
1068
1073
|
clipByScene.delete(sceneIndex);
|
|
1074
|
+
const replanned = new Map();
|
|
1075
|
+
for (const [sceneIndex, clip] of clipByScene) {
|
|
1076
|
+
if (!clip.replannedIdea)
|
|
1077
|
+
continue;
|
|
1078
|
+
const { imageUrl, framePrompt, videoPrompt } = clip.replannedIdea;
|
|
1079
|
+
keyframeByScene.set(sceneIndex, keyframeDownload(sceneIndex, imageUrl));
|
|
1080
|
+
if (framePrompt && videoPrompt)
|
|
1081
|
+
replanned.set(sceneIndex, { framePrompt, videoPrompt });
|
|
1082
|
+
}
|
|
1083
|
+
let storyboard = null;
|
|
1084
|
+
if (storyboardRaw !== undefined) {
|
|
1085
|
+
const body = replanned.size > 0 ? withReplannedPrompts(storyboardRaw, replanned) : storyboardRaw;
|
|
1086
|
+
storyboard = "storyboard.json";
|
|
1087
|
+
texts.unshift({ file: storyboard, body: body.endsWith("\n") ? body : `${body}\n` });
|
|
1088
|
+
}
|
|
1069
1089
|
const sceneIndexes = [
|
|
1070
1090
|
...new Set([
|
|
1071
1091
|
...clipByScene.keys(),
|
|
@@ -2211,6 +2231,10 @@ export async function statusFlow(runId, json, deps) {
|
|
|
2211
2231
|
}
|
|
2212
2232
|
if (row.clip?.error)
|
|
2213
2233
|
lines.push(` ${row.clip.error}`);
|
|
2234
|
+
const usedIdea = row.clip?.artifact?.type === "video" ? row.clip.artifact.replannedIdea : undefined;
|
|
2235
|
+
if (usedIdea?.framePrompt) {
|
|
2236
|
+
lines.push(` clip: made from a new idea instead of the approved one. It shows: ${withoutLookRider(usedIdea.framePrompt)}`);
|
|
2237
|
+
}
|
|
2214
2238
|
if (revoicedByScene.has(sceneIndex))
|
|
2215
2239
|
lines.push(" voice: cast voice applied");
|
|
2216
2240
|
if (voiceFirstByScene.has(sceneIndex))
|
|
@@ -2608,11 +2632,9 @@ function brollEditLines(edit, now) {
|
|
|
2608
2632
|
if (now - edit.requestedAt >= BROLL_EDIT_STALE_MS) {
|
|
2609
2633
|
return [`The last B-roll change (${asked}) never finished. You can ask for it again.`];
|
|
2610
2634
|
}
|
|
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
|
-
];
|
|
2635
|
+
return [`Planning now: ${asked}. Then the storyboard pictures it changes are redrawn.`, BROLL_WAIT];
|
|
2615
2636
|
}
|
|
2637
|
+
const BROLL_WAIT = "Wait for it to finish before you change another shot or approve the storyboard.";
|
|
2616
2638
|
export async function brollFlow(runId, json, deps) {
|
|
2617
2639
|
const res = await deps.get(`${STORYBOARD_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
2618
2640
|
if (!res.ok)
|
|
@@ -2672,25 +2694,19 @@ export async function brollEditFlow(runId, edit, json, deps) {
|
|
|
2672
2694
|
const res = await deps.post(BROLL_PATH, { runId, ...edit });
|
|
2673
2695
|
if (!res.ok)
|
|
2674
2696
|
return errorResult(res, json);
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
`See them: exodus video storyboard ${runId}`,
|
|
2683
|
-
],
|
|
2684
|
-
};
|
|
2697
|
+
const said = edit.op === "remove"
|
|
2698
|
+
? `B-roll scene ${edit.sceneIndex} is gone. The storyboard pictures it changes are being redrawn.`
|
|
2699
|
+
: `${edit.op === "add"
|
|
2700
|
+
? `Planning a B-roll shot on line ${edit.lineId}.`
|
|
2701
|
+
: `Moving B-roll scene ${edit.sceneIndex} to line ${edit.lineId}.`} Then the storyboard pictures it changes are redrawn.`;
|
|
2702
|
+
if (json) {
|
|
2703
|
+
return { code: 0, lines: [JSON.stringify({ runId, ...res.data, message: `${said} ${BROLL_WAIT}` })] };
|
|
2685
2704
|
}
|
|
2686
2705
|
return {
|
|
2687
2706
|
code: 0,
|
|
2688
2707
|
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.",
|
|
2708
|
+
said,
|
|
2709
|
+
BROLL_WAIT,
|
|
2694
2710
|
`Check on it: exodus video broll ${runId}`,
|
|
2695
2711
|
`See the pictures: exodus video storyboard ${runId}`,
|
|
2696
2712
|
],
|
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 {
|