@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.
@@ -0,0 +1,698 @@
1
+ // Connectivity-aware plate keying ("smart key") + MULTI-ZONE plates.
2
+ //
3
+ // ── The limitation this removes ───────────────────────────────────────────────
4
+ // A flat chroma key (`chromakey` in ffmpeg, `removeGreenscreenLocal`) deletes
5
+ // every pixel that *looks like* the key color, wherever it is. That single rule
6
+ // is what has been forcing sticker art to stay simple and flat:
7
+ //
8
+ // • nothing on the art may be the plate color → no green leaves on a green
9
+ // sheet, no magenta candy on a magenta sheet;
10
+ // • outline / line-art shapes come back as a rim around a see-through hole,
11
+ // because their interior was left as bare plate;
12
+ // • one plate color per SHEET means the whole pack shares one banned hue, so
13
+ // the more items a sheet holds, the more of the palette is off-limits.
14
+ //
15
+ // None of that is inherent to keying — it's an artifact of treating "matches the
16
+ // key color" as "is background". The background of a sticker sheet has a much
17
+ // stronger, purely geometric definition: it is the plate region that REACHES THE
18
+ // EDGE OF THE SHEET. A green pixel in the middle of a mascot's eye does not, so
19
+ // it isn't background, so it must survive.
20
+ //
21
+ // So this module keys by CONNECTIVITY, not by color alone:
22
+ // 1. score every pixel's distance to its plate color (chroma-only for
23
+ // chromatic plates, so lighting/gradient on the plate still keys);
24
+ // 2. flood-fill inward from the sheet border through plate-ish pixels;
25
+ // 3. only what the fill REACHES becomes transparent, with a soft ramp across
26
+ // the loose band at the boundary;
27
+ // 4. unpremultiply the plate out of every partial-alpha pixel (exact math,
28
+ // per-pixel) instead of running a global `despill` that discolors the art.
29
+ //
30
+ // The direct consequences: plate-colored art interiors survive, hollow/outline
31
+ // art keeps its interior, soft edges and glows feather out to semi-transparency
32
+ // instead of leaving a hard halo, and the art may use ANY color as long as it
33
+ // doesn't bleed into the plate at its own silhouette.
34
+ //
35
+ // ── And then: one plate color per STICKER, not per sheet ──────────────────────
36
+ // The remaining constraint is the silhouette edge: an item whose OUTER edge is
37
+ // the plate color still dissolves into it. That's fixed by dropping the "one
38
+ // plate per sheet" assumption — a sheet is laid out as a grid of colored PANELS,
39
+ // each item on its own panel, each panel a plate color chosen against that
40
+ // item's own colors. A green frog sits on magenta while the pink flower next to
41
+ // it sits on green. `detectPlateZones` recovers that grid from the sheet itself
42
+ // (the panel colors change along the sheet's own edges), so a zoned sheet from a
43
+ // free web generator works the same as one we planned.
44
+ //
45
+ // Bundle-safe: ffmpeg (raw pixel in, raw pixel out) + typed arrays. No backend
46
+ // import, no network, no native dependency.
47
+ import { spawn } from "node:child_process";
48
+ import { existsSync } from "node:fs";
49
+ import { resolveFfmpeg } from "../services/clip-curation/ffmpeg.js";
50
+ import { probeImageDimensions } from "./greenscreen-local.js";
51
+ export function parseHexColor(value) {
52
+ const hex = /^#?([0-9a-f]{6})$/i.exec(value.trim());
53
+ if (!hex) {
54
+ const named = {
55
+ green: [0, 255, 0], lime: [0, 255, 0], blue: [0, 71, 187], magenta: [255, 0, 255],
56
+ white: [255, 255, 255], black: [0, 0, 0], red: [255, 0, 0], cyan: [0, 255, 255], yellow: [255, 255, 0]
57
+ };
58
+ const hit = named[value.trim().toLowerCase()];
59
+ if (hit)
60
+ return hit;
61
+ throw new Error(`Can't read "${value}" as a color — use a hex like #00FF00.`);
62
+ }
63
+ const n = parseInt(hex[1], 16);
64
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
65
+ }
66
+ export function hexOf(rgb) {
67
+ return `#${rgb.map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, "0")).join("").toUpperCase()}`;
68
+ }
69
+ /** True when a color has enough hue to be keyed on CHROMA alone. Grey/white/
70
+ * black plates carry no chroma, so they have to be matched on full RGB. */
71
+ export function isChromatic(rgb) {
72
+ return Math.max(...rgb) - Math.min(...rgb) >= 40;
73
+ }
74
+ /** Normalized chromaticity (r, g) — the color's ratio, with intensity divided
75
+ * out. This is what makes the match brightness-invariant: #00FF00 and a shaded
76
+ * #008C00 land on the same point, so an unevenly-lit plate still keys. (ffmpeg's
77
+ * chromakey compares raw Cb/Cr instead, which is why IT struggles with a dark
78
+ * or vignetted greenscreen.) */
79
+ function chromaticity(r, g, b) {
80
+ const sum = r + g + b;
81
+ if (sum <= 0)
82
+ return [1 / 3, 1 / 3];
83
+ return [r / sum, g / sum];
84
+ }
85
+ /**
86
+ * Distance from a pixel to the plate color, normalized so ~0 is an exact match
87
+ * and 1 is "nothing like it".
88
+ *
89
+ * Chromatic plates are matched on CHROMATICITY only, so plate brightness,
90
+ * gradients, vignetting and the art's own bounce light don't matter. Achromatic
91
+ * plates (white/black/grey) have no chromaticity to compare — every grey sits at
92
+ * the same point — so they fall back to full RGB distance, which is also why they
93
+ * need a much tighter tolerance.
94
+ */
95
+ export function plateDistance(r, g, b, plate, chromaticPlate) {
96
+ if (chromaticPlate) {
97
+ // Very dark pixels have unstable chromaticity (a rounding error swings the
98
+ // ratio), so never let near-black masquerade as a lit plate.
99
+ if (r + g + b < 24 && plate[0] + plate[1] + plate[2] >= 60)
100
+ return 1;
101
+ const [pr, pg] = chromaticity(plate[0], plate[1], plate[2]);
102
+ const [cr, cg] = chromaticity(r, g, b);
103
+ return Math.min(1, Math.sqrt((cr - pr) * (cr - pr) + (cg - pg) * (cg - pg)) / 0.9);
104
+ }
105
+ const dr = r - plate[0];
106
+ const dg = g - plate[1];
107
+ const db = b - plate[2];
108
+ return Math.sqrt(dr * dr + dg * dg + db * db) / 441.673;
109
+ }
110
+ /**
111
+ * Estimate how opaque a boundary pixel really is (its alpha "coverage"), by
112
+ * unmixing the plate out of it rather than by how far its color drifted.
113
+ *
114
+ * A boundary pixel is `C = α·art + (1-α)·plate`, with `art` unknown. Two bounds
115
+ * fall straight out of `0 ≤ art ≤ 255`:
116
+ *
117
+ * • plate's BRIGHT channels: the plate can only ADD to such a channel, so
118
+ * `C_c ≥ (1-α)·plate_c` ⟹ `α ≥ 1 - C_c/plate_c`. The tightest (smallest) of
119
+ * those readings is the best estimate of coverage — on green, a 90 %-plate
120
+ * pixel reads 0.10 and a 50/50 blend reads 0.42.
121
+ * • plate's DARK channels give a lower bound the art alone must account for:
122
+ * `C_d = α·art_d ≤ α·255` ⟹ `α ≥ C_d/255`. This is the guard that stops the
123
+ * estimate eroding real art — white art on green reads α ≥ 1 and stays solid.
124
+ *
125
+ * The distance ramp is kept as a third, coarse upper reading so a pixel that
126
+ * isn't plate-like at all (black art on green) can never be dragged transparent.
127
+ */
128
+ export function coverage(rgb, offset, plate, dist, inner, outer) {
129
+ const c = [rgb[offset], rgb[offset + 1], rgb[offset + 2]];
130
+ // Coarse ramp on plate distance.
131
+ let upper = Math.max(0, Math.min(1, (dist - inner) / Math.max(1e-6, outer - inner)));
132
+ // Tightest bright-channel reading.
133
+ for (let i = 0; i < 3; i++) {
134
+ if (plate[i] < 32)
135
+ continue;
136
+ upper = Math.min(upper, Math.max(0, 1 - c[i] / plate[i]));
137
+ }
138
+ // Dark-channel floor: whatever the plate can't explain must be art.
139
+ let floor = 0;
140
+ for (let i = 0; i < 3; i++) {
141
+ if (plate[i] >= 32)
142
+ continue;
143
+ floor = Math.max(floor, c[i] / 255);
144
+ }
145
+ return Math.max(0, Math.min(1, Math.max(upper, floor)));
146
+ }
147
+ /**
148
+ * Turn an observed boundary color back into the ART color for a given coverage:
149
+ * `C = α·art + (1-α)·plate` ⟹ `art = (C - (1-α)·plate)/α`. PNG/WebP store
150
+ * straight (unassociated) alpha, so writing the art color with alpha α makes the
151
+ * composite exact — no plate fringe.
152
+ *
153
+ * Strictly better than ffmpeg's global `despill`, which rebalances EVERY pixel
154
+ * and so discolors plate-hued art in the interior; this only ever touches pixels
155
+ * that are actually part-plate.
156
+ */
157
+ function unpremultiplyPlate(rgba, offset, plate, alpha) {
158
+ if (alpha <= 0 || alpha >= 1)
159
+ return;
160
+ for (let c = 0; c < 3; c++) {
161
+ const v = (rgba[offset + c] - (1 - alpha) * plate[c]) / alpha;
162
+ rgba[offset + c] = Math.max(0, Math.min(255, Math.round(v)));
163
+ }
164
+ }
165
+ // Defaults for THIS keyer. Deliberately separate from GREENSCREEN_PRESETS: those
166
+ // numbers are `chromakey`'s similarity/blend, on a different metric. The band is
167
+ // generous on purpose — inside a border-connected fill a wide band only buys
168
+ // softer edges (and feathers glows), it can no longer eat the art's interior.
169
+ export const SMART_KEY_DEFAULTS = {
170
+ chromatic: { tolerance: 0.16, softness: 0.2 },
171
+ achromatic: { tolerance: 0.055, softness: 0.08 }
172
+ };
173
+ export function defaultsForPlate(plate) {
174
+ return isChromatic(plate) ? { ...SMART_KEY_DEFAULTS.chromatic } : { ...SMART_KEY_DEFAULTS.achromatic };
175
+ }
176
+ /** A zone that covers a whole sheet — the "plain single-plate sheet" case. */
177
+ export function singleZone(width, height, hex) {
178
+ return { x: 0, y: 0, width, height, hex: hex.toUpperCase(), rgb: parseHexColor(hex) };
179
+ }
180
+ /**
181
+ * Recover a panel grid from the sheet itself.
182
+ *
183
+ * A zoned sheet advertises its own layout along its edges: walk the top edge and
184
+ * the panel colors change at every column boundary; walk the left edge and they
185
+ * change at every row boundary. So segment both edges into runs of flat color
186
+ * and take the boundaries. Two guards keep art from faking a boundary:
187
+ *
188
+ * • a run must be at least `minRunPct` of the edge, and
189
+ * • a boundary must appear on BOTH opposite edges (top AND bottom for columns,
190
+ * left AND right for rows) — art can touch one edge at a given offset, but
191
+ * almost never both at the same offset.
192
+ *
193
+ * Returns a single full-sheet zone when the sheet is a plain one-color plate
194
+ * (the common case), or null when the edges are too busy to read at all.
195
+ */
196
+ export function detectZoneGrid(rgb, w, h, opts = {}) {
197
+ const tol = opts.tolerance ?? 26;
198
+ const minRunPct = opts.minRunPct ?? 8;
199
+ const band = Math.max(1, Math.round(Math.min(w, h) * 0.01));
200
+ const at = (x, y) => {
201
+ const i = (y * w + x) * 3;
202
+ return [rgb[i], rgb[i + 1], rgb[i + 2]];
203
+ };
204
+ /** Mean color of a small patch, so JPEG noise doesn't split a run. */
205
+ const patch = (x, y, sw, sh) => {
206
+ let r = 0, g = 0, b = 0, n = 0;
207
+ for (let yy = Math.max(0, y); yy < Math.min(h, y + sh); yy++) {
208
+ for (let xx = Math.max(0, x); xx < Math.min(w, x + sw); xx++) {
209
+ const c = at(xx, yy);
210
+ r += c[0];
211
+ g += c[1];
212
+ b += c[2];
213
+ n++;
214
+ }
215
+ }
216
+ return n ? [r / n, g / n, b / n] : [0, 0, 0];
217
+ };
218
+ const near = (a, b) => Math.max(Math.abs(a[0] - b[0]), Math.abs(a[1] - b[1]), Math.abs(a[2] - b[2])) <= tol;
219
+ /** Split one edge into runs of flat color; returns the run start offsets. */
220
+ const runsAlong = (axis, offset) => {
221
+ const span = axis === "x" ? w : h;
222
+ const minRun = Math.max(2, Math.round((minRunPct / 100) * span));
223
+ const step = Math.max(1, Math.round(span / 256)); // ~256 samples is plenty
224
+ // Sample ONE pixel across the axis being walked (averaging only across the
225
+ // other axis): a patch that straddles a panel seam averages the two panel
226
+ // colors and reads as a third "run".
227
+ const sample = (p) => (axis === "x" ? patch(p, offset, 1, band) : patch(offset, p, band, 1));
228
+ const starts = [0];
229
+ let runColor = sample(0);
230
+ let runStart = 0;
231
+ for (let p = step; p < span; p += step) {
232
+ const c = sample(p);
233
+ if (near(c, runColor))
234
+ continue;
235
+ // A change only counts once the PREVIOUS run was long enough to be a panel;
236
+ // otherwise it's art brushing the edge and we stay in the current run.
237
+ if (p - runStart >= minRun) {
238
+ starts.push(p);
239
+ runStart = p;
240
+ // Re-read the new run's color a little past the seam, clear of any
241
+ // antialiasing on the boundary itself.
242
+ runColor = sample(Math.min(span - 1, p + Math.max(1, band)));
243
+ }
244
+ }
245
+ // Drop a trailing sliver (art at the far edge, or a 1px border artifact).
246
+ while (starts.length > 1 && span - starts[starts.length - 1] < minRun)
247
+ starts.pop();
248
+ return starts;
249
+ };
250
+ /** Keep only boundaries that BOTH opposite edges agree on. */
251
+ const agree = (a, b, span) => {
252
+ const slack = Math.max(4, Math.round(span * 0.04));
253
+ const out = [0];
254
+ for (const v of a.slice(1)) {
255
+ const match = b.slice(1).find((u) => Math.abs(u - v) <= slack);
256
+ if (match !== undefined)
257
+ out.push(Math.round((v + match) / 2));
258
+ }
259
+ return out;
260
+ };
261
+ const inset = band;
262
+ const colStarts = agree(runsAlong("x", inset), runsAlong("x", h - 1 - inset), w);
263
+ const rowStarts = agree(runsAlong("y", inset), runsAlong("y", w - 1 - inset), h);
264
+ const zones = [];
265
+ for (let ri = 0; ri < rowStarts.length; ri++) {
266
+ const y0 = rowStarts[ri];
267
+ const y1 = ri + 1 < rowStarts.length ? rowStarts[ri + 1] : h;
268
+ for (let ci = 0; ci < colStarts.length; ci++) {
269
+ const x0 = colStarts[ci];
270
+ const x1 = ci + 1 < colStarts.length ? colStarts[ci + 1] : w;
271
+ const zw = x1 - x0;
272
+ const zh = y1 - y0;
273
+ if (zw < 8 || zh < 8)
274
+ return null;
275
+ // Read the panel's plate color from ITS OWN four corners — the art lives in
276
+ // the middle, so corners are plate. The median of the four survives one
277
+ // corner being clipped by a wide item.
278
+ const p = Math.max(2, Math.round(Math.min(zw, zh) * 0.02));
279
+ const s = Math.max(3, Math.round(Math.min(zw, zh) * 0.03));
280
+ const corners = [
281
+ patch(x0 + p, y0 + p, s, s),
282
+ patch(x1 - p - s, y0 + p, s, s),
283
+ patch(x0 + p, y1 - p - s, s, s),
284
+ patch(x1 - p - s, y1 - p - s, s, s)
285
+ ];
286
+ const median = [0, 1, 2].map((ch) => {
287
+ const vals = corners.map((c) => c[ch]).sort((a, b) => a - b);
288
+ return (vals[1] + vals[2]) / 2;
289
+ });
290
+ // At least three corners must look like that median, or this panel has no
291
+ // readable plate and guessing one would key holes through the art.
292
+ const votes = corners.filter((c) => near(c, median)).length;
293
+ if (votes < 3)
294
+ return null;
295
+ zones.push({ x: x0, y: y0, width: zw, height: zh, hex: hexOf(median), rgb: [Math.round(median[0]), Math.round(median[1]), Math.round(median[2])] });
296
+ }
297
+ }
298
+ return zones.length ? zones : null;
299
+ }
300
+ /**
301
+ * Compute a straight (unassociated) RGBA image from a sheet's RGB pixels by
302
+ * flood-filling the plate inward from each zone's border.
303
+ *
304
+ * PURE: takes and returns typed arrays, so the whole decision surface is
305
+ * testable without ffmpeg. `rgb` is w*h*3 bytes; the returned `rgba` is w*h*4.
306
+ */
307
+ export function keyPlateFromPixels(rgb, w, h, zones, opts = {}) {
308
+ const despill = opts.despill ?? true;
309
+ const rgba = new Uint8Array(w * h * 4);
310
+ // Start fully opaque: anything the fill never reaches IS art, by definition.
311
+ for (let i = 0, j = 0; i < w * h; i++, j += 3) {
312
+ rgba[i * 4] = rgb[j];
313
+ rgba[i * 4 + 1] = rgb[j + 1];
314
+ rgba[i * 4 + 2] = rgb[j + 2];
315
+ rgba[i * 4 + 3] = 255;
316
+ }
317
+ const perZone = [];
318
+ let keyedTotal = 0;
319
+ let protectedTotal = 0;
320
+ let plateColoredTotal = 0;
321
+ let featheredTotal = 0;
322
+ const queue = new Int32Array(w * h);
323
+ zones.forEach((zone, zi) => {
324
+ const { x: zx, y: zy, width: zw, height: zh } = zone;
325
+ const chromatic = isChromatic(zone.rgb);
326
+ const base = defaultsForPlate(zone.rgb);
327
+ const inner = Math.min(0.95, Math.max(0.001, opts.tolerance ?? base.tolerance));
328
+ const bandWidth = Math.min(0.95, Math.max(0, opts.softness ?? base.softness));
329
+ const outer = Math.min(1, inner + bandWidth);
330
+ // Per-pixel plate distance for this panel only. `loose` is the mask the fill
331
+ // may travel through; `tight` marks definite plate.
332
+ const dist = new Float32Array(zw * zh);
333
+ const loose = new Uint8Array(zw * zh);
334
+ for (let y = 0; y < zh; y++) {
335
+ for (let x = 0; x < zw; x++) {
336
+ const src = ((zy + y) * w + (zx + x)) * 3;
337
+ const d = plateDistance(rgb[src], rgb[src + 1], rgb[src + 2], zone.rgb, chromatic);
338
+ const p = y * zw + x;
339
+ dist[p] = d;
340
+ if (d <= outer)
341
+ loose[p] = 1;
342
+ if (d <= inner)
343
+ plateColoredTotal++;
344
+ }
345
+ }
346
+ // Flood inward from the panel's own perimeter, 4-connected (an 8-connected
347
+ // fill squeezes through 1px diagonal seams in antialiased line art and eats
348
+ // interiors it has no business reaching).
349
+ const reached = new Uint8Array(zw * zh);
350
+ let head = 0;
351
+ let tail = 0;
352
+ const push = (p) => {
353
+ if (loose[p] && !reached[p]) {
354
+ reached[p] = 1;
355
+ queue[tail++] = p;
356
+ }
357
+ };
358
+ for (let x = 0; x < zw; x++) {
359
+ push(x);
360
+ push((zh - 1) * zw + x);
361
+ }
362
+ for (let y = 0; y < zh; y++) {
363
+ push(y * zw);
364
+ push(y * zw + (zw - 1));
365
+ }
366
+ while (head < tail) {
367
+ const p = queue[head++];
368
+ const py = (p / zw) | 0;
369
+ const px = p - py * zw;
370
+ if (px > 0)
371
+ push(p - 1);
372
+ if (px < zw - 1)
373
+ push(p + 1);
374
+ if (py > 0)
375
+ push(p - zw);
376
+ if (py < zh - 1)
377
+ push(p + zw);
378
+ }
379
+ // Write alpha: 0 inside the reached plate, an estimated coverage across the
380
+ // boundary, and full opacity everywhere the fill never got to.
381
+ const cleared = new Uint8Array(zw * zh); // fully-keyed, for the edge shell below
382
+ let keyedHere = 0;
383
+ for (let y = 0; y < zh; y++) {
384
+ for (let x = 0; x < zw; x++) {
385
+ const p = y * zw + x;
386
+ const out = ((zy + y) * w + (zx + x)) * 4;
387
+ if (!reached[p]) {
388
+ // Plate-colored but unreachable = art interior we just saved.
389
+ if (dist[p] <= inner)
390
+ protectedTotal++;
391
+ continue;
392
+ }
393
+ if (dist[p] <= inner) {
394
+ rgba[out + 3] = 0;
395
+ cleared[p] = 1;
396
+ keyedHere++;
397
+ continue;
398
+ }
399
+ const a = coverage(rgb, ((zy + y) * w + (zx + x)) * 3, zone.rgb, dist[p], inner, outer);
400
+ rgba[out + 3] = Math.round(a * 255);
401
+ if (a < 1)
402
+ featheredTotal++;
403
+ keyedHere += 1 - a;
404
+ if (despill)
405
+ unpremultiplyPlate(rgba, out, zone.rgb, a);
406
+ }
407
+ }
408
+ // EDGE SHELL. Antialiasing leaves a one-pixel ring of art/plate blend whose
409
+ // color has already drifted past `outer` — too far to be called plate, but
410
+ // still carrying the plate's tint. Colour distance can't catch it; geometry
411
+ // can: any opaque pixel touching the cleared region is on the silhouette, so
412
+ // estimate its coverage the same way. This is what removes the green fringe
413
+ // that a flat key leaves behind (and it can't erode the art, because a pixel
414
+ // with nothing of the plate in it estimates as fully opaque).
415
+ for (let y = 0; y < zh; y++) {
416
+ for (let x = 0; x < zw; x++) {
417
+ const p = y * zw + x;
418
+ const out = ((zy + y) * w + (zx + x)) * 4;
419
+ if (rgba[out + 3] !== 255)
420
+ continue;
421
+ const touching = (x > 0 && cleared[p - 1]) || (x < zw - 1 && cleared[p + 1]) ||
422
+ (y > 0 && cleared[p - zw]) || (y < zh - 1 && cleared[p + zw]);
423
+ if (!touching)
424
+ continue;
425
+ const a = coverage(rgb, ((zy + y) * w + (zx + x)) * 3, zone.rgb, dist[p], inner, outer);
426
+ if (a >= 1)
427
+ continue;
428
+ rgba[out + 3] = Math.round(a * 255);
429
+ featheredTotal++;
430
+ keyedHere += 1 - a;
431
+ if (despill)
432
+ unpremultiplyPlate(rgba, out, zone.rgb, a);
433
+ }
434
+ }
435
+ keyedTotal += keyedHere;
436
+ perZone.push({ zone: zi + 1, hex: zone.hex, keyed_pct: Math.round((keyedHere / (zw * zh)) * 1000) / 10 });
437
+ });
438
+ return {
439
+ rgba,
440
+ stats: {
441
+ zones,
442
+ perZone,
443
+ keyed_pct: Math.round((keyedTotal / (w * h)) * 1000) / 10,
444
+ protected_px: protectedTotal,
445
+ protected_pct: plateColoredTotal > 0 ? Math.round((protectedTotal / plateColoredTotal) * 1000) / 10 : 0,
446
+ feathered_px: featheredTotal
447
+ }
448
+ };
449
+ }
450
+ // ── ffmpeg I/O ───────────────────────────────────────────────────────────────
451
+ /** Read a still as raw RGB24 at its native size (or a given size). */
452
+ export async function readRgbPlane(sourcePath, size) {
453
+ if (!existsSync(sourcePath))
454
+ throw new Error(`No such source file: ${sourcePath}`);
455
+ const dims = size ?? (await probeImageDimensions(sourcePath));
456
+ if (!dims)
457
+ throw new Error(`Couldn't read image dimensions for ${sourcePath}.`);
458
+ const ffmpeg = await resolveFfmpeg();
459
+ const scale = size ? [`scale=${dims.width}:${dims.height}:flags=area`] : [];
460
+ const args = [
461
+ "-hide_banner", "-v", "error",
462
+ "-i", sourcePath,
463
+ "-frames:v", "1",
464
+ ...(scale.length ? ["-vf", scale.join(",")] : []),
465
+ "-f", "rawvideo", "-pix_fmt", "rgb24",
466
+ "-"
467
+ ];
468
+ const need = dims.width * dims.height * 3;
469
+ const buf = await new Promise((resolve, reject) => {
470
+ const child = spawn(ffmpeg, args, { stdio: ["ignore", "pipe", "pipe"] });
471
+ const chunks = [];
472
+ let stderr = "";
473
+ child.stdout.on("data", (d) => chunks.push(d));
474
+ child.stderr.on("data", (d) => (stderr += d.toString()));
475
+ child.on("error", reject);
476
+ child.on("close", (code) => {
477
+ const out = Buffer.concat(chunks);
478
+ if (code !== 0 || out.length < need) {
479
+ reject(new Error(`Couldn't read ${sourcePath} as pixels (ffmpeg exit ${code})${stderr.trim() ? `: ${stderr.trim().split("\n").slice(-2).join(" ")}` : ""}.`));
480
+ }
481
+ else
482
+ resolve(out);
483
+ });
484
+ });
485
+ return { rgb: new Uint8Array(buf.buffer, buf.byteOffset, need), width: dims.width, height: dims.height };
486
+ }
487
+ /** Write raw RGBA out as a transparent PNG/WebP. */
488
+ export async function writeRgbaStill(rgba, width, height, outputPath) {
489
+ const ffmpeg = await resolveFfmpeg();
490
+ const isWebp = /\.webp$/i.test(outputPath);
491
+ const args = [
492
+ "-hide_banner", "-v", "error", "-y",
493
+ "-f", "rawvideo", "-pix_fmt", "rgba", "-s", `${width}x${height}`, "-i", "-",
494
+ "-frames:v", "1",
495
+ ...(isWebp ? ["-c:v", "libwebp", "-lossless", "1", "-pix_fmt", "rgba"] : ["-c:v", "png", "-pix_fmt", "rgba"]),
496
+ outputPath
497
+ ];
498
+ await new Promise((resolve, reject) => {
499
+ const child = spawn(ffmpeg, args, { stdio: ["pipe", "ignore", "pipe"] });
500
+ let stderr = "";
501
+ child.stderr.on("data", (d) => (stderr += d.toString()));
502
+ child.on("error", reject);
503
+ child.on("close", (code) => {
504
+ if (code !== 0 || !existsSync(outputPath)) {
505
+ reject(new Error(`Writing the keyed still failed (ffmpeg exit ${code})${stderr.trim() ? `: ${stderr.trim().split("\n").slice(-2).join(" ")}` : ""}.`));
506
+ }
507
+ else
508
+ resolve();
509
+ });
510
+ child.stdin.on("error", () => { });
511
+ child.stdin.end(Buffer.from(rgba.buffer, rgba.byteOffset, rgba.length));
512
+ });
513
+ }
514
+ /**
515
+ * Zones for a declared R×C grid: split the sheet into equal panels and read each
516
+ * panel's plate color from its own corners. Used when WE generated the sheet, so
517
+ * the layout is known and only the colors need reading back (image models drift
518
+ * the hue; the corners tell the truth).
519
+ */
520
+ export function zonesFromGrid(rgb, w, h, rows, cols) {
521
+ const zones = [];
522
+ for (let r = 0; r < rows; r++) {
523
+ for (let c = 0; c < cols; c++) {
524
+ const x0 = Math.round((c * w) / cols);
525
+ const x1 = Math.round(((c + 1) * w) / cols);
526
+ const y0 = Math.round((r * h) / rows);
527
+ const y1 = Math.round(((r + 1) * h) / rows);
528
+ const zw = x1 - x0;
529
+ const zh = y1 - y0;
530
+ const inset = Math.max(2, Math.round(Math.min(zw, zh) * 0.02));
531
+ const s = Math.max(3, Math.round(Math.min(zw, zh) * 0.03));
532
+ const corners = [
533
+ [x0 + inset, y0 + inset],
534
+ [x1 - inset - s, y0 + inset],
535
+ [x0 + inset, y1 - inset - s],
536
+ [x1 - inset - s, y1 - inset - s]
537
+ ].map(([px, py]) => {
538
+ let sr = 0, sg = 0, sb = 0, n = 0;
539
+ for (let yy = py; yy < Math.min(h, py + s); yy++) {
540
+ for (let xx = px; xx < Math.min(w, px + s); xx++) {
541
+ const i = (yy * w + xx) * 3;
542
+ sr += rgb[i];
543
+ sg += rgb[i + 1];
544
+ sb += rgb[i + 2];
545
+ n++;
546
+ }
547
+ }
548
+ return n ? [sr / n, sg / n, sb / n] : [0, 0, 0];
549
+ });
550
+ const median = [0, 1, 2].map((ch) => {
551
+ const vals = corners.map((cc) => cc[ch]).sort((a, b) => a - b);
552
+ return Math.round((vals[1] + vals[2]) / 2);
553
+ });
554
+ zones.push({ x: x0, y: y0, width: zw, height: zh, hex: hexOf(median), rgb: median });
555
+ }
556
+ }
557
+ return zones;
558
+ }
559
+ /** Detect a sheet's panel layout (or its single plate color) from the file. */
560
+ export async function detectPlateZones(sourcePath) {
561
+ // Detection is a layout question — run it on a downscale for speed, then map
562
+ // the boundaries back up to source pixels.
563
+ const dims = await probeImageDimensions(sourcePath);
564
+ if (!dims)
565
+ return null;
566
+ const longSide = Math.max(dims.width, dims.height);
567
+ const scale = longSide > 720 ? 720 / longSide : 1;
568
+ const sw = Math.max(8, Math.round(dims.width * scale));
569
+ const sh = Math.max(8, Math.round(dims.height * scale));
570
+ const { rgb } = await readRgbPlane(sourcePath, { width: sw, height: sh });
571
+ const small = detectZoneGrid(rgb, sw, sh);
572
+ if (!small)
573
+ return null;
574
+ const kx = dims.width / sw;
575
+ const ky = dims.height / sh;
576
+ return small.map((z) => {
577
+ const x0 = Math.round(z.x * kx);
578
+ const y0 = Math.round(z.y * ky);
579
+ const x1 = Math.min(dims.width, Math.round((z.x + z.width) * kx));
580
+ const y1 = Math.min(dims.height, Math.round((z.y + z.height) * ky));
581
+ return { ...z, x: x0, y: y0, width: Math.max(1, x1 - x0), height: Math.max(1, y1 - y0) };
582
+ });
583
+ }
584
+ /**
585
+ * Key a still's plate(s) out by connectivity and write a transparent PNG/WebP.
586
+ * This is the drop-in upgrade for `removeGreenscreenLocal` on images.
587
+ */
588
+ export async function smartKeyPlate(input) {
589
+ const { rgb, width, height } = await readRgbPlane(input.sourcePath);
590
+ let zones;
591
+ let zoneSource;
592
+ if (input.zones?.length) {
593
+ zones = input.zones;
594
+ zoneSource = "declared";
595
+ }
596
+ else if (input.grid && (input.grid.rows > 1 || input.grid.cols > 1)) {
597
+ zones = zonesFromGrid(rgb, width, height, input.grid.rows, input.grid.cols);
598
+ zoneSource = "grid";
599
+ }
600
+ else if (input.singlePlate || input.keyColor) {
601
+ zones = [singleZone(width, height, input.keyColor ?? "#00FF00")];
602
+ zoneSource = "single";
603
+ }
604
+ else {
605
+ const detected = detectZoneGrid(rgb, width, height);
606
+ if (detected && detected.length > 1) {
607
+ zones = detected;
608
+ zoneSource = "detected";
609
+ }
610
+ else {
611
+ zones = [detected?.[0] ?? singleZone(width, height, "#00FF00")];
612
+ zoneSource = detected ? "detected" : "single";
613
+ }
614
+ }
615
+ const { rgba, stats } = keyPlateFromPixels(rgb, width, height, zones, input);
616
+ await writeRgbaStill(rgba, width, height, input.outputPath);
617
+ return { outputPath: input.outputPath, width, height, stats, zoneSource };
618
+ }
619
+ /**
620
+ * Lay a pack out as a grid of colored panels and pick each panel's plate color
621
+ * AGAINST THE ITEM THAT SITS ON IT — the whole point of zoning. Adjacent panels
622
+ * are also kept different, both so the grid is readable from the sheet's edges
623
+ * and so an item that overruns its panel doesn't dissolve into its neighbour.
624
+ *
625
+ * `pick` is injected (it's `pickPlateColor` from sticker-pack.ts) to keep this
626
+ * module free of the plate-vocabulary table.
627
+ */
628
+ export function planZonedSheet(items, count, pick, opts = {}) {
629
+ const n = Math.max(1, items.length || count);
630
+ const cols = Math.max(1, opts.cols ?? Math.ceil(Math.sqrt(n)));
631
+ const rows = Math.ceil(n / cols);
632
+ const cells = [];
633
+ for (let i = 0; i < n; i++) {
634
+ const row = Math.floor(i / cols);
635
+ const col = i % cols;
636
+ const name = items[i] ?? null;
637
+ // The item's OWN description drives its plate. With no name, the theme does.
638
+ const first = pick([name ?? "", opts.theme ?? ""].join(" ").trim() || "generic");
639
+ let chosen = first;
640
+ // Nudge off any neighbour that already claimed this color (left and above).
641
+ const taken = new Set([cells.find((c) => c.row === row && c.col === col - 1), cells.find((c) => c.row === row - 1 && c.col === col)]
642
+ .filter(Boolean)
643
+ .map((c) => c.keyColor));
644
+ if (taken.has(chosen.keyColor)) {
645
+ const alt = pick(`${name ?? ""} ${opts.theme ?? ""} ${[...taken].map((hex) => hex).join(" ")}`);
646
+ // pick() only understands words, so fall back to a deterministic rotation
647
+ // through the alternates when it hands back the same color.
648
+ chosen = alt.keyColor !== chosen.keyColor ? alt : rotatePlate(chosen, taken);
649
+ }
650
+ cells.push({ index: i + 1, name, preset: chosen.preset, keyColor: chosen.keyColor.toUpperCase(), row, col });
651
+ }
652
+ return { rows, cols, cells };
653
+ }
654
+ /** Deterministic fallback rotation when the word-based picker can't move. */
655
+ function rotatePlate(current, taken) {
656
+ const ring = [
657
+ { preset: "green", keyColor: "#00FF00" },
658
+ { preset: "magenta", keyColor: "#FF00FF" },
659
+ { preset: "blue", keyColor: "#0047BB" },
660
+ { preset: "orange", keyColor: "#FF7A00" },
661
+ { preset: "cyan", keyColor: "#00E5FF" }
662
+ ];
663
+ const start = Math.max(0, ring.findIndex((r) => r.keyColor === current.keyColor.toUpperCase()));
664
+ for (let step = 1; step <= ring.length; step++) {
665
+ const cand = ring[(start + step) % ring.length];
666
+ if (!taken.has(cand.keyColor))
667
+ return cand;
668
+ }
669
+ return current;
670
+ }
671
+ /**
672
+ * The generation prompt for a ZONED sheet. Everything a flat sheet's prompt has
673
+ * to forbid because one color is banned everywhere, this can allow: the art may
674
+ * use any palette (its panel color is chosen against it), and it may be hollow,
675
+ * outlined or line-art, because a border-connected fill can't reach a shape's
676
+ * interior. What's left is the two things geometry can't fix — the item must not
677
+ * touch its panel's plate color at its own edge, and it must stay inside its
678
+ * panel.
679
+ */
680
+ export function zonedSheetInstruction(plan, opts = {}) {
681
+ const panels = plan.cells
682
+ .map((c) => `panel ${c.index} (row ${c.row + 1}, column ${c.col + 1}): solid ${c.keyColor} background, containing ONLY ${c.name ?? `item ${c.index}`}`)
683
+ .join("; ");
684
+ return (`LAYOUT — a color-block sticker sheet, NOT one background: divide the square canvas into an exact ${plan.rows}×${plan.cols} grid ` +
685
+ `of equal rectangular panels (${plan.cells.length} panels used), edge to edge, with NO gaps, borders, outlines, gutters or dividing lines ` +
686
+ `between them — each panel is defined purely by its own flat background color. Each panel holds exactly ONE object, ` +
687
+ `drawn once, centered, at a comfortable size with a clear margin of that panel's own background color on all four sides. ` +
688
+ `Nothing crosses a panel edge; nothing spans two panels. Panels, in order: ${panels}. ` +
689
+ `Every panel background must be one completely flat, evenly-lit, uniform fill of the EXACT color named — no gradient, ` +
690
+ `no texture, no vignette, no shading. ` +
691
+ `ART — the objects themselves are free: any palette, any style, flat or shaded or painterly, filled or outlined, ` +
692
+ `as long as (a) the object's OUTER EDGE is clearly a different color from its OWN panel's background, with crisp, ` +
693
+ `non-blurry edges, and (b) the object casts no drop shadow, glow, reflection or blur onto the panel background. ` +
694
+ `An object may absolutely use the color of a DIFFERENT panel. No text, no labels, no watermarks, no frames. ` +
695
+ `One consistent art style across all panels.` +
696
+ (opts.theme ? ` Overall theme: ${opts.theme}.` : ""));
697
+ }
698
+ //# sourceMappingURL=plate-key.js.map