@officexapp/vidfarm-devcli 0.21.33 → 0.21.35

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 (26) hide show
  1. package/.agents/skills/editor-capabilities/SKILL.md +13 -2
  2. package/.agents/skills/vidfarm/SKILL.md +73 -29
  3. package/.agents/skills/vidfarm/harnesses/README.md +112 -0
  4. package/.agents/skills/vidfarm/{regimes/explainer.QA_REGIME.md → harnesses/explainer.HARNESS.md} +22 -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} +19 -1
  7. package/.agents/skills/vidfarm/{regimes/short-form.QA_REGIME.md → harnesses/short-form.HARNESS.md} +67 -7
  8. package/.agents/skills/vidfarm/{regimes/ugc-testimonial.QA_REGIME.md → harnesses/ugc-testimonial.HARNESS.md} +10 -3
  9. package/.agents/skills/vidfarm/recipes/{bulk-scripting-with-a-regime.md → bulk-scripting-with-a-harness.md} +35 -12
  10. package/.agents/skills/vidfarm/recipes/cutout-graphics-for-explainers.md +46 -2
  11. package/.agents/skills/vidfarm/recipes/local-edit-render-approve.md +2 -1
  12. package/.agents/skills/vidfarm/references/automation-and-local-dev.md +84 -22
  13. package/.agents/skills/vidfarm/references/editor-workflows.md +19 -4
  14. package/.agents/skills/vidfarm/references/hooks-and-virality.md +62 -5
  15. package/.agents/skills/vidfarm/references/reviewing-renders.md +140 -0
  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 +462 -75
  19. package/SKILL.md +33 -14
  20. package/dist/src/cli.js +799 -91
  21. package/dist/src/devcli/{qa-regime.js → harness.js} +132 -55
  22. package/dist/src/devcli/qa-check.js +209 -4
  23. package/dist/src/devcli/skill-docs.js +136 -0
  24. package/dist/src/devcli/stills.js +65 -1
  25. package/package.json +4 -3
  26. package/.agents/skills/vidfarm/regimes/README.md +0 -77
package/dist/src/cli.js CHANGED
@@ -19,8 +19,8 @@ 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
26
  import { segmentAlphaComponents, encodeTransparentGif, encodeTransparentAnimatedGif, pickPlateColor, detectPlateColor, keySafeArtInstruction, analyzeKeyedArt, HOLE_WARN_PCT } from "./devcli/sticker-pack.js";
@@ -28,6 +28,7 @@ import { runDoctorCommand } from "./devcli/doctor.js";
28
28
  import { findFreePort } from "./devcli/port-utils.js";
29
29
  import { scanLocalServers } from "./devcli/process-scan.js";
30
30
  import { runSkillsCommand } from "./devcli/skills.js";
31
+ import { AmbiguousDocRef, DEFAULT_PACK, bundledPackDir, listPackDocs, readPackDoc, searchPackDocs } from "./devcli/skill-docs.js";
31
32
  import { initTelemetry, reportCliCrash } from "./devcli/telemetry.js";
32
33
  import { resolveLocalDataDir, localBackendAvailable, LocalModeUnavailableError, localApiRequest } from "./devcli/local-backend.js";
33
34
  import { startLocalFrontendServer, serveShellsPresent } from "./devcli/local-frontend-server.js";
@@ -612,7 +613,18 @@ Speech (TTS/STT) — LOCAL-FIRST on your own AI key; --cloud is the explicit bac
612
613
  --out <file> Output mp3 path (default music-<id>.mp3)
613
614
  --own-key Use your own ElevenLabs key instead of the platform key + wallet
614
615
  (default: platform ElevenLabs, wallet-billed) → POST /api/v1/primitives/music/generate
615
- voices List ElevenLabs voices for TTS (voice_id + name + labels).
616
+ voices Browse + SAMPLE narration voices (voice_id + name + labels + preview).
617
+ Default roster = the premium ElevenLabs catalog reached through
618
+ VIDFARM'S OWN ElevenLabs connection: no ElevenLabs account, API key,
619
+ or subscription needed — narration on these runs on the platform key
620
+ and is billed as vidfarm wallet credits (pennies per narration).
621
+ --sample Write listenable samples to ./voice-samples so a human can CHOOSE by
622
+ ear. FREE on both tiers (premium = ElevenLabs' own preview clips,
623
+ free = rendered locally) — safe even in cost mode minimize.
624
+ --sample-count <n> How many to sample (default 6)
625
+ --sample-out <dir> | --sample-text "<line>"
626
+ --search "<text>" Filter by name/labels/description (e.g. "british narrator")
627
+ --free List the $0 local Kokoro voices instead (--all lists both rosters)
616
628
  --own-key List voices on your own saved ElevenLabs key (default: platform account)
617
629
  --limit <n> | --json Show more / raw JSON
618
630
  stt <file|url> Video or audio → transcript (alias: transcribe). LOCAL: local ffmpeg demux +
