@officexapp/vidfarm-devcli 0.21.35 → 0.21.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 greenscreen sheet:
383
- key the flat plate once, auto-find each item in the
384
- alpha channel, and cut every one into its own snug
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. Prompts for key-safe art (solid fills, no
389
- outline-only/hollow shapes, nothing in a near-plate
390
- shade) and flags any item that came out hollow.
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; never a DM funnel, "follow for part two", or ragebait. Then build the timeline. Re-theming a decomposed template: viral_dna already names the source's hook/retention/payoff — rebuild each charge for the new subject, never flatten the loop into a product statement. Full craft harness: the vidfarm skill's references/hooks-and-virality.md. Checkable form: \`vidfarm harness show hooks\`.
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
- if (!ctx.json)
5368
- console.log(`${DIM}Keying out ${presetRaw ?? keyColor ?? "green"} background locally with ffmpeg (free)…${RESET}`);
5369
- let result = await removeGreenscreenLocal({ sourcePath, mediaType, outputPath: keyTargetPath, keyColor, tolerance, softness, despill });
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
- // The plate color is only half the battle art drawn as a hollow outline
5789
- // (or in a near-plate shade) gets its middle keyed away too.
5790
- keySafeArtInstruction(keyColorHex));
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
- if (!ctx.json)
5944
- console.log(`${DIM}Keying out the ${presetRaw ?? keyColor} background locally with ffmpeg (free)…${RESET}`);
5945
- await removeGreenscreenLocal({ sourcePath, mediaType: "image", outputPath: keyedPath, keyColor, tolerance, softness, despill });
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
- ? `${art.hole_pct}% of this cutout is transparent HOLES inside the art. If it's meant to be a ring/frame/donut, ignore this. ` +
5973
- `Otherwise the key ate the fill: the art was drawn as an outline with a bare ${keyColor} interior, or filled in a near-${keyColor} shade. ` +
5974
- `Fix it in the PROMPT ask for "a closed, solidly filled shape, no outline-only or hollow art, nothing in ${keyColor} or any near-shade of it, ` +
5975
- `fully opaque, no translucency or glow" and re-generate. (--tolerance lower can rescue a near-shade fill; nothing rescues an empty one.)`
5976
- : null;
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 wayor 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
- keySafeArtInstruction(keyColorHex));
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
- if (generatePrompt && !explicitKeyColor && !parsed.values["no-auto-key"]) {
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 = `${generatePrompt}. ${stickerSheetInstruction(keyColor, count, itemNames)}`;
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(`${DIM}Generating ONE ${keyColor} plate holding ${count} items…${RESET}`);
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 flat plate ONCE → a transparent sheet ---------------------
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
- if (!ctx.json)
6408
- console.log(`${DIM}Keying out the ${presetRaw ?? keyColor} plate locally with ffmpeg (free)…${RESET}`);
6409
- await removeGreenscreenLocal({ sourcePath: sheetPath, mediaType: "image", outputPath: keyedSheet, keyColor, tolerance, softness, despill });
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) => `${String(i).padStart(2, "0")}${itemNames[i - 1] ? ` (${itemNames[i - 1]})` : ""} — ${seg.components.find((c) => c.index === i)?.hole_pct}% see-through`);
6441
- hollowNote =
6442
- `${seg.hollow.length} of ${seg.components.length} sticker(s) came out mostly hollow: ${named.join(", ")}. ` +
6443
- `If those are meant to be rings/frames/donuts, ignore this. Otherwise the key ate their FILL: the art was drawn ` +
6444
- `as an outline with a bare ${keyColor} interior, or filled in a near-${keyColor} shade. Fix it in the PROMPT, not the keyer — ` +
6445
- `ask for "closed, solidly filled shapes, no outline-only or hollow objects, nothing on the art in ${keyColor} or any ` +
6446
- `near-shade of it, fully opaque, no translucency or glow" then re-generate the sheet. ` +
6447
- `(Lowering --tolerance can rescue a near-shade fill from an existing sheet, but not a genuinely empty one.)`;
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({ ok: true, target: "local", dry_run: true, sheet: `${seg.sourceWidth}x${seg.sourceHeight}`, found: seg.components.length, skipped_specks: seg.rejected, key_color: keyColor, key_color_auto: plateAuto, hollow: seg.hollow, hollow_note: hollowNote, items: seg.components });
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
- console.log(` ${DIM}${String(c.index).padStart(2, "0")} crop ${c.x},${c.y},${c.width},${c.height} (${c.width}×${c.height}, ${c.area_pct}% of sheet)${RESET}${hollowFlag}`);
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 label = itemNames[c.index - 1] ? slugifyStickerName(itemNames[c.index - 1]) : "";
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
- await cropImageRegion({ sourcePath: keyedSheet, outputPath: regionPath, x: c.x, y: c.y, width: c.width, height: c.height });
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: itemNames[c.index - 1] ?? null,
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
- if (itemNames.length && itemNames.length !== written.length) {
6526
- console.log(`${YELLOW}Note:${RESET} ${DIM}you named ${itemNames.length} item(s) but ${written.length} were cut, so names may be off-by-one. Re-run with --dry-run to inspect the boxes; two touching items segment as ONE (lower --gap or ask the generator for wider spacing), and one item split in two segments as TWO (raise --gap).${RESET}`);
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 });