@officexapp/vidfarm-devcli 0.21.27 → 0.21.29
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 +52 -0
- package/.agents/skills/vidfarm/SKILL.md +58 -5
- 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 +4 -3
- package/.agents/skills/vidfarm/recipes/retheme-template.md +1 -1
- package/.agents/skills/vidfarm/references/assets-and-sourcing.md +3 -3
- package/.agents/skills/vidfarm/references/automation-and-local-dev.md +92 -1
- package/.agents/skills/vidfarm/references/editor-workflows.md +135 -6
- package/.agents/skills/vidfarm/references/hooks-and-virality.md +237 -0
- package/.agents/skills/vidfarm/references/onboarding.md +5 -5
- package/.agents/skills/vidfarm/references/primitives.md +5 -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 +685 -32
- package/SKILL.md +22 -3
- package/demo/dist/app.js +103 -103
- package/dist/src/cli.js +987 -11
- package/dist/src/devcli/handoff.js +162 -0
- package/dist/src/devcli/interaction-mode.js +154 -0
- package/dist/src/devcli/qa-check.js +593 -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/package.json +8 -1
- package/public/serve-shells/tools-image.html +378 -265
- package/public/serve-shells/tools-video.html +760 -167
package/dist/src/cli.js
CHANGED
|
@@ -19,7 +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 { extractCompositionFacts, formatQaReport, qaCompositionHtml } from "./devcli/qa-check.js";
|
|
23
|
+
import { discoverRegime, formatRegimeReport, listBuiltinRegimes, loadAndEvaluateRegime, mergeRegimeIntoReport, parseRegime, resolveRegimePath } from "./devcli/qa-regime.js";
|
|
22
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";
|
|
23
26
|
import { runDoctorCommand } from "./devcli/doctor.js";
|
|
24
27
|
import { findFreePort } from "./devcli/port-utils.js";
|
|
25
28
|
import { scanLocalServers } from "./devcli/process-scan.js";
|
|
@@ -29,6 +32,9 @@ import { resolveLocalDataDir, localBackendAvailable, LocalModeUnavailableError,
|
|
|
29
32
|
import { startLocalFrontendServer, serveShellsPresent } from "./devcli/local-frontend-server.js";
|
|
30
33
|
import { readStoredAuth, writeStoredAuth, clearStoredAuth, hostsMatch } from "./devcli/auth-store.js";
|
|
31
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";
|
|
32
38
|
// vidfarm-devcli — command-line bridge for the Vidfarm video studio. The
|
|
33
39
|
// `serve` command boots the FULL editor locally (single origin, disk-backed
|
|
34
40
|
// records + storage) so power users edit compositions on disk while a browser
|
|
@@ -89,6 +95,38 @@ Account (persisted login — points the CLI + \`serve\` at cloud prod):
|
|
|
89
95
|
--clear forgets it · --note "<why>" annotates the save.
|
|
90
96
|
Override per-run with --cost-mode <m> / VIDFARM_COST_MODE;
|
|
91
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)
|
|
92
130
|
|
|
93
131
|
Local editor loop:
|
|
94
132
|
serve [template_id] [opts] Boot the editor locally. On a full source checkout the
|
|
@@ -282,6 +320,10 @@ Generate AI media and drop it on the timeline (for local coding agents):
|
|
|
282
320
|
--tolerance <0..1> Key radius (default 0.3); --softness <0..1> edge feather (0.1)
|
|
283
321
|
--no-despill Skip edge color-fringe suppression
|
|
284
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)
|
|
285
327
|
--local Key on your machine with bundled ffmpeg for FREE
|
|
286
328
|
(no wallet, no account; cloud-parity chroma key) — DEFAULT
|
|
287
329
|
--cloud Force the billed cloud primitive
|
|
@@ -293,13 +335,48 @@ Generate AI media and drop it on the timeline (for local coding agents):
|
|
|
293
335
|
(alias: sticker)
|
|
294
336
|
--generate "<prompt>" AI-generate the graphic first, on a matching chroma plate
|
|
295
337
|
(billed image primitive) — then key + trim in one shot
|
|
296
|
-
--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.
|
|
297
342
|
--pad <px> Keep N px of transparent breathing room around the cutout
|
|
298
343
|
--alpha-threshold <n> Alpha 0..255 below which edges count as empty (default 8)
|
|
299
344
|
--no-trim Key only; don't shrink to the bounding box
|
|
300
345
|
--output-format png|webp Cutout format (default png)
|
|
301
346
|
--keep-plate <file> Also save the pre-key generated plate (with --generate)
|
|
302
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
|
|
303
380
|
mask <image|url> Lift an illustration OUT of an image you already have
|
|
304
381
|
(an infographic, poster, marketing image, screenshot)
|
|
305
382
|
→ snug transparent PNG you can reuse as a FREE explainer
|
|
@@ -595,6 +672,29 @@ Local media engines & toolchain (all local, free, no account — no cloud key ne
|
|
|
595
672
|
lint <dir|composition.html> Validate a composition on disk (same (local)
|
|
596
673
|
checks the /editor chat preflights); prints
|
|
597
674
|
errors/warnings, exit 1 on errors [--json]
|
|
675
|
+
qa <dir|composition.html> Social-native QA pass — HIGHLY RECOMMENDED (local, devcli-only)
|
|
676
|
+
on every video you produce. Blocklists "HTML
|
|
677
|
+
slop" (CTA buttons, benefit chip rows, frosted
|
|
678
|
+
cards, gradient text, web-page classes/fonts)
|
|
679
|
+
and checks the caption font regime + safe zone.
|
|
680
|
+
Feedback only: exits 0 even when it finds slop,
|
|
681
|
+
and it never runs automatically. Blocklist, not
|
|
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
|
|
687
|
+
--json Machine-readable findings (rule/severity/fix)
|
|
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)
|
|
598
698
|
stills <dir|composition.html> Render PNG stills of the CURRENT (local in-process render)
|
|
599
699
|
composition — the visual self-check loop
|
|
600
700
|
("did my edit look right") without a full render
|
|
@@ -915,7 +1015,6 @@ async function main() {
|
|
|
915
1015
|
await runGenerateCommand(rest);
|
|
916
1016
|
return;
|
|
917
1017
|
case "sequence":
|
|
918
|
-
case "storyboard":
|
|
919
1018
|
case "previz":
|
|
920
1019
|
await runSequenceCommand(rest);
|
|
921
1020
|
return;
|
|
@@ -941,6 +1040,12 @@ async function main() {
|
|
|
941
1040
|
case "sticker":
|
|
942
1041
|
await runCutoutCommand(rest);
|
|
943
1042
|
return;
|
|
1043
|
+
case "sticker-pack":
|
|
1044
|
+
case "stickerpack":
|
|
1045
|
+
case "stickers":
|
|
1046
|
+
case "sticker-sheet":
|
|
1047
|
+
await runStickerPackCommand(rest);
|
|
1048
|
+
return;
|
|
944
1049
|
case "mask":
|
|
945
1050
|
case "isolate":
|
|
946
1051
|
case "extract":
|
|
@@ -1035,6 +1140,19 @@ async function main() {
|
|
|
1035
1140
|
case "cost":
|
|
1036
1141
|
await runCostModeCommand(rest);
|
|
1037
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;
|
|
1038
1156
|
case "update-skill":
|
|
1039
1157
|
case "skill":
|
|
1040
1158
|
await runUpdateSkillCommand(rest);
|
|
@@ -1177,6 +1295,15 @@ async function main() {
|
|
|
1177
1295
|
case "lint":
|
|
1178
1296
|
await runLintCommand(rest);
|
|
1179
1297
|
return;
|
|
1298
|
+
case "qa":
|
|
1299
|
+
case "qa-check":
|
|
1300
|
+
case "slop-check":
|
|
1301
|
+
await runQaCommand(rest);
|
|
1302
|
+
return;
|
|
1303
|
+
case "regime":
|
|
1304
|
+
case "regimes":
|
|
1305
|
+
await runRegimeCommand(rest);
|
|
1306
|
+
return;
|
|
1180
1307
|
case "stills":
|
|
1181
1308
|
await runStillsCommand(rest);
|
|
1182
1309
|
return;
|
|
@@ -1313,6 +1440,13 @@ function commonOptions() {
|
|
|
1313
1440
|
// env > `vidfarm cost-mode` stored value > default (hybrid). Billed commands
|
|
1314
1441
|
// gate on it; `--yes`/-y confirms a billed spend when the mode is "minimize".
|
|
1315
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 },
|
|
1316
1450
|
yes: { type: "boolean", default: false, short: "y" },
|
|
1317
1451
|
home: { type: "string" }
|
|
1318
1452
|
};
|
|
@@ -1354,12 +1488,22 @@ function commonContext(values) {
|
|
|
1354
1488
|
json: Boolean(values.json),
|
|
1355
1489
|
target: resolveTarget(values),
|
|
1356
1490
|
costMode: resolveCostMode(values),
|
|
1491
|
+
interaction: resolveInteractionMode(values),
|
|
1357
1492
|
yes: Boolean(values.yes),
|
|
1358
1493
|
home: values.home
|
|
1359
1494
|
};
|
|
1360
1495
|
}
|
|
1361
|
-
/** 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. */
|
|
1362
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
|
+
: "";
|
|
1363
1507
|
assertBilledAllowed({
|
|
1364
1508
|
resolved: ctx.costMode,
|
|
1365
1509
|
yes: ctx.yes,
|
|
@@ -1367,7 +1511,14 @@ function guardBilled(ctx, input) {
|
|
|
1367
1511
|
label: input.label,
|
|
1368
1512
|
estimate: input.estimate,
|
|
1369
1513
|
freeAlternative: input.freeAlternative
|
|
1514
|
+
? `${input.freeAlternative}${interactiveNote}`
|
|
1515
|
+
: (interactiveNote ? interactiveNote.trimStart() : undefined)
|
|
1370
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
|
+
}
|
|
1371
1522
|
}
|
|
1372
1523
|
/**
|
|
1373
1524
|
* One dispatch seam for dual local/cloud REST calls. `local` runs the request
|
|
@@ -2156,6 +2307,11 @@ Rules:
|
|
|
2156
2307
|
- When swapping visuals, match both the literal scene DNA and the narrative purpose of the beat.
|
|
2157
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.
|
|
2158
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.
|
|
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).
|
|
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.
|
|
2159
2315
|
|
|
2160
2316
|
The three paintbrushes (Vidfarm is thrift-first — do NOT spend AI credits on every scene):
|
|
2161
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.
|
|
@@ -3575,6 +3731,9 @@ async function runRenderCommand(argv) {
|
|
|
3575
3731
|
}
|
|
3576
3732
|
console.log(`${GREEN}Rendered locally in ${(result.durationMs / 1000).toFixed(1)}s → ${result.outputPath}${RESET}`);
|
|
3577
3733
|
console.log(`${DIM}$0.00 — local Vidfarm render, no cloud charge.${RESET}`);
|
|
3734
|
+
// Nudge, not a gate: QA never runs automatically, but every production
|
|
3735
|
+
// should get a look before it's published.
|
|
3736
|
+
console.log(`${DIM}Recommended: ${RESET}vidfarm qa ${sourceDir ?? "<dir>"}${DIM} — social-native QA (HTML slop + font regime). Feedback only, free, instant.${RESET}`);
|
|
3578
3737
|
}
|
|
3579
3738
|
return;
|
|
3580
3739
|
}
|
|
@@ -4154,14 +4313,20 @@ async function runGenerateCommand(argv) {
|
|
|
4154
4313
|
estimate: "expensive, ~$1+",
|
|
4155
4314
|
freeAlternative: 'free stock video — vidfarm media search "<meaning>" --type video (Pixabay, $0; needs a free ' +
|
|
4156
4315
|
"pixabay key — it may already be saved: check vidfarm provider-keys), reuse a raw clip (vidfarm " +
|
|
4157
|
-
"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)"
|
|
4158
4320
|
}
|
|
4159
4321
|
: {
|
|
4160
4322
|
label: "AI image generation",
|
|
4161
4323
|
estimate: "cheap, ~$0.01–$0.05",
|
|
4162
4324
|
freeAlternative: 'free stock image/vector — vidfarm media search "<meaning>" --type image|vector ($0). ' +
|
|
4163
4325
|
"Openverse (CC images) is keyless and always on — check it first; a free Pixabay key adds " +
|
|
4164
|
-
"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"
|
|
4165
4330
|
});
|
|
4166
4331
|
const refs = await resolveReferenceUrls(ctx, parsed.values.ref);
|
|
4167
4332
|
const payload = { prompt };
|
|
@@ -4903,6 +5068,13 @@ async function runRemoveGreenscreenCommand(argv) {
|
|
|
4903
5068
|
softness: { type: "string" },
|
|
4904
5069
|
"no-despill": { type: "boolean", default: false },
|
|
4905
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" },
|
|
4906
5078
|
local: { type: "boolean", default: false },
|
|
4907
5079
|
cloud: { type: "boolean", default: false },
|
|
4908
5080
|
out: { type: "string" },
|
|
@@ -4912,6 +5084,13 @@ async function runRemoveGreenscreenCommand(argv) {
|
|
|
4912
5084
|
});
|
|
4913
5085
|
const ctx = commonContext(parsed.values);
|
|
4914
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";
|
|
4915
5094
|
const sourceArg = parsed.values.source ?? parsed.positionals[0];
|
|
4916
5095
|
if (!sourceArg) {
|
|
4917
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]`.");
|
|
@@ -5040,13 +5219,52 @@ async function runLocalGreenscreen(ctx, values, sourceArg, presetRaw) {
|
|
|
5040
5219
|
const softness = values.softness !== undefined ? Number(values.softness) : preset?.softness;
|
|
5041
5220
|
const despill = !values["no-despill"];
|
|
5042
5221
|
const outputFormat = values["output-format"]?.trim().toLowerCase();
|
|
5222
|
+
const wantGif = Boolean(values.gif);
|
|
5043
5223
|
const outPath = values.out
|
|
5044
5224
|
? path.resolve(process.cwd(), String(values.out))
|
|
5045
|
-
:
|
|
5225
|
+
: wantGif
|
|
5226
|
+
? defaultGreenscreenOutPath(sourcePath, mediaType, outputFormat).replace(/\.[^.]+$/, ".gif")
|
|
5227
|
+
: defaultGreenscreenOutPath(sourcePath, mediaType, outputFormat);
|
|
5046
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;
|
|
5047
5236
|
if (!ctx.json)
|
|
5048
5237
|
console.log(`${DIM}Keying out ${presetRaw ?? keyColor ?? "green"} background locally with ffmpeg (free)…${RESET}`);
|
|
5049
|
-
|
|
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
|
+
}
|
|
5050
5268
|
if (ctx.json) {
|
|
5051
5269
|
printJson({ ok: true, target: "local", media_type: mediaType, format: result.format, out: result.outputPath, bytes: safeSize(result.outputPath), webm_alpha: result.webmAlpha });
|
|
5052
5270
|
}
|
|
@@ -5105,6 +5323,7 @@ async function runCutoutCommand(argv) {
|
|
|
5105
5323
|
// Keyer knobs (mirror remove-greenscreen).
|
|
5106
5324
|
preset: { type: "string" },
|
|
5107
5325
|
"key-color": { type: "string" },
|
|
5326
|
+
"no-auto-key": { type: "boolean", default: false },
|
|
5108
5327
|
tolerance: { type: "string" },
|
|
5109
5328
|
softness: { type: "string" },
|
|
5110
5329
|
"no-despill": { type: "boolean", default: false },
|
|
@@ -5128,9 +5347,10 @@ async function runCutoutCommand(argv) {
|
|
|
5128
5347
|
throw new Error(`Unknown --preset "${presetRaw}". Choose one of: ${Object.keys(GREENSCREEN_PRESETS).join(", ")}, or pass --key-color <hex>.`);
|
|
5129
5348
|
}
|
|
5130
5349
|
const preset = presetRaw ? GREENSCREEN_PRESETS[presetRaw] : undefined;
|
|
5131
|
-
const
|
|
5132
|
-
|
|
5133
|
-
|
|
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;
|
|
5134
5354
|
const despill = !parsed.values["no-despill"];
|
|
5135
5355
|
const outputFormat = parsed.values["output-format"]?.trim().toLowerCase();
|
|
5136
5356
|
const outExt = outputFormat === "webp" ? "webp" : "png";
|
|
@@ -5139,6 +5359,27 @@ async function runCutoutCommand(argv) {
|
|
|
5139
5359
|
if (!generatePrompt && !sourceArg) {
|
|
5140
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.");
|
|
5141
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
|
+
}
|
|
5142
5383
|
const work = mkdtempSync(path.join(tmpdir(), "vidfarm-cutout-"));
|
|
5143
5384
|
let sourcePath;
|
|
5144
5385
|
let stemHint;
|
|
@@ -5148,7 +5389,9 @@ async function runCutoutCommand(argv) {
|
|
|
5148
5389
|
guardBilled(ctx, {
|
|
5149
5390
|
label: "AI image generation (cutout plate)",
|
|
5150
5391
|
estimate: "cheap, ~$0.01–$0.05",
|
|
5151
|
-
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"
|
|
5152
5395
|
});
|
|
5153
5396
|
const refs = await resolveReferenceUrls(ctx, parsed.values.ref);
|
|
5154
5397
|
const fullPrompt = `${generatePrompt}. ${cutoutPlateInstruction(keyColor)}`;
|
|
@@ -5206,6 +5449,14 @@ async function runCutoutCommand(argv) {
|
|
|
5206
5449
|
}
|
|
5207
5450
|
}
|
|
5208
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
|
+
}
|
|
5209
5460
|
const keyedPath = path.join(work, "keyed.png");
|
|
5210
5461
|
if (!ctx.json)
|
|
5211
5462
|
console.log(`${DIM}Keying out the ${presetRaw ?? keyColor} background locally with ffmpeg (free)…${RESET}`);
|
|
@@ -5436,6 +5687,326 @@ async function runMaskCommand(argv) {
|
|
|
5436
5687
|
rmSync(work, { recursive: true, force: true });
|
|
5437
5688
|
}
|
|
5438
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
|
+
}
|
|
5439
6010
|
// Poll a greenscreen job through the same backend it was submitted to (local
|
|
5440
6011
|
// in-process app or cloud). Mirrors pollPrimitiveJob's terminal conditions.
|
|
5441
6012
|
async function pollGreenscreenJob(ctx, target, jobId) {
|
|
@@ -7228,6 +7799,7 @@ async function runWhoamiCommand(argv) {
|
|
|
7228
7799
|
console.log(` plan ${customer.isPaidPlan ? `${GREEN}paid${plan}${RESET}` : `${DIM}free${plan}${RESET}`}`);
|
|
7229
7800
|
console.log(` credential ${DIM}${stored ? "persisted login (vidfarm login)" : "env/flag key — not persisted (run vidfarm login to persist)"}${RESET}`);
|
|
7230
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}` : ""}`);
|
|
7231
7803
|
return;
|
|
7232
7804
|
}
|
|
7233
7805
|
emitResult(result, ctx.json);
|
|
@@ -7284,6 +7856,267 @@ async function runCostModeCommand(argv) {
|
|
|
7284
7856
|
if (!resolved.isSet) {
|
|
7285
7857
|
console.log(`${DIM}Nothing saved yet — ask the user which one they want before spending AI credits.${RESET}`);
|
|
7286
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).');
|
|
7287
8120
|
}
|
|
7288
8121
|
async function runProviderKeysCommand(argv) {
|
|
7289
8122
|
const parsed = parseArgs({ args: argv, allowPositionals: false, options: commonOptions() });
|
|
@@ -8615,6 +9448,149 @@ async function runLintCommand(argv) {
|
|
|
8615
9448
|
if (!result.ok)
|
|
8616
9449
|
process.exitCode = 1;
|
|
8617
9450
|
}
|
|
9451
|
+
// `vidfarm qa <dir|composition.html>` — the social-native QA pass. Compositions
|
|
9452
|
+
// are authored in HTML, so an agent's web-page instincts leak in as landing-page
|
|
9453
|
+
// furniture (gradient CTA capsules, benefit chip rows, frosted cards) that never
|
|
9454
|
+
// appears in a real TikTok. This is a BLOCKLIST of those specific patterns plus
|
|
9455
|
+
// the font regime — deliberately NOT an allowlist, so weird/ugly/hand-made
|
|
9456
|
+
// compositions pass untouched and nothing pushes every video toward one style.
|
|
9457
|
+
//
|
|
9458
|
+
// FEEDBACK, NOT A GATE: exit code stays 0 even when slop is found, unless the
|
|
9459
|
+
// caller opts into --strict. Nothing in the render path calls this. It is a
|
|
9460
|
+
// devcli-only capability (no cloud/REST twin) — heavily recommended after every
|
|
9461
|
+
// production, never mandatory and never automatic.
|
|
9462
|
+
async function runQaCommand(argv) {
|
|
9463
|
+
const parsed = parseArgs({
|
|
9464
|
+
args: argv,
|
|
9465
|
+
allowPositionals: true,
|
|
9466
|
+
options: {
|
|
9467
|
+
json: { type: "boolean", default: false },
|
|
9468
|
+
strict: { type: "boolean", default: false },
|
|
9469
|
+
regime: { type: "string", multiple: true },
|
|
9470
|
+
"no-regime": { type: "boolean", default: false }
|
|
9471
|
+
}
|
|
9472
|
+
});
|
|
9473
|
+
const target = parsed.positionals[0];
|
|
9474
|
+
if (!target)
|
|
9475
|
+
throw new Error("qa requires a composition path: `vidfarm qa <dir-or-composition.html> [--regime <name|path>] [--json] [--strict]`.");
|
|
9476
|
+
const htmlPath = resolveCompositionHtmlPath(target);
|
|
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
|
+
}
|
|
9494
|
+
if (parsed.values.json) {
|
|
9495
|
+
printJson(report);
|
|
9496
|
+
if (!report.ok && parsed.values.strict)
|
|
9497
|
+
process.exitCode = 1;
|
|
9498
|
+
return;
|
|
9499
|
+
}
|
|
9500
|
+
const body = formatQaReport(report, { red: RED, yellow: YELLOW, green: GREEN, dim: DIM, reset: RESET });
|
|
9501
|
+
if (body)
|
|
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
|
+
}
|
|
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;
|
|
9508
|
+
if (report.verdict === "clean") {
|
|
9509
|
+
console.log(`${GREEN}✓ no HTML slop found${graded ? ", regime checks passed" : ""}${RESET} ${DIM}— ${scope} — ${htmlPath}${RESET}`);
|
|
9510
|
+
}
|
|
9511
|
+
else if (report.verdict === "warnings") {
|
|
9512
|
+
console.log(`${YELLOW}! ${report.warnings.length} warning(s), no slop${RESET} ${DIM}— ${scope} — ${htmlPath}${RESET}`);
|
|
9513
|
+
}
|
|
9514
|
+
else {
|
|
9515
|
+
console.log(`${RED}✗ ${report.errors.length} ${graded ? "issue(s)" : "slop issue(s)"}${RESET}, ${report.warnings.length} warning(s) ${DIM}— ${scope} — ${htmlPath}${RESET}`);
|
|
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}`);
|
|
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
|
+
}
|
|
9529
|
+
if (!report.ok && parsed.values.strict)
|
|
9530
|
+
process.exitCode = 1;
|
|
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
|
+
}
|
|
8618
9594
|
// `vidfarm stills <dir|composition.html>` — render PNG stills of the CURRENT
|
|
8619
9595
|
// composition in-process (the agent's visual self-verification loop).
|
|
8620
9596
|
async function runStillsCommand(argv) {
|