@@ -716,26 +728,45 @@ Local media engines & toolchain (all local, free, no account — no cloud key ne
716
728
  Feedback only: exits 0 even when it finds slop,
717
729
  and it never runs automatically. Blocklist, not
718
730
  allowlist — unusual styles pass untouched
719
- --regime <name|path> Grade against a QA_REGIME.md too (repeatable —
731
+ --harness <name|path> Grade against a HARNESS.md too (repeatable —
720
732
  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
733
+ <dir>/HARNESS.md is picked up automatically
734
+ --no-harness Skip HARNESS.md auto-discovery
723
735
  --json Machine-readable findings (rule/severity/fix)
724
736
  --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)
737
+ harness <sub> HARNESS.md — the reusable AI harness for ONE (local, devcli-only)
738
+ format or template: what makes it special,
739
+ written down so an agent can reproduce it.
740
+ "create me a harness" / "update the harness for
741
+ this format" / "give me the harness for this
742
+ template_id" all land here
743
+ harness list [--json] Bundled starting points: short-form,
744
+ hooks, ugc-testimonial, explainer,
745
+ product-demo
746
+ harness show <name|path> Print one [--dna <strand>] to print just
747
+ one strand (viral_dna, visual_dna, …)
748
+ harness init <name> [--out <p>] Copy one next to your work, then EDIT it
749
+ (default ./HARNESS.md; --dir <d> to
750
+ write <d>/HARNESS.md)
751
+ harness derive <forkId|dir> DECOMPOSITION → HARNESS.md. Distils a
752
+ decomposed template's viral/visual/
753
+ structural/audio/build DNA into an
754
+ editable harness [--out <p>] [--force]
755
+ harness check <dir> Grade a composition against its harness
756
+ (same engine as vidfarm qa)
734
757
  stills <dir|composition.html> Render PNG stills of the CURRENT (local in-process render)
735
758
  composition — the visual self-check loop
736
759
  ("did my edit look right") without a full render
737
760
  --at 0,2.5,7 Timestamps (default: midpoint of each scene clip, cap 8)
738
761
  --out <dir> Output dir (default <dir>/stills)
762
+ --sheet ALSO tile them into one contact sheet PNG — the
763
+ whole-video review pass. Read it as ONE image:
764
+ scene-by-scene building drifts (uneven margins,
765
+ 3 type sizes, a wandering accent colour, N equal
766
+ beats, a jarring join) and only a side-by-side
767
+ sheet shows it
768
+ --sheet-out <file> Sheet path (default <out>/contact-sheet.png)
769
+ --sheet-width <px> Per-tile width in the sheet (default 320)
739
770
  doctor Health-check the local toolchain: node, (local)
740
771
  ffmpeg, hyperframes engines, Chrome, API key
741
772
  (whoami), provider keys, agent CLI, poisoned
@@ -792,13 +823,27 @@ Account:
792
823
  provider-keys List saved AI provider keys → GET /api/v1/user/me/provider-keys
793
824
  add-provider-key <provider> <secret> Save an AI provider key → POST /api/v1/user/me/provider-keys
794
825
 
795
- Agent skill (install the latest director skill so your AI agent can act):
826
+ Agent skill (the director knowledge a full copy SHIPS INSIDE this CLI):
827
+ skill ls List every file in the bundled pack, with sizes (local — offline, no account)
828
+ skill show <path> Print one file. Shorthand works: \`skill show
829
+ primitives\`, \`skill show harnesses/README.md\`
830
+ skill search "<term>" Grep all of it — the fastest way to find the one
831
+ paragraph you need without loading a whole file
832
+ --limit <n> Max matches (default 40)
833
+ skill path Where the bundled pack lives on disk
834
+ skill --print Print the single-file director rollup (bundled
835
+ copy; --remote for the host's latest)
836
+ NOTE: the bundled pack is pinned to THIS CLI
837
+ version — the pairing that actually works. It is
838
+ documentation, not entitlement: the free-local
839
+ half (clips, hyperframes, serve render, qa,
840
+ harnesses, dedupe, local TTS) runs offline, while
841
+ AI generation, hosted render, recycle, download
842
+ and marketplace still need login + network.
796
843
  update-skill DEPRECATED installer alias; installs the canonical
797
844
  multi-file vidfarm pack via \`skills add\`.
798
845
  Use \`vidfarm skills add vidfarm\` directly.
799
846
  --dir <path> Project root for the canonical pack install
800
- --print Print the generated single-file director rollup
801
- (copy/share artifact; not the canonical install)
802
847
 
803
848
  Files (multi-step flows the devcli handles for you):
804
849
  upload <file> Upload to the EPHEMERAL temp store, print URL → presign + S3 PUT + finalize
@@ -1380,9 +1425,13 @@ async function main() {
1380
1425
  case "slop-check":
1381
1426
  await runQaCommand(rest);
1382
1427
  return;
1428
+ case "harness":
1429
+ case "harnesses":
1430
+ // `regime` is the pre-rename name. Kept as a silent alias so old scripts
1431
+ // and old skill copies keep working; every surface says "harness" now.
1383
1432
  case "regime":
1384
1433
  case "regimes":
1385
- await runRegimeCommand(rest);
1434
+ await runHarnessCommand(rest);
1386
1435
  return;
1387
1436
  case "stills":
1388
1437
  await runStillsCommand(rest);
@@ -2388,9 +2437,10 @@ Rules:
2388
2437
  - 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.
2389
2438
  - 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.
2390
2439
  - 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).
2391
- - 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\`.
2440
+ - 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 harness show hooks\`.
2392
2441
  - 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\`.
2393
- - 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 reportnever claim a pass on the half the CLI can't judge. When a batch teaches you something, write it back into the regime.
2442
+ - 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 therea 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.
2443
+ - 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.)
2394
2444
  - 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\`.
2395
2445
  - 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.
2396
2446
 
@@ -6577,10 +6627,6 @@ async function runTtsCommand(argv) {
6577
6627
  throw new Error("tts --engine must be local (keyless Kokoro), byok (your provider key — default), or cloud.");
6578
6628
  }
6579
6629
  const wantCloud = Boolean(parsed.values.cloud) || engine === "cloud";
6580
- if (engine === "local") {
6581
- await runLocalKokoroTts({ text, voice, style, out: parsed.values.out, format: explicitFormat, json });
6582
- return;
6583
- }
6584
6630
  // COST-MODE DEFAULT: in minimize/hybrid, narration starts on the FREE local
6585
6631
  // Kokoro engine unless the run asked for a premium/cloud voice (see
6586
6632
  // wantsPremiumVoice). The BYOK key path is a real fallback, not a downgrade —
@@ -6588,6 +6634,18 @@ async function runTtsCommand(argv) {
6588
6634
  // below rather than failing the run.
6589
6635
  const costMode = resolveCostMode(parsed.values);
6590
6636
  const premium = wantsPremiumVoice(parsed.values, voice, style);
6637
+ // Offer the human the choice of voice BEFORE any engine runs — the notice is
6638
+ // identical whichever tier this run lands on, so it sits above the dispatch.
6639
+ printVoiceCheckpoint({
6640
+ interaction: resolveInteractionMode(parsed.values),
6641
+ costMode: costMode.mode,
6642
+ voiceChosen: Boolean(voice?.trim()),
6643
+ tier: engine === "local" || (!engine && !wantCloud && costMode.mode !== "rich-ai" && costMode.mode !== "pure-videogen" && !premium) ? "free" : "premium"
6644
+ });
6645
+ if (engine === "local") {
6646
+ await runLocalKokoroTts({ text, voice, style, out: parsed.values.out, format: explicitFormat, json });
6647
+ return;
6648
+ }
6591
6649
  /** Set when the cost-mode default ran Kokoro and it failed — so the keyless
6592
6650
  * path below doesn't pointlessly retry the same engine. */
6593
6651
  let kokoroError = null;
@@ -6685,7 +6743,7 @@ async function runTtsCommand(argv) {
6685
6743
  if (parsed.values["own-key"])
6686
6744
  payload.use_wallet_credits = false;
6687
6745
  if (!ctx.json && !parsed.values["own-key"]) {
6688
- 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}`);
6746
+ 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}`);
6689
6747
  }
6690
6748
  const tracer = parsed.values.tracer ?? `devcli-tts-${Date.now().toString(36)}`;
6691
6749
  const submit = await apiRequest({ method: "POST", host: ctx.host, path: "/api/v1/primitives/audio/speech", auth: ctx.auth, body: { tracer, payload } });
@@ -6722,6 +6780,38 @@ async function runTtsCommand(argv) {
6722
6780
  console.log(`${GREEN}Wrote ${outPath}${RESET} ${DIM}(${mediaUrl})${RESET}`);
6723
6781
  }
6724
6782
  }
6783
+ /**
6784
+ * The VOICE CHECKPOINT. Who a video sounds like is a taste decision, and the
6785
+ * default voice is the one choice an agent makes silently that a human would
6786
+ * almost always want a say in. So when narration is about to run with NO voice
6787
+ * named, say so — loudly enough that the agent stops and offers samples.
6788
+ *
6789
+ * Interactive mode → always offer (sampling is FREE on both rosters, so this
6790
+ * holds in `minimize` exactly as much as in `hybrid`; only the roster the offer
6791
+ * leads with changes). Autonomous with a mode actually set → stay silent and
6792
+ * let the pipeline run. Mode never set → offer, because "nobody asked" is not
6793
+ * the same as "the user opted out of choosing".
6794
+ *
6795
+ * stderr, never stdout, so `--json` output stays parseable.
6796
+ */
6797
+ function printVoiceCheckpoint(input) {
6798
+ if (input.voiceChosen)
6799
+ return;
6800
+ if (input.interaction.mode !== "interactive" && input.interaction.isSet)
6801
+ return;
6802
+ const lead = input.interaction.mode === "interactive"
6803
+ ? "[voice] Interactive mode and no voice picked yet"
6804
+ : "[voice] No interaction preference set and no voice picked yet";
6805
+ const free = "vidfarm voices --free --sample (renders $0 local Kokoro samples)";
6806
+ const premium = "vidfarm voices --sample (premium ElevenLabs voices through VIDFARM'S OWN ElevenLabs connection — " +
6807
+ "no ElevenLabs account or API key needed, narration billed as wallet credits, pennies each)";
6808
+ const order = input.tier === "free"
6809
+ ? `Sample the free voices first: ${free}. Same command reaches the premium tier: ${premium}.`
6810
+ : `Sample them: ${premium}. Or stay at $0: ${free}.`;
6811
+ console.error(`${lead} — ASK THE USER whether they want to hear a few samples and choose. ${order} ` +
6812
+ "Sampling costs nothing on either tier (preview clips + local renders), so it's safe even in cost mode minimize. " +
6813
+ "Then narrate with --voice <id>.");
6814
+ }
6725
6815
  /** Kokoro voice ids look like af_heart / am_adam / bf_emma. Anything else is a
6726
6816
  * cloud provider preset (openai "alloy", gemini "Kore", an ElevenLabs id).
6727
6817
  * Declared as a hoisted function, not a const: `wantsPremiumVoice` runs during
@@ -6770,7 +6860,7 @@ async function runLocalKokoroTts(input) {
6770
6860
  if (!input.json) {
6771
6861
  console.log(`${DIM}Generating speech with the local Kokoro-82M engine (free, no account)…${RESET}`);
6772
6862
  if (input.costModeDefault) {
6773
- console.log(`${DIM}(cost mode ${costModeDisplayName(input.costModeDefault)} → free local voice by default. Want a premium voice? add --style/--voice/--provider, or --cloud.)${RESET}`);
6863
+ 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}`);
6774
6864
  }
6775
6865
  }
6776
6866
  const run = await runHyperframesCommand("tts", [input.text, "-o", wavPath, ...(voice ? ["-v", voice] : [])], { stdio: input.json ? "capture" : "inherit" });
@@ -6956,8 +7046,53 @@ async function runMusicCommand(argv) {
6956
7046
  console.log(`${DIM}Place it as its own <audio> layer under narration (~0.1–0.2 volume).${RESET}`);
6957
7047
  }
6958
7048
  }
