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