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
@@ -0,0 +1,543 @@
1
+ /**
2
+ * The background editor: the photograph as it arrived, and a purple brush.
3
+ *
4
+ * The rule is that what is purple is what goes. Not a suggestion the machine
5
+ * then reinterprets — the purple IS the mask, and applying takes exactly those
6
+ * pixels, whether a third of the picture is marked or all of it.
7
+ *
8
+ * Neither brush is a pixel brush. A stroke is a set of SEEDS: on release, the
9
+ * same region growth the automatic pass uses runs from every place the brush
10
+ * touched, and the region it reaches is taken in one go. Dragging across a
11
+ * background is "cut this out", not "colour this in" — the brush says WHERE,
12
+ * the algorithm decides HOW FAR.
13
+ *
14
+ * The eraser is that same growth, fenced to what is already selected: drag
15
+ * over a piece of the subject that was cut by mistake and the whole wrongly
16
+ * taken region comes back, stopping exactly where the selection stopped. One
17
+ * tool, pointed either way.
18
+ *
19
+ * That is why the page opens on the untouched picture. An automatic cut shown
20
+ * before anyone asked for it hides the thing that matters: a hole in the
21
+ * subject and a piece of wall that survived both look like nothing once the
22
+ * background is gone. Purple inverts that — nothing photographed for a set is
23
+ * this violet, so every purple pixel is a decision you can see and argue with.
24
+ *
25
+ * The paint layer is kept at the picture's FULL resolution while interaction
26
+ * runs on a preview at most 512 px wide. Painting a preview and scaling the
27
+ * result up would turn a machine-cut edge into a staircase; painting into full
28
+ * resolution and only DISPLAYING the preview costs nothing extra and keeps the
29
+ * edge exactly as sharp as the picture is.
30
+ *
31
+ * No framework: a canvas, a pointer and two typed arrays, mounted the same way
32
+ * from the studio's React sidebar and from the plain-DOM bench.
33
+ */
34
+
35
+ import { backgroundMask, paintMask } from "./matte.js";
36
+
37
+ /** The overlay colour. Chosen to be impossible: nothing photographed for a set
38
+ * is this violet, so every purple pixel is a decision, not the picture. */
39
+ export const MATTE_PURPLE = [139, 92, 246];
40
+ const OVERLAY_ALPHA = 0.62;
41
+ /** The ground the picture sits on — the studio's own dark, so the stage reads
42
+ * as part of the panel rather than as a hole in it. */
43
+ const STAGE_GROUND = "#141819";
44
+ /** Interaction runs on a preview this wide at most — the paint underneath it
45
+ * stays at the picture's own resolution. */
46
+ export const EDIT_PREVIEW_MAX = 512;
47
+ /** Zoom range. 1 is the whole picture — there is no reason to look at less of
48
+ * it than that — and 16 puts a preview pixel under a fingertip. */
49
+ export const MIN_ZOOM = 1;
50
+ export const MAX_ZOOM = 16;
51
+ /** How many edits back you can walk. Each step is a full-resolution copy of
52
+ * the selection, so this is a memory budget as much as a usability one: seven
53
+ * is comfortably more than the "that stroke went too far" case a region brush
54
+ * actually produces, and eight masks of a 2048 px picture is a few megabytes. */
55
+ export const MATTE_HISTORY = 7;
56
+
57
+ const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
58
+
59
+ export function createMatteEditor(canvas, {
60
+ onChange = () => {},
61
+ createBitmap = globalThis.createImageBitmap,
62
+ makeCanvas = (width, height) => new OffscreenCanvas(width, height),
63
+ } = {}) {
64
+ const view = canvas.getContext("2d", { willReadFrequently: true });
65
+ const state = {
66
+ /** the picture at full size — what Auto-detect reads and what the paint
67
+ * layer is sized to */
68
+ full: null,
69
+ /** the same picture small enough to repaint on every pointer move */
70
+ preview: null,
71
+ /** 1 = purple = this goes. Full resolution, and the only mask there is. */
72
+ paint: null,
73
+ tolerance: 0.18,
74
+ brush: 18,
75
+ mode: "paint",
76
+ painting: false,
77
+ panning: null,
78
+ last: null,
79
+ /** where this stroke has touched, in full-resolution pixels — the seeds
80
+ * the growth runs from when the pointer comes up */
81
+ seeds: [],
82
+ /** the selection as it was when the stroke began. The eraser grows
83
+ * through THIS rather than through the live layer, which the stroke's
84
+ * own dabs are busy clearing. */
85
+ before: null,
86
+ /** What part of the picture the canvas is showing: a zoom factor and the
87
+ * top-left of the visible rect, in preview pixels. Zoom never goes below
88
+ * 1 and the rect is kept inside the picture, so there is no empty space
89
+ * to get lost in — at a hair's width the only thing worth looking at is
90
+ * the hair. */
91
+ view: { zoom: 1, x: 0, y: 0 },
92
+ /** offscreen copy of the whole preview, the source drawImage crops from */
93
+ buffer: null,
94
+ /** undo buffer: whole selections, newest last, current at `at` */
95
+ history: [],
96
+ at: -1,
97
+ };
98
+
99
+ /** Remember the selection as it stands. Called after a change has landed —
100
+ * a stroke that has finished growing, an auto-detect, a clear — never
101
+ * mid-drag, so one undo takes back one decision rather than one dab. */
102
+ function commit() {
103
+ if (!state.paint) return;
104
+ // Anything ahead of the cursor is a future that has just been replaced.
105
+ state.history.length = state.at + 1;
106
+ state.history.push(Uint8Array.from(state.paint));
107
+ while (state.history.length > MATTE_HISTORY + 1) state.history.shift();
108
+ state.at = state.history.length - 1;
109
+ }
110
+
111
+ function step(delta) {
112
+ const next = state.at + delta;
113
+ if (!state.paint || next < 0 || next >= state.history.length) return false;
114
+ state.at = next;
115
+ state.paint.set(state.history[next]);
116
+ repaint();
117
+ return true;
118
+ }
119
+
120
+ /** full-resolution pixels per preview pixel */
121
+ const ratio = () => (state.full && state.preview ? state.full.width / state.preview.width : 1);
122
+
123
+ function report() {
124
+ if (!state.paint) return onChange({ painted: 0, coverage: 0, zoom: 1, canUndo: false, canRedo: false });
125
+ let painted = 0;
126
+ for (let pixel = 0; pixel < state.paint.length; pixel++) painted += state.paint[pixel];
127
+ onChange({
128
+ painted,
129
+ coverage: painted / state.paint.length,
130
+ zoom: state.view.zoom,
131
+ canUndo: state.at > 0,
132
+ canRedo: state.at >= 0 && state.at < state.history.length - 1,
133
+ });
134
+ }
135
+
136
+ /** Repaint the preview: the picture, plus purple wherever the paint layer
137
+ * says a pixel goes. The mask is sampled from full resolution rather than
138
+ * kept in two places, so what is displayed can never drift from what will
139
+ * be applied. */
140
+ /**
141
+ * The canvas is a STAGE, and the picture sits on it.
142
+ *
143
+ * The obvious way to fit a picture into a panel is to let the element
144
+ * letterbox it — but then the bars live outside the canvas and stay there
145
+ * when you zoom, cropping the very edge you zoomed in to see. So the canvas
146
+ * fills its box, the ground is painted inside it, and the picture is drawn
147
+ * on top: at fit it sits in the middle with ground either side, and past fit
148
+ * it simply covers the stage. Nothing occludes it at any zoom.
149
+ */
150
+ function stageSize() {
151
+ const rect = canvas.getBoundingClientRect();
152
+ // The stage is measured in DEVICE pixels: on a retina panel a canvas
153
+ // backed at CSS size is visibly soft, and softness in a matte editor is
154
+ // a lie about where the edge is.
155
+ const dpr = Math.min(3, Math.max(1, globalThis.devicePixelRatio || 1));
156
+ const width = Math.max(1, Math.round((rect.width || canvas.width || 1) * dpr));
157
+ const height = Math.max(1, Math.round((rect.height || canvas.height || 1) * dpr));
158
+ return { width, height, left: rect.left, top: rect.top, dpr };
159
+ }
160
+
161
+ /** Stage pixels per preview pixel: the fit scale, times the zoom. */
162
+ function scaleOf(stage) {
163
+ if (!state.preview) return 1;
164
+ const fit = Math.min(stage.width / state.preview.width, stage.height / state.preview.height);
165
+ return fit * clamp(state.view.zoom, MIN_ZOOM, MAX_ZOOM);
166
+ }
167
+
168
+ /**
169
+ * Where the picture lands on the stage, and what it is centred on.
170
+ *
171
+ * The centre is clamped per axis: while the picture covers the stage on that
172
+ * axis it cannot be dragged far enough to open a gap, and while it does not
173
+ * (the letterboxed axis at fit) it stays centred, because sliding a picture
174
+ * around inside its own empty margin is not panning, it is drift.
175
+ */
176
+ function placement() {
177
+ const stage = stageSize();
178
+ const scale = scaleOf(stage);
179
+ const width = state.preview ? state.preview.width * scale : 0;
180
+ const height = state.preview ? state.preview.height * scale : 0;
181
+ const halfW = stage.width / 2 / scale;
182
+ const halfH = stage.height / 2 / scale;
183
+ const center = { x: state.view.x, y: state.view.y };
184
+ center.x = width >= stage.width
185
+ ? clamp(center.x, halfW, state.preview.width - halfW)
186
+ : state.preview.width / 2;
187
+ center.y = height >= stage.height
188
+ ? clamp(center.y, halfH, state.preview.height - halfH)
189
+ : state.preview.height / 2;
190
+ return {
191
+ stage,
192
+ scale,
193
+ center,
194
+ width,
195
+ height,
196
+ left: stage.width / 2 - center.x * scale,
197
+ top: stage.height / 2 - center.y * scale,
198
+ };
199
+ }
200
+
201
+ function repaint() {
202
+ if (!state.preview) return;
203
+ const { width, height, data } = state.preview;
204
+ const scale = ratio();
205
+ const fullWidth = state.full.width;
206
+ const out = new Uint8ClampedArray(data.length);
207
+ const [pr, pg, pb] = MATTE_PURPLE;
208
+ for (let y = 0; y < height; y++) {
209
+ const fy = Math.min(state.full.height - 1, Math.floor(y * scale));
210
+ for (let x = 0; x < width; x++) {
211
+ const index = (y * width + x) << 2;
212
+ const alpha = data[index + 3] / 255;
213
+ // Transparent parts of the source read as the page behind them, so
214
+ // they get the studio's own dark ground rather than black.
215
+ let r = data[index] * alpha + 26 * (1 - alpha);
216
+ let g = data[index + 1] * alpha + 30 * (1 - alpha);
217
+ let b = data[index + 2] * alpha + 32 * (1 - alpha);
218
+ if (state.paint[fy * fullWidth + Math.min(fullWidth - 1, Math.floor(x * scale))]) {
219
+ r = r * (1 - OVERLAY_ALPHA) + pr * OVERLAY_ALPHA;
220
+ g = g * (1 - OVERLAY_ALPHA) + pg * OVERLAY_ALPHA;
221
+ b = b * (1 - OVERLAY_ALPHA) + pb * OVERLAY_ALPHA;
222
+ }
223
+ out[index] = r;
224
+ out[index + 1] = g;
225
+ out[index + 2] = b;
226
+ out[index + 3] = 255;
227
+ }
228
+ }
229
+ if (!state.buffer || state.buffer.width !== width || state.buffer.height !== height) {
230
+ state.buffer = makeCanvas(width, height);
231
+ state.buffer.width = width;
232
+ state.buffer.height = height;
233
+ }
234
+ // The whole picture is composited once, then placed on the stage.
235
+ // Compositing only the visible part would be cheaper and wrong: the mask
236
+ // is sampled per preview pixel, and cropping first would resample twice.
237
+ state.buffer.getContext("2d").putImageData(new ImageData(out, width, height), 0, 0);
238
+
239
+ const stage = stageSize();
240
+ canvas.width = stage.width;
241
+ canvas.height = stage.height;
242
+ const place = placement();
243
+ state.view.x = place.center.x;
244
+ state.view.y = place.center.y;
245
+ view.fillStyle = STAGE_GROUND;
246
+ view.fillRect(0, 0, stage.width, stage.height);
247
+ // Nearest-neighbour past 2x: a matte edge is a decision about which
248
+ // pixels go, and smoothing it into a gradient is a lie about where it is.
249
+ view.imageSmoothingEnabled = state.view.zoom < 2;
250
+ view.drawImage(state.buffer, 0, 0, width, height, place.left, place.top, place.width, place.height);
251
+ report();
252
+ }
253
+
254
+ /* --------------------------------------------------------- pointer --- */
255
+
256
+ /** Where a pointer is, in FULL-resolution picture pixels: stage → picture,
257
+ * through the same placement the paint is drawn with. */
258
+ function at(event) {
259
+ const place = placement();
260
+ const dpr = place.stage.dpr;
261
+ const x = ((event.clientX - place.stage.left) * dpr - place.left) / place.scale;
262
+ const y = ((event.clientY - place.stage.top) * dpr - place.top) / place.scale;
263
+ return { x: x * ratio(), y: y * ratio() };
264
+ }
265
+
266
+ /** Is the pointer on the PICTURE, rather than on the ground beside it? A
267
+ * stroke that starts on the ground is not a stroke about the photograph. */
268
+ function inside(event) {
269
+ const place = placement();
270
+ const dpr = place.stage.dpr;
271
+ const x = (event.clientX - place.stage.left) * dpr - place.left;
272
+ const y = (event.clientY - place.stage.top) * dpr - place.top;
273
+ return x >= 0 && y >= 0 && x <= place.width && y <= place.height;
274
+ }
275
+
276
+ /** The brush is set in SCREEN pixels, so it shrinks in picture pixels as the
277
+ * picture is magnified — which is what makes zooming in the way to work an
278
+ * edge: the same wrist movement covers less photograph. */
279
+ function radius() {
280
+ const place = placement();
281
+ return Math.max(1, (((state.brush * place.stage.dpr) / 2) / place.scale) * ratio());
282
+ }
283
+
284
+ function stroke(from, to) {
285
+ const value = state.mode === "erase" ? 0 : 1;
286
+ const r = radius();
287
+ const steps = Math.max(1, Math.ceil(Math.hypot(to.x - from.x, to.y - from.y) / Math.max(1, r * 0.4)));
288
+ let touched = 0;
289
+ for (let step = 1; step <= steps; step++) {
290
+ const x = from.x + ((to.x - from.x) * step) / steps;
291
+ const y = from.y + ((to.y - from.y) * step) / steps;
292
+ touched += paintMask(state.paint, state.full, { x, y, radius: r, value });
293
+ // Every dab leaves a seed behind. The growth waits for the pointer to
294
+ // come up: one flood per stroke is fast, one per pointermove would be
295
+ // a full-resolution flood 60 times a second.
296
+ state.seeds.push({ x, y });
297
+ }
298
+ if (touched) repaint();
299
+ }
300
+
301
+ /** The stroke is over: run the growth from everywhere the brush touched. */
302
+ function growFromStroke() {
303
+ const seeds = state.seeds;
304
+ const before = state.before;
305
+ state.seeds = [];
306
+ state.before = null;
307
+ if (!seeds.length || !state.full) return;
308
+ const erasing = state.mode === "erase";
309
+ const reached = backgroundMask(state.full, {
310
+ points: seeds,
311
+ tolerance: state.tolerance,
312
+ // Erasing grows only through what was already purple, so it gives a
313
+ // wrongly-cut region back whole and cannot spill into the picture.
314
+ within: erasing ? before : null,
315
+ });
316
+ const value = erasing ? 0 : 1;
317
+ let changed = 0;
318
+ for (let pixel = 0; pixel < reached.length; pixel++) {
319
+ if (!reached[pixel] || state.paint[pixel] === value) continue;
320
+ state.paint[pixel] = value;
321
+ changed += 1;
322
+ }
323
+ if (changed) {
324
+ commit();
325
+ repaint();
326
+ }
327
+ }
328
+
329
+ const onDown = (event) => {
330
+ if (!state.preview || !inside(event)) return;
331
+ event.preventDefault();
332
+ canvas.setPointerCapture?.(event.pointerId);
333
+ // Middle button, or space held: this drag moves the view, not the mask.
334
+ if (event.button === 1 || spaceHeld) {
335
+ state.panning = { x: event.clientX, y: event.clientY };
336
+ return;
337
+ }
338
+ state.painting = true;
339
+ // The snapshot the eraser grows through, taken before this stroke's own
340
+ // dabs start clearing the layer it would otherwise read.
341
+ state.before = Uint8Array.from(state.paint);
342
+ state.last = at(event);
343
+ stroke(state.last, state.last);
344
+ };
345
+ const onMove = (event) => {
346
+ if (state.panning) {
347
+ const place = placement();
348
+ // Stage pixels to preview pixels, so the picture tracks the cursor
349
+ // exactly rather than sliding faster or slower than the hand.
350
+ const dpr = place.stage.dpr;
351
+ state.view.x = place.center.x - ((event.clientX - state.panning.x) * dpr) / place.scale;
352
+ state.view.y = place.center.y - ((event.clientY - state.panning.y) * dpr) / place.scale;
353
+ state.panning = { x: event.clientX, y: event.clientY };
354
+ repaint();
355
+ return;
356
+ }
357
+ if (!state.painting) return;
358
+ const point = at(event);
359
+ stroke(state.last, point);
360
+ state.last = point;
361
+ };
362
+ const onUp = (event) => {
363
+ if (state.panning) {
364
+ state.panning = null;
365
+ if (canvas.hasPointerCapture?.(event.pointerId)) canvas.releasePointerCapture(event.pointerId);
366
+ return;
367
+ }
368
+ if (!state.painting) return;
369
+ state.painting = false;
370
+ growFromStroke();
371
+ if (canvas.hasPointerCapture?.(event.pointerId)) canvas.releasePointerCapture(event.pointerId);
372
+ };
373
+ /* ------------------------------------------------------------ view --- */
374
+
375
+ /** Zoom about a point on screen, so what is under the cursor stays under
376
+ * the cursor — the only zoom that lets you keep working on the thing you
377
+ * were looking at. */
378
+ function zoomAt(clientX, clientY, factor) {
379
+ if (!state.preview) return;
380
+ const place = placement();
381
+ // The picture point under the cursor, before and after — it has to stay
382
+ // under the cursor, or zooming loses the thing you were looking at.
383
+ const dpr = place.stage.dpr;
384
+ const anchorX = ((clientX - place.stage.left) * dpr - place.left) / place.scale;
385
+ const anchorY = ((clientY - place.stage.top) * dpr - place.top) / place.scale;
386
+ state.view.zoom = clamp(state.view.zoom * factor, MIN_ZOOM, MAX_ZOOM);
387
+ const next = placement();
388
+ const offsetX = (clientX - next.stage.left) * next.stage.dpr - next.stage.width / 2;
389
+ const offsetY = (clientY - next.stage.top) * next.stage.dpr - next.stage.height / 2;
390
+ state.view.x = anchorX - offsetX / next.scale;
391
+ state.view.y = anchorY - offsetY / next.scale;
392
+ repaint();
393
+ }
394
+
395
+ const onWheel = (event) => {
396
+ if (!state.preview) return;
397
+ event.preventDefault();
398
+ zoomAt(event.clientX, event.clientY, event.deltaY < 0 ? 1.15 : 1 / 1.15);
399
+ };
400
+
401
+ // Middle button pans, and so does the left button while space is held —
402
+ // the two conventions people arrive with from every other paint tool.
403
+ let spaceHeld = false;
404
+ const onKeyDown = (event) => {
405
+ if (event.key === " ") spaceHeld = true;
406
+ };
407
+ const onKeyUp = (event) => {
408
+ if (event.key === " ") spaceHeld = false;
409
+ };
410
+ canvas.addEventListener("pointerdown", onDown);
411
+ canvas.addEventListener("pointermove", onMove);
412
+ canvas.addEventListener("pointerup", onUp);
413
+ canvas.addEventListener("pointercancel", onUp);
414
+ canvas.addEventListener("pointerleave", onUp);
415
+ canvas.addEventListener("wheel", onWheel, { passive: false });
416
+ canvas.addEventListener("keydown", onKeyDown);
417
+ canvas.addEventListener("keyup", onKeyUp);
418
+
419
+ /* ------------------------------------------------------------- api --- */
420
+
421
+ return {
422
+ /**
423
+ * Decode an asset and show it AS IT IS. Nothing is marked until someone
424
+ * asks — either with the brush or with Auto-detect.
425
+ */
426
+ async load(asset) {
427
+ const bitmap = await createBitmap(new Blob([asset.bytes], { type: asset.type }));
428
+ try {
429
+ const read = (width, height) => {
430
+ const work = makeCanvas(width, height);
431
+ const context = work.getContext("2d", { willReadFrequently: true });
432
+ context.drawImage(bitmap, 0, 0, width, height);
433
+ const image = context.getImageData(0, 0, width, height);
434
+ return { data: image.data, width, height };
435
+ };
436
+ state.full = read(bitmap.width, bitmap.height);
437
+ const scale = Math.min(1, EDIT_PREVIEW_MAX / Math.max(bitmap.width, bitmap.height));
438
+ state.preview = scale === 1
439
+ ? { data: Uint8ClampedArray.from(state.full.data), width: state.full.width, height: state.full.height }
440
+ : read(Math.max(1, Math.round(bitmap.width * scale)), Math.max(1, Math.round(bitmap.height * scale)));
441
+ state.paint = new Uint8Array(state.full.width * state.full.height);
442
+ state.view = { zoom: 1, x: state.preview.width / 2, y: state.preview.height / 2 };
443
+ state.buffer = null;
444
+ state.history = [];
445
+ state.at = -1;
446
+ commit();
447
+ repaint();
448
+ } finally {
449
+ bitmap?.close?.();
450
+ }
451
+ },
452
+
453
+ /** Put a saved selection back on the picture — the second half of a
454
+ * card's state, alongside the photograph itself. */
455
+ setMask(mask, width, height) {
456
+ if (!state.paint || !mask) return false;
457
+ if (width !== state.full.width || height !== state.full.height) return false;
458
+ state.paint.set(mask);
459
+ commit();
460
+ repaint();
461
+ return true;
462
+ },
463
+
464
+ /**
465
+ * The first pass, on request: grow the background from the frame's edges
466
+ * and paint what it reaches. It ADDS to the purple rather than replacing
467
+ * it, so running it again at a higher tolerance extends the selection
468
+ * instead of discarding the corrections already made by hand.
469
+ */
470
+ autoDetect(tolerance = state.tolerance) {
471
+ if (!state.full) return 0;
472
+ state.tolerance = clamp(Number(tolerance) || 0, 0.01, 0.9);
473
+ const found = backgroundMask(state.full, { tolerance: state.tolerance });
474
+ let added = 0;
475
+ for (let pixel = 0; pixel < found.length; pixel++) {
476
+ if (!found[pixel] || state.paint[pixel]) continue;
477
+ state.paint[pixel] = 1;
478
+ added += 1;
479
+ }
480
+ if (added) commit();
481
+ repaint();
482
+ return added;
483
+ },
484
+
485
+ /** Zoom about the middle of the canvas — what the +/− buttons do. */
486
+ zoomBy(factor) {
487
+ const stage = stageSize();
488
+ // The centre of the stage, back in client coordinates.
489
+ zoomAt(stage.left + stage.width / 2 / stage.dpr, stage.top + stage.height / 2 / stage.dpr, factor);
490
+ },
491
+ /** The whole picture again. */
492
+ fit() {
493
+ state.view = { zoom: 1, x: (state.preview?.width ?? 0) / 2, y: (state.preview?.height ?? 0) / 2 };
494
+ repaint();
495
+ },
496
+ zoom: () => state.view.zoom,
497
+ setTolerance(value) {
498
+ state.tolerance = clamp(Number(value) || 0, 0.01, 0.9);
499
+ },
500
+ setBrush(value) {
501
+ state.brush = clamp(Number(value) || 1, 2, 200);
502
+ },
503
+ /** "paint" lays purple down; "erase" takes it off. */
504
+ setMode(mode) {
505
+ state.mode = mode === "erase" ? "erase" : "paint";
506
+ },
507
+ mode: () => state.mode,
508
+ tolerance: () => state.tolerance,
509
+ /** Back to the untouched photograph. */
510
+ clear() {
511
+ if (!state.paint) return;
512
+ state.paint.fill(0);
513
+ commit();
514
+ repaint();
515
+ },
516
+ /** One decision back, and forward again. */
517
+ undo: () => step(-1),
518
+ redo: () => step(1),
519
+ canUndo: () => state.at > 0,
520
+ canRedo: () => state.at >= 0 && state.at < state.history.length - 1,
521
+ hasPaint: () => !!state.paint?.some((value) => value === 1),
522
+ /**
523
+ * What the cutter needs: the purple, at the picture's own resolution.
524
+ * No tolerance travels with it — the growth already happened, on screen,
525
+ * where it could be corrected.
526
+ */
527
+ options() {
528
+ return state.paint && this.hasPaint()
529
+ ? { mask: state.paint, maskWidth: state.full.width, maskHeight: state.full.height }
530
+ : null;
531
+ },
532
+ dispose() {
533
+ canvas.removeEventListener("pointerdown", onDown);
534
+ canvas.removeEventListener("pointermove", onMove);
535
+ canvas.removeEventListener("pointerup", onUp);
536
+ canvas.removeEventListener("pointercancel", onUp);
537
+ canvas.removeEventListener("pointerleave", onUp);
538
+ canvas.removeEventListener("wheel", onWheel);
539
+ canvas.removeEventListener("keydown", onKeyDown);
540
+ canvas.removeEventListener("keyup", onKeyUp);
541
+ },
542
+ };
543
+ }