@officexapp/vidfarm-devcli 0.21.34 → 0.21.36

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 (28) hide show
  1. package/.agents/skills/editor-capabilities/SKILL.md +14 -3
  2. package/.agents/skills/vidfarm/SKILL.md +66 -33
  3. package/.agents/skills/vidfarm/harnesses/README.md +112 -0
  4. package/.agents/skills/vidfarm/{regimes/explainer.QA_REGIME.md → harnesses/explainer.HARNESS.md} +3 -2
  5. package/.agents/skills/vidfarm/{regimes/hooks.QA_REGIME.md → harnesses/hooks.HARNESS.md} +3 -3
  6. package/.agents/skills/vidfarm/{regimes/product-demo.QA_REGIME.md → harnesses/product-demo.HARNESS.md} +1 -1
  7. package/.agents/skills/vidfarm/{regimes/short-form.QA_REGIME.md → harnesses/short-form.HARNESS.md} +39 -10
  8. package/.agents/skills/vidfarm/{regimes/ugc-testimonial.QA_REGIME.md → harnesses/ugc-testimonial.HARNESS.md} +3 -3
  9. package/.agents/skills/vidfarm/recipes/{bulk-scripting-with-a-regime.md → bulk-scripting-with-a-harness.md} +20 -12
  10. package/.agents/skills/vidfarm/recipes/cutout-graphics-for-explainers.md +43 -13
  11. package/.agents/skills/vidfarm/recipes/local-edit-render-approve.md +1 -1
  12. package/.agents/skills/vidfarm/references/automation-and-local-dev.md +77 -26
  13. package/.agents/skills/vidfarm/references/editor-workflows.md +18 -5
  14. package/.agents/skills/vidfarm/references/hooks-and-virality.md +65 -7
  15. package/.agents/skills/vidfarm/references/reviewing-renders.md +2 -1
  16. package/.agents/skills/vidfarm-media/SKILL.md +2 -2
  17. package/.agents/skills/vidfarm-media/references/tts.md +26 -4
  18. package/SKILL.director.md +292 -98
  19. package/SKILL.md +33 -15
  20. package/dist/src/cli.js +1200 -141
  21. package/dist/src/devcli/handoff.js +54 -33
  22. package/dist/src/devcli/{qa-regime.js → harness.js} +132 -55
  23. package/dist/src/devcli/plate-key.js +698 -0
  24. package/dist/src/devcli/qa-check.js +209 -4
  25. package/dist/src/devcli/skill-docs.js +136 -0
  26. package/dist/src/devcli/sticker-pack.js +48 -0
  27. package/package.json +6 -4
  28. package/.agents/skills/vidfarm/regimes/README.md +0 -79
package/dist/src/cli.js CHANGED
@@ -19,15 +19,17 @@ 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
+ import { extractCompositionFacts, formatQaReport, formatWatchTheVideoNotice, qaCompositionHtml, watchTheVideoDirective } from "./devcli/qa-check.js";
23
+ import { HARNESS_FILENAME, discoverHarness, formatHarnessReport, isLegacyHarnessFilename, listBuiltinHarnesses, loadAndEvaluateHarness, mergeHarnessIntoReport, parseHarness, resolveHarnessPath } from "./devcli/harness.js";
24
24
  import { removeGreenscreenLocal, localGreenscreenAvailable, defaultGreenscreenOutPath, GREENSCREEN_PRESETS, trimTransparentBorders, cropImageRegion } from "./devcli/greenscreen-local.js";
25
25
  import { dedupeMediaLocal, localDedupeAvailable, defaultDedupeOutPath, describeLocalDedupe, DEDUPE_PRESETS, DEDUPE_DEFAULT_PRESET, isDedupePresetName } from "./devcli/dedupe-local.js";
26
- import { segmentAlphaComponents, encodeTransparentGif, encodeTransparentAnimatedGif, pickPlateColor, detectPlateColor, keySafeArtInstruction, analyzeKeyedArt, HOLE_WARN_PCT } from "./devcli/sticker-pack.js";
26
+ import { segmentAlphaComponents, encodeTransparentGif, encodeTransparentAnimatedGif, pickPlateColor, detectPlateColor, keySafeArtInstruction, connectivitySafeArtInstruction, analyzeKeyedArt, measureOpaqueShare, HOLE_WARN_PCT } from "./devcli/sticker-pack.js";
27
+ import { smartKeyPlate, planZonedSheet, zonedSheetInstruction } from "./devcli/plate-key.js";
27
28
  import { runDoctorCommand } from "./devcli/doctor.js";
28
29
  import { findFreePort } from "./devcli/port-utils.js";
29
30
  import { scanLocalServers } from "./devcli/process-scan.js";
30
31
  import { runSkillsCommand } from "./devcli/skills.js";
32
+ import { AmbiguousDocRef, DEFAULT_PACK, bundledPackDir, listPackDocs, readPackDoc, searchPackDocs } from "./devcli/skill-docs.js";
31
33
  import { initTelemetry, reportCliCrash } from "./devcli/telemetry.js";
32
34
  import { resolveLocalDataDir, localBackendAvailable, LocalModeUnavailableError, localApiRequest } from "./devcli/local-backend.js";
33
35
  import { startLocalFrontendServer, serveShellsPresent } from "./devcli/local-frontend-server.js";
@@ -320,6 +322,10 @@ Generate AI media and drop it on the timeline (for local coding agents):
320
322
  --media-type <k> Force image|video instead of auto-detecting
321
323
  --tolerance <0..1> Key radius (default 0.3); --softness <0..1> edge feather (0.1)
322
324
  --no-despill Skip edge color-fringe suppression
325
+ --smart IMAGE only: key by border-connected fill instead of by
326
+ color alone, so plate-colored detail inside the subject
327
+ and hollow/outline art survive (what cutout and
328
+ sticker-pack do by default). Local only
323
329
  --output-format <f> Image only: png|webp
324
330
  --gif Write a TRANSPARENT GIF instead (animated for a clip)
325
331
  — local only, 1-bit alpha, for GIF-only sticker surfaces
@@ -372,21 +378,25 @@ Generate AI media and drop it on the timeline (for local coding agents):
372
378
  default: --generate moves the plate off any hue the subject
373
379
  mentions; an existing file's plate is read from its corners.
374
380
  --no-auto-key forces plain green.
381
+ --key-mode smart|flat smart (default): flood-fill the plate in from the frame
382
+ edge, so plate-colored detail inside the subject and
383
+ hollow/outline art survive and the edge is feathered +
384
+ de-spilled per pixel. flat: the plain ffmpeg chromakey
375
385
  --pad <px> Keep N px of transparent breathing room around the cutout
376
386
  --alpha-threshold <n> Alpha 0..255 below which edges count as empty (default 8)
377
387
  --no-trim Key only; don't shrink to the bounding box
378
388
  --output-format png|webp Cutout format (default png)
379
389
  --keep-plate <file> Also save the pre-key generated plate (with --generate)
380
390
  --out <file> Write the cutout here (default <source>.cutout.png)
381
- sticker-pack [sheet|url] Make a whole STICKER PACK from ONE greenscreen sheet:
382
- key the flat plate once, auto-find each item in the
383
- alpha channel, and cut every one into its own snug
391
+ sticker-pack [sheet|url] Make a whole STICKER PACK from ONE sheet: key the
392
+ plate(s) once, auto-find each item in the alpha
393
+ channel, and cut every one into its own snug
384
394
  transparent file (+ stickers.json). One image job for
385
395
  the set = one art style, ~1/N the cost of N cutouts.
386
396
  Items may be any size, from an icon to a full-frame
387
- landscape. Prompts for key-safe art (solid fills, no
388
- outline-only/hollow shapes, nothing in a near-plate
389
- shade) and flags any item that came out hollow.
397
+ landscape. Keys by CONNECTIVITY, so plate-colored
398
+ detail inside an item and hollow/outline shapes
399
+ survive, and edges come out feathered + de-spilled.
390
400
  Local, free, ffmpeg-only. Image-only.
391
401
  (aliases: stickers, sticker-sheet)
