@officexapp/vidfarm-devcli 0.21.27 → 0.21.29

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.
Files changed (31) hide show
  1. package/.agents/skills/editor-capabilities/SKILL.md +52 -0
  2. package/.agents/skills/vidfarm/SKILL.md +58 -5
  3. package/.agents/skills/vidfarm/recipes/bulk-scripting-with-a-regime.md +65 -0
  4. package/.agents/skills/vidfarm/recipes/cutout-graphics-for-explainers.md +78 -7
  5. package/.agents/skills/vidfarm/recipes/local-edit-render-approve.md +4 -3
  6. package/.agents/skills/vidfarm/recipes/retheme-template.md +1 -1
  7. package/.agents/skills/vidfarm/references/assets-and-sourcing.md +3 -3
  8. package/.agents/skills/vidfarm/references/automation-and-local-dev.md +92 -1
  9. package/.agents/skills/vidfarm/references/editor-workflows.md +135 -6
  10. package/.agents/skills/vidfarm/references/hooks-and-virality.md +237 -0
  11. package/.agents/skills/vidfarm/references/onboarding.md +5 -5
  12. package/.agents/skills/vidfarm/references/primitives.md +5 -1
  13. package/.agents/skills/vidfarm/regimes/README.md +77 -0
  14. package/.agents/skills/vidfarm/regimes/explainer.QA_REGIME.md +82 -0
  15. package/.agents/skills/vidfarm/regimes/hooks.QA_REGIME.md +117 -0
  16. package/.agents/skills/vidfarm/regimes/product-demo.QA_REGIME.md +92 -0
  17. package/.agents/skills/vidfarm/regimes/short-form.QA_REGIME.md +163 -0
  18. package/.agents/skills/vidfarm/regimes/ugc-testimonial.QA_REGIME.md +82 -0
  19. package/SKILL.director.md +685 -32
  20. package/SKILL.md +22 -3
  21. package/demo/dist/app.js +103 -103
  22. package/dist/src/cli.js +987 -11
  23. package/dist/src/devcli/handoff.js +162 -0
  24. package/dist/src/devcli/interaction-mode.js +154 -0
  25. package/dist/src/devcli/qa-check.js +593 -0
  26. package/dist/src/devcli/qa-regime.js +396 -0
  27. package/dist/src/devcli/sticker-pack.js +396 -0
  28. package/dist/src/devcli/storyboard.js +243 -0
  29. package/package.json +8 -1
  30. package/public/serve-shells/tools-image.html +378 -265
  31. package/public/serve-shells/tools-video.html +760 -167
package/dist/src/cli.js CHANGED
@@ -19,7 +19,10 @@ 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 { extractCompositionFacts, formatQaReport, qaCompositionHtml } from "./devcli/qa-check.js";
23
+ import { discoverRegime, formatRegimeReport, listBuiltinRegimes, loadAndEvaluateRegime, mergeRegimeIntoReport, parseRegime, resolveRegimePath } from "./devcli/qa-regime.js";
22
24
  import { removeGreenscreenLocal, localGreenscreenAvailable, defaultGreenscreenOutPath, GREENSCREEN_PRESETS, trimTransparentBorders, cropImageRegion } from "./devcli/greenscreen-local.js";
25
+ import { segmentAlphaComponents, encodeTransparentGif, encodeTransparentAnimatedGif, pickPlateColor, detectPlateColor } from "./devcli/sticker-pack.js";
23
26
  import { runDoctorCommand } from "./devcli/doctor.js";
24
27
  import { findFreePort } from "./devcli/port-utils.js";
25
28
  import { scanLocalServers } from "./devcli/process-scan.js";