6959
- // List ElevenLabs voices for TTS. --own-key lists the voices on your own saved
6960
- // ElevenLabs key; default lists the platform account's voices.
7049
+ /** The sentence spoken when sampling FREE voices (premium samples reuse
7050
+ * ElevenLabs' own preview clips, so they need no text). A hoisted function,
7051
+ * not a const: cli.ts runs `await main()` at top level ABOVE this point, so a
7052
+ * const declared here hits the TDZ when a command reads it. */
7053
+ function voiceSampleText() {
7054
+ return "Here's how I sound. Pick the voice that fits your video.";
7055
+ }
7056
+ /** Pull the human-meaningful line out of a hyperframes engine failure. Its CLI
7057
+ * paints spinners and cursor codes, so the LAST line is usually an escape
7058
+ * sequence — prefer a line that actually reads like an error. */
7059
+ function engineFailureLine(output, fallback) {
7060
+ const clean = output
7061
+ // ESC-anchored so it strips real ANSI, never a literal "[mode]" in a message.
7062
+ .replace(/\u001b\[[0-9;?]*[a-zA-Z]/g, "")
7063
+ .split("\n")
7064
+ .map((line) => line.replace(/^[\s│◇◆○●✖✗ו]+/, "").trim())
7065
+ .filter((line) => line.length > 2);
7066
+ return (clean.find((line) => /fail|error|not installed|cannot|unable|missing/i.test(line)) ?? clean.pop() ?? fallback).slice(0, 240);
7067
+ }
7068
+ /** The free local Kokoro roster, straight from the bundled engine (never a
7069
+ * hardcoded list — the engine owns which ids actually load). */
7070
+ async function listFreeVoices() {
7071
+ const run = await runHyperframesCommand("tts", ["--list", "--json"], { stdio: "capture" });
7072
+ if (run.code !== 0)
7073
+ return [];
7074
+ try {
7075
+ const parsed = JSON.parse(run.stdout.slice(run.stdout.indexOf("[")));
7076
+ return Array.isArray(parsed) ? parsed : [];
7077
+ }
7078
+ catch {
7079
+ return [];
7080
+ }
7081
+ }
7082
+ // Browse the voices available for narration, and SAMPLE them so a human can
7083
+ // actually choose one by ear. Two rosters, because they cost different money:
7084
+ //
7085
+ // premium (default) — the ElevenLabs catalog reached through VIDFARM'S OWN
7086
+ // ElevenLabs connection. No ElevenLabs account, no API key, no separate
7087
+ // subscription: narration runs on the platform key and is billed as
7088
+ // Vidfarm wallet credits. `--own-key` swaps in the customer's own saved
7089
+ // ElevenLabs key instead (their account, their voices, no wallet spend).
7090
+ // free (--free) — the keyless local Kokoro-82M presets, $0 forever, the
7091
+ // default engine in cost mode minimize/hybrid.
7092
+ //
7093
+ // SAMPLING IS ALWAYS FREE on both rosters: premium samples are ElevenLabs'
7094
+ // static preview clips (a CDN download, not a synthesis call) and free samples
7095
+ // are generated on the local engine. So `--sample` is safe in `minimize`.
6961
7096
  async function runVoicesCommand(argv) {
6962
7097
  const parsed = parseArgs({
6963
7098
  args: argv,
@@ -6965,28 +7100,135 @@ async function runVoicesCommand(argv) {
6965
7100
  options: {
6966
7101
  ...commonOptions(),
6967
7102
  "own-key": { type: "boolean", default: false },
7103
+ free: { type: "boolean", default: false },
7104
+ all: { type: "boolean", default: false },
7105
+ search: { type: "string" },
7106
+ sample: { type: "boolean", default: false },
7107
+ "sample-count": { type: "string" },
7108
+ "sample-out": { type: "string" },
7109
+ "sample-text": { type: "string" },
6968
7110
  limit: { type: "string" }
6969
7111
  }
6970
7112
  });
6971
7113
  const ctx = commonContext(parsed.values);
6972
- const query = parsed.values["own-key"] ? "?use_wallet_credits=false" : "";
6973
- const res = await apiRequest({ method: "GET", host: ctx.host, path: `/api/v1/primitives/audio/voices${query}`, auth: ctx.auth });
6974
- assertApiOk(res, "voices");
6975
- const body = (res.json ?? {});
6976
- const voices = Array.isArray(body.voices) ? body.voices : [];
7114
+ const wantFree = Boolean(parsed.values.free) || Boolean(parsed.values.all);
7115
+ const wantPremium = !parsed.values.free || Boolean(parsed.values.all);
7116
+ const search = parsed.values.search?.trim().toLowerCase() || null;
7117
+ const sample = Boolean(parsed.values.sample);
7118
+ const sampleCount = Math.max(1, Number(parsed.values["sample-count"] ?? "6") || 6);
7119
+ const sampleText = parsed.values["sample-text"]?.trim() || voiceSampleText();
7120
+ const sampleDir = path.resolve(process.cwd(), parsed.values["sample-out"] ?? "voice-samples");
7121
+ const limit = Number(parsed.values.limit ?? "40") || 40;
7122
+ const matches = (haystack) => !search || haystack.toLowerCase().includes(search);
7123
+ const premium = [];
7124
+ let scope = "platform";
7125
+ let libraryUrl = "https://elevenlabs.io/app/voice-library";
7126
+ if (wantPremium) {
7127
+ const query = parsed.values["own-key"] ? "?use_wallet_credits=false" : "";
7128
+ const res = await apiRequest({ method: "GET", host: ctx.host, path: `/api/v1/primitives/audio/voices${query}`, auth: ctx.auth });
7129
+ assertApiOk(res, "voices");
7130
+ const body = (res.json ?? {});
7131
+ scope = String(body.scope ?? "platform");
7132
+ libraryUrl = String(body.voice_library_url ?? libraryUrl);
7133
+ for (const voice of Array.isArray(body.voices) ? body.voices : []) {
7134
+ const labels = voice.labels && typeof voice.labels === "object" ? Object.values(voice.labels).join(", ") : "";
7135
+ if (matches(`${voice.name ?? ""} ${labels} ${voice.description ?? ""} ${voice.category ?? ""}`)) {
7136
+ premium.push({ ...voice, label_text: labels });
7137
+ }
7138
+ }
7139
+ }
7140
+ const free = wantFree ? (await listFreeVoices()).filter((v) => matches(`${v.id} ${v.label} ${v.language} ${v.gender}`)) : [];
7141
+ // Sampling: write listenable audio to disk so the human can play it and pick.
7142
+ const samples = [];
7143
+ /** Why a sample didn't land. Never swallowed: a browse that quietly produced
7144
+ * no audio reads as "there was nothing to hear". */
7145
+ const sampleErrors = [];
7146
+ if (sample) {
7147
+ mkdirSync(sampleDir, { recursive: true });
7148
+ for (const voice of premium.filter((v) => v.preview_url).slice(0, sampleCount)) {
7149
+ const out = path.join(sampleDir, `premium-${String(voice.name ?? voice.voice_id).replace(/[^a-z0-9]+/gi, "-").toLowerCase()}-${voice.voice_id}.mp3`);
7150
+ try {
7151
+ await downloadUrlToFile(String(voice.preview_url), out);
7152
+ samples.push({ voice_id: String(voice.voice_id), name: String(voice.name ?? ""), tier: "premium", out });
7153
+ }
7154
+ catch (error) {
7155
+ // A dead preview URL must not sink the whole browse.
7156
+ sampleErrors.push({ voice_id: String(voice.voice_id), tier: "premium", error: String(error?.message ?? error).slice(0, 200) });
7157
+ }
7158
+ }
7159
+ for (const voice of free.slice(0, sampleCount)) {
7160
+ const out = path.join(sampleDir, `free-${voice.id}.wav`);
7161
+ const run = await runHyperframesCommand("tts", [sampleText, "-o", out, "-v", voice.id], { stdio: "capture" });
7162
+ if (run.code === 0 && existsSync(out)) {
7163
+ samples.push({ voice_id: voice.id, name: voice.label, tier: "free", out });
7164
+ }
7165
+ else {
7166
+ sampleErrors.push({ voice_id: voice.id, tier: "free", error: engineFailureLine(`${run.stderr}\n${run.stdout}`, `exit ${run.code}`) });
7167
+ }
7168
+ }
7169
+ }
6977
7170
  if (ctx.json) {
6978
- printJson(body);
7171
+ printJson({
7172
+ ok: true,
7173
+ scope,
7174
+ billing: parsed.values["own-key"] ? "your own ElevenLabs key" : "vidfarm platform ElevenLabs connection (wallet credits)",
7175
+ voice_library_url: libraryUrl,
7176
+ premium_voices: premium,
7177
+ free_voices: free,
7178
+ samples,
7179
+ sample_errors: sampleErrors,
7180
+ sample_dir: sample ? sampleDir : null
7181
+ });
6979
7182
  return;
6980
7183
  }
6981
- console.log(`${DIM}${voices.length} ElevenLabs voices (${body.scope ?? "platform"} account). Browse more: ${body.voice_library_url ?? "https://elevenlabs.io/app/voice-library"}${RESET}`);
6982
- const limit = Number(parsed.values.limit ?? "40") || 40;
6983
- for (const voice of voices.slice(0, limit)) {
6984
- const labels = voice.labels && typeof voice.labels === "object" ? Object.values(voice.labels).join(", ") : "";
6985
- console.log(`${GREEN}${voice.voice_id}${RESET} ${voice.name}${labels ? ` ${DIM}(${labels})${RESET}` : ""}`);
7184
+ if (wantPremium) {
7185
+ console.log(scope === "user"
7186
+ ? `${GREEN}${premium.length} premium ElevenLabs voices${RESET} ${DIM}(YOUR OWN ElevenLabs key — billed to your ElevenLabs account, no wallet spend).${RESET}`
7187
+ : `${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}`);
7188
+ for (const voice of premium.slice(0, limit)) {
7189
+ console.log(`${GREEN}${voice.voice_id}${RESET} ${voice.name}${voice.label_text ? ` ${DIM}(${voice.label_text})${RESET}` : ""}`);
7190
+ if (voice.preview_url)
7191
+ console.log(` ${DIM}preview: ${voice.preview_url}${RESET}`);
7192
+ }
7193
+ if (premium.length > limit)
7194
+ console.log(`${DIM}… ${premium.length - limit} more (raise with --limit, narrow with --search "british narrator", or use --json).${RESET}`);
7195
+ console.log(`${DIM}Browse thousands more at ${libraryUrl}.${RESET}`);
7196
+ }
7197
+ if (wantFree) {
7198
+ 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}`);
7199
+ for (const voice of free.slice(0, limit)) {
7200
+ console.log(`${GREEN}${voice.id}${RESET} ${voice.label} ${DIM}(${voice.language}, ${voice.gender})${RESET}`);
7201
+ }
7202
+ }
7203
+ if (!wantFree && wantPremium) {
7204
+ console.log(`${DIM}Prefer to spend nothing? vidfarm voices --free lists the $0 local voices (--all lists both).${RESET}`);
6986
7205
  }
6987
- if (voices.length > limit)
6988
- console.log(`${DIM} ${voices.length - limit} more (raise with --limit or use --json).${RESET}`);
6989
- console.log(`${DIM}Use one: vidfarm tts "Hello" --cloud --voice <voice_id>${RESET}`);
7206
+ if (samples.length) {
7207
+ 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}`);
7208
+ for (const s of samples)
7209
+ console.log(` ${DIM}${s.tier === "free" ? "free " : "prem "}${s.voice_id} — ${s.name} → ${s.out}${RESET}`);
7210
+ console.log(`${DIM}PLAY THESE FOR THE USER and let them pick, then narrate with the id they choose.${RESET}`);
7211
+ }
7212
+ if (sampleErrors.length) {
7213
+ const freeFailed = sampleErrors.filter((e) => e.tier === "free");
7214
+ console.log(`${RED}${sampleErrors.length} sample(s) could not be produced.${RESET} ${DIM}${sampleErrors[0].error}${RESET}`);
7215
+ if (freeFailed.length) {
7216
+ 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}`);
7217
+ }
7218
+ }
7219
+ if (!sample) {
7220
+ 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}`);
7221
+ }
7222
+ // Interaction mode decides whose ear picks the voice. In interactive mode the
7223
+ // human should hear samples and choose; when the mode was never set, say so
7224
+ // rather than silently picking a default voice on their behalf.
7225
+ if (ctx.interaction.mode === "interactive") {
7226
+ console.log(`${DIM}[mode] Interactive — don't pick for them: sample a few, share the files, and let the user choose the voice.${RESET}`);
7227
+ }
7228
+ else if (!ctx.interaction.isSet) {
7229
+ 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}`);
7230
+ }
7231
+ console.log(`${DIM}Use one: vidfarm tts "Hello" --cloud --voice <voice_id> (free voice: vidfarm tts "Hello" --engine local --voice <kokoro_id>)${RESET}`);
6990
7232
  }
6991
7233
  async function runMediaCommand(argv) {
6992
7234
  const MEDIA_TYPES = ["image", "vector", "icon", "video", "bgm", "sfx"];
@@ -10130,31 +10372,52 @@ async function runQaCommand(argv) {
10130
10372
  options: {
10131
10373
  json: { type: "boolean", default: false },
10132
10374
  strict: { type: "boolean", default: false },
10375
+ harness: { type: "string", multiple: true },
10376
+ "no-harness": { type: "boolean", default: false },
10377
+ // Pre-rename aliases; `harness` is the name everywhere else now.
10133
10378
  regime: { type: "string", multiple: true },
10134
10379
  "no-regime": { type: "boolean", default: false }
10135
10380
  }
10136
10381
  });
10137
10382
  const target = parsed.positionals[0];
10138
10383
  if (!target)
10139
- throw new Error("qa requires a composition path: `vidfarm qa <dir-or-composition.html> [--regime <name|path>] [--json] [--strict]`.");
10384
+ throw new Error("qa requires a composition path: `vidfarm qa <dir-or-composition.html> [--harness <name|path>] [--json] [--strict]`.");
10140
10385
  const htmlPath = resolveCompositionHtmlPath(target);
10141
10386
  const html = readFileSync(htmlPath, "utf8");
10142
10387
  let report = qaCompositionHtml(html);
10143
- // Regime resolution, in precedence order: explicit --regime flags (stackable)
10144
- // → VIDFARM_QA_REGIME (a default for a whole scripting run) → the working
10145
- // directory's own QA_REGIME.md. --no-regime opts out of the implicit paths.
10146
- const explicit = parsed.values.regime ?? [];
10147
- const fromEnv = (process.env.VIDFARM_QA_REGIME ?? "").split(",").map((entry) => entry.trim()).filter(Boolean);
10148
- let regimeRefs = explicit.length ? explicit : fromEnv;
10149
- if (!regimeRefs.length && !parsed.values["no-regime"]) {
10150
- const discovered = discoverRegime(path.dirname(htmlPath));
10151
- if (discovered)
10152
- regimeRefs = [discovered];
10153
- }
10154
- if (regimeRefs.length) {
10155
- const facts = extractCompositionFacts(html);
10156
- report = mergeRegimeIntoReport(report, regimeRefs.map((ref) => loadAndEvaluateRegime(ref, facts)));
10388
+ // Harness resolution, in precedence order: explicit --harness flags
10389
+ // (stackable) VIDFARM_HARNESS (a default for a whole scripting run) → the
10390
+ // working directory's own HARNESS.md. --no-harness opts out of the implicit
10391
+ // paths. The `regime`-spelled forms are the pre-rename aliases.
10392
+ const explicit = [
10393
+ ...(parsed.values.harness ?? []),
10394
+ ...(parsed.values.regime ?? [])
10395
+ ];
10396
+ const envValue = process.env.VIDFARM_HARNESS ?? process.env.VIDFARM_QA_REGIME ?? "";
10397
+ const fromEnv = envValue.split(",").map((entry) => entry.trim()).filter(Boolean);
10398
+ let harnessRefs = explicit.length ? explicit : fromEnv;
10399
+ const optedOut = Boolean(parsed.values["no-harness"]) || Boolean(parsed.values["no-regime"]);
10400
+ if (!harnessRefs.length && !optedOut) {
10401
+ const discovered = discoverHarness(path.dirname(htmlPath));
10402
+ if (discovered) {
10403
+ harnessRefs = [discovered];
10404
+ if (isLegacyHarnessFilename(discovered)) {
10405
+ console.log(`${DIM}Using ${path.basename(discovered)} — the file is now called ${HARNESS_FILENAME}. Rename it when convenient; both are read.${RESET}`);
10406
+ }
10407
+ }
10157
10408
  }
10409
+ if (harnessRefs.length) {
10410
+ const facts = extractCompositionFacts(html);
10411
+ report = mergeHarnessIntoReport(report, harnessRefs.map((ref) => loadAndEvaluateHarness(ref, facts)));
10412
+ }
10413
+ // The directive is on the report already; re-issue it with THIS work dir (and
10414
+ // an existing render, if there is one) substituted into the commands.
10415
+ const workDir = path.dirname(htmlPath);
10416
+ const render = newestRenderIn(workDir);
10417
+ report = {
10418
+ ...report,
10419
+ watch_the_video: watchTheVideoDirective(pastablePath(workDir), render ? pastablePath(render) : null)
10420
+ };
10158
10421
  if (parsed.values.json) {
10159
10422
  printJson(report);
10160
10423
  if (!report.ok && parsed.values.strict)
@@ -10164,13 +10427,13 @@ async function runQaCommand(argv) {
10164
10427
  const body = formatQaReport(report, { red: RED, yellow: YELLOW, green: GREEN, dim: DIM, reset: RESET });
10165
10428
  if (body)
10166
10429
  console.log(body);
10167
- for (const evaluation of report.regimes ?? []) {
10168
- console.log(formatRegimeReport(evaluation, { green: GREEN, red: RED, yellow: YELLOW, dim: DIM, reset: RESET }));
10430
+ for (const evaluation of report.harnesses ?? []) {
10431
+ console.log(formatHarnessReport(evaluation, { green: GREEN, red: RED, yellow: YELLOW, dim: DIM, reset: RESET }));
10169
10432
  }
10170
10433
  const scope = `${report.checked.layers} layer(s), ${report.checked.text_layers} text layer(s)${report.checked.canvas ? `, ${report.checked.canvas}` : ""}`;
10171
- const graded = (report.regimes ?? []).length > 0;
10434
+ const graded = (report.harnesses ?? []).length > 0;
10172
10435
  if (report.verdict === "clean") {
10173
- console.log(`${GREEN}✓ no HTML slop found${graded ? ", regime checks passed" : ""}${RESET} ${DIM}— ${scope} — ${htmlPath}${RESET}`);
10436
+ console.log(`${GREEN}✓ no HTML slop found${graded ? ", harness checks passed" : ""}${RESET} ${DIM}— ${scope} — ${htmlPath}${RESET}`);
10174
10437
  }
10175
10438
  else if (report.verdict === "warnings") {
10176
10439
  console.log(`${YELLOW}! ${report.warnings.length} warning(s), no slop${RESET} ${DIM}— ${scope} — ${htmlPath}${RESET}`);
@@ -10179,81 +10442,376 @@ async function runQaCommand(argv) {
10179
10442
  console.log(`${RED}✗ ${report.errors.length} ${graded ? "issue(s)" : "slop issue(s)"}${RESET}, ${report.warnings.length} warning(s) ${DIM}— ${scope} — ${htmlPath}${RESET}`);
10180
10443
  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}`);