392
402
  --generate "<theme>" AI-generate the sheet first (ONE billed image job for
@@ -398,6 +408,30 @@ Generate AI media and drop it on the timeline (for local coding agents):
398
408
  art can't collide with (green art → magenta plate, etc.);
399
409
  splitting an existing sheet detects the plate from its
400
410
  corners. --no-auto-key forces plain green. (--flat too)
411
+ --sheet-mode <m> auto (default) | zoned | flat. ZONED asks the model for a
412
+ grid of color PANELS — one item per panel, each panel's
413
+ plate picked against that item — so a green frog and a
414
+ pink flower can share a sheet, item art can use ANY
415
+ palette, and names are pinned to panels instead of guessed
416
+ from reading order. auto = zoned when generating 2+ named
417
+ items. FLAT is the classic one-color sheet: use it for a
418
+ weaker image model that can't follow a color-block grid
419
+ (if a model ignores the grid, keying detects that and
420
+ falls back to a flat plate on its own)
421
+ --zones auto|off|RxC How to read panels off a sheet at key time (default auto:
422
+ recover the grid from the sheet's own edges, else one
423
+ plate). Pass RxC for a sheet you know the layout of
424
+ --zone-cols <n> Force the panel grid's column count when planning a
425
+ zoned generation
426
+ --key-mode smart|flat smart (default): flood-fill the plate inward from the
427
+ sheet edge, so only background that REACHES the edge is
428
+ deleted. flat: the plain ffmpeg chromakey (deletes the key
429
+ color wherever it appears) — the simple fallback, and the
430
+ way to match a cloud render exactly
431
+ --refine Re-cut every item with the local ONNX matting model
432
+ (free, ~1-2s each) instead of the chroma cut — the path
433
+ for painterly, soft-edged, furry or glassy art. Falls
434
+ back to the keyed cut per item when the matte looks wrong
401
435
  --dry-run Just list the detected crop boxes; write nothing
402
436
  --gap <pct> Merge islands closer than this % of the sheet's short
403
437
  side into ONE item (default 1.2) — raise it to rejoin a
@@ -612,7 +646,18 @@ Speech (TTS/STT) — LOCAL-FIRST on your own AI key; --cloud is the explicit bac
612
646
  --out <file> Output mp3 path (default music-<id>.mp3)
613
647
  --own-key Use your own ElevenLabs key instead of the platform key + wallet
614
648
  (default: platform ElevenLabs, wallet-billed) → POST /api/v1/primitives/music/generate
615
- voices List ElevenLabs voices for TTS (voice_id + name + labels).
649
+ voices Browse + SAMPLE narration voices (voice_id + name + labels + preview).
650
+ Default roster = the premium ElevenLabs catalog reached through
651
+ VIDFARM'S OWN ElevenLabs connection: no ElevenLabs account, API key,
652
+ or subscription needed — narration on these runs on the platform key
653
+ and is billed as vidfarm wallet credits (pennies per narration).
654
+ --sample Write listenable samples to ./voice-samples so a human can CHOOSE by
655
+ ear. FREE on both tiers (premium = ElevenLabs' own preview clips,
656
+ free = rendered locally) — safe even in cost mode minimize.
657
+ --sample-count <n> How many to sample (default 6)
658
+ --sample-out <dir> | --sample-text "<line>"
659
+ --search "<text>" Filter by name/labels/description (e.g. "british narrator")
660
+ --free List the $0 local Kokoro voices instead (--all lists both rosters)
616
661
  --own-key List voices on your own saved ElevenLabs key (default: platform account)
617
662
  --limit <n> | --json Show more / raw JSON
618
663
  stt <file|url> Video or audio → transcript (alias: transcribe). LOCAL: local ffmpeg demux +
@@ -716,21 +761,32 @@ Local media engines & toolchain (all local, free, no account — no cloud key ne
716
761
  Feedback only: exits 0 even when it finds slop,
717
762
  and it never runs automatically. Blocklist, not
718
763
  allowlist — unusual styles pass untouched
719
- --regime <name|path> Grade against a QA_REGIME.md too (repeatable —
764
+ --harness <name|path> Grade against a HARNESS.md too (repeatable —
720
765
  they stack). A built-in name or ANY file path.
721
- <dir>/QA_REGIME.md is picked up automatically
722
- --no-regime Skip QA_REGIME.md auto-discovery
766
+ <dir>/HARNESS.md is picked up automatically
767
+ --no-harness Skip HARNESS.md auto-discovery
723
768
  --json Machine-readable findings (rule/severity/fix)
724
769
  --strict Also exit 1 when slop is found (for CI)
725
- regime list|show|init QA_REGIME.md — the per-style quality contract (local, devcli-only)
726
- you own, for bulk/scripting mode. Built-ins:
727
- short-form, hooks, ugc-testimonial, explainer,
728
- product-demo
729
- regime list [--json] What ships with the CLI
730
- regime show <name|path> Print one
731
- regime init <name> [--out <path>] Copy it next to your work, then EDIT it
732
- (default ./QA_REGIME.md; --dir <d> to
733
- write <d>/QA_REGIME.md)
770
+ harness <sub> HARNESS.md — the reusable AI harness for ONE (local, devcli-only)
771
+ format or template: what makes it special,
772
+ written down so an agent can reproduce it.
773
+ "create me a harness" / "update the harness for
774
+ this format" / "give me the harness for this
775
+ template_id" all land here
776
+ harness list [--json] Bundled starting points: short-form,
777
+ hooks, ugc-testimonial, explainer,
778
+ product-demo
779
+ harness show <name|path> Print one [--dna <strand>] to print just
780
+ one strand (viral_dna, visual_dna, …)
781
+ harness init <name> [--out <p>] Copy one next to your work, then EDIT it
782
+ (default ./HARNESS.md; --dir <d> to
783
+ write <d>/HARNESS.md)
784
+ harness derive <forkId|dir> DECOMPOSITION → HARNESS.md. Distils a
785
+ decomposed template's viral/visual/
786
+ structural/audio/build DNA into an
787
+ editable harness [--out <p>] [--force]
788
+ harness check <dir> Grade a composition against its harness
789
+ (same engine as vidfarm qa)
734
790
  stills <dir|composition.html> Render PNG stills of the CURRENT (local in-process render)
735
791
  composition — the visual self-check loop
736
792
  ("did my edit look right") without a full render
@@ -800,13 +856,27 @@ Account:
800
856
  provider-keys List saved AI provider keys → GET /api/v1/user/me/provider-keys
801
857
  add-provider-key <provider> <secret> Save an AI provider key → POST /api/v1/user/me/provider-keys
802
858
 
803
- Agent skill (install the latest director skill so your AI agent can act):
859
+ Agent skill (the director knowledge a full copy SHIPS INSIDE this CLI):
860
+ skill ls List every file in the bundled pack, with sizes (local — offline, no account)
861
+ skill show <path> Print one file. Shorthand works: \`skill show
862
+ primitives\`, \`skill show harnesses/README.md\`
863
+ skill search "<term>" Grep all of it — the fastest way to find the one
864
+ paragraph you need without loading a whole file
865
+ --limit <n> Max matches (default 40)
866
+ skill path Where the bundled pack lives on disk
867
+ skill --print Print the single-file director rollup (bundled
868
+ copy; --remote for the host's latest)
869
+ NOTE: the bundled pack is pinned to THIS CLI
870
+ version — the pairing that actually works. It is
871
+ documentation, not entitlement: the free-local
872
+ half (clips, hyperframes, serve render, qa,
873
+ harnesses, dedupe, local TTS) runs offline, while
874
+ AI generation, hosted render, recycle, download
875
+ and marketplace still need login + network.
804
876
  update-skill DEPRECATED installer alias; installs the canonical
805
877
  multi-file vidfarm pack via \`skills add\`.
806
878
  Use \`vidfarm skills add vidfarm\` directly.
807
879
  --dir <path> Project root for the canonical pack install
808
- --print Print the generated single-file director rollup
809
- (copy/share artifact; not the canonical install)
810
880
 
811
881
  Files (multi-step flows the devcli handles for you):
812
882
  upload <file> Upload to the EPHEMERAL temp store, print URL → presign + S3 PUT + finalize
@@ -1388,9 +1458,13 @@ async function main() {
1388
1458
  case "slop-check":
1389
1459
  await runQaCommand(rest);
1390
1460
  return;
1461
+ case "harness":
1462
+ case "harnesses":
1463
+ // `regime` is the pre-rename name. Kept as a silent alias so old scripts
1464
+ // and old skill copies keep working; every surface says "harness" now.
1391
1465
  case "regime":
1392
1466
  case "regimes":
1393
- await runRegimeCommand(rest);
1467
+ await runHarnessCommand(rest);
1394
1468
  return;
1395
1469
  case "stills":
1396
1470
  await runStillsCommand(rest);
@@ -2396,10 +2470,10 @@ Rules:
2396
2470
  - 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.
2397
2471
  - 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.
2398
2472
  - NO HTML SLOP. You are editing HTML, but the output is a social video, not a web page. THE TEST IS THE NATIVE-EDITOR TEST: could you have made this element with the tools inside TikTok's own editor? That toolset is a font, a color, a stroke/outline, a soft shadow, a tight text box, alignment, opacity, rotation, animation presets — plus stickers, emoji, drawn marks and clips. It has NO padded capsule, NO border, NO gradient fill, NO blur panel, NO card. If you reached past it, cut it. 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). AND NOT A SINGLE PILL EITHER: one lonely rounded, padded, filled capsule around a static stat or label — "10 hrs / week", "STEP 2", "EP.01", "+40%" — is a web badge, and being the only one on screen does not make it native. The ONLY legitimate capsule in a video is the active-word spotlight/karaoke caption highlight, because it moves with the spoken word. Emphasize a stat the way the editor would: bigger, heavier, ALL-CAPS, an accent color, a hand-drawn circle or underline, or its own beat on screen. Rule of thumb on anything holding words: border-radius over ~8px PLUS a background fill PLUS padding = a badge; drop the fill or drop the radius until the band hugs the glyphs. None of this 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).
2399
- - 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\`.
2473
+ - 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; a keyword comment ask ("comment CLIPPER and I'll send the breakdown") is standard and allowed, but never "follow for part two", ragebait, or an earnings/health claim traded for the reply. 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 harness show hooks\`.
2400
2474
  - 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\`.
2401
2475
  - REVIEW THE WHOLE VIDEO AS ONE OBJECT — AND NEVER JUDGE IT BY ONE FRAME. Assume your own finished video has a defect you cannot see: across a 32-video batch, EVERY first-pass video had a real defect its own author had already reported as "verified, looks good". The cause is structural — you build scene by scene, each scene correct while it is the whole world, so every scene passes alone and the video fails as a SEQUENCE: margins shift between beats, headline sizes drift, the accent color wanders, one asset is flat vector and the next is photographic, every beat is the same length, a join lands like a slap. Nobody watches a scene; they watch the sequence. So before you call anything done, tile ~12 stills into ONE contact sheet and READ IT AS AN IMAGE — one command: \`vidfarm stills . --sheet\` (add \`--at 0,2,4,…\` to pick timestamps; writes stills/contact-sheet.png). Check: visual balance (no dead band under top-anchored content), consistent spacing/margins, ONE type scale, ONE accent color, ONE illustration style, deliberate pacing rather than N identical beats, nothing jarring at the joins, no frame where two elements compete for the eye — and the summary question, does it look like one person made it in one sitting? Fix drift by defining the SYSTEM (type scale, margin, palette, default beat) and applying it to every scene, not by patching the one scene that stood out. The defects that actually ship, in observed frequency order: large flat dead regions · a placeholder empty state that reads as a failed render · two contradictory numbers in one frame · a CTA still building at the last frame (settle it >=2s before the end) · two headlines superimposed at a scene handoff (exit at nextIn-0.18, duration 0.24, ease power2.out) · type colliding with a busy background exactly as it is spoken. If a frame looks empty, sample 0.2s apart to see whether it RESTS there — a transient wipe frame is fine, >0.5s is a hole. AND ALWAYS COMPARE TWO FRAMES FROM DIFFERENT SCENES: a frozen render (an overlay/watermark pass missing \`-loop 1\`, or assets outside the composition root so the timeline never runs) makes every frame identical while duration, frame count and audio hash all still pass, and frame 0 looks perfect. Verify audio by MEASUREMENT, never "it sounds fine" (you cannot hear it): ~12-15 dB speech-over-bed separation across the actual word spans, peak <0 dBFS. Never \`adelay\` the VO — whisper word timings and every caption built from them are relative to the raw vo.wav; use apad+atrim. Report what you MEASURED separately from what you JUDGED. Full method: the vidfarm skill's references/reviewing-renders.md.
2402
- - 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.
2476
+ - THE HARNESS IS A FIRST-CLASS ARTIFACT KNOW THE THREE PHRASINGS. A HARNESS.md is the reusable AI harness for ONE format or template: what makes it special, written down so an agent reproduces it without the director in the room. "Create me a harness" \`vidfarm harness init short-form --out ./HARNESS.md\` (bases: short-form, hooks, ugc-testimonial, explainer, product-demo), then EDIT it with them. "Update the harness for this format" open the file and write the new rule in WITH ITS REASON on the same line. "Give me the harness for this template_id" → they mean the DECOMPOSITION: \`vidfarm harness derive <forkId>\` distils this fork's viral/visual/structural/audio/build DNA into an editable HARNESS.md, keyed the same way the decompose JSON is (\`## Viral DNA\` → \`viral_dna\`, …); \`vidfarm harness show <ref> --dna visual\` prints one strand. \`vidfarm qa .\` picks up ./HARNESS.md automatically; \`--harness <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. 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 harness, because nobody watches variant #37 as carefully as #1. When a batch teaches you something, write it back into the harness — the compositions are disposable, the harness compounds. (Formerly QA_REGIME.md; \`vidfarm regime …\` still works as an alias. NOTE: the \`.harness/\` directory beside this file is machine-generated context regenerated on every pull — never hand-edit it. HARNESS.md is the one you own.)
2403
2477
  - DEDUPLICATE BEFORE YOU PUBLISH — AND ASK FIRST. Social platforms fingerprint every upload, so the same render posted twice (a second account, another platform, a re-post next month) gets the later copy suppressed as duplicate/reused content. BEFORE you render for publication, and before any bulk run, ASK the director: "do you want deduplicated copies for posting, and how many?" Ask THEN, not after — dedupe is a post-render ffmpeg pass, so the correct order is RENDER ONCE → DEDUPE N, and deciding late means paying for a second render. Run it on the EXPORTED file: \`vidfarm dedupe ./final.mp4\` (one copy) or \`vidfarm dedupe ./final.mp4 --variants N --seed <slug> --out-dir ./posts\` (N copies, one per account/slot). Free, offline, no wallet — it never re-renders the composition. The default \`standard\` preset is skew 2%, zoom 3%, rotate 2°, speed +2%, saturation +4%, plus contrast/brightness/hue/grain, a container-metadata strip and a per-variant CRF walk; invisible to a viewer, and each variant differs from the original AND from its siblings. Post each variant to a DIFFERENT account — two accounts posting the same variant defeats the point. A rotate forces a bigger centre-crop to hide the black corners (~6.7% on a tall frame at 2°) and the CLI says so; pass \`--rotate 0\` when framing matters more. Cloud twin: \`POST /api/v1/primitives/media/dedupe\`.
2404
2478
  - 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.
2405
2479
 
@@ -5157,6 +5231,12 @@ async function runRemoveGreenscreenCommand(argv) {
5157
5231
  tolerance: { type: "string" },
5158
5232
  softness: { type: "string" },
5159
5233
  "no-despill": { type: "boolean", default: false },
5234
+ // IMAGE-only upgrade: key by border-connected fill instead of by color
5235
+ // alone, so plate-colored detail inside the subject and hollow/outline art
5236
+ // survive (`vidfarm cutout` / `sticker-pack` use this by default). Video
5237
+ // stays on the ffmpeg filter chain — per-frame fills in JS would be slow
5238
+ // and would break cloud parity.
5239
+ smart: { type: "boolean", default: false },
5160
5240
  "output-format": { type: "string" },
5161
5241
  // Animated transparent GIF (local only) — the "sticker for GIF-only
5162
5242
  // surfaces" output. GIF alpha is 1-bit, so it's a hand-off format, not a
@@ -5323,9 +5403,38 @@ async function runLocalGreenscreen(ctx, values, sourceArg, presetRaw) {
5323
5403
  const keyTargetPath = gifWork
5324
5404
  ? path.join(gifWork, mediaType === "video" ? "keyed.webm" : "keyed.png")
5325
5405
  : outPath;
5326
- if (!ctx.json)
5327
- console.log(`${DIM}Keying out ${presetRaw ?? keyColor ?? "green"} background locally with ffmpeg (free)…${RESET}`);
5328
- let result = await removeGreenscreenLocal({ sourcePath, mediaType, outputPath: keyTargetPath, keyColor, tolerance, softness, despill });
5406
+ const wantSmart = Boolean(values.smart);
5407
+ if (wantSmart && mediaType === "video") {
5408
+ throw new Error("--smart is image-only (it flood-fills the plate per frame). For a clip, key it without --smart, or cut the subject out with `vidfarm remove-background <video>` (ONNX matting, color-blind).");
5409
+ }
5410
+ let result;
5411
+ if (wantSmart) {
5412
+ if (!ctx.json)
5413
+ console.log(`${DIM}Keying out ${presetRaw ?? keyColor ?? "green"} background locally with the smart keyer (border-connected fill, free)…${RESET}`);
5414
+ const smart = await smartKeyPlate({
5415
+ sourcePath,
5416
+ outputPath: /\.(png|webp)$/i.test(keyTargetPath) ? keyTargetPath : `${keyTargetPath.replace(/\.[^.]+$/, "")}.png`,
5417
+ keyColor: keyColor ?? "#00FF00",
5418
+ singlePlate: true,
5419
+ tolerance: values.tolerance !== undefined ? Number(values.tolerance) : undefined,
5420
+ softness: values.softness !== undefined ? Number(values.softness) : undefined,
5421
+ despill
5422
+ });
5423
+ result = {
5424
+ outputPath: smart.outputPath,
5425
+ format: (/\.webp$/i.test(smart.outputPath) ? "webp" : "png"),
5426
+ alpha: true,
5427
+ webmAlpha: false
5428
+ };
5429
+ if (!ctx.json && smart.stats.protected_px) {
5430
+ console.log(`${DIM}Kept ${smart.stats.protected_px.toLocaleString()} plate-colored pixel(s) INSIDE the subject that a flat key would have deleted.${RESET}`);
5431
+ }
5432
+ }
5433
+ else {
5434
+ if (!ctx.json)
5435
+ console.log(`${DIM}Keying out ${presetRaw ?? keyColor ?? "green"} background locally with ffmpeg (free)…${RESET}`);
5436
+ result = await removeGreenscreenLocal({ sourcePath, mediaType, outputPath: keyTargetPath, keyColor, tolerance, softness, despill });
5437
+ }
5329
5438
  if (gifWork) {
5330
5439
  try {
5331
5440
  if (!ctx.json)
@@ -5735,7 +5844,7 @@ async function runLocalDedupe(ctx, input) {
5735
5844
  // "Cutout graphics for explainers" recipe for placement + zoom/grow/shake/move).
5736
5845
  /** Build a chroma-plate instruction so an AI-generated graphic keys cleanly.
5737
5846
  * Matches the color to the chosen key so the keyer has a flat edge to cut. */
5738
- function cutoutPlateInstruction(keyColorHex) {
5847
+ function cutoutPlateInstruction(keyColorHex, keyMode = "smart") {
5739
5848
  const named = /^#?00ff00$/i.test(keyColorHex) ? "pure chroma-key green"
5740
5849
  : /^#?0047bb$/i.test(keyColorHex) ? "solid chroma-key blue"
5741
5850
  : /^#?ff00ff$/i.test(keyColorHex) ? "solid magenta"
@@ -5744,9 +5853,10 @@ function cutoutPlateInstruction(keyColorHex) {
5744
5853
  `like a green-screen plate. ` +
5745
5854
  `Center the subject with generous empty margin on all sides, no drop shadow or reflection cast on the ` +
5746
5855
  `background, crisp clean edges, sticker/cutout style, single subject, no text. ` +
5747
- // The plate color is only half the battle art drawn as a hollow outline
5748
- // (or in a near-plate shade) gets its middle keyed away too.
5749
- keySafeArtInstruction(keyColorHex));
5856
+ // Which art rules apply depends on the keyer: a flat chromakey deletes the
5857
+ // key color wherever it appears (so hollow art loses its middle), while the
5858
+ // smart keyer only deletes background that reaches the frame edge.
5859
+ (keyMode === "smart" ? connectivitySafeArtInstruction(keyColorHex) : keySafeArtInstruction(keyColorHex)));
5750
5860
  }
5751
5861
  async function runCutoutCommand(argv) {
5752
5862
  const parsed = parseArgs({
@@ -5768,6 +5878,7 @@ async function runCutoutCommand(argv) {
5768
5878
  tolerance: { type: "string" },
5769
5879
  softness: { type: "string" },
5770
5880
  "no-despill": { type: "boolean", default: false },
5881
+ "key-mode": { type: "string" }, // smart (default) | flat
5771
5882
  // Trim knobs.
5772
5883
  "no-trim": { type: "boolean", default: false },
5773
5884
  pad: { type: "string" },
@@ -5795,6 +5906,15 @@ async function runCutoutCommand(argv) {
5795
5906
  const despill = !parsed.values["no-despill"];
5796
5907
  const outputFormat = parsed.values["output-format"]?.trim().toLowerCase();
5797
5908
  const outExt = outputFormat === "webp" ? "webp" : "png";
5909
+ // `smart` keys by border-connected fill, so plate-colored details inside the
5910
+ // subject and hollow/outline art survive, and the edge is feathered + de-spilled
5911
+ // per pixel. `flat` is the plain ffmpeg chromakey, kept as the simple fallback
5912
+ // (and the way to match a cloud render exactly).
5913
+ const keyModeRaw = (parsed.values["key-mode"] ?? "smart").trim().toLowerCase();
5914
+ if (!["smart", "flat"].includes(keyModeRaw)) {
5915
+ throw new Error(`Unknown --key-mode "${keyModeRaw}". Use smart (default) or flat (the plain ffmpeg chromakey).`);
5916
+ }
5917
+ const keyMode = keyModeRaw;
5798
5918
  const generatePrompt = parsed.values.generate?.trim();
5799
5919
  const sourceArg = parsed.values.source ?? parsed.positionals[0];
5800
5920
  if (!generatePrompt && !sourceArg) {
@@ -5835,7 +5955,7 @@ async function runCutoutCommand(argv) {
5835
5955
  "and hands the PNG back; `vidfarm cutout <file>` then keys + trims it for $0"
5836
5956
  });
5837
5957
  const refs = await resolveReferenceUrls(ctx, parsed.values.ref);
5838
- const fullPrompt = `${generatePrompt}. ${cutoutPlateInstruction(keyColor)}`;
5958
+ const fullPrompt = `${generatePrompt}. ${cutoutPlateInstruction(keyColor, keyMode)}`;
5839
5959
  const payload = { prompt: fullPrompt };
5840
5960
  payload.aspect_ratio = parsed.values["aspect-ratio"] ?? "1:1";
5841
5961
  if (parsed.values.provider)
@@ -5899,9 +6019,28 @@ async function runCutoutCommand(argv) {
5899
6019
  }
5900
6020
  }
5901
6021
  const keyedPath = path.join(work, "keyed.png");
5902
- if (!ctx.json)
5903
- console.log(`${DIM}Keying out the ${presetRaw ?? keyColor} background locally with ffmpeg (free)…${RESET}`);
5904
- await removeGreenscreenLocal({ sourcePath, mediaType: "image", outputPath: keyedPath, keyColor, tolerance, softness, despill });
6022
+ let cutoutKey = null;
6023
+ if (keyMode === "flat") {
6024
+ if (!ctx.json)
6025
+ console.log(`${DIM}Keying out the ${presetRaw ?? keyColor} background locally with ffmpeg chromakey (--key-mode flat, free)…${RESET}`);
6026
+ await removeGreenscreenLocal({ sourcePath, mediaType: "image", outputPath: keyedPath, keyColor, tolerance, softness, despill });
6027
+ }
6028
+ else {
6029
+ if (!ctx.json)
6030
+ console.log(`${DIM}Keying out the ${presetRaw ?? keyColor} background locally with the smart keyer (border-connected fill, free)…${RESET}`);
6031
+ cutoutKey = await smartKeyPlate({
6032
+ sourcePath,
6033
+ outputPath: keyedPath,
6034
+ keyColor,
6035
+ singlePlate: true,
6036
+ tolerance: parsed.values.tolerance !== undefined ? Number(parsed.values.tolerance) : undefined,
6037
+ softness: parsed.values.softness !== undefined ? Number(parsed.values.softness) : undefined,
6038
+ despill
6039
+ });
6040
+ if (!ctx.json && cutoutKey.stats.protected_px) {
6041
+ console.log(`${DIM}Kept ${cutoutKey.stats.protected_px.toLocaleString()} ${keyColor}-colored pixel(s) INSIDE the subject that a flat key would have deleted.${RESET}`);
6042
+ }
6043
+ }
5905
6044
  // ---- 3. Trim to the tight cutout bounding box ---------------------------
5906
6045
  const outPath = parsed.values.out
5907
6046
  ? path.resolve(process.cwd(), String(parsed.values.out))
@@ -5927,12 +6066,18 @@ async function runCutoutCommand(argv) {
5927
6066
  // left as bare plate) or a near-plate fill comes back as a rim around a
5928
6067
  // see-through hole — invisible until it composites over a real background.
5929
6068
  const art = await analyzeKeyedArt(trim.outputPath);
5930
- const hollowNote = art?.hollow
5931
- ? `${art.hole_pct}% of this cutout is transparent HOLES inside the art. If it's meant to be a ring/frame/donut, ignore this. ` +
5932
- `Otherwise the key ate the fill: the art was drawn as an outline with a bare ${keyColor} interior, or filled in a near-${keyColor} shade. ` +
5933
- `Fix it in the PROMPT ask for "a closed, solidly filled shape, no outline-only or hollow art, nothing in ${keyColor} or any near-shade of it, ` +
5934
- `fully opaque, no translucency or glow" and re-generate. (--tolerance lower can rescue a near-shade fill; nothing rescues an empty one.)`
5935
- : null;
6069
+ const hollowNote = !art?.hollow
6070
+ ? null
6071
+ : keyMode === "smart"
6072
+ // The smart keyer can't reach an enclosed interior, so a hole is almost
6073
+ // always drawn that wayor the fill is OPEN to the background.
6074
+ ? `${art.hole_pct}% of this cutout is transparent HOLES inside the art. The smart keyer can't delete an enclosed interior, so this is ` +
6075
+ `most likely real (a ring, frame, donut, or separate pieces with background between them). If it looks wrong, the shape's fill is open ` +
6076
+ `to the background through a gap in its outline — ask the prompt for a closed, solidly filled shape.`
6077
+ : `${art.hole_pct}% of this cutout is transparent HOLES inside the art. If it's meant to be a ring/frame/donut, ignore this. ` +
6078
+ `Otherwise the flat key ate the fill: the art was drawn as an outline with a bare ${keyColor} interior, or filled in a near-${keyColor} shade. ` +
6079
+ `Re-run without --key-mode flat (the smart keyer keeps enclosed interiors), or fix it in the PROMPT — "a closed, solidly filled shape, ` +
6080
+ `nothing in ${keyColor} or any near-shade of it, fully opaque".`;
5936
6081
  if (ctx.json) {
5937
6082
  printJson({
5938
6083
  ok: true,
@@ -5947,6 +6092,9 @@ async function runCutoutCommand(argv) {
5947
6092
  area_reduced_pct: shrinkPct,
5948
6093
  bytes: safeSize(trim.outputPath),
5949
6094
  key_color: keyColor,
6095
+ key_mode: keyMode,
6096
+ keyed_pct: cutoutKey?.stats.keyed_pct ?? null,
6097
+ protected_px: cutoutKey?.stats.protected_px ?? null,
5950
6098
  hole_pct: art?.hole_pct ?? null,
5951
6099
  hollow: art?.hollow ?? false,
5952
6100
  hollow_note: hollowNote
@@ -6166,7 +6314,7 @@ function slugifyStickerName(raw) {
6166
6314
  * in the parts that make segmentation work: a spread-out grid and, above all,
6167
6315
  * visible plate-colored GAPS between items (touching items merge into one
6168
6316
  * island and come out as a single sticker). */
6169
- function stickerSheetInstruction(keyColorHex, count, items) {
6317
+ function stickerSheetInstruction(keyColorHex, count, items, keyMode = "smart") {
6170
6318
  const named = /^#?00ff00$/i.test(keyColorHex) ? "pure chroma-key green"
6171
6319
  : /^#?0047bb$/i.test(keyColorHex) ? "solid chroma-key blue"
6172
6320
  : /^#?ff00ff$/i.test(keyColorHex) ? "solid magenta"
@@ -6180,7 +6328,10 @@ function stickerSheetInstruction(keyColorHex, count, items) {
6180
6328
  `nothing touching, overlapping, or connected. Keep a wide ${keyColorHex} margin around the edges of the sheet too. ` +
6181
6329
  `No drop shadows, no reflections, no text, no labels, ` +
6182
6330
  `no frames or dividing lines between items. One consistent art style across all items, crisp clean edges, sticker/cutout style. ` +
6183
- keySafeArtInstruction(keyColorHex));
6331
+ // The art rules depend on which keyer will cut this sheet: the smart keyer
6332
+ // only deletes background that reaches the sheet's edge, so it can allow
6333
+ // hollow shapes and plate-colored detail that a flat chromakey cannot.
6334
+ (keyMode === "smart" ? connectivitySafeArtInstruction(keyColorHex) : keySafeArtInstruction(keyColorHex)));
6184
6335
  }
6185
6336
  async function runStickerPackCommand(argv) {
6186
6337
  const parsed = parseArgs({
@@ -6205,6 +6356,12 @@ async function runStickerPackCommand(argv) {
6205
6356
  tolerance: { type: "string" },
6206
6357
  softness: { type: "string" },
6207
6358
  "no-despill": { type: "boolean", default: false },
6359
+ // How the plate is removed, and how the sheet is laid out.
6360
+ "key-mode": { type: "string" }, // smart (default) | flat
6361
+ "sheet-mode": { type: "string" }, // auto (default) | zoned | flat
6362
+ zones: { type: "string" }, // auto (default) | off | RxC
6363
+ "zone-cols": { type: "string" },
6364
+ refine: { type: "boolean", default: false },
6208
6365
  // Segmentation knobs.
6209
6366
  "min-area": { type: "string" },
6210
6367
  gap: { type: "string" },
@@ -6249,13 +6406,71 @@ async function runStickerPackCommand(argv) {
6249
6406
  if (!generatePrompt && !sourceArg) {
6250
6407
  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).");
6251
6408
  }
6409
+ // ---- How to key, and how to lay the sheet out -----------------------------
6410
+ // KEY MODE. `smart` (default) keys by CONNECTIVITY: it flood-fills the plate
6411
+ // inward from the sheet's edge, so only background that reaches the edge is
6412
+ // deleted. Plate-colored art interiors and hollow/outline shapes survive, and
6413
+ // edges come out feathered and de-spilled per pixel. `flat` is the original
6414
+ // ffmpeg chromakey — every pixel that looks like the key color goes, wherever
6415
+ // it is. Kept as a first-class fallback: it is the cloud path's exact filter
6416
+ // chain, so it's the way to reproduce a cloud render bit-for-bit.
6417
+ const keyModeRaw = (parsed.values["key-mode"] ?? "smart").trim().toLowerCase();
6418
+ if (!["smart", "flat"].includes(keyModeRaw)) {
6419
+ throw new Error(`Unknown --key-mode "${keyModeRaw}". Use smart (default — connectivity fill, keeps plate-colored art) or flat (the plain ffmpeg chromakey).`);
6420
+ }
6421
+ const keyMode = keyModeRaw;
6422
+ // SHEET MODE. `zoned` puts every item on its OWN colored panel, so each item's
6423
+ // plate is chosen against that item — a green frog on magenta beside a pink
6424
+ // flower on green. `flat` is the classic ONE-color sheet. `auto` (default)
6425
+ // zones a named multi-item generation and stays flat otherwise, because a
6426
+ // weaker image model may not honor a color-block grid; if the model ignores it
6427
+ // anyway, keying falls back to a flat plate automatically (see below).
6428
+ const sheetModeRaw = (parsed.values["sheet-mode"] ?? "auto").trim().toLowerCase();
6429
+ if (!["auto", "zoned", "flat"].includes(sheetModeRaw)) {
6430
+ throw new Error(`Unknown --sheet-mode "${sheetModeRaw}". Use auto (default), zoned (one plate color per item — needs a model that follows a color-block grid), or flat (one plate color for the whole sheet).`);
6431
+ }
6432
+ if (sheetModeRaw === "zoned" && keyMode === "flat") {
6433
+ throw new Error("--sheet-mode zoned needs the smart keyer (a flat chromakey can only remove one color per sheet). Drop --key-mode flat, or use --sheet-mode flat.");
6434
+ }
6435
+ if (sheetModeRaw === "zoned" && !generatePrompt) {
6436
+ throw new Error("--sheet-mode zoned only applies when GENERATING a sheet (it plans the panel colors). For a zoned sheet you already have, pass --zones auto (the default) or --zones RxC.");
6437
+ }
6438
+ const wantZonedSheet = generatePrompt
6439
+ ? sheetModeRaw === "zoned" || (sheetModeRaw === "auto" && keyMode === "smart" && itemNames.length >= 2 && !explicitKeyColor)
6440
+ : false;
6441
+ // ZONES: how to READ panels back off a sheet at key time. Independent of
6442
+ // sheet-mode, because a zoned sheet can also arrive from a web generator.
6443
+ const zonesRaw = (parsed.values.zones ?? "auto").trim().toLowerCase();
6444
+ let declaredGrid = null;
6445
+ if (!["auto", "off", "none"].includes(zonesRaw)) {
6446
+ const m = /^(\d+)\s*[x×]\s*(\d+)$/.exec(zonesRaw);
6447
+ if (!m)
6448
+ throw new Error(`Unknown --zones "${zonesRaw}". Use auto (default — read the panels off the sheet), off (one plate for the whole sheet), or a grid like 3x2 (rows x columns).`);
6449
+ declaredGrid = { rows: Number(m[1]), cols: Number(m[2]) };
6450
+ }
6451
+ const zonesOff = ["off", "none"].includes(zonesRaw);
6452
+ const refine = Boolean(parsed.values.refine);
6252
6453
  // PLATE CHOICE. A chroma key removes exactly one color, so a plate the art
6253
6454
  // also uses keys holes through the art — a pack of leaves/frogs/money on GREEN
6254
6455
  // is the classic own-goal. When generating and the caller didn't pin a color,
6255
6456
  // read the subject and move the plate off any hue it mentions.
6256
6457
  let plateNote = null;
6257
6458
  let plateAuto = false;
6258
- if (generatePrompt && !explicitKeyColor && !parsed.values["no-auto-key"]) {
6459
+ let zonePlan = null;
6460
+ if (wantZonedSheet) {
6461
+ // ZONED: no single plate to pick — every panel gets its own, chosen against
6462
+ // the item that sits on it.
6463
+ zonePlan = planZonedSheet(itemNames, itemNames.length, pickPlateColor, {
6464
+ theme: generatePrompt,
6465
+ cols: parsed.values["zone-cols"] !== undefined ? Math.max(1, Math.round(Number(parsed.values["zone-cols"]))) : undefined
6466
+ });
6467
+ plateAuto = true;
6468
+ plateNote =
6469
+ `Zoned sheet: ${zonePlan.rows}×${zonePlan.cols} panels, one plate color per item ` +
6470
+ `(${zonePlan.cells.map((c) => `${c.name ?? `item ${c.index}`} on ${c.keyColor}`).join(", ")}). ` +
6471
+ `Each item's art can use ANY color — including the colors of the other panels.`;
6472
+ }
6473
+ else if (generatePrompt && !explicitKeyColor && !parsed.values["no-auto-key"]) {
6259
6474
  const picked = pickPlateColor([generatePrompt, itemNames.join(" ")].join(" "));
6260
6475
  keyColor = picked.keyColor;
6261
6476
  plateAuto = true;
@@ -6290,7 +6505,9 @@ async function runStickerPackCommand(argv) {
6290
6505
  "back; this command then splits it for $0"
6291
6506
  });
6292
6507
  const refs = await resolveReferenceUrls(ctx, parsed.values.ref);
6293
- const fullPrompt = `${generatePrompt}. ${stickerSheetInstruction(keyColor, count, itemNames)}`;
6508
+ const fullPrompt = zonePlan
6509
+ ? `${generatePrompt}. ${zonedSheetInstruction(zonePlan, { theme: generatePrompt })}`
6510
+ : `${generatePrompt}. ${stickerSheetInstruction(keyColor, count, itemNames, keyMode)}`;
6294
6511
  const payload = { prompt: fullPrompt };
6295
6512
  // A sheet wants room to spread items out — square by default, not portrait.
6296
6513
  payload.aspect_ratio = parsed.values["aspect-ratio"] ?? "1:1";
@@ -6303,8 +6520,11 @@ async function runStickerPackCommand(argv) {
6303
6520
  const tracer = parsed.values.tracer ?? `devcli-stickerpack-${Date.now().toString(36)}`;
6304
6521
  if (!ctx.json && plateNote)
6305
6522
  console.log(`${YELLOW}Plate:${RESET} ${DIM}${plateNote}${RESET}`);
6306
- if (!ctx.json)
6307
- console.log(`${DIM}Generating ONE ${keyColor} plate holding ${count} items…${RESET}`);
6523
+ if (!ctx.json) {
6524
+ console.log(zonePlan
6525
+ ? `${DIM}Generating ONE color-block sheet — ${zonePlan.rows}×${zonePlan.cols} panels, ${count} items, a different plate under each…${RESET}`
6526
+ : `${DIM}Generating ONE ${keyColor} plate holding ${count} items…${RESET}`);
6527
+ }
6308
6528
  const submit = await apiRequest({ method: "POST", host: ctx.host, path: "/api/v1/primitives/images/generate", auth: ctx.auth, body: { tracer, payload } });
6309
6529
  assertApiOk(submit, "sticker-pack generate");
6310
6530
  const jobId = submit.json?.job_id;
@@ -6344,12 +6564,12 @@ async function runStickerPackCommand(argv) {
6344
6564
  await downloadUrlToFile(sourceUrl, sheetPath);
6345
6565
  }
6346
6566
  }
6347
- // ---- 2. Key the flat plate ONCE → a transparent sheet ---------------------
6567
+ // ---- 2. Key the plate(s) ONCE → a transparent sheet ------------------------
6348
6568
  // For a sheet we did NOT generate, read the plate off the image instead of
6349
6569
  // assuming green: the four corners of a sticker sheet are always plate, so
6350
6570
  // a user can hand back a red/purple/blue sheet from a web generator and the
6351
6571
  // cut still works without them knowing what a chroma key is.
6352
- if (!explicitKeyColor && !generatePrompt && !parsed.values["no-auto-key"]) {
6572
+ if (!explicitKeyColor && !generatePrompt && !parsed.values["no-auto-key"] && (keyMode === "flat" || zonesOff)) {
6353
6573
  const detected = await detectPlateColor(sheetPath);
6354
6574
  if (detected && detected.hex.toUpperCase() !== keyColor.toUpperCase()) {
6355
6575
  keyColor = detected.hex;
@@ -6363,9 +6583,83 @@ async function runStickerPackCommand(argv) {
6363
6583
  console.log(`${YELLOW}Plate:${RESET} ${DIM}${plateNote}${RESET}`);
6364
6584
  }
6365
6585
  const keyedSheet = path.join(work, "keyed-sheet.png");
6366
- if (!ctx.json)
6367
- console.log(`${DIM}Keying out the ${presetRaw ?? keyColor} plate locally with ffmpeg (free)…${RESET}`);
6368
- await removeGreenscreenLocal({ sourcePath: sheetPath, mediaType: "image", outputPath: keyedSheet, keyColor, tolerance, softness, despill });
6586
+ let smart = null;
6587
+ let zonesUsed = [];
6588
+ let keyNote = null;
6589
+ if (keyMode === "flat") {
6590
+ if (!ctx.json)
6591
+ console.log(`${DIM}Keying out the ${presetRaw ?? keyColor} plate locally with ffmpeg chromakey (--key-mode flat, free)…${RESET}`);
6592
+ await removeGreenscreenLocal({ sourcePath: sheetPath, mediaType: "image", outputPath: keyedSheet, keyColor, tolerance, softness, despill });
6593
+ }
6594
+ else {
6595
+ // SMART. Zones come from (a) the grid we asked the model for, (b) a grid the
6596
+ // caller declared, (c) the sheet's own edges, or (d) one plate for the whole
6597
+ // sheet. Panels are keyed independently, each with its own color read back
6598
+ // off that panel's corners.
6599
+ const grid = declaredGrid ?? (zonePlan && !zonesOff ? { rows: zonePlan.rows, cols: zonePlan.cols } : null);
6600
+ if (!ctx.json) {
6601
+ console.log(grid || !zonesOff
6602
+ ? `${DIM}Keying locally with the smart keyer (border-connected fill${grid ? `, ${grid.rows}×${grid.cols} panels` : ", auto zones"}, free)…${RESET}`
6603
+ : `${DIM}Keying the ${keyColor} plate locally with the smart keyer (border-connected fill, free)…${RESET}`);
6604
+ }
6605
+ smart = await smartKeyPlate({
6606
+ sourcePath: sheetPath,
6607
+ outputPath: keyedSheet,
6608
+ keyColor: zonesOff || explicitKeyColor ? keyColor : undefined,
6609
+ singlePlate: zonesOff,
6610
+ grid: grid ?? undefined,
6611
+ tolerance: parsed.values.tolerance !== undefined ? Number(parsed.values.tolerance) : undefined,
6612
+ softness: parsed.values.softness !== undefined ? Number(parsed.values.softness) : undefined,
6613
+ despill
6614
+ });
6615
+ // AUTOMATIC FALLBACK TO A FLAT PLATE. A weaker image model may ignore the
6616
+ // color-block grid and paint one background anyway (or paint a busy one).
6617
+ // Then some "panel" has almost nothing to key, and its item would come out
6618
+ // welded to a colored rectangle. Detect that and re-key the sheet as a
6619
+ // single plate, read off its corners — the simple method, still free.
6620
+ // Two ways a panel says "this sheet isn't the grid I asked for":
6621
+ // • it keyed almost NOTHING (25%) — the color read at its corners isn't a
6622
+ // plate, so the panel is really part of one big background or of the art;
6623
+ // • it keyed EVERYTHING — the item filled the panel corner to corner, so
6624
+ // the item's own color was read as the plate and the item was deleted.
6625
+ // The second only counts for a panel we expected to hold an item: a trailing
6626
+ // panel of a grid wider than the item list is legitimately all plate.
6627
+ const expectedItems = zonePlan ? zonePlan.cells.length : itemNames.length;
6628
+ const weakZone = smart.stats.perZone.find((z) => z.keyed_pct < 25 || (z.keyed_pct > 99.5 && expectedItems > 0 && z.zone <= expectedItems));
6629
+ if (smart.stats.zones.length > 1 && weakZone) {
6630
+ const detected = await detectPlateColor(sheetPath);
6631
+ keyColor = detected?.hex ?? keyColor;
6632
+ plateAuto = Boolean(detected);
6633
+ keyNote =
6634
+ `Panel ${weakZone.zone} (${weakZone.hex}) keyed ${weakZone.keyed_pct}% of itself — ` +
6635
+ `${weakZone.keyed_pct < 25 ? "there's no plate there" : "its item filled the panel corner to corner, so the item's own color read as the plate"}, ` +
6636
+ `i.e. the sheet isn't really a color-block grid, ` +
6637
+ `so it was re-keyed as ONE ${keyColor} plate${detected ? " (read off the sheet's corners)" : ""}. ` +
6638
+ `That's the simple path and it works; for zoned sheets the image model has to follow a color-block grid ` +
6639
+ `(try a stronger model, or pass --sheet-mode flat to skip zoning entirely).`;
6640
+ smart = await smartKeyPlate({
6641
+ sourcePath: sheetPath,
6642
+ outputPath: keyedSheet,
6643
+ keyColor,
6644
+ singlePlate: true,
6645
+ tolerance: parsed.values.tolerance !== undefined ? Number(parsed.values.tolerance) : undefined,
6646
+ softness: parsed.values.softness !== undefined ? Number(parsed.values.softness) : undefined,
6647
+ despill
6648
+ });
6649
+ zonePlan = null; // panel→name mapping is meaningless once we fall back
6650
+ if (!ctx.json)
6651
+ console.log(`${YELLOW}Zones:${RESET} ${DIM}${keyNote}${RESET}`);
6652
+ }
6653
+ zonesUsed = smart.stats.zones;
6654
+ if (zonesUsed.length === 1)
6655
+ keyColor = zonesUsed[0].hex;
6656
+ if (!ctx.json) {
6657
+ const zoneWord = zonesUsed.length > 1 ? `${zonesUsed.length} panels (${zonesUsed.map((z) => z.hex).join(", ")})` : `one ${keyColor} plate`;
6658
+ console.log(`${DIM}Keyed ${smart.stats.keyed_pct}% of the sheet — ${zoneWord}` +
6659
+ (smart.stats.protected_px ? `, kept ${smart.stats.protected_px.toLocaleString()} plate-colored pixel(s) INSIDE the art that a flat key would have punched out` : "") +
6660
+ `.${RESET}`);
6661
+ }
6662
+ }
6369
6663
  if (parsed.values["keep-sheet"]) {
6370
6664
  const keepPath = path.resolve(process.cwd(), String(parsed.values["keep-sheet"]));
6371
6665
  mkdirSync(path.dirname(keepPath), { recursive: true });
@@ -6387,6 +6681,43 @@ async function runStickerPackCommand(argv) {
6387
6681
  if (!seg.components.length) {
6388
6682
  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).");
6389
6683
  }
6684
+ // ---- 3a. Names, by PANEL when the sheet is zoned -------------------------
6685
+ // On a flat sheet, names can only be matched to items in reading order, which
6686
+ // goes off-by-one the moment two items merge or one splits. A zoned sheet
6687
+ // makes it exact: panel N holds the item we asked for in panel N, so an item's
6688
+ // name comes from whichever panel its box sits in.
6689
+ const zoneIndexOf = (c) => {
6690
+ if (zonesUsed.length < 2)
6691
+ return -1;
6692
+ const cx = c.x + c.width / 2;
6693
+ const cy = c.y + c.height / 2;
6694
+ return zonesUsed.findIndex((z) => cx >= z.x && cx < z.x + z.width && cy >= z.y && cy < z.y + z.height);
6695
+ };
6696
+ const nameOf = (c) => {
6697
+ if (zonePlan) {
6698
+ const zi = zoneIndexOf(c);
6699
+ if (zi >= 0)
6700
+ return zonePlan.cells.find((cell) => cell.index === zi + 1)?.name ?? null;
6701
+ }
6702
+ return itemNames[c.index - 1] ?? null;
6703
+ };
6704
+ // A panel that yielded NO sticker is worth saying out loud: either it was
6705
+ // empty (a grid wider than the item list — fine) or its item was keyed away
6706
+ // with the panel. The pack is still usable, so this warns rather than fails.
6707
+ let emptyPanelNote = null;
6708
+ if (zonesUsed.length > 1) {
6709
+ const empty = zonesUsed
6710
+ .map((_, i) => i + 1)
6711
+ .filter((panel) => !seg.components.some((c) => zoneIndexOf(c) === panel - 1));
6712
+ if (empty.length) {
6713
+ emptyPanelNote =
6714
+ `Panel(s) ${empty.join(", ")} produced no sticker. If those panels were meant to be empty, ignore this — otherwise the item ` +
6715
+ `there touched its panel's edges (so its own color read as the plate and it keyed away). Re-run with --zones off, or ` +
6716
+ `--key-color <hex> to pin the plate.`;
6717
+ if (!ctx.json)
6718
+ console.log(`${YELLOW}Panels:${RESET} ${DIM}${emptyPanelNote}${RESET}`);
6719
+ }
6720
+ }
6390
6721
  // ---- 3b. Hollow-sticker check -------------------------------------------
6391
6722
  // The other half of "did the key work": items that came back as a rim around
6392
6723
  // a transparent hole, because the art was drawn as an outline (interior left
@@ -6396,14 +6727,24 @@ async function runStickerPackCommand(argv) {
6396
6727
  // which is why this warns and never blocks.
6397
6728
  let hollowNote = null;
6398
6729
  if (seg.hollow.length) {
6399
- const named = seg.hollow.map((i) => `${String(i).padStart(2, "0")}${itemNames[i - 1] ? ` (${itemNames[i - 1]})` : ""} — ${seg.components.find((c) => c.index === i)?.hole_pct}% see-through`);
6400
- hollowNote =
6401
- `${seg.hollow.length} of ${seg.components.length} sticker(s) came out mostly hollow: ${named.join(", ")}. ` +
6402
- `If those are meant to be rings/frames/donuts, ignore this. Otherwise the key ate their FILL: the art was drawn ` +
6403
- `as an outline with a bare ${keyColor} interior, or filled in a near-${keyColor} shade. Fix it in the PROMPT, not the keyer — ` +
6404
- `ask for "closed, solidly filled shapes, no outline-only or hollow objects, nothing on the art in ${keyColor} or any ` +
6405
- `near-shade of it, fully opaque, no translucency or glow" then re-generate the sheet. ` +
6406
- `(Lowering --tolerance can rescue a near-shade fill from an existing sheet, but not a genuinely empty one.)`;
6730
+ const named = seg.hollow.map((i) => {
6731
+ const c = seg.components.find((comp) => comp.index === i);
6732
+ const label = c ? nameOf(c) : null;
6733
+ return `${String(i).padStart(2, "0")}${label ? ` (${label})` : ""} ${c?.hole_pct}% see-through`;
6734
+ });
6735
+ hollowNote = keyMode === "smart"
6736
+ // Under the smart keyer an ENCLOSED interior can't be keyed away, so a
6737
+ // hole here is either genuinely drawn art (a ring, a frame) or a gap that
6738
+ // the plate really did reach through — not the old "the key ate the fill".
6739
+ ? `${seg.hollow.length} of ${seg.components.length} sticker(s) have large transparent areas inside them: ${named.join(", ")}. ` +
6740
+ `With the smart keyer a shape's ENCLOSED interior can't be keyed away, so this is most likely real: a ring, frame, donut, ` +
6741
+ `or an item drawn as separate pieces with plate showing between them. If it looks wrong, the art's fill is probably OPEN ` +
6742
+ `to the background (a gap in the outline lets the fill reach the sheet edge) — ask the prompt for closed, solidly filled shapes.`
6743
+ : `${seg.hollow.length} of ${seg.components.length} sticker(s) came out mostly hollow: ${named.join(", ")}. ` +
6744
+ `If those are meant to be rings/frames/donuts, ignore this. Otherwise the flat key ate their FILL: the art was drawn ` +
6745
+ `as an outline with a bare ${keyColor} interior, or filled in a near-${keyColor} shade. Either re-run without ` +
6746
+ `--key-mode flat (the smart keyer keeps enclosed interiors), or fix it in the PROMPT — ` +
6747
+ `"closed, solidly filled shapes, nothing on the art in ${keyColor} or any near-shade of it, fully opaque".`;
6407
6748
  if (!ctx.json)
6408
6749
  console.log(`${YELLOW}Hollow:${RESET} ${DIM}${hollowNote}${RESET}`);
6409
6750
  }
@@ -6411,13 +6752,24 @@ async function runStickerPackCommand(argv) {
6411
6752
  // Report the boxes without writing stickers — for eyeballing segmentation
6412
6753
  // before spending disk, and for hand-fixing a merged item with `mask --crop`.
6413
6754
  if (ctx.json) {
6414
- 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, hollow: seg.hollow, hollow_note: hollowNote, items: seg.components });
6755
+ printJson({
6756
+ ok: true, target: "local", dry_run: true, sheet: `${seg.sourceWidth}x${seg.sourceHeight}`,
6757
+ found: seg.components.length, skipped_specks: seg.rejected, key_mode: keyMode,
6758
+ key_color: keyColor, key_color_auto: plateAuto,
6759
+ zones: zonesUsed.map((z, i) => ({ panel: i + 1, x: z.x, y: z.y, width: z.width, height: z.height, plate: z.hex, keyed_pct: smart?.stats.perZone[i]?.keyed_pct ?? null })),
6760
+ protected_px: smart?.stats.protected_px ?? null,
6761
+ hollow: seg.hollow, hollow_note: hollowNote,
6762
+ items: seg.components.map((c) => ({ ...c, name: nameOf(c), panel: zoneIndexOf(c) >= 0 ? zoneIndexOf(c) + 1 : null }))
6763
+ });
6415
6764
  }
6416
6765
  else {
6417
6766
  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}`);
6418
6767
  for (const c of seg.components) {
6419
6768
  const hollowFlag = c.hole_pct >= HOLE_WARN_PCT ? ` ${YELLOW}⚠ ${c.hole_pct}% hollow${RESET}` : "";
6420
- 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}${hollowFlag}`);
6769
+ const zi = zoneIndexOf(c);
6770
+ const where = zi >= 0 ? ` panel ${zi + 1} (${zonesUsed[zi].hex})` : "";
6771
+ const label = nameOf(c);
6772
+ 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)${where}${label ? ` ${label}` : ""}${RESET}${hollowFlag}`);
6421
6773
  }
6422
6774
  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}`);
6423
6775
  }
@@ -6428,11 +6780,56 @@ async function runStickerPackCommand(argv) {
6428
6780
  const padPx = parsed.values.pad !== undefined ? Number(parsed.values.pad) : 0;
6429
6781
  const gifAlpha = parsed.values["gif-alpha"] !== undefined ? Number(parsed.values["gif-alpha"]) : undefined;
6430
6782
  const written = [];
6783
+ if (refine && !ctx.json) {
6784
+ console.log(`${DIM}Refining each item with the local ONNX matting model (free, a second or two each) — this is the path for painterly/soft-edged art…${RESET}`);
6785
+ }
6431
6786
  for (const c of seg.components) {
6432
- const label = itemNames[c.index - 1] ? slugifyStickerName(itemNames[c.index - 1]) : "";
6787
+ const itemName = nameOf(c);
6788
+ const label = itemName ? slugifyStickerName(itemName) : "";
6433
6789
  const stem = `${prefix}-${String(c.index).padStart(2, "0")}${label ? `-${label}` : ""}`;
6434
6790
  const regionPath = path.join(work, `${stem}.region.png`);
6435
- await cropImageRegion({ sourcePath: keyedSheet, outputPath: regionPath, x: c.x, y: c.y, width: c.width, height: c.height });
6791
+ let refined = false;
6792
+ if (refine) {
6793
+ // REFINE. The keyer decides WHERE each item is; matting decides what its
6794
+ // alpha should be. Cropping the item out of the ORIGINAL (un-keyed) sheet
6795
+ // and running the ONNX matte on it is color-blind, so it handles the art a
6796
+ // chroma key can't: soft/painterly edges, fur, glows, glass, drop shadows,
6797
+ // and an item whose own silhouette is the plate color. A little margin
6798
+ // gives the model context to separate against.
6799
+ const margin = Math.round(Math.max(c.width, c.height) * 0.08);
6800
+ const rawRegion = path.join(work, `${stem}.raw.png`);
6801
+ await cropImageRegion({
6802
+ sourcePath: sheetPath,
6803
+ outputPath: rawRegion,
6804
+ x: Math.max(0, c.x - margin),
6805
+ y: Math.max(0, c.y - margin),
6806
+ width: c.width + margin * 2,
6807
+ height: c.height + margin * 2
6808
+ });
6809
+ const matted = path.join(work, `${stem}.matte.png`);
6810
+ const run = await runHyperframesCommand("remove-background", [rawRegion, "-o", matted], { stdio: "capture" });
6811
+ // Trust the matte only if it looks like a cut-out. The model is trained on
6812
+ // photographic subjects, so on flat vector art it can return an EMPTY
6813
+ // matte (blank sticker) or an untouched one (plate still attached) — both
6814
+ // of which write a file and exit 0. The keyed cut is the safer answer then.
6815
+ const opaqueShare = run.code === 0 && existsSync(matted) ? await measureOpaqueShare(matted) : null;
6816
+ const usable = opaqueShare !== null && opaqueShare >= 5 && opaqueShare <= 95;
6817
+ if (usable) {
6818
+ copyFileSync(matted, regionPath);
6819
+ refined = true;
6820
+ }
6821
+ else if (!ctx.json) {
6822
+ const why = run.code !== 0
6823
+ ? (run.stderr.trim().split("\n").pop() ?? "the matting engine failed")
6824
+ : opaqueShare === null
6825
+ ? "the matte had no alpha"
6826
+ : `the matte came back ${opaqueShare < 5 ? "empty" : "fully opaque"} (${opaqueShare}% opaque) — the model didn't see a subject in this art`;
6827
+ console.log(` ${YELLOW}↳${RESET} ${DIM}item ${c.index}: kept the keyed cut instead of the matte — ${why}.${RESET}`);
6828
+ }
6829
+ }
6830
+ if (!refined) {
6831
+ await cropImageRegion({ sourcePath: keyedSheet, outputPath: regionPath, x: c.x, y: c.y, width: c.width, height: c.height });
6832
+ }
6436
6833
  // Trim to the item's TRUE box: segmentation works on a downscaled alpha, so
6437
6834
  // the crop carries a pixel or two of slack — this is what makes the sticker's
6438
6835
  // pixel size equal the subject, the property placement/scaling relies on.
@@ -6443,13 +6840,17 @@ async function runStickerPackCommand(argv) {
6443
6840
  finalPath = path.join(outDir, `${stem}.gif`);
6444
6841
  await encodeTransparentGif(trim.outputPath, finalPath, { alphaThreshold: gifAlpha });
6445
6842
  }
6843
+ const zi = zoneIndexOf(c);
6446
6844
  written.push({
6447
6845
  index: c.index,
6448
- name: itemNames[c.index - 1] ?? null,
6846
+ name: itemName,
6449
6847
  file: finalPath,
6450
6848
  width: trim.width,
6451
6849
  height: trim.height,
6452
6850
  sheet_crop: { x: c.x, y: c.y, width: c.width, height: c.height },
6851
+ panel: zi >= 0 ? zi + 1 : null,
6852
+ plate: zi >= 0 ? zonesUsed[zi].hex : keyColor,
6853
+ refined,
6453
6854
  area_pct: c.area_pct,
6454
6855
  holes: c.holes,
6455
6856
  hole_pct: c.hole_pct,
@@ -6464,10 +6865,22 @@ async function runStickerPackCommand(argv) {
6464
6865
  // A manifest so the next step (place/keyframes, or an agent picking props by
6465
6866
  // name) doesn't have to re-derive what's in the pack.
6466
6867
  const manifestPath = path.join(outDir, "stickers.json");
6868
+ const keyReport = {
6869
+ key_mode: keyMode,
6870
+ sheet_mode: zonesUsed.length > 1 ? "zoned" : "flat",
6871
+ zones: zonesUsed.map((z, i) => ({ panel: i + 1, x: z.x, y: z.y, width: z.width, height: z.height, plate: z.hex, keyed_pct: smart?.stats.perZone[i]?.keyed_pct ?? null })),
6872
+ keyed_pct: smart?.stats.keyed_pct ?? null,
6873
+ protected_px: smart?.stats.protected_px ?? null,
6874
+ protected_pct: smart?.stats.protected_pct ?? null,
6875
+ key_note: keyNote,
6876
+ empty_panel_note: emptyPanelNote,
6877
+ refined: refine
6878
+ };
6467
6879
  writeFileSync(manifestPath, `${JSON.stringify({
6468
6880
  generated_from: generatePrompt ? "generate" : sourceArg,
6469
6881
  key_color: keyColor,
6470
6882
  key_color_auto: plateAuto,
6883
+ ...keyReport,
6471
6884
  hollow: seg.hollow,
6472
6885
  hollow_note: hollowNote,
6473
6886
  sheet_width: seg.sourceWidth,
@@ -6477,12 +6890,14 @@ async function runStickerPackCommand(argv) {
6477
6890
  stickers: written
6478
6891
  }, null, 2)}\n`);
6479
6892
  if (ctx.json) {
6480
- 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, hollow: seg.hollow, hollow_note: hollowNote, stickers: written });
6893
+ 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, ...keyReport, hollow: seg.hollow, hollow_note: hollowNote, stickers: written });
6481
6894
  }
6482
6895
  else {
6483
6896
  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}`);
6484
- if (itemNames.length && itemNames.length !== written.length) {
6485
- 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}`);
6897
+ // Names are only guessed by reading order on a FLAT sheet; a zoned sheet
6898
+ // pins each name to its panel, so the off-by-one caveat doesn't apply.
6899
+ if (itemNames.length && itemNames.length !== written.length && !zonePlan) {
6900
+ 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). A zoned sheet (--sheet-mode zoned) names items by PANEL instead of by order, which can't drift.${RESET}`);
6486
6901
  }
6487
6902
  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}`);
6488
6903
  }
@@ -6586,10 +7001,6 @@ async function runTtsCommand(argv) {
6586
7001
  throw new Error("tts --engine must be local (keyless Kokoro), byok (your provider key — default), or cloud.");
6587
7002
  }
6588
7003
  const wantCloud = Boolean(parsed.values.cloud) || engine === "cloud";
6589
- if (engine === "local") {
6590
- await runLocalKokoroTts({ text, voice, style, out: parsed.values.out, format: explicitFormat, json });
6591
- return;
6592
- }
6593
7004
  // COST-MODE DEFAULT: in minimize/hybrid, narration starts on the FREE local
6594
7005
  // Kokoro engine unless the run asked for a premium/cloud voice (see
6595
7006
  // wantsPremiumVoice). The BYOK key path is a real fallback, not a downgrade —
@@ -6597,6 +7008,18 @@ async function runTtsCommand(argv) {
6597
7008
  // below rather than failing the run.
6598
7009
  const costMode = resolveCostMode(parsed.values);
6599
7010
  const premium = wantsPremiumVoice(parsed.values, voice, style);
7011
+ // Offer the human the choice of voice BEFORE any engine runs — the notice is
7012
+ // identical whichever tier this run lands on, so it sits above the dispatch.
7013
+ printVoiceCheckpoint({
7014
+ interaction: resolveInteractionMode(parsed.values),
7015
+ costMode: costMode.mode,
7016
+ voiceChosen: Boolean(voice?.trim()),
7017
+ tier: engine === "local" || (!engine && !wantCloud && costMode.mode !== "rich-ai" && costMode.mode !== "pure-videogen" && !premium) ? "free" : "premium"
7018
+ });
7019
+ if (engine === "local") {
7020
+ await runLocalKokoroTts({ text, voice, style, out: parsed.values.out, format: explicitFormat, json });
7021
+ return;
7022
+ }
6600
7023
  /** Set when the cost-mode default ran Kokoro and it failed — so the keyless
6601
7024
  * path below doesn't pointlessly retry the same engine. */
6602
7025
  let kokoroError = null;
@@ -6694,7 +7117,7 @@ async function runTtsCommand(argv) {
6694
7117
  if (parsed.values["own-key"])
6695
7118
  payload.use_wallet_credits = false;
6696
7119
  if (!ctx.json && !parsed.values["own-key"]) {
6697
- console.log(`${DIM}Using ElevenLabs on the platform key (wallet-billed). Browse voices: vidfarm voices then pass --voice <voice_id>. Add --own-key to use your own key.${RESET}`);
7120
+ console.log(`${DIM}Premium ElevenLabs narration through VIDFARM'S OWN ElevenLabs connection — no ElevenLabs account or API key needed on your side; it's billed as vidfarm wallet credits. Hear the catalog first: vidfarm voices --sample, then pass --voice <voice_id>. Already have an ElevenLabs key? add --own-key.${RESET}`);
6698
7121
  }
6699
7122
  const tracer = parsed.values.tracer ?? `devcli-tts-${Date.now().toString(36)}`;
6700
7123
  const submit = await apiRequest({ method: "POST", host: ctx.host, path: "/api/v1/primitives/audio/speech", auth: ctx.auth, body: { tracer, payload } });
@@ -6731,6 +7154,38 @@ async function runTtsCommand(argv) {
6731
7154
  console.log(`${GREEN}Wrote ${outPath}${RESET} ${DIM}(${mediaUrl})${RESET}`);
6732
7155
  }
6733
7156
  }
7157
+ /**
7158
+ * The VOICE CHECKPOINT. Who a video sounds like is a taste decision, and the
7159
+ * default voice is the one choice an agent makes silently that a human would
7160
+ * almost always want a say in. So when narration is about to run with NO voice
7161
+ * named, say so — loudly enough that the agent stops and offers samples.
7162
+ *
7163
+ * Interactive mode → always offer (sampling is FREE on both rosters, so this
7164
+ * holds in `minimize` exactly as much as in `hybrid`; only the roster the offer
7165
+ * leads with changes). Autonomous with a mode actually set → stay silent and
7166
+ * let the pipeline run. Mode never set → offer, because "nobody asked" is not
7167
+ * the same as "the user opted out of choosing".
7168
+ *
7169
+ * stderr, never stdout, so `--json` output stays parseable.
7170
+ */
7171
+ function printVoiceCheckpoint(input) {
7172
+ if (input.voiceChosen)
7173
+ return;
7174
+ if (input.interaction.mode !== "interactive" && input.interaction.isSet)
7175
+ return;
7176
+ const lead = input.interaction.mode === "interactive"
7177
+ ? "[voice] Interactive mode and no voice picked yet"
7178
+ : "[voice] No interaction preference set and no voice picked yet";
7179
+ const free = "vidfarm voices --free --sample (renders $0 local Kokoro samples)";
7180
+ const premium = "vidfarm voices --sample (premium ElevenLabs voices through VIDFARM'S OWN ElevenLabs connection — " +
7181
+ "no ElevenLabs account or API key needed, narration billed as wallet credits, pennies each)";
7182
+ const order = input.tier === "free"
7183
+ ? `Sample the free voices first: ${free}. Same command reaches the premium tier: ${premium}.`
7184
+ : `Sample them: ${premium}. Or stay at $0: ${free}.`;
7185
+ console.error(`${lead} — ASK THE USER whether they want to hear a few samples and choose. ${order} ` +
7186
+ "Sampling costs nothing on either tier (preview clips + local renders), so it's safe even in cost mode minimize. " +
7187
+ "Then narrate with --voice <id>.");
7188
+ }
6734
7189
  /** Kokoro voice ids look like af_heart / am_adam / bf_emma. Anything else is a
6735
7190
  * cloud provider preset (openai "alloy", gemini "Kore", an ElevenLabs id).
6736
7191
  * Declared as a hoisted function, not a const: `wantsPremiumVoice` runs during
@@ -6779,7 +7234,7 @@ async function runLocalKokoroTts(input) {
6779
7234
  if (!input.json) {
6780
7235
  console.log(`${DIM}Generating speech with the local Kokoro-82M engine (free, no account)…${RESET}`);
6781
7236
  if (input.costModeDefault) {
6782
- console.log(`${DIM}(cost mode ${costModeDisplayName(input.costModeDefault)} → free local voice by default. Want a premium voice? add --style/--voice/--provider, or --cloud.)${RESET}`);
7237
+ console.log(`${DIM}(cost mode ${costModeDisplayName(input.costModeDefault)} → free local voice by default. Pick a different free voice: vidfarm voices --free --sample. Want a PREMIUM voice? vidfarm carries the ElevenLabs catalog on its own connection — no ElevenLabs account needed, just wallet credits: vidfarm voices --sample, then --cloud --voice <voice_id>.)${RESET}`);
6783
7238
  }
6784
7239
  }
6785
7240
  const run = await runHyperframesCommand("tts", [input.text, "-o", wavPath, ...(voice ? ["-v", voice] : [])], { stdio: input.json ? "capture" : "inherit" });
@@ -6965,8 +7420,53 @@ async function runMusicCommand(argv) {
6965
7420
  console.log(`${DIM}Place it as its own <audio> layer under narration (~0.1–0.2 volume).${RESET}`);
6966
7421
  }
6967
7422
  }
6968
- // List ElevenLabs voices for TTS. --own-key lists the voices on your own saved
6969
- // ElevenLabs key; default lists the platform account's voices.
7423
+ /** The sentence spoken when sampling FREE voices (premium samples reuse
7424
+ * ElevenLabs' own preview clips, so they need no text). A hoisted function,
7425
+ * not a const: cli.ts runs `await main()` at top level ABOVE this point, so a
7426
+ * const declared here hits the TDZ when a command reads it. */
7427
+ function voiceSampleText() {
7428
+ return "Here's how I sound. Pick the voice that fits your video.";
7429
+ }
7430
+ /** Pull the human-meaningful line out of a hyperframes engine failure. Its CLI
7431
+ * paints spinners and cursor codes, so the LAST line is usually an escape
7432
+ * sequence — prefer a line that actually reads like an error. */
7433
+ function engineFailureLine(output, fallback) {
7434
+ const clean = output
7435
+ // ESC-anchored so it strips real ANSI, never a literal "[mode]" in a message.
7436
+ .replace(/\u001b\[[0-9;?]*[a-zA-Z]/g, "")
7437
+ .split("\n")
7438
+ .map((line) => line.replace(/^[\s│◇◆○●✖✗ו]+/, "").trim())
7439
+ .filter((line) => line.length > 2);
7440
+ return (clean.find((line) => /fail|error|not installed|cannot|unable|missing/i.test(line)) ?? clean.pop() ?? fallback).slice(0, 240);
7441
+ }
7442
+ /** The free local Kokoro roster, straight from the bundled engine (never a
7443
+ * hardcoded list — the engine owns which ids actually load). */
7444
+ async function listFreeVoices() {
7445
+ const run = await runHyperframesCommand("tts", ["--list", "--json"], { stdio: "capture" });
7446
+ if (run.code !== 0)
7447
+ return [];
7448
+ try {
7449
+ const parsed = JSON.parse(run.stdout.slice(run.stdout.indexOf("[")));
7450
+ return Array.isArray(parsed) ? parsed : [];
7451
+ }
7452
+ catch {
7453
+ return [];
7454
+ }
7455
+ }
7456
+ // Browse the voices available for narration, and SAMPLE them so a human can
7457
+ // actually choose one by ear. Two rosters, because they cost different money:
7458
+ //
7459
+ // premium (default) — the ElevenLabs catalog reached through VIDFARM'S OWN
7460
+ // ElevenLabs connection. No ElevenLabs account, no API key, no separate
7461
+ // subscription: narration runs on the platform key and is billed as
7462
+ // Vidfarm wallet credits. `--own-key` swaps in the customer's own saved
7463
+ // ElevenLabs key instead (their account, their voices, no wallet spend).
7464
+ // free (--free) — the keyless local Kokoro-82M presets, $0 forever, the
7465
+ // default engine in cost mode minimize/hybrid.
7466
+ //
7467
+ // SAMPLING IS ALWAYS FREE on both rosters: premium samples are ElevenLabs'
7468
+ // static preview clips (a CDN download, not a synthesis call) and free samples
7469
+ // are generated on the local engine. So `--sample` is safe in `minimize`.
6970
7470
  async function runVoicesCommand(argv) {
6971
7471
  const parsed = parseArgs({
6972
7472
  args: argv,
@@ -6974,28 +7474,135 @@ async function runVoicesCommand(argv) {
6974
7474
  options: {
6975
7475
  ...commonOptions(),
6976
7476
  "own-key": { type: "boolean", default: false },
7477
+ free: { type: "boolean", default: false },
7478
+ all: { type: "boolean", default: false },
7479
+ search: { type: "string" },
7480
+ sample: { type: "boolean", default: false },
7481
+ "sample-count": { type: "string" },
7482
+ "sample-out": { type: "string" },
7483
+ "sample-text": { type: "string" },
6977
7484
  limit: { type: "string" }
6978
7485
  }
6979
7486
  });
6980
7487
  const ctx = commonContext(parsed.values);
6981
- const query = parsed.values["own-key"] ? "?use_wallet_credits=false" : "";
6982
- const res = await apiRequest({ method: "GET", host: ctx.host, path: `/api/v1/primitives/audio/voices${query}`, auth: ctx.auth });
6983
- assertApiOk(res, "voices");
6984
- const body = (res.json ?? {});
6985
- const voices = Array.isArray(body.voices) ? body.voices : [];
7488
+ const wantFree = Boolean(parsed.values.free) || Boolean(parsed.values.all);
7489
+ const wantPremium = !parsed.values.free || Boolean(parsed.values.all);
7490
+ const search = parsed.values.search?.trim().toLowerCase() || null;
7491
+ const sample = Boolean(parsed.values.sample);
7492
+ const sampleCount = Math.max(1, Number(parsed.values["sample-count"] ?? "6") || 6);
7493
+ const sampleText = parsed.values["sample-text"]?.trim() || voiceSampleText();
7494
+ const sampleDir = path.resolve(process.cwd(), parsed.values["sample-out"] ?? "voice-samples");
7495
+ const limit = Number(parsed.values.limit ?? "40") || 40;
7496
+ const matches = (haystack) => !search || haystack.toLowerCase().includes(search);
7497
+ const premium = [];
7498
+ let scope = "platform";
7499
+ let libraryUrl = "https://elevenlabs.io/app/voice-library";
7500
+ if (wantPremium) {
7501
+ const query = parsed.values["own-key"] ? "?use_wallet_credits=false" : "";
7502
+ const res = await apiRequest({ method: "GET", host: ctx.host, path: `/api/v1/primitives/audio/voices${query}`, auth: ctx.auth });
7503
+ assertApiOk(res, "voices");
7504
+ const body = (res.json ?? {});
7505
+ scope = String(body.scope ?? "platform");
7506
+ libraryUrl = String(body.voice_library_url ?? libraryUrl);
7507
+ for (const voice of Array.isArray(body.voices) ? body.voices : []) {
7508
+ const labels = voice.labels && typeof voice.labels === "object" ? Object.values(voice.labels).join(", ") : "";
7509
+ if (matches(`${voice.name ?? ""} ${labels} ${voice.description ?? ""} ${voice.category ?? ""}`)) {
7510
+ premium.push({ ...voice, label_text: labels });
7511
+ }
7512
+ }
7513
+ }
7514
+ const free = wantFree ? (await listFreeVoices()).filter((v) => matches(`${v.id} ${v.label} ${v.language} ${v.gender}`)) : [];
7515
+ // Sampling: write listenable audio to disk so the human can play it and pick.
7516
+ const samples = [];
7517
+ /** Why a sample didn't land. Never swallowed: a browse that quietly produced
7518
+ * no audio reads as "there was nothing to hear". */
7519
+ const sampleErrors = [];
7520
+ if (sample) {
7521
+ mkdirSync(sampleDir, { recursive: true });
7522
+ for (const voice of premium.filter((v) => v.preview_url).slice(0, sampleCount)) {
7523
+ const out = path.join(sampleDir, `premium-${String(voice.name ?? voice.voice_id).replace(/[^a-z0-9]+/gi, "-").toLowerCase()}-${voice.voice_id}.mp3`);
7524
+ try {
7525
+ await downloadUrlToFile(String(voice.preview_url), out);
7526
+ samples.push({ voice_id: String(voice.voice_id), name: String(voice.name ?? ""), tier: "premium", out });
7527
+ }
7528
+ catch (error) {
7529
+ // A dead preview URL must not sink the whole browse.
7530
+ sampleErrors.push({ voice_id: String(voice.voice_id), tier: "premium", error: String(error?.message ?? error).slice(0, 200) });
7531
+ }
7532
+ }
7533
+ for (const voice of free.slice(0, sampleCount)) {
7534
+ const out = path.join(sampleDir, `free-${voice.id}.wav`);
7535
+ const run = await runHyperframesCommand("tts", [sampleText, "-o", out, "-v", voice.id], { stdio: "capture" });
7536
+ if (run.code === 0 && existsSync(out)) {
7537
+ samples.push({ voice_id: voice.id, name: voice.label, tier: "free", out });
7538
+ }
7539
+ else {
7540
+ sampleErrors.push({ voice_id: voice.id, tier: "free", error: engineFailureLine(`${run.stderr}\n${run.stdout}`, `exit ${run.code}`) });
7541
+ }
7542
+ }
7543
+ }
6986
7544
  if (ctx.json) {
6987
- printJson(body);
7545
+ printJson({
7546
+ ok: true,
7547
+ scope,
7548
+ billing: parsed.values["own-key"] ? "your own ElevenLabs key" : "vidfarm platform ElevenLabs connection (wallet credits)",
7549
+ voice_library_url: libraryUrl,
7550
+ premium_voices: premium,
7551
+ free_voices: free,
7552
+ samples,
7553
+ sample_errors: sampleErrors,
7554
+ sample_dir: sample ? sampleDir : null
7555
+ });
6988
7556
  return;
6989
7557
  }
6990
- console.log(`${DIM}${voices.length} ElevenLabs voices (${body.scope ?? "platform"} account). Browse more: ${body.voice_library_url ?? "https://elevenlabs.io/app/voice-library"}${RESET}`);
6991
- const limit = Number(parsed.values.limit ?? "40") || 40;
6992
- for (const voice of voices.slice(0, limit)) {
6993
- const labels = voice.labels && typeof voice.labels === "object" ? Object.values(voice.labels).join(", ") : "";
6994
- console.log(`${GREEN}${voice.voice_id}${RESET} ${voice.name}${labels ? ` ${DIM}(${labels})${RESET}` : ""}`);
7558
+ if (wantPremium) {
7559
+ console.log(scope === "user"
7560
+ ? `${GREEN}${premium.length} premium ElevenLabs voices${RESET} ${DIM}(YOUR OWN ElevenLabs key — billed to your ElevenLabs account, no wallet spend).${RESET}`
7561
+ : `${GREEN}${premium.length} premium ElevenLabs voices${RESET} ${DIM}— available right here through VIDFARM'S OWN ElevenLabs connection. You do NOT need an ElevenLabs account, API key, or subscription: narration on these voices runs on the platform key and is billed as vidfarm wallet credits (pennies per narration). Already have an ElevenLabs key? add --own-key.${RESET}`);
7562
+ for (const voice of premium.slice(0, limit)) {
7563
+ console.log(`${GREEN}${voice.voice_id}${RESET} ${voice.name}${voice.label_text ? ` ${DIM}(${voice.label_text})${RESET}` : ""}`);
7564
+ if (voice.preview_url)
7565
+ console.log(` ${DIM}preview: ${voice.preview_url}${RESET}`);
7566
+ }
7567
+ if (premium.length > limit)
7568
+ console.log(`${DIM}… ${premium.length - limit} more (raise with --limit, narrow with --search "british narrator", or use --json).${RESET}`);
7569
+ console.log(`${DIM}Browse thousands more at ${libraryUrl}.${RESET}`);
7570
+ }
7571
+ if (wantFree) {
7572
+ console.log(`\n${GREEN}${free.length} free local voices${RESET} ${DIM}(Kokoro-82M, keyless, $0 forever — the default engine in cost mode minimize/hybrid).${RESET}`);
7573
+ for (const voice of free.slice(0, limit)) {
7574
+ console.log(`${GREEN}${voice.id}${RESET} ${voice.label} ${DIM}(${voice.language}, ${voice.gender})${RESET}`);
7575
+ }
7576
+ }
7577
+ if (!wantFree && wantPremium) {
7578
+ console.log(`${DIM}Prefer to spend nothing? vidfarm voices --free lists the $0 local voices (--all lists both).${RESET}`);
7579
+ }
7580
+ if (samples.length) {
7581
+ console.log(`\n${GREEN}Wrote ${samples.length} samples to ${sampleDir}${RESET} ${DIM}(free — premium samples are ElevenLabs' own preview clips, free samples are rendered locally; no credits spent).${RESET}`);
7582
+ for (const s of samples)
7583
+ console.log(` ${DIM}${s.tier === "free" ? "free " : "prem "}${s.voice_id} — ${s.name} → ${s.out}${RESET}`);
7584
+ console.log(`${DIM}PLAY THESE FOR THE USER and let them pick, then narrate with the id they choose.${RESET}`);
7585
+ }
7586
+ if (sampleErrors.length) {
7587
+ const freeFailed = sampleErrors.filter((e) => e.tier === "free");
7588
+ console.log(`${RED}${sampleErrors.length} sample(s) could not be produced.${RESET} ${DIM}${sampleErrors[0].error}${RESET}`);
7589
+ if (freeFailed.length) {
7590
+ console.log(`${DIM}Free samples render on the local Kokoro engine — install it once (\`pip install kokoro-onnx soundfile\`, or point HYPERFRAMES_PYTHON at a venv that has them). The PREMIUM voices need nothing installed: vidfarm voices --sample streams ElevenLabs' own preview clips over vidfarm's connection.${RESET}`);
7591
+ }
7592
+ }
7593
+ if (!sample) {
7594
+ console.log(`${DIM}Hear them before choosing: vidfarm voices --sample${parsed.values.free ? " --free" : ""} — downloads/renders ${sampleCount} samples to ./voice-samples (free, no credits).${RESET}`);
6995
7595
  }
6996
- if (voices.length > limit)
6997
- console.log(`${DIM}… ${voices.length - limit} more (raise with --limit or use --json).${RESET}`);
6998
- console.log(`${DIM}Use one: vidfarm tts "Hello" --cloud --voice <voice_id>${RESET}`);
7596
+ // Interaction mode decides whose ear picks the voice. In interactive mode the
7597
+ // human should hear samples and choose; when the mode was never set, say so
7598
+ // rather than silently picking a default voice on their behalf.
7599
+ if (ctx.interaction.mode === "interactive") {
7600
+ console.log(`${DIM}[mode] Interactive — don't pick for them: sample a few, share the files, and let the user choose the voice.${RESET}`);
7601
+ }
7602
+ else if (!ctx.interaction.isSet) {
7603
+ console.log(`${DIM}[mode] No interaction preference set — ask whether the user wants to hear samples and pick the voice themselves (\`vidfarm mode interactive\`), or wants you to choose one.${RESET}`);
7604
+ }
7605
+ console.log(`${DIM}Use one: vidfarm tts "Hello" --cloud --voice <voice_id> (free voice: vidfarm tts "Hello" --engine local --voice <kokoro_id>)${RESET}`);
6999
7606
  }
7000
7607
  async function runMediaCommand(argv) {
7001
7608
  const MEDIA_TYPES = ["image", "vector", "icon", "video", "bgm", "sfx"];
@@ -8628,6 +9235,7 @@ async function runHandoffCommand(argv) {
8628
9235
  "out-dir": { type: "string" },
8629
9236
  grid: { type: "string" },
8630
9237
  single: { type: "boolean", default: false },
9238
+ zoned: { type: "boolean", default: false },
8631
9239
  // raws
8632
9240
  keywords: { type: "string" },
8633
9241
  platforms: { type: "string" },
@@ -8650,7 +9258,8 @@ async function runHandoffCommand(argv) {
8650
9258
  keyColor: parsed.values["key-color"],
8651
9259
  outDir: parsed.values["out-dir"],
8652
9260
  grid: parsed.values.grid,
8653
- pack: parsed.values.single ? false : undefined
9261
+ pack: parsed.values.single ? false : undefined,
9262
+ zoned: Boolean(parsed.values.zoned)
8654
9263
  });
8655
9264
  if (json)
8656
9265
  return printJson({ ok: true, kind: "image", pack: !parsed.values.single, ...brief });
@@ -10139,31 +10748,52 @@ async function runQaCommand(argv) {
10139
10748
  options: {
10140
10749
  json: { type: "boolean", default: false },
10141
10750
  strict: { type: "boolean", default: false },
10751
+ harness: { type: "string", multiple: true },
10752
+ "no-harness": { type: "boolean", default: false },
10753
+ // Pre-rename aliases; `harness` is the name everywhere else now.
10142
10754
  regime: { type: "string", multiple: true },
10143
10755
  "no-regime": { type: "boolean", default: false }
10144
10756
  }
10145
10757
  });
10146
10758
  const target = parsed.positionals[0];
10147
10759
  if (!target)
10148
- throw new Error("qa requires a composition path: `vidfarm qa <dir-or-composition.html> [--regime <name|path>] [--json] [--strict]`.");
10760
+ throw new Error("qa requires a composition path: `vidfarm qa <dir-or-composition.html> [--harness <name|path>] [--json] [--strict]`.");
10149
10761
  const htmlPath = resolveCompositionHtmlPath(target);
10150
10762
  const html = readFileSync(htmlPath, "utf8");
10151
10763
  let report = qaCompositionHtml(html);
10152
- // Regime resolution, in precedence order: explicit --regime flags (stackable)
10153
- // → VIDFARM_QA_REGIME (a default for a whole scripting run) → the working
10154
- // directory's own QA_REGIME.md. --no-regime opts out of the implicit paths.
10155
- const explicit = parsed.values.regime ?? [];
10156
- const fromEnv = (process.env.VIDFARM_QA_REGIME ?? "").split(",").map((entry) => entry.trim()).filter(Boolean);
10157
- let regimeRefs = explicit.length ? explicit : fromEnv;
10158
- if (!regimeRefs.length && !parsed.values["no-regime"]) {
10159
- const discovered = discoverRegime(path.dirname(htmlPath));
10160
- if (discovered)
10161
- regimeRefs = [discovered];
10162
- }
10163
- if (regimeRefs.length) {
10164
- const facts = extractCompositionFacts(html);
10165
- report = mergeRegimeIntoReport(report, regimeRefs.map((ref) => loadAndEvaluateRegime(ref, facts)));
10764
+ // Harness resolution, in precedence order: explicit --harness flags
10765
+ // (stackable) VIDFARM_HARNESS (a default for a whole scripting run) → the
10766
+ // working directory's own HARNESS.md. --no-harness opts out of the implicit
10767
+ // paths. The `regime`-spelled forms are the pre-rename aliases.
10768
+ const explicit = [
10769
+ ...(parsed.values.harness ?? []),
10770
+ ...(parsed.values.regime ?? [])
10771
+ ];
10772
+ const envValue = process.env.VIDFARM_HARNESS ?? process.env.VIDFARM_QA_REGIME ?? "";
10773
+ const fromEnv = envValue.split(",").map((entry) => entry.trim()).filter(Boolean);
10774
+ let harnessRefs = explicit.length ? explicit : fromEnv;
10775
+ const optedOut = Boolean(parsed.values["no-harness"]) || Boolean(parsed.values["no-regime"]);
10776
+ if (!harnessRefs.length && !optedOut) {
10777
+ const discovered = discoverHarness(path.dirname(htmlPath));
10778
+ if (discovered) {
10779
+ harnessRefs = [discovered];
10780
+ if (isLegacyHarnessFilename(discovered)) {
10781
+ console.log(`${DIM}Using ${path.basename(discovered)} — the file is now called ${HARNESS_FILENAME}. Rename it when convenient; both are read.${RESET}`);
10782
+ }
10783
+ }
10166
10784
  }
10785
+ if (harnessRefs.length) {
10786
+ const facts = extractCompositionFacts(html);
10787
+ report = mergeHarnessIntoReport(report, harnessRefs.map((ref) => loadAndEvaluateHarness(ref, facts)));
10788
+ }
10789
+ // The directive is on the report already; re-issue it with THIS work dir (and
10790
+ // an existing render, if there is one) substituted into the commands.
10791
+ const workDir = path.dirname(htmlPath);
10792
+ const render = newestRenderIn(workDir);
10793
+ report = {
10794
+ ...report,
10795
+ watch_the_video: watchTheVideoDirective(pastablePath(workDir), render ? pastablePath(render) : null)
10796
+ };
10167
10797
  if (parsed.values.json) {
10168
10798
  printJson(report);
10169
10799
  if (!report.ok && parsed.values.strict)
@@ -10173,13 +10803,13 @@ async function runQaCommand(argv) {
10173
10803
  const body = formatQaReport(report, { red: RED, yellow: YELLOW, green: GREEN, dim: DIM, reset: RESET });
10174
10804
  if (body)
10175
10805
  console.log(body);
10176
- for (const evaluation of report.regimes ?? []) {
10177
- console.log(formatRegimeReport(evaluation, { green: GREEN, red: RED, yellow: YELLOW, dim: DIM, reset: RESET }));
10806
+ for (const evaluation of report.harnesses ?? []) {
10807
+ console.log(formatHarnessReport(evaluation, { green: GREEN, red: RED, yellow: YELLOW, dim: DIM, reset: RESET }));
10178
10808
  }
10179
10809
  const scope = `${report.checked.layers} layer(s), ${report.checked.text_layers} text layer(s)${report.checked.canvas ? `, ${report.checked.canvas}` : ""}`;
10180
- const graded = (report.regimes ?? []).length > 0;
10810
+ const graded = (report.harnesses ?? []).length > 0;
10181
10811
  if (report.verdict === "clean") {
10182
- console.log(`${GREEN}✓ no HTML slop found${graded ? ", regime checks passed" : ""}${RESET} ${DIM}— ${scope} — ${htmlPath}${RESET}`);
10812
+ console.log(`${GREEN}✓ no HTML slop found${graded ? ", harness checks passed" : ""}${RESET} ${DIM}— ${scope} — ${htmlPath}${RESET}`);
10183
10813
  }
10184
10814
  else if (report.verdict === "warnings") {
10185
10815
  console.log(`${YELLOW}! ${report.warnings.length} warning(s), no slop${RESET} ${DIM}— ${scope} — ${htmlPath}${RESET}`);
@@ -10188,81 +10818,376 @@ async function runQaCommand(argv) {
10188
10818
  console.log(`${RED}✗ ${report.errors.length} ${graded ? "issue(s)" : "slop issue(s)"}${RESET}, ${report.warnings.length} warning(s) ${DIM}— ${scope} — ${htmlPath}${RESET}`);
10189
10819
  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}`);
10190
10820
  }
10191
- const pendingReview = (report.regimes ?? []).reduce((total, evaluation) => total + evaluation.review_items.length, 0);
10821
+ const pendingReview = (report.harnesses ?? []).reduce((total, evaluation) => total + evaluation.review_items.length, 0);
10192
10822
  if (pendingReview) {
10193
- console.log(`${DIM}${pendingReview} regime item(s) above are NOT machine-checkable — answer them yourself before you call this video done.${RESET}`);
10823
+ console.log(`${DIM}${pendingReview} harness item(s) above are NOT machine-checkable — answer them yourself before you call this video done.${RESET}`);
10194
10824
  }
10195
10825
  else if (!graded) {
10196
10826
  // Everything above is mechanical. Structure — hook, loop, payoff, bait — is
10197
10827
  // what actually decides whether the video travels, and nothing here checks
10198
10828
  // it. Say so rather than letting a green tick imply the video is good.
10199
10829
  console.log(`${DIM}Checked mechanics only — nothing here grades the hook, the loop, the payoff, or the bait.${RESET}`);
10200
- console.log(`${DIM}Structure: vidfarm regime show hooks · grade this video: vidfarm qa <dir> --regime short-form${RESET}`);
10830
+ console.log(`${DIM}Structure: vidfarm harness show hooks · grade this video: vidfarm qa <dir> --harness short-form${RESET}`);
10201
10831
  }
10832
+ // Last word on every run, clean ones included: this tool never saw the video.
10833
+ console.log(formatWatchTheVideoNotice(report.watch_the_video, { yellow: YELLOW, dim: DIM, reset: RESET, bold: BOLD }));
10202
10834
  if (!report.ok && parsed.values.strict)
10203
10835
  process.exitCode = 1;
10204
10836
  }
10205
- // `vidfarm regime <list|show|init>` the QA_REGIME.md surface. A regime is the
10206
- // director's OWN quality contract for one template style; the built-ins are
10207
- // starting points to copy and edit, never a house style to conform to.
10208
- async function runRegimeCommand(argv) {
10837
+ /** Shortest form of a path the caller can paste back: relative when it's under cwd. */
10838
+ function pastablePath(target) {
10839
+ const rel = path.relative(process.cwd(), target);
10840
+ if (!rel)
10841
+ return ".";
10842
+ return rel.startsWith("..") ? target : `./${rel}`;
10843
+ }
10844
+ /** Newest rendered MP4 in `<dir>/renders`, if the director has rendered yet. */
10845
+ function newestRenderIn(dir) {
10846
+ const rendersDir = path.join(dir, "renders");
10847
+ try {
10848
+ const candidates = readdirSync(rendersDir)
10849
+ .filter((entry) => entry.toLowerCase().endsWith(".mp4"))
10850
+ .map((entry) => {
10851
+ const full = path.join(rendersDir, entry);
10852
+ return { full, mtime: statSync(full).mtimeMs };
10853
+ })
10854
+ .sort((a, b) => b.mtime - a.mtime);
10855
+ return candidates.length ? candidates[0].full : null;
10856
+ }
10857
+ catch {
10858
+ return null;
10859
+ }
10860
+ }
10861
+ // `vidfarm harness <list|show|init|derive|check>` — the HARNESS.md surface.
10862
+ //
10863
+ // A harness is the reusable apparatus for ONE format or template: what makes it
10864
+ // special, written down so an agent can reproduce it without the director in
10865
+ // the room. Three director phrasings all land here:
10866
+ //
10867
+ // "create me a harness" → init (copy a base, then EDIT)
10868
+ // "update the harness for this format" → edit the file in place; the
10869
+ // CLI just reads and grades it
10870
+ // "give me the harness for this template_id" → derive (the DECOMPOSITION,
10871
+ // distilled into a harness)
10872
+ //
10873
+ // The built-ins are starting points to copy and edit, never a house style to
10874
+ // conform to.
10875
+ async function runHarnessCommand(argv) {
10209
10876
  const sub = argv[0];
10210
10877
  const parsed = parseArgs({
10211
10878
  args: argv.slice(1),
10212
10879
  allowPositionals: true,
10213
10880
  options: {
10214
- json: { type: "boolean", default: false },
10881
+ // `derive` talks to the API to fetch a fork's decompose output, so this
10882
+ // command carries the standard auth/host flags alongside its own.
10883
+ ...commonOptions(),
10215
10884
  out: { type: "string" },
10216
10885
  dir: { type: "string" },
10886
+ dna: { type: "string" },
10887
+ refetch: { type: "boolean", default: false },
10217
10888
  force: { type: "boolean", default: false }
10218
10889
  }
10219
10890
  });
10220
10891
  const json = Boolean(parsed.values.json);
10221
10892
  if (!sub || sub === "list") {
10222
- const builtins = listBuiltinRegimes();
10893
+ const builtins = listBuiltinHarnesses();
10223
10894
  if (json)
10224
- return printJson({ regimes: builtins.map(({ name, path: file, video_type }) => ({ name, path: file, video_type })) });
10225
- console.log(`${DIM}Built-in QA regimes — copy one next to your work, then edit it:${RESET}`);
10895
+ return printJson({ harnesses: builtins.map(({ name, path: file, video_type }) => ({ name, path: file, video_type })) });
10896
+ console.log(`${DIM}Built-in harnesses — copy one next to your work, then edit it:${RESET}`);
10226
10897
  for (const entry of builtins) {
10227
10898
  console.log(` ${GREEN}${entry.name}${RESET} ${DIM}${entry.video_type ?? ""}${RESET}`);
10228
10899
  }
10229
- console.log(`\n${DIM}vidfarm regime init <name> --out ./work/QA_REGIME.md${RESET}`);
10230
- console.log(`${DIM}vidfarm qa ./work --regime <name|path> (repeatable regimes stack)${RESET}`);
10231
- 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}`);
10900
+ console.log(`\n${DIM}vidfarm harness init <name> --out ./work/${HARNESS_FILENAME}${RESET}`);
10901
+ console.log(`${DIM}vidfarm harness derive <forkId> (a decomposed template → a harness)${RESET}`);
10902
+ console.log(`${DIM}vidfarm qa ./work --harness <name|path> (repeatable harnesses stack)${RESET}`);
10903
+ console.log(`${DIM}A harness is YOURS: any ${HARNESS_FILENAME} anywhere on disk can be passed in. Format: .agents/skills/vidfarm/harnesses/README.md${RESET}`);
10232
10904
  return;
10233
10905
  }
10234
10906
  if (sub === "show") {
10235
10907
  const ref = parsed.positionals[0];
10236
10908
  if (!ref)
10237
- throw new Error("regime show requires a name or path: `vidfarm regime show <name|path>`.");
10238
- const file = resolveRegimePath(ref);
10909
+ throw new Error("harness show requires a name or path: `vidfarm harness show <name|path>`.");
10910
+ const file = resolveHarnessPath(ref);
10239
10911
  const raw = readFileSync(file, "utf8");
10912
+ const doc = parseHarness(raw, file);
10913
+ // `--dna visual` prints ONE strand. A harness that documents five kinds of
10914
+ // DNA is long; an agent re-theming the look only needs the visual strand.
10915
+ const wanted = parsed.values.dna ? String(parsed.values.dna).toLowerCase().replace(/[^a-z0-9]+/g, "_") : null;
10916
+ if (wanted) {
10917
+ const strand = doc.dna.find((entry) => entry.key === wanted || entry.key === `${wanted}_dna` || entry.key.startsWith(wanted));
10918
+ if (!strand) {
10919
+ throw new Error(`"${doc.name}" documents no ${wanted} strand. It has: ${doc.dna.map((entry) => entry.key).join(", ") || "(no DNA sections)"}.`);
10920
+ }
10921
+ if (json)
10922
+ return printJson(strand);
10923
+ console.log(`## ${strand.heading}\n\n${strand.body}`);
10924
+ return;
10925
+ }
10240
10926
  if (json)
10241
- return printJson(parseRegime(raw, file));
10927
+ return printJson(doc);
10242
10928
  console.log(raw);
10243
10929
  return;
10244
10930
  }
10245
- if (sub === "init") {
10246
- const ref = parsed.positionals[0];
10247
- if (!ref)
10248
- throw new Error("regime init requires a built-in name or source path: `vidfarm regime init short-form --out ./work/QA_REGIME.md`.");
10249
- const source = resolveRegimePath(ref);
10931
+ if (sub === "init" || sub === "new" || sub === "create") {
10932
+ const ref = parsed.positionals[0] ?? "short-form";
10933
+ const source = resolveHarnessPath(ref);
10250
10934
  const outPath = parsed.values.out
10251
10935
  ? path.resolve(String(parsed.values.out))
10252
- : path.resolve(String(parsed.values.dir ?? "."), "QA_REGIME.md");
10936
+ : path.resolve(String(parsed.values.dir ?? "."), HARNESS_FILENAME);
10253
10937
  if (existsSync(outPath) && !parsed.values.force) {
10254
10938
  throw new Error(`${outPath} already exists. Pass --force to overwrite (you will lose your edits).`);
10255
10939
  }
10256
10940
  mkdirSync(path.dirname(outPath), { recursive: true });
10257
10941
  writeFileSync(outPath, readFileSync(source, "utf8"), "utf8");
10258
10942
  if (json)
10259
- return printJson({ ok: true, regime: ref, source, path: outPath });
10943
+ return printJson({ ok: true, harness: ref, source, path: outPath });
10260
10944
  console.log(`${GREEN}${outPath}${RESET} ${DIM}← ${path.basename(source)}${RESET}`);
10261
10945
  console.log(`${DIM}Now EDIT it — delete what doesn't apply, add what makes your format yours (start with the audience line).${RESET}`);
10262
10946
  console.log(`${DIM}Then: vidfarm qa ${path.dirname(outPath)}${RESET}`);
10263
10947
  return;
10264
10948
  }
10265
- throw new Error(`Unknown regime subcommand "${sub}". Use: list | show <name|path> | init <name> [--out <path>]`);
10949
+ if (sub === "derive" || sub === "from" || sub === "decompose") {
10950
+ const ref = parsed.positionals[0];
10951
+ if (!ref) {
10952
+ throw new Error("harness derive requires a fork/template id or a pulled work dir: `vidfarm harness derive <forkId|dir> [--out ./work/HARNESS.md]`.");
10953
+ }
10954
+ await runHarnessDerive(ref, {
10955
+ outPath: parsed.values.out ? path.resolve(String(parsed.values.out)) : null,
10956
+ dirOverride: parsed.values.dir ? path.resolve(String(parsed.values.dir)) : null,
10957
+ refetch: Boolean(parsed.values.refetch),
10958
+ force: Boolean(parsed.values.force),
10959
+ values: parsed.values,
10960
+ json
10961
+ });
10962
+ return;
10963
+ }
10964
+ if (sub === "check" || sub === "qa" || sub === "grade") {
10965
+ // The same engine as `vidfarm qa`, reachable under the noun the director
10966
+ // used. One implementation, two doors.
10967
+ const rest = argv.slice(1);
10968
+ await runQaCommand(rest.length ? rest : ["."]);
10969
+ return;
10970
+ }
10971
+ throw new Error(`Unknown harness subcommand "${sub}". Use: list | show <name|path> [--dna <strand>] | init <name> [--out <path>] | derive <forkId|dir> | check <dir>`);
10972
+ }
10973
+ /**
10974
+ * `vidfarm harness derive <forkId|dir>` — THE DECOMPOSITION, as a harness.
10975
+ *
10976
+ * When a director says "give me the harness for this template_id", this is what
10977
+ * they mean: the decompose pass already extracted the template's DNA into JSON
10978
+ * (video-context viral_dna, editor-harness, replication-harness,
10979
+ * scene-annotations), and this folds those strands into ONE editable Markdown
10980
+ * doc laid out the way every other harness is — so a derived harness and a
10981
+ * hand-written one are the same artifact, gradeable by the same `vidfarm qa`.
10982
+ *
10983
+ * It writes the DNA it FOUND and says "unknown" where the decompose pass has
10984
+ * nothing, rather than inventing a plausible strand. A harness that quietly
10985
+ * fabricates the thing the director asked for is worse than a short one.
10986
+ */
10987
+ async function runHarnessDerive(ref, opts) {
10988
+ const asDir = path.resolve(ref);
10989
+ const isLocalDir = existsSync(asDir) && statSync(asDir).isDirectory();
10990
+ let dir = opts.dirOverride ?? (isLocalDir ? asDir : null);
10991
+ let forkId = isLocalDir ? null : ref;
10992
+ if (!dir) {
10993
+ // A bare id: pull the decompose artifacts down the same way `vidfarm pull`
10994
+ // does, into the same cache dir, so a later `pull` is a no-op.
10995
+ const ctx = commonContext(opts.values);
10996
+ dir = path.resolve(process.cwd(), path.join(".vidfarm", ref));
10997
+ mkdirSync(dir, { recursive: true });
10998
+ await fetchCompositionFiles({ host: ctx.host, forkId: ref, dir, apiKey: ctx.auth.apiKey, shareToken: ctx.auth.shareToken, refetch: opts.refetch });
10999
+ }
11000
+ const videoContext = readJsonFile(path.join(dir, "video-context.json"));
11001
+ const editorHarness = readJsonFile(path.join(dir, "editor-harness.json"));
11002
+ const replicationHarness = readJsonFile(path.join(dir, "replication-harness.json"));
11003
+ if (!videoContext && !editorHarness && !replicationHarness) {
11004
+ throw new Error(`No decompose output in ${dir} — nothing to derive a harness from. Run \`vidfarm decompose ${forkId ?? "<forkId>"}\` first, ` +
11005
+ `then re-run. (A harness can also be written from scratch: \`vidfarm harness init short-form\`.)`);
11006
+ }
11007
+ const markdown = buildDerivedHarnessMarkdown({
11008
+ ref,
11009
+ forkId,
11010
+ dir,
11011
+ videoContext,
11012
+ editorHarness,
11013
+ replicationHarness,
11014
+ sceneAnnotations: readJsonFile(path.join(dir, "scene-annotations.json")),
11015
+ compositionHtml: existsSync(path.join(dir, "composition.html")) ? readFileSync(path.join(dir, "composition.html"), "utf8") : null
11016
+ });
11017
+ const outPath = opts.outPath ?? path.join(dir, HARNESS_FILENAME);
11018
+ if (existsSync(outPath) && !opts.force) {
11019
+ throw new Error(`${outPath} already exists. Pass --force to overwrite (you will lose your edits), or --out <path> to write elsewhere.`);
11020
+ }
11021
+ mkdirSync(path.dirname(outPath), { recursive: true });
11022
+ writeFileSync(outPath, markdown, "utf8");
11023
+ const parsedOut = parseHarness(markdown, outPath);
11024
+ if (opts.json) {
11025
+ return printJson({
11026
+ ok: true,
11027
+ source: forkId ?? dir,
11028
+ dir,
11029
+ path: outPath,
11030
+ dna: parsedOut.dna.map((strand) => strand.key),
11031
+ checks: Object.keys(parsedOut.checks),
11032
+ review_items: parsedOut.review_items.length
11033
+ });
11034
+ }
11035
+ console.log(`${GREEN}${outPath}${RESET} ${DIM}← decomposition of ${forkId ?? path.basename(dir)}${RESET}`);
11036
+ console.log(`${DIM}DNA written: ${parsedOut.dna.map((strand) => strand.key).join(", ") || "none"}${RESET}`);
11037
+ console.log(`${DIM}${Object.keys(parsedOut.checks).length} machine check(s), ${parsedOut.review_items.length} review item(s).${RESET}`);
11038
+ console.log(`${DIM}This is a STARTING POINT, not a verdict — read it, delete what the decompose pass guessed wrong, and add what only you know.${RESET}`);
11039
+ console.log(`${DIM}Then: vidfarm qa ${pastablePath(dir)}${RESET}`);
11040
+ }
11041
+ /** Fold the decompose JSON strands into the standard harness layout. */
11042
+ function buildDerivedHarnessMarkdown(input) {
11043
+ const viralDna = (input.videoContext?.viral_dna && typeof input.videoContext.viral_dna === "object"
11044
+ ? input.videoContext.viral_dna : null);
11045
+ const harness = (input.editorHarness?.harness && typeof input.editorHarness.harness === "object"
11046
+ ? input.editorHarness.harness : null);
11047
+ const replication = (input.replicationHarness?.harness && typeof input.replicationHarness.harness === "object"
11048
+ ? input.replicationHarness.harness : null);
11049
+ const sub = (parent, key) => parent && parent[key] && typeof parent[key] === "object" && !Array.isArray(parent[key])
11050
+ ? parent[key]
11051
+ : null;
11052
+ const emotional = sub(viralDna, "emotional_punch") ?? sub(harness, "emotional");
11053
+ const pacing = sub(harness, "pacing");
11054
+ const typography = sub(harness, "typography");
11055
+ const broll = sub(harness, "broll");
11056
+ const transitions = sub(harness, "transitions");
11057
+ const audio = sub(harness, "audio");
11058
+ const staticVsPivot = sub(viralDna, "static_vs_pivot");
11059
+ const annotations = Array.isArray(input.sceneAnnotations?.annotations)
11060
+ ? input.sceneAnnotations.annotations
11061
+ : [];
11062
+ const scenes = Array.isArray(harness?.scenes) ? harness.scenes : [];
11063
+ const value = (raw) => readJsonString(raw) || "_unknown — the decompose pass didn't record this; fill it in._";
11064
+ const bullets = (items, empty) => items.length ? items.map((item) => `- ${item}`).join("\n") : `- ${empty}`;
11065
+ // The composition itself settles the two checks nobody should have to type.
11066
+ const info = input.compositionHtml ? inspectComposition(input.compositionHtml) : null;
11067
+ const durationCheck = info?.duration_seconds ? `${Math.max(1, Math.floor(info.duration_seconds * 0.7))}-${Math.ceil(info.duration_seconds * 1.3)}` : null;
11068
+ const beatRows = scenes.slice(0, 12).map((scene) => {
11069
+ const role = readJsonString(scene.role) || "beat";
11070
+ const importance = readJsonString(scene.importance);
11071
+ const bias = readJsonString(scene.edit_bias);
11072
+ const keep = scene.must_keep === true ? " **must-keep**" : "";
11073
+ return `**${role}**${importance ? ` (${importance})` : ""}${keep}${bias ? ` — ${bias}` : ""}`;
11074
+ });
11075
+ const mustPreserve = annotations
11076
+ .flatMap((entry) => readJsonStringList(entry.must_preserve, 3).map((item) => {
11077
+ const slug = readJsonString(entry.scene_slug);
11078
+ return slug ? `${slug}: ${item}` : item;
11079
+ }))
11080
+ .slice(0, 8);
11081
+ const recommendedStrategy = readJsonString(replication?.recommended_strategy) || "cheap_efficient";
11082
+ const cheapPlan = sub(replication, "cheap");
11083
+ const qualityPlan = sub(replication, "quality");
11084
+ const motionStyle = sub(replication, "motion_style");
11085
+ const frontMatter = [
11086
+ "---",
11087
+ `name: ${(input.forkId ?? path.basename(input.dir)).replace(/[^A-Za-z0-9_-]/g, "-")}`,
11088
+ `video_type: ${readJsonString(viralDna?.trend_tagline) || readJsonString(harness?.one_liner) || "derived from a decomposed template"}`,
11089
+ `derived_from: decompose`,
11090
+ input.forkId ? `source_template_id: ${input.forkId}` : null,
11091
+ "checks:",
11092
+ durationCheck ? ` duration_sec: ${durationCheck}` : null,
11093
+ info?.aspect_ratio ? ` aspect: ${info.aspect_ratio}` : null,
11094
+ " first_frame_visual: required",
11095
+ " font_regime: required",
11096
+ " safe_zone: required",
11097
+ "---"
11098
+ ].filter(Boolean).join("\n");
11099
+ return `${frontMatter}
11100
+
11101
+ # Harness: ${readJsonString(viralDna?.trend_tagline) || readJsonString(harness?.one_liner) || input.forkId || path.basename(input.dir)}
11102
+
11103
+ Derived from the decomposition of ${input.forkId ? `\`${input.forkId}\`` : path.basename(input.dir)}. **This is a first draft written by a model that watched the source, not a verdict.** Read every strand, delete what it got wrong, and add what only you know — the audience, the offer, the banned vocabulary. A harness nobody edited is about the source video, not about your videos.
11104
+
11105
+ ## Viral DNA — why the source travelled
11106
+
11107
+ - **Trend/format:** ${value(viralDna?.trend_tagline)}
11108
+ - **Hook:** ${value(viralDna?.hook)}
11109
+ - **Retention mechanic:** ${value(viralDna?.retention ?? viralDna?.retention_mechanic)}
11110
+ - **Payoff:** ${value(viralDna?.payoff)}
11111
+ - **Core emotion:** ${value(emotional?.core_emotion ?? emotional?.target_feeling)}
11112
+ - **Mechanism:** ${value(emotional?.mechanism)}
11113
+ - **Contrast:** ${value(emotional?.contrast)}
11114
+
11115
+ **Rebuild each charge for the new subject — never flatten the loop into a product statement.** The four charges (hook / loop / payoff / bait) are the craft behind this section: \`vidfarm harness show hooks\`.
11116
+
11117
+ - [ ] Does my version have its OWN hook, or did I keep the source's sentence with the nouns swapped?
11118
+ - [ ] Does the curiosity loop close inside this video, at a timestamp I can name?
11119
+ - [ ] Is the withheld answer one the viewer genuinely can't supply themselves?
11120
+
11121
+ ## Visual DNA — how it looks and cuts
11122
+
11123
+ - **Cut rhythm:** ${value(pacing?.cut_rhythm)}${readJsonString(pacing?.avg_scene_seconds) ? ` (~${readJsonString(pacing?.avg_scene_seconds)}s per scene)` : ""}
11124
+ - **Energy curve:** ${value(pacing?.energy_curve)}
11125
+ - **Caption style:** ${value(typography?.caption_style)} — placement ${readJsonString(typography?.placement) || "unrecorded"}, ${readJsonString(typography?.text_density) || "density unrecorded"}
11126
+ - **Font character:** ${value(typography?.font_character)}
11127
+ - **B-roll:** ${value(broll?.reliance)}${readJsonString(broll?.sourcing) ? ` — sourced ${readJsonString(broll?.sourcing)}` : ""}${readJsonString(broll?.cadence) ? `, cadence ${readJsonString(broll?.cadence)}` : ""}
11128
+ - **Transitions:** default ${readJsonString(transitions?.default) || "unrecorded"}, in ${readJsonString(transitions?.intro) || "unrecorded"}, out ${readJsonString(transitions?.outro) || "unrecorded"}
11129
+ - **Motion feel:** ${value(motionStyle?.animation_feel)}
11130
+
11131
+ - [ ] One type scale, one accent colour, one illustration style across every beat — checked on a contact sheet, not scene by scene (\`vidfarm stills . --sheet\`).
11132
+ - [ ] Caption colour and plate chosen by MEASURING the composited background, not by habit — one treatment for the whole video.
11133
+
11134
+ ## Structural DNA — the beats
11135
+
11136
+ ${bullets(beatRows, "_no scene roles recorded — run `vidfarm decompose` with the harness pass._")}
11137
+
11138
+ **Load-bearing beats (do not reskin past these):**
11139
+ ${bullets(mustPreserve.length ? mustPreserve : readJsonStringList(harness?.important_scenes, 6), "_none recorded._")}
11140
+
11141
+ - [ ] Every must-keep beat kept its timing, role, and caption cadence — I swapped the subject, not the structure.
11142
+
11143
+ ## Audio DNA — voice, bed, timing
11144
+
11145
+ - **Voiceover:** ${value(audio?.voiceover)}
11146
+ - **Music:** ${value(audio?.music)}
11147
+ - **SFX:** ${value(audio?.sfx)}
11148
+ - **Captions from:** ${value(audio?.captions_from)}
11149
+ - **Comedic timing:** ${value(emotional?.comedic_timing)}
11150
+ - **Intonation / delivery:** ${value(emotional?.intonation ?? emotional?.delivery)}
11151
+
11152
+ - [ ] The held beat / pause / hard cut that sells the joke survived the re-cut.
11153
+ - [ ] Speech sits ~12–15 dB over the bed across the actual word spans — MEASURED, not "sounds fine".
11154
+
11155
+ ## Build DNA — which paintbrush per beat
11156
+
11157
+ Recommended strategy: **${recommendedStrategy}**${readJsonString(replication?.recommendation_reason) ? ` — ${readJsonString(replication?.recommendation_reason)}` : ""}
11158
+
11159
+ - **Cheap mix:** ${value(cheapPlan?.method_mix ?? cheapPlan?.one_liner)}
11160
+ - **Best-quality mix:** ${value(qualityPlan?.method_mix ?? qualityPlan?.one_liner)}
11161
+ - **Free-tier path:** ${value(replication?.free_tier_note)}
11162
+
11163
+ **Do these in hyperframes (HTML/CSS/JS), never AI video:**
11164
+ ${bullets(readJsonStringList(motionStyle?.hyperframes_candidates, 6), "all on-screen text and graphic overlays")}
11165
+
11166
+ **Guardrails the replication pass flagged:**
11167
+ ${bullets(readJsonStringList(replication?.viral_dna_guardrails, 6), "_none recorded._")}
11168
+
11169
+ ## Re-theming — what's static, what pivots
11170
+
11171
+ - **Summary:** ${value(staticVsPivot?.summary)}
11172
+ - **Scenes:** ${value(sub(staticVsPivot, "scene_replacement")?.overall)}
11173
+ - **Narration:** ${value(sub(staticVsPivot, "narration")?.overall)}
11174
+ - **Music:** ${value(sub(staticVsPivot, "music")?.overall)}
11175
+ - **Captions:** ${value(sub(staticVsPivot, "captions")?.overall)}
11176
+
11177
+ ## Rules for this format
11178
+
11179
+ ${bullets(readJsonStringList(harness?.do, 6).map((item) => `DO: ${item}`).concat(readJsonStringList(harness?.dont, 6).map((item) => `DON'T: ${item}`)), "_none recorded — write yours here, each with the reason attached._")}
11180
+
11181
+ > Every rule you add needs its **why** on the same line. A rule whose reason is missing gets argued away by the next agent that reads it.
11182
+
11183
+ ## Whole-video review — do this last
11184
+
11185
+ Nothing above can see the finished video. Tile ~12 stills into one contact sheet and read it as a single image (\`vidfarm stills . --sheet\`): margins that shift between beats, three type sizes, an accent colour that wanders, N identically-long beats, a jarring join, a dead band under top-anchored content. Those are sequence-level defects, invisible to every per-scene check and to the agent that built it. Compare two frames from DIFFERENT scenes — a frozen render passes duration, frame count and every one-frame check. Method: \`references/reviewing-renders.md\`.
11186
+
11187
+ - [ ] I read a contact sheet of the finished render, not just stills of the scenes I was working on.
11188
+ - [ ] I compared two frames from different scenes and they differ.
11189
+ - [ ] I reported what I MEASURED separately from what I JUDGED.
11190
+ `;
10266
11191
  }
10267
11192
  // `vidfarm stills <dir|composition.html>` — render PNG stills of the CURRENT
10268
11193
  // composition in-process (the agent's visual self-verification loop).
@@ -10351,7 +11276,134 @@ async function fetchSkillContents(host, target) {
10351
11276
  }
10352
11277
  throw new Error(`Could not fetch ${target.bundled} from ${host} and no bundled copy was found.`);
10353
11278
  }
11279
+ /** The installed devcli's own version — the thing the bundled pack is pinned to. */
11280
+ function devcliVersion() {
11281
+ const pkgPath = locateBundledSkill("package.json");
11282
+ if (!pkgPath)
11283
+ return null;
11284
+ try {
11285
+ const version = JSON.parse(readFileSync(pkgPath, "utf8")).version;
11286
+ return typeof version === "string" ? version : null;
11287
+ }
11288
+ catch {
11289
+ return null;
11290
+ }
11291
+ }
11292
+ /**
11293
+ * `vidfarm skill <ls|show|search|print|path>` — READ the skill pack that ships
11294
+ * inside this devcli, with no network and no account.
11295
+ *
11296
+ * The pack is already in the npm tarball (`.agents/skills/**` is in
11297
+ * package.json `files`), so every install has the full director knowledge on
11298
+ * disk. Before this, nothing could read it without `vidfarm skills add`
11299
+ * (installs into a project) or a fetch — which meant going online to learn how
11300
+ * to use a local-first CLI.
11301
+ *
11302
+ * The bundled copy is version-pinned to this CLI, which is the correct pairing.
11303
+ * It is documentation, NOT entitlement: the paid primitives it documents (AI
11304
+ * generation, hosted render, social recycle, media download, marketplace) still
11305
+ * require `vidfarm login` and a cloud call. The free-local half (clipping,
11306
+ * hyperframes, `vidfarm serve` render, qa, harnesses, dedupe, Kokoro TTS) is
11307
+ * genuinely offline. Every surface below says so rather than letting an offline
11308
+ * read imply an offline entitlement.
11309
+ */
11310
+ async function runSkillDocsCommand(sub, argv) {
11311
+ const parsed = parseArgs({
11312
+ args: argv,
11313
+ allowPositionals: true,
11314
+ options: { json: { type: "boolean", default: false }, pack: { type: "string" }, limit: { type: "string" } }
11315
+ });
11316
+ const json = Boolean(parsed.values.json);
11317
+ const pack = String(parsed.values.pack ?? DEFAULT_PACK);
11318
+ const ref = parsed.positionals[0];
11319
+ if (sub === "path" || sub === "where") {
11320
+ const dir = bundledPackDir(pack);
11321
+ if (!dir)
11322
+ throw new Error(`No bundled "${pack}" pack found next to this devcli install.`);
11323
+ if (json)
11324
+ return printJson({ ok: true, pack, path: dir, offline: true }), true;
11325
+ console.log(dir);
11326
+ return true;
11327
+ }
11328
+ if (sub === "ls" || sub === "list" || sub === "files" || sub === "index") {
11329
+ const docs = listPackDocs(pack);
11330
+ if (!docs.length)
11331
+ throw new Error(`No bundled "${pack}" pack found next to this devcli install. Fetch it with \`vidfarm skills add ${pack}\`.`);
11332
+ if (json)
11333
+ return printJson({ ok: true, pack, source: "bundled", offline: true, files: docs.map(({ rel, lines, bytes }) => ({ path: rel, lines, bytes })) }), true;
11334
+ console.log(`${DIM}Bundled ${pack} skill pack — pinned to devcli ${devcliVersion() ?? "(this install)"}, read offline, no account:${RESET}`);
11335
+ let group = "";
11336
+ for (const doc of docs) {
11337
+ const dir = doc.rel.includes("/") ? doc.rel.slice(0, doc.rel.indexOf("/")) : "";
11338
+ if (dir !== group) {
11339
+ group = dir;
11340
+ if (dir)
11341
+ console.log(` ${BOLD}${dir}/${RESET}`);
11342
+ }
11343
+ console.log(` ${GREEN}${doc.rel.padEnd(46)}${RESET} ${DIM}${String(doc.lines).padStart(4)} ln${RESET}`);
11344
+ }
11345
+ console.log(`\n${DIM}vidfarm skill show <path|shorthand> · vidfarm skill search "<term>"${RESET}`);
11346
+ console.log(`${DIM}Start with SKILL.md — its File Index says which file answers what.${RESET}`);
11347
+ console.log(`${DIM}This is documentation, not entitlement: the free-local half (clips, hyperframes, \`vidfarm serve\` render, qa, harnesses, dedupe, local TTS) runs offline; AI generation, hosted render, recycle, download and marketplace still need \`vidfarm login\` and a network call.${RESET}`);
11348
+ return true;
11349
+ }
11350
+ if (sub === "show" || sub === "read" || sub === "cat") {
11351
+ if (!ref)
11352
+ throw new Error('skill show requires a file: `vidfarm skill show references/primitives.md` (shorthand like "primitives" works too).');
11353
+ let resolved;
11354
+ try {
11355
+ resolved = readPackDoc(ref, pack);
11356
+ }
11357
+ catch (error) {
11358
+ // An ambiguous shorthand is a normal outcome, not a crash — "hooks"
11359
+ // legitimately names both the craft reference and the harness. Print the
11360
+ // choice instead of a stack trace, and don't guess on the caller's behalf.
11361
+ if (!(error instanceof AmbiguousDocRef))
11362
+ throw error;
11363
+ if (json)
11364
+ return printJson({ ok: false, pack, ref, ambiguous: error.candidates }), true;
11365
+ console.log(`${YELLOW}"${ref}" matches ${error.candidates.length} files${RESET} ${DIM}— name one:${RESET}`);
11366
+ for (const candidate of error.candidates)
11367
+ console.log(` vidfarm skill show ${GREEN}${candidate}${RESET}`);
11368
+ process.exitCode = 1;
11369
+ return true;
11370
+ }
11371
+ const { doc, contents } = resolved;
11372
+ if (json)
11373
+ return printJson({ ok: true, pack, path: doc.rel, lines: doc.lines, source: "bundled", contents }), true;
11374
+ process.stdout.write(contents.endsWith("\n") ? contents : `${contents}\n`);
11375
+ return true;
11376
+ }
11377
+ if (sub === "search" || sub === "grep" || sub === "find") {
11378
+ if (!ref)
11379
+ throw new Error('skill search requires a term: `vidfarm skill search greenscreen`.');
11380
+ const limit = Number(parsed.values.limit ?? 40);
11381
+ const matches = searchPackDocs(ref, { name: pack, limit: Number.isFinite(limit) ? limit : 40 });
11382
+ if (json)
11383
+ return printJson({ ok: true, pack, term: ref, matches }), true;
11384
+ if (!matches.length) {
11385
+ console.log(`${DIM}No match for "${ref}" in the bundled ${pack} pack. \`vidfarm skill ls\` lists every file.${RESET}`);
11386
+ return true;
11387
+ }
11388
+ let current = "";
11389
+ for (const match of matches) {
11390
+ if (match.rel !== current) {
11391
+ current = match.rel;
11392
+ console.log(`${BOLD}${match.rel}${RESET}`);
11393
+ }
11394
+ console.log(` ${DIM}${String(match.line).padStart(4)}${RESET} ${match.text}`);
11395
+ }
11396
+ console.log(`\n${DIM}${matches.length} match(es). Open one: vidfarm skill show <path>${RESET}`);
11397
+ return true;
11398
+ }
11399
+ return false;
11400
+ }
10354
11401
  async function runUpdateSkillCommand(argv) {
11402
+ // Read-only subcommands come first and never touch the network or auth, so
11403
+ // `vidfarm skill ls` works on a plane with no credential configured.
11404
+ const sub = argv[0] ?? "";
11405
+ if (sub && !sub.startsWith("-") && await runSkillDocsCommand(sub, argv.slice(1)))
11406
+ return;
10355
11407
  const parsed = parseArgs({
10356
11408
  args: argv,
10357
11409
  allowPositionals: false,
@@ -10360,14 +11412,21 @@ async function runUpdateSkillCommand(argv) {
10360
11412
  global: { type: "boolean", default: false },
10361
11413
  dir: { type: "string" },
10362
11414
  platform: { type: "boolean", default: false },
10363
- print: { type: "boolean", default: false }
11415
+ print: { type: "boolean", default: false },
11416
+ remote: { type: "boolean", default: false }
10364
11417
  }
10365
11418
  });
10366
11419
  const ctx = commonContext(parsed.values);
10367
11420
  const skillNames = ["vidfarm", ...(parsed.values.platform ? ["vidfarm-platform"] : [])];
10368
11421
  if (parsed.values.print) {
11422
+ // BUNDLED FIRST. The tarball's copy is pinned to this CLI version, which is
11423
+ // the pairing that actually works — a newer skill against an older binary
11424
+ // is the usual cause of "the skill says to do X but the command 404s".
11425
+ // `--remote` opts into the host's latest when that's what you want.
10369
11426
  for (const name of skillNames) {
10370
- const { contents } = await fetchSkillContents(ctx.host, SKILL_TARGETS[name]);
11427
+ const target = SKILL_TARGETS[name];
11428
+ const bundled = parsed.values.remote ? null : locateBundledSkill(target.bundled);
11429
+ const contents = bundled ? readFileSync(bundled, "utf8") : (await fetchSkillContents(ctx.host, target)).contents;
10371
11430
  process.stdout.write(contents.endsWith("\n") ? contents : `${contents}\n`);
10372
11431
  }
10373
11432
  return;