@@ -29,6 +32,9 @@ import { resolveLocalDataDir, localBackendAvailable, LocalModeUnavailableError,
29
32
  import { startLocalFrontendServer, serveShellsPresent } from "./devcli/local-frontend-server.js";
30
33
  import { readStoredAuth, writeStoredAuth, clearStoredAuth, hostsMatch } from "./devcli/auth-store.js";
31
34
  import { CostModeBlockedError, assertBilledAllowed, clearStoredCostMode, costModeExplainer, costModeSummaryLine, normalizeCostMode, resolveCostMode, writeStoredCostMode, COST_MODE_BLURB, COST_MODE_DISPLAY_LIST, costModeDisplayName } from "./devcli/cost-mode.js";
35
+ import { INTERACTION_MODES, INTERACTION_MODE_BLURB, clearStoredInteractionMode, interactionModeExplainer, interactionModeSummaryLine, interactiveOfferLine, normalizeInteractionMode, resolveInteractionMode, writeStoredInteractionMode } from "./devcli/interaction-mode.js";
36
+ import { buildImageHandoff, buildRawsHandoff, formatHandoff } from "./devcli/handoff.js";
37
+ import { STORYBOARD_FILENAME, readStoryboard, renderStoryboardScaffold, storyboardStage } from "./devcli/storyboard.js";
32
38
  // vidfarm-devcli — command-line bridge for the Vidfarm video studio. The
33
39
  // `serve` command boots the FULL editor locally (single origin, disk-backed
34
40
  // records + storage) so power users edit compositions on disk while a browser
@@ -89,6 +95,38 @@ Account (persisted login — points the CLI + \`serve\` at cloud prod):
89
95
  --clear forgets it · --note "<why>" annotates the save.
90
96
  Override per-run with --cost-mode <m> / VIDFARM_COST_MODE;
91
97
  in "minimize" a billed op is refused until you add --yes.
98
+ mode [interactive|autonomous] Show or set whether the HUMAN is in the loop —
99
+ a SECOND axis, independent of cost mode (every cost
100
+ mode runs either way; quality usually goes UP in
101
+ interactive, most of all in minimize).
102
+ interactive = at checkpoints the user gets a copy-paste
103
+ image prompt for a FREE web generator (→ sticker packs)
104
+ or a keyword list to hand-source raw clips.
105
+ autonomous = agent finishes solo within the budget.
106
+ No arg = show current + explain. --clear forgets it ·
107
+ per-run: --interactive / --autonomous / --interaction-mode.
108
+ (aliases: interaction-mode, interaction)
109
+ handoff <image|raws> Mint the copy-paste brief an INTERACTIVE user works from.
110
+ image --theme "<what>" --items "a,b,c" [--style …] [--key-color #00FF00]
111
+ A free-web-image-gen brief. Defaults to a STICKER PACK:
112
+ one prompt → one greenscreen sheet → I split it with
113
+ vidfarm sticker-pack for $0. --single for one subject.
114
+ raws --keywords "a,b" [--platforms tiktok,youtube] [--count N] [--purpose "…"]
115
+ A manual clip-sourcing brief: what to search, how to
116
+ download (a downloader SEARCH, not a rotting link), and
117
+ the vidfarm clipper / raws scan follow-up. Try browser control
118
+ or "vidfarm raws scan" first.
119
+ storyboard [dir] The PLAN pass: read the project's STORYBOARD.md (ordered
120
+ frames + duration/status/src/scene/voiceover, plus SCRIPT.md).
121
+ The editor renders this file in its Storyboard view — a
122
+ contact sheet with per-frame comments — so it's the cheapest
123
+ place to get a director's approval before building.
124
+ (alias: plan · not to be confused with "vidfarm sequence",
125
+ which GENERATES storyboard images)
126
+ --init Scaffold a STORYBOARD.md (--force overwrites)
127
+ --frames "Title|scene,…" Frames to scaffold · --count <n> for N blank frames
128
+ --title/--format/--message/--arc/--audience Frontmatter for --init
129
+ --json The parsed manifest (frames, statuses, warnings)
92
130
 
93
131
  Local editor loop:
94
132
  serve [template_id] [opts] Boot the editor locally. On a full source checkout the
@@ -282,6 +320,10 @@ Generate AI media and drop it on the timeline (for local coding agents):
282
320
  --tolerance <0..1> Key radius (default 0.3); --softness <0..1> edge feather (0.1)
283
321
  --no-despill Skip edge color-fringe suppression
284
322
  --output-format <f> Image only: png|webp
323
+ --gif Write a TRANSPARENT GIF instead (animated for a clip)
324
+ — local only, 1-bit alpha, for GIF-only sticker surfaces
325
+ --gif-fps/--gif-width Animated-GIF frame rate (15) / max width, in px
326
+ --gif-alpha <0..255> Where GIF's 1-bit alpha cuts the edge (default 128)
285
327
  --local Key on your machine with bundled ffmpeg for FREE
286
328
  (no wallet, no account; cloud-parity chroma key) — DEFAULT
287
329
  --cloud Force the billed cloud primitive
@@ -293,13 +335,48 @@ Generate AI media and drop it on the timeline (for local coding agents):
293
335
  (alias: sticker)
294
336
  --generate "<prompt>" AI-generate the graphic first, on a matching chroma plate
295
337
  (billed image primitive) — then key + trim in one shot
296
- --preset/--key-color Plate color (same knobs as remove-greenscreen)
338
+ --preset/--key-color Plate color (same knobs as remove-greenscreen). Auto by
339
+ default: --generate moves the plate off any hue the subject
340
+ mentions; an existing file's plate is read from its corners.
341
+ --no-auto-key forces plain green.
297
342
  --pad <px> Keep N px of transparent breathing room around the cutout
298
343
  --alpha-threshold <n> Alpha 0..255 below which edges count as empty (default 8)
299
344
  --no-trim Key only; don't shrink to the bounding box
300
345
  --output-format png|webp Cutout format (default png)
301
346
  --keep-plate <file> Also save the pre-key generated plate (with --generate)
302
347
  --out <file> Write the cutout here (default <source>.cutout.png)
348
+ sticker-pack [sheet|url] Make a whole STICKER PACK from ONE greenscreen sheet:
349
+ key the flat plate once, auto-find each item in the
350
+ alpha channel, and cut every one into its own snug
351
+ transparent file (+ stickers.json). One image job for
352
+ the set = one art style, ~1/N the cost of N cutouts.
353
+ Items may be any size, from an icon to a full-frame
354
+ landscape. Local, free, ffmpeg-only. Image-only.
355
+ (aliases: stickers, sticker-sheet)
356
+ --generate "<theme>" AI-generate the sheet first (ONE billed image job for
357
+ the WHOLE pack) on a spaced grid over a chroma plate
358
+ --items "a,b,c" The exact items to generate — also names the outputs
359
+ (sticker-01-a.png, sticker-02-b.png, …)
360
+ --count <n> How many items to generate when --items is omitted (6)
361
+ --preset/--key-color Plate color. SMART BY DEFAULT: generating picks a plate the
362
+ art can't collide with (green art → magenta plate, etc.);
363
+ splitting an existing sheet detects the plate from its
364
+ corners. --no-auto-key forces plain green. (--flat too)
365
+ --dry-run Just list the detected crop boxes; write nothing
366
+ --gap <pct> Merge islands closer than this % of the sheet's short
367
+ side into ONE item (default 1.2) — raise it to rejoin a
368
+ split item, lower it to separate two that got merged
369
+ --min-area <pct> Drop islands smaller than this % of the sheet (0.15);
370
+ there is NO maximum — a landscape can be one sticker
371
+ --max-items <n> Cap how many items are cut (default 64)
372
+ --pad <px> Keep N px of transparent breathing room per sticker
373
+ --alpha-threshold <n> Alpha 0..255 below which edges count as empty (default 8)
374
+ --output-format <f> png (default) | webp | gif (1-bit alpha, GIF-only surfaces)
375
+ --gif-alpha <0..255> Where GIF's 1-bit alpha cuts the edge (default 128)
376
+ --out-dir <dir> Where the pack lands (default ./stickers)
377
+ --prefix <name> Filename prefix (default "sticker")
378
+ --keep-plate <file> Also save the pre-key generated sheet
379
+ --keep-sheet <file> Also save the keyed, uncut transparent sheet
303
380
  mask <image|url> Lift an illustration OUT of an image you already have
304
381
  (an infographic, poster, marketing image, screenshot)
305
382
  → snug transparent PNG you can reuse as a FREE explainer
@@ -595,6 +672,29 @@ Local media engines & toolchain (all local, free, no account — no cloud key ne
595
672
  lint <dir|composition.html> Validate a composition on disk (same (local)
596
673
  checks the /editor chat preflights); prints
597
674
  errors/warnings, exit 1 on errors [--json]
675
+ qa <dir|composition.html> Social-native QA pass — HIGHLY RECOMMENDED (local, devcli-only)
676
+ on every video you produce. Blocklists "HTML
677
+ slop" (CTA buttons, benefit chip rows, frosted
678
+ cards, gradient text, web-page classes/fonts)
679
+ and checks the caption font regime + safe zone.
680
+ Feedback only: exits 0 even when it finds slop,
681
+ and it never runs automatically. Blocklist, not
682
+ allowlist — unusual styles pass untouched
683
+ --regime <name|path> Grade against a QA_REGIME.md too (repeatable —
684
+ they stack). A built-in name or ANY file path.
685
+ <dir>/QA_REGIME.md is picked up automatically
686
+ --no-regime Skip QA_REGIME.md auto-discovery
687
+ --json Machine-readable findings (rule/severity/fix)
688
+ --strict Also exit 1 when slop is found (for CI)
689
+ regime list|show|init QA_REGIME.md — the per-style quality contract (local, devcli-only)
690
+ you own, for bulk/scripting mode. Built-ins:
691
+ short-form, hooks, ugc-testimonial, explainer,
692
+ product-demo
693
+ regime list [--json] What ships with the CLI
694
+ regime show <name|path> Print one
695
+ regime init <name> [--out <path>] Copy it next to your work, then EDIT it
696
+ (default ./QA_REGIME.md; --dir <d> to
697
+ write <d>/QA_REGIME.md)
598
698
  stills <dir|composition.html> Render PNG stills of the CURRENT (local in-process render)
599
699
  composition — the visual self-check loop
600
700
  ("did my edit look right") without a full render
@@ -915,7 +1015,6 @@ async function main() {
915
1015
  await runGenerateCommand(rest);
916
1016
  return;
917
1017
  case "sequence":
918
- case "storyboard":
919
1018
  case "previz":
920
1019
  await runSequenceCommand(rest);
921
1020
  return;
@@ -941,6 +1040,12 @@ async function main() {
941
1040
  case "sticker":
942
1041
  await runCutoutCommand(rest);
943
1042
  return;
1043
+ case "sticker-pack":
1044
+ case "stickerpack":
1045
+ case "stickers":
1046
+ case "sticker-sheet":
1047
+ await runStickerPackCommand(rest);
1048
+ return;
944
1049
  case "mask":
945
1050
  case "isolate":
946
1051
  case "extract":
@@ -1035,6 +1140,19 @@ async function main() {
1035
1140
  case "cost":
1036
1141
  await runCostModeCommand(rest);
1037
1142
  return;
1143
+ case "mode":
1144
+ case "interaction-mode":
1145
+ case "interaction":
1146
+ await runInteractionModeCommand(rest);
1147
+ return;
1148
+ case "handoff":
1149
+ case "hand-off":
1150
+ await runHandoffCommand(rest);
1151
+ return;
1152
+ case "storyboard":
1153
+ case "plan":
1154
+ await runStoryboardCommand(rest);
1155
+ return;
1038
1156
  case "update-skill":
1039
1157
  case "skill":
1040
1158
  await runUpdateSkillCommand(rest);
@@ -1177,6 +1295,15 @@ async function main() {
1177
1295
  case "lint":
1178
1296
  await runLintCommand(rest);
1179
1297
  return;
1298
+ case "qa":
1299
+ case "qa-check":
1300
+ case "slop-check":
1301
+ await runQaCommand(rest);
1302
+ return;
1303
+ case "regime":
1304
+ case "regimes":
1305
+ await runRegimeCommand(rest);
1306
+ return;
1180
1307
  case "stills":
1181
1308
  await runStillsCommand(rest);
1182
1309
  return;
@@ -1313,6 +1440,13 @@ function commonOptions() {
1313
1440
  // env > `vidfarm cost-mode` stored value > default (hybrid). Billed commands
1314
1441
  // gate on it; `--yes`/-y confirms a billed spend when the mode is "minimize".
1315
1442
  "cost-mode": { type: "string" },
1443
+ // Is the human in the loop? interactive | autonomous — an axis INDEPENDENT
1444
+ // of cost mode (see interaction-mode.ts). Flag > VIDFARM_INTERACTION_MODE >
1445
+ // `vidfarm mode` stored value > default (autonomous, but flagged unset so
1446
+ // the agent knows to ask — interactive usually produces a better video).
1447
+ "interaction-mode": { type: "string" },
1448
+ interactive: { type: "boolean", default: false },
1449
+ autonomous: { type: "boolean", default: false },
1316
1450
  yes: { type: "boolean", default: false, short: "y" },
1317
1451
  home: { type: "string" }
1318
1452
  };
@@ -1354,12 +1488,22 @@ function commonContext(values) {
1354
1488
  json: Boolean(values.json),
1355
1489
  target: resolveTarget(values),
1356
1490
  costMode: resolveCostMode(values),
1491
+ interaction: resolveInteractionMode(values),
1357
1492
  yes: Boolean(values.yes),
1358
1493
  home: values.home
1359
1494
  };
1360
1495
  }
1361
- /** Gate a billed cloud/AI op against the run's cost mode (see cost-mode.ts). */
1496
+ /** Gate a billed cloud/AI op against the run's cost mode (see cost-mode.ts).
1497
+ * `interactiveAlternative` names the free HUMAN path (a copy-paste prompt the
1498
+ * user runs in a web tool) — surfaced when the spend is blocked or when no
1499
+ * interaction preference has been recorded yet, because in `minimize` that
1500
+ * hand-off is the difference between custom art and none at all. */
1362
1501
  function guardBilled(ctx, input) {
1502
+ const interactiveNote = input.interactiveAlternative
1503
+ ? (ctx.interaction.mode === "interactive"
1504
+ ? `\nYou're in INTERACTIVE mode — prefer the free hand-off: ${input.interactiveAlternative}`
1505
+ : `\nOr offer the user interactive mode (free, usually better): ${input.interactiveAlternative} — \`vidfarm mode interactive\`.`)
1506
+ : "";
1363
1507
  assertBilledAllowed({
1364
1508
  resolved: ctx.costMode,
1365
1509
  yes: ctx.yes,
@@ -1367,7 +1511,14 @@ function guardBilled(ctx, input) {
1367
1511
  label: input.label,
1368
1512
  estimate: input.estimate,
1369
1513
  freeAlternative: input.freeAlternative
1514
+ ? `${input.freeAlternative}${interactiveNote}`
1515
+ : (interactiveNote ? interactiveNote.trimStart() : undefined)
1370
1516
  });
1517
+ if (!ctx.json && input.interactiveAlternative) {
1518
+ const offer = interactiveOfferLine({ interaction: ctx.interaction, about: input.label });
1519
+ if (offer)
1520
+ console.error(offer);
1521
+ }
1371
1522
  }
1372
1523
  /**
1373
1524
  * One dispatch seam for dual local/cloud REST calls. `local` runs the request
@@ -2156,6 +2307,11 @@ Rules:
2156
2307
  - When swapping visuals, match both the literal scene DNA and the narrative purpose of the beat.
2157
2308
  - 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.
2158
2309
  - 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.
2310
+ - 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).
2311
+ - STRUCTURE BEFORE POLISH — THE FOUR CHARGES, WRITTEN BEFORE YOU TOUCH THE TIMELINE. Most agent-made videos fail on structure, not polish, because the timeline is the fun part so it gets built first and the words get retrofitted. Invert it: (1) HOOK — write the opening line as text first: a complete clause (subject + verb), no jargon, naming a SITUATION ("I've quit six businesses") not a label ("anonymity"); it goes on screen at start:0, because caption chunk 1 is read before any audio and muted autoplay is the default. Banned openings: throat-clearing ("so I was thinking", "here's the thing"), a logo, a title card, a fade from black, context before the claim. (2) LOOP — one open question by 0:10, said ON SCREEN, closing INSIDE this video (state the timestamp it closes at; if you can't, there is no loop), and the withheld answer must be one the viewer CANNOT supply themselves — a formally-correct loop with a guessable answer passes every mechanical check and dies in the field. (3) PAYOFF — shown, not summarized, ≥5 uninterrupted seconds, landing BEFORE the final beat; the payoff is not the CTA. (4) BAIT — one ask in the final beat and in the post caption; never a DM funnel, "follow for part two", or ragebait. Then build the timeline. Re-theming a decomposed template: viral_dna already names the source's hook/retention/payoff — rebuild each charge for the new subject, never flatten the loop into a product statement. Full craft harness: the vidfarm skill's references/hooks-and-virality.md. Checkable form: \`vidfarm regime show hooks\`.
2312
+ - THE FIRST FRAME IS THE THUMBNAIL. Frame 0 is one frame of ~30 in the first second, but every feed card, share link, and paused player freezes on it — more people see that frame than watch the video. It must never be black, empty, mid-fade, or mid-animation: a real visual at start:0 (\`vidfarm retime . --layer <key> --start 0\`), the hook words already on screen at t=0, and NO entrance transition on the FIRST clip (\`vidfarm transitions set . --layer <key> --in none\`; junction transitions between later clips are fine). Look at the actual pixels before you render: \`vidfarm stills . --at 0\`.
2313
+ - ONE-TIME OR BULK? Ask before you build. If the director wants volume (daily posting, N variants, hook tests), that's SCRIPTING MODE: pin this fork as the base, vary exactly ONE thing per variant, and install a QA_REGIME.md — \`vidfarm regime init short-form --out ./QA_REGIME.md\` (bases: short-form, hooks, ugc-testimonial, explainer, product-demo), then EDIT it with them. It is their own written quality standard, and it exists because nobody watches variant #37 as carefully as #1. \`vidfarm qa .\` picks up ./QA_REGIME.md automatically; \`--regime <name|path>\` adds more (they stack, and any file of theirs anywhere is valid). Its \`checks:\` are machine-settled; its \`- [ ]\` items come back for YOU to answer honestly in your report — never claim a pass on the half the CLI can't judge. When a batch teaches you something, write it back into the regime.
2314
+ - 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 first frame, the font regime, and the 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.
2159
2315
 
2160
2316
  The three paintbrushes (Vidfarm is thrift-first — do NOT spend AI credits on every scene):
2161
2317
  - 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.
@@ -3575,6 +3731,9 @@ async function runRenderCommand(argv) {
3575
3731
  }
3576
3732
  console.log(`${GREEN}Rendered locally in ${(result.durationMs / 1000).toFixed(1)}s → ${result.outputPath}${RESET}`);
3577
3733
  console.log(`${DIM}$0.00 — local Vidfarm render, no cloud charge.${RESET}`);
3734
+ // Nudge, not a gate: QA never runs automatically, but every production
3735
+ // should get a look before it's published.
3736
+ console.log(`${DIM}Recommended: ${RESET}vidfarm qa ${sourceDir ?? "<dir>"}${DIM} — social-native QA (HTML slop + font regime). Feedback only, free, instant.${RESET}`);
3578
3737
  }
3579
3738
  return;
3580
3739
  }
@@ -4154,14 +4313,20 @@ async function runGenerateCommand(argv) {
4154
4313
  estimate: "expensive, ~$1+",
4155
4314
  freeAlternative: 'free stock video — vidfarm media search "<meaning>" --type video (Pixabay, $0; needs a free ' +
4156
4315
  "pixabay key — it may already be saved: check vidfarm provider-keys), reuse a raw clip (vidfarm " +
4157
- "raws), or author the beat as an HTML hyperframe"
4316
+ "raws), or author the beat as an HTML hyperframe",
4317
+ interactiveAlternative: 'hand the user a clip-sourcing brief — vidfarm handoff raws --keywords "<kw>,<kw>" — they search ' +
4318
+ "TikTok/YouTube and download a few clips with a free downloader ($0, and a human eye picks better " +
4319
+ "footage than a keyword scan)"
4158
4320
  }
4159
4321
  : {
4160
4322
  label: "AI image generation",
4161
4323
  estimate: "cheap, ~$0.01–$0.05",
4162
4324
  freeAlternative: 'free stock image/vector — vidfarm media search "<meaning>" --type image|vector ($0). ' +
4163
4325
  "Openverse (CC images) is keyless and always on — check it first; a free Pixabay key adds " +
4164
- "photos/vectors and may already be saved (check vidfarm provider-keys)"
4326
+ "photos/vectors and may already be saved (check vidfarm provider-keys)",
4327
+ interactiveAlternative: 'hand the user an image brief — vidfarm handoff image --theme "<what>" --items "a,b,c" — they run it ' +
4328
+ "in a free web generator (meta.ai / ChatGPT / Gemini) and drop the PNG back; needing several graphics " +
4329
+ "makes it ONE sticker sheet that vidfarm sticker-pack splits for $0"
4165
4330
  });
4166
4331
  const refs = await resolveReferenceUrls(ctx, parsed.values.ref);
4167
4332
  const payload = { prompt };
@@ -4903,6 +5068,13 @@ async function runRemoveGreenscreenCommand(argv) {
4903
5068
  softness: { type: "string" },
4904
5069
  "no-despill": { type: "boolean", default: false },
4905
5070
  "output-format": { type: "string" },
5071
+ // Animated transparent GIF (local only) — the "sticker for GIF-only
5072
+ // surfaces" output. GIF alpha is 1-bit, so it's a hand-off format, not a
5073
+ // compositing one; --gif-fps/--gif-width/--gif-alpha tune the tradeoff.
5074
+ gif: { type: "boolean", default: false },
5075
+ "gif-fps": { type: "string" },
5076
+ "gif-width": { type: "string" },
5077
+ "gif-alpha": { type: "string" },
4906
5078
  local: { type: "boolean", default: false },
4907
5079
  cloud: { type: "boolean", default: false },
4908
5080
  out: { type: "string" },
@@ -4912,6 +5084,13 @@ async function runRemoveGreenscreenCommand(argv) {
4912
5084
  });
4913
5085
  const ctx = commonContext(parsed.values);
4914
5086
  let target = resolveGreenscreenTarget(parsed.values);
5087
+ // GIF is encoded by the local ffmpeg pass — the cloud primitive only returns
5088
+ // PNG/WebP/WebM — so --gif implies (and requires) the local keyer.
5089
+ if (parsed.values.gif && parsed.values.cloud) {
5090
+ throw new Error("--gif is a local-only output (the cloud primitive returns png/webp/webm). Drop --cloud, or key in the cloud and convert the result afterwards.");
5091
+ }
5092
+ if (parsed.values.gif)
5093
+ target = "local";
4915
5094
  const sourceArg = parsed.values.source ?? parsed.positionals[0];
4916
5095
  if (!sourceArg) {
4917
5096
  throw new Error("remove-greenscreen requires a source image or video: `vidfarm remove-greenscreen <image|video|url> [--preset green|blue|white|black|digital-green|magenta] [--key-color #00FF00] [--cloud] [--out cutout.png|.webm]`.");
@@ -5040,13 +5219,52 @@ async function runLocalGreenscreen(ctx, values, sourceArg, presetRaw) {
5040
5219
  const softness = values.softness !== undefined ? Number(values.softness) : preset?.softness;
5041
5220
  const despill = !values["no-despill"];
5042
5221
  const outputFormat = values["output-format"]?.trim().toLowerCase();
5222
+ const wantGif = Boolean(values.gif);
5043
5223
  const outPath = values.out
5044
5224
  ? path.resolve(process.cwd(), String(values.out))
5045
- : defaultGreenscreenOutPath(sourcePath, mediaType, outputFormat);
5225
+ : wantGif
5226
+ ? defaultGreenscreenOutPath(sourcePath, mediaType, outputFormat).replace(/\.[^.]+$/, ".gif")
5227
+ : defaultGreenscreenOutPath(sourcePath, mediaType, outputFormat);
5046
5228
  mkdirSync(path.dirname(outPath), { recursive: true });
5229
+ // For GIF we key into a temp alpha master first (PNG for a still, WebM/MOV
5230
+ // for a clip) and quantize afterwards — ffmpeg can't key straight into GIF's
5231
+ // palette without losing the alpha we just cut.
5232
+ const gifWork = wantGif ? mkdtempSync(path.join(tmpdir(), "vidfarm-gif-")) : null;
5233
+ const keyTargetPath = gifWork
5234
+ ? path.join(gifWork, mediaType === "video" ? "keyed.webm" : "keyed.png")
5235
+ : outPath;
5047
5236
  if (!ctx.json)
5048
5237
  console.log(`${DIM}Keying out ${presetRaw ?? keyColor ?? "green"} background locally with ffmpeg (free)…${RESET}`);
5049
- const result = await removeGreenscreenLocal({ sourcePath, mediaType, outputPath: outPath, keyColor, tolerance, softness, despill });
5238
+ let result = await removeGreenscreenLocal({ sourcePath, mediaType, outputPath: keyTargetPath, keyColor, tolerance, softness, despill });
5239
+ if (gifWork) {
5240
+ try {
5241
+ if (!ctx.json)
5242
+ console.log(`${DIM}Encoding a transparent ${mediaType === "video" ? "animated " : ""}GIF (1-bit alpha)…${RESET}`);
5243
+ const gifAlpha = values["gif-alpha"] !== undefined ? Number(values["gif-alpha"]) : undefined;
5244
+ if (mediaType === "video") {
5245
+ await encodeTransparentAnimatedGif(result.outputPath, outPath, {
5246
+ fps: values["gif-fps"] !== undefined ? Number(values["gif-fps"]) : undefined,
5247
+ width: values["gif-width"] !== undefined ? Number(values["gif-width"]) : undefined,
5248
+ alphaThreshold: gifAlpha
5249
+ });
5250
+ }
5251
+ else {
5252
+ await encodeTransparentGif(result.outputPath, outPath, { alphaThreshold: gifAlpha });
5253
+ }
5254
+ result = { ...result, outputPath: outPath, format: result.format, webmAlpha: false };
5255
+ }
5256
+ finally {
5257
+ rmSync(gifWork, { recursive: true, force: true });
5258
+ }
5259
+ if (ctx.json) {
5260
+ printJson({ ok: true, target: "local", media_type: mediaType, format: "gif", out: outPath, bytes: safeSize(outPath), webm_alpha: false });
5261
+ }
5262
+ else {
5263
+ console.log(`${GREEN}Transparent ${mediaType === "video" ? "animated " : ""}GIF ready:${RESET} ${outPath} ${DIM}(${formatBytes(safeSize(outPath))})${RESET}`);
5264
+ console.log(`${DIM}GIF alpha is 1-bit, so edges are hard — that's the format, not the key. For compositing on a composition prefer PNG/WebP (still) or transparent WebM (clip); use GIF for surfaces that only take GIF.${RESET}`);
5265
+ }
5266
+ return;
5267
+ }
5050
5268
  if (ctx.json) {
5051
5269
  printJson({ ok: true, target: "local", media_type: mediaType, format: result.format, out: result.outputPath, bytes: safeSize(result.outputPath), webm_alpha: result.webmAlpha });
5052
5270
  }
@@ -5105,6 +5323,7 @@ async function runCutoutCommand(argv) {
5105
5323
  // Keyer knobs (mirror remove-greenscreen).
5106
5324
  preset: { type: "string" },
5107
5325
  "key-color": { type: "string" },
5326
+ "no-auto-key": { type: "boolean", default: false },
5108
5327
  tolerance: { type: "string" },
5109
5328
  softness: { type: "string" },
5110
5329
  "no-despill": { type: "boolean", default: false },
@@ -5128,9 +5347,10 @@ async function runCutoutCommand(argv) {
5128
5347
  throw new Error(`Unknown --preset "${presetRaw}". Choose one of: ${Object.keys(GREENSCREEN_PRESETS).join(", ")}, or pass --key-color <hex>.`);
5129
5348
  }
5130
5349
  const preset = presetRaw ? GREENSCREEN_PRESETS[presetRaw] : undefined;
5131
- const keyColor = parsed.values["key-color"] ?? preset?.key_color ?? "#00FF00";
5132
- const tolerance = parsed.values.tolerance !== undefined ? Number(parsed.values.tolerance) : preset?.tolerance;
5133
- const softness = parsed.values.softness !== undefined ? Number(parsed.values.softness) : preset?.softness;
5350
+ const explicitKeyColor = parsed.values["key-color"] ?? preset?.key_color;
5351
+ let keyColor = explicitKeyColor ?? "#00FF00";
5352
+ let tolerance = parsed.values.tolerance !== undefined ? Number(parsed.values.tolerance) : preset?.tolerance;
5353
+ let softness = parsed.values.softness !== undefined ? Number(parsed.values.softness) : preset?.softness;
5134
5354
  const despill = !parsed.values["no-despill"];
5135
5355
  const outputFormat = parsed.values["output-format"]?.trim().toLowerCase();
5136
5356
  const outExt = outputFormat === "webp" ? "webp" : "png";
@@ -5139,6 +5359,27 @@ async function runCutoutCommand(argv) {
5139
5359
  if (!generatePrompt && !sourceArg) {
5140
5360
  throw new Error("cutout needs a source: `vidfarm cutout <image|url>` to key+trim an existing graphic, or `vidfarm cutout --generate \"a robot mascot waving\"` to AI-generate one first.");
5141
5361
  }
5362
+ // Plate choice: a chroma key removes ONE color, so a green plate under a green
5363
+ // subject (a frog, a leaf, a dollar bill) keys holes through the art. When
5364
+ // generating without a pinned color, move the plate off any hue the subject
5365
+ // mentions; when keying a file the caller didn't color-pin, read the plate off
5366
+ // the image's own corners.
5367
+ let plateNote = null;
5368
+ if (!explicitKeyColor && !parsed.values["no-auto-key"] && generatePrompt) {
5369
+ const picked = pickPlateColor(generatePrompt);
5370
+ keyColor = picked.keyColor;
5371
+ const p = GREENSCREEN_PRESETS[picked.preset];
5372
+ if (tolerance === undefined)
5373
+ tolerance = p?.tolerance;
5374
+ if (softness === undefined)
5375
+ softness = p?.softness;
5376
+ if (picked.moved)
5377
+ plateNote = `Using a ${picked.preset} (${picked.keyColor}) plate instead of green — ${picked.reason}.`;
5378
+ else if (picked.reason)
5379
+ plateNote = `Sticking with green, but heads up: ${picked.reason}.`;
5380
+ if (!ctx.json && plateNote)
5381
+ console.log(`${YELLOW}Plate:${RESET} ${DIM}${plateNote}${RESET}`);
5382
+ }
5142
5383
  const work = mkdtempSync(path.join(tmpdir(), "vidfarm-cutout-"));
5143
5384
  let sourcePath;
5144
5385
  let stemHint;
@@ -5148,7 +5389,9 @@ async function runCutoutCommand(argv) {
5148
5389
  guardBilled(ctx, {
5149
5390
  label: "AI image generation (cutout plate)",
5150
5391
  estimate: "cheap, ~$0.01–$0.05",
5151
- freeAlternative: "pass an existing graphic instead — `vidfarm cutout <image|url>` keys+trims for $0.00"
5392
+ freeAlternative: "pass an existing graphic instead — `vidfarm cutout <image|url>` keys+trims for $0.00",
5393
+ interactiveAlternative: 'vidfarm handoff image --theme "<subject>" --single — the user generates the plate in a free web tool ' +
5394
+ "and hands the PNG back; `vidfarm cutout <file>` then keys + trims it for $0"
5152
5395
  });
5153
5396
  const refs = await resolveReferenceUrls(ctx, parsed.values.ref);
5154
5397
  const fullPrompt = `${generatePrompt}. ${cutoutPlateInstruction(keyColor)}`;
@@ -5206,6 +5449,14 @@ async function runCutoutCommand(argv) {
5206
5449
  }
5207
5450
  }
5208
5451
  // ---- 2. Key out the flat plate → transparent PNG ------------------------
5452
+ if (!explicitKeyColor && !parsed.values["no-auto-key"] && !generatePrompt) {
5453
+ const detected = await detectPlateColor(sourcePath);
5454
+ if (detected && detected.hex.toUpperCase() !== keyColor.toUpperCase()) {
5455
+ keyColor = detected.hex;
5456
+ if (!ctx.json)
5457
+ console.log(`${YELLOW}Plate:${RESET} ${DIM}Detected the graphic's background as ${detected.hex} from its corners (pass --key-color to override).${RESET}`);
5458
+ }
5459
+ }
5209
5460
  const keyedPath = path.join(work, "keyed.png");
5210
5461
  if (!ctx.json)
5211
5462
  console.log(`${DIM}Keying out the ${presetRaw ?? keyColor} background locally with ffmpeg (free)…${RESET}`);
@@ -5436,6 +5687,326 @@ async function runMaskCommand(argv) {
5436
5687
  rmSync(work, { recursive: true, force: true });
5437
5688
  }
5438
5689
  }
5690
+ // ── sticker-pack: ONE plate of MANY items → many individual transparent stickers
5691
+ // The bulk sibling of `cutout`. "Make me a sticker pack" is never one graphic —
5692
+ // it's a SET (props, icons, reactions, characters, backdrops) that has to share
5693
+ // one art style. Generating them one at a time is both expensive (N image jobs)
5694
+ // and inconsistent (N independent styles). The standard move is the opposite:
5695
+ // generate ONE image holding every item, laid out on a flat chroma plate, then
5696
+ // cut each item out locally for $0. This verb is that whole loop:
5697
+ // 1. generate (or accept) a single greenscreen SHEET of many items
5698
+ // 2. key the flat plate out once → a transparent sheet
5699
+ // 3. SEGMENT the sheet's alpha into connected islands — one per item — with no
5700
+ // hand-measured --crop rects (see devcli/sticker-pack.ts)
5701
+ // 4. crop + alpha-trim each island → its own snug transparent file, plus a
5702
+ // stickers.json manifest of names/sizes
5703
+ // Items are size-agnostic on purpose: a "sticker" here is any transparent
5704
+ // element you place and animate — an icon, a mascot, a prop, OR a full-width
5705
+ // landscape/backdrop that fills the frame.
5706
+ function slugifyStickerName(raw) {
5707
+ return raw.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
5708
+ }
5709
+ /** Prompt scaffolding for a MULTI-item plate. Differs from cutoutPlateInstruction
5710
+ * in the parts that make segmentation work: a spread-out grid and, above all,
5711
+ * visible plate-colored GAPS between items (touching items merge into one
5712
+ * island and come out as a single sticker). */
5713
+ function stickerSheetInstruction(keyColorHex, count, items) {
5714
+ const named = /^#?00ff00$/i.test(keyColorHex) ? "pure chroma-key green"
5715
+ : /^#?0047bb$/i.test(keyColorHex) ? "solid chroma-key blue"
5716
+ : /^#?ff00ff$/i.test(keyColorHex) ? "solid magenta"
5717
+ : `a solid flat ${keyColorHex}`;
5718
+ const list = items.length
5719
+ ? `Include exactly these ${items.length} separate items, one of each: ${items.join("; ")}.`
5720
+ : `Include exactly ${count} distinct separate items.`;
5721
+ return (`A sticker sheet: ${count} separate objects arranged in a spaced grid on a completely flat, evenly-lit ` +
5722
+ `${named} (${keyColorHex}) background. ${list} ` +
5723
+ `CRITICAL: every item must be fully separated from the others by a clear margin of plain ${keyColorHex} background — ` +
5724
+ `nothing touching, overlapping, or connected. Keep a wide ${keyColorHex} margin around the edges of the sheet too. ` +
5725
+ `No item may contain that background color anywhere on it. No drop shadows, no reflections, no text, no labels, ` +
5726
+ `no frames or dividing lines between items. One consistent art style across all items, crisp clean edges, sticker/cutout style.`);
5727
+ }
5728
+ async function runStickerPackCommand(argv) {
5729
+ const parsed = parseArgs({
5730
+ args: argv,
5731
+ allowPositionals: true,
5732
+ options: {
5733
+ ...commonOptions(),
5734
+ source: { type: "string" },
5735
+ // Optional AI generation of the sheet (ONE billed image job for the set).
5736
+ generate: { type: "string" },
5737
+ items: { type: "string" },
5738
+ count: { type: "string" },
5739
+ ref: { type: "string", multiple: true },
5740
+ "aspect-ratio": { type: "string" },
5741
+ provider: { type: "string" },
5742
+ model: { type: "string" },
5743
+ // Keyer knobs (mirror cutout / remove-greenscreen).
5744
+ preset: { type: "string" },
5745
+ "key-color": { type: "string" },
5746
+ flat: { type: "string" },
5747
+ "no-auto-key": { type: "boolean", default: false },
5748
+ tolerance: { type: "string" },
5749
+ softness: { type: "string" },
5750
+ "no-despill": { type: "boolean", default: false },
5751
+ // Segmentation knobs.
5752
+ "min-area": { type: "string" },
5753
+ gap: { type: "string" },
5754
+ "max-items": { type: "string" },
5755
+ "dry-run": { type: "boolean", default: false },
5756
+ // Per-sticker output knobs (mirror cutout).
5757
+ pad: { type: "string" },
5758
+ "alpha-threshold": { type: "string" },
5759
+ "output-format": { type: "string" },
5760
+ "gif-alpha": { type: "string" },
5761
+ "out-dir": { type: "string" },
5762
+ prefix: { type: "string" },
5763
+ "keep-plate": { type: "string" },
5764
+ "keep-sheet": { type: "string" },
5765
+ tracer: { type: "string" }
5766
+ }
5767
+ });
5768
+ const ctx = commonContext(parsed.values);
5769
+ if (!(await localGreenscreenAvailable())) {
5770
+ throw new Error("sticker-pack needs ffmpeg (for the free local keyer, alpha segmentation and trim), which wasn't found. Install ffmpeg (or `npm i -g ffmpeg-static`).");
5771
+ }
5772
+ const presetRaw = parsed.values.preset?.trim().toLowerCase();
5773
+ if (presetRaw && !(presetRaw in GREENSCREEN_PRESETS)) {
5774
+ throw new Error(`Unknown --preset "${presetRaw}". Choose one of: ${Object.keys(GREENSCREEN_PRESETS).join(", ")}, or pass --key-color <hex>.`);
5775
+ }
5776
+ const preset = presetRaw ? GREENSCREEN_PRESETS[presetRaw] : undefined;
5777
+ const explicitKeyColor = parsed.values["key-color"] ?? parsed.values.flat ?? preset?.key_color;
5778
+ let keyColor = explicitKeyColor ?? "#00FF00";
5779
+ let tolerance = parsed.values.tolerance !== undefined ? Number(parsed.values.tolerance) : preset?.tolerance;
5780
+ let softness = parsed.values.softness !== undefined ? Number(parsed.values.softness) : preset?.softness;
5781
+ const despill = !parsed.values["no-despill"];
5782
+ const outputFormat = parsed.values["output-format"]?.trim().toLowerCase();
5783
+ if (outputFormat && !["png", "webp", "gif"].includes(outputFormat)) {
5784
+ throw new Error(`Unknown --output-format "${outputFormat}". Use png (default), webp, or gif (1-bit alpha — for GIF-only surfaces).`);
5785
+ }
5786
+ const wantGif = outputFormat === "gif";
5787
+ const stillExt = outputFormat === "webp" ? "webp" : "png";
5788
+ const itemNames = parsed.values.items
5789
+ ?.split(/\s*[;,]\s*/).map((s) => s.trim()).filter(Boolean) ?? [];
5790
+ const generatePrompt = parsed.values.generate?.trim();
5791
+ const sourceArg = parsed.values.source ?? parsed.positionals[0];
5792
+ if (!generatePrompt && !sourceArg) {
5793
+ throw new Error("sticker-pack needs a sheet: `vidfarm sticker-pack <sheet.png|url>` to cut up a greenscreen sheet you already have, or `vidfarm sticker-pack --generate \"cozy farm props\" --items \"barn,tractor,hay bale,sun\"` to AI-generate one first (ONE image job for the whole set).");
5794
+ }
5795
+ // PLATE CHOICE. A chroma key removes exactly one color, so a plate the art
5796
+ // also uses keys holes through the art — a pack of leaves/frogs/money on GREEN
5797
+ // is the classic own-goal. When generating and the caller didn't pin a color,
5798
+ // read the subject and move the plate off any hue it mentions.
5799
+ let plateNote = null;
5800
+ let plateAuto = false;
5801
+ if (generatePrompt && !explicitKeyColor && !parsed.values["no-auto-key"]) {
5802
+ const picked = pickPlateColor([generatePrompt, itemNames.join(" ")].join(" "));
5803
+ keyColor = picked.keyColor;
5804
+ plateAuto = true;
5805
+ const p = GREENSCREEN_PRESETS[picked.preset];
5806
+ if (tolerance === undefined)
5807
+ tolerance = p?.tolerance;
5808
+ if (softness === undefined)
5809
+ softness = p?.softness;
5810
+ if (picked.moved)
5811
+ plateNote = `Using a ${picked.preset} (${picked.keyColor}) plate instead of green — ${picked.reason}.`;
5812
+ else if (picked.reason)
5813
+ plateNote = `Sticking with green, but heads up: ${picked.reason}.`;
5814
+ }
5815
+ const outDir = path.resolve(process.cwd(), String(parsed.values["out-dir"] ?? "./stickers"));
5816
+ const prefix = parsed.values.prefix?.trim() || "sticker";
5817
+ const work = mkdtempSync(path.join(tmpdir(), "vidfarm-stickerpack-"));
5818
+ try {
5819
+ // ---- 1. Obtain the sheet (generate ONE plate, or use an existing one) -----
5820
+ let sheetPath;
5821
+ if (generatePrompt) {
5822
+ const count = parsed.values.count !== undefined
5823
+ ? Math.max(2, Math.round(Number(parsed.values.count)))
5824
+ : (itemNames.length || 6);
5825
+ if (!Number.isFinite(count))
5826
+ throw new Error(`--count must be a number. Got "${String(parsed.values.count)}".`);
5827
+ guardBilled(ctx, {
5828
+ label: `AI image generation (sticker sheet of ${count} items)`,
5829
+ estimate: "cheap, ~$0.01–$0.05 for the WHOLE pack (one image job, not one per sticker)",
5830
+ freeAlternative: "pass a greenscreen sheet you already have — `vidfarm sticker-pack <sheet.png>` cuts it up for $0.00",
5831
+ interactiveAlternative: `vidfarm handoff image --theme "${generatePrompt.replace(/"/g, "'")}"${itemNames.length ? ` --items "${itemNames.join(",")}"` : ""} — ` +
5832
+ "the user generates the SAME sheet in a free web tool (often better art than the API tier) and hands it " +
5833
+ "back; this command then splits it for $0"
5834
+ });
5835
+ const refs = await resolveReferenceUrls(ctx, parsed.values.ref);
5836
+ const fullPrompt = `${generatePrompt}. ${stickerSheetInstruction(keyColor, count, itemNames)}`;
5837
+ const payload = { prompt: fullPrompt };
5838
+ // A sheet wants room to spread items out — square by default, not portrait.
5839
+ payload.aspect_ratio = parsed.values["aspect-ratio"] ?? "1:1";
5840
+ if (parsed.values.provider)
5841
+ payload.provider = parsed.values.provider;
5842
+ if (parsed.values.model)
5843
+ payload.model = parsed.values.model;
5844
+ if (refs.length)
5845
+ payload.prompt_attachments = refs.slice(0, 16);
5846
+ const tracer = parsed.values.tracer ?? `devcli-stickerpack-${Date.now().toString(36)}`;
5847
+ if (!ctx.json && plateNote)
5848
+ console.log(`${YELLOW}Plate:${RESET} ${DIM}${plateNote}${RESET}`);
5849
+ if (!ctx.json)
5850
+ console.log(`${DIM}Generating ONE ${keyColor} plate holding ${count} items…${RESET}`);
5851
+ const submit = await apiRequest({ method: "POST", host: ctx.host, path: "/api/v1/primitives/images/generate", auth: ctx.auth, body: { tracer, payload } });
5852
+ assertApiOk(submit, "sticker-pack generate");
5853
+ const jobId = submit.json?.job_id;
5854
+ if (!jobId)
5855
+ throw new Error("sticker-pack generate: no job id returned.");
5856
+ const job = await pollPrimitiveJob(ctx, jobId);
5857
+ const mediaUrl = resolveJobMediaUrl(job);
5858
+ if (!mediaUrl)
5859
+ throw new Error(`sticker-pack generate: job ${jobId} produced no image (${String(job?.status ?? "unknown")}).`);
5860
+ sheetPath = path.join(work, "sheet.png");
5861
+ await downloadUrlToFile(mediaUrl, sheetPath);
5862
+ if (parsed.values["keep-plate"]) {
5863
+ const keepPath = path.resolve(process.cwd(), String(parsed.values["keep-plate"]));
5864
+ mkdirSync(path.dirname(keepPath), { recursive: true });
5865
+ await downloadUrlToFile(mediaUrl, keepPath);
5866
+ if (!ctx.json)
5867
+ console.log(`${DIM}Saved the pre-key sheet to ${keepPath}${RESET}`);
5868
+ }
5869
+ }
5870
+ else {
5871
+ const directPath = path.resolve(process.cwd(), sourceArg);
5872
+ const isLocalFile = !/^https?:\/\//i.test(sourceArg) && existsSync(directPath);
5873
+ if (isLocalFile) {
5874
+ if (/\.(mp4|mov|webm|m4v|mkv)(\?|#|$)/i.test(directPath)) {
5875
+ throw new Error("sticker-pack is image-only (a sheet is one still). For a MOVING sticker, key the clip with `vidfarm remove-greenscreen <video>` → transparent WebM (add --gif for an animated transparent GIF).");
5876
+ }
5877
+ sheetPath = directPath;
5878
+ }
5879
+ else {
5880
+ const sourceUrl = await resolveSingleMediaUrl(ctx, sourceArg);
5881
+ if (/\.(mp4|mov|webm|m4v|mkv)(\?|#|$)/i.test(sourceUrl)) {
5882
+ throw new Error("sticker-pack is image-only. Key a clip with `vidfarm remove-greenscreen <video>`.");
5883
+ }
5884
+ sheetPath = path.join(work, path.basename(new URL(sourceUrl).pathname) || "sheet.png");
5885
+ if (!ctx.json)
5886
+ console.log(`${DIM}Downloading sheet…${RESET}`);
5887
+ await downloadUrlToFile(sourceUrl, sheetPath);
5888
+ }
5889
+ }
5890
+ // ---- 2. Key the flat plate ONCE → a transparent sheet ---------------------
5891
+ // For a sheet we did NOT generate, read the plate off the image instead of
5892
+ // assuming green: the four corners of a sticker sheet are always plate, so
5893
+ // a user can hand back a red/purple/blue sheet from a web generator and the
5894
+ // cut still works without them knowing what a chroma key is.
5895
+ if (!explicitKeyColor && !generatePrompt && !parsed.values["no-auto-key"]) {
5896
+ const detected = await detectPlateColor(sheetPath);
5897
+ if (detected && detected.hex.toUpperCase() !== keyColor.toUpperCase()) {
5898
+ keyColor = detected.hex;
5899
+ plateAuto = true;
5900
+ plateNote = `Detected the sheet's plate color as ${detected.hex} from its corners (pass --key-color to override).`;
5901
+ }
5902
+ else if (!detected) {
5903
+ plateNote = "Couldn't read a uniform plate color from the sheet's corners — falling back to green. If the background isn't green, pass --preset <name> or --key-color <hex>.";
5904
+ }
5905
+ if (!ctx.json && plateNote)
5906
+ console.log(`${YELLOW}Plate:${RESET} ${DIM}${plateNote}${RESET}`);
5907
+ }
5908
+ const keyedSheet = path.join(work, "keyed-sheet.png");
5909
+ if (!ctx.json)
5910
+ console.log(`${DIM}Keying out the ${presetRaw ?? keyColor} plate locally with ffmpeg (free)…${RESET}`);
5911
+ await removeGreenscreenLocal({ sourcePath: sheetPath, mediaType: "image", outputPath: keyedSheet, keyColor, tolerance, softness, despill });
5912
+ if (parsed.values["keep-sheet"]) {
5913
+ const keepPath = path.resolve(process.cwd(), String(parsed.values["keep-sheet"]));
5914
+ mkdirSync(path.dirname(keepPath), { recursive: true });
5915
+ copyFileSync(keyedSheet, keepPath);
5916
+ if (!ctx.json)
5917
+ console.log(`${DIM}Saved the keyed (uncut) sheet to ${keepPath}${RESET}`);
5918
+ }
5919
+ // ---- 3. Segment the sheet's alpha into one island per item ---------------
5920
+ const alphaThreshold = parsed.values["alpha-threshold"] !== undefined ? Number(parsed.values["alpha-threshold"]) : undefined;
5921
+ if (!ctx.json)
5922
+ console.log(`${DIM}Finding the individual items in the sheet's alpha channel…${RESET}`);
5923
+ const seg = await segmentAlphaComponents({
5924
+ sourcePath: keyedSheet,
5925
+ alphaThreshold,
5926
+ minAreaPct: parsed.values["min-area"] !== undefined ? Number(parsed.values["min-area"]) : undefined,
5927
+ gapPct: parsed.values.gap !== undefined ? Number(parsed.values.gap) : undefined,
5928
+ maxItems: parsed.values["max-items"] !== undefined ? Number(parsed.values["max-items"]) : undefined
5929
+ });
5930
+ if (!seg.components.length) {
5931
+ throw new Error("No items found in the sheet. Either the plate didn't key (check --preset/--key-color, raise --tolerance) or every item was filtered as speckle (lower --min-area).");
5932
+ }
5933
+ if (parsed.values["dry-run"]) {
5934
+ // Report the boxes without writing stickers — for eyeballing segmentation
5935
+ // before spending disk, and for hand-fixing a merged item with `mask --crop`.
5936
+ if (ctx.json) {
5937
+ printJson({ ok: true, target: "local", dry_run: true, sheet: `${seg.sourceWidth}x${seg.sourceHeight}`, found: seg.components.length, skipped_specks: seg.rejected, key_color: keyColor, key_color_auto: plateAuto, items: seg.components });
5938
+ }
5939
+ else {
5940
+ console.log(`${GREEN}Found ${seg.components.length} item${seg.components.length === 1 ? "" : "s"}${RESET} ${DIM}on the ${seg.sourceWidth}×${seg.sourceHeight} sheet${seg.rejected ? `, ${seg.rejected} speck(s) skipped` : ""} (dry run — nothing written):${RESET}`);
5941
+ for (const c of seg.components) {
5942
+ console.log(` ${DIM}${String(c.index).padStart(2, "0")} crop ${c.x},${c.y},${c.width},${c.height} (${c.width}×${c.height}, ${c.area_pct}% of sheet)${RESET}`);
5943
+ }
5944
+ console.log(`${DIM}Merged two items into one box? Raise the gap between them in the prompt, lower --gap, or grab that one by hand: vidfarm mask <sheet> --crop x,y,w,h --flat "${keyColor}".${RESET}`);
5945
+ }
5946
+ return;
5947
+ }
5948
+ // ---- 4. Crop + alpha-trim each island into its own sticker ---------------
5949
+ mkdirSync(outDir, { recursive: true });
5950
+ const padPx = parsed.values.pad !== undefined ? Number(parsed.values.pad) : 0;
5951
+ const gifAlpha = parsed.values["gif-alpha"] !== undefined ? Number(parsed.values["gif-alpha"]) : undefined;
5952
+ const written = [];
5953
+ for (const c of seg.components) {
5954
+ const label = itemNames[c.index - 1] ? slugifyStickerName(itemNames[c.index - 1]) : "";
5955
+ const stem = `${prefix}-${String(c.index).padStart(2, "0")}${label ? `-${label}` : ""}`;
5956
+ const regionPath = path.join(work, `${stem}.region.png`);
5957
+ await cropImageRegion({ sourcePath: keyedSheet, outputPath: regionPath, x: c.x, y: c.y, width: c.width, height: c.height });
5958
+ // Trim to the item's TRUE box: segmentation works on a downscaled alpha, so
5959
+ // the crop carries a pixel or two of slack — this is what makes the sticker's
5960
+ // pixel size equal the subject, the property placement/scaling relies on.
5961
+ const stillPath = path.join(wantGif ? work : outDir, `${stem}.${stillExt}`);
5962
+ const trim = await trimTransparentBorders({ sourcePath: regionPath, outputPath: stillPath, padPx, alphaThreshold });
5963
+ let finalPath = trim.outputPath;
5964
+ if (wantGif) {
5965
+ finalPath = path.join(outDir, `${stem}.gif`);
5966
+ await encodeTransparentGif(trim.outputPath, finalPath, { alphaThreshold: gifAlpha });
5967
+ }
5968
+ written.push({
5969
+ index: c.index,
5970
+ name: itemNames[c.index - 1] ?? null,
5971
+ file: finalPath,
5972
+ width: trim.width,
5973
+ height: trim.height,
5974
+ sheet_crop: { x: c.x, y: c.y, width: c.width, height: c.height },
5975
+ area_pct: c.area_pct,
5976
+ bytes: safeSize(finalPath)
5977
+ });
5978
+ if (!ctx.json) {
5979
+ console.log(` ${GREEN}✓${RESET} ${path.relative(process.cwd(), finalPath)} ${DIM}(${trim.width}×${trim.height}, ${formatBytes(safeSize(finalPath))})${RESET}`);
5980
+ }
5981
+ }
5982
+ // A manifest so the next step (place/keyframes, or an agent picking props by
5983
+ // name) doesn't have to re-derive what's in the pack.
5984
+ const manifestPath = path.join(outDir, "stickers.json");
5985
+ writeFileSync(manifestPath, `${JSON.stringify({
5986
+ generated_from: generatePrompt ? "generate" : sourceArg,
5987
+ key_color: keyColor,
5988
+ key_color_auto: plateAuto,
5989
+ sheet_width: seg.sourceWidth,
5990
+ sheet_height: seg.sourceHeight,
5991
+ format: wantGif ? "gif" : stillExt,
5992
+ count: written.length,
5993
+ stickers: written
5994
+ }, null, 2)}\n`);
5995
+ if (ctx.json) {
5996
+ printJson({ ok: true, target: "local", out_dir: outDir, manifest: manifestPath, count: written.length, skipped_specks: seg.rejected, key_color: keyColor, key_color_auto: plateAuto, key_color_note: plateNote, stickers: written });
5997
+ }
5998
+ else {
5999
+ console.log(`${GREEN}Sticker pack ready:${RESET} ${written.length} transparent sticker${written.length === 1 ? "" : "s"} in ${outDir} ${DIM}(manifest: stickers.json${seg.rejected ? `, ${seg.rejected} speck(s) skipped` : ""})${RESET}`);
6000
+ if (itemNames.length && itemNames.length !== written.length) {
6001
+ console.log(`${YELLOW}Note:${RESET} ${DIM}you named ${itemNames.length} item(s) but ${written.length} were cut, so names may be off-by-one. Re-run with --dry-run to inspect the boxes; two touching items segment as ONE (lower --gap or ask the generator for wider spacing), and one item split in two segments as TWO (raise --gap).${RESET}`);
6002
+ }
6003
+ console.log(`${DIM}Place one: vidfarm place <dir> --src "${written[0]?.file}" --kind image. Animate it (pop-in/float/shake/drift) with vidfarm keyframes — see the skill's "Cutout graphics for explainers" recipe.${RESET}`);
6004
+ }
6005
+ }
6006
+ finally {
6007
+ rmSync(work, { recursive: true, force: true });
6008
+ }
6009
+ }
5439
6010
  // Poll a greenscreen job through the same backend it was submitted to (local
5440
6011
  // in-process app or cloud). Mirrors pollPrimitiveJob's terminal conditions.
5441
6012
  async function pollGreenscreenJob(ctx, target, jobId) {
@@ -7228,6 +7799,7 @@ async function runWhoamiCommand(argv) {
7228
7799
  console.log(` plan ${customer.isPaidPlan ? `${GREEN}paid${plan}${RESET}` : `${DIM}free${plan}${RESET}`}`);
7229
7800
  console.log(` credential ${DIM}${stored ? "persisted login (vidfarm login)" : "env/flag key — not persisted (run vidfarm login to persist)"}${RESET}`);
7230
7801
  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}` : ""}`);
7802
+ console.log(` ${interactionModeSummaryLine(ctx.interaction).replace("interaction mode: ", "interaction ")}${!ctx.interaction.isSet ? ` ${DIM}(set with: vidfarm mode <${INTERACTION_MODES.join("|")}>)${RESET}` : ""}`);
7231
7803
  return;
7232
7804
  }
7233
7805
  emitResult(result, ctx.json);
@@ -7284,6 +7856,267 @@ async function runCostModeCommand(argv) {
7284
7856
  if (!resolved.isSet) {
7285
7857
  console.log(`${DIM}Nothing saved yet — ask the user which one they want before spending AI credits.${RESET}`);
7286
7858
  }
7859
+ // Cost is only half the plan: ask the SECOND axis too. In minimize especially,
7860
+ // interactive mode is what turns "$0 means no custom art" into "$0 with a full
7861
+ // sticker pack and hand-picked footage".
7862
+ const interaction = resolveInteractionMode(parsed.values);
7863
+ console.log("");
7864
+ console.log(`${BOLD}${interactionModeSummaryLine(interaction)}${RESET}`);
7865
+ if (!interaction.isSet) {
7866
+ console.log(`${DIM}Ask this one too — it is INDEPENDENT of cost: is the user willing to do a little manual work (paste an image prompt into a free web tool, hand-source a few clips) for better output? ${BOLD}vidfarm mode <interactive|autonomous>${RESET}`);
7867
+ }
7868
+ if (resolved.mode === "minimize") {
7869
+ console.log(`${DIM}In minimize, ${interaction.mode === "interactive" ? "you're already on" : "offer"} the sweet spot: minimize + interactive = $0 videos that still get custom art (free web image gen → vidfarm sticker-pack) and hand-picked clips (vidfarm handoff raws).${RESET}`);
7870
+ }
7871
+ }
7872
+ // `vidfarm storyboard [dir]` — the PLAN pass, before any animation work.
7873
+ // Storyboarding is a first-class part of the composition format now: a project
7874
+ // root carries STORYBOARD.md (ordered frames + per-frame status/duration/
7875
+ // narration) and optionally SCRIPT.md (the full voiceover). The Vidfarm editor
7876
+ // renders exactly this file in its Storyboard view — a contact sheet with a
7877
+ // comment box per frame — which makes it the natural place to get a director's
7878
+ // approval before building. This command is the agent-side twin: scaffold a
7879
+ // plan, read one back, and see which frames are still outlines.
7880
+ async function runStoryboardCommand(argv) {
7881
+ // `storyboard` used to be an alias for the AI shot-sheet pipeline (`vidfarm
7882
+ // sequence`). That name now belongs to the composition format's plan file, so
7883
+ // keep the old muscle memory working: a sequence SUBCOMMAND still routes there.
7884
+ const SEQUENCE_SUBCOMMANDS = new Set(["init", "plan", "characters", "boards", "prompts", "animate", "assemble", "status", "run"]);
7885
+ if (argv[0] && SEQUENCE_SUBCOMMANDS.has(argv[0].trim().toLowerCase()) && argv[0].trim().toLowerCase() !== "plan") {
7886
+ return runSequenceCommand(argv);
7887
+ }
7888
+ const parsed = parseArgs({
7889
+ args: argv,
7890
+ allowPositionals: true,
7891
+ options: {
7892
+ ...commonOptions(),
7893
+ init: { type: "boolean", default: false },
7894
+ force: { type: "boolean", default: false },
7895
+ frames: { type: "string" },
7896
+ count: { type: "string" },
7897
+ title: { type: "string" },
7898
+ format: { type: "string" },
7899
+ message: { type: "string" },
7900
+ arc: { type: "string" },
7901
+ audience: { type: "string" }
7902
+ }
7903
+ });
7904
+ const json = Boolean(parsed.values.json);
7905
+ const dir = path.resolve(process.cwd(), parsed.positionals[0] ?? ".");
7906
+ if (!existsSync(dir))
7907
+ throw new Error(`No such project directory: ${dir}`);
7908
+ const target = path.join(dir, STORYBOARD_FILENAME);
7909
+ if (parsed.values.init) {
7910
+ if (existsSync(target) && !parsed.values.force) {
7911
+ throw new Error(`${target} already exists. Edit it, or pass --force to overwrite it.`);
7912
+ }
7913
+ const frames = parsed.values.frames?.split(/\s*[;,]\s*/).map((s) => s.trim()).filter(Boolean);
7914
+ const body = renderStoryboardScaffold({
7915
+ title: parsed.values.title,
7916
+ format: parsed.values.format,
7917
+ message: parsed.values.message,
7918
+ arc: parsed.values.arc,
7919
+ audience: parsed.values.audience,
7920
+ frames,
7921
+ count: parsed.values.count !== undefined ? Number(parsed.values.count) : undefined
7922
+ });
7923
+ writeFileSync(target, body, "utf8");
7924
+ if (json)
7925
+ return printJson({ ok: true, created: target, frames: frames?.length ?? Number(parsed.values.count ?? 5) });
7926
+ console.log(`${GREEN}Storyboard created:${RESET} ${target}`);
7927
+ console.log(`${DIM}Fill in each frame's scene + voiceover, then show the director: the editor's ${BOLD}Storyboard${RESET}${DIM} view renders this file as a contact sheet with a comment box per frame (vidfarm serve → open the composition → Storyboard).${RESET}`);
7928
+ console.log(`${DIM}Advance each frame as you build it: status outline → built (a real sub-composition at src:) → animated.${RESET}`);
7929
+ return;
7930
+ }
7931
+ const read = readStoryboard(dir);
7932
+ const stage = storyboardStage(read.manifest.frames);
7933
+ if (json) {
7934
+ return printJson({
7935
+ ok: true,
7936
+ exists: read.exists,
7937
+ path: read.path,
7938
+ dir,
7939
+ stage,
7940
+ globals: read.manifest.globals,
7941
+ frames: read.manifest.frames,
7942
+ warnings: read.manifest.warnings,
7943
+ script: { exists: read.script.exists, path: read.script.path }
7944
+ });
7945
+ }
7946
+ if (!read.exists) {
7947
+ console.log(`${YELLOW}No ${STORYBOARD_FILENAME} in ${dir}.${RESET}`);
7948
+ console.log(`${DIM}Draft the plan first — it's the cheapest place to get the director's approval: ${BOLD}vidfarm storyboard ${path.relative(process.cwd(), dir) || "."} --init --frames "Hook,Problem,Reveal,Proof,CTA"${RESET}`);
7949
+ return;
7950
+ }
7951
+ const g = read.manifest.globals;
7952
+ console.log(`${BOLD}Storyboard${RESET} ${DIM}${read.absPath}${RESET}`);
7953
+ if (g.message)
7954
+ console.log(` ${DIM}message${RESET} ${g.message}`);
7955
+ if (g.arc)
7956
+ console.log(` ${DIM}arc${RESET} ${g.arc}`);
7957
+ if (g.audience)
7958
+ console.log(` ${DIM}audience${RESET} ${g.audience}`);
7959
+ if (g.format)
7960
+ console.log(` ${DIM}format${RESET} ${g.format}`);
7961
+ const counts = { outline: 0, built: 0, animated: 0 };
7962
+ for (const f of read.manifest.frames)
7963
+ counts[f.status]++;
7964
+ console.log(` ${DIM}stage${RESET} ${stage} ${DIM}(${counts.outline} outline · ${counts.built} built · ${counts.animated} animated)${RESET}`);
7965
+ console.log("");
7966
+ for (const f of read.manifest.frames) {
7967
+ const mark = f.status === "animated" ? `${GREEN}●${RESET}` : f.status === "built" ? `${YELLOW}◐${RESET}` : `${DIM}○${RESET}`;
7968
+ console.log(` ${mark} ${String(f.number ?? f.index).padStart(2, "0")} ${f.title ?? "(untitled)"}${f.duration ? ` ${DIM}${f.duration}${RESET}` : ""}`);
7969
+ if (f.scene)
7970
+ console.log(` ${DIM}${f.scene}${RESET}`);
7971
+ if (f.voiceover)
7972
+ console.log(` ${DIM}VO: ${f.voiceover}${RESET}`);
7973
+ if (f.src)
7974
+ console.log(` ${DIM}src: ${f.src}${RESET}`);
7975
+ }
7976
+ if (read.script.exists)
7977
+ console.log(`\n${DIM}Narration script: ${path.join(dir, read.script.path)}${RESET}`);
7978
+ for (const w of read.manifest.warnings)
7979
+ console.log(`${YELLOW}warn:${RESET} ${DIM}${w.message}${RESET}`);
7980
+ console.log(`\n${DIM}Show it to the director in the editor's ${BOLD}Storyboard${RESET}${DIM} view (per-frame comments come back to you), then build frame by frame.${RESET}`);
7981
+ }
7982
+ // `vidfarm mode [interactive|autonomous]` — show or set whether the HUMAN is in
7983
+ // the loop. Independent of cost mode: any cost mode runs either way, and the
7984
+ // pair is what determines the plan. Quality usually goes UP in interactive —
7985
+ // most of all in `minimize`, where free frontier web models stand in for the AI
7986
+ // budget the user isn't spending.
7987
+ async function runInteractionModeCommand(argv) {
7988
+ const parsed = parseArgs({
7989
+ args: argv,
7990
+ allowPositionals: true,
7991
+ options: { ...commonOptions(), note: { type: "string" }, clear: { type: "boolean", default: false } }
7992
+ });
7993
+ const json = Boolean(parsed.values.json);
7994
+ const home = parsed.values.home;
7995
+ if (parsed.values.clear) {
7996
+ const removed = clearStoredInteractionMode(home);
7997
+ if (json)
7998
+ return printJson({ ok: true, cleared: removed });
7999
+ console.log(removed ? `${GREEN}Cleared saved interaction mode.${RESET}` : `${DIM}No saved interaction mode to clear.${RESET}`);
8000
+ return;
8001
+ }
8002
+ const requested = parsed.positionals[0];
8003
+ if (requested) {
8004
+ const mode = normalizeInteractionMode(requested);
8005
+ if (!mode) {
8006
+ throw new Error(`Unknown interaction mode "${requested}". Choose one of: ${INTERACTION_MODES.join(", ")}.`);
8007
+ }
8008
+ const savedAt = new Date().toISOString();
8009
+ const file = writeStoredInteractionMode(mode, savedAt, parsed.values.note ?? null, home);
8010
+ if (json)
8011
+ return printJson({ ok: true, interaction_mode: mode, saved_at: savedAt, file });
8012
+ console.log(`${GREEN}${BOLD}Interaction mode: ${mode}${RESET}`);
8013
+ console.log(`${DIM}${INTERACTION_MODE_BLURB[mode]}${RESET}`);
8014
+ console.log(`${DIM}Saved to ${file}.${RESET}`);
8015
+ if (mode === "interactive") {
8016
+ console.log(`${DIM}Hand-offs: ${BOLD}vidfarm handoff image --theme "<what>" --items "a,b,c"${RESET}${DIM} (sticker pack — the default for manual art)` +
8017
+ ` · ${BOLD}vidfarm handoff raws --keywords "<kw>,<kw>"${RESET}${DIM} (manual clip sourcing).${RESET}`);
8018
+ }
8019
+ const cost = resolveCostMode(parsed.values);
8020
+ if (!cost.isSet) {
8021
+ console.log(`${DIM}Cost preference still unset — ask that too: vidfarm cost-mode <${COST_MODE_DISPLAY_LIST.join("|")}>.${RESET}`);
8022
+ }
8023
+ else if (cost.mode === "minimize" && mode === "interactive") {
8024
+ console.log(`${DIM}minimize + interactive is the sweet spot: $0 videos that still get custom art (free web image gen → vidfarm sticker-pack) and hand-picked footage.${RESET}`);
8025
+ }
8026
+ return;
8027
+ }
8028
+ const resolved = resolveInteractionMode(parsed.values);
8029
+ const cost = resolveCostMode(parsed.values);
8030
+ if (json) {
8031
+ return printJson({
8032
+ ok: true,
8033
+ interaction_mode: resolved.mode,
8034
+ is_set: resolved.isSet,
8035
+ source: resolved.source,
8036
+ cost_mode: cost.mode,
8037
+ cost_mode_is_set: cost.isSet
8038
+ });
8039
+ }
8040
+ console.log(`${BOLD}${interactionModeSummaryLine(resolved)}${RESET}`);
8041
+ console.log(`${DIM}${costModeSummaryLine(cost)}${RESET}`);
8042
+ console.log("");
8043
+ console.log(interactionModeExplainer());
8044
+ console.log("");
8045
+ console.log(`${DIM}Set it: ${BOLD}vidfarm mode <${INTERACTION_MODES.join("|")}>${RESET}${DIM} · forget it: vidfarm mode --clear · per-run: --interactive / --autonomous.${RESET}`);
8046
+ if (!resolved.isSet) {
8047
+ console.log(`${DIM}Nothing saved yet — ask the user before you either spend credits or quietly skip custom art.${RESET}`);
8048
+ }
8049
+ }
8050
+ // `vidfarm handoff <image|raws>` — mint the copy-paste brief an INTERACTIVE-mode
8051
+ // user works from. The agent prints this verbatim, the user does one manual step
8052
+ // (paste a prompt into a free image tool / search + download some clips), and
8053
+ // the agent picks the files back up with the printed follow-up command.
8054
+ async function runHandoffCommand(argv) {
8055
+ const kind = (argv[0] ?? "").trim().toLowerCase();
8056
+ const rest = argv.slice(1);
8057
+ const parsed = parseArgs({
8058
+ args: rest,
8059
+ allowPositionals: true,
8060
+ options: {
8061
+ ...commonOptions(),
8062
+ // image
8063
+ theme: { type: "string" },
8064
+ items: { type: "string" },
8065
+ style: { type: "string" },
8066
+ "key-color": { type: "string" },
8067
+ "out-dir": { type: "string" },
8068
+ grid: { type: "string" },
8069
+ single: { type: "boolean", default: false },
8070
+ // raws
8071
+ keywords: { type: "string" },
8072
+ platforms: { type: "string" },
8073
+ count: { type: "string" },
8074
+ purpose: { type: "string" }
8075
+ }
8076
+ });
8077
+ const json = Boolean(parsed.values.json);
8078
+ const splitList = (raw) => (raw ?? "").split(/\s*[;,]\s*/).map((s) => s.trim()).filter(Boolean);
8079
+ if (kind === "image" || kind === "images" || kind === "sticker-pack" || kind === "stickers") {
8080
+ const theme = parsed.values.theme?.trim() || parsed.positionals.join(" ").trim();
8081
+ const items = splitList(parsed.values.items);
8082
+ if (!theme && !items.length) {
8083
+ throw new Error('handoff image needs a subject: `vidfarm handoff image --theme "cozy farm props" --items "barn,tractor,hay bale"` (or pass the theme as a positional).');
8084
+ }
8085
+ const brief = buildImageHandoff({
8086
+ theme: theme || items.join(", "),
8087
+ items,
8088
+ style: parsed.values.style,
8089
+ keyColor: parsed.values["key-color"],
8090
+ outDir: parsed.values["out-dir"],
8091
+ grid: parsed.values.grid,
8092
+ pack: parsed.values.single ? false : undefined
8093
+ });
8094
+ if (json)
8095
+ return printJson({ ok: true, kind: "image", pack: !parsed.values.single, ...brief });
8096
+ if (brief.keyColorNote)
8097
+ console.log(`${YELLOW}Plate:${RESET} ${DIM}${brief.keyColorNote}${RESET}\n`);
8098
+ console.log(formatHandoff(`${BOLD}Free image hand-off${RESET} ${DIM}— you generate it in a free web tool, I cut it up locally for $0.${RESET}`, brief, { promptLabel: `${DIM}─── copy this prompt into the image tool ───${RESET}` }));
8099
+ return;
8100
+ }
8101
+ if (kind === "raws" || kind === "clips" || kind === "footage") {
8102
+ const keywords = splitList(parsed.values.keywords ?? parsed.positionals.join(","));
8103
+ if (!keywords.length) {
8104
+ throw new Error('handoff raws needs keywords: `vidfarm handoff raws --keywords "villa construction,pouring concrete" --platforms tiktok,youtube`.');
8105
+ }
8106
+ const brief = buildRawsHandoff({
8107
+ keywords,
8108
+ platforms: splitList(parsed.values.platforms),
8109
+ count: parsed.values.count !== undefined ? Number(parsed.values.count) : undefined,
8110
+ purpose: parsed.values.purpose,
8111
+ outDir: parsed.values["out-dir"]
8112
+ });
8113
+ if (json)
8114
+ return printJson({ ok: true, kind: "raws", ...brief });
8115
+ console.log(formatHandoff(`${BOLD}Manual clip-sourcing hand-off${RESET} ${DIM}— your eye picks better clips than a keyword scan.${RESET}`, brief, { promptLabel: `${DIM}─── search + download these ───${RESET}` }));
8116
+ console.log(`${DIM}Note: try browser control or \`vidfarm raws scan <url>\` first — only hand this to the user when neither is available, or when you want a human's taste on the picks.${RESET}`);
8117
+ return;
8118
+ }
8119
+ throw new Error('handoff needs a kind: `vidfarm handoff image --theme "…" --items "a,b,c"` (free web image gen → sticker pack) or `vidfarm handoff raws --keywords "…"` (manual clip sourcing).');
7287
8120
  }
7288
8121
  async function runProviderKeysCommand(argv) {
7289
8122
  const parsed = parseArgs({ args: argv, allowPositionals: false, options: commonOptions() });
@@ -8615,6 +9448,149 @@ async function runLintCommand(argv) {
8615
9448
  if (!result.ok)
8616
9449
  process.exitCode = 1;
8617
9450
  }
9451
+ // `vidfarm qa <dir|composition.html>` — the social-native QA pass. Compositions
9452
+ // are authored in HTML, so an agent's web-page instincts leak in as landing-page
9453
+ // furniture (gradient CTA capsules, benefit chip rows, frosted cards) that never
9454
+ // appears in a real TikTok. This is a BLOCKLIST of those specific patterns plus
9455
+ // the font regime — deliberately NOT an allowlist, so weird/ugly/hand-made
9456
+ // compositions pass untouched and nothing pushes every video toward one style.
9457
+ //
9458
+ // FEEDBACK, NOT A GATE: exit code stays 0 even when slop is found, unless the
9459
+ // caller opts into --strict. Nothing in the render path calls this. It is a
9460
+ // devcli-only capability (no cloud/REST twin) — heavily recommended after every
9461
+ // production, never mandatory and never automatic.
9462
+ async function runQaCommand(argv) {
9463
+ const parsed = parseArgs({
9464
+ args: argv,
9465
+ allowPositionals: true,
9466
+ options: {
9467
+ json: { type: "boolean", default: false },
9468
+ strict: { type: "boolean", default: false },
9469
+ regime: { type: "string", multiple: true },
9470
+ "no-regime": { type: "boolean", default: false }
9471
+ }
9472
+ });
9473
+ const target = parsed.positionals[0];
9474
+ if (!target)
9475
+ throw new Error("qa requires a composition path: `vidfarm qa <dir-or-composition.html> [--regime <name|path>] [--json] [--strict]`.");
9476
+ const htmlPath = resolveCompositionHtmlPath(target);
9477
+ const html = readFileSync(htmlPath, "utf8");
9478
+ let report = qaCompositionHtml(html);
9479
+ // Regime resolution, in precedence order: explicit --regime flags (stackable)
9480
+ // → VIDFARM_QA_REGIME (a default for a whole scripting run) → the working
9481
+ // directory's own QA_REGIME.md. --no-regime opts out of the implicit paths.
9482
+ const explicit = parsed.values.regime ?? [];
9483
+ const fromEnv = (process.env.VIDFARM_QA_REGIME ?? "").split(",").map((entry) => entry.trim()).filter(Boolean);
9484
+ let regimeRefs = explicit.length ? explicit : fromEnv;
9485
+ if (!regimeRefs.length && !parsed.values["no-regime"]) {
9486
+ const discovered = discoverRegime(path.dirname(htmlPath));
9487
+ if (discovered)
9488
+ regimeRefs = [discovered];
9489
+ }
9490
+ if (regimeRefs.length) {
9491
+ const facts = extractCompositionFacts(html);
9492
+ report = mergeRegimeIntoReport(report, regimeRefs.map((ref) => loadAndEvaluateRegime(ref, facts)));
9493
+ }
9494
+ if (parsed.values.json) {
9495
+ printJson(report);
9496
+ if (!report.ok && parsed.values.strict)
9497
+ process.exitCode = 1;
9498
+ return;
9499
+ }
9500
+ const body = formatQaReport(report, { red: RED, yellow: YELLOW, green: GREEN, dim: DIM, reset: RESET });
9501
+ if (body)
9502
+ console.log(body);
9503
+ for (const evaluation of report.regimes ?? []) {
9504
+ console.log(formatRegimeReport(evaluation, { green: GREEN, red: RED, yellow: YELLOW, dim: DIM, reset: RESET }));
9505
+ }
9506
+ const scope = `${report.checked.layers} layer(s), ${report.checked.text_layers} text layer(s)${report.checked.canvas ? `, ${report.checked.canvas}` : ""}`;
9507
+ const graded = (report.regimes ?? []).length > 0;
9508
+ if (report.verdict === "clean") {
9509
+ console.log(`${GREEN}✓ no HTML slop found${graded ? ", regime checks passed" : ""}${RESET} ${DIM}— ${scope} — ${htmlPath}${RESET}`);
9510
+ }
9511
+ else if (report.verdict === "warnings") {
9512
+ console.log(`${YELLOW}! ${report.warnings.length} warning(s), no slop${RESET} ${DIM}— ${scope} — ${htmlPath}${RESET}`);
9513
+ }
9514
+ else {
9515
+ console.log(`${RED}✗ ${report.errors.length} ${graded ? "issue(s)" : "slop issue(s)"}${RESET}, ${report.warnings.length} warning(s) ${DIM}— ${scope} — ${htmlPath}${RESET}`);
9516
+ 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}`);
9517
+ }
9518
+ const pendingReview = (report.regimes ?? []).reduce((total, evaluation) => total + evaluation.review_items.length, 0);
9519
+ if (pendingReview) {
9520
+ console.log(`${DIM}${pendingReview} regime item(s) above are NOT machine-checkable — answer them yourself before you call this video done.${RESET}`);
9521
+ }
9522
+ else if (!graded) {
9523
+ // Everything above is mechanical. Structure — hook, loop, payoff, bait — is
9524
+ // what actually decides whether the video travels, and nothing here checks
9525
+ // it. Say so rather than letting a green tick imply the video is good.
9526
+ console.log(`${DIM}Checked mechanics only — nothing here grades the hook, the loop, the payoff, or the bait.${RESET}`);
9527
+ console.log(`${DIM}Structure: vidfarm regime show hooks · grade this video: vidfarm qa <dir> --regime short-form${RESET}`);
9528
+ }
9529
+ if (!report.ok && parsed.values.strict)
9530
+ process.exitCode = 1;
9531
+ }
9532
+ // `vidfarm regime <list|show|init>` — the QA_REGIME.md surface. A regime is the
9533
+ // director's OWN quality contract for one template style; the built-ins are
9534
+ // starting points to copy and edit, never a house style to conform to.
9535
+ async function runRegimeCommand(argv) {
9536
+ const sub = argv[0];
9537
+ const parsed = parseArgs({
9538
+ args: argv.slice(1),
9539
+ allowPositionals: true,
9540
+ options: {
9541
+ json: { type: "boolean", default: false },
9542
+ out: { type: "string" },
9543
+ dir: { type: "string" },
9544
+ force: { type: "boolean", default: false }
9545
+ }
9546
+ });
9547
+ const json = Boolean(parsed.values.json);
9548
+ if (!sub || sub === "list") {
9549
+ const builtins = listBuiltinRegimes();
9550
+ if (json)
9551
+ return printJson({ regimes: builtins.map(({ name, path: file, video_type }) => ({ name, path: file, video_type })) });
9552
+ console.log(`${DIM}Built-in QA regimes — copy one next to your work, then edit it:${RESET}`);
9553
+ for (const entry of builtins) {
9554
+ console.log(` ${GREEN}${entry.name}${RESET} ${DIM}${entry.video_type ?? ""}${RESET}`);
9555
+ }
9556
+ console.log(`\n${DIM}vidfarm regime init <name> --out ./work/QA_REGIME.md${RESET}`);
9557
+ console.log(`${DIM}vidfarm qa ./work --regime <name|path> (repeatable — regimes stack)${RESET}`);
9558
+ console.log(`${DIM}A regime is YOURS: any QA_REGIME.md anywhere on disk can be passed in. Format: .agents/skills/vidfarm/regimes/README.md${RESET}`);
9559
+ return;
9560
+ }
9561
+ if (sub === "show") {
9562
+ const ref = parsed.positionals[0];
9563
+ if (!ref)
9564
+ throw new Error("regime show requires a name or path: `vidfarm regime show <name|path>`.");
9565
+ const file = resolveRegimePath(ref);
9566
+ const raw = readFileSync(file, "utf8");
9567
+ if (json)
9568
+ return printJson(parseRegime(raw, file));
9569
+ console.log(raw);
9570
+ return;
9571
+ }
9572
+ if (sub === "init") {
9573
+ const ref = parsed.positionals[0];
9574
+ if (!ref)
9575
+ throw new Error("regime init requires a built-in name or source path: `vidfarm regime init short-form --out ./work/QA_REGIME.md`.");
9576
+ const source = resolveRegimePath(ref);
9577
+ const outPath = parsed.values.out
9578
+ ? path.resolve(String(parsed.values.out))
9579
+ : path.resolve(String(parsed.values.dir ?? "."), "QA_REGIME.md");
9580
+ if (existsSync(outPath) && !parsed.values.force) {
9581
+ throw new Error(`${outPath} already exists. Pass --force to overwrite (you will lose your edits).`);
9582
+ }
9583
+ mkdirSync(path.dirname(outPath), { recursive: true });
9584
+ writeFileSync(outPath, readFileSync(source, "utf8"), "utf8");
9585
+ if (json)
9586
+ return printJson({ ok: true, regime: ref, source, path: outPath });
9587
+ console.log(`${GREEN}${outPath}${RESET} ${DIM}← ${path.basename(source)}${RESET}`);
9588
+ console.log(`${DIM}Now EDIT it — delete what doesn't apply, add what makes your format yours (start with the audience line).${RESET}`);
9589
+ console.log(`${DIM}Then: vidfarm qa ${path.dirname(outPath)}${RESET}`);
9590
+ return;
9591
+ }
9592
+ throw new Error(`Unknown regime subcommand "${sub}". Use: list | show <name|path> | init <name> [--out <path>]`);
9593
+ }
8618
9594
  // `vidfarm stills <dir|composition.html>` — render PNG stills of the CURRENT
8619
9595
  // composition in-process (the agent's visual self-verification loop).
8620
9596
  async function runStillsCommand(argv) {