10181
10444
  }
10182
- const pendingReview = (report.regimes ?? []).reduce((total, evaluation) => total + evaluation.review_items.length, 0);
10445
+ const pendingReview = (report.harnesses ?? []).reduce((total, evaluation) => total + evaluation.review_items.length, 0);
10183
10446
  if (pendingReview) {
10184
- console.log(`${DIM}${pendingReview} regime item(s) above are NOT machine-checkable — answer them yourself before you call this video done.${RESET}`);
10447
+ console.log(`${DIM}${pendingReview} harness item(s) above are NOT machine-checkable — answer them yourself before you call this video done.${RESET}`);
10185
10448
  }
10186
10449
  else if (!graded) {
10187
10450
  // Everything above is mechanical. Structure — hook, loop, payoff, bait — is
10188
10451
  // what actually decides whether the video travels, and nothing here checks
10189
10452
  // it. Say so rather than letting a green tick imply the video is good.
10190
10453
  console.log(`${DIM}Checked mechanics only — nothing here grades the hook, the loop, the payoff, or the bait.${RESET}`);
10191
- console.log(`${DIM}Structure: vidfarm regime show hooks · grade this video: vidfarm qa <dir> --regime short-form${RESET}`);
10454
+ console.log(`${DIM}Structure: vidfarm harness show hooks · grade this video: vidfarm qa <dir> --harness short-form${RESET}`);
10192
10455
  }
