cozyclay 1.2.0 → 1.3.0

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.
Files changed (72) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +31 -0
  3. package/THIRD_PARTY_NOTICES.md +37 -1
  4. package/bin/cozyclay.mjs +51 -2
  5. package/dist/ai-camera-control/index.html +406 -0
  6. package/dist/app/index.html +5 -5
  7. package/dist/assets/app-B3U5aut1.js +4811 -0
  8. package/dist/assets/app-BrRF0wso.css +1 -0
  9. package/dist/assets/vision_bundle-jFkh-fIS.js +41 -0
  10. package/dist/fonts/InstrumentSerif-OFL.txt +93 -0
  11. package/dist/fonts/Inter-OFL.txt +92 -0
  12. package/dist/fonts/README.md +15 -0
  13. package/dist/index.html +60 -16
  14. package/dist/sitemap.xml +7 -1
  15. package/mcp/LIVE-PROTOCOL.md +63 -0
  16. package/mcp/README.md +142 -0
  17. package/mcp/ardy-prompts.mjs +170 -0
  18. package/mcp/live-hub.mjs +105 -0
  19. package/mcp/package.json +24 -0
  20. package/mcp/server.mjs +1394 -0
  21. package/package.json +122 -90
  22. package/src/App.jsx +2957 -514
  23. package/src/ardy/cskel27.js +7 -2
  24. package/src/ardy/ik.js +25 -15
  25. package/src/ardy/npz.js +64 -3
  26. package/src/ardy/playback.js +31 -1
  27. package/src/ardy/prompt-clips.js +7 -2
  28. package/src/ardy/retime.js +211 -0
  29. package/src/ardy/timeline-coordinates.js +13 -0
  30. package/src/ardy/timeline.jsx +124 -5
  31. package/src/ardy/to-cskel27.js +34 -12
  32. package/src/ardy/trim.js +33 -0
  33. package/src/asset-pane.jsx +36 -0
  34. package/src/dualview.jsx +14 -8
  35. package/src/hierarchy-model.js +95 -13
  36. package/src/hierarchy-panel.jsx +139 -6
  37. package/src/live-control.js +122 -0
  38. package/src/matte-editor.js +543 -0
  39. package/src/matte.js +503 -0
  40. package/src/multimodel-ingest.js +344 -0
  41. package/src/object-gizmo.jsx +43 -15
  42. package/src/planview.jsx +43 -32
  43. package/src/pose-extract/detector.js +75 -0
  44. package/src/pose-extract/index.js +3 -0
  45. package/src/pose-extract/take.js +87 -0
  46. package/src/pose-extract/video-frames.js +91 -0
  47. package/src/pose-thumbs.js +152 -0
  48. package/src/posestudio.jsx +361 -11
  49. package/src/project-browser.jsx +135 -0
  50. package/src/project.js +289 -0
  51. package/src/props.jsx +69 -3
  52. package/src/room.jsx +14 -35
  53. package/src/scene-asset-cache.js +125 -0
  54. package/src/scene-assets.js +288 -0
  55. package/src/scene-objects.js +245 -7
  56. package/src/scenes.js +207 -26
  57. package/src/shot-authoring.js +55 -13
  58. package/src/styles.css +1296 -129
  59. package/tools/ardy/BRIDGE.md +3 -2
  60. package/tools/ardy/README.md +9 -5
  61. package/tools/ardy/bridge.mjs +57 -1
  62. package/tools/ardy/bvh-cskel27.mjs +1209 -0
  63. package/tools/ardy/cclay_constrained_generate.py +123 -11
  64. package/tools/ardy/cclay_sequence_generate.py +49 -0
  65. package/tools/ardy/extract.mjs +367 -0
  66. package/tools/ardy/footage.mjs +462 -0
  67. package/tools/ardy/npz.mjs +74 -9
  68. package/tools/ardy/run-on-box.sh +25 -0
  69. package/tools/ardy/run-sequence-on-box.sh +15 -0
  70. package/tools/ardy/runners/remote.mjs +8 -2
  71. package/dist/assets/app-Cgpk2hwX.js +0 -4803
  72. package/dist/assets/app-DgZvaAE1.css +0 -1
