@officexapp/vidfarm-devcli 0.21.26 → 0.21.28

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
@@ -19,6 +19,7 @@ import { formatCompositionLintIssues, lintCompositionHtml } from "./services/com
19
19
  import { resolveFfmpeg } from "./services/clip-curation/ffmpeg.js";
20
20
  import { parseHyperframesJson, runHyperframesCommand } from "./devcli/hyperframes-cli.js";
21
21
  import { renderCompositionStills } from "./devcli/stills.js";
22
+ import { formatQaReport, qaCompositionHtml } from "./devcli/qa-check.js";
22
23
  import { removeGreenscreenLocal, localGreenscreenAvailable, defaultGreenscreenOutPath, GREENSCREEN_PRESETS, trimTransparentBorders, cropImageRegion } from "./devcli/greenscreen-local.js";
23
24
  import { runDoctorCommand } from "./devcli/doctor.js";
24
25
  import { findFreePort } from "./devcli/port-utils.js";
@@ -80,9 +81,12 @@ Account (persisted login — points the CLI + \`serve\` at cloud prod):
80
81
  whoami Show the logged-in account, host, plan, and cost mode
81
82
  cost-mode [mode] Show or set the money-saving preference all billed
82
83
  commands respect, quoted per finished video:
83
- minimize = $0 · hybrid = ~$0.01-$1 · rich-ai = $1+.
84
+ minimize = $0 · hybrid = ~$0.01-$1 · rich-ai = $1+
85
+ (AI video gen → REUSABLE greenscreen raws, remixed
86
+ with hyperframes) · pure-videogen = $5+ (script →
87
+ storyboard → frame-by-frame generated scenes).
84
88
  Spend is billed to YOUR AI provider keys (BYOK).
85
- No arg = show current + explain the three simply.
89
+ No arg = show current + explain them simply.
86
90
  --clear forgets it · --note "<why>" annotates the save.
87
91
  Override per-run with --cost-mode <m> / VIDFARM_COST_MODE;
88
92
  in "minimize" a billed op is refused until you add --yes.
@@ -122,9 +126,17 @@ Discover & inspiration (browse the viral-video catalog, add your own source):
122
126
  --limit <n> Max results
123
127
  --cursor <cursor> Continue from next_cursor
124
128
  --sort <mode> relevance, wow, automation, or recent
125
- public-raws [query...] Browse PUBLIC RAWS (feed / categories) → GET /api/v1/public-raws
129
+ public-raws [query...] Browse PUBLIC RAWS free, cleared B-roll you → GET /api/v1/public-raws
130
+ can drop straight into a composition.
131
+ --categories LIST THE SHELVES first (curated categories + → GET /api/v1/public-raws/categories
132
+ live counts + what each is FOR). The cheapest
133
+ way to find footage: pick a shelf, then pull
134
+ it with --category. Great for bulk scripting —
135
+ one shelf is a ready-made clip pool.
126
136
  --query <text> Keyword/vector query over title + metadata
127
- --category <key> Filter one public-raw category (e.g. emotion:confused)
137
+ --category <name> Filter ONE category. Takes a curated shelf
138
+ ("scroll-stoppers", "greenscreen", "reaction")
139
+ or an exact taxonomy key (emotion:confused).
128
140
  --type <raw_type> Filter one raw family (greenscreen, b_roll, ...)
129
141
  --freshness-days <n> Only raws newer than N days
130
142
  --bookmark <raw_id> Save that public raw into /raws/public/* → POST /api/v1/public-raws/:rawId/bookmark
@@ -187,6 +199,35 @@ Generate AI media and drop it on the timeline (for local coding agents):
187
199
  --no-wait Return the job id instead of polling to the URL
188
200
  --place <dir> After generating, place it into that composition
189
201
  --at <time> | --replace <layer_key> Placement (fill gap vs replace scene)
202
+ sequence <step> STORYBOARD-DRIVEN VIDEO — brief → character card → storyboard
203
+ sheet → shot prompt → AI video → final cut. Resumable:
204
+ all state lives in sequence.json, rerunning a finished
205
+ step is a no-op. (aliases: storyboard, previz)
206
+ init "<brief>" Scaffold sequence.json + PLAN_TASK.md from one line
207
+ --dir <path> Project folder (default: vidfarm-sequence-<brief>)
208
+ --parts <n> Shots to chain; part 2+ extends the one before (default 1)
209
+ --panels <n> Panels per storyboard sheet (default 8)
210
+ --duration <s> Seconds per part (default 15); --aspect-ratio <r> (16:9)
211
+ --style "<look>" Final-film style lock, e.g. "painterly anime, crisp daylight"
212
+ --board-style <k> rough = monochrome previz (default) | final = rendered in
213
+ the final look, so the board doubles as a style check
214
+ --character <name> --character-description "<silhouette, wardrobe>"
215
+ --no-laban Drop the Laban weight/time/space/flow movement block
216
+ plan Write PLAN_TASK.md for a desktop agent to direct the
217
+ sequence properly; --check validates the plan instead
218
+ characters One identity sheet per character [cheap, ~$0.05 each]
219
+ boards One storyboard sheet per part [cheap, ~$0.05 each]
220
+ prompts Compose the video prompts (free, local, deterministic)
221
+ --print <kind>:<id> Preview a prompt without spending: board:part-01,
222
+ shot:part-01, card:<slug>
223
+ animate Generate video per part [EXPENSIVE, ~$1+ each]
224
+ --resolution <r> --audio --provider <p> --model <m>
225
+ assemble ffmpeg-concat the parts into renders/final.mp4 [free]
226
+ --music <file|url> Lay a music bed under the cut; --out <path>
227
+ run All of the above in order, with a confirm gate before
228
+ each spend. --yes runs it fully autonomously.
229
+ status What's done, what's next (the default step)
230
+ --force Redo a completed step; --part <id> / --only <slug> scope it
190
231
  inpaint <image|url> Masked image EDIT — replace ONLY the painted region → POST /api/v1/primitives/images/inpaint
191
232
  --mask <png|@file|url> Alpha-mask PNG: TRANSPARENT pixels = the editable region (required)
192
233
  --prompt <text> Whole-image edit instruction (what to put in the mask)
@@ -209,6 +250,28 @@ Generate AI media and drop it on the timeline (for local coding agents):
209
250
  --out <file> Download the transparent PNG to this path
210
251
  --no-wait Return the job id instead of polling to the URL
211
252
  --place <dir> --at <time>|--replace <layer_key> Fuse onto a composition
253
+ avatar "<who they are>" CREATE AN AVATAR — a TALKING HEAD video with lip-synced
254
+ spoken audio, generated on a forced greenscreen plate and
255
+ chroma-keyed in the same job → transparent presenter WebM
256
+ you composite over any background. (aliases: talking-head,
257
+ spokesperson) → POST /api/v1/primitives/videos/create-avatar
258
+ --say "<line>" What the avatar SAYS out loud, verbatim (alias --script)
259
+ --voice <desc> Delivery note, e.g. "warm, upbeat, mid-tempo"
260
+ --ref <url|@file|id> Likeness reference — headshot or character sprite card
261
+ (repeatable) → input_references
262
+ --duration <s> Clip length in seconds; --resolution 720p|1080p
263
+ --aspect-ratio <r> e.g. 9:16 (match where it lands)
264
+ --preset <name> Plate color: green (default) | digital-green | blue |
265
+ white | black | magenta; --key-color <hex> overrides
266
+ --tolerance <0..1> Key radius (default 0.3); --softness <0..1> feather (0.1)
267
+ --no-despill Skip edge color-fringe suppression
268
+ --local Generate in the cloud (your keys), key on YOUR machine
269
+ with bundled ffmpeg for free
270
+ --keep-greenscreen Stop at the raw greenscreen MP4; don't key it at all
271
+ --provider <p> --model <m> Video provider/model overrides (omit to auto-pick)
272
+ --out <file> Write the finished avatar here
273
+ --no-wait Return the job id instead of polling to the URL
274
+ --place <dir> --at <time>|--replace <layer_key> Fuse onto a composition
212
275
  remove-greenscreen <image|video|url> Key out a FLAT solid background from an
213
276
  IMAGE (→ transparent PNG/WebP) or VIDEO (→ transparent
214
277
  WebM/VP9 alpha), auto-detecting the media kind.
@@ -533,6 +596,16 @@ Local media engines & toolchain (all local, free, no account — no cloud key ne
533
596
  lint <dir|composition.html> Validate a composition on disk (same (local)
534
597
  checks the /editor chat preflights); prints
535
598
  errors/warnings, exit 1 on errors [--json]
599
+ qa <dir|composition.html> Social-native QA pass — HIGHLY RECOMMENDED (local, devcli-only)
600
+ on every video you produce. Blocklists "HTML
601
+ slop" (CTA buttons, benefit chip rows, frosted
602
+ cards, gradient text, web-page classes/fonts)
603
+ and checks the caption font regime + safe zone.
604
+ Feedback only: exits 0 even when it finds slop,
605
+ and it never runs automatically. Blocklist, not
606
+ allowlist — unusual styles pass untouched
607
+ --json Machine-readable findings (rule/severity/fix)
608
+ --strict Also exit 1 when slop is found (for CI)
536
609
  stills <dir|composition.html> Render PNG stills of the CURRENT (local in-process render)
537
610
  composition — the visual self-check loop
538
611
  ("did my edit look right") without a full render
@@ -689,15 +762,19 @@ Cost spectrum (default to the cheapest approach that works; see SKILL.director.m
689
762
  Notes: image gen is cheap (use freely); AI VIDEO gen is expensive (ask the user
690
763
  first). Decompose is a one-time ~$0.10 — or skip it by forking a decomposed template.
691
764
  Per-video budget by mode: minimize = $0 (explicitly free), hybrid = ~$0.01-$1,
692
- rich-ai = $1+. All of it is billed to YOUR OWN AI provider keys (BYOK) — add them
693
- with 'vidfarm add-provider-key <provider> <key>' or at vidfarm.cc/settings/developer.
694
- Set 'vidfarm cost-mode <minimize|hybrid|rich-ai>' once and every billed command
695
- (generate, music, decompose, cloud render/TTS/STT/greenscreen, create, replicate)
765
+ rich-ai = $1+ (AI video gen buys REUSABLE greenscreen raws keyed once, then remixed
766
+ with hyperframes HTML/CSS motion and saved for later hybrid/minimize videos),
767
+ pure-videogen = $5+ (no reuse at all: text script → image storyboard → frame-by-frame
768
+ generation of every scene). All of it is billed to YOUR OWN AI provider keys (BYOK)
769
+ add them with 'vidfarm add-provider-key <provider> <key>' or vidfarm.cc/settings/developer.
770
+ Set 'vidfarm cost-mode <minimize|hybrid|rich-ai|pure-videogen>' once and every billed
771
+ command (generate, music, decompose, cloud render/TTS/STT/greenscreen, create, replicate)
696
772
  respects it: minimize refuses billed spend without --yes and points you at the free
697
- local path; hybrid/rich-ai run but print each op's cost. FREE local engines never gate
773
+ local path; the others run but print each op's cost. FREE local engines never gate
698
774
  (local render, tts --engine local, stt --engine whisper, remove-greenscreen --local).
699
775
  In minimize AND hybrid, 'vidfarm tts' DEFAULTS to the free local Kokoro voice — ask for
700
- a premium voice (--style/--voice/--provider/--own-key/--cloud) or rich-ai to opt out.
776
+ a premium voice (--style/--voice/--provider/--own-key/--cloud), or be in rich-ai /
777
+ pure-videogen, to opt out.
701
778
 
702
779
  Escape hatch — call ANY route directly:
703
780
  api <METHOD> <path> Raw REST call with auth + pretty errors
@@ -848,6 +925,11 @@ async function main() {
848
925
  case "generate":
849
926
  await runGenerateCommand(rest);
850
927
  return;
928
+ case "sequence":
929
+ case "storyboard":
930
+ case "previz":
931
+ await runSequenceCommand(rest);
932
+ return;
851
933
  case "inpaint":
852
934
  await runInpaintCommand(rest);
853
935
  return;
@@ -855,6 +937,11 @@ async function main() {
855
937
  case "overlay":
856
938
  await runCreateOverlayCommand(rest);
857
939
  return;
940
+ case "avatar":
941
+ case "talking-head":
942
+ case "spokesperson":
943
+ await runAvatarCommand(rest);
944
+ return;
858
945
  case "remove-greenscreen":
859
946
  case "remove-background-greenscreen":
860
947
  case "rmbg-green":
@@ -1101,6 +1188,11 @@ async function main() {
1101
1188
  case "lint":
1102
1189
  await runLintCommand(rest);
1103
1190
  return;
1191
+ case "qa":
1192
+ case "qa-check":
1193
+ case "slop-check":
1194
+ await runQaCommand(rest);
1195
+ return;
1104
1196
  case "stills":
1105
1197
  await runStillsCommand(rest);
1106
1198
  return;
@@ -1233,7 +1325,7 @@ function commonOptions() {
1233
1325
  local: { type: "boolean", default: false },
1234
1326
  cloud: { type: "boolean", default: false },
1235
1327
  both: { type: "boolean", default: false },
1236
- // Money-saving preference: minimize | hybrid | pure-ai. Flag > VIDFARM_COST_MODE
1328
+ // Money-saving preference: minimize | hybrid | rich-ai | pure-videogen. Flag > VIDFARM_COST_MODE
1237
1329
  // env > `vidfarm cost-mode` stored value > default (hybrid). Billed commands
1238
1330
  // gate on it; `--yes`/-y confirms a billed spend when the mode is "minimize".
1239
1331
  "cost-mode": { type: "string" },
@@ -2080,6 +2172,8 @@ Rules:
2080
2172
  - When swapping visuals, match both the literal scene DNA and the narrative purpose of the beat.
2081
2173
  - For replacement graphics, screenshots, or still-like scenes, prefer AI image generation plus Ken Burns before paying for AI video unless static_vs_pivot says motion footage is load-bearing.
2082
2174
  - If narration must be customized, default to premium ElevenLabs first, then the user's own ElevenLabs path, then BYOK OpenAI/Gemini/OpenRouter. If captions or scenes were timed to the old VO, retime them to the new narration.
2175
+ - NO HTML SLOP. You are editing HTML, but the output is a social video, not a web page. Never author landing-page furniture: CTA "buttons" (a filled/gradient rounded capsule with action copy like "Sign Up for a Free Trial →"), benefit chip/badge rows ("✓ No Credit Card Needed"), bordered/shadowed/frosted cards holding a headline + URL, gradient text fills, feature grids, bulleted lists, or web-default fonts (Inter/Roboto/Arial/system-ui). None of that appears in a real TikTok, and nothing in a video is clickable — say it as timed text on the footage instead. Arrows, scribble/underline marks, italics, ALL-CAPS, single-word color pops, emoji, transparent cut-out stickers, and mock social UI (iMessage bubbles, comment cards) are all fine. Captions use an imported family (Montserrat default / TikTok Sans / Abel / Source Code Pro / Yesteryear) at weight 700-900, ~36-64px on a 1080-wide frame, inside the 8%-85% safe zone, with exactly one of four backgrounds: outline, plain, an active-word spotlight/karaoke pill, or a tight-hugging solid band (radius <=8px, no border/shadow/gradient/blur).
2176
+ - QA EVERY VIDEO BEFORE YOU RENDER: run \`vidfarm qa .\` in this directory. It's free, instant, and local — a blocklist for the slop above plus the font regime and safe zone, with a concrete fix per finding. It's feedback, not a gate (exits 0 even on findings, never runs automatically) and a blocklist, not an allowlist, so stylized or hand-made work passes untouched. Fix what's real, ignore what's a deliberate style call. \`--json\` for scripted batches.
2083
2177
 
2084
2178
  The three paintbrushes (Vidfarm is thrift-first — do NOT spend AI credits on every scene):
2085
2179
  - Paint each REPLACE beat with one of three brushes, cheapest first: (1) RAW CLIPS remixed from existing footage (\`vidfarm raws search\` the local/library first, then \`vidfarm raws scan <url|file>\` to hunt new ones — free local compute); (2) HTML/JS HYPERFRAMES — animate text/images/logos/stickers/charts with CSS or JS adapters (anime.js/GSAP/Lottie/Three) for titles, kinetic captions, data beats, brand cards; (3) PURE AI GENERATION (\`vidfarm generate image|video\`) — most expensive, AI video especially, last resort for beats no clip or hyperframe can cover.
@@ -2633,10 +2727,25 @@ async function runPublicRawsCommand(argv) {
2633
2727
  category: { type: "string" },
2634
2728
  type: { type: "string" },
2635
2729
  "freshness-days": { type: "string" },
2636
- bookmark: { type: "string" }
2730
+ bookmark: { type: "string" },
2731
+ categories: { type: "boolean", default: false }
2637
2732
  }
2638
2733
  });
2639
2734
  const ctx = commonContext(parsed.values);
2735
+ // `--categories` is the map BEFORE the territory: the curated shelves with
2736
+ // live counts. Start here when you don't know what footage exists — pick a
2737
+ // shelf, then re-run with --category <slug> to pull its clips.
2738
+ if (parsed.values.categories) {
2739
+ const result = await apiRequest({
2740
+ method: "GET",
2741
+ host: ctx.host,
2742
+ path: "/api/v1/public-raws/categories",
2743
+ auth: ctx.auth
2744
+ });
2745
+ assertApiOk(result, "public-raws categories");
2746
+ emitResult(result, ctx.json, [["Browse ", `${ctx.host}/discover/raws/categories`]]);
2747
+ return;
2748
+ }
2640
2749
  const bookmark = parsed.values.bookmark?.trim();
2641
2750
  if (bookmark) {
2642
2751
  const result = await apiRequest({
@@ -3484,6 +3593,9 @@ async function runRenderCommand(argv) {
3484
3593
  }
3485
3594
  console.log(`${GREEN}Rendered locally in ${(result.durationMs / 1000).toFixed(1)}s → ${result.outputPath}${RESET}`);
3486
3595
  console.log(`${DIM}$0.00 — local Vidfarm render, no cloud charge.${RESET}`);
3596
+ // Nudge, not a gate: QA never runs automatically, but every production
3597
+ // should get a look before it's published.
3598
+ console.log(`${DIM}Recommended: ${RESET}vidfarm qa ${sourceDir ?? "<dir>"}${DIM} — social-native QA (HTML slop + font regime). Feedback only, free, instant.${RESET}`);
3487
3599
  }
3488
3600
  return;
3489
3601
  }
@@ -4152,6 +4264,74 @@ async function runGenerateCommand(argv) {
4152
4264
  console.log(`${DIM}Place it with: vidfarm place <dir> --src "${mediaUrl}" --kind ${mediaType} --at <time>|--replace <layer_key>${RESET}`);
4153
4265
  }
4154
4266
  }
4267
+ // ── Sequence: storyboard-driven AI video pipeline ────────────────────────────
4268
+ // `vidfarm sequence` is a multi-step, resumable director's workflow: brief →
4269
+ // character card → storyboard sheet → shot prompt → AI video → final cut. The
4270
+ // orchestration + disk state live in devcli/sequence.ts and the prompt grammar
4271
+ // in services/sequence-prompts.ts; this wrapper only supplies the cloud seam
4272
+ // (auth, primitive jobs, cost gating, downloads) so there is exactly one copy
4273
+ // of the REST plumbing.
4274
+ /** Submit a primitive generate job and poll it to a finished media URL. */
4275
+ async function generatePrimitiveMedia(ctx, kind, payload) {
4276
+ const tracer = `devcli-seq-${Date.now().toString(36)}`;
4277
+ const route = kind === "video" ? "/api/v1/primitives/videos/generate" : "/api/v1/primitives/images/generate";
4278
+ const submit = await apiRequest({ method: "POST", host: ctx.host, path: route, auth: ctx.auth, body: { tracer, payload } });
4279
+ assertApiOk(submit, `generate ${kind}`);
4280
+ const jobId = submit.json?.job_id;
4281
+ if (!jobId)
4282
+ throw new Error(`generate ${kind} did not return a job id.`);
4283
+ const job = await pollPrimitiveJob(ctx, jobId);
4284
+ const url = resolveJobMediaUrl(job);
4285
+ if (!url) {
4286
+ throw new Error(`generate ${kind} ${String(job?.status ?? "did not finish")} — no media URL (job ${jobId}).`);
4287
+ }
4288
+ return { url, jobId };
4289
+ }
4290
+ async function runSequenceCommand(argv) {
4291
+ // Parsed loosely: the subcommand flags live in devcli/sequence.ts, and this
4292
+ // pass only needs the shared host/auth/cost-mode options.
4293
+ const parsed = parseArgs({ args: argv, allowPositionals: true, strict: false, options: commonOptions() });
4294
+ const ctx = commonContext(parsed.values);
4295
+ const { runSequenceCommand: run } = await import("./devcli/sequence.js");
4296
+ await run(argv, {
4297
+ json: ctx.json,
4298
+ yes: ctx.yes,
4299
+ guardBilled: (input) => guardBilled(ctx, input),
4300
+ resolveRefs: (values) => resolveReferenceUrls(ctx, values),
4301
+ async generateImage(input) {
4302
+ const payload = { prompt: input.prompt };
4303
+ if (input.aspectRatio)
4304
+ payload.aspect_ratio = input.aspectRatio;
4305
+ if (input.provider)
4306
+ payload.provider = input.provider;
4307
+ if (input.model)
4308
+ payload.model = input.model;
4309
+ if (input.refs.length)
4310
+ payload.prompt_attachments = input.refs.slice(0, 16);
4311
+ return generatePrimitiveMedia(ctx, "image", payload);
4312
+ },
4313
+ async generateVideo(input) {
4314
+ const payload = {
4315
+ prompt: input.prompt,
4316
+ duration: Math.round(input.durationSeconds)
4317
+ };
4318
+ if (input.aspectRatio)
4319
+ payload.aspect_ratio = input.aspectRatio;
4320
+ if (input.resolution)
4321
+ payload.resolution = input.resolution;
4322
+ if (input.audio)
4323
+ payload.generate_audio = true;
4324
+ if (input.provider)
4325
+ payload.provider = input.provider;
4326
+ if (input.model)
4327
+ payload.model = input.model;
4328
+ if (input.refs.length)
4329
+ payload.input_references = input.refs.slice(0, 8);
4330
+ return generatePrimitiveMedia(ctx, "video", payload);
4331
+ },
4332
+ download: (url, dest) => downloadUrlToFile(url, dest, ctx.auth.apiKey ? { "vidfarm-api-key": ctx.auth.apiKey } : undefined)
4333
+ });
4334
+ }
4155
4335
  // ── Inpaint: masked image edit ───────────────────────────────────────────────
4156
4336
  // The devcli twin of the web /tools/image page. The browser page rasterizes painted
4157
4337
  // strokes into an alpha mask (transparent = editable), composes one provider
@@ -4473,6 +4653,236 @@ async function runCreateOverlayCommand(argv) {
4473
4653
  console.log(`${DIM}Place it with: vidfarm place <dir> --src "${mediaUrl}" --kind image --at <time>|--replace <layer_key>${RESET}`);
4474
4654
  }
4475
4655
  }
4656
+ // ── avatar: talking-head presenter, keyed off its greenscreen ────────────────
4657
+ // "Create an avatar" is ALWAYS this: a generated VIDEO of a talking head with
4658
+ // lip-synced spoken audio, shot on a flat key-color plate, then chroma-keyed so
4659
+ // the presenter composites over whatever is behind them. The devcli twin of the
4660
+ // talking_avatar primitive.
4661
+ //
4662
+ // The generation leg is always the cloud primitive (it rides the caller's own
4663
+ // saved video-provider keys — BYOK, same as the web app). The KEYING leg is
4664
+ // where local/cloud differ:
4665
+ // default → the same job keys it in the cloud → browser-ready transparent
4666
+ // WebM (VP9 alpha, audio preserved) you can drop straight in.
4667
+ // --local → the job returns the raw greenscreen MP4 and bundled ffmpeg keys
4668
+ // it on your machine for free (ProRes .mov fallback if this
4669
+ // ffmpeg build can't write VP9 alpha).
4670
+ // --keep-greenscreen → stop at the plate; don't key at all.
4671
+ async function runAvatarCommand(argv) {
4672
+ const parsed = parseArgs({
4673
+ args: argv,
4674
+ allowPositionals: true,
4675
+ options: {
4676
+ ...commonOptions(),
4677
+ prompt: { type: "string" },
4678
+ // What the avatar says out loud. `--script` is the alias.
4679
+ say: { type: "string" },
4680
+ script: { type: "string" },
4681
+ voice: { type: "string" },
4682
+ // Likeness references: headshot, character sprite card, brand photo.
4683
+ ref: { type: "string", multiple: true },
4684
+ duration: { type: "string" },
4685
+ resolution: { type: "string" },
4686
+ "aspect-ratio": { type: "string" },
4687
+ provider: { type: "string" },
4688
+ model: { type: "string" },
4689
+ // Plate + keying knobs (mirror remove-greenscreen).
4690
+ preset: { type: "string" },
4691
+ "key-color": { type: "string" },
4692
+ tolerance: { type: "string" },
4693
+ softness: { type: "string" },
4694
+ "no-despill": { type: "boolean", default: false },
4695
+ local: { type: "boolean", default: false },
4696
+ "keep-greenscreen": { type: "boolean", default: false },
4697
+ out: { type: "string" },
4698
+ "no-wait": { type: "boolean", default: false },
4699
+ tracer: { type: "string" },
4700
+ // Fused placement onto a local composition.html (same as `generate`).
4701
+ place: { type: "string" },
4702
+ at: { type: "string" },
4703
+ replace: { type: "string" },
4704
+ track: { type: "string" },
4705
+ transition: { type: "string" },
4706
+ "transition-duration": { type: "string" },
4707
+ "transition-out": { type: "string" },
4708
+ "transition-out-duration": { type: "string" },
4709
+ "layer-key": { type: "string" }
4710
+ }
4711
+ });
4712
+ const ctx = commonContext(parsed.values);
4713
+ guardBilled(ctx, {
4714
+ label: "AI talking-avatar video (generation with audio + chroma key)",
4715
+ estimate: "expensive, ~$0.50–$1.50 (AI video)",
4716
+ 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)'
4717
+ });
4718
+ const prompt = (parsed.values.prompt ?? parsed.positionals.join(" ")).trim();
4719
+ if (!prompt) {
4720
+ 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).');
4721
+ }
4722
+ const script = (parsed.values.say ?? parsed.values.script ?? "").trim();
4723
+ const refs = await resolveReferenceUrls(ctx, parsed.values.ref);
4724
+ const presetRaw = parsed.values.preset?.trim().toLowerCase();
4725
+ if (presetRaw && !GREENSCREEN_PRESETS[presetRaw]) {
4726
+ throw new Error(`Unknown --preset "${presetRaw}". Choose one of: ${Object.keys(GREENSCREEN_PRESETS).join(", ")}, or pass --key-color <hex>.`);
4727
+ }
4728
+ // --local keys on this machine, so ask the cloud job for the raw plate.
4729
+ const keyLocally = Boolean(parsed.values.local);
4730
+ const keepPlate = Boolean(parsed.values["keep-greenscreen"]);
4731
+ const payload = { prompt };
4732
+ if (script)
4733
+ payload.script = script;
4734
+ if (parsed.values.voice)
4735
+ payload.voice = parsed.values.voice;
4736
+ if (refs.length)
4737
+ payload.input_references = refs.slice(0, 8);
4738
+ if (parsed.values.duration)
4739
+ payload.duration = Math.round(Number(parsed.values.duration));
4740
+ if (parsed.values.resolution)
4741
+ payload.resolution = parsed.values.resolution;
4742
+ if (parsed.values["aspect-ratio"])
4743
+ payload.aspect_ratio = parsed.values["aspect-ratio"];
4744
+ if (parsed.values.provider)
4745
+ payload.provider = parsed.values.provider;
4746
+ if (parsed.values.model)
4747
+ payload.model = parsed.values.model;
4748
+ if (presetRaw)
4749
+ payload.preset = presetRaw;
4750
+ if (parsed.values["key-color"])
4751
+ payload.key_color = parsed.values["key-color"];
4752
+ if (parsed.values.tolerance)
4753
+ payload.tolerance = Number(parsed.values.tolerance);
4754
+ if (parsed.values.softness)
4755
+ payload.softness = Number(parsed.values.softness);
4756
+ if (parsed.values["no-despill"])
4757
+ payload.despill = false;
4758
+ if (keyLocally || keepPlate)
4759
+ payload.keep_greenscreen = true;
4760
+ const tracer = parsed.values.tracer ?? `devcli-avatar-${Date.now().toString(36)}`;
4761
+ const submit = await apiRequest({ method: "POST", host: ctx.host, path: "/api/v1/primitives/videos/create-avatar", auth: ctx.auth, body: { tracer, payload } });
4762
+ assertApiOk(submit, "avatar");
4763
+ const jobId = submit.json?.job_id;
4764
+ const wait = !parsed.values["no-wait"];
4765
+ if (!wait || !jobId) {
4766
+ if (!ctx.json && jobId)
4767
+ 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}`);
4768
+ emitResult(submit, ctx.json);
4769
+ return;
4770
+ }
4771
+ if (!ctx.json) {
4772
+ console.log(`${DIM}Generating talking avatar${script ? ` saying "${script.slice(0, 60)}${script.length > 60 ? "…" : ""}"` : ""} on a greenscreen plate (${jobId})… polling every 5s.${RESET}`);
4773
+ }
4774
+ const job = await pollPrimitiveJob(ctx, jobId);
4775
+ const mediaUrl = resolveJobMediaUrl(job);
4776
+ const status = String(job?.status ?? "");
4777
+ if (!mediaUrl) {
4778
+ if (ctx.json) {
4779
+ printJson({ ok: false, job_id: jobId, status, job });
4780
+ }
4781
+ else {
4782
+ console.log(`${RED}Avatar ${status || "did not finish"} — no media URL. Check your video-provider key and wallet.${RESET}`);
4783
+ printJson(job);
4784
+ }
4785
+ process.exitCode = 1;
4786
+ return;
4787
+ }
4788
+ // Local keying leg: pull the plate down and run the free ffmpeg keyer on it.
4789
+ let localOut = null;
4790
+ if (keyLocally && !keepPlate) {
4791
+ const downloadDir = mkdtempSync(path.join(tmpdir(), "vidfarm-avatar-"));
4792
+ const platePath = path.join(downloadDir, "avatar-greenscreen.mp4");
4793
+ try {
4794
+ if (!ctx.json)
4795
+ console.log(`${DIM}Downloading the greenscreen plate…${RESET}`);
4796
+ await downloadUrlToFile(mediaUrl, platePath);
4797
+ const preset = presetRaw ? GREENSCREEN_PRESETS[presetRaw] : undefined;
4798
+ const outPath = parsed.values.out
4799
+ ? path.resolve(process.cwd(), String(parsed.values.out))
4800
+ : path.resolve(process.cwd(), "avatar.webm");
4801
+ mkdirSync(path.dirname(outPath), { recursive: true });
4802
+ if (!ctx.json)
4803
+ console.log(`${DIM}Keying the avatar off its greenscreen locally with ffmpeg (free)…${RESET}`);
4804
+ localOut = await removeGreenscreenLocal({
4805
+ sourcePath: platePath,
4806
+ mediaType: "video",
4807
+ outputPath: outPath,
4808
+ keyColor: parsed.values["key-color"] ?? preset?.key_color,
4809
+ tolerance: parsed.values.tolerance !== undefined ? Number(parsed.values.tolerance) : preset?.tolerance,
4810
+ softness: parsed.values.softness !== undefined ? Number(parsed.values.softness) : preset?.softness,
4811
+ despill: !parsed.values["no-despill"]
4812
+ });
4813
+ }
4814
+ finally {
4815
+ rmSync(downloadDir, { recursive: true, force: true });
4816
+ }
4817
+ }
4818
+ // Whatever the caller ends up placing/saving: the local cut-out if we keyed
4819
+ // here, otherwise the cloud result (transparent WebM, or the plate with
4820
+ // --keep-greenscreen).
4821
+ const finalSrc = localOut?.outputPath ?? mediaUrl;
4822
+ let outPath = localOut?.outputPath ?? null;
4823
+ if (!localOut && parsed.values.out) {
4824
+ outPath = path.resolve(process.cwd(), String(parsed.values.out));
4825
+ await downloadUrlToFile(mediaUrl, outPath);
4826
+ }
4827
+ let placement = null;
4828
+ if (parsed.values.place) {
4829
+ const htmlPath = resolveCompositionHtmlPath(String(parsed.values.place));
4830
+ placement = placeMediaOnDisk({
4831
+ htmlPath,
4832
+ src: finalSrc,
4833
+ kind: "video",
4834
+ at: parsed.values.at ? parseTimeToSeconds(String(parsed.values.at)) : undefined,
4835
+ replace: parsed.values.replace,
4836
+ track: parsed.values.track ? Number(parsed.values.track) : undefined,
4837
+ transition: parsed.values.transition,
4838
+ transitionDuration: parsed.values["transition-duration"] ? Number(parsed.values["transition-duration"]) : undefined,
4839
+ transitionOut: parsed.values["transition-out"],
4840
+ transitionOutDuration: parsed.values["transition-out-duration"] ? Number(parsed.values["transition-out-duration"]) : undefined,
4841
+ layerKey: parsed.values["layer-key"]
4842
+ });
4843
+ if (!ctx.json)
4844
+ console.log(`${GREEN}Placed as ${placement.layerKey} in ${htmlPath}. Run \`vidfarm publish --dir ${path.dirname(htmlPath)}\` (or it live-morphs under \`serve\`).${RESET}`);
4845
+ }
4846
+ const transparent = !keepPlate;
4847
+ if (ctx.json) {
4848
+ printJson({
4849
+ ok: true,
4850
+ job_id: jobId,
4851
+ media_url: mediaUrl,
4852
+ transparent,
4853
+ keyed: keepPlate ? "none" : keyLocally ? "local" : "cloud",
4854
+ format: localOut?.format ?? (keepPlate ? "mp4" : "webm"),
4855
+ out: outPath,
4856
+ placement,
4857
+ script: script || null,
4858
+ greenscreen_source_url: job?.result?.greenscreen_source_url ?? null
4859
+ });
4860
+ return;
4861
+ }
4862
+ if (keepPlate) {
4863
+ console.log(`${GREEN}Avatar greenscreen plate ready:${RESET} ${mediaUrl}`);
4864
+ console.log(`${DIM}Key it whenever you like: vidfarm remove-greenscreen "${mediaUrl}" --local${RESET}`);
4865
+ }
4866
+ else if (localOut) {
4867
+ const label = localOut.format === "webm" ? "Transparent talking avatar (WebM)" : "Transparent talking avatar (ProRes 4444 .mov)";
4868
+ console.log(`${GREEN}${label} ready:${RESET} ${localOut.outputPath}`);
4869
+ if (!localOut.webmAlpha) {
4870
+ 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}`);
4871
+ }
4872
+ console.log(`${DIM}Greenscreen plate (re-keyable): ${mediaUrl}${RESET}`);
4873
+ }
4874
+ else {
4875
+ console.log(`${GREEN}Transparent talking avatar ready:${RESET} ${mediaUrl}`);
4876
+ if (outPath)
4877
+ console.log(`${DIM}Saved to ${outPath}${RESET}`);
4878
+ const plate = job?.result?.greenscreen_source_url;
4879
+ if (plate)
4880
+ console.log(`${DIM}Greenscreen plate (re-key at a different tolerance for free): ${plate}${RESET}`);
4881
+ }
4882
+ if (!placement) {
4883
+ 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}`);
4884
+ }
4885
+ }
4476
4886
  // ── Green-screen (chroma-key) background removal ──────────────────────────────