10456
+ // Last word on every run, clean ones included: this tool never saw the video.
10457
+ console.log(formatWatchTheVideoNotice(report.watch_the_video, { yellow: YELLOW, dim: DIM, reset: RESET, bold: BOLD }));
10193
10458
  if (!report.ok && parsed.values.strict)
10194
10459
  process.exitCode = 1;
10195
10460
  }
10196
- // `vidfarm regime <list|show|init>` the QA_REGIME.md surface. A regime is the
10197
- // director's OWN quality contract for one template style; the built-ins are
10198
- // starting points to copy and edit, never a house style to conform to.
10199
- async function runRegimeCommand(argv) {
10461
+ /** Shortest form of a path the caller can paste back: relative when it's under cwd. */
10462
+ function pastablePath(target) {
10463
+ const rel = path.relative(process.cwd(), target);
10464
+ if (!rel)
10465
+ return ".";
10466
+ return rel.startsWith("..") ? target : `./${rel}`;
10467
+ }
10468
+ /** Newest rendered MP4 in `<dir>/renders`, if the director has rendered yet. */
10469
+ function newestRenderIn(dir) {
10470
+ const rendersDir = path.join(dir, "renders");
10471
+ try {
10472
+ const candidates = readdirSync(rendersDir)
10473
+ .filter((entry) => entry.toLowerCase().endsWith(".mp4"))
10474
+ .map((entry) => {
10475
+ const full = path.join(rendersDir, entry);
10476
+ return { full, mtime: statSync(full).mtimeMs };
10477
+ })
10478
+ .sort((a, b) => b.mtime - a.mtime);
10479
+ return candidates.length ? candidates[0].full : null;
10480
+ }
10481
+ catch {
10482
+ return null;
10483
+ }
10484
+ }
10485
+ // `vidfarm harness <list|show|init|derive|check>` — the HARNESS.md surface.
10486
+ //
10487
+ // A harness is the reusable apparatus for ONE format or template: what makes it
10488
+ // special, written down so an agent can reproduce it without the director in
10489
+ // the room. Three director phrasings all land here:
10490
+ //
10491
+ // "create me a harness" → init (copy a base, then EDIT)
10492
+ // "update the harness for this format" → edit the file in place; the
10493
+ // CLI just reads and grades it
10494
+ // "give me the harness for this template_id" → derive (the DECOMPOSITION,
10495
+ // distilled into a harness)
10496
+ //
10497
+ // The built-ins are starting points to copy and edit, never a house style to
10498
+ // conform to.
10499
+ async function runHarnessCommand(argv) {
10200
10500
  const sub = argv[0];
10201
10501
  const parsed = parseArgs({
10202
10502
  args: argv.slice(1),
10203
10503
  allowPositionals: true,
10204
10504
  options: {
10205
- json: { type: "boolean", default: false },
10505
+ // `derive` talks to the API to fetch a fork's decompose output, so this
10506
+ // command carries the standard auth/host flags alongside its own.
10507
+ ...commonOptions(),
10206
10508
  out: { type: "string" },
10207
10509
  dir: { type: "string" },
10510
+ dna: { type: "string" },
10511
+ refetch: { type: "boolean", default: false },
10208
10512
  force: { type: "boolean", default: false }
10209
10513
  }
10210
10514
  });
10211
10515
  const json = Boolean(parsed.values.json);
10212
10516
  if (!sub || sub === "list") {
10213
- const builtins = listBuiltinRegimes();
10517
+ const builtins = listBuiltinHarnesses();
10214
10518
  if (json)
10215
- return printJson({ regimes: builtins.map(({ name, path: file, video_type }) => ({ name, path: file, video_type })) });
10216
- console.log(`${DIM}Built-in QA regimes — copy one next to your work, then edit it:${RESET}`);
10519
+ return printJson({ harnesses: builtins.map(({ name, path: file, video_type }) => ({ name, path: file, video_type })) });
10520
+ console.log(`${DIM}Built-in harnesses — copy one next to your work, then edit it:${RESET}`);
10217
10521
  for (const entry of builtins) {
10218
10522
  console.log(` ${GREEN}${entry.name}${RESET} ${DIM}${entry.video_type ?? ""}${RESET}`);
10219
10523
  }
10220
- console.log(`\n${DIM}vidfarm regime init <name> --out ./work/QA_REGIME.md${RESET}`);
10221
- console.log(`${DIM}vidfarm qa ./work --regime <name|path> (repeatable regimes stack)${RESET}`);
10222
- 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}`);
10524
+ console.log(`\n${DIM}vidfarm harness init <name> --out ./work/${HARNESS_FILENAME}${RESET}`);
10525
+ console.log(`${DIM}vidfarm harness derive <forkId> (a decomposed template → a harness)${RESET}`);
10526
+ console.log(`${DIM}vidfarm qa ./work --harness <name|path> (repeatable harnesses stack)${RESET}`);
10527
+ 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}`);
10223
10528
  return;
10224
10529
  }
10225
10530
  if (sub === "show") {
10226
10531
  const ref = parsed.positionals[0];
10227
10532
  if (!ref)
10228
- throw new Error("regime show requires a name or path: `vidfarm regime show <name|path>`.");
10229
- const file = resolveRegimePath(ref);
10533
+ throw new Error("harness show requires a name or path: `vidfarm harness show <name|path>`.");
10534
+ const file = resolveHarnessPath(ref);
10230
10535
  const raw = readFileSync(file, "utf8");
10536
+ const doc = parseHarness(raw, file);
10537
+ // `--dna visual` prints ONE strand. A harness that documents five kinds of
10538
+ // DNA is long; an agent re-theming the look only needs the visual strand.
10539
+ const wanted = parsed.values.dna ? String(parsed.values.dna).toLowerCase().replace(/[^a-z0-9]+/g, "_") : null;
10540
+ if (wanted) {
10541
+ const strand = doc.dna.find((entry) => entry.key === wanted || entry.key === `${wanted}_dna` || entry.key.startsWith(wanted));
10542
+ if (!strand) {
10543
+ throw new Error(`"${doc.name}" documents no ${wanted} strand. It has: ${doc.dna.map((entry) => entry.key).join(", ") || "(no DNA sections)"}.`);
10544
+ }
10545
+ if (json)
10546
+ return printJson(strand);
10547
+ console.log(`## ${strand.heading}\n\n${strand.body}`);
10548
+ return;
10549
+ }
10231
10550
  if (json)
10232
- return printJson(parseRegime(raw, file));
10551
+ return printJson(doc);
10233
10552
  console.log(raw);
10234
10553
  return;
10235
10554
  }