package/src/matte.js ADDED
@@ -0,0 +1,503 @@
1
+ /**
2
+ * Background removal: the step between "a photo" and "a cutout".
3
+ *
4
+ * A card IS its transparency. A JPEG of a sofa imported as-is stands up as a
5
+ * rectangle of somebody's living room, which blocks nothing and reads as a
6
+ * mistake in the frame. So the picture has to lose its background before it
7
+ * can be a set piece.
8
+ *
9
+ * This is the cheap, offline half of that job (issue #17 P2): grow a region
10
+ * from the edges of the frame and drop what it reaches. It cannot cut a
11
+ * subject out of a busy street — that is the model-backed half, P3, which
12
+ * belongs behind the same runner boundary as ARDY — but it is exact on the
13
+ * pictures previs actually uses: a product shot, a prop on a sweep, anything
14
+ * photographed against a wall.
15
+ *
16
+ * Two rules make it hold up on real photographs:
17
+ *
18
+ * - Growth is LOCAL. A pixel joins the background if it is close to the
19
+ * pixel it grew from, not merely close to the corner it started at. A wall
20
+ * that shades from top to bottom is one region; a global threshold either
21
+ * stops halfway up it or eats the subject.
22
+ * - Growth is also BOUNDED by the seed colours, so a local chain of small
23
+ * steps cannot walk across a soft edge and out the other side into the
24
+ * subject.
25
+ *
26
+ * Everything here is pure: pixels in, pixels out, no DOM. `cutOutBackground`
27
+ * at the bottom is the browser glue, and every browser API it needs is
28
+ * injected — the same seam `importImageFile` uses, for the same reason.
29
+ */
30
+
31
+ import { assetIdForBytes, normalizeAsset } from "./scene-assets.js";
32
+
33
+ /** Colour distance is compared squared, so nothing here takes a square root. */
34
+ const dist2 = (data, a, b) => {
35
+ const dr = data[a] - data[b];
36
+ const dg = data[a + 1] - data[b + 1];
37
+ const db = data[a + 2] - data[b + 2];
38
+ return dr * dr + dg * dg + db * db;
39
+ };
40
+
41
+ const seedDist2 = (data, index, seeds) => {
42
+ let best = Infinity;
43
+ for (let s = 0; s < seeds.length; s += 3) {
44
+ const dr = data[index] - seeds[s];
45
+ const dg = data[index + 1] - seeds[s + 1];
46
+ const db = data[index + 2] - seeds[s + 2];
47
+ const value = dr * dr + dg * dg + db * db;
48
+ if (value < best) best = value;
49
+ }
50
+ return best;
51
+ };
52
+
53
+ /**
54
+ * Where to start growing from. Every pixel on the frame's edge is a candidate
55
+ * background sample; they are quantized to a small palette so a noisy border
56
+ * does not turn into ten thousand reference colours.
57
+ *
58
+ * `points` overrides this entirely — that is the "click the background you
59
+ * meant" path, and one deliberate click beats four guessed corners on a
60
+ * picture whose subject runs off the edge.
61
+ */
62
+ export function borderSeeds({ data, width, height }, points = null) {
63
+ const sampleColour = (x, y) => {
64
+ const index = (Math.min(height - 1, Math.max(0, y)) * width + Math.min(width - 1, Math.max(0, x))) * 4;
65
+ return index;
66
+ };
67
+ if (points?.length) {
68
+ const seeds = [];
69
+ for (const { x, y } of points) {
70
+ const index = sampleColour(Math.round(x), Math.round(y));
71
+ seeds.push(data[index], data[index + 1], data[index + 2]);
72
+ }
73
+ return { seeds: Float64Array.from(seeds), starts: points.map(({ x, y }) => sampleColour(Math.round(x), Math.round(y))) };
74
+ }
75
+
76
+ // Quantize to 16 levels per channel: enough to tell a wall from a sofa,
77
+ // coarse enough that grain does not multiply the reference set.
78
+ const counts = new Map();
79
+ const starts = [];
80
+ const step = Math.max(1, Math.round(Math.min(width, height) / 64));
81
+ const tally = (x, y) => {
82
+ const index = sampleColour(x, y);
83
+ starts.push(index);
84
+ const key = ((data[index] >> 4) << 8) | ((data[index + 1] >> 4) << 4) | (data[index + 2] >> 4);
85
+ const seen = counts.get(key);
86
+ if (seen) seen.count += 1;
87
+ else counts.set(key, { count: 1, r: data[index], g: data[index + 1], b: data[index + 2] });
88
+ };
89
+ for (let x = 0; x < width; x += step) {
90
+ tally(x, 0);
91
+ tally(x, height - 1);
92
+ }
93
+ for (let y = 0; y < height; y += step) {
94
+ tally(0, y);
95
+ tally(width - 1, y);
96
+ }
97
+
98
+ // Only the colours that DOMINATE the border are background. A subject
99
+ // rooted at the bottom of the frame — which is most subjects — touches the
100
+ // edge too, and taking every border colour as a reference would let the
101
+ // growth start inside it and eat the picture from its own feet up.
102
+ const ranked = [...counts.values()].sort((a, b) => b.count - a.count);
103
+ const floor = (ranked[0]?.count ?? 0) * 0.2;
104
+ const seeds = [];
105
+ for (const entry of ranked) {
106
+ if (entry.count < floor) break;
107
+ seeds.push(entry.r, entry.g, entry.b);
108
+ }
109
+ return { seeds: Float64Array.from(seeds), starts };
110
+ }
111
+
112
+ /**
113
+ * Drop the background, in place on a copy.
114
+ *
115
+ * `tolerance` (0–1) is the one control a person should ever need: it scales
116
+ * both the bound against the seed colours and the local step. `shrink` eats
117
+ * the fringe of background-coloured pixels that survives any threshold, and
118
+ * `feather` softens what is left so the card's edge does not read as a
119
+ * cut-out-with-scissors line against the set.
120
+ */
121
+ /**
122
+ * `within`, when given, fences the growth: a pixel can only be entered if it is
123
+ * marked there. That is what lets the eraser be the same tool as the brush —
124
+ * growing back through a region that is already selected, and stopping where
125
+ * the selection stops, rather than nibbling at it a disc at a time.
126
+ */
127
+ export function backgroundMask(pixels, { points = null, tolerance = 0.18, within = null } = {}) {
128
+ const { data, width, height } = pixels;
129
+ const total = width * height;
130
+ const background = new Uint8Array(total);
131
+ if (!total) return background;
132
+
133
+ const { seeds, starts } = borderSeeds(pixels, points);
134
+ if (!seeds.length) return background;
135
+
136
+ // 0–1 maps onto a squared distance in 0–255³ space. The local step is a
137
+ // fraction of the global bound: small enough that a soft edge stops it,
138
+ // large enough to walk a gradient.
139
+ const globalLimit = (tolerance * 441.67) ** 2;
140
+ const localLimit = (tolerance * 441.67 * 0.42) ** 2;
141
+
142
+ const stack = new Int32Array(total);
143
+ let top = 0;
144
+ for (const start of starts) {
145
+ const pixel = start >> 2;
146
+ if (background[pixel]) continue;
147
+ if (within && !within[pixel]) continue;
148
+ // A start that is already far from every seed colour (a subject running
149
+ // off the frame edge) is not background and must not begin a region.
150
+ if (seedDist2(data, start, seeds) > globalLimit) continue;
151
+ background[pixel] = 1;
152
+ stack[top++] = pixel;
153
+ }
154
+
155
+ while (top > 0) {
156
+ const pixel = stack[--top];
157
+ const index = pixel << 2;
158
+ const x = pixel % width;
159
+ const y = (pixel - x) / width;
160
+ for (let edge = 0; edge < 4; edge++) {
161
+ const nx = x + (edge === 0 ? -1 : edge === 1 ? 1 : 0);
162
+ const ny = y + (edge === 2 ? -1 : edge === 3 ? 1 : 0);
163
+ if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue;
164
+ const neighbour = ny * width + nx;
165
+ if (background[neighbour]) continue;
166
+ if (within && !within[neighbour]) continue;
167
+ const neighbourIndex = neighbour << 2;
168
+ // Already transparent counts as background: an image that arrives
169
+ // half-cut stays cut.
170
+ const clear = data[neighbourIndex + 3] === 0;
171
+ if (!clear && dist2(data, neighbourIndex, index) > localLimit) continue;
172
+ if (!clear && seedDist2(data, neighbourIndex, seeds) > globalLimit) continue;
173
+ background[neighbour] = 1;
174
+ stack[top++] = neighbour;
175
+ }
176
+ }
177
+ return background;
178
+ }
179
+
180
+ /* -------------------------------------------------------------- brush ---- */
181
+
182
+ /**
183
+ * A round stroke into the paint layer: `value` 1 means "this is background
184
+ * after all", -1 means "put that back". The paint layer is kept SEPARATE from
185
+ * the automatic mask so moving the tolerance slider re-runs the growth without
186
+ * throwing away a stroke someone has already made.
187
+ *
188
+ * Coordinates and radius are in the paint layer's own pixels.
189
+ */
190
+ export function paintMask(paint, { width, height }, { x, y, radius, value = 1 }) {
191
+ const cx = Math.round(x);
192
+ const cy = Math.round(y);
193
+ const r = Math.max(0.5, radius);
194
+ const r2 = r * r;
195
+ const minX = Math.max(0, Math.floor(cx - r));
196
+ const maxX = Math.min(width - 1, Math.ceil(cx + r));
197
+ const minY = Math.max(0, Math.floor(cy - r));
198
+ const maxY = Math.min(height - 1, Math.ceil(cy + r));
199
+ let touched = 0;
200
+ for (let py = minY; py <= maxY; py++) {
201
+ for (let px = minX; px <= maxX; px++) {
202
+ const dx = px - cx;
203
+ const dy = py - cy;
204
+ if (dx * dx + dy * dy > r2) continue;
205
+ const at = py * width + px;
206
+ if (paint[at] === value) continue;
207
+ paint[at] = value;
208
+ touched += 1;
209
+ }
210
+ }
211
+ return touched;
212
+ }
213
+
214
+ /**
215
+ * The automatic mask with the strokes laid over it.
216
+ *
217
+ * The two layers can be different sizes on purpose: the editor paints on a
218
+ * preview a few hundred pixels wide, while the mask that is finally applied is
219
+ * grown at the picture's full resolution. A brush stroke is coarse by nature,
220
+ * so sampling it up is honest; the machine-cut edge stays exact.
221
+ */
222
+ export function combineMask(auto, paint, { width, height, paintWidth = width, paintHeight = height } = {}) {
223
+ const out = Uint8Array.from(auto);
224
+ if (!paint) return out;
225
+ const sx = paintWidth / width;
226
+ const sy = paintHeight / height;
227
+ for (let y = 0; y < height; y++) {
228
+ const py = Math.min(paintHeight - 1, Math.floor(y * sy));
229
+ for (let x = 0; x < width; x++) {
230
+ const stroke = paint[py * paintWidth + Math.min(paintWidth - 1, Math.floor(x * sx))];
231
+ if (!stroke) continue;
232
+ out[y * width + x] = stroke > 0 ? 1 : 0;
233
+ }
234
+ }
235
+ return out;
236
+ }
237
+
238
+ /* -------------------------------------------------------------- apply ---- */
239
+
240
+ /**
241
+ * Turn a mask into transparency: erode the fringe, soften the boundary, write
242
+ * the alpha. Split from the growth above so the editor can show a mask, let
243
+ * someone paint on it, and only then pay for the pixels.
244
+ */
245
+ export function applyMask(pixels, mask, { shrink = 1, feather = 1 } = {}) {
246
+ const { width, height } = pixels;
247
+ const data = new Uint8ClampedArray(pixels.data);
248
+ const total = width * height;
249
+ if (!total) return { data, width, height, removed: 0 };
250
+
251
+ /* -- fringe: trim the blended rim, WITHOUT eating thin structures ------- */
252
+ //
253
+ // A photographed edge is a pixel of subject mixed with a pixel of wall, and
254
+ // that mixture is far enough from the wall's own colour that the growth
255
+ // cannot take it. Left alone it survives as a ring of half-wall around the
256
+ // cutout, which on a set reads as an outline drawn round the card — the
257
+ // thing that makes a cutout look cut out.
258
+ //
259
+ // Eroding the kept region by a pixel removes it. Eroding it BLINDLY also
260
+ // removes every structure thinner than the erosion: hair, branches, chair
261
+ // legs, an antenna. So a pixel is only trimmed where there is DEPTH behind
262
+ // it — three more kept pixels away from the background it touches. A mass
263
+ // gets its rim taken; anything up to three pixels thick is left whole,
264
+ // because that is not a rim, it is the subject.
265
+ let grown = mask;
266
+ for (let pass = 0; pass < Math.max(0, Math.round(shrink)); pass++) {
267
+ const next = Uint8Array.from(grown);
268
+ const kept = (x, y) => x >= 0 && y >= 0 && x < width && y < height && !grown[y * width + x];
269
+ for (let y = 0; y < height; y++) {
270
+ for (let x = 0; x < width; x++) {
271
+ const pixel = y * width + x;
272
+ if (grown[pixel]) continue;
273
+ for (const [dx, dy] of [[-1, 0], [1, 0], [0, -1], [0, 1]]) {
274
+ const nx = x + dx;
275
+ const ny = y + dy;
276
+ // Background in this direction, and depth in the other one.
277
+ const background = nx < 0 || ny < 0 || nx >= width || ny >= height || grown[ny * width + nx];
278
+ if (!background) continue;
279
+ if (kept(x - dx, y - dy) && kept(x - dx * 2, y - dy * 2) && kept(x - dx * 3, y - dy * 3)) {
280
+ next[pixel] = 1;
281
+ break;
282
+ }
283
+ }
284
+ }
285
+ }
286
+ grown = next;
287
+ }
288
+
289
+ /* -- alpha, then a soft edge that only touches the boundary ------------- */
290
+ const alpha = new Float32Array(total);
291
+ let removed = 0;
292
+ for (let pixel = 0; pixel < total; pixel++) {
293
+ if (grown[pixel]) removed += 1;
294
+ alpha[pixel] = grown[pixel] ? 0 : data[(pixel << 2) + 3];
295
+ }
296
+ for (let pass = 0; pass < Math.max(0, Math.round(feather)); pass++) {
297
+ const blurred = Float32Array.from(alpha);
298
+ for (let y = 0; y < height; y++) {
299
+ for (let x = 0; x < width; x++) {
300
+ const pixel = y * width + x;
301
+ let sum = alpha[pixel];
302
+ let count = 1;
303
+ if (x > 0) { sum += alpha[pixel - 1]; count++; }
304
+ if (x < width - 1) { sum += alpha[pixel + 1]; count++; }
305
+ if (y > 0) { sum += alpha[pixel - width]; count++; }
306
+ if (y < height - 1) { sum += alpha[pixel + width]; count++; }
307
+ const average = sum / count;
308
+ // Only the boundary softens. Blurring the interior would make a
309
+ // solid subject faintly see-through, which reads as a ghost.
310
+ if (average !== alpha[pixel]) blurred[pixel] = average;
311
+ }
312
+ }
313
+ alpha.set(blurred);
314
+ }
315
+ for (let pixel = 0; pixel < total; pixel++) data[(pixel << 2) + 3] = Math.round(alpha[pixel]);
316
+
317
+ return { data, width, height, removed };
318
+ }
319
+
320
+ /**
321
+ * Drop the background, in place on a copy — the one-shot path, and the same
322
+ * two steps the editor runs separately.
323
+ *
324
+ * `tolerance` (0–1) is the one control a person should ever need: it scales
325
+ * both the bound against the seed colours and the local step. `shrink` eats
326
+ * the fringe of background-coloured pixels that survives any threshold, and
327
+ * `feather` softens what is left so the card's edge does not read as a
328
+ * cut-out-with-scissors line against the set.
329
+ */
330
+ export function removeBackground(pixels, { points = null, tolerance = 0.18, shrink = 1, feather = 1, mask = null } = {}) {
331
+ const grown = mask ?? backgroundMask(pixels, { points, tolerance });
332
+ return applyMask(pixels, grown, { shrink, feather });
333
+ }
334
+
335
+ /* --------------------------------------------------------------- trim ---- */
336
+
337
+ /** The box the picture still occupies, or null when nothing survived. */
338
+ export function opaqueBounds({ data, width, height }, threshold = 8) {
339
+ let minX = width;
340
+ let minY = height;
341
+ let maxX = -1;
342
+ let maxY = -1;
343
+ for (let y = 0; y < height; y++) {
344
+ for (let x = 0; x < width; x++) {
345
+ if (data[(y * width + x) * 4 + 3] < threshold) continue;
346
+ if (x < minX) minX = x;
347
+ if (x > maxX) maxX = x;
348
+ if (y < minY) minY = y;
349
+ if (y > maxY) maxY = y;
350
+ }
351
+ }
352
+ return maxX < 0 ? null : { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 };
353
+ }
354
+
355
+ /**
356
+ * Crop to a box. Cutting the empty margin away is not tidiness: the card's
357
+ * width is derived from the picture's aspect, so a frame of dead transparency
358
+ * is a card that is wider than the thing it shows and stands the subject off
359
+ * the floor.
360
+ */
361
+ export function cropPixels({ data, width, height }, box) {
362
+ const out = new Uint8ClampedArray(box.width * box.height * 4);
363
+ for (let y = 0; y < box.height; y++) {
364
+ const from = ((box.y + y) * width + box.x) * 4;
365
+ out.set(data.subarray(from, from + box.width * 4), y * box.width * 4);
366
+ }
367
+ return { data: out, width: box.width, height: box.height };
368
+ }
369
+
370
+ /* ------------------------------------------------------------- browser ---- */
371
+
372
+ function defaultCanvas(width, height) {
373
+ if (typeof OffscreenCanvas === "function") return new OffscreenCanvas(width, height);
374
+ throw new Error("this browser cannot edit images — OffscreenCanvas is unavailable");
375
+ }
376
+
377
+ /**
378
+ * The purple, as a storable picture.
379
+ *
380
+ * A card keeps TWO things: the photograph it was imported from, untouched, and
381
+ * the region someone has decided is background. The second one has to survive
382
+ * a reload like the first, and the asset store already knows how to keep a
383
+ * picture — so the mask is written as one. White is "this goes", and PNG's own
384
+ * compression handles a mask of flat regions better than anything worth
385
+ * hand-rolling.
386
+ */
387
+ export async function encodeMask(mask, { width, height }, { makeCanvas = defaultCanvas } = {}) {
388
+ const canvas = makeCanvas(width, height);
389
+ const context = canvas.getContext("2d");
390
+ if (!context) throw new Error("this browser cannot store a selection — no 2D context");
391
+ const data = new Uint8ClampedArray(width * height * 4);
392
+ for (let pixel = 0; pixel < width * height; pixel++) {
393
+ const value = mask[pixel] ? 255 : 0;
394
+ const index = pixel << 2;
395
+ data[index] = value;
396
+ data[index + 1] = value;
397
+ data[index + 2] = value;
398
+ data[index + 3] = 255;
399
+ }
400
+ context.putImageData(new ImageData(data, width, height), 0, 0);
401
+ const blob = await canvas.convertToBlob({ type: "image/png" });
402
+ return blob.arrayBuffer();
403
+ }
404
+
405
+ /** The purple as an asset record, ready for the store the pictures live in. */
406
+ export async function maskAsset(mask, { width, height, name = "matte" }, deps = {}) {
407
+ const bytes = await encodeMask(mask, { width, height }, deps);
408
+ const asset = normalizeAsset({
409
+ id: await assetIdForBytes(bytes, deps.subtle ?? globalThis.crypto?.subtle),
410
+ type: "image/png",
411
+ width,
412
+ height,
413
+ bytes,
414
+ name,
415
+ });
416
+ if (!asset) throw new Error("that selection could not be stored");
417
+ return asset;
418
+ }
419
+
420
+ /** The purple back out of a stored picture. */
421
+ export async function decodeMask(asset, { createBitmap = globalThis.createImageBitmap, makeCanvas = defaultCanvas } = {}) {
422
+ const bitmap = await createBitmap(new Blob([asset.bytes], { type: asset.type }));
423
+ try {
424
+ const canvas = makeCanvas(bitmap.width, bitmap.height);
425
+ const context = canvas.getContext("2d", { willReadFrequently: true });
426
+ context.drawImage(bitmap, 0, 0);
427
+ const image = context.getImageData(0, 0, bitmap.width, bitmap.height);
428
+ const mask = new Uint8Array(bitmap.width * bitmap.height);
429
+ for (let pixel = 0; pixel < mask.length; pixel++) mask[pixel] = image.data[pixel << 2] > 127 ? 1 : 0;
430
+ return { mask, width: bitmap.width, height: bitmap.height };
431
+ } finally {
432
+ bitmap?.close?.();
433
+ }
434
+ }
435
+
436
+ /**
437
+ * An asset with its background gone, as a NEW asset record.
438
+ *
439
+ * New, not edited: assets are addressed by their own bytes, so a cut picture
440
+ * is a different picture. Keeping the original addressable is what makes the
441
+ * cut undoable and what lets a second card use the untouched photo.
442
+ *
443
+ * `heightScale` comes back with it. Trimming changes how much of the frame the
444
+ * subject fills, and the card is sized in metres — so the caller multiplies the
445
+ * card's height by this to keep the subject exactly the size it already was.
446
+ */
447
+ export async function cutOutBackground(asset, options = {}, {
448
+ subtle = globalThis.crypto?.subtle,
449
+ createBitmap = globalThis.createImageBitmap,
450
+ makeCanvas = defaultCanvas,
451
+ } = {}) {
452
+ if (!asset?.bytes) throw new TypeError("cutOutBackground needs an asset record");
453
+ if (typeof createBitmap !== "function") throw new Error("this browser cannot decode images — createImageBitmap is unavailable");
454
+ const bitmap = await createBitmap(new Blob([asset.bytes], { type: asset.type }));
455
+ try {
456
+ const canvas = makeCanvas(bitmap.width, bitmap.height);
457
+ const context = canvas.getContext("2d", { willReadFrequently: true });
458
+ if (!context) throw new Error("this browser cannot edit images — no 2D context");
459
+ context.drawImage(bitmap, 0, 0);
460
+ const source = context.getImageData(0, 0, bitmap.width, bitmap.height);
461
+
462
+ // A mask from the editor wins over growing a fresh one: it already IS
463
+ // the answer, corrected by hand at this picture's own resolution. It has
464
+ // to BE this picture's resolution, or it would land on the wrong pixels.
465
+ const pixels = { data: source.data, width: source.width, height: source.height };
466
+ if (options.mask && options.mask.length !== source.width * source.height) {
467
+ throw new Error("that selection was made on a different picture");
468
+ }
469
+ const mask = combineMask(options.mask ?? backgroundMask(pixels, options), options.paint ?? null, {
470
+ width: source.width,
471
+ height: source.height,
472
+ paintWidth: options.paintWidth ?? source.width,
473
+ paintHeight: options.paintHeight ?? source.height,
474
+ });
475
+ const cut = applyMask(pixels, mask, options);
476
+ const box = opaqueBounds(cut);
477
+ if (!box) throw new Error("that removed the whole picture — try a lower tolerance");
478
+ const trimmed = options.trim === false ? cut : cropPixels(cut, box);
479
+
480
+ const out = makeCanvas(trimmed.width, trimmed.height);
481
+ const outContext = out.getContext("2d");
482
+ outContext.putImageData(new ImageData(trimmed.data, trimmed.width, trimmed.height), 0, 0);
483
+ // PNG always: whatever came in, what goes out has alpha to carry.
484
+ const blob = await out.convertToBlob({ type: "image/png" });
485
+ const bytes = await blob.arrayBuffer();
486
+ const cutAsset = normalizeAsset({
487
+ id: await assetIdForBytes(bytes, subtle),
488
+ type: "image/png",
489
+ width: trimmed.width,
490
+ height: trimmed.height,
491
+ bytes,
492
+ name: asset.name,
493
+ });
494
+ if (!cutAsset) throw new Error("the cut picture could not be stored");
495
+ return {
496
+ asset: cutAsset,
497
+ heightScale: trimmed.height / (source.height || 1),
498
+ removed: cut.removed / (source.width * source.height),
499
+ };
500
+ } finally {
501
+ bitmap?.close?.();
502
+ }
503
+ }