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