10236
- if (sub === "init") {
10237
- const ref = parsed.positionals[0];
10238
- if (!ref)
10239
- throw new Error("regime init requires a built-in name or source path: `vidfarm regime init short-form --out ./work/QA_REGIME.md`.");
10240
- const source = resolveRegimePath(ref);
10555
+ if (sub === "init" || sub === "new" || sub === "create") {
10556
+ const ref = parsed.positionals[0] ?? "short-form";
10557
+ const source = resolveHarnessPath(ref);
10241
10558
  const outPath = parsed.values.out
10242
10559
  ? path.resolve(String(parsed.values.out))
10243
- : path.resolve(String(parsed.values.dir ?? "."), "QA_REGIME.md");
10560
+ : path.resolve(String(parsed.values.dir ?? "."), HARNESS_FILENAME);
10244
10561
  if (existsSync(outPath) && !parsed.values.force) {
10245
10562
  throw new Error(`${outPath} already exists. Pass --force to overwrite (you will lose your edits).`);
10246
10563
  }
10247
10564
  mkdirSync(path.dirname(outPath), { recursive: true });
10248
10565
  writeFileSync(outPath, readFileSync(source, "utf8"), "utf8");
10249
10566
  if (json)
10250
- return printJson({ ok: true, regime: ref, source, path: outPath });
10567
+ return printJson({ ok: true, harness: ref, source, path: outPath });
10251
10568
  console.log(`${GREEN}${outPath}${RESET} ${DIM}← ${path.basename(source)}${RESET}`);
10252
10569
  console.log(`${DIM}Now EDIT it — delete what doesn't apply, add what makes your format yours (start with the audience line).${RESET}`);
10253
10570
  console.log(`${DIM}Then: vidfarm qa ${path.dirname(outPath)}${RESET}`);
10254
10571
  return;
10255
10572
  }
10256
- throw new Error(`Unknown regime subcommand "${sub}". Use: list | show <name|path> | init <name> [--out <path>]`);
10573
+ if (sub === "derive" || sub === "from" || sub === "decompose") {
10574
+ const ref = parsed.positionals[0];
10575
+ if (!ref) {
10576
+ throw new Error("harness derive requires a fork/template id or a pulled work dir: `vidfarm harness derive <forkId|dir> [--out ./work/HARNESS.md]`.");
10577
+ }
10578
+ await runHarnessDerive(ref, {
10579
+ outPath: parsed.values.out ? path.resolve(String(parsed.values.out)) : null,
10580
+ dirOverride: parsed.values.dir ? path.resolve(String(parsed.values.dir)) : null,
10581
+ refetch: Boolean(parsed.values.refetch),
10582
+ force: Boolean(parsed.values.force),
10583
+ values: parsed.values,
10584
+ json
10585
+ });
10586
+ return;
10587
+ }
10588
+ if (sub === "check" || sub === "qa" || sub === "grade") {
10589
+ // The same engine as `vidfarm qa`, reachable under the noun the director
10590
+ // used. One implementation, two doors.
10591
+ const rest = argv.slice(1);
10592
+ await runQaCommand(rest.length ? rest : ["."]);
10593
+ return;
10594
+ }
10595
+ throw new Error(`Unknown harness subcommand "${sub}". Use: list | show <name|path> [--dna <strand>] | init <name> [--out <path>] | derive <forkId|dir> | check <dir>`);
10596
+ }
10597
+ /**
10598
+ * `vidfarm harness derive <forkId|dir>` — THE DECOMPOSITION, as a harness.
10599
+ *
10600
+ * When a director says "give me the harness for this template_id", this is what
10601
+ * they mean: the decompose pass already extracted the template's DNA into JSON
10602
+ * (video-context viral_dna, editor-harness, replication-harness,
10603
+ * scene-annotations), and this folds those strands into ONE editable Markdown
10604
+ * doc laid out the way every other harness is — so a derived harness and a
10605
+ * hand-written one are the same artifact, gradeable by the same `vidfarm qa`.
10606
+ *
10607
+ * It writes the DNA it FOUND and says "unknown" where the decompose pass has
10608
+ * nothing, rather than inventing a plausible strand. A harness that quietly
10609
+ * fabricates the thing the director asked for is worse than a short one.
10610
+ */
10611
+ async function runHarnessDerive(ref, opts) {
10612
+ const asDir = path.resolve(ref);
10613
+ const isLocalDir = existsSync(asDir) && statSync(asDir).isDirectory();
10614
+ let dir = opts.dirOverride ?? (isLocalDir ? asDir : null);
10615
+ let forkId = isLocalDir ? null : ref;
10616
+ if (!dir) {
10617
+ // A bare id: pull the decompose artifacts down the same way `vidfarm pull`
10618
+ // does, into the same cache dir, so a later `pull` is a no-op.
10619
+ const ctx = commonContext(opts.values);
10620
+ dir = path.resolve(process.cwd(), path.join(".vidfarm", ref));
10621
+ mkdirSync(dir, { recursive: true });
10622
+ await fetchCompositionFiles({ host: ctx.host, forkId: ref, dir, apiKey: ctx.auth.apiKey, shareToken: ctx.auth.shareToken, refetch: opts.refetch });
10623
+ }
10624
+ const videoContext = readJsonFile(path.join(dir, "video-context.json"));
10625
+ const editorHarness = readJsonFile(path.join(dir, "editor-harness.json"));
10626
+ const replicationHarness = readJsonFile(path.join(dir, "replication-harness.json"));
10627
+ if (!videoContext && !editorHarness && !replicationHarness) {
10628
+ throw new Error(`No decompose output in ${dir} — nothing to derive a harness from. Run \`vidfarm decompose ${forkId ?? "<forkId>"}\` first, ` +
10629
+ `then re-run. (A harness can also be written from scratch: \`vidfarm harness init short-form\`.)`);
10630
+ }
10631
+ const markdown = buildDerivedHarnessMarkdown({
10632
+ ref,
10633
+ forkId,
10634
+ dir,
10635
+ videoContext,
10636
+ editorHarness,
10637
+ replicationHarness,
10638
+ sceneAnnotations: readJsonFile(path.join(dir, "scene-annotations.json")),
10639
+ compositionHtml: existsSync(path.join(dir, "composition.html")) ? readFileSync(path.join(dir, "composition.html"), "utf8") : null
10640
+ });
10641
+ const outPath = opts.outPath ?? path.join(dir, HARNESS_FILENAME);
10642
+ if (existsSync(outPath) && !opts.force) {
10643
+ throw new Error(`${outPath} already exists. Pass --force to overwrite (you will lose your edits), or --out <path> to write elsewhere.`);
10644
+ }
10645
+ mkdirSync(path.dirname(outPath), { recursive: true });
10646
+ writeFileSync(outPath, markdown, "utf8");
10647
+ const parsedOut = parseHarness(markdown, outPath);
10648
+ if (opts.json) {
10649
+ return printJson({
10650
+ ok: true,
10651
+ source: forkId ?? dir,
10652
+ dir,
10653
+ path: outPath,
10654
+ dna: parsedOut.dna.map((strand) => strand.key),
10655
+ checks: Object.keys(parsedOut.checks),
10656
+ review_items: parsedOut.review_items.length
10657
+ });
10658
+ }
10659
+ console.log(`${GREEN}${outPath}${RESET} ${DIM}← decomposition of ${forkId ?? path.basename(dir)}${RESET}`);
10660
+ console.log(`${DIM}DNA written: ${parsedOut.dna.map((strand) => strand.key).join(", ") || "none"}${RESET}`);
10661
+ console.log(`${DIM}${Object.keys(parsedOut.checks).length} machine check(s), ${parsedOut.review_items.length} review item(s).${RESET}`);
10662
+ 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}`);
10663
+ console.log(`${DIM}Then: vidfarm qa ${pastablePath(dir)}${RESET}`);
10664
+ }
10665
+ /** Fold the decompose JSON strands into the standard harness layout. */
10666
+ function buildDerivedHarnessMarkdown(input) {
10667
+ const viralDna = (input.videoContext?.viral_dna && typeof input.videoContext.viral_dna === "object"
10668
+ ? input.videoContext.viral_dna : null);
10669
+ const harness = (input.editorHarness?.harness && typeof input.editorHarness.harness === "object"
10670
+ ? input.editorHarness.harness : null);
10671
+ const replication = (input.replicationHarness?.harness && typeof input.replicationHarness.harness === "object"
10672
+ ? input.replicationHarness.harness : null);
10673
+ const sub = (parent, key) => parent && parent[key] && typeof parent[key] === "object" && !Array.isArray(parent[key])
10674
+ ? parent[key]
10675
+ : null;
10676
+ const emotional = sub(viralDna, "emotional_punch") ?? sub(harness, "emotional");
10677
+ const pacing = sub(harness, "pacing");
10678
+ const typography = sub(harness, "typography");
10679
+ const broll = sub(harness, "broll");
10680
+ const transitions = sub(harness, "transitions");
10681
+ const audio = sub(harness, "audio");
10682
+ const staticVsPivot = sub(viralDna, "static_vs_pivot");
10683
+ const annotations = Array.isArray(input.sceneAnnotations?.annotations)
10684
+ ? input.sceneAnnotations.annotations
10685
+ : [];
10686
+ const scenes = Array.isArray(harness?.scenes) ? harness.scenes : [];
10687
+ const value = (raw) => readJsonString(raw) || "_unknown — the decompose pass didn't record this; fill it in._";
10688
+ const bullets = (items, empty) => items.length ? items.map((item) => `- ${item}`).join("\n") : `- ${empty}`;
10689
+ // The composition itself settles the two checks nobody should have to type.
10690
+ const info = input.compositionHtml ? inspectComposition(input.compositionHtml) : null;
10691
+ const durationCheck = info?.duration_seconds ? `${Math.max(1, Math.floor(info.duration_seconds * 0.7))}-${Math.ceil(info.duration_seconds * 1.3)}` : null;
10692
+ const beatRows = scenes.slice(0, 12).map((scene) => {
10693
+ const role = readJsonString(scene.role) || "beat";
10694
+ const importance = readJsonString(scene.importance);
10695
+ const bias = readJsonString(scene.edit_bias);
10696
+ const keep = scene.must_keep === true ? " **must-keep**" : "";
10697
+ return `**${role}**${importance ? ` (${importance})` : ""}${keep}${bias ? ` — ${bias}` : ""}`;
10698
+ });
10699
+ const mustPreserve = annotations
10700
+ .flatMap((entry) => readJsonStringList(entry.must_preserve, 3).map((item) => {
10701
+ const slug = readJsonString(entry.scene_slug);
10702
+ return slug ? `${slug}: ${item}` : item;
10703
+ }))
10704
+ .slice(0, 8);
10705
+ const recommendedStrategy = readJsonString(replication?.recommended_strategy) || "cheap_efficient";
10706
+ const cheapPlan = sub(replication, "cheap");
10707
+ const qualityPlan = sub(replication, "quality");
10708
+ const motionStyle = sub(replication, "motion_style");
10709
+ const frontMatter = [
10710
+ "---",
10711
+ `name: ${(input.forkId ?? path.basename(input.dir)).replace(/[^A-Za-z0-9_-]/g, "-")}`,
10712
+ `video_type: ${readJsonString(viralDna?.trend_tagline) || readJsonString(harness?.one_liner) || "derived from a decomposed template"}`,
10713
+ `derived_from: decompose`,
10714
+ input.forkId ? `source_template_id: ${input.forkId}` : null,
10715
+ "checks:",
10716
+ durationCheck ? ` duration_sec: ${durationCheck}` : null,
10717
+ info?.aspect_ratio ? ` aspect: ${info.aspect_ratio}` : null,
10718
+ " first_frame_visual: required",
10719
+ " font_regime: required",
10720
+ " safe_zone: required",
10721
+ "---"
10722
+ ].filter(Boolean).join("\n");
10723
+ return `${frontMatter}
10724
+
10725
+ # Harness: ${readJsonString(viralDna?.trend_tagline) || readJsonString(harness?.one_liner) || input.forkId || path.basename(input.dir)}
10726
+
10727
+ 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.
10728
+
10729
+ ## Viral DNA — why the source travelled
10730
+
10731
+ - **Trend/format:** ${value(viralDna?.trend_tagline)}
10732
+ - **Hook:** ${value(viralDna?.hook)}
10733
+ - **Retention mechanic:** ${value(viralDna?.retention ?? viralDna?.retention_mechanic)}
10734
+ - **Payoff:** ${value(viralDna?.payoff)}
10735
+ - **Core emotion:** ${value(emotional?.core_emotion ?? emotional?.target_feeling)}
10736
+ - **Mechanism:** ${value(emotional?.mechanism)}
10737
+ - **Contrast:** ${value(emotional?.contrast)}
10738
+
10739
+ **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\`.
10740
+
10741
+ - [ ] Does my version have its OWN hook, or did I keep the source's sentence with the nouns swapped?
10742
+ - [ ] Does the curiosity loop close inside this video, at a timestamp I can name?
10743
+ - [ ] Is the withheld answer one the viewer genuinely can't supply themselves?
10744
+
10745
+ ## Visual DNA — how it looks and cuts
10746
+
10747
+ - **Cut rhythm:** ${value(pacing?.cut_rhythm)}${readJsonString(pacing?.avg_scene_seconds) ? ` (~${readJsonString(pacing?.avg_scene_seconds)}s per scene)` : ""}
10748
+ - **Energy curve:** ${value(pacing?.energy_curve)}
10749
+ - **Caption style:** ${value(typography?.caption_style)} — placement ${readJsonString(typography?.placement) || "unrecorded"}, ${readJsonString(typography?.text_density) || "density unrecorded"}
10750
+ - **Font character:** ${value(typography?.font_character)}
10751
+ - **B-roll:** ${value(broll?.reliance)}${readJsonString(broll?.sourcing) ? ` — sourced ${readJsonString(broll?.sourcing)}` : ""}${readJsonString(broll?.cadence) ? `, cadence ${readJsonString(broll?.cadence)}` : ""}
10752
+ - **Transitions:** default ${readJsonString(transitions?.default) || "unrecorded"}, in ${readJsonString(transitions?.intro) || "unrecorded"}, out ${readJsonString(transitions?.outro) || "unrecorded"}
10753
+ - **Motion feel:** ${value(motionStyle?.animation_feel)}
10754
+
10755
+ - [ ] One type scale, one accent colour, one illustration style across every beat — checked on a contact sheet, not scene by scene (\`vidfarm stills . --sheet\`).
10756
+ - [ ] Caption colour and plate chosen by MEASURING the composited background, not by habit — one treatment for the whole video.
10757
+
10758
+ ## Structural DNA — the beats
10759
+
10760
+ ${bullets(beatRows, "_no scene roles recorded — run `vidfarm decompose` with the harness pass._")}
10761
+
10762
+ **Load-bearing beats (do not reskin past these):**
10763
+ ${bullets(mustPreserve.length ? mustPreserve : readJsonStringList(harness?.important_scenes, 6), "_none recorded._")}
10764
+
10765
+ - [ ] Every must-keep beat kept its timing, role, and caption cadence — I swapped the subject, not the structure.
10766
+
10767
+ ## Audio DNA — voice, bed, timing
10768
+
10769
+ - **Voiceover:** ${value(audio?.voiceover)}
10770
+ - **Music:** ${value(audio?.music)}
10771
+ - **SFX:** ${value(audio?.sfx)}
10772
+ - **Captions from:** ${value(audio?.captions_from)}
10773
+ - **Comedic timing:** ${value(emotional?.comedic_timing)}
10774
+ - **Intonation / delivery:** ${value(emotional?.intonation ?? emotional?.delivery)}
10775
+
10776
+ - [ ] The held beat / pause / hard cut that sells the joke survived the re-cut.
10777
+ - [ ] Speech sits ~12–15 dB over the bed across the actual word spans — MEASURED, not "sounds fine".
10778
+
10779
+ ## Build DNA — which paintbrush per beat
10780
+
10781
+ Recommended strategy: **${recommendedStrategy}**${readJsonString(replication?.recommendation_reason) ? ` — ${readJsonString(replication?.recommendation_reason)}` : ""}
10782
+
10783
+ - **Cheap mix:** ${value(cheapPlan?.method_mix ?? cheapPlan?.one_liner)}
10784
+ - **Best-quality mix:** ${value(qualityPlan?.method_mix ?? qualityPlan?.one_liner)}
10785
+ - **Free-tier path:** ${value(replication?.free_tier_note)}
10786
+
10787
+ **Do these in hyperframes (HTML/CSS/JS), never AI video:**
10788
+ ${bullets(readJsonStringList(motionStyle?.hyperframes_candidates, 6), "all on-screen text and graphic overlays")}
10789
+
10790
+ **Guardrails the replication pass flagged:**
10791
+ ${bullets(readJsonStringList(replication?.viral_dna_guardrails, 6), "_none recorded._")}
10792
+
10793
+ ## Re-theming — what's static, what pivots
10794
+
10795
+ - **Summary:** ${value(staticVsPivot?.summary)}
10796
+ - **Scenes:** ${value(sub(staticVsPivot, "scene_replacement")?.overall)}
10797
+ - **Narration:** ${value(sub(staticVsPivot, "narration")?.overall)}
10798
+ - **Music:** ${value(sub(staticVsPivot, "music")?.overall)}
10799
+ - **Captions:** ${value(sub(staticVsPivot, "captions")?.overall)}
10800
+
10801
+ ## Rules for this format
10802
+
10803
+ ${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._")}
10804
+
10805
+ > 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.
10806
+
10807
+ ## Whole-video review — do this last
10808
+
10809
+ 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\`.
10810
+
10811
+ - [ ] I read a contact sheet of the finished render, not just stills of the scenes I was working on.
10812
+ - [ ] I compared two frames from different scenes and they differ.
10813
+ - [ ] I reported what I MEASURED separately from what I JUDGED.
10814
+ `;
10257
10815
  }
