@officexapp/vidfarm-devcli 0.21.26 → 0.21.28
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 +31 -9
- package/.agents/skills/vidfarm/recipes/local-edit-render-approve.md +3 -2
- package/.agents/skills/vidfarm/references/assets-and-sourcing.md +29 -3
- package/.agents/skills/vidfarm/references/automation-and-local-dev.md +59 -6
- package/.agents/skills/vidfarm/references/core-workflows.md +1 -1
- package/.agents/skills/vidfarm/references/editor-workflows.md +85 -7
- package/.agents/skills/vidfarm/references/onboarding.md +4 -4
- package/.agents/skills/vidfarm/references/primitives.md +33 -1
- package/.agents/skills/vidfarm-media/SKILL.md +3 -1
- package/.agents/skills/vidfarm-media/references/tts.md +1 -1
- package/SKILL.director.md +245 -33
- package/SKILL.md +11 -4
- package/clipper.md +3 -2
- package/dist/src/cli.js +478 -19
- package/dist/src/devcli/cost-mode.js +46 -18
- package/dist/src/devcli/qa-check.js +420 -0
- package/dist/src/devcli/sequence.js +619 -0
- package/dist/src/services/sequence-prompts.js +463 -0
- package/package.json +5 -1
- package/public/assets/file-directory-app.js +26 -26
- package/public/assets/homepage-client-app.js +12 -12
- package/public/serve-shells/editor.html +66 -3
- package/public/serve-shells/library-files.html +60 -3
- package/public/serve-shells/library-raws.html +60 -3
- package/public/serve-shells/tools-clipper.html +60 -3
- package/public/serve-shells/tools-image.html +1316 -617
- package/public/serve-shells/tools-video.html +880 -161
package/dist/src/cli.js
CHANGED
|
@@ -19,6 +19,7 @@ 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
23
|
import { removeGreenscreenLocal, localGreenscreenAvailable, defaultGreenscreenOutPath, GREENSCREEN_PRESETS, trimTransparentBorders, cropImageRegion } from "./devcli/greenscreen-local.js";
|
|
23
24
|
import { runDoctorCommand } from "./devcli/doctor.js";
|
|
24
25
|
import { findFreePort } from "./devcli/port-utils.js";
|
|
@@ -80,9 +81,12 @@ Account (persisted login — points the CLI + \`serve\` at cloud prod):
|
|
|
80
81
|
whoami Show the logged-in account, host, plan, and cost mode
|
|
81
82
|
cost-mode [mode] Show or set the money-saving preference all billed
|
|
82
83
|
commands respect, quoted per finished video:
|
|
83
|
-
minimize = $0 · hybrid = ~$0.01-$1 · rich-ai = $1
|
|
84
|
+
minimize = $0 · hybrid = ~$0.01-$1 · rich-ai = $1+
|
|
85
|
+
(AI video gen → REUSABLE greenscreen raws, remixed
|
|
86
|
+
with hyperframes) · pure-videogen = $5+ (script →
|
|
87
|
+
storyboard → frame-by-frame generated scenes).
|
|
84
88
|
Spend is billed to YOUR AI provider keys (BYOK).
|
|
85
|
-
No arg = show current + explain
|
|
89
|
+
No arg = show current + explain them simply.
|
|
86
90
|
--clear forgets it · --note "<why>" annotates the save.
|
|
87
91
|
Override per-run with --cost-mode <m> / VIDFARM_COST_MODE;
|
|
88
92
|
in "minimize" a billed op is refused until you add --yes.
|
|
@@ -122,9 +126,17 @@ Discover & inspiration (browse the viral-video catalog, add your own source):
|
|
|
122
126
|
--limit <n> Max results
|
|
123
127
|
--cursor <cursor> Continue from next_cursor
|
|
124
128
|
--sort <mode> relevance, wow, automation, or recent
|
|
125
|
-
public-raws [query...] Browse PUBLIC RAWS
|
|
129
|
+
public-raws [query...] Browse PUBLIC RAWS — free, cleared B-roll you → GET /api/v1/public-raws
|
|
130
|
+
can drop straight into a composition.
|
|
131
|
+
--categories LIST THE SHELVES first (curated categories + → GET /api/v1/public-raws/categories
|
|
132
|
+
live counts + what each is FOR). The cheapest
|
|
133
|
+
way to find footage: pick a shelf, then pull
|
|
134
|
+
it with --category. Great for bulk scripting —
|
|
135
|
+
one shelf is a ready-made clip pool.
|
|
126
136
|
--query <text> Keyword/vector query over title + metadata
|
|
127
|
-
--category <
|
|
137
|
+
--category <name> Filter ONE category. Takes a curated shelf
|
|
138
|
+
("scroll-stoppers", "greenscreen", "reaction")
|
|
139
|
+
or an exact taxonomy key (emotion:confused).
|
|
128
140
|
--type <raw_type> Filter one raw family (greenscreen, b_roll, ...)
|
|
129
141
|
--freshness-days <n> Only raws newer than N days
|
|
130
142
|
--bookmark <raw_id> Save that public raw into /raws/public/* → POST /api/v1/public-raws/:rawId/bookmark
|
|
@@ -187,6 +199,35 @@ Generate AI media and drop it on the timeline (for local coding agents):
|
|
|
187
199
|
--no-wait Return the job id instead of polling to the URL
|
|
188
200
|
--place <dir> After generating, place it into that composition
|
|
189
201
|
--at <time> | --replace <layer_key> Placement (fill gap vs replace scene)
|
|
202
|
+
sequence <step> STORYBOARD-DRIVEN VIDEO — brief → character card → storyboard
|
|
203
|
+
sheet → shot prompt → AI video → final cut. Resumable:
|
|
204
|
+
all state lives in sequence.json, rerunning a finished
|
|
205
|
+
step is a no-op. (aliases: storyboard, previz)
|
|
206
|
+
init "<brief>" Scaffold sequence.json + PLAN_TASK.md from one line
|
|
207
|
+
--dir <path> Project folder (default: vidfarm-sequence-<brief>)
|
|
208
|
+
--parts <n> Shots to chain; part 2+ extends the one before (default 1)
|
|
209
|
+
--panels <n> Panels per storyboard sheet (default 8)
|
|
210
|
+
--duration <s> Seconds per part (default 15); --aspect-ratio <r> (16:9)
|
|
211
|
+
--style "<look>" Final-film style lock, e.g. "painterly anime, crisp daylight"
|
|
212
|
+
--board-style <k> rough = monochrome previz (default) | final = rendered in
|
|
213
|
+
the final look, so the board doubles as a style check
|
|
214
|
+
--character <name> --character-description "<silhouette, wardrobe>"
|
|
215
|
+
--no-laban Drop the Laban weight/time/space/flow movement block
|
|
216
|
+
plan Write PLAN_TASK.md for a desktop agent to direct the
|
|
217
|
+
sequence properly; --check validates the plan instead
|
|
218
|
+
characters One identity sheet per character [cheap, ~$0.05 each]
|
|
219
|
+
boards One storyboard sheet per part [cheap, ~$0.05 each]
|
|
220
|
+
prompts Compose the video prompts (free, local, deterministic)
|
|
221
|
+
--print <kind>:<id> Preview a prompt without spending: board:part-01,
|
|
222
|
+
shot:part-01, card:<slug>
|
|
223
|
+
animate Generate video per part [EXPENSIVE, ~$1+ each]
|
|
224
|
+
--resolution <r> --audio --provider <p> --model <m>
|
|
225
|
+
assemble ffmpeg-concat the parts into renders/final.mp4 [free]
|
|
226
|
+
--music <file|url> Lay a music bed under the cut; --out <path>
|
|
227
|
+
run All of the above in order, with a confirm gate before
|
|
228
|
+
each spend. --yes runs it fully autonomously.
|
|
229
|
+
status What's done, what's next (the default step)
|
|
230
|
+
--force Redo a completed step; --part <id> / --only <slug> scope it
|
|
190
231
|
inpaint <image|url> Masked image EDIT — replace ONLY the painted region → POST /api/v1/primitives/images/inpaint
|
|
191
232
|
--mask <png|@file|url> Alpha-mask PNG: TRANSPARENT pixels = the editable region (required)
|
|
192
233
|
--prompt <text> Whole-image edit instruction (what to put in the mask)
|
|
@@ -209,6 +250,28 @@ Generate AI media and drop it on the timeline (for local coding agents):
|
|
|
209
250
|
--out <file> Download the transparent PNG to this path
|
|
210
251
|
--no-wait Return the job id instead of polling to the URL
|
|
211
252
|
--place <dir> --at <time>|--replace <layer_key> Fuse onto a composition
|
|
253
|
+
avatar "<who they are>" CREATE AN AVATAR — a TALKING HEAD video with lip-synced
|
|
254
|
+
spoken audio, generated on a forced greenscreen plate and
|
|
255
|
+
chroma-keyed in the same job → transparent presenter WebM
|
|
256
|
+
you composite over any background. (aliases: talking-head,
|
|
257
|
+
spokesperson) → POST /api/v1/primitives/videos/create-avatar
|
|
258
|
+
--say "<line>" What the avatar SAYS out loud, verbatim (alias --script)
|
|
259
|
+
--voice <desc> Delivery note, e.g. "warm, upbeat, mid-tempo"
|
|
260
|
+
--ref <url|@file|id> Likeness reference — headshot or character sprite card
|
|
261
|
+
(repeatable) → input_references
|
|
262
|
+
--duration <s> Clip length in seconds; --resolution 720p|1080p
|
|
263
|
+
--aspect-ratio <r> e.g. 9:16 (match where it lands)
|
|
264
|
+
--preset <name> Plate color: green (default) | digital-green | blue |
|
|
265
|
+
white | black | magenta; --key-color <hex> overrides
|
|
266
|
+
--tolerance <0..1> Key radius (default 0.3); --softness <0..1> feather (0.1)
|
|
267
|
+
--no-despill Skip edge color-fringe suppression
|
|
268
|
+
--local Generate in the cloud (your keys), key on YOUR machine
|
|
269
|
+
with bundled ffmpeg for free
|
|
270
|
+
--keep-greenscreen Stop at the raw greenscreen MP4; don't key it at all
|
|
271
|
+
--provider <p> --model <m> Video provider/model overrides (omit to auto-pick)
|
|
272
|
+
--out <file> Write the finished avatar here
|
|
273
|
+
--no-wait Return the job id instead of polling to the URL
|
|
274
|
+
--place <dir> --at <time>|--replace <layer_key> Fuse onto a composition
|
|
212
275
|
remove-greenscreen <image|video|url> Key out a FLAT solid background from an
|
|
213
276
|
IMAGE (→ transparent PNG/WebP) or VIDEO (→ transparent
|
|
214
277
|
WebM/VP9 alpha), auto-detecting the media kind.
|
|
@@ -533,6 +596,16 @@ Local media engines & toolchain (all local, free, no account — no cloud key ne
|
|
|
533
596
|
lint <dir|composition.html> Validate a composition on disk (same (local)
|
|
534
597
|
checks the /editor chat preflights); prints
|
|
535
598
|
errors/warnings, exit 1 on errors [--json]
|
|
599
|
+
qa <dir|composition.html> Social-native QA pass — HIGHLY RECOMMENDED (local, devcli-only)
|
|
600
|
+
on every video you produce. Blocklists "HTML
|
|
601
|
+
slop" (CTA buttons, benefit chip rows, frosted
|
|
602
|
+
cards, gradient text, web-page classes/fonts)
|
|
603
|
+
and checks the caption font regime + safe zone.
|
|
604
|
+
Feedback only: exits 0 even when it finds slop,
|
|
605
|
+
and it never runs automatically. Blocklist, not
|
|
606
|
+
allowlist — unusual styles pass untouched
|
|
607
|
+
--json Machine-readable findings (rule/severity/fix)
|
|
608
|
+
--strict Also exit 1 when slop is found (for CI)
|
|
536
609
|
stills <dir|composition.html> Render PNG stills of the CURRENT (local in-process render)
|
|
537
610
|
composition — the visual self-check loop
|
|
538
611
|
("did my edit look right") without a full render
|
|
@@ -689,15 +762,19 @@ Cost spectrum (default to the cheapest approach that works; see SKILL.director.m
|
|
|
689
762
|
Notes: image gen is cheap (use freely); AI VIDEO gen is expensive (ask the user
|
|
690
763
|
first). Decompose is a one-time ~$0.10 — or skip it by forking a decomposed template.
|
|
691
764
|
Per-video budget by mode: minimize = $0 (explicitly free), hybrid = ~$0.01-$1,
|
|
692
|
-
rich-ai = $1
|
|
693
|
-
with
|
|
694
|
-
|
|
695
|
-
|
|
765
|
+
rich-ai = $1+ (AI video gen buys REUSABLE greenscreen raws — keyed once, then remixed
|
|
766
|
+
with hyperframes HTML/CSS motion and saved for later hybrid/minimize videos),
|
|
767
|
+
pure-videogen = $5+ (no reuse at all: text script → image storyboard → frame-by-frame
|
|
768
|
+
generation of every scene). All of it is billed to YOUR OWN AI provider keys (BYOK) —
|
|
769
|
+
add them with 'vidfarm add-provider-key <provider> <key>' or vidfarm.cc/settings/developer.
|
|
770
|
+
Set 'vidfarm cost-mode <minimize|hybrid|rich-ai|pure-videogen>' once and every billed
|
|
771
|
+
command (generate, music, decompose, cloud render/TTS/STT/greenscreen, create, replicate)
|
|
696
772
|
respects it: minimize refuses billed spend without --yes and points you at the free
|
|
697
|
-
local path;
|
|
773
|
+
local path; the others run but print each op's cost. FREE local engines never gate
|
|
698
774
|
(local render, tts --engine local, stt --engine whisper, remove-greenscreen --local).
|
|
699
775
|
In minimize AND hybrid, 'vidfarm tts' DEFAULTS to the free local Kokoro voice — ask for
|
|
700
|
-
a premium voice (--style/--voice/--provider/--own-key/--cloud) or rich-ai
|
|
776
|
+
a premium voice (--style/--voice/--provider/--own-key/--cloud), or be in rich-ai /
|
|
777
|
+
pure-videogen, to opt out.
|
|
701
778
|
|
|
702
779
|
Escape hatch — call ANY route directly:
|
|
703
780
|
api <METHOD> <path> Raw REST call with auth + pretty errors
|
|
@@ -848,6 +925,11 @@ async function main() {
|
|
|
848
925
|
case "generate":
|
|
849
926
|
await runGenerateCommand(rest);
|
|
850
927
|
return;
|
|
928
|
+
case "sequence":
|
|
929
|
+
case "storyboard":
|
|
930
|
+
case "previz":
|
|
931
|
+
await runSequenceCommand(rest);
|
|
932
|
+
return;
|
|
851
933
|
case "inpaint":
|
|
852
934
|
await runInpaintCommand(rest);
|
|
853
935
|
return;
|
|
@@ -855,6 +937,11 @@ async function main() {
|
|
|
855
937
|
case "overlay":
|
|
856
938
|
await runCreateOverlayCommand(rest);
|
|
857
939
|
return;
|
|
940
|
+
case "avatar":
|
|
941
|
+
case "talking-head":
|
|
942
|
+
case "spokesperson":
|
|
943
|
+
await runAvatarCommand(rest);
|
|
944
|
+
return;
|
|
858
945
|
case "remove-greenscreen":
|
|
859
946
|
case "remove-background-greenscreen":
|
|
860
947
|
case "rmbg-green":
|
|
@@ -1101,6 +1188,11 @@ async function main() {
|
|
|
1101
1188
|
case "lint":
|
|
1102
1189
|
await runLintCommand(rest);
|
|
1103
1190
|
return;
|
|
1191
|
+
case "qa":
|
|
1192
|
+
case "qa-check":
|
|
1193
|
+
case "slop-check":
|
|
1194
|
+
await runQaCommand(rest);
|
|
1195
|
+
return;
|
|
1104
1196
|
case "stills":
|
|
1105
1197
|
await runStillsCommand(rest);
|
|
1106
1198
|
return;
|
|
@@ -1233,7 +1325,7 @@ function commonOptions() {
|
|
|
1233
1325
|
local: { type: "boolean", default: false },
|
|
1234
1326
|
cloud: { type: "boolean", default: false },
|
|
1235
1327
|
both: { type: "boolean", default: false },
|
|
1236
|
-
// Money-saving preference: minimize | hybrid | pure-
|
|
1328
|
+
// Money-saving preference: minimize | hybrid | rich-ai | pure-videogen. Flag > VIDFARM_COST_MODE
|
|
1237
1329
|
// env > `vidfarm cost-mode` stored value > default (hybrid). Billed commands
|
|
1238
1330
|
// gate on it; `--yes`/-y confirms a billed spend when the mode is "minimize".
|
|
1239
1331
|
"cost-mode": { type: "string" },
|
|
@@ -2080,6 +2172,8 @@ Rules:
|
|
|
2080
2172
|
- When swapping visuals, match both the literal scene DNA and the narrative purpose of the beat.
|
|
2081
2173
|
- 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.
|
|
2082
2174
|
- 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
|
+
- 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
|
+
- 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 font regime and 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.
|
|
2083
2177
|
|
|
2084
2178
|
The three paintbrushes (Vidfarm is thrift-first — do NOT spend AI credits on every scene):
|
|
2085
2179
|
- 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.
|
|
@@ -2633,10 +2727,25 @@ async function runPublicRawsCommand(argv) {
|
|
|
2633
2727
|
category: { type: "string" },
|
|
2634
2728
|
type: { type: "string" },
|
|
2635
2729
|
"freshness-days": { type: "string" },
|
|
2636
|
-
bookmark: { type: "string" }
|
|
2730
|
+
bookmark: { type: "string" },
|
|
2731
|
+
categories: { type: "boolean", default: false }
|
|
2637
2732
|
}
|
|
2638
2733
|
});
|
|
2639
2734
|
const ctx = commonContext(parsed.values);
|
|
2735
|
+
// `--categories` is the map BEFORE the territory: the curated shelves with
|
|
2736
|
+
// live counts. Start here when you don't know what footage exists — pick a
|
|
2737
|
+
// shelf, then re-run with --category <slug> to pull its clips.
|
|
2738
|
+
if (parsed.values.categories) {
|
|
2739
|
+
const result = await apiRequest({
|
|
2740
|
+
method: "GET",
|
|
2741
|
+
host: ctx.host,
|
|
2742
|
+
path: "/api/v1/public-raws/categories",
|
|
2743
|
+
auth: ctx.auth
|
|
2744
|
+
});
|
|
2745
|
+
assertApiOk(result, "public-raws categories");
|
|
2746
|
+
emitResult(result, ctx.json, [["Browse ", `${ctx.host}/discover/raws/categories`]]);
|
|
2747
|
+
return;
|
|
2748
|
+
}
|
|
2640
2749
|
const bookmark = parsed.values.bookmark?.trim();
|
|
2641
2750
|
if (bookmark) {
|
|
2642
2751
|
const result = await apiRequest({
|
|
@@ -3484,6 +3593,9 @@ async function runRenderCommand(argv) {
|
|
|
3484
3593
|
}
|
|
3485
3594
|
console.log(`${GREEN}Rendered locally in ${(result.durationMs / 1000).toFixed(1)}s → ${result.outputPath}${RESET}`);
|
|
3486
3595
|
console.log(`${DIM}$0.00 — local Vidfarm render, no cloud charge.${RESET}`);
|
|
3596
|
+
// Nudge, not a gate: QA never runs automatically, but every production
|
|
3597
|
+
// should get a look before it's published.
|
|
3598
|
+
console.log(`${DIM}Recommended: ${RESET}vidfarm qa ${sourceDir ?? "<dir>"}${DIM} — social-native QA (HTML slop + font regime). Feedback only, free, instant.${RESET}`);
|
|
3487
3599
|
}
|
|
3488
3600
|
return;
|
|
3489
3601
|
}
|
|
@@ -4152,6 +4264,74 @@ async function runGenerateCommand(argv) {
|
|
|
4152
4264
|
console.log(`${DIM}Place it with: vidfarm place <dir> --src "${mediaUrl}" --kind ${mediaType} --at <time>|--replace <layer_key>${RESET}`);
|
|
4153
4265
|
}
|
|
4154
4266
|
}
|
|
4267
|
+
// ── Sequence: storyboard-driven AI video pipeline ────────────────────────────
|
|
4268
|
+
// `vidfarm sequence` is a multi-step, resumable director's workflow: brief →
|
|
4269
|
+
// character card → storyboard sheet → shot prompt → AI video → final cut. The
|
|
4270
|
+
// orchestration + disk state live in devcli/sequence.ts and the prompt grammar
|
|
4271
|
+
// in services/sequence-prompts.ts; this wrapper only supplies the cloud seam
|
|
4272
|
+
// (auth, primitive jobs, cost gating, downloads) so there is exactly one copy
|
|
4273
|
+
// of the REST plumbing.
|
|
4274
|
+
/** Submit a primitive generate job and poll it to a finished media URL. */
|
|
4275
|
+
async function generatePrimitiveMedia(ctx, kind, payload) {
|
|
4276
|
+
const tracer = `devcli-seq-${Date.now().toString(36)}`;
|
|
4277
|
+
const route = kind === "video" ? "/api/v1/primitives/videos/generate" : "/api/v1/primitives/images/generate";
|
|
4278
|
+
const submit = await apiRequest({ method: "POST", host: ctx.host, path: route, auth: ctx.auth, body: { tracer, payload } });
|
|
4279
|
+
assertApiOk(submit, `generate ${kind}`);
|
|
4280
|
+
const jobId = submit.json?.job_id;
|
|
4281
|
+
if (!jobId)
|
|
4282
|
+
throw new Error(`generate ${kind} did not return a job id.`);
|
|
4283
|
+
const job = await pollPrimitiveJob(ctx, jobId);
|
|
4284
|
+
const url = resolveJobMediaUrl(job);
|
|
4285
|
+
if (!url) {
|
|
4286
|
+
throw new Error(`generate ${kind} ${String(job?.status ?? "did not finish")} — no media URL (job ${jobId}).`);
|
|
4287
|
+
}
|
|
4288
|
+
return { url, jobId };
|
|
4289
|
+
}
|
|
4290
|
+
async function runSequenceCommand(argv) {
|
|
4291
|
+
// Parsed loosely: the subcommand flags live in devcli/sequence.ts, and this
|
|
4292
|
+
// pass only needs the shared host/auth/cost-mode options.
|
|
4293
|
+
const parsed = parseArgs({ args: argv, allowPositionals: true, strict: false, options: commonOptions() });
|
|
4294
|
+
const ctx = commonContext(parsed.values);
|
|
4295
|
+
const { runSequenceCommand: run } = await import("./devcli/sequence.js");
|
|
4296
|
+
await run(argv, {
|
|
4297
|
+
json: ctx.json,
|
|
4298
|
+
yes: ctx.yes,
|
|
4299
|
+
guardBilled: (input) => guardBilled(ctx, input),
|
|
4300
|
+
resolveRefs: (values) => resolveReferenceUrls(ctx, values),
|
|
4301
|
+
async generateImage(input) {
|
|
4302
|
+
const payload = { prompt: input.prompt };
|
|
4303
|
+
if (input.aspectRatio)
|
|
4304
|
+
payload.aspect_ratio = input.aspectRatio;
|
|
4305
|
+
if (input.provider)
|
|
4306
|
+
payload.provider = input.provider;
|
|
4307
|
+
if (input.model)
|
|
4308
|
+
payload.model = input.model;
|
|
4309
|
+
if (input.refs.length)
|
|
4310
|
+
payload.prompt_attachments = input.refs.slice(0, 16);
|
|
4311
|
+
return generatePrimitiveMedia(ctx, "image", payload);
|
|
4312
|
+
},
|
|
4313
|
+
async generateVideo(input) {
|
|
4314
|
+
const payload = {
|
|
4315
|
+
prompt: input.prompt,
|
|
4316
|
+
duration: Math.round(input.durationSeconds)
|
|
4317
|
+
};
|
|
4318
|
+
if (input.aspectRatio)
|
|
4319
|
+
payload.aspect_ratio = input.aspectRatio;
|
|
4320
|
+
if (input.resolution)
|
|
4321
|
+
payload.resolution = input.resolution;
|
|
4322
|
+
if (input.audio)
|
|
4323
|
+
payload.generate_audio = true;
|
|
4324
|
+
if (input.provider)
|
|
4325
|
+
payload.provider = input.provider;
|
|
4326
|
+
if (input.model)
|
|
4327
|
+
payload.model = input.model;
|
|
4328
|
+
if (input.refs.length)
|
|
4329
|
+
payload.input_references = input.refs.slice(0, 8);
|
|
4330
|
+
return generatePrimitiveMedia(ctx, "video", payload);
|
|
4331
|
+
},
|
|
4332
|
+
download: (url, dest) => downloadUrlToFile(url, dest, ctx.auth.apiKey ? { "vidfarm-api-key": ctx.auth.apiKey } : undefined)
|
|
4333
|
+
});
|
|
4334
|
+
}
|
|
4155
4335
|
// ── Inpaint: masked image edit ───────────────────────────────────────────────
|
|
4156
4336
|
// The devcli twin of the web /tools/image page. The browser page rasterizes painted
|
|
4157
4337
|
// strokes into an alpha mask (transparent = editable), composes one provider
|
|
@@ -4473,6 +4653,236 @@ async function runCreateOverlayCommand(argv) {
|
|
|
4473
4653
|
console.log(`${DIM}Place it with: vidfarm place <dir> --src "${mediaUrl}" --kind image --at <time>|--replace <layer_key>${RESET}`);
|
|
4474
4654
|
}
|
|
4475
4655
|
}
|
|
4656
|
+
// ── avatar: talking-head presenter, keyed off its greenscreen ────────────────
|
|
4657
|
+
// "Create an avatar" is ALWAYS this: a generated VIDEO of a talking head with
|
|
4658
|
+
// lip-synced spoken audio, shot on a flat key-color plate, then chroma-keyed so
|
|
4659
|
+
// the presenter composites over whatever is behind them. The devcli twin of the
|
|
4660
|
+
// talking_avatar primitive.
|
|
4661
|
+
//
|
|
4662
|
+
// The generation leg is always the cloud primitive (it rides the caller's own
|
|
4663
|
+
// saved video-provider keys — BYOK, same as the web app). The KEYING leg is
|
|
4664
|
+
// where local/cloud differ:
|
|
4665
|
+
// default → the same job keys it in the cloud → browser-ready transparent
|
|
4666
|
+
// WebM (VP9 alpha, audio preserved) you can drop straight in.
|
|
4667
|
+
// --local → the job returns the raw greenscreen MP4 and bundled ffmpeg keys
|
|
4668
|
+
// it on your machine for free (ProRes .mov fallback if this
|
|
4669
|
+
// ffmpeg build can't write VP9 alpha).
|
|
4670
|
+
// --keep-greenscreen → stop at the plate; don't key at all.
|
|
4671
|
+
async function runAvatarCommand(argv) {
|
|
4672
|
+
const parsed = parseArgs({
|
|
4673
|
+
args: argv,
|
|
4674
|
+
allowPositionals: true,
|
|
4675
|
+
options: {
|
|
4676
|
+
...commonOptions(),
|
|
4677
|
+
prompt: { type: "string" },
|
|
4678
|
+
// What the avatar says out loud. `--script` is the alias.
|
|
4679
|
+
say: { type: "string" },
|
|
4680
|
+
script: { type: "string" },
|
|
4681
|
+
voice: { type: "string" },
|
|
4682
|
+
// Likeness references: headshot, character sprite card, brand photo.
|
|
4683
|
+
ref: { type: "string", multiple: true },
|
|
4684
|
+
duration: { type: "string" },
|
|
4685
|
+
resolution: { type: "string" },
|
|
4686
|
+
"aspect-ratio": { type: "string" },
|
|
4687
|
+
provider: { type: "string" },
|
|
4688
|
+
model: { type: "string" },
|
|
4689
|
+
// Plate + keying knobs (mirror remove-greenscreen).
|
|
4690
|
+
preset: { type: "string" },
|
|
4691
|
+
"key-color": { type: "string" },
|
|
4692
|
+
tolerance: { type: "string" },
|
|
4693
|
+
softness: { type: "string" },
|
|
4694
|
+
"no-despill": { type: "boolean", default: false },
|
|
4695
|
+
local: { type: "boolean", default: false },
|
|
4696
|
+
"keep-greenscreen": { type: "boolean", default: false },
|
|
4697
|
+
out: { type: "string" },
|
|
4698
|
+
"no-wait": { type: "boolean", default: false },
|
|
4699
|
+
tracer: { type: "string" },
|
|
4700
|
+
// Fused placement onto a local composition.html (same as `generate`).
|
|
4701
|
+
place: { type: "string" },
|
|
4702
|
+
at: { type: "string" },
|
|
4703
|
+
replace: { type: "string" },
|
|
4704
|
+
track: { type: "string" },
|
|
4705
|
+
transition: { type: "string" },
|
|
4706
|
+
"transition-duration": { type: "string" },
|
|
4707
|
+
"transition-out": { type: "string" },
|
|
4708
|
+
"transition-out-duration": { type: "string" },
|
|
4709
|
+
"layer-key": { type: "string" }
|
|
4710
|
+
}
|
|
4711
|
+
});
|
|
4712
|
+
const ctx = commonContext(parsed.values);
|
|
4713
|
+
guardBilled(ctx, {
|
|
4714
|
+
label: "AI talking-avatar video (generation with audio + chroma key)",
|
|
4715
|
+
estimate: "expensive, ~$0.50–$1.50 (AI video)",
|
|
4716
|
+
freeAlternative: 'reuse a free talking-head clip — vidfarm public-raws --category talking-head — or key an existing greenscreen clip with vidfarm remove-greenscreen --local ($0)'
|
|
4717
|
+
});
|
|
4718
|
+
const prompt = (parsed.values.prompt ?? parsed.positionals.join(" ")).trim();
|
|
4719
|
+
if (!prompt) {
|
|
4720
|
+
throw new Error('avatar requires a description of WHO the avatar is: `vidfarm avatar "friendly 30-something founder in a hoodie" --say "Here is why we built this."` — describe the person, not the background (the greenscreen plate is forced for you).');
|
|
4721
|
+
}
|
|
4722
|
+
const script = (parsed.values.say ?? parsed.values.script ?? "").trim();
|
|
4723
|
+
const refs = await resolveReferenceUrls(ctx, parsed.values.ref);
|
|
4724
|
+
const presetRaw = parsed.values.preset?.trim().toLowerCase();
|
|
4725
|
+
if (presetRaw && !GREENSCREEN_PRESETS[presetRaw]) {
|
|
4726
|
+
throw new Error(`Unknown --preset "${presetRaw}". Choose one of: ${Object.keys(GREENSCREEN_PRESETS).join(", ")}, or pass --key-color <hex>.`);
|
|
4727
|
+
}
|
|
4728
|
+
// --local keys on this machine, so ask the cloud job for the raw plate.
|
|
4729
|
+
const keyLocally = Boolean(parsed.values.local);
|
|
4730
|
+
const keepPlate = Boolean(parsed.values["keep-greenscreen"]);
|
|
4731
|
+
const payload = { prompt };
|
|
4732
|
+
if (script)
|
|
4733
|
+
payload.script = script;
|
|
4734
|
+
if (parsed.values.voice)
|
|
4735
|
+
payload.voice = parsed.values.voice;
|
|
4736
|
+
if (refs.length)
|
|
4737
|
+
payload.input_references = refs.slice(0, 8);
|
|
4738
|
+
if (parsed.values.duration)
|
|
4739
|
+
payload.duration = Math.round(Number(parsed.values.duration));
|
|
4740
|
+
if (parsed.values.resolution)
|
|
4741
|
+
payload.resolution = parsed.values.resolution;
|
|
4742
|
+
if (parsed.values["aspect-ratio"])
|
|
4743
|
+
payload.aspect_ratio = parsed.values["aspect-ratio"];
|
|
4744
|
+
if (parsed.values.provider)
|
|
4745
|
+
payload.provider = parsed.values.provider;
|
|
4746
|
+
if (parsed.values.model)
|
|
4747
|
+
payload.model = parsed.values.model;
|
|
4748
|
+
if (presetRaw)
|
|
4749
|
+
payload.preset = presetRaw;
|
|
4750
|
+
if (parsed.values["key-color"])
|
|
4751
|
+
payload.key_color = parsed.values["key-color"];
|
|
4752
|
+
if (parsed.values.tolerance)
|
|
4753
|
+
payload.tolerance = Number(parsed.values.tolerance);
|
|
4754
|
+
if (parsed.values.softness)
|
|
4755
|
+
payload.softness = Number(parsed.values.softness);
|
|
4756
|
+
if (parsed.values["no-despill"])
|
|
4757
|
+
payload.despill = false;
|
|
4758
|
+
if (keyLocally || keepPlate)
|
|
4759
|
+
payload.keep_greenscreen = true;
|
|
4760
|
+
const tracer = parsed.values.tracer ?? `devcli-avatar-${Date.now().toString(36)}`;
|
|
4761
|
+
const submit = await apiRequest({ method: "POST", host: ctx.host, path: "/api/v1/primitives/videos/create-avatar", auth: ctx.auth, body: { tracer, payload } });
|
|
4762
|
+
assertApiOk(submit, "avatar");
|
|
4763
|
+
const jobId = submit.json?.job_id;
|
|
4764
|
+
const wait = !parsed.values["no-wait"];
|
|
4765
|
+
if (!wait || !jobId) {
|
|
4766
|
+
if (!ctx.json && jobId)
|
|
4767
|
+
console.log(`${DIM}Queued ${jobId} (tracer ${tracer}). Poll: vidfarm api GET /api/v1/user/me/jobs/${jobId} — or drop --no-wait to auto-poll.${RESET}`);
|
|
4768
|
+
emitResult(submit, ctx.json);
|
|
4769
|
+
return;
|
|
4770
|
+
}
|
|
4771
|
+
if (!ctx.json) {
|
|
4772
|
+
console.log(`${DIM}Generating talking avatar${script ? ` saying "${script.slice(0, 60)}${script.length > 60 ? "…" : ""}"` : ""} on a greenscreen plate (${jobId})… polling every 5s.${RESET}`);
|
|
4773
|
+
}
|
|
4774
|
+
const job = await pollPrimitiveJob(ctx, jobId);
|
|
4775
|
+
const mediaUrl = resolveJobMediaUrl(job);
|
|
4776
|
+
const status = String(job?.status ?? "");
|
|
4777
|
+
if (!mediaUrl) {
|
|
4778
|
+
if (ctx.json) {
|
|
4779
|
+
printJson({ ok: false, job_id: jobId, status, job });
|
|
4780
|
+
}
|
|
4781
|
+
else {
|
|
4782
|
+
console.log(`${RED}Avatar ${status || "did not finish"} — no media URL. Check your video-provider key and wallet.${RESET}`);
|
|
4783
|
+
printJson(job);
|
|
4784
|
+
}
|
|
4785
|
+
process.exitCode = 1;
|
|
4786
|
+
return;
|
|
4787
|
+
}
|
|
4788
|
+
// Local keying leg: pull the plate down and run the free ffmpeg keyer on it.
|
|
4789
|
+
let localOut = null;
|
|
4790
|
+
if (keyLocally && !keepPlate) {
|
|
4791
|
+
const downloadDir = mkdtempSync(path.join(tmpdir(), "vidfarm-avatar-"));
|
|
4792
|
+
const platePath = path.join(downloadDir, "avatar-greenscreen.mp4");
|
|
4793
|
+
try {
|
|
4794
|
+
if (!ctx.json)
|
|
4795
|
+
console.log(`${DIM}Downloading the greenscreen plate…${RESET}`);
|
|
4796
|
+
await downloadUrlToFile(mediaUrl, platePath);
|
|
4797
|
+
const preset = presetRaw ? GREENSCREEN_PRESETS[presetRaw] : undefined;
|
|
4798
|
+
const outPath = parsed.values.out
|
|
4799
|
+
? path.resolve(process.cwd(), String(parsed.values.out))
|
|
4800
|
+
: path.resolve(process.cwd(), "avatar.webm");
|
|
4801
|
+
mkdirSync(path.dirname(outPath), { recursive: true });
|
|
4802
|
+
if (!ctx.json)
|
|
4803
|
+
console.log(`${DIM}Keying the avatar off its greenscreen locally with ffmpeg (free)…${RESET}`);
|
|
4804
|
+
localOut = await removeGreenscreenLocal({
|
|
4805
|
+
sourcePath: platePath,
|
|
4806
|
+
mediaType: "video",
|
|
4807
|
+
outputPath: outPath,
|
|
4808
|
+
keyColor: parsed.values["key-color"] ?? preset?.key_color,
|
|
4809
|
+
tolerance: parsed.values.tolerance !== undefined ? Number(parsed.values.tolerance) : preset?.tolerance,
|
|
4810
|
+
softness: parsed.values.softness !== undefined ? Number(parsed.values.softness) : preset?.softness,
|
|
4811
|
+
despill: !parsed.values["no-despill"]
|
|
4812
|
+
});
|
|
4813
|
+
}
|
|
4814
|
+
finally {
|
|
4815
|
+
rmSync(downloadDir, { recursive: true, force: true });
|
|
4816
|
+
}
|
|
4817
|
+
}
|
|
4818
|
+
// Whatever the caller ends up placing/saving: the local cut-out if we keyed
|
|
4819
|
+
// here, otherwise the cloud result (transparent WebM, or the plate with
|
|
4820
|
+
// --keep-greenscreen).
|
|
4821
|
+
const finalSrc = localOut?.outputPath ?? mediaUrl;
|
|
4822
|
+
let outPath = localOut?.outputPath ?? null;
|
|
4823
|
+
if (!localOut && parsed.values.out) {
|
|
4824
|
+
outPath = path.resolve(process.cwd(), String(parsed.values.out));
|
|
4825
|
+
await downloadUrlToFile(mediaUrl, outPath);
|
|
4826
|
+
}
|
|
4827
|
+
let placement = null;
|
|
4828
|
+
if (parsed.values.place) {
|
|
4829
|
+
const htmlPath = resolveCompositionHtmlPath(String(parsed.values.place));
|
|
4830
|
+
placement = placeMediaOnDisk({
|
|
4831
|
+
htmlPath,
|
|
4832
|
+
src: finalSrc,
|
|
4833
|
+
kind: "video",
|
|
4834
|
+
at: parsed.values.at ? parseTimeToSeconds(String(parsed.values.at)) : undefined,
|
|
4835
|
+
replace: parsed.values.replace,
|
|
4836
|
+
track: parsed.values.track ? Number(parsed.values.track) : undefined,
|
|
4837
|
+
transition: parsed.values.transition,
|
|
4838
|
+
transitionDuration: parsed.values["transition-duration"] ? Number(parsed.values["transition-duration"]) : undefined,
|
|
4839
|
+
transitionOut: parsed.values["transition-out"],
|
|
4840
|
+
transitionOutDuration: parsed.values["transition-out-duration"] ? Number(parsed.values["transition-out-duration"]) : undefined,
|
|
4841
|
+
layerKey: parsed.values["layer-key"]
|
|
4842
|
+
});
|
|
4843
|
+
if (!ctx.json)
|
|
4844
|
+
console.log(`${GREEN}Placed as ${placement.layerKey} in ${htmlPath}. Run \`vidfarm publish --dir ${path.dirname(htmlPath)}\` (or it live-morphs under \`serve\`).${RESET}`);
|
|
4845
|
+
}
|
|
4846
|
+
const transparent = !keepPlate;
|
|
4847
|
+
if (ctx.json) {
|
|
4848
|
+
printJson({
|
|
4849
|
+
ok: true,
|
|
4850
|
+
job_id: jobId,
|
|
4851
|
+
media_url: mediaUrl,
|
|
4852
|
+
transparent,
|
|
4853
|
+
keyed: keepPlate ? "none" : keyLocally ? "local" : "cloud",
|
|
4854
|
+
format: localOut?.format ?? (keepPlate ? "mp4" : "webm"),
|
|
4855
|
+
out: outPath,
|
|
4856
|
+
placement,
|
|
4857
|
+
script: script || null,
|
|
4858
|
+
greenscreen_source_url: job?.result?.greenscreen_source_url ?? null
|
|
4859
|
+
});
|
|
4860
|
+
return;
|
|
4861
|
+
}
|
|
4862
|
+
if (keepPlate) {
|
|
4863
|
+
console.log(`${GREEN}Avatar greenscreen plate ready:${RESET} ${mediaUrl}`);
|
|
4864
|
+
console.log(`${DIM}Key it whenever you like: vidfarm remove-greenscreen "${mediaUrl}" --local${RESET}`);
|
|
4865
|
+
}
|
|
4866
|
+
else if (localOut) {
|
|
4867
|
+
const label = localOut.format === "webm" ? "Transparent talking avatar (WebM)" : "Transparent talking avatar (ProRes 4444 .mov)";
|
|
4868
|
+
console.log(`${GREEN}${label} ready:${RESET} ${localOut.outputPath}`);
|
|
4869
|
+
if (!localOut.webmAlpha) {
|
|
4870
|
+
console.log(`${YELLOW}Note:${RESET} ${DIM}this ffmpeg build can't write VP9 alpha, so a ProRes .mov alpha master was produced — transparent and free, but not browser/editor-playable. Re-run without ${RESET}--local${DIM} for a browser-ready WebM.${RESET}`);
|
|
4871
|
+
}
|
|
4872
|
+
console.log(`${DIM}Greenscreen plate (re-keyable): ${mediaUrl}${RESET}`);
|
|
4873
|
+
}
|
|
4874
|
+
else {
|
|
4875
|
+
console.log(`${GREEN}Transparent talking avatar ready:${RESET} ${mediaUrl}`);
|
|
4876
|
+
if (outPath)
|
|
4877
|
+
console.log(`${DIM}Saved to ${outPath}${RESET}`);
|
|
4878
|
+
const plate = job?.result?.greenscreen_source_url;
|
|
4879
|
+
if (plate)
|
|
4880
|
+
console.log(`${DIM}Greenscreen plate (re-key at a different tolerance for free): ${plate}${RESET}`);
|
|
4881
|
+
}
|
|
4882
|
+
if (!placement) {
|
|
4883
|
+
console.log(`${DIM}Drop it over a background with: vidfarm place <dir> --src "${finalSrc}" --kind video --at <time> (the alpha survives into the render).${RESET}`);
|
|
4884
|
+
}
|
|
4885
|
+
}
|
|
4476
4886
|
// ── Green-screen (chroma-key) background removal ──────────────────────────────
|
|
4477
4887
|
// The devcli twin of the remove-greenscreen primitive: key out a flat, solid
|
|
4478
4888
|
// background from an IMAGE (→ transparent PNG/WebP) OR a VIDEO (→ transparent
|
|
@@ -5156,7 +5566,7 @@ async function runTtsCommand(argv) {
|
|
|
5156
5566
|
/** Set when the cost-mode default ran Kokoro and it failed — so the keyless
|
|
5157
5567
|
* path below doesn't pointlessly retry the same engine. */
|
|
5158
5568
|
let kokoroError = null;
|
|
5159
|
-
if (!engine && !wantCloud && costMode.mode !== "
|
|
5569
|
+
if (!engine && !wantCloud && costMode.mode !== "rich-ai" && costMode.mode !== "pure-videogen" && !premium) {
|
|
5160
5570
|
try {
|
|
5161
5571
|
await runLocalKokoroTts({
|
|
5162
5572
|
text,
|
|
@@ -6838,15 +7248,16 @@ async function runWhoamiCommand(argv) {
|
|
|
6838
7248
|
console.log(`${GREEN}${BOLD}${who}${RESET} → ${ctx.host}`);
|
|
6839
7249
|
console.log(` plan ${customer.isPaidPlan ? `${GREEN}paid${plan}${RESET}` : `${DIM}free${plan}${RESET}`}`);
|
|
6840
7250
|
console.log(` credential ${DIM}${stored ? "persisted login (vidfarm login)" : "env/flag key — not persisted (run vidfarm login to persist)"}${RESET}`);
|
|
6841
|
-
console.log(` ${costModeSummaryLine(ctx.costMode).replace("cost mode: ", "cost mode ")}${!ctx.costMode.isSet ? ` ${DIM}(set with: vidfarm cost-mode
|
|
7251
|
+
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}` : ""}`);
|
|
6842
7252
|
return;
|
|
6843
7253
|
}
|
|
6844
7254
|
emitResult(result, ctx.json);
|
|
6845
7255
|
}
|
|
6846
|
-
// `vidfarm cost-mode [minimize|hybrid|rich-ai]` — show or set the
|
|
6847
|
-
// preference every billed command respects. No arg = show current +
|
|
6848
|
-
//
|
|
6849
|
-
// about saving the choice into whatever agent memory it has).
|
|
7256
|
+
// `vidfarm cost-mode [minimize|hybrid|rich-ai|pure-videogen]` — show or set the
|
|
7257
|
+
// money-saving preference every billed command respects. No arg = show current +
|
|
7258
|
+
// explain the modes simply so an agent can relay them to the user (and be
|
|
7259
|
+
// reminded to ask about saving the choice into whatever agent memory it has).
|
|
7260
|
+
// `--clear` forgets it.
|
|
6850
7261
|
async function runCostModeCommand(argv) {
|
|
6851
7262
|
const parsed = parseArgs({
|
|
6852
7263
|
args: argv,
|
|
@@ -6889,7 +7300,7 @@ async function runCostModeCommand(argv) {
|
|
|
6889
7300
|
console.log("");
|
|
6890
7301
|
console.log(costModeExplainer());
|
|
6891
7302
|
console.log("");
|
|
6892
|
-
console.log(`${DIM}Set it: ${BOLD}vidfarm cost-mode
|
|
7303
|
+
console.log(`${DIM}Set it: ${BOLD}vidfarm cost-mode <${COST_MODE_DISPLAY_LIST.join("|")}>${RESET}`);
|
|
6893
7304
|
console.log(`${DIM}Forget it: vidfarm cost-mode --clear · override per-run: --cost-mode <m> or VIDFARM_COST_MODE.${RESET}`);
|
|
6894
7305
|
if (!resolved.isSet) {
|
|
6895
7306
|
console.log(`${DIM}Nothing saved yet — ask the user which one they want before spending AI credits.${RESET}`);
|
|
@@ -8225,6 +8636,54 @@ async function runLintCommand(argv) {
|
|
|
8225
8636
|
if (!result.ok)
|
|
8226
8637
|
process.exitCode = 1;
|
|
8227
8638
|
}
|
|
8639
|
+
// `vidfarm qa <dir|composition.html>` — the social-native QA pass. Compositions
|
|
8640
|
+
// are authored in HTML, so an agent's web-page instincts leak in as landing-page
|
|
8641
|
+
// furniture (gradient CTA capsules, benefit chip rows, frosted cards) that never
|
|
8642
|
+
// appears in a real TikTok. This is a BLOCKLIST of those specific patterns plus
|
|
8643
|
+
// the font regime — deliberately NOT an allowlist, so weird/ugly/hand-made
|
|
8644
|
+
// compositions pass untouched and nothing pushes every video toward one style.
|
|
8645
|
+
//
|
|
8646
|
+
// FEEDBACK, NOT A GATE: exit code stays 0 even when slop is found, unless the
|
|
8647
|
+
// caller opts into --strict. Nothing in the render path calls this. It is a
|
|
8648
|
+
// devcli-only capability (no cloud/REST twin) — heavily recommended after every
|
|
8649
|
+
// production, never mandatory and never automatic.
|
|
8650
|
+
async function runQaCommand(argv) {
|
|
8651
|
+
const parsed = parseArgs({
|
|
8652
|
+
args: argv,
|
|
8653
|
+
allowPositionals: true,
|
|
8654
|
+
options: {
|
|
8655
|
+
json: { type: "boolean", default: false },
|
|
8656
|
+
strict: { type: "boolean", default: false }
|
|
8657
|
+
}
|
|
8658
|
+
});
|
|
8659
|
+
const target = parsed.positionals[0];
|
|
8660
|
+
if (!target)
|
|
8661
|
+
throw new Error("qa requires a composition path: `vidfarm qa <dir-or-composition.html> [--json] [--strict]`.");
|
|
8662
|
+
const htmlPath = resolveCompositionHtmlPath(target);
|
|
8663
|
+
const report = qaCompositionHtml(readFileSync(htmlPath, "utf8"));
|
|
8664
|
+
if (parsed.values.json) {
|
|
8665
|
+
printJson(report);
|
|
8666
|
+
if (!report.ok && parsed.values.strict)
|
|
8667
|
+
process.exitCode = 1;
|
|
8668
|
+
return;
|
|
8669
|
+
}
|
|
8670
|
+
const body = formatQaReport(report, { red: RED, yellow: YELLOW, green: GREEN, dim: DIM, reset: RESET });
|
|
8671
|
+
if (body)
|
|
8672
|
+
console.log(body);
|
|
8673
|
+
const scope = `${report.checked.layers} layer(s), ${report.checked.text_layers} text layer(s)${report.checked.canvas ? `, ${report.checked.canvas}` : ""}`;
|
|
8674
|
+
if (report.verdict === "clean") {
|
|
8675
|
+
console.log(`${GREEN}✓ no HTML slop found${RESET} ${DIM}— ${scope} — ${htmlPath}${RESET}`);
|
|
8676
|
+
}
|
|
8677
|
+
else if (report.verdict === "warnings") {
|
|
8678
|
+
console.log(`${YELLOW}! ${report.warnings.length} warning(s), no slop${RESET} ${DIM}— ${scope} — ${htmlPath}${RESET}`);
|
|
8679
|
+
}
|
|
8680
|
+
else {
|
|
8681
|
+
console.log(`${RED}✗ ${report.errors.length} slop issue(s)${RESET}, ${report.warnings.length} warning(s) ${DIM}— ${scope} — ${htmlPath}${RESET}`);
|
|
8682
|
+
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
|
+
}
|
|
8684
|
+
if (!report.ok && parsed.values.strict)
|
|
8685
|
+
process.exitCode = 1;
|
|
8686
|
+
}
|
|
8228
8687
|
// `vidfarm stills <dir|composition.html>` — render PNG stills of the CURRENT
|
|
8229
8688
|
// composition in-process (the agent's visual self-verification loop).
|
|
8230
8689
|
async function runStillsCommand(argv) {
|