4477
4887
  // The devcli twin of the remove-greenscreen primitive: key out a flat, solid
4478
4888
  // background from an IMAGE (→ transparent PNG/WebP) OR a VIDEO (→ transparent
@@ -5156,7 +5566,7 @@ async function runTtsCommand(argv) {
5156
5566
  /** Set when the cost-mode default ran Kokoro and it failed — so the keyless
5157
5567
  * path below doesn't pointlessly retry the same engine. */
5158
5568
  let kokoroError = null;
5159
- if (!engine && !wantCloud && costMode.mode !== "pure-ai" && !premium) {
5569
+ if (!engine && !wantCloud && costMode.mode !== "rich-ai" && costMode.mode !== "pure-videogen" && !premium) {
5160
5570
  try {
5161
5571
  await runLocalKokoroTts({
5162
5572
  text,
@@ -6838,15 +7248,16 @@ async function runWhoamiCommand(argv) {
6838
7248
  console.log(`${GREEN}${BOLD}${who}${RESET} → ${ctx.host}`);
6839
7249
  console.log(` plan ${customer.isPaidPlan ? `${GREEN}paid${plan}${RESET}` : `${DIM}free${plan}${RESET}`}`);
6840
7250
  console.log(` credential ${DIM}${stored ? "persisted login (vidfarm login)" : "env/flag key — not persisted (run vidfarm login to persist)"}${RESET}`);
6841
- console.log(` ${costModeSummaryLine(ctx.costMode).replace("cost mode: ", "cost mode ")}${!ctx.costMode.isSet ? ` ${DIM}(set with: vidfarm cost-mode <minimize|hybrid|rich-ai>)${RESET}` : ""}`);
7251
+ 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}` : ""}`);
6842
7252
  return;
6843
7253
  }
6844
7254
  emitResult(result, ctx.json);
6845
7255
  }
6846
- // `vidfarm cost-mode [minimize|hybrid|rich-ai]` — show or set the money-saving
6847
- // preference every billed command respects. No arg = show current + explain the
6848
- // three simply so an agent can relay them to the user (and be reminded to ask
6849
- // about saving the choice into whatever agent memory it has). `--clear` forgets it.
7256
+ // `vidfarm cost-mode [minimize|hybrid|rich-ai|pure-videogen]` — show or set the
7257
+ // money-saving preference every billed command respects. No arg = show current +
7258
+ // explain the modes simply so an agent can relay them to the user (and be
7259
+ // reminded to ask about saving the choice into whatever agent memory it has).
7260
+ // `--clear` forgets it.
6850
7261
  async function runCostModeCommand(argv) {
6851
7262
  const parsed = parseArgs({
6852
7263
  args: argv,
@@ -6889,7 +7300,7 @@ async function runCostModeCommand(argv) {
6889
7300
  console.log("");
6890
7301
  console.log(costModeExplainer());
6891
7302
  console.log("");
6892
- console.log(`${DIM}Set it: ${BOLD}vidfarm cost-mode <minimize|hybrid|rich-ai>${RESET}`);
7303
+ console.log(`${DIM}Set it: ${BOLD}vidfarm cost-mode <${COST_MODE_DISPLAY_LIST.join("|")}>${RESET}`);
6893
7304
  console.log(`${DIM}Forget it: vidfarm cost-mode --clear · override per-run: --cost-mode <m> or VIDFARM_COST_MODE.${RESET}`);
6894
7305
  if (!resolved.isSet) {
6895
7306
  console.log(`${DIM}Nothing saved yet — ask the user which one they want before spending AI credits.${RESET}`);
@@ -8225,6 +8636,54 @@ async function runLintCommand(argv) {
8225
8636
  if (!result.ok)
8226
8637
  process.exitCode = 1;
8227
8638
  }
8639
+ // `vidfarm qa <dir|composition.html>` — the social-native QA pass. Compositions
8640
+ // are authored in HTML, so an agent's web-page instincts leak in as landing-page
8641
+ // furniture (gradient CTA capsules, benefit chip rows, frosted cards) that never
8642
+ // appears in a real TikTok. This is a BLOCKLIST of those specific patterns plus
8643
+ // the font regime — deliberately NOT an allowlist, so weird/ugly/hand-made
8644
+ // compositions pass untouched and nothing pushes every video toward one style.
8645
+ //
8646
+ // FEEDBACK, NOT A GATE: exit code stays 0 even when slop is found, unless the
8647
+ // caller opts into --strict. Nothing in the render path calls this. It is a
8648
+ // devcli-only capability (no cloud/REST twin) — heavily recommended after every
8649
+ // production, never mandatory and never automatic.
8650
+ async function runQaCommand(argv) {
8651
+ const parsed = parseArgs({
8652
+ args: argv,
8653
+ allowPositionals: true,
8654
+ options: {
8655
+ json: { type: "boolean", default: false },
8656
+ strict: { type: "boolean", default: false }
8657
+ }
8658
+ });
8659
+ const target = parsed.positionals[0];
8660
+ if (!target)
8661
+ throw new Error("qa requires a composition path: `vidfarm qa <dir-or-composition.html> [--json] [--strict]`.");
8662
+ const htmlPath = resolveCompositionHtmlPath(target);
8663
+ const report = qaCompositionHtml(readFileSync(htmlPath, "utf8"));
8664
+ if (parsed.values.json) {
8665
+ printJson(report);
8666
+ if (!report.ok && parsed.values.strict)
8667
+ process.exitCode = 1;
8668
+ return;
8669
+ }
8670
+ const body = formatQaReport(report, { red: RED, yellow: YELLOW, green: GREEN, dim: DIM, reset: RESET });
8671
+ if (body)
8672
+ console.log(body);
8673
+ const scope = `${report.checked.layers} layer(s), ${report.checked.text_layers} text layer(s)${report.checked.canvas ? `, ${report.checked.canvas}` : ""}`;
8674
+ if (report.verdict === "clean") {
8675
+ console.log(`${GREEN}✓ no HTML slop found${RESET} ${DIM}— ${scope} — ${htmlPath}${RESET}`);
8676
+ }
8677
+ else if (report.verdict === "warnings") {
8678
+ console.log(`${YELLOW}! ${report.warnings.length} warning(s), no slop${RESET} ${DIM}— ${scope} — ${htmlPath}${RESET}`);
8679
+ }
8680
+ else {
8681
+ console.log(`${RED}✗ ${report.errors.length} slop issue(s)${RESET}, ${report.warnings.length} warning(s) ${DIM}— ${scope} — ${htmlPath}${RESET}`);
8682
+ console.log(`${DIM}This is feedback, not a gate — fix what's real and re-run. A deliberate stylistic choice that trips a rule is fine to ignore.${RESET}`);
8683
+ }
8684
+ if (!report.ok && parsed.values.strict)
8685
+ process.exitCode = 1;
8686
+ }
8228
8687
  // `vidfarm stills <dir|composition.html>` — render PNG stills of the CURRENT
8229
8688
  // composition in-process (the agent's visual self-verification loop).
8230
8689
  async function runStillsCommand(argv) {