10258
10816
  // `vidfarm stills <dir|composition.html>` — render PNG stills of the CURRENT
10259
10817
  // composition in-process (the agent's visual self-verification loop).
@@ -10264,20 +10822,32 @@ async function runStillsCommand(argv) {
10264
10822
  options: {
10265
10823
  at: { type: "string" },
10266
10824
  out: { type: "string" },
10825
+ sheet: { type: "boolean", default: false },
10826
+ "sheet-out": { type: "string" },
10827
+ "sheet-width": { type: "string" },
10267
10828
  json: { type: "boolean", default: false }
10268
10829
  }
10269
10830
  });
10270
10831
  const target = parsed.positionals[0];
10271
10832
  if (!target)
10272
- throw new Error("stills requires a composition path: `vidfarm stills <dir-or-composition.html> [--at 0,2.5,7] [--out <dir>]`.");
10833
+ throw new Error("stills requires a composition path: `vidfarm stills <dir-or-composition.html> [--at 0,2.5,7] [--sheet] [--out <dir>]`.");
10273
10834
  const htmlPath = resolveCompositionHtmlPath(target);
10274
10835
  const json = Boolean(parsed.values.json);
10275
10836
  const at = parsed.values.at
10276
10837
  ? String(parsed.values.at).split(",").map((value) => parseTimeToSeconds(value.trim())).filter((value) => Number.isFinite(value))
10277
10838
  : undefined;
10839
+ const sheetWidthRaw = parsed.values["sheet-width"] ? Number.parseInt(String(parsed.values["sheet-width"]), 10) : undefined;
10278
10840
  if (!json)
10279
10841
  console.log(`${DIM}Rendering stills in-process (free, local Chrome capture)…${RESET}`);
10280
- const result = await renderCompositionStills({ htmlPath, at, outDir: parsed.values.out, quiet: json });
10842
+ const result = await renderCompositionStills({
10843
+ htmlPath,
10844
+ at,
10845
+ outDir: parsed.values.out,
10846
+ quiet: json,
10847
+ sheet: Boolean(parsed.values.sheet),
10848
+ sheetPath: parsed.values["sheet-out"],
10849
+ ...(Number.isFinite(sheetWidthRaw) ? { sheetTileWidth: sheetWidthRaw } : {})
10850
+ });
10281
10851
  if (json) {
10282
10852
  printJson(result);
10283
10853
  return;
@@ -10286,6 +10856,10 @@ async function runStillsCommand(argv) {
10286
10856
  console.log(` ${GREEN}${still.path}${RESET} ${DIM}(requested ${still.requested_sec}s → captured ${still.captured_sec}s)${RESET}`);
10287
10857
  }
10288
10858
  console.log(`${DIM}${result.stills.length} still(s) at ${result.grid_fps}fps grid accuracy — open them to verify the edit before rendering.${RESET}`);
10859
+ if (result.sheet) {
10860
+ console.log(` ${GREEN}${result.sheet}${RESET} ${DIM}(contact sheet)${RESET}`);
10861
+ console.log(`${DIM}READ the contact sheet as ONE image — that is how you catch what per-scene checks miss: uneven margins, a wandering type scale or accent colour, N identically-long beats, a jarring join, dead space under top-anchored content. Fix drift by defining the system, not by patching the one odd scene.${RESET}`);
10862
+ }
10289
10863
  }
