@officexapp/vidfarm-devcli 0.21.35 → 0.21.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/editor-capabilities/SKILL.md +1 -1
- package/.agents/skills/vidfarm/SKILL.md +7 -3
- package/.agents/skills/vidfarm/harnesses/short-form.HARNESS.md +3 -3
- package/.agents/skills/vidfarm/recipes/cutout-graphics-for-explainers.md +42 -12
- package/.agents/skills/vidfarm/references/automation-and-local-dev.md +4 -4
- package/.agents/skills/vidfarm/references/hooks-and-virality.md +3 -2
- package/SKILL.director.md +56 -21
- package/SKILL.md +1 -1
- package/dist/src/cli.js +429 -53
- package/dist/src/devcli/handoff.js +54 -33
- package/dist/src/devcli/local-frontend-server.js +92 -6
- package/dist/src/devcli/plate-key.js +698 -0
- package/dist/src/devcli/sticker-pack.js +48 -0
- package/package.json +3 -2
|
@@ -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
|