@officexapp/vidfarm-devcli 0.21.24 → 0.21.27

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/dist/src/cli.js CHANGED
@@ -79,8 +79,13 @@ Account (persisted login — points the CLI + \`serve\` at cloud prod):
79
79
  logout Clear the persisted credential
80
80
  whoami Show the logged-in account, host, plan, and cost mode
81
81
  cost-mode [mode] Show or set the money-saving preference all billed
82
- commands respect: minimize | hybrid | rich-ai.
83
- No arg = show current + explain the three simply.
82
+ commands respect, quoted per finished video:
83
+ minimize = $0 · hybrid = ~$0.01-$1 · rich-ai = $1+
84
+ (AI video gen → REUSABLE greenscreen raws, remixed
85
+ with hyperframes) · pure-videogen = $5+ (script →
86
+ storyboard → frame-by-frame generated scenes).
87
+ Spend is billed to YOUR AI provider keys (BYOK).
88
+ No arg = show current + explain them simply.
84
89
  --clear forgets it · --note "<why>" annotates the save.
85
90
  Override per-run with --cost-mode <m> / VIDFARM_COST_MODE;
86
91
  in "minimize" a billed op is refused until you add --yes.
@@ -120,9 +125,17 @@ Discover & inspiration (browse the viral-video catalog, add your own source):
120
125
  --limit <n> Max results
121
126
  --cursor <cursor> Continue from next_cursor
122
127
  --sort <mode> relevance, wow, automation, or recent
123
- public-raws [query...] Browse PUBLIC RAWS (feed / categories) → GET /api/v1/public-raws
128
+ public-raws [query...] Browse PUBLIC RAWS free, cleared B-roll you → GET /api/v1/public-raws
129
+ can drop straight into a composition.
130
+ --categories LIST THE SHELVES first (curated categories + → GET /api/v1/public-raws/categories
131
+ live counts + what each is FOR). The cheapest
132
+ way to find footage: pick a shelf, then pull
133
+ it with --category. Great for bulk scripting —
134
+ one shelf is a ready-made clip pool.
124
135
  --query <text> Keyword/vector query over title + metadata
125
- --category <key> Filter one public-raw category (e.g. emotion:confused)
136
+ --category <name> Filter ONE category. Takes a curated shelf
137
+ ("scroll-stoppers", "greenscreen", "reaction")
138
+ or an exact taxonomy key (emotion:confused).
126
139
  --type <raw_type> Filter one raw family (greenscreen, b_roll, ...)
127
140
  --freshness-days <n> Only raws newer than N days
128
141
  --bookmark <raw_id> Save that public raw into /raws/public/* → POST /api/v1/public-raws/:rawId/bookmark
@@ -185,6 +198,35 @@ Generate AI media and drop it on the timeline (for local coding agents):
185
198
  --no-wait Return the job id instead of polling to the URL
186
199
  --place <dir> After generating, place it into that composition
187
200
  --at <time> | --replace <layer_key> Placement (fill gap vs replace scene)
201
+ sequence <step> STORYBOARD-DRIVEN VIDEO — brief → character card → storyboard
202
+ sheet → shot prompt → AI video → final cut. Resumable:
203
+ all state lives in sequence.json, rerunning a finished
204
+ step is a no-op. (aliases: storyboard, previz)
205
+ init "<brief>" Scaffold sequence.json + PLAN_TASK.md from one line
206
+ --dir <path> Project folder (default: vidfarm-sequence-<brief>)
207
+ --parts <n> Shots to chain; part 2+ extends the one before (default 1)
208
+ --panels <n> Panels per storyboard sheet (default 8)
209
+ --duration <s> Seconds per part (default 15); --aspect-ratio <r> (16:9)
210
+ --style "<look>" Final-film style lock, e.g. "painterly anime, crisp daylight"
211
+ --board-style <k> rough = monochrome previz (default) | final = rendered in
212
+ the final look, so the board doubles as a style check
213
+ --character <name> --character-description "<silhouette, wardrobe>"
214
+ --no-laban Drop the Laban weight/time/space/flow movement block
215
+ plan Write PLAN_TASK.md for a desktop agent to direct the
216
+ sequence properly; --check validates the plan instead
217
+ characters One identity sheet per character [cheap, ~$0.05 each]
218
+ boards One storyboard sheet per part [cheap, ~$0.05 each]
219
+ prompts Compose the video prompts (free, local, deterministic)
220
+ --print <kind>:<id> Preview a prompt without spending: board:part-01,
221
+ shot:part-01, card:<slug>
222
+ animate Generate video per part [EXPENSIVE, ~$1+ each]
223
+ --resolution <r> --audio --provider <p> --model <m>
224
+ assemble ffmpeg-concat the parts into renders/final.mp4 [free]
225
+ --music <file|url> Lay a music bed under the cut; --out <path>
226
+ run All of the above in order, with a confirm gate before
227
+ each spend. --yes runs it fully autonomously.
228
+ status What's done, what's next (the default step)
229
+ --force Redo a completed step; --part <id> / --only <slug> scope it
188
230
  inpaint <image|url> Masked image EDIT — replace ONLY the painted region → POST /api/v1/primitives/images/inpaint
189
231
  --mask <png|@file|url> Alpha-mask PNG: TRANSPARENT pixels = the editable region (required)
190
232
  --prompt <text> Whole-image edit instruction (what to put in the mask)
@@ -207,6 +249,28 @@ Generate AI media and drop it on the timeline (for local coding agents):
207
249
  --out <file> Download the transparent PNG to this path
208
250
  --no-wait Return the job id instead of polling to the URL
209
251
  --place <dir> --at <time>|--replace <layer_key> Fuse onto a composition
252
+ avatar "<who they are>" CREATE AN AVATAR — a TALKING HEAD video with lip-synced
253
+ spoken audio, generated on a forced greenscreen plate and
254
+ chroma-keyed in the same job → transparent presenter WebM
255
+ you composite over any background. (aliases: talking-head,
256
+ spokesperson) → POST /api/v1/primitives/videos/create-avatar
257
+ --say "<line>" What the avatar SAYS out loud, verbatim (alias --script)
258
+ --voice <desc> Delivery note, e.g. "warm, upbeat, mid-tempo"
259
+ --ref <url|@file|id> Likeness reference — headshot or character sprite card
260
+ (repeatable) → input_references
261
+ --duration <s> Clip length in seconds; --resolution 720p|1080p
262
+ --aspect-ratio <r> e.g. 9:16 (match where it lands)
263
+ --preset <name> Plate color: green (default) | digital-green | blue |
264
+ white | black | magenta; --key-color <hex> overrides
265
+ --tolerance <0..1> Key radius (default 0.3); --softness <0..1> feather (0.1)
266
+ --no-despill Skip edge color-fringe suppression
267
+ --local Generate in the cloud (your keys), key on YOUR machine
268
+ with bundled ffmpeg for free
269
+ --keep-greenscreen Stop at the raw greenscreen MP4; don't key it at all
270
+ --provider <p> --model <m> Video provider/model overrides (omit to auto-pick)
271
+ --out <file> Write the finished avatar here
272
+ --no-wait Return the job id instead of polling to the URL
273
+ --place <dir> --at <time>|--replace <layer_key> Fuse onto a composition
210
274
  remove-greenscreen <image|video|url> Key out a FLAT solid background from an
211
275
  IMAGE (→ transparent PNG/WebP) or VIDEO (→ transparent
212
276
  WebM/VP9 alpha), auto-detecting the media kind.
@@ -686,13 +750,20 @@ Cost spectrum (default to the cheapest approach that works; see SKILL.director.m
686
750
  $10+ Heavy AI generation (many/long AI clips, custom characters).
687
751
  Notes: image gen is cheap (use freely); AI VIDEO gen is expensive (ask the user
688
752
  first). Decompose is a one-time ~$0.10 — or skip it by forking a decomposed template.
689
- Set 'vidfarm cost-mode <minimize|hybrid|rich-ai>' once and every billed command
690
- (generate, music, decompose, cloud render/TTS/STT/greenscreen, create, replicate)
753
+ Per-video budget by mode: minimize = $0 (explicitly free), hybrid = ~$0.01-$1,
754
+ rich-ai = $1+ (AI video gen buys REUSABLE greenscreen raws — keyed once, then remixed
755
+ with hyperframes HTML/CSS motion and saved for later hybrid/minimize videos),
756
+ pure-videogen = $5+ (no reuse at all: text script → image storyboard → frame-by-frame
757
+ generation of every scene). All of it is billed to YOUR OWN AI provider keys (BYOK) —
758
+ add them with 'vidfarm add-provider-key <provider> <key>' or vidfarm.cc/settings/developer.
759
+ Set 'vidfarm cost-mode <minimize|hybrid|rich-ai|pure-videogen>' once and every billed
760
+ command (generate, music, decompose, cloud render/TTS/STT/greenscreen, create, replicate)
691
761
  respects it: minimize refuses billed spend without --yes and points you at the free
692
- local path; hybrid/rich-ai run but print each op's cost. FREE local engines never gate
762
+ local path; the others run but print each op's cost. FREE local engines never gate
693
763
  (local render, tts --engine local, stt --engine whisper, remove-greenscreen --local).
694
764
  In minimize AND hybrid, 'vidfarm tts' DEFAULTS to the free local Kokoro voice — ask for
695
- a premium voice (--style/--voice/--provider/--own-key/--cloud) or rich-ai to opt out.
765
+ a premium voice (--style/--voice/--provider/--own-key/--cloud), or be in rich-ai /
766
+ pure-videogen, to opt out.
696
767
 
697
768
  Escape hatch — call ANY route directly:
698
769
  api <METHOD> <path> Raw REST call with auth + pretty errors
@@ -843,6 +914,11 @@ async function main() {
843
914
  case "generate":
844
915
  await runGenerateCommand(rest);
845
916
  return;
917
+ case "sequence":
918
+ case "storyboard":
919
+ case "previz":
920
+ await runSequenceCommand(rest);
921
+ return;
846
922
  case "inpaint":
847
923
  await runInpaintCommand(rest);
848
924
  return;
@@ -850,6 +926,11 @@ async function main() {
850
926
  case "overlay":
851
927
  await runCreateOverlayCommand(rest);
852
928
  return;
929
+ case "avatar":
930
+ case "talking-head":
931
+ case "spokesperson":
932
+ await runAvatarCommand(rest);
933
+ return;
853
934
  case "remove-greenscreen":
854
935
  case "remove-background-greenscreen":
855
936
  case "rmbg-green":
@@ -1228,7 +1309,7 @@ function commonOptions() {
1228
1309
  local: { type: "boolean", default: false },
1229
1310
  cloud: { type: "boolean", default: false },
1230
1311
  both: { type: "boolean", default: false },
1231
- // Money-saving preference: minimize | hybrid | pure-ai. Flag > VIDFARM_COST_MODE
1312
+ // Money-saving preference: minimize | hybrid | rich-ai | pure-videogen. Flag > VIDFARM_COST_MODE
1232
1313
  // env > `vidfarm cost-mode` stored value > default (hybrid). Billed commands
1233
1314
  // gate on it; `--yes`/-y confirms a billed spend when the mode is "minimize".
1234
1315
  "cost-mode": { type: "string" },
@@ -2628,10 +2709,25 @@ async function runPublicRawsCommand(argv) {
2628
2709
  category: { type: "string" },
2629
2710
  type: { type: "string" },
2630
2711
  "freshness-days": { type: "string" },
2631
- bookmark: { type: "string" }
2712
+ bookmark: { type: "string" },
2713
+ categories: { type: "boolean", default: false }
2632
2714
  }
2633
2715
  });
2634
2716
  const ctx = commonContext(parsed.values);
2717
+ // `--categories` is the map BEFORE the territory: the curated shelves with
2718
+ // live counts. Start here when you don't know what footage exists — pick a
2719
+ // shelf, then re-run with --category <slug> to pull its clips.
2720
+ if (parsed.values.categories) {
2721
+ const result = await apiRequest({
2722
+ method: "GET",
2723
+ host: ctx.host,
2724
+ path: "/api/v1/public-raws/categories",
2725
+ auth: ctx.auth
2726
+ });
2727
+ assertApiOk(result, "public-raws categories");
2728
+ emitResult(result, ctx.json, [["Browse ", `${ctx.host}/discover/raws/categories`]]);
2729
+ return;
2730
+ }
2635
2731
  const bookmark = parsed.values.bookmark?.trim();
2636
2732
  if (bookmark) {
2637
2733
  const result = await apiRequest({
@@ -4147,6 +4243,74 @@ async function runGenerateCommand(argv) {
4147
4243
  console.log(`${DIM}Place it with: vidfarm place <dir> --src "${mediaUrl}" --kind ${mediaType} --at <time>|--replace <layer_key>${RESET}`);
4148
4244
  }
4149
4245
  }
4246
+ // ── Sequence: storyboard-driven AI video pipeline ────────────────────────────
4247
+ // `vidfarm sequence` is a multi-step, resumable director's workflow: brief →
4248
+ // character card → storyboard sheet → shot prompt → AI video → final cut. The
4249
+ // orchestration + disk state live in devcli/sequence.ts and the prompt grammar
4250
+ // in services/sequence-prompts.ts; this wrapper only supplies the cloud seam
4251
+ // (auth, primitive jobs, cost gating, downloads) so there is exactly one copy
4252
+ // of the REST plumbing.
4253
+ /** Submit a primitive generate job and poll it to a finished media URL. */
4254
+ async function generatePrimitiveMedia(ctx, kind, payload) {
4255
+ const tracer = `devcli-seq-${Date.now().toString(36)}`;
4256
+ const route = kind === "video" ? "/api/v1/primitives/videos/generate" : "/api/v1/primitives/images/generate";
4257
+ const submit = await apiRequest({ method: "POST", host: ctx.host, path: route, auth: ctx.auth, body: { tracer, payload } });
4258
+ assertApiOk(submit, `generate ${kind}`);
4259
+ const jobId = submit.json?.job_id;
4260
+ if (!jobId)
4261
+ throw new Error(`generate ${kind} did not return a job id.`);
4262
+ const job = await pollPrimitiveJob(ctx, jobId);
4263
+ const url = resolveJobMediaUrl(job);
4264
+ if (!url) {
4265
+ throw new Error(`generate ${kind} ${String(job?.status ?? "did not finish")} — no media URL (job ${jobId}).`);
4266
+ }
4267
+ return { url, jobId };
4268
+ }
4269
+ async function runSequenceCommand(argv) {
4270
+ // Parsed loosely: the subcommand flags live in devcli/sequence.ts, and this
4271
+ // pass only needs the shared host/auth/cost-mode options.
4272
+ const parsed = parseArgs({ args: argv, allowPositionals: true, strict: false, options: commonOptions() });
4273
+ const ctx = commonContext(parsed.values);
4274
+ const { runSequenceCommand: run } = await import("./devcli/sequence.js");
4275
+ await run(argv, {
4276
+ json: ctx.json,
4277
+ yes: ctx.yes,
4278
+ guardBilled: (input) => guardBilled(ctx, input),
4279
+ resolveRefs: (values) => resolveReferenceUrls(ctx, values),
4280
+ async generateImage(input) {
4281
+ const payload = { prompt: input.prompt };
4282
+ if (input.aspectRatio)
4283
+ payload.aspect_ratio = input.aspectRatio;
4284
+ if (input.provider)
4285
+ payload.provider = input.provider;
4286
+ if (input.model)
4287
+ payload.model = input.model;
4288
+ if (input.refs.length)
4289
+ payload.prompt_attachments = input.refs.slice(0, 16);
4290
+ return generatePrimitiveMedia(ctx, "image", payload);
4291
+ },
4292
+ async generateVideo(input) {
4293
+ const payload = {
4294
+ prompt: input.prompt,
4295
+ duration: Math.round(input.durationSeconds)
4296
+ };
4297
+ if (input.aspectRatio)
4298
+ payload.aspect_ratio = input.aspectRatio;
4299
+ if (input.resolution)
4300
+ payload.resolution = input.resolution;
4301
+ if (input.audio)
4302
+ payload.generate_audio = true;
4303
+ if (input.provider)
4304
+ payload.provider = input.provider;
4305
+ if (input.model)
4306
+ payload.model = input.model;
4307
+ if (input.refs.length)
4308
+ payload.input_references = input.refs.slice(0, 8);
4309
+ return generatePrimitiveMedia(ctx, "video", payload);
4310
+ },
4311
+ download: (url, dest) => downloadUrlToFile(url, dest, ctx.auth.apiKey ? { "vidfarm-api-key": ctx.auth.apiKey } : undefined)
4312
+ });
4313
+ }
4150
4314
  // ── Inpaint: masked image edit ───────────────────────────────────────────────
4151
4315
  // The devcli twin of the web /tools/image page. The browser page rasterizes painted
4152
4316
  // strokes into an alpha mask (transparent = editable), composes one provider
@@ -4468,6 +4632,236 @@ async function runCreateOverlayCommand(argv) {
4468
4632
  console.log(`${DIM}Place it with: vidfarm place <dir> --src "${mediaUrl}" --kind image --at <time>|--replace <layer_key>${RESET}`);
4469
4633
  }
4470
4634
  }
4635
+ // ── avatar: talking-head presenter, keyed off its greenscreen ────────────────
4636
+ // "Create an avatar" is ALWAYS this: a generated VIDEO of a talking head with
4637
+ // lip-synced spoken audio, shot on a flat key-color plate, then chroma-keyed so
4638
+ // the presenter composites over whatever is behind them. The devcli twin of the
4639
+ // talking_avatar primitive.
4640
+ //
4641
+ // The generation leg is always the cloud primitive (it rides the caller's own
4642
+ // saved video-provider keys — BYOK, same as the web app). The KEYING leg is
4643
+ // where local/cloud differ:
4644
+ // default → the same job keys it in the cloud → browser-ready transparent
4645
+ // WebM (VP9 alpha, audio preserved) you can drop straight in.
4646
+ // --local → the job returns the raw greenscreen MP4 and bundled ffmpeg keys
4647
+ // it on your machine for free (ProRes .mov fallback if this
4648
+ // ffmpeg build can't write VP9 alpha).
4649
+ // --keep-greenscreen → stop at the plate; don't key at all.
4650
+ async function runAvatarCommand(argv) {
4651
+ const parsed = parseArgs({
4652
+ args: argv,
4653
+ allowPositionals: true,
4654
+ options: {
4655
+ ...commonOptions(),
4656
+ prompt: { type: "string" },
4657
+ // What the avatar says out loud. `--script` is the alias.
4658
+ say: { type: "string" },
4659
+ script: { type: "string" },
4660
+ voice: { type: "string" },
4661
+ // Likeness references: headshot, character sprite card, brand photo.
4662
+ ref: { type: "string", multiple: true },
4663
+ duration: { type: "string" },
4664
+ resolution: { type: "string" },
4665
+ "aspect-ratio": { type: "string" },
4666
+ provider: { type: "string" },
4667
+ model: { type: "string" },
4668
+ // Plate + keying knobs (mirror remove-greenscreen).
4669
+ preset: { type: "string" },
4670
+ "key-color": { type: "string" },
4671
+ tolerance: { type: "string" },
4672
+ softness: { type: "string" },
4673
+ "no-despill": { type: "boolean", default: false },
4674
+ local: { type: "boolean", default: false },
4675
+ "keep-greenscreen": { type: "boolean", default: false },
4676
+ out: { type: "string" },
4677
+ "no-wait": { type: "boolean", default: false },
4678
+ tracer: { type: "string" },
4679
+ // Fused placement onto a local composition.html (same as `generate`).
4680
+ place: { type: "string" },
4681
+ at: { type: "string" },
4682
+ replace: { type: "string" },
4683
+ track: { type: "string" },
4684
+ transition: { type: "string" },
4685
+ "transition-duration": { type: "string" },
4686
+ "transition-out": { type: "string" },
4687
+ "transition-out-duration": { type: "string" },
4688
+ "layer-key": { type: "string" }
4689
+ }
4690
+ });
4691
+ const ctx = commonContext(parsed.values);
4692
+ guardBilled(ctx, {
4693
+ label: "AI talking-avatar video (generation with audio + chroma key)",
4694
+ estimate: "expensive, ~$0.50–$1.50 (AI video)",
4695
+ freeAlternative: 'reuse a free talking-head clip — vidfarm public-raws --category talking-head — or key an existing greenscreen clip with vidfarm remove-greenscreen --local ($0)'
4696
+ });
4697
+ const prompt = (parsed.values.prompt ?? parsed.positionals.join(" ")).trim();
4698
+ if (!prompt) {
4699
+ throw new Error('avatar requires a description of WHO the avatar is: `vidfarm avatar "friendly 30-something founder in a hoodie" --say "Here is why we built this."` — describe the person, not the background (the greenscreen plate is forced for you).');
4700
+ }
4701
+ const script = (parsed.values.say ?? parsed.values.script ?? "").trim();
4702
+ const refs = await resolveReferenceUrls(ctx, parsed.values.ref);
4703
+ const presetRaw = parsed.values.preset?.trim().toLowerCase();
4704
+ if (presetRaw && !GREENSCREEN_PRESETS[presetRaw]) {
4705
+ throw new Error(`Unknown --preset "${presetRaw}". Choose one of: ${Object.keys(GREENSCREEN_PRESETS).join(", ")}, or pass --key-color <hex>.`);
4706
+ }
4707
+ // --local keys on this machine, so ask the cloud job for the raw plate.
4708
+ const keyLocally = Boolean(parsed.values.local);
4709
+ const keepPlate = Boolean(parsed.values["keep-greenscreen"]);
4710
+ const payload = { prompt };
4711
+ if (script)
4712
+ payload.script = script;
4713
+ if (parsed.values.voice)
4714
+ payload.voice = parsed.values.voice;
4715
+ if (refs.length)
4716
+ payload.input_references = refs.slice(0, 8);
4717
+ if (parsed.values.duration)
4718
+ payload.duration = Math.round(Number(parsed.values.duration));
4719
+ if (parsed.values.resolution)
4720
+ payload.resolution = parsed.values.resolution;
4721
+ if (parsed.values["aspect-ratio"])
4722
+ payload.aspect_ratio = parsed.values["aspect-ratio"];
4723
+ if (parsed.values.provider)
4724
+ payload.provider = parsed.values.provider;
4725
+ if (parsed.values.model)
4726
+ payload.model = parsed.values.model;
4727
+ if (presetRaw)
4728
+ payload.preset = presetRaw;
4729
+ if (parsed.values["key-color"])
4730
+ payload.key_color = parsed.values["key-color"];
4731
+ if (parsed.values.tolerance)
4732
+ payload.tolerance = Number(parsed.values.tolerance);
4733
+ if (parsed.values.softness)
4734
+ payload.softness = Number(parsed.values.softness);
4735
+ if (parsed.values["no-despill"])
4736
+ payload.despill = false;
4737
+ if (keyLocally || keepPlate)
4738
+ payload.keep_greenscreen = true;
4739
+ const tracer = parsed.values.tracer ?? `devcli-avatar-${Date.now().toString(36)}`;
4740
+ const submit = await apiRequest({ method: "POST", host: ctx.host, path: "/api/v1/primitives/videos/create-avatar", auth: ctx.auth, body: { tracer, payload } });
4741
+ assertApiOk(submit, "avatar");
4742
+ const jobId = submit.json?.job_id;
4743
+ const wait = !parsed.values["no-wait"];
4744
+ if (!wait || !jobId) {
4745
+ if (!ctx.json && jobId)
4746
+ console.log(`${DIM}Queued ${jobId} (tracer ${tracer}). Poll: vidfarm api GET /api/v1/user/me/jobs/${jobId} — or drop --no-wait to auto-poll.${RESET}`);
4747
+ emitResult(submit, ctx.json);
4748
+ return;
4749
+ }
4750
+ if (!ctx.json) {
4751
+ console.log(`${DIM}Generating talking avatar${script ? ` saying "${script.slice(0, 60)}${script.length > 60 ? "…" : ""}"` : ""} on a greenscreen plate (${jobId})… polling every 5s.${RESET}`);
4752
+ }
4753
+ const job = await pollPrimitiveJob(ctx, jobId);
4754
+ const mediaUrl = resolveJobMediaUrl(job);
4755
+ const status = String(job?.status ?? "");
4756
+ if (!mediaUrl) {
4757
+ if (ctx.json) {
4758
+ printJson({ ok: false, job_id: jobId, status, job });
4759
+ }
4760
+ else {
4761
+ console.log(`${RED}Avatar ${status || "did not finish"} — no media URL. Check your video-provider key and wallet.${RESET}`);
4762
+ printJson(job);
4763
+ }
4764
+ process.exitCode = 1;
4765
+ return;
4766
+ }
4767
+ // Local keying leg: pull the plate down and run the free ffmpeg keyer on it.
4768
+ let localOut = null;
4769
+ if (keyLocally && !keepPlate) {
4770
+ const downloadDir = mkdtempSync(path.join(tmpdir(), "vidfarm-avatar-"));
4771
+ const platePath = path.join(downloadDir, "avatar-greenscreen.mp4");
4772
+ try {
4773
+ if (!ctx.json)
4774
+ console.log(`${DIM}Downloading the greenscreen plate…${RESET}`);
4775
+ await downloadUrlToFile(mediaUrl, platePath);
4776
+ const preset = presetRaw ? GREENSCREEN_PRESETS[presetRaw] : undefined;
4777
+ const outPath = parsed.values.out
4778
+ ? path.resolve(process.cwd(), String(parsed.values.out))
4779
+ : path.resolve(process.cwd(), "avatar.webm");
4780
+ mkdirSync(path.dirname(outPath), { recursive: true });
4781
+ if (!ctx.json)
4782
+ console.log(`${DIM}Keying the avatar off its greenscreen locally with ffmpeg (free)…${RESET}`);
4783
+ localOut = await removeGreenscreenLocal({
4784
+ sourcePath: platePath,
4785
+ mediaType: "video",
4786
+ outputPath: outPath,
4787
+ keyColor: parsed.values["key-color"] ?? preset?.key_color,
4788
+ tolerance: parsed.values.tolerance !== undefined ? Number(parsed.values.tolerance) : preset?.tolerance,
4789
+ softness: parsed.values.softness !== undefined ? Number(parsed.values.softness) : preset?.softness,
4790
+ despill: !parsed.values["no-despill"]
4791
+ });
4792
+ }
4793
+ finally {
4794
+ rmSync(downloadDir, { recursive: true, force: true });
4795
+ }
4796
+ }
4797
+ // Whatever the caller ends up placing/saving: the local cut-out if we keyed
4798
+ // here, otherwise the cloud result (transparent WebM, or the plate with
4799
+ // --keep-greenscreen).
4800
+ const finalSrc = localOut?.outputPath ?? mediaUrl;
4801
+ let outPath = localOut?.outputPath ?? null;
4802
+ if (!localOut && parsed.values.out) {
4803
+ outPath = path.resolve(process.cwd(), String(parsed.values.out));
4804
+ await downloadUrlToFile(mediaUrl, outPath);
4805
+ }
4806
+ let placement = null;
4807
+ if (parsed.values.place) {
4808
+ const htmlPath = resolveCompositionHtmlPath(String(parsed.values.place));
4809
+ placement = placeMediaOnDisk({
4810
+ htmlPath,
4811
+ src: finalSrc,
4812
+ kind: "video",
4813
+ at: parsed.values.at ? parseTimeToSeconds(String(parsed.values.at)) : undefined,
4814
+ replace: parsed.values.replace,
4815
+ track: parsed.values.track ? Number(parsed.values.track) : undefined,
4816
+ transition: parsed.values.transition,
4817
+ transitionDuration: parsed.values["transition-duration"] ? Number(parsed.values["transition-duration"]) : undefined,
4818
+ transitionOut: parsed.values["transition-out"],
4819
+ transitionOutDuration: parsed.values["transition-out-duration"] ? Number(parsed.values["transition-out-duration"]) : undefined,
4820
+ layerKey: parsed.values["layer-key"]
4821
+ });
4822
+ if (!ctx.json)
4823
+ console.log(`${GREEN}Placed as ${placement.layerKey} in ${htmlPath}. Run \`vidfarm publish --dir ${path.dirname(htmlPath)}\` (or it live-morphs under \`serve\`).${RESET}`);
4824
+ }
4825
+ const transparent = !keepPlate;
4826
+ if (ctx.json) {
4827
+ printJson({
4828
+ ok: true,
4829
+ job_id: jobId,
4830
+ media_url: mediaUrl,
4831
+ transparent,
4832
+ keyed: keepPlate ? "none" : keyLocally ? "local" : "cloud",
4833
+ format: localOut?.format ?? (keepPlate ? "mp4" : "webm"),
4834
+ out: outPath,
4835
+ placement,
4836
+ script: script || null,
4837
+ greenscreen_source_url: job?.result?.greenscreen_source_url ?? null
4838
+ });
4839
+ return;
4840
+ }
4841
+ if (keepPlate) {
4842
+ console.log(`${GREEN}Avatar greenscreen plate ready:${RESET} ${mediaUrl}`);
4843
+ console.log(`${DIM}Key it whenever you like: vidfarm remove-greenscreen "${mediaUrl}" --local${RESET}`);
4844
+ }
4845
+ else if (localOut) {
4846
+ const label = localOut.format === "webm" ? "Transparent talking avatar (WebM)" : "Transparent talking avatar (ProRes 4444 .mov)";
4847
+ console.log(`${GREEN}${label} ready:${RESET} ${localOut.outputPath}`);
4848
+ if (!localOut.webmAlpha) {
4849
+ console.log(`${YELLOW}Note:${RESET} ${DIM}this ffmpeg build can't write VP9 alpha, so a ProRes .mov alpha master was produced — transparent and free, but not browser/editor-playable. Re-run without ${RESET}--local${DIM} for a browser-ready WebM.${RESET}`);
4850
+ }
4851
+ console.log(`${DIM}Greenscreen plate (re-keyable): ${mediaUrl}${RESET}`);
4852
+ }
4853
+ else {
4854
+ console.log(`${GREEN}Transparent talking avatar ready:${RESET} ${mediaUrl}`);
4855
+ if (outPath)
4856
+ console.log(`${DIM}Saved to ${outPath}${RESET}`);
4857
+ const plate = job?.result?.greenscreen_source_url;
4858
+ if (plate)
4859
+ console.log(`${DIM}Greenscreen plate (re-key at a different tolerance for free): ${plate}${RESET}`);
4860
+ }
4861
+ if (!placement) {
4862
+ console.log(`${DIM}Drop it over a background with: vidfarm place <dir> --src "${finalSrc}" --kind video --at <time> (the alpha survives into the render).${RESET}`);
4863
+ }
4864
+ }
4471
4865
  // ── Green-screen (chroma-key) background removal ──────────────────────────────
