@officexapp/vidfarm-devcli 0.21.35 → 0.21.37
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 +1 -1
- package/.agents/skills/vidfarm/SKILL.md +7 -3
- package/.agents/skills/vidfarm/harnesses/short-form.HARNESS.md +3 -3
- package/.agents/skills/vidfarm/recipes/cutout-graphics-for-explainers.md +42 -12
- package/.agents/skills/vidfarm/references/automation-and-local-dev.md +4 -4
- package/.agents/skills/vidfarm/references/hooks-and-virality.md +3 -2
- package/SKILL.director.md +56 -21
- package/SKILL.md +1 -1
- package/dist/src/cli.js +429 -53
- package/dist/src/devcli/handoff.js +54 -33
- package/dist/src/devcli/local-frontend-server.js +92 -6
- package/dist/src/devcli/plate-key.js +698 -0
- package/dist/src/devcli/sticker-pack.js +48 -0
- package/package.json +3 -2
package/dist/src/cli.js
CHANGED
|
@@ -23,7 +23,8 @@ import { extractCompositionFacts, formatQaReport, formatWatchTheVideoNotice, qaC
|
|
|
23
23
|
import { HARNESS_FILENAME, discoverHarness, formatHarnessReport, isLegacyHarnessFilename, listBuiltinHarnesses, loadAndEvaluateHarness, mergeHarnessIntoReport, parseHarness, resolveHarnessPath } from "./devcli/harness.js";
|
|
24
24
|
import { removeGreenscreenLocal, localGreenscreenAvailable, defaultGreenscreenOutPath, GREENSCREEN_PRESETS, trimTransparentBorders, cropImageRegion } from "./devcli/greenscreen-local.js";
|
|
25
25
|
import { dedupeMediaLocal, localDedupeAvailable, defaultDedupeOutPath, describeLocalDedupe, DEDUPE_PRESETS, DEDUPE_DEFAULT_PRESET, isDedupePresetName } from "./devcli/dedupe-local.js";
|
|
26
|
-
import { segmentAlphaComponents, encodeTransparentGif, encodeTransparentAnimatedGif, pickPlateColor, detectPlateColor, keySafeArtInstruction, analyzeKeyedArt, HOLE_WARN_PCT } from "./devcli/sticker-pack.js";
|
|
26
|
+
import { segmentAlphaComponents, encodeTransparentGif, encodeTransparentAnimatedGif, pickPlateColor, detectPlateColor, keySafeArtInstruction, connectivitySafeArtInstruction, analyzeKeyedArt, measureOpaqueShare, HOLE_WARN_PCT } from "./devcli/sticker-pack.js";
|
|
27
|
+
import { smartKeyPlate, planZonedSheet, zonedSheetInstruction } from "./devcli/plate-key.js";
|
|
27
28
|
import { runDoctorCommand } from "./devcli/doctor.js";
|
|
28
29
|
import { findFreePort } from "./devcli/port-utils.js";
|
|
29
30
|
import { scanLocalServers } from "./devcli/process-scan.js";
|
|
@@ -321,6 +322,10 @@ Generate AI media and drop it on the timeline (for local coding agents):
|
|
|
321
322
|
--media-type <k> Force image|video instead of auto-detecting
|
|
322
323
|
--tolerance <0..1> Key radius (default 0.3); --softness <0..1> edge feather (0.1)
|
|
323
324
|
--no-despill Skip edge color-fringe suppression
|
|
325
|
+
--smart IMAGE only: key by border-connected fill instead of by
|
|
326
|
+
color alone, so plate-colored detail inside the subject
|
|
327
|
+
and hollow/outline art survive (what cutout and
|
|
328
|
+
sticker-pack do by default). Local only
|
|
324
329
|
--output-format <f> Image only: png|webp
|
|
325
330
|
--gif Write a TRANSPARENT GIF instead (animated for a clip)
|
|
326
331
|
— local only, 1-bit alpha, for GIF-only sticker surfaces
|
|
@@ -373,21 +378,25 @@ Generate AI media and drop it on the timeline (for local coding agents):
|
|
|
373
378
|
default: --generate moves the plate off any hue the subject
|
|
374
379
|
mentions; an existing file's plate is read from its corners.
|
|
375
380
|
--no-auto-key forces plain green.
|
|
381
|
+
--key-mode smart|flat smart (default): flood-fill the plate in from the frame
|
|
382
|
+
edge, so plate-colored detail inside the subject and
|
|
383
|
+
hollow/outline art survive and the edge is feathered +
|
|
384
|
+
de-spilled per pixel. flat: the plain ffmpeg chromakey
|
|
376
385
|
--pad <px> Keep N px of transparent breathing room around the cutout
|
|
377
386
|
--alpha-threshold <n> Alpha 0..255 below which edges count as empty (default 8)
|
|
378
387
|
--no-trim Key only; don't shrink to the bounding box
|
|
379
388
|
--output-format png|webp Cutout format (default png)
|
|
380
389
|
--keep-plate <file> Also save the pre-key generated plate (with --generate)
|
|
381
390
|
--out <file> Write the cutout here (default <source>.cutout.png)
|
|
382
|
-
sticker-pack [sheet|url] Make a whole STICKER PACK from ONE
|
|
383
|
-
|
|
384
|
-
|
|
391
|
+
sticker-pack [sheet|url] Make a whole STICKER PACK from ONE sheet: key the
|
|
392
|
+
plate(s) once, auto-find each item in the alpha
|
|
393
|
+
channel, and cut every one into its own snug
|
|
385
394
|
transparent file (+ stickers.json). One image job for
|
|
386
395
|
the set = one art style, ~1/N the cost of N cutouts.
|
|
387
396
|
Items may be any size, from an icon to a full-frame
|
|
388
|
-
landscape.
|
|
389
|
-
|
|
390
|
-
|
|
397
|
+
landscape. Keys by CONNECTIVITY, so plate-colored
|
|
398
|
+
detail inside an item and hollow/outline shapes
|
|
399
|
+
survive, and edges come out feathered + de-spilled.
|
|
391
400
|
Local, free, ffmpeg-only. Image-only.
|
|
392
401
|
(aliases: stickers, sticker-sheet)
|
|
393
402
|
--generate "<theme>" AI-generate the sheet first (ONE billed image job for
|
|
@@ -399,6 +408,30 @@ Generate AI media and drop it on the timeline (for local coding agents):
|
|
|
399
408
|
art can't collide with (green art → magenta plate, etc.);
|
|
400
409
|
splitting an existing sheet detects the plate from its
|
|
401
410
|
corners. --no-auto-key forces plain green. (--flat too)
|
|
411
|
+
--sheet-mode <m> auto (default) | zoned | flat. ZONED asks the model for a
|
|
412
|
+
grid of color PANELS — one item per panel, each panel's
|
|
413
|
+
plate picked against that item — so a green frog and a
|
|
414
|
+
pink flower can share a sheet, item art can use ANY
|
|
415
|
+
palette, and names are pinned to panels instead of guessed
|
|
416
|
+
from reading order. auto = zoned when generating 2+ named
|
|
417
|
+
items. FLAT is the classic one-color sheet: use it for a
|
|
418
|
+
weaker image model that can't follow a color-block grid
|
|
419
|
+
(if a model ignores the grid, keying detects that and
|
|
420
|
+
falls back to a flat plate on its own)
|
|
421
|
+
--zones auto|off|RxC How to read panels off a sheet at key time (default auto:
|
|
422
|
+
recover the grid from the sheet's own edges, else one
|
|
423
|
+
plate). Pass RxC for a sheet you know the layout of
|
|
424
|
+
--zone-cols <n> Force the panel grid's column count when planning a
|
|
425
|
+
zoned generation
|
|
426
|
+
--key-mode smart|flat smart (default): flood-fill the plate inward from the
|
|
427
|
+
sheet edge, so only background that REACHES the edge is
|
|
428
|
+
deleted. flat: the plain ffmpeg chromakey (deletes the key
|
|
429
|
+
color wherever it appears) — the simple fallback, and the
|
|
430
|
+
way to match a cloud render exactly
|
|
431
|
+
--refine Re-cut every item with the local ONNX matting model
|
|
432
|
+
(free, ~1-2s each) instead of the chroma cut — the path
|
|
433
|
+
for painterly, soft-edged, furry or glassy art. Falls
|
|
434
|
+
back to the keyed cut per item when the matte looks wrong
|
|
402
435
|
--dry-run Just list the detected crop boxes; write nothing
|
|
403
436
|
--gap <pct> Merge islands closer than this % of the sheet's short
|
|
404
437
|
side into ONE item (default 1.2) — raise it to rejoin a
|
|
@@ -2437,7 +2470,7 @@ Rules:
|
|
|
2437
2470
|
- For replacement graphics, screenshots, or still-like scenes, prefer AI image generation plus Ken Burns before paying for AI video unless static_vs_pivot says motion footage is load-bearing.
|
|
2438
2471
|
- If narration must be customized, default to premium ElevenLabs first, then the user's own ElevenLabs path, then BYOK OpenAI/Gemini/OpenRouter. If captions or scenes were timed to the old VO, retime them to the new narration.
|
|
2439
2472
|
- NO HTML SLOP. You are editing HTML, but the output is a social video, not a web page. THE TEST IS THE NATIVE-EDITOR TEST: could you have made this element with the tools inside TikTok's own editor? That toolset is a font, a color, a stroke/outline, a soft shadow, a tight text box, alignment, opacity, rotation, animation presets — plus stickers, emoji, drawn marks and clips. It has NO padded capsule, NO border, NO gradient fill, NO blur panel, NO card. If you reached past it, cut it. Never author landing-page furniture: CTA "buttons" (a filled/gradient rounded capsule with action copy like "Sign Up for a Free Trial →"), benefit chip/badge rows ("✓ No Credit Card Needed"), bordered/shadowed/frosted cards holding a headline + URL, gradient text fills, feature grids, bulleted lists, or web-default fonts (Inter/Roboto/Arial/system-ui). AND NOT A SINGLE PILL EITHER: one lonely rounded, padded, filled capsule around a static stat or label — "10 hrs / week", "STEP 2", "EP.01", "+40%" — is a web badge, and being the only one on screen does not make it native. The ONLY legitimate capsule in a video is the active-word spotlight/karaoke caption highlight, because it moves with the spoken word. Emphasize a stat the way the editor would: bigger, heavier, ALL-CAPS, an accent color, a hand-drawn circle or underline, or its own beat on screen. Rule of thumb on anything holding words: border-radius over ~8px PLUS a background fill PLUS padding = a badge; drop the fill or drop the radius until the band hugs the glyphs. None of this appears in a real TikTok, and nothing in a video is clickable — say it as timed text on the footage instead. Arrows, scribble/underline marks, italics, ALL-CAPS, single-word color pops, emoji, transparent cut-out stickers, and mock social UI (iMessage bubbles, comment cards) are all fine. Captions use an imported family (Montserrat default / TikTok Sans / Abel / Source Code Pro / Yesteryear) at weight 700-900, ~36-64px on a 1080-wide frame, inside the 8%-85% safe zone, with exactly one of four backgrounds: outline, plain, an active-word spotlight/karaoke pill, or a tight-hugging solid band (radius <=8px, no border/shadow/gradient/blur).
|
|
2440
|
-
- STRUCTURE BEFORE POLISH — THE FOUR CHARGES, WRITTEN BEFORE YOU TOUCH THE TIMELINE. Most agent-made videos fail on structure, not polish, because the timeline is the fun part so it gets built first and the words get retrofitted. Invert it: (1) HOOK — write the opening line as text first: a complete clause (subject + verb), no jargon, naming a SITUATION ("I've quit six businesses") not a label ("anonymity"); it goes on screen at start:0, because caption chunk 1 is read before any audio and muted autoplay is the default. Banned openings: throat-clearing ("so I was thinking", "here's the thing"), a logo, a title card, a fade from black, context before the claim. (2) LOOP — one open question by 0:10, said ON SCREEN, closing INSIDE this video (state the timestamp it closes at; if you can't, there is no loop), and the withheld answer must be one the viewer CANNOT supply themselves — a formally-correct loop with a guessable answer passes every mechanical check and dies in the field. (3) PAYOFF — shown, not summarized, ≥5 uninterrupted seconds, landing BEFORE the final beat; the payoff is not the CTA. (4) BAIT — one ask in the final beat and in the post caption;
|
|
2473
|
+
- STRUCTURE BEFORE POLISH — THE FOUR CHARGES, WRITTEN BEFORE YOU TOUCH THE TIMELINE. Most agent-made videos fail on structure, not polish, because the timeline is the fun part so it gets built first and the words get retrofitted. Invert it: (1) HOOK — write the opening line as text first: a complete clause (subject + verb), no jargon, naming a SITUATION ("I've quit six businesses") not a label ("anonymity"); it goes on screen at start:0, because caption chunk 1 is read before any audio and muted autoplay is the default. Banned openings: throat-clearing ("so I was thinking", "here's the thing"), a logo, a title card, a fade from black, context before the claim. (2) LOOP — one open question by 0:10, said ON SCREEN, closing INSIDE this video (state the timestamp it closes at; if you can't, there is no loop), and the withheld answer must be one the viewer CANNOT supply themselves — a formally-correct loop with a guessable answer passes every mechanical check and dies in the field. (3) PAYOFF — shown, not summarized, ≥5 uninterrupted seconds, landing BEFORE the final beat; the payoff is not the CTA. (4) BAIT — one ask in the final beat and in the post caption; a keyword comment ask ("comment CLIPPER and I'll send the breakdown") is standard and allowed, but never "follow for part two", ragebait, or an earnings/health claim traded for the reply. Then build the timeline. Re-theming a decomposed template: viral_dna already names the source's hook/retention/payoff — rebuild each charge for the new subject, never flatten the loop into a product statement. Full craft harness: the vidfarm skill's references/hooks-and-virality.md. Checkable form: \`vidfarm harness show hooks\`.
|
|
2441
2474
|
- THE FIRST FRAME IS THE THUMBNAIL. Frame 0 is one frame of ~30 in the first second, but every feed card, share link, and paused player freezes on it — more people see that frame than watch the video. It must never be black, empty, mid-fade, or mid-animation: a real visual at start:0 (\`vidfarm retime . --layer <key> --start 0\`), the hook words already on screen at t=0, and NO entrance transition on the FIRST clip (\`vidfarm transitions set . --layer <key> --in none\`; junction transitions between later clips are fine). Look at the actual pixels before you render: \`vidfarm stills . --at 0\`.
|
|
2442
2475
|
- REVIEW THE WHOLE VIDEO AS ONE OBJECT — AND NEVER JUDGE IT BY ONE FRAME. Assume your own finished video has a defect you cannot see: across a 32-video batch, EVERY first-pass video had a real defect its own author had already reported as "verified, looks good". The cause is structural — you build scene by scene, each scene correct while it is the whole world, so every scene passes alone and the video fails as a SEQUENCE: margins shift between beats, headline sizes drift, the accent color wanders, one asset is flat vector and the next is photographic, every beat is the same length, a join lands like a slap. Nobody watches a scene; they watch the sequence. So before you call anything done, tile ~12 stills into ONE contact sheet and READ IT AS AN IMAGE — one command: \`vidfarm stills . --sheet\` (add \`--at 0,2,4,…\` to pick timestamps; writes stills/contact-sheet.png). Check: visual balance (no dead band under top-anchored content), consistent spacing/margins, ONE type scale, ONE accent color, ONE illustration style, deliberate pacing rather than N identical beats, nothing jarring at the joins, no frame where two elements compete for the eye — and the summary question, does it look like one person made it in one sitting? Fix drift by defining the SYSTEM (type scale, margin, palette, default beat) and applying it to every scene, not by patching the one scene that stood out. The defects that actually ship, in observed frequency order: large flat dead regions · a placeholder empty state that reads as a failed render · two contradictory numbers in one frame · a CTA still building at the last frame (settle it >=2s before the end) · two headlines superimposed at a scene handoff (exit at nextIn-0.18, duration 0.24, ease power2.out) · type colliding with a busy background exactly as it is spoken. If a frame looks empty, sample 0.2s apart to see whether it RESTS there — a transient wipe frame is fine, >0.5s is a hole. AND ALWAYS COMPARE TWO FRAMES FROM DIFFERENT SCENES: a frozen render (an overlay/watermark pass missing \`-loop 1\`, or assets outside the composition root so the timeline never runs) makes every frame identical while duration, frame count and audio hash all still pass, and frame 0 looks perfect. Verify audio by MEASUREMENT, never "it sounds fine" (you cannot hear it): ~12-15 dB speech-over-bed separation across the actual word spans, peak <0 dBFS. Never \`adelay\` the VO — whisper word timings and every caption built from them are relative to the raw vo.wav; use apad+atrim. Report what you MEASURED separately from what you JUDGED. Full method: the vidfarm skill's references/reviewing-renders.md.
|
|
2443
2476
|
- THE HARNESS IS A FIRST-CLASS ARTIFACT — KNOW THE THREE PHRASINGS. A HARNESS.md is the reusable AI harness for ONE format or template: what makes it special, written down so an agent reproduces it without the director in the room. "Create me a harness" → \`vidfarm harness init short-form --out ./HARNESS.md\` (bases: short-form, hooks, ugc-testimonial, explainer, product-demo), then EDIT it with them. "Update the harness for this format" → open the file and write the new rule in WITH ITS REASON on the same line. "Give me the harness for this template_id" → they mean the DECOMPOSITION: \`vidfarm harness derive <forkId>\` distils this fork's viral/visual/structural/audio/build DNA into an editable HARNESS.md, keyed the same way the decompose JSON is (\`## Viral DNA\` → \`viral_dna\`, …); \`vidfarm harness show <ref> --dna visual\` prints one strand. \`vidfarm qa .\` picks up ./HARNESS.md automatically; \`--harness <name|path>\` adds more (they stack, and any file of theirs anywhere is valid). Its \`checks:\` are machine-settled; its \`- [ ]\` items come back for YOU to answer honestly in your report — never claim a pass on the half the CLI can't judge. ONE-TIME OR BULK? Ask before you build: if the director wants volume (daily posting, N variants, hook tests), that's SCRIPTING MODE — pin this fork as the base, vary exactly ONE thing per variant, and install a harness, because nobody watches variant #37 as carefully as #1. When a batch teaches you something, write it back into the harness — the compositions are disposable, the harness compounds. (Formerly QA_REGIME.md; \`vidfarm regime …\` still works as an alias. NOTE: the \`.harness/\` directory beside this file is machine-generated context regenerated on every pull — never hand-edit it. HARNESS.md is the one you own.)
|
|
@@ -5198,6 +5231,12 @@ async function runRemoveGreenscreenCommand(argv) {
|
|
|
5198
5231
|
tolerance: { type: "string" },
|
|
5199
5232
|
softness: { type: "string" },
|
|
5200
5233
|
"no-despill": { type: "boolean", default: false },
|
|
5234
|
+
// IMAGE-only upgrade: key by border-connected fill instead of by color
|
|
5235
|
+
// alone, so plate-colored detail inside the subject and hollow/outline art
|
|
5236
|
+
// survive (`vidfarm cutout` / `sticker-pack` use this by default). Video
|
|
5237
|
+
// stays on the ffmpeg filter chain — per-frame fills in JS would be slow
|
|
5238
|
+
// and would break cloud parity.
|
|
5239
|
+
smart: { type: "boolean", default: false },
|
|
5201
5240
|
"output-format": { type: "string" },
|
|
5202
5241
|
// Animated transparent GIF (local only) — the "sticker for GIF-only
|
|
5203
5242
|
// surfaces" output. GIF alpha is 1-bit, so it's a hand-off format, not a
|
|
@@ -5364,9 +5403,38 @@ async function runLocalGreenscreen(ctx, values, sourceArg, presetRaw) {
|
|
|
5364
5403
|
const keyTargetPath = gifWork
|
|
5365
5404
|
? path.join(gifWork, mediaType === "video" ? "keyed.webm" : "keyed.png")
|
|
5366
5405
|
: outPath;
|
|
5367
|
-
|
|
5368
|
-
|
|
5369
|
-
|
|
5406
|
+
const wantSmart = Boolean(values.smart);
|
|
5407
|
+
if (wantSmart && mediaType === "video") {
|
|
5408
|
+
throw new Error("--smart is image-only (it flood-fills the plate per frame). For a clip, key it without --smart, or cut the subject out with `vidfarm remove-background <video>` (ONNX matting, color-blind).");
|
|
5409
|
+
}
|
|
5410
|
+
let result;
|
|
5411
|
+
if (wantSmart) {
|
|
5412
|
+
if (!ctx.json)
|
|
5413
|
+
console.log(`${DIM}Keying out ${presetRaw ?? keyColor ?? "green"} background locally with the smart keyer (border-connected fill, free)…${RESET}`);
|
|
5414
|
+
const smart = await smartKeyPlate({
|
|
5415
|
+
sourcePath,
|
|
5416
|
+
outputPath: /\.(png|webp)$/i.test(keyTargetPath) ? keyTargetPath : `${keyTargetPath.replace(/\.[^.]+$/, "")}.png`,
|
|
5417
|
+
keyColor: keyColor ?? "#00FF00",
|
|
5418
|
+
singlePlate: true,
|
|
5419
|
+
tolerance: values.tolerance !== undefined ? Number(values.tolerance) : undefined,
|
|
5420
|
+
softness: values.softness !== undefined ? Number(values.softness) : undefined,
|
|
5421
|
+
despill
|
|
5422
|
+
});
|
|
5423
|
+
result = {
|
|
5424
|
+
outputPath: smart.outputPath,
|
|
5425
|
+
format: (/\.webp$/i.test(smart.outputPath) ? "webp" : "png"),
|
|
5426
|
+
alpha: true,
|
|
5427
|
+
webmAlpha: false
|
|
5428
|
+
};
|
|
5429
|
+
if (!ctx.json && smart.stats.protected_px) {
|
|
5430
|
+
console.log(`${DIM}Kept ${smart.stats.protected_px.toLocaleString()} plate-colored pixel(s) INSIDE the subject that a flat key would have deleted.${RESET}`);
|
|
5431
|
+
}
|
|
5432
|
+
}
|
|
5433
|
+
else {
|
|
5434
|
+
if (!ctx.json)
|
|
5435
|
+
console.log(`${DIM}Keying out ${presetRaw ?? keyColor ?? "green"} background locally with ffmpeg (free)…${RESET}`);
|
|
5436
|
+
result = await removeGreenscreenLocal({ sourcePath, mediaType, outputPath: keyTargetPath, keyColor, tolerance, softness, despill });
|
|
5437
|
+
}
|
|
5370
5438
|
if (gifWork) {
|
|
5371
5439
|
try {
|
|
5372
5440
|
if (!ctx.json)
|
|
@@ -5776,7 +5844,7 @@ async function runLocalDedupe(ctx, input) {
|
|
|
5776
5844
|
// "Cutout graphics for explainers" recipe for placement + zoom/grow/shake/move).
|
|
5777
5845
|
/** Build a chroma-plate instruction so an AI-generated graphic keys cleanly.
|
|
5778
5846
|
* Matches the color to the chosen key so the keyer has a flat edge to cut. */
|
|
5779
|
-
function cutoutPlateInstruction(keyColorHex) {
|
|
5847
|
+
function cutoutPlateInstruction(keyColorHex, keyMode = "smart") {
|
|
5780
5848
|
const named = /^#?00ff00$/i.test(keyColorHex) ? "pure chroma-key green"
|
|
5781
5849
|
: /^#?0047bb$/i.test(keyColorHex) ? "solid chroma-key blue"
|
|
5782
5850
|
: /^#?ff00ff$/i.test(keyColorHex) ? "solid magenta"
|
|
@@ -5785,9 +5853,10 @@ function cutoutPlateInstruction(keyColorHex) {
|
|
|
5785
5853
|
`like a green-screen plate. ` +
|
|
5786
5854
|
`Center the subject with generous empty margin on all sides, no drop shadow or reflection cast on the ` +
|
|
5787
5855
|
`background, crisp clean edges, sticker/cutout style, single subject, no text. ` +
|
|
5788
|
-
//
|
|
5789
|
-
//
|
|
5790
|
-
|
|
5856
|
+
// Which art rules apply depends on the keyer: a flat chromakey deletes the
|
|
5857
|
+
// key color wherever it appears (so hollow art loses its middle), while the
|
|
5858
|
+
// smart keyer only deletes background that reaches the frame edge.
|
|
5859
|
+
(keyMode === "smart" ? connectivitySafeArtInstruction(keyColorHex) : keySafeArtInstruction(keyColorHex)));
|
|
5791
5860
|
}
|
|
5792
5861
|
async function runCutoutCommand(argv) {
|
|
5793
5862
|
const parsed = parseArgs({
|
|
@@ -5809,6 +5878,7 @@ async function runCutoutCommand(argv) {
|
|
|
5809
5878
|
tolerance: { type: "string" },
|
|
5810
5879
|
softness: { type: "string" },
|
|
5811
5880
|
"no-despill": { type: "boolean", default: false },
|
|
5881
|
+
"key-mode": { type: "string" }, // smart (default) | flat
|
|
5812
5882
|
// Trim knobs.
|
|
5813
5883
|
"no-trim": { type: "boolean", default: false },
|
|
5814
5884
|
pad: { type: "string" },
|
|
@@ -5836,6 +5906,15 @@ async function runCutoutCommand(argv) {
|
|
|
5836
5906
|
const despill = !parsed.values["no-despill"];
|
|
5837
5907
|
const outputFormat = parsed.values["output-format"]?.trim().toLowerCase();
|
|
5838
5908
|
const outExt = outputFormat === "webp" ? "webp" : "png";
|
|
5909
|
+
// `smart` keys by border-connected fill, so plate-colored details inside the
|
|
5910
|
+
// subject and hollow/outline art survive, and the edge is feathered + de-spilled
|
|
5911
|
+
// per pixel. `flat` is the plain ffmpeg chromakey, kept as the simple fallback
|
|
5912
|
+
// (and the way to match a cloud render exactly).
|
|
5913
|
+
const keyModeRaw = (parsed.values["key-mode"] ?? "smart").trim().toLowerCase();
|
|
5914
|
+
if (!["smart", "flat"].includes(keyModeRaw)) {
|
|
5915
|
+
throw new Error(`Unknown --key-mode "${keyModeRaw}". Use smart (default) or flat (the plain ffmpeg chromakey).`);
|
|
5916
|
+
}
|
|
5917
|
+
const keyMode = keyModeRaw;
|
|
5839
5918
|
const generatePrompt = parsed.values.generate?.trim();
|
|
5840
5919
|
const sourceArg = parsed.values.source ?? parsed.positionals[0];
|
|
5841
5920
|
if (!generatePrompt && !sourceArg) {
|
|
@@ -5876,7 +5955,7 @@ async function runCutoutCommand(argv) {
|
|
|
5876
5955
|
"and hands the PNG back; `vidfarm cutout <file>` then keys + trims it for $0"
|
|
5877
5956
|
});
|
|
5878
5957
|
const refs = await resolveReferenceUrls(ctx, parsed.values.ref);
|
|
5879
|
-
const fullPrompt = `${generatePrompt}. ${cutoutPlateInstruction(keyColor)}`;
|
|
5958
|
+
const fullPrompt = `${generatePrompt}. ${cutoutPlateInstruction(keyColor, keyMode)}`;
|
|
5880
5959
|
const payload = { prompt: fullPrompt };
|
|
5881
5960
|
payload.aspect_ratio = parsed.values["aspect-ratio"] ?? "1:1";
|
|
5882
5961
|
if (parsed.values.provider)
|
|
@@ -5940,9 +6019,28 @@ async function runCutoutCommand(argv) {
|
|
|
5940
6019
|
}
|
|
5941
6020
|
}
|
|
5942
6021
|
const keyedPath = path.join(work, "keyed.png");
|
|
5943
|
-
|
|
5944
|
-
|
|
5945
|
-
|
|
6022
|
+
let cutoutKey = null;
|
|
6023
|
+
if (keyMode === "flat") {
|
|
6024
|
+
if (!ctx.json)
|
|
6025
|
+
console.log(`${DIM}Keying out the ${presetRaw ?? keyColor} background locally with ffmpeg chromakey (--key-mode flat, free)…${RESET}`);
|
|
6026
|
+
await removeGreenscreenLocal({ sourcePath, mediaType: "image", outputPath: keyedPath, keyColor, tolerance, softness, despill });
|
|
6027
|
+
}
|
|
6028
|
+
else {
|
|
6029
|
+
if (!ctx.json)
|
|
6030
|
+
console.log(`${DIM}Keying out the ${presetRaw ?? keyColor} background locally with the smart keyer (border-connected fill, free)…${RESET}`);
|
|
6031
|
+
cutoutKey = await smartKeyPlate({
|
|
6032
|
+
sourcePath,
|
|
6033
|
+
outputPath: keyedPath,
|
|
6034
|
+
keyColor,
|
|
6035
|
+
singlePlate: true,
|
|
6036
|
+
tolerance: parsed.values.tolerance !== undefined ? Number(parsed.values.tolerance) : undefined,
|
|
6037
|
+
softness: parsed.values.softness !== undefined ? Number(parsed.values.softness) : undefined,
|
|
6038
|
+
despill
|
|
6039
|
+
});
|
|
6040
|
+
if (!ctx.json && cutoutKey.stats.protected_px) {
|
|
6041
|
+
console.log(`${DIM}Kept ${cutoutKey.stats.protected_px.toLocaleString()} ${keyColor}-colored pixel(s) INSIDE the subject that a flat key would have deleted.${RESET}`);
|
|
6042
|
+
}
|
|
6043
|
+
}
|
|
5946
6044
|
// ---- 3. Trim to the tight cutout bounding box ---------------------------
|
|
5947
6045
|
const outPath = parsed.values.out
|
|
5948
6046
|
? path.resolve(process.cwd(), String(parsed.values.out))
|
|
@@ -5968,12 +6066,18 @@ async function runCutoutCommand(argv) {
|
|
|
5968
6066
|
// left as bare plate) or a near-plate fill comes back as a rim around a
|
|
5969
6067
|
// see-through hole — invisible until it composites over a real background.
|
|
5970
6068
|
const art = await analyzeKeyedArt(trim.outputPath);
|
|
5971
|
-
const hollowNote = art?.hollow
|
|
5972
|
-
?
|
|
5973
|
-
|
|
5974
|
-
|
|
5975
|
-
|
|
5976
|
-
|
|
6069
|
+
const hollowNote = !art?.hollow
|
|
6070
|
+
? null
|
|
6071
|
+
: keyMode === "smart"
|
|
6072
|
+
// The smart keyer can't reach an enclosed interior, so a hole is almost
|
|
6073
|
+
// always drawn that way — or the fill is OPEN to the background.
|
|
6074
|
+
? `${art.hole_pct}% of this cutout is transparent HOLES inside the art. The smart keyer can't delete an enclosed interior, so this is ` +
|
|
6075
|
+
`most likely real (a ring, frame, donut, or separate pieces with background between them). If it looks wrong, the shape's fill is open ` +
|
|
6076
|
+
`to the background through a gap in its outline — ask the prompt for a closed, solidly filled shape.`
|
|
6077
|
+
: `${art.hole_pct}% of this cutout is transparent HOLES inside the art. If it's meant to be a ring/frame/donut, ignore this. ` +
|
|
6078
|
+
`Otherwise the flat key ate the fill: the art was drawn as an outline with a bare ${keyColor} interior, or filled in a near-${keyColor} shade. ` +
|
|
6079
|
+
`Re-run without --key-mode flat (the smart keyer keeps enclosed interiors), or fix it in the PROMPT — "a closed, solidly filled shape, ` +
|
|
6080
|
+
`nothing in ${keyColor} or any near-shade of it, fully opaque".`;
|
|
5977
6081
|
if (ctx.json) {
|
|
5978
6082
|
printJson({
|
|
5979
6083
|
ok: true,
|
|
@@ -5988,6 +6092,9 @@ async function runCutoutCommand(argv) {
|
|
|
5988
6092
|
area_reduced_pct: shrinkPct,
|
|
5989
6093
|
bytes: safeSize(trim.outputPath),
|
|
5990
6094
|
key_color: keyColor,
|
|
6095
|
+
key_mode: keyMode,
|
|
6096
|
+
keyed_pct: cutoutKey?.stats.keyed_pct ?? null,
|
|
6097
|
+
protected_px: cutoutKey?.stats.protected_px ?? null,
|
|
5991
6098
|
hole_pct: art?.hole_pct ?? null,
|
|
5992
6099
|
hollow: art?.hollow ?? false,
|
|
5993
6100
|
hollow_note: hollowNote
|
|
@@ -6207,7 +6314,7 @@ function slugifyStickerName(raw) {
|
|
|
6207
6314
|
* in the parts that make segmentation work: a spread-out grid and, above all,
|
|
6208
6315
|
* visible plate-colored GAPS between items (touching items merge into one
|
|
6209
6316
|
* island and come out as a single sticker). */
|
|
6210
|
-
function stickerSheetInstruction(keyColorHex, count, items) {
|
|
6317
|
+
function stickerSheetInstruction(keyColorHex, count, items, keyMode = "smart") {
|
|
6211
6318
|
const named = /^#?00ff00$/i.test(keyColorHex) ? "pure chroma-key green"
|
|
6212
6319
|
: /^#?0047bb$/i.test(keyColorHex) ? "solid chroma-key blue"
|
|
6213
6320
|
: /^#?ff00ff$/i.test(keyColorHex) ? "solid magenta"
|
|
@@ -6221,7 +6328,10 @@ function stickerSheetInstruction(keyColorHex, count, items) {
|
|
|
6221
6328
|
`nothing touching, overlapping, or connected. Keep a wide ${keyColorHex} margin around the edges of the sheet too. ` +
|
|
6222
6329
|
`No drop shadows, no reflections, no text, no labels, ` +
|
|
6223
6330
|
`no frames or dividing lines between items. One consistent art style across all items, crisp clean edges, sticker/cutout style. ` +
|
|
6224
|
-
|
|
6331
|
+
// The art rules depend on which keyer will cut this sheet: the smart keyer
|
|
6332
|
+
// only deletes background that reaches the sheet's edge, so it can allow
|
|
6333
|
+
// hollow shapes and plate-colored detail that a flat chromakey cannot.
|
|
6334
|
+
(keyMode === "smart" ? connectivitySafeArtInstruction(keyColorHex) : keySafeArtInstruction(keyColorHex)));
|
|
6225
6335
|
}
|
|
6226
6336
|
async function runStickerPackCommand(argv) {
|
|
6227
6337
|
const parsed = parseArgs({
|
|
@@ -6246,6 +6356,12 @@ async function runStickerPackCommand(argv) {
|
|
|
6246
6356
|
tolerance: { type: "string" },
|
|
6247
6357
|
softness: { type: "string" },
|
|
6248
6358
|
"no-despill": { type: "boolean", default: false },
|
|
6359
|
+
// How the plate is removed, and how the sheet is laid out.
|
|
6360
|
+
"key-mode": { type: "string" }, // smart (default) | flat
|
|
6361
|
+
"sheet-mode": { type: "string" }, // auto (default) | zoned | flat
|
|
6362
|
+
zones: { type: "string" }, // auto (default) | off | RxC
|
|
6363
|
+
"zone-cols": { type: "string" },
|
|
6364
|
+
refine: { type: "boolean", default: false },
|
|
6249
6365
|
// Segmentation knobs.
|
|
6250
6366
|
"min-area": { type: "string" },
|
|
6251
6367
|
gap: { type: "string" },
|
|
@@ -6290,13 +6406,71 @@ async function runStickerPackCommand(argv) {
|
|
|
6290
6406
|
if (!generatePrompt && !sourceArg) {
|
|
6291
6407
|
throw new Error("sticker-pack needs a sheet: `vidfarm sticker-pack <sheet.png|url>` to cut up a greenscreen sheet you already have, or `vidfarm sticker-pack --generate \"cozy farm props\" --items \"barn,tractor,hay bale,sun\"` to AI-generate one first (ONE image job for the whole set).");
|
|
6292
6408
|
}
|
|
6409
|
+
// ---- How to key, and how to lay the sheet out -----------------------------
|
|
6410
|
+
// KEY MODE. `smart` (default) keys by CONNECTIVITY: it flood-fills the plate
|
|
6411
|
+
// inward from the sheet's edge, so only background that reaches the edge is
|
|
6412
|
+
// deleted. Plate-colored art interiors and hollow/outline shapes survive, and
|
|
6413
|
+
// edges come out feathered and de-spilled per pixel. `flat` is the original
|
|
6414
|
+
// ffmpeg chromakey — every pixel that looks like the key color goes, wherever
|
|
6415
|
+
// it is. Kept as a first-class fallback: it is the cloud path's exact filter
|
|
6416
|
+
// chain, so it's the way to reproduce a cloud render bit-for-bit.
|
|
6417
|
+
const keyModeRaw = (parsed.values["key-mode"] ?? "smart").trim().toLowerCase();
|
|
6418
|
+
if (!["smart", "flat"].includes(keyModeRaw)) {
|
|
6419
|
+
throw new Error(`Unknown --key-mode "${keyModeRaw}". Use smart (default — connectivity fill, keeps plate-colored art) or flat (the plain ffmpeg chromakey).`);
|
|
6420
|
+
}
|
|
6421
|
+
const keyMode = keyModeRaw;
|
|
6422
|
+
// SHEET MODE. `zoned` puts every item on its OWN colored panel, so each item's
|
|
6423
|
+
// plate is chosen against that item — a green frog on magenta beside a pink
|
|
6424
|
+
// flower on green. `flat` is the classic ONE-color sheet. `auto` (default)
|
|
6425
|
+
// zones a named multi-item generation and stays flat otherwise, because a
|
|
6426
|
+
// weaker image model may not honor a color-block grid; if the model ignores it
|
|
6427
|
+
// anyway, keying falls back to a flat plate automatically (see below).
|
|
6428
|
+
const sheetModeRaw = (parsed.values["sheet-mode"] ?? "auto").trim().toLowerCase();
|
|
6429
|
+
if (!["auto", "zoned", "flat"].includes(sheetModeRaw)) {
|
|
6430
|
+
throw new Error(`Unknown --sheet-mode "${sheetModeRaw}". Use auto (default), zoned (one plate color per item — needs a model that follows a color-block grid), or flat (one plate color for the whole sheet).`);
|
|
6431
|
+
}
|
|
6432
|
+
if (sheetModeRaw === "zoned" && keyMode === "flat") {
|
|
6433
|
+
throw new Error("--sheet-mode zoned needs the smart keyer (a flat chromakey can only remove one color per sheet). Drop --key-mode flat, or use --sheet-mode flat.");
|
|
6434
|
+
}
|
|
6435
|
+
if (sheetModeRaw === "zoned" && !generatePrompt) {
|
|
6436
|
+
throw new Error("--sheet-mode zoned only applies when GENERATING a sheet (it plans the panel colors). For a zoned sheet you already have, pass --zones auto (the default) or --zones RxC.");
|
|
6437
|
+
}
|
|
6438
|
+
const wantZonedSheet = generatePrompt
|
|
6439
|
+
? sheetModeRaw === "zoned" || (sheetModeRaw === "auto" && keyMode === "smart" && itemNames.length >= 2 && !explicitKeyColor)
|
|
6440
|
+
: false;
|
|
6441
|
+
// ZONES: how to READ panels back off a sheet at key time. Independent of
|
|
6442
|
+
// sheet-mode, because a zoned sheet can also arrive from a web generator.
|
|
6443
|
+
const zonesRaw = (parsed.values.zones ?? "auto").trim().toLowerCase();
|
|
6444
|
+
let declaredGrid = null;
|
|
6445
|
+
if (!["auto", "off", "none"].includes(zonesRaw)) {
|
|
6446
|
+
const m = /^(\d+)\s*[x×]\s*(\d+)$/.exec(zonesRaw);
|
|
6447
|
+
if (!m)
|
|
6448
|
+
throw new Error(`Unknown --zones "${zonesRaw}". Use auto (default — read the panels off the sheet), off (one plate for the whole sheet), or a grid like 3x2 (rows x columns).`);
|
|
6449
|
+
declaredGrid = { rows: Number(m[1]), cols: Number(m[2]) };
|
|
6450
|
+
}
|
|
6451
|
+
const zonesOff = ["off", "none"].includes(zonesRaw);
|
|
6452
|
+
const refine = Boolean(parsed.values.refine);
|
|
6293
6453
|
// PLATE CHOICE. A chroma key removes exactly one color, so a plate the art
|
|
6294
6454
|
// also uses keys holes through the art — a pack of leaves/frogs/money on GREEN
|
|
6295
6455
|
// is the classic own-goal. When generating and the caller didn't pin a color,
|
|
6296
6456
|
// read the subject and move the plate off any hue it mentions.
|
|
6297
6457
|
let plateNote = null;
|
|
6298
6458
|
let plateAuto = false;
|
|
6299
|
-
|
|
6459
|
+
let zonePlan = null;
|
|
6460
|
+
if (wantZonedSheet) {
|
|
6461
|
+
// ZONED: no single plate to pick — every panel gets its own, chosen against
|
|
6462
|
+
// the item that sits on it.
|
|
6463
|
+
zonePlan = planZonedSheet(itemNames, itemNames.length, pickPlateColor, {
|
|
6464
|
+
theme: generatePrompt,
|
|
6465
|
+
cols: parsed.values["zone-cols"] !== undefined ? Math.max(1, Math.round(Number(parsed.values["zone-cols"]))) : undefined
|
|
6466
|
+
});
|
|
6467
|
+
plateAuto = true;
|
|
6468
|
+
plateNote =
|
|
6469
|
+
`Zoned sheet: ${zonePlan.rows}×${zonePlan.cols} panels, one plate color per item ` +
|
|
6470
|
+
`(${zonePlan.cells.map((c) => `${c.name ?? `item ${c.index}`} on ${c.keyColor}`).join(", ")}). ` +
|
|
6471
|
+
`Each item's art can use ANY color — including the colors of the other panels.`;
|
|
6472
|
+
}
|
|
6473
|
+
else if (generatePrompt && !explicitKeyColor && !parsed.values["no-auto-key"]) {
|
|
6300
6474
|
const picked = pickPlateColor([generatePrompt, itemNames.join(" ")].join(" "));
|
|
6301
6475
|
keyColor = picked.keyColor;
|
|
6302
6476
|
plateAuto = true;
|
|
@@ -6331,7 +6505,9 @@ async function runStickerPackCommand(argv) {
|
|
|
6331
6505
|
"back; this command then splits it for $0"
|
|
6332
6506
|
});
|
|
6333
6507
|
const refs = await resolveReferenceUrls(ctx, parsed.values.ref);
|
|
6334
|
-
const fullPrompt =
|
|
6508
|
+
const fullPrompt = zonePlan
|
|
6509
|
+
? `${generatePrompt}. ${zonedSheetInstruction(zonePlan, { theme: generatePrompt })}`
|
|
6510
|
+
: `${generatePrompt}. ${stickerSheetInstruction(keyColor, count, itemNames, keyMode)}`;
|
|
6335
6511
|
const payload = { prompt: fullPrompt };
|
|
6336
6512
|
// A sheet wants room to spread items out — square by default, not portrait.
|
|
6337
6513
|
payload.aspect_ratio = parsed.values["aspect-ratio"] ?? "1:1";
|
|
@@ -6344,8 +6520,11 @@ async function runStickerPackCommand(argv) {
|
|
|
6344
6520
|
const tracer = parsed.values.tracer ?? `devcli-stickerpack-${Date.now().toString(36)}`;
|
|
6345
6521
|
if (!ctx.json && plateNote)
|
|
6346
6522
|
console.log(`${YELLOW}Plate:${RESET} ${DIM}${plateNote}${RESET}`);
|
|
6347
|
-
if (!ctx.json)
|
|
6348
|
-
console.log(
|
|
6523
|
+
if (!ctx.json) {
|
|
6524
|
+
console.log(zonePlan
|
|
6525
|
+
? `${DIM}Generating ONE color-block sheet — ${zonePlan.rows}×${zonePlan.cols} panels, ${count} items, a different plate under each…${RESET}`
|
|
6526
|
+
: `${DIM}Generating ONE ${keyColor} plate holding ${count} items…${RESET}`);
|
|
6527
|
+
}
|
|
6349
6528
|
const submit = await apiRequest({ method: "POST", host: ctx.host, path: "/api/v1/primitives/images/generate", auth: ctx.auth, body: { tracer, payload } });
|
|
6350
6529
|
assertApiOk(submit, "sticker-pack generate");
|
|
6351
6530
|
const jobId = submit.json?.job_id;
|
|
@@ -6385,12 +6564,12 @@ async function runStickerPackCommand(argv) {
|
|
|
6385
6564
|
await downloadUrlToFile(sourceUrl, sheetPath);
|
|
6386
6565
|
}
|
|
6387
6566
|
}
|
|
6388
|
-
// ---- 2. Key the
|
|
6567
|
+
// ---- 2. Key the plate(s) ONCE → a transparent sheet ------------------------
|
|
6389
6568
|
// For a sheet we did NOT generate, read the plate off the image instead of
|
|
6390
6569
|
// assuming green: the four corners of a sticker sheet are always plate, so
|
|
6391
6570
|
// a user can hand back a red/purple/blue sheet from a web generator and the
|
|
6392
6571
|
// cut still works without them knowing what a chroma key is.
|
|
6393
|
-
if (!explicitKeyColor && !generatePrompt && !parsed.values["no-auto-key"]) {
|
|
6572
|
+
if (!explicitKeyColor && !generatePrompt && !parsed.values["no-auto-key"] && (keyMode === "flat" || zonesOff)) {
|
|
6394
6573
|
const detected = await detectPlateColor(sheetPath);
|
|
6395
6574
|
if (detected && detected.hex.toUpperCase() !== keyColor.toUpperCase()) {
|
|
6396
6575
|
keyColor = detected.hex;
|
|
@@ -6404,9 +6583,83 @@ async function runStickerPackCommand(argv) {
|
|
|
6404
6583
|
console.log(`${YELLOW}Plate:${RESET} ${DIM}${plateNote}${RESET}`);
|
|
6405
6584
|
}
|
|
6406
6585
|
const keyedSheet = path.join(work, "keyed-sheet.png");
|
|
6407
|
-
|
|
6408
|
-
|
|
6409
|
-
|
|
6586
|
+
let smart = null;
|
|
6587
|
+
let zonesUsed = [];
|
|
6588
|
+
let keyNote = null;
|
|
6589
|
+
if (keyMode === "flat") {
|
|
6590
|
+
if (!ctx.json)
|
|
6591
|
+
console.log(`${DIM}Keying out the ${presetRaw ?? keyColor} plate locally with ffmpeg chromakey (--key-mode flat, free)…${RESET}`);
|
|
6592
|
+
await removeGreenscreenLocal({ sourcePath: sheetPath, mediaType: "image", outputPath: keyedSheet, keyColor, tolerance, softness, despill });
|
|
6593
|
+
}
|
|
6594
|
+
else {
|
|
6595
|
+
// SMART. Zones come from (a) the grid we asked the model for, (b) a grid the
|
|
6596
|
+
// caller declared, (c) the sheet's own edges, or (d) one plate for the whole
|
|
6597
|
+
// sheet. Panels are keyed independently, each with its own color read back
|
|
6598
|
+
// off that panel's corners.
|
|
6599
|
+
const grid = declaredGrid ?? (zonePlan && !zonesOff ? { rows: zonePlan.rows, cols: zonePlan.cols } : null);
|
|
6600
|
+
if (!ctx.json) {
|
|
6601
|
+
console.log(grid || !zonesOff
|
|
6602
|
+
? `${DIM}Keying locally with the smart keyer (border-connected fill${grid ? `, ${grid.rows}×${grid.cols} panels` : ", auto zones"}, free)…${RESET}`
|
|
6603
|
+
: `${DIM}Keying the ${keyColor} plate locally with the smart keyer (border-connected fill, free)…${RESET}`);
|
|
6604
|
+
}
|
|
6605
|
+
smart = await smartKeyPlate({
|
|
6606
|
+
sourcePath: sheetPath,
|
|
6607
|
+
outputPath: keyedSheet,
|
|
6608
|
+
keyColor: zonesOff || explicitKeyColor ? keyColor : undefined,
|
|
6609
|
+
singlePlate: zonesOff,
|
|
6610
|
+
grid: grid ?? undefined,
|
|
6611
|
+
tolerance: parsed.values.tolerance !== undefined ? Number(parsed.values.tolerance) : undefined,
|
|
6612
|
+
softness: parsed.values.softness !== undefined ? Number(parsed.values.softness) : undefined,
|
|
6613
|
+
despill
|
|
6614
|
+
});
|
|
6615
|
+
// AUTOMATIC FALLBACK TO A FLAT PLATE. A weaker image model may ignore the
|
|
6616
|
+
// color-block grid and paint one background anyway (or paint a busy one).
|
|
6617
|
+
// Then some "panel" has almost nothing to key, and its item would come out
|
|
6618
|
+
// welded to a colored rectangle. Detect that and re-key the sheet as a
|
|
6619
|
+
// single plate, read off its corners — the simple method, still free.
|
|
6620
|
+
// Two ways a panel says "this sheet isn't the grid I asked for":
|
|
6621
|
+
// • it keyed almost NOTHING (25%) — the color read at its corners isn't a
|
|
6622
|
+
// plate, so the panel is really part of one big background or of the art;
|
|
6623
|
+
// • it keyed EVERYTHING — the item filled the panel corner to corner, so
|
|
6624
|
+
// the item's own color was read as the plate and the item was deleted.
|
|
6625
|
+
// The second only counts for a panel we expected to hold an item: a trailing
|
|
6626
|
+
// panel of a grid wider than the item list is legitimately all plate.
|
|
6627
|
+
const expectedItems = zonePlan ? zonePlan.cells.length : itemNames.length;
|
|
6628
|
+
const weakZone = smart.stats.perZone.find((z) => z.keyed_pct < 25 || (z.keyed_pct > 99.5 && expectedItems > 0 && z.zone <= expectedItems));
|
|
6629
|
+
if (smart.stats.zones.length > 1 && weakZone) {
|
|
6630
|
+
const detected = await detectPlateColor(sheetPath);
|
|
6631
|
+
keyColor = detected?.hex ?? keyColor;
|
|
6632
|
+
plateAuto = Boolean(detected);
|
|
6633
|
+
keyNote =
|
|
6634
|
+
`Panel ${weakZone.zone} (${weakZone.hex}) keyed ${weakZone.keyed_pct}% of itself — ` +
|
|
6635
|
+
`${weakZone.keyed_pct < 25 ? "there's no plate there" : "its item filled the panel corner to corner, so the item's own color read as the plate"}, ` +
|
|
6636
|
+
`i.e. the sheet isn't really a color-block grid, ` +
|
|
6637
|
+
`so it was re-keyed as ONE ${keyColor} plate${detected ? " (read off the sheet's corners)" : ""}. ` +
|
|
6638
|
+
`That's the simple path and it works; for zoned sheets the image model has to follow a color-block grid ` +
|
|
6639
|
+
`(try a stronger model, or pass --sheet-mode flat to skip zoning entirely).`;
|
|
6640
|
+
smart = await smartKeyPlate({
|
|
6641
|
+
sourcePath: sheetPath,
|
|
6642
|
+
outputPath: keyedSheet,
|
|
6643
|
+
keyColor,
|
|
6644
|
+
singlePlate: true,
|
|
6645
|
+
tolerance: parsed.values.tolerance !== undefined ? Number(parsed.values.tolerance) : undefined,
|
|
6646
|
+
softness: parsed.values.softness !== undefined ? Number(parsed.values.softness) : undefined,
|
|
6647
|
+
despill
|
|
6648
|
+
});
|
|
6649
|
+
zonePlan = null; // panel→name mapping is meaningless once we fall back
|
|
6650
|
+
if (!ctx.json)
|
|
6651
|
+
console.log(`${YELLOW}Zones:${RESET} ${DIM}${keyNote}${RESET}`);
|
|
6652
|
+
}
|
|
6653
|
+
zonesUsed = smart.stats.zones;
|
|
6654
|
+
if (zonesUsed.length === 1)
|
|
6655
|
+
keyColor = zonesUsed[0].hex;
|
|
6656
|
+
if (!ctx.json) {
|
|
6657
|
+
const zoneWord = zonesUsed.length > 1 ? `${zonesUsed.length} panels (${zonesUsed.map((z) => z.hex).join(", ")})` : `one ${keyColor} plate`;
|
|
6658
|
+
console.log(`${DIM}Keyed ${smart.stats.keyed_pct}% of the sheet — ${zoneWord}` +
|
|
6659
|
+
(smart.stats.protected_px ? `, kept ${smart.stats.protected_px.toLocaleString()} plate-colored pixel(s) INSIDE the art that a flat key would have punched out` : "") +
|
|
6660
|
+
`.${RESET}`);
|
|
6661
|
+
}
|
|
6662
|
+
}
|
|
6410
6663
|
if (parsed.values["keep-sheet"]) {
|
|
6411
6664
|
const keepPath = path.resolve(process.cwd(), String(parsed.values["keep-sheet"]));
|
|
6412
6665
|
mkdirSync(path.dirname(keepPath), { recursive: true });
|
|
@@ -6428,6 +6681,43 @@ async function runStickerPackCommand(argv) {
|
|
|
6428
6681
|
if (!seg.components.length) {
|
|
6429
6682
|
throw new Error("No items found in the sheet. Either the plate didn't key (check --preset/--key-color, raise --tolerance) or every item was filtered as speckle (lower --min-area).");
|
|
6430
6683
|
}
|
|
6684
|
+
// ---- 3a. Names, by PANEL when the sheet is zoned -------------------------
|
|
6685
|
+
// On a flat sheet, names can only be matched to items in reading order, which
|
|
6686
|
+
// goes off-by-one the moment two items merge or one splits. A zoned sheet
|
|
6687
|
+
// makes it exact: panel N holds the item we asked for in panel N, so an item's
|
|
6688
|
+
// name comes from whichever panel its box sits in.
|
|
6689
|
+
const zoneIndexOf = (c) => {
|
|
6690
|
+
if (zonesUsed.length < 2)
|
|
6691
|
+
return -1;
|
|
6692
|
+
const cx = c.x + c.width / 2;
|
|
6693
|
+
const cy = c.y + c.height / 2;
|
|
6694
|
+
return zonesUsed.findIndex((z) => cx >= z.x && cx < z.x + z.width && cy >= z.y && cy < z.y + z.height);
|
|
6695
|
+
};
|
|
6696
|
+
const nameOf = (c) => {
|
|
6697
|
+
if (zonePlan) {
|
|
6698
|
+
const zi = zoneIndexOf(c);
|
|
6699
|
+
if (zi >= 0)
|
|
6700
|
+
return zonePlan.cells.find((cell) => cell.index === zi + 1)?.name ?? null;
|
|
6701
|
+
}
|
|
6702
|
+
return itemNames[c.index - 1] ?? null;
|
|
6703
|
+
};
|
|
6704
|
+
// A panel that yielded NO sticker is worth saying out loud: either it was
|
|
6705
|
+
// empty (a grid wider than the item list — fine) or its item was keyed away
|
|
6706
|
+
// with the panel. The pack is still usable, so this warns rather than fails.
|
|
6707
|
+
let emptyPanelNote = null;
|
|
6708
|
+
if (zonesUsed.length > 1) {
|
|
6709
|
+
const empty = zonesUsed
|
|
6710
|
+
.map((_, i) => i + 1)
|
|
6711
|
+
.filter((panel) => !seg.components.some((c) => zoneIndexOf(c) === panel - 1));
|
|
6712
|
+
if (empty.length) {
|
|
6713
|
+
emptyPanelNote =
|
|
6714
|
+
`Panel(s) ${empty.join(", ")} produced no sticker. If those panels were meant to be empty, ignore this — otherwise the item ` +
|
|
6715
|
+
`there touched its panel's edges (so its own color read as the plate and it keyed away). Re-run with --zones off, or ` +
|
|
6716
|
+
`--key-color <hex> to pin the plate.`;
|
|
6717
|
+
if (!ctx.json)
|
|
6718
|
+
console.log(`${YELLOW}Panels:${RESET} ${DIM}${emptyPanelNote}${RESET}`);
|
|
6719
|
+
}
|
|
6720
|
+
}
|
|
6431
6721
|
// ---- 3b. Hollow-sticker check -------------------------------------------
|
|
6432
6722
|
// The other half of "did the key work": items that came back as a rim around
|
|
6433
6723
|
// a transparent hole, because the art was drawn as an outline (interior left
|
|
@@ -6437,14 +6727,24 @@ async function runStickerPackCommand(argv) {
|
|
|
6437
6727
|
// which is why this warns and never blocks.
|
|
6438
6728
|
let hollowNote = null;
|
|
6439
6729
|
if (seg.hollow.length) {
|
|
6440
|
-
const named = seg.hollow.map((i) =>
|
|
6441
|
-
|
|
6442
|
-
|
|
6443
|
-
|
|
6444
|
-
|
|
6445
|
-
|
|
6446
|
-
|
|
6447
|
-
|
|
6730
|
+
const named = seg.hollow.map((i) => {
|
|
6731
|
+
const c = seg.components.find((comp) => comp.index === i);
|
|
6732
|
+
const label = c ? nameOf(c) : null;
|
|
6733
|
+
return `${String(i).padStart(2, "0")}${label ? ` (${label})` : ""} — ${c?.hole_pct}% see-through`;
|
|
6734
|
+
});
|
|
6735
|
+
hollowNote = keyMode === "smart"
|
|
6736
|
+
// Under the smart keyer an ENCLOSED interior can't be keyed away, so a
|
|
6737
|
+
// hole here is either genuinely drawn art (a ring, a frame) or a gap that
|
|
6738
|
+
// the plate really did reach through — not the old "the key ate the fill".
|
|
6739
|
+
? `${seg.hollow.length} of ${seg.components.length} sticker(s) have large transparent areas inside them: ${named.join(", ")}. ` +
|
|
6740
|
+
`With the smart keyer a shape's ENCLOSED interior can't be keyed away, so this is most likely real: a ring, frame, donut, ` +
|
|
6741
|
+
`or an item drawn as separate pieces with plate showing between them. If it looks wrong, the art's fill is probably OPEN ` +
|
|
6742
|
+
`to the background (a gap in the outline lets the fill reach the sheet edge) — ask the prompt for closed, solidly filled shapes.`
|
|
6743
|
+
: `${seg.hollow.length} of ${seg.components.length} sticker(s) came out mostly hollow: ${named.join(", ")}. ` +
|
|
6744
|
+
`If those are meant to be rings/frames/donuts, ignore this. Otherwise the flat key ate their FILL: the art was drawn ` +
|
|
6745
|
+
`as an outline with a bare ${keyColor} interior, or filled in a near-${keyColor} shade. Either re-run without ` +
|
|
6746
|
+
`--key-mode flat (the smart keyer keeps enclosed interiors), or fix it in the PROMPT — ` +
|
|
6747
|
+
`"closed, solidly filled shapes, nothing on the art in ${keyColor} or any near-shade of it, fully opaque".`;
|
|
6448
6748
|
if (!ctx.json)
|
|
6449
6749
|
console.log(`${YELLOW}Hollow:${RESET} ${DIM}${hollowNote}${RESET}`);
|
|
6450
6750
|
}
|
|
@@ -6452,13 +6752,24 @@ async function runStickerPackCommand(argv) {
|
|
|
6452
6752
|
// Report the boxes without writing stickers — for eyeballing segmentation
|
|
6453
6753
|
// before spending disk, and for hand-fixing a merged item with `mask --crop`.
|
|
6454
6754
|
if (ctx.json) {
|
|
6455
|
-
printJson({
|
|
6755
|
+
printJson({
|
|
6756
|
+
ok: true, target: "local", dry_run: true, sheet: `${seg.sourceWidth}x${seg.sourceHeight}`,
|
|
6757
|
+
found: seg.components.length, skipped_specks: seg.rejected, key_mode: keyMode,
|
|
6758
|
+
key_color: keyColor, key_color_auto: plateAuto,
|
|
6759
|
+
zones: zonesUsed.map((z, i) => ({ panel: i + 1, x: z.x, y: z.y, width: z.width, height: z.height, plate: z.hex, keyed_pct: smart?.stats.perZone[i]?.keyed_pct ?? null })),
|
|
6760
|
+
protected_px: smart?.stats.protected_px ?? null,
|
|
6761
|
+
hollow: seg.hollow, hollow_note: hollowNote,
|
|
6762
|
+
items: seg.components.map((c) => ({ ...c, name: nameOf(c), panel: zoneIndexOf(c) >= 0 ? zoneIndexOf(c) + 1 : null }))
|
|
6763
|
+
});
|
|
6456
6764
|
}
|
|
6457
6765
|
else {
|
|
6458
6766
|
console.log(`${GREEN}Found ${seg.components.length} item${seg.components.length === 1 ? "" : "s"}${RESET} ${DIM}on the ${seg.sourceWidth}×${seg.sourceHeight} sheet${seg.rejected ? `, ${seg.rejected} speck(s) skipped` : ""} (dry run — nothing written):${RESET}`);
|
|
6459
6767
|
for (const c of seg.components) {
|
|
6460
6768
|
const hollowFlag = c.hole_pct >= HOLE_WARN_PCT ? ` ${YELLOW}⚠ ${c.hole_pct}% hollow${RESET}` : "";
|
|
6461
|
-
|
|
6769
|
+
const zi = zoneIndexOf(c);
|
|
6770
|
+
const where = zi >= 0 ? ` panel ${zi + 1} (${zonesUsed[zi].hex})` : "";
|
|
6771
|
+
const label = nameOf(c);
|
|
6772
|
+
console.log(` ${DIM}${String(c.index).padStart(2, "0")} crop ${c.x},${c.y},${c.width},${c.height} (${c.width}×${c.height}, ${c.area_pct}% of sheet)${where}${label ? ` ${label}` : ""}${RESET}${hollowFlag}`);
|
|
6462
6773
|
}
|
|
6463
6774
|
console.log(`${DIM}Merged two items into one box? Raise the gap between them in the prompt, lower --gap, or grab that one by hand: vidfarm mask <sheet> --crop x,y,w,h --flat "${keyColor}".${RESET}`);
|
|
6464
6775
|
}
|
|
@@ -6469,11 +6780,56 @@ async function runStickerPackCommand(argv) {
|
|
|
6469
6780
|
const padPx = parsed.values.pad !== undefined ? Number(parsed.values.pad) : 0;
|
|
6470
6781
|
const gifAlpha = parsed.values["gif-alpha"] !== undefined ? Number(parsed.values["gif-alpha"]) : undefined;
|
|
6471
6782
|
const written = [];
|
|
6783
|
+
if (refine && !ctx.json) {
|
|
6784
|
+
console.log(`${DIM}Refining each item with the local ONNX matting model (free, a second or two each) — this is the path for painterly/soft-edged art…${RESET}`);
|
|
6785
|
+
}
|
|
6472
6786
|
for (const c of seg.components) {
|
|
6473
|
-
const
|
|
6787
|
+
const itemName = nameOf(c);
|
|
6788
|
+
const label = itemName ? slugifyStickerName(itemName) : "";
|
|
6474
6789
|
const stem = `${prefix}-${String(c.index).padStart(2, "0")}${label ? `-${label}` : ""}`;
|
|
6475
6790
|
const regionPath = path.join(work, `${stem}.region.png`);
|
|
6476
|
-
|
|
6791
|
+
let refined = false;
|
|
6792
|
+
if (refine) {
|
|
6793
|
+
// REFINE. The keyer decides WHERE each item is; matting decides what its
|
|
6794
|
+
// alpha should be. Cropping the item out of the ORIGINAL (un-keyed) sheet
|
|
6795
|
+
// and running the ONNX matte on it is color-blind, so it handles the art a
|
|
6796
|
+
// chroma key can't: soft/painterly edges, fur, glows, glass, drop shadows,
|
|
6797
|
+
// and an item whose own silhouette is the plate color. A little margin
|
|
6798
|
+
// gives the model context to separate against.
|
|
6799
|
+
const margin = Math.round(Math.max(c.width, c.height) * 0.08);
|
|
6800
|
+
const rawRegion = path.join(work, `${stem}.raw.png`);
|
|
6801
|
+
await cropImageRegion({
|
|
6802
|
+
sourcePath: sheetPath,
|
|
6803
|
+
outputPath: rawRegion,
|
|
6804
|
+
x: Math.max(0, c.x - margin),
|
|
6805
|
+
y: Math.max(0, c.y - margin),
|
|
6806
|
+
width: c.width + margin * 2,
|
|
6807
|
+
height: c.height + margin * 2
|
|
6808
|
+
});
|
|
6809
|
+
const matted = path.join(work, `${stem}.matte.png`);
|
|
6810
|
+
const run = await runHyperframesCommand("remove-background", [rawRegion, "-o", matted], { stdio: "capture" });
|
|
6811
|
+
// Trust the matte only if it looks like a cut-out. The model is trained on
|
|
6812
|
+
// photographic subjects, so on flat vector art it can return an EMPTY
|
|
6813
|
+
// matte (blank sticker) or an untouched one (plate still attached) — both
|
|
6814
|
+
// of which write a file and exit 0. The keyed cut is the safer answer then.
|
|
6815
|
+
const opaqueShare = run.code === 0 && existsSync(matted) ? await measureOpaqueShare(matted) : null;
|
|
6816
|
+
const usable = opaqueShare !== null && opaqueShare >= 5 && opaqueShare <= 95;
|
|
6817
|
+
if (usable) {
|
|
6818
|
+
copyFileSync(matted, regionPath);
|
|
6819
|
+
refined = true;
|
|
6820
|
+
}
|
|
6821
|
+
else if (!ctx.json) {
|
|
6822
|
+
const why = run.code !== 0
|
|
6823
|
+
? (run.stderr.trim().split("\n").pop() ?? "the matting engine failed")
|
|
6824
|
+
: opaqueShare === null
|
|
6825
|
+
? "the matte had no alpha"
|
|
6826
|
+
: `the matte came back ${opaqueShare < 5 ? "empty" : "fully opaque"} (${opaqueShare}% opaque) — the model didn't see a subject in this art`;
|
|
6827
|
+
console.log(` ${YELLOW}↳${RESET} ${DIM}item ${c.index}: kept the keyed cut instead of the matte — ${why}.${RESET}`);
|
|
6828
|
+
}
|
|
6829
|
+
}
|
|
6830
|
+
if (!refined) {
|
|
6831
|
+
await cropImageRegion({ sourcePath: keyedSheet, outputPath: regionPath, x: c.x, y: c.y, width: c.width, height: c.height });
|
|
6832
|
+
}
|
|
6477
6833
|
// Trim to the item's TRUE box: segmentation works on a downscaled alpha, so
|
|
6478
6834
|
// the crop carries a pixel or two of slack — this is what makes the sticker's
|
|
6479
6835
|
// pixel size equal the subject, the property placement/scaling relies on.
|
|
@@ -6484,13 +6840,17 @@ async function runStickerPackCommand(argv) {
|
|
|
6484
6840
|
finalPath = path.join(outDir, `${stem}.gif`);
|
|
6485
6841
|
await encodeTransparentGif(trim.outputPath, finalPath, { alphaThreshold: gifAlpha });
|
|
6486
6842
|
}
|
|
6843
|
+
const zi = zoneIndexOf(c);
|
|
6487
6844
|
written.push({
|
|
6488
6845
|
index: c.index,
|
|
6489
|
-
name:
|
|
6846
|
+
name: itemName,
|
|
6490
6847
|
file: finalPath,
|
|
6491
6848
|
width: trim.width,
|
|
6492
6849
|
height: trim.height,
|
|
6493
6850
|
sheet_crop: { x: c.x, y: c.y, width: c.width, height: c.height },
|
|
6851
|
+
panel: zi >= 0 ? zi + 1 : null,
|
|
6852
|
+
plate: zi >= 0 ? zonesUsed[zi].hex : keyColor,
|
|
6853
|
+
refined,
|
|
6494
6854
|
area_pct: c.area_pct,
|
|
6495
6855
|
holes: c.holes,
|
|
6496
6856
|
hole_pct: c.hole_pct,
|
|
@@ -6505,10 +6865,22 @@ async function runStickerPackCommand(argv) {
|
|
|
6505
6865
|
// A manifest so the next step (place/keyframes, or an agent picking props by
|
|
6506
6866
|
// name) doesn't have to re-derive what's in the pack.
|
|
6507
6867
|
const manifestPath = path.join(outDir, "stickers.json");
|
|
6868
|
+
const keyReport = {
|
|
6869
|
+
key_mode: keyMode,
|
|
6870
|
+
sheet_mode: zonesUsed.length > 1 ? "zoned" : "flat",
|
|
6871
|
+
zones: zonesUsed.map((z, i) => ({ panel: i + 1, x: z.x, y: z.y, width: z.width, height: z.height, plate: z.hex, keyed_pct: smart?.stats.perZone[i]?.keyed_pct ?? null })),
|
|
6872
|
+
keyed_pct: smart?.stats.keyed_pct ?? null,
|
|
6873
|
+
protected_px: smart?.stats.protected_px ?? null,
|
|
6874
|
+
protected_pct: smart?.stats.protected_pct ?? null,
|
|
6875
|
+
key_note: keyNote,
|
|
6876
|
+
empty_panel_note: emptyPanelNote,
|
|
6877
|
+
refined: refine
|
|
6878
|
+
};
|
|
6508
6879
|
writeFileSync(manifestPath, `${JSON.stringify({
|
|
6509
6880
|
generated_from: generatePrompt ? "generate" : sourceArg,
|
|
6510
6881
|
key_color: keyColor,
|
|
6511
6882
|
key_color_auto: plateAuto,
|
|
6883
|
+
...keyReport,
|
|
6512
6884
|
hollow: seg.hollow,
|
|
6513
6885
|
hollow_note: hollowNote,
|
|
6514
6886
|
sheet_width: seg.sourceWidth,
|
|
@@ -6518,12 +6890,14 @@ async function runStickerPackCommand(argv) {
|
|
|
6518
6890
|
stickers: written
|
|
6519
6891
|
}, null, 2)}\n`);
|
|
6520
6892
|
if (ctx.json) {
|
|
6521
|
-
printJson({ ok: true, target: "local", out_dir: outDir, manifest: manifestPath, count: written.length, skipped_specks: seg.rejected, key_color: keyColor, key_color_auto: plateAuto, key_color_note: plateNote, hollow: seg.hollow, hollow_note: hollowNote, stickers: written });
|
|
6893
|
+
printJson({ ok: true, target: "local", out_dir: outDir, manifest: manifestPath, count: written.length, skipped_specks: seg.rejected, key_color: keyColor, key_color_auto: plateAuto, key_color_note: plateNote, ...keyReport, hollow: seg.hollow, hollow_note: hollowNote, stickers: written });
|
|
6522
6894
|
}
|
|
6523
6895
|
else {
|
|
6524
6896
|
console.log(`${GREEN}Sticker pack ready:${RESET} ${written.length} transparent sticker${written.length === 1 ? "" : "s"} in ${outDir} ${DIM}(manifest: stickers.json${seg.rejected ? `, ${seg.rejected} speck(s) skipped` : ""})${RESET}`);
|
|
6525
|
-
|
|
6526
|
-
|
|
6897
|
+
// Names are only guessed by reading order on a FLAT sheet; a zoned sheet
|
|
6898
|
+
// pins each name to its panel, so the off-by-one caveat doesn't apply.
|
|
6899
|
+
if (itemNames.length && itemNames.length !== written.length && !zonePlan) {
|
|
6900
|
+
console.log(`${YELLOW}Note:${RESET} ${DIM}you named ${itemNames.length} item(s) but ${written.length} were cut, so names may be off-by-one. Re-run with --dry-run to inspect the boxes; two touching items segment as ONE (lower --gap or ask the generator for wider spacing), and one item split in two segments as TWO (raise --gap). A zoned sheet (--sheet-mode zoned) names items by PANEL instead of by order, which can't drift.${RESET}`);
|
|
6527
6901
|
}
|
|
6528
6902
|
console.log(`${DIM}Place one: vidfarm place <dir> --src "${written[0]?.file}" --kind image. Animate it (pop-in/float/shake/drift) with vidfarm keyframes — see the skill's "Cutout graphics for explainers" recipe.${RESET}`);
|
|
6529
6903
|
}
|
|
@@ -8861,6 +9235,7 @@ async function runHandoffCommand(argv) {
|
|
|
8861
9235
|
"out-dir": { type: "string" },
|
|
8862
9236
|
grid: { type: "string" },
|
|
8863
9237
|
single: { type: "boolean", default: false },
|
|
9238
|
+
zoned: { type: "boolean", default: false },
|
|
8864
9239
|
// raws
|
|
8865
9240
|
keywords: { type: "string" },
|
|
8866
9241
|
platforms: { type: "string" },
|
|
@@ -8883,7 +9258,8 @@ async function runHandoffCommand(argv) {
|
|
|
8883
9258
|
keyColor: parsed.values["key-color"],
|
|
8884
9259
|
outDir: parsed.values["out-dir"],
|
|
8885
9260
|
grid: parsed.values.grid,
|
|
8886
|
-
pack: parsed.values.single ? false : undefined
|
|
9261
|
+
pack: parsed.values.single ? false : undefined,
|
|
9262
|
+
zoned: Boolean(parsed.values.zoned)
|
|
8887
9263
|
});
|
|
8888
9264
|
if (json)
|
|
8889
9265
|
return printJson({ ok: true, kind: "image", pack: !parsed.values.single, ...brief });
|