@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.
- package/.agents/skills/editor-capabilities/SKILL.md +13 -2
- package/.agents/skills/vidfarm/SKILL.md +73 -29
- package/.agents/skills/vidfarm/harnesses/README.md +112 -0
- package/.agents/skills/vidfarm/{regimes/explainer.QA_REGIME.md → harnesses/explainer.HARNESS.md} +22 -2
- package/.agents/skills/vidfarm/{regimes/hooks.QA_REGIME.md → harnesses/hooks.HARNESS.md} +3 -3
- package/.agents/skills/vidfarm/{regimes/product-demo.QA_REGIME.md → harnesses/product-demo.HARNESS.md} +19 -1
- package/.agents/skills/vidfarm/{regimes/short-form.QA_REGIME.md → harnesses/short-form.HARNESS.md} +67 -7
- package/.agents/skills/vidfarm/{regimes/ugc-testimonial.QA_REGIME.md → harnesses/ugc-testimonial.HARNESS.md} +10 -3
- package/.agents/skills/vidfarm/recipes/{bulk-scripting-with-a-regime.md → bulk-scripting-with-a-harness.md} +35 -12
- package/.agents/skills/vidfarm/recipes/cutout-graphics-for-explainers.md +46 -2
- package/.agents/skills/vidfarm/recipes/local-edit-render-approve.md +2 -1
- package/.agents/skills/vidfarm/references/automation-and-local-dev.md +84 -22
- package/.agents/skills/vidfarm/references/editor-workflows.md +19 -4
- package/.agents/skills/vidfarm/references/hooks-and-virality.md +62 -5
- package/.agents/skills/vidfarm/references/reviewing-renders.md +140 -0
- package/.agents/skills/vidfarm-media/SKILL.md +2 -2
- package/.agents/skills/vidfarm-media/references/tts.md +26 -4
- package/SKILL.director.md +462 -75
- package/SKILL.md +33 -14
- package/dist/src/cli.js +799 -91
- package/dist/src/devcli/{qa-regime.js → harness.js} +132 -55
- package/dist/src/devcli/qa-check.js +209 -4
- package/dist/src/devcli/skill-docs.js +136 -0
- package/dist/src/devcli/stills.js +65 -1
- package/package.json +4 -3
- 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 {
|
|
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
|
|
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
|
-
--
|
|
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>/
|
|
722
|
-
--no-
|
|
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
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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-
|
|
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 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.
|
|
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}
|
|
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
|
|
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
|
-
|
|
6960
|
-
|
|
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
|
|
6973
|
-
const
|
|
6974
|
-
|
|
6975
|
-
const
|
|
6976
|
-
const
|
|
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(
|
|
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
|
-
|
|
6982
|
-
|
|
6983
|
-
|
|
6984
|
-
|
|
6985
|
-
|
|
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 (
|
|
6988
|
-
console.log(
|
|
6989
|
-
|
|
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> [--
|
|
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
|
-
//
|
|
10144
|
-
// →
|
|
10145
|
-
// directory's own
|
|
10146
|
-
|
|
10147
|
-
const
|
|
10148
|
-
|
|
10149
|
-
|
|
10150
|
-
|
|
10151
|
-
|
|
10152
|
-
|
|
10153
|
-
|
|
10154
|
-
|
|
10155
|
-
|
|
10156
|
-
|
|
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.
|
|
10168
|
-
console.log(
|
|
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.
|
|
10434
|
+
const graded = (report.harnesses ?? []).length > 0;
|
|
10172
10435
|
if (report.verdict === "clean") {
|
|
10173
|
-
console.log(`${GREEN}✓ no HTML slop found${graded ? ",
|
|
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.
|
|
10445
|
+
const pendingReview = (report.harnesses ?? []).reduce((total, evaluation) => total + evaluation.review_items.length, 0);
|
|
10183
10446
|
if (pendingReview) {
|
|
10184
|
-
console.log(`${DIM}${pendingReview}
|
|
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
|
|
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
|
-
|
|
10197
|
-
|
|
10198
|
-
|
|
10199
|
-
|
|
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
|
-
|
|
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 =
|
|
10517
|
+
const builtins = listBuiltinHarnesses();
|
|
10214
10518
|
if (json)
|
|
10215
|
-
return printJson({
|
|
10216
|
-
console.log(`${DIM}Built-in
|
|
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
|
|
10221
|
-
console.log(`${DIM}vidfarm
|
|
10222
|
-
console.log(`${DIM}
|
|
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("
|
|
10229
|
-
const file =
|
|
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(
|
|
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
|
-
|
|
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 ?? "."),
|
|
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,
|
|
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
|
-
|
|
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({
|
|
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
|
|
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;
|