4472
4866
  // The devcli twin of the remove-greenscreen primitive: key out a flat, solid
4473
4867
  // background from an IMAGE (→ transparent PNG/WebP) OR a VIDEO (→ transparent
@@ -5151,7 +5545,7 @@ async function runTtsCommand(argv) {
5151
5545
  /** Set when the cost-mode default ran Kokoro and it failed — so the keyless
5152
5546
  * path below doesn't pointlessly retry the same engine. */
5153
5547
  let kokoroError = null;
5154
- if (!engine && !wantCloud && costMode.mode !== "pure-ai" && !premium) {
5548
+ if (!engine && !wantCloud && costMode.mode !== "rich-ai" && costMode.mode !== "pure-videogen" && !premium) {
5155
5549
  try {
5156
5550
  await runLocalKokoroTts({
5157
5551
  text,
@@ -6833,15 +7227,16 @@ async function runWhoamiCommand(argv) {
6833
7227
  console.log(`${GREEN}${BOLD}${who}${RESET} → ${ctx.host}`);
6834
7228
  console.log(` plan ${customer.isPaidPlan ? `${GREEN}paid${plan}${RESET}` : `${DIM}free${plan}${RESET}`}`);
6835
7229
  console.log(` credential ${DIM}${stored ? "persisted login (vidfarm login)" : "env/flag key — not persisted (run vidfarm login to persist)"}${RESET}`);
6836
- console.log(` ${costModeSummaryLine(ctx.costMode).replace("cost mode: ", "cost mode ")}${!ctx.costMode.isSet ? ` ${DIM}(set with: vidfarm cost-mode <minimize|hybrid|rich-ai>)${RESET}` : ""}`);
7230
+ console.log(` ${costModeSummaryLine(ctx.costMode).replace("cost mode: ", "cost mode ")}${!ctx.costMode.isSet ? ` ${DIM}(set with: vidfarm cost-mode <${COST_MODE_DISPLAY_LIST.join("|")}>)${RESET}` : ""}`);
6837
7231
  return;
6838
7232
  }
6839
7233
  emitResult(result, ctx.json);
6840
7234
  }
6841
- // `vidfarm cost-mode [minimize|hybrid|rich-ai]` — show or set the money-saving
6842
- // preference every billed command respects. No arg = show current + explain the
6843
- // three simply so an agent can relay them to the user (and be reminded to ask
6844
- // about saving the choice into whatever agent memory it has). `--clear` forgets it.
7235
+ // `vidfarm cost-mode [minimize|hybrid|rich-ai|pure-videogen]` — show or set the
7236
+ // money-saving preference every billed command respects. No arg = show current +
7237
+ // explain the modes simply so an agent can relay them to the user (and be
7238
+ // reminded to ask about saving the choice into whatever agent memory it has).
7239
+ // `--clear` forgets it.
6845
7240
  async function runCostModeCommand(argv) {
6846
7241
  const parsed = parseArgs({
6847
7242
  args: argv,
@@ -6884,7 +7279,7 @@ async function runCostModeCommand(argv) {
6884
7279
  console.log("");
6885
7280
  console.log(costModeExplainer());
6886
7281
  console.log("");
6887
- console.log(`${DIM}Set it: ${BOLD}vidfarm cost-mode <minimize|hybrid|rich-ai>${RESET}`);
7282
+ console.log(`${DIM}Set it: ${BOLD}vidfarm cost-mode <${COST_MODE_DISPLAY_LIST.join("|")}>${RESET}`);
6888
7283
  console.log(`${DIM}Forget it: vidfarm cost-mode --clear · override per-run: --cost-mode <m> or VIDFARM_COST_MODE.${RESET}`);
6889
7284
  if (!resolved.isSet) {
6890
7285
  console.log(`${DIM}Nothing saved yet — ask the user which one they want before spending AI credits.${RESET}`);