10290
10864
  // ── Agent skill ───────────────────────────────────────────────────────────────
10291
10865
  // Install the latest director skill onto disk as a Claude Code / agent skill so
@@ -10326,7 +10900,134 @@ async function fetchSkillContents(host, target) {
10326
10900
  }
10327
10901
  throw new Error(`Could not fetch ${target.bundled} from ${host} and no bundled copy was found.`);
10328
10902
  }
10903
+ /** The installed devcli's own version — the thing the bundled pack is pinned to. */
10904
+ function devcliVersion() {
10905
+ const pkgPath = locateBundledSkill("package.json");
10906
+ if (!pkgPath)
10907
+ return null;
10908
+ try {
10909
+ const version = JSON.parse(readFileSync(pkgPath, "utf8")).version;
10910
+ return typeof version === "string" ? version : null;
10911
+ }
10912
+ catch {
10913
+ return null;
10914
+ }
10915
+ }
10916
+ /**
10917
+ * `vidfarm skill <ls|show|search|print|path>` — READ the skill pack that ships
10918
+ * inside this devcli, with no network and no account.
10919
+ *
10920
+ * The pack is already in the npm tarball (`.agents/skills/**` is in
10921
+ * package.json `files`), so every install has the full director knowledge on
10922
+ * disk. Before this, nothing could read it without `vidfarm skills add`
10923
+ * (installs into a project) or a fetch — which meant going online to learn how
10924
+ * to use a local-first CLI.
10925
+ *
10926
+ * The bundled copy is version-pinned to this CLI, which is the correct pairing.
10927
+ * It is documentation, NOT entitlement: the paid primitives it documents (AI
10928
+ * generation, hosted render, social recycle, media download, marketplace) still
10929
+ * require `vidfarm login` and a cloud call. The free-local half (clipping,
10930
+ * hyperframes, `vidfarm serve` render, qa, harnesses, dedupe, Kokoro TTS) is
10931
+ * genuinely offline. Every surface below says so rather than letting an offline
10932
+ * read imply an offline entitlement.
10933
+ */
10934
+ async function runSkillDocsCommand(sub, argv) {
10935
+ const parsed = parseArgs({
10936
+ args: argv,
10937
+ allowPositionals: true,
10938
+ options: { json: { type: "boolean", default: false }, pack: { type: "string" }, limit: { type: "string" } }
10939
+ });
10940
+ const json = Boolean(parsed.values.json);
10941
+ const pack = String(parsed.values.pack ?? DEFAULT_PACK);
10942
+ const ref = parsed.positionals[0];
10943
+ if (sub === "path" || sub === "where") {
10944
+ const dir = bundledPackDir(pack);
10945
+ if (!dir)
10946
+ throw new Error(`No bundled "${pack}" pack found next to this devcli install.`);
10947
+ if (json)
10948
+ return printJson({ ok: true, pack, path: dir, offline: true }), true;
10949
+ console.log(dir);
10950
+ return true;
10951
+ }
10952
+ if (sub === "ls" || sub === "list" || sub === "files" || sub === "index") {
10953
+ const docs = listPackDocs(pack);
10954
+ if (!docs.length)
10955
+ throw new Error(`No bundled "${pack}" pack found next to this devcli install. Fetch it with \`vidfarm skills add ${pack}\`.`);
10956
+ if (json)
10957
+ return printJson({ ok: true, pack, source: "bundled", offline: true, files: docs.map(({ rel, lines, bytes }) => ({ path: rel, lines, bytes })) }), true;
10958
+ console.log(`${DIM}Bundled ${pack} skill pack — pinned to devcli ${devcliVersion() ?? "(this install)"}, read offline, no account:${RESET}`);
10959
+ let group = "";
10960
+ for (const doc of docs) {
10961
+ const dir = doc.rel.includes("/") ? doc.rel.slice(0, doc.rel.indexOf("/")) : "";
10962
+ if (dir !== group) {
10963
+ group = dir;
10964
+ if (dir)
10965
+ console.log(` ${BOLD}${dir}/${RESET}`);
10966
+ }
10967
+ console.log(` ${GREEN}${doc.rel.padEnd(46)}${RESET} ${DIM}${String(doc.lines).padStart(4)} ln${RESET}`);
10968
+ }
10969
+ console.log(`\n${DIM}vidfarm skill show <path|shorthand> · vidfarm skill search "<term>"${RESET}`);
10970
+ console.log(`${DIM}Start with SKILL.md — its File Index says which file answers what.${RESET}`);
10971
+ 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}`);
10972
+ return true;
10973
+ }
10974
+ if (sub === "show" || sub === "read" || sub === "cat") {
10975
+ if (!ref)
10976
+ throw new Error('skill show requires a file: `vidfarm skill show references/primitives.md` (shorthand like "primitives" works too).');
10977
+ let resolved;
10978
+ try {
10979
+ resolved = readPackDoc(ref, pack);
10980
+ }
10981
+ catch (error) {
10982
+ // An ambiguous shorthand is a normal outcome, not a crash — "hooks"
10983
+ // legitimately names both the craft reference and the harness. Print the
10984
+ // choice instead of a stack trace, and don't guess on the caller's behalf.
10985
+ if (!(error instanceof AmbiguousDocRef))
10986
+ throw error;
10987
+ if (json)
10988
+ return printJson({ ok: false, pack, ref, ambiguous: error.candidates }), true;
10989
+ console.log(`${YELLOW}"${ref}" matches ${error.candidates.length} files${RESET} ${DIM}— name one:${RESET}`);
10990
+ for (const candidate of error.candidates)
10991
+ console.log(` vidfarm skill show ${GREEN}${candidate}${RESET}`);
10992
+ process.exitCode = 1;
10993
+ return true;
10994
+ }
10995
+ const { doc, contents } = resolved;
10996
+ if (json)
10997
+ return printJson({ ok: true, pack, path: doc.rel, lines: doc.lines, source: "bundled", contents }), true;
10998
+ process.stdout.write(contents.endsWith("\n") ? contents : `${contents}\n`);
10999
+ return true;
11000
+ }
11001
+ if (sub === "search" || sub === "grep" || sub === "find") {
11002
+ if (!ref)
11003
+ throw new Error('skill search requires a term: `vidfarm skill search greenscreen`.');
11004
+ const limit = Number(parsed.values.limit ?? 40);
11005
+ const matches = searchPackDocs(ref, { name: pack, limit: Number.isFinite(limit) ? limit : 40 });
11006
+ if (json)
11007
+ return printJson({ ok: true, pack, term: ref, matches }), true;
11008
+ if (!matches.length) {
11009
+ console.log(`${DIM}No match for "${ref}" in the bundled ${pack} pack. \`vidfarm skill ls\` lists every file.${RESET}`);
11010
+ return true;
11011
+ }
11012
+ let current = "";
11013
+ for (const match of matches) {
11014
+ if (match.rel !== current) {
11015
+ current = match.rel;
11016
+ console.log(`${BOLD}${match.rel}${RESET}`);
11017
+ }
11018
+ console.log(` ${DIM}${String(match.line).padStart(4)}${RESET} ${match.text}`);
11019
+ }
11020
+ console.log(`\n${DIM}${matches.length} match(es). Open one: vidfarm skill show <path>${RESET}`);
11021
+ return true;
11022
+ }
11023
+ return false;
11024
+ }
10329
11025
  async function runUpdateSkillCommand(argv) {
11026
+ // Read-only subcommands come first and never touch the network or auth, so
11027
+ // `vidfarm skill ls` works on a plane with no credential configured.
11028
+ const sub = argv[0] ?? "";
11029
+ if (sub && !sub.startsWith("-") && await runSkillDocsCommand(sub, argv.slice(1)))
11030
+ return;
10330
11031
  const parsed = parseArgs({
10331
11032
  args: argv,
10332
11033
  allowPositionals: false,
@@ -10335,14 +11036,21 @@ async function runUpdateSkillCommand(argv) {
10335
11036
  global: { type: "boolean", default: false },
10336
11037
  dir: { type: "string" },
10337
11038
  platform: { type: "boolean", default: false },
10338
- print: { type: "boolean", default: false }
11039
+ print: { type: "boolean", default: false },
11040
+ remote: { type: "boolean", default: false }
10339
11041
  }
10340
11042
  });
10341
11043
  const ctx = commonContext(parsed.values);
10342
11044
  const skillNames = ["vidfarm", ...(parsed.values.platform ? ["vidfarm-platform"] : [])];
10343
11045
  if (parsed.values.print) {
11046
+ // BUNDLED FIRST. The tarball's copy is pinned to this CLI version, which is
11047
+ // the pairing that actually works — a newer skill against an older binary
11048
+ // is the usual cause of "the skill says to do X but the command 404s".
11049
+ // `--remote` opts into the host's latest when that's what you want.
10344
11050
  for (const name of skillNames) {
10345
- const { contents } = await fetchSkillContents(ctx.host, SKILL_TARGETS[name]);
11051
+ const target = SKILL_TARGETS[name];
11052
+ const bundled = parsed.values.remote ? null : locateBundledSkill(target.bundled);
11053
+ const contents = bundled ? readFileSync(bundled, "utf8") : (await fetchSkillContents(ctx.host, target)).contents;
10346
11054
  process.stdout.write(contents.endsWith("\n") ? contents : `${contents}\n`);
10347
11055
  }
10348
11056
  return;