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.
- package/CHANGELOG.md +85 -0
- package/README.md +31 -0
- package/THIRD_PARTY_NOTICES.md +37 -1
- package/bin/cozyclay.mjs +51 -2
- package/dist/ai-camera-control/index.html +406 -0
- package/dist/app/index.html +5 -5
- package/dist/assets/app-B3U5aut1.js +4811 -0
- package/dist/assets/app-BrRF0wso.css +1 -0
- package/dist/assets/vision_bundle-jFkh-fIS.js +41 -0
- package/dist/fonts/InstrumentSerif-OFL.txt +93 -0
- package/dist/fonts/Inter-OFL.txt +92 -0
- package/dist/fonts/README.md +15 -0
- package/dist/index.html +60 -16
- package/dist/sitemap.xml +7 -1
- package/mcp/LIVE-PROTOCOL.md +63 -0
- package/mcp/README.md +142 -0
- package/mcp/ardy-prompts.mjs +170 -0
- package/mcp/live-hub.mjs +105 -0
- package/mcp/package.json +24 -0
- package/mcp/server.mjs +1394 -0
- package/package.json +122 -90
- package/src/App.jsx +2957 -514
- package/src/ardy/cskel27.js +7 -2
- package/src/ardy/ik.js +25 -15
- package/src/ardy/npz.js +64 -3
- package/src/ardy/playback.js +31 -1
- package/src/ardy/prompt-clips.js +7 -2
- package/src/ardy/retime.js +211 -0
- package/src/ardy/timeline-coordinates.js +13 -0
- package/src/ardy/timeline.jsx +124 -5
- package/src/ardy/to-cskel27.js +34 -12
- package/src/ardy/trim.js +33 -0
- package/src/asset-pane.jsx +36 -0
- package/src/dualview.jsx +14 -8
- package/src/hierarchy-model.js +95 -13
- package/src/hierarchy-panel.jsx +139 -6
- package/src/live-control.js +122 -0
- package/src/matte-editor.js +543 -0
- package/src/matte.js +503 -0
- package/src/multimodel-ingest.js +344 -0
- package/src/object-gizmo.jsx +43 -15
- package/src/planview.jsx +43 -32
- package/src/pose-extract/detector.js +75 -0
- package/src/pose-extract/index.js +3 -0
- package/src/pose-extract/take.js +87 -0
- package/src/pose-extract/video-frames.js +91 -0
- package/src/pose-thumbs.js +152 -0
- package/src/posestudio.jsx +361 -11
- package/src/project-browser.jsx +135 -0
- package/src/project.js +289 -0
- package/src/props.jsx +69 -3
- package/src/room.jsx +14 -35
- package/src/scene-asset-cache.js +125 -0
- package/src/scene-assets.js +288 -0
- package/src/scene-objects.js +245 -7
- package/src/scenes.js +207 -26
- package/src/shot-authoring.js +55 -13
- package/src/styles.css +1296 -129
- package/tools/ardy/BRIDGE.md +3 -2
- package/tools/ardy/README.md +9 -5
- package/tools/ardy/bridge.mjs +57 -1
- package/tools/ardy/bvh-cskel27.mjs +1209 -0
- package/tools/ardy/cclay_constrained_generate.py +123 -11
- package/tools/ardy/cclay_sequence_generate.py +49 -0
- package/tools/ardy/extract.mjs +367 -0
- package/tools/ardy/footage.mjs +462 -0
- package/tools/ardy/npz.mjs +74 -9
- package/tools/ardy/run-on-box.sh +25 -0
- package/tools/ardy/run-sequence-on-box.sh +15 -0
- package/tools/ardy/runners/remote.mjs +8 -2
- package/dist/assets/app-Cgpk2hwX.js +0 -4803
- 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
|
+
}
|