@pixerate/editor 0.205.0 → 0.207.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.
@@ -4015,10 +4015,10 @@ function edgeWidth(guide, w, h, r) {
4015
4015
  for (let i = 0; i < n; i++) out[i] = (hi[i] - lo[i]) / Math.max(gm[i], 1e-4);
4016
4016
  return out;
4017
4017
  }
4018
- function refineCoverage(guide, m, w, h, params = {}) {
4018
+ function refineCoverage(guide, m, w, h, params = {}, band = null) {
4019
4019
  const p = normalizeRefineEdgeParams(params);
4020
4020
  let a = Float32Array.from(m);
4021
- if (p.radius > 0 && guide) {
4021
+ if ((p.radius > 0 || band) && guide) {
4022
4022
  const n = w * h;
4023
4023
  const boundary = new Uint8Array(n);
4024
4024
  let any = false;
@@ -4033,14 +4033,15 @@ function refineCoverage(guide, m, w, h, params = {}) {
4033
4033
  }
4034
4034
  }
4035
4035
  }
4036
- if (any) {
4036
+ if (any || band) {
4037
4037
  const dist = distanceTransform(boundary, w, h);
4038
- const r = gfRadius(p);
4038
+ const r = band ? Math.max(4, gfRadius(p)) : gfRadius(p);
4039
4039
  const q = guidedFilterColor(guide, m, w, h, r);
4040
4040
  const widths = p.smartRadius ? edgeWidth(guide, w, h, r) : null;
4041
4041
  for (let i = 0; i < n; i++) {
4042
4042
  const reff = widths ? Math.min(Math.max(p.radius, 1.5), Math.max(1.5, widths[i])) : p.radius;
4043
- const wgt = Math.min(1, Math.max(0, reff + 1 - dist[i]));
4043
+ const edge = p.radius > 0 ? Math.min(1, Math.max(0, reff + 1 - dist[i])) : 0;
4044
+ const wgt = band ? Math.max(edge, Math.min(1, Math.max(0, band[i]))) : edge;
4044
4045
  if (wgt > 0) {
4045
4046
  const qi = Math.min(1, Math.max(0, q[i]));
4046
4047
  a[i] = m[i] + (qi - m[i]) * wgt;
@@ -4081,17 +4082,24 @@ function guidePlanes(pixels, win) {
4081
4082
  }
4082
4083
  return [r, g, b];
4083
4084
  }
4084
- function refineSelection(pixels, mask, params = {}) {
4085
+ function refineSelection(pixels, mask, params = {}, refineBrush = null) {
4085
4086
  const p = normalizeRefineEdgeParams(params);
4086
4087
  if (pixels && (pixels.width !== mask.width || pixels.height !== mask.height)) {
4087
4088
  throw new Error("Refine Edge needs pixels the size of the selection");
4088
4089
  }
4089
4090
  if (!mask.bounds) return mask;
4090
- const halo = refineEdgeHalo(p);
4091
- const x0 = Math.max(0, mask.bounds.x - halo);
4092
- const y0 = Math.max(0, mask.bounds.y - halo);
4093
- const x1 = Math.min(mask.width, mask.bounds.x + mask.bounds.width + halo);
4094
- const y1 = Math.min(mask.height, mask.bounds.y + mask.bounds.height + halo);
4091
+ const brush = refineBrush?.bounds ? refineBrush : null;
4092
+ const halo = refineEdgeHalo(p) + (brush ? 2 * Math.max(4, gfRadius(p)) : 0);
4093
+ const b = brush ? {
4094
+ x: Math.min(mask.bounds.x, brush.bounds.x),
4095
+ y: Math.min(mask.bounds.y, brush.bounds.y),
4096
+ r: Math.max(mask.bounds.x + mask.bounds.width, brush.bounds.x + brush.bounds.width),
4097
+ btm: Math.max(mask.bounds.y + mask.bounds.height, brush.bounds.y + brush.bounds.height)
4098
+ } : { x: mask.bounds.x, y: mask.bounds.y, r: mask.bounds.x + mask.bounds.width, btm: mask.bounds.y + mask.bounds.height };
4099
+ const x0 = Math.max(0, b.x - halo);
4100
+ const y0 = Math.max(0, b.y - halo);
4101
+ const x1 = Math.min(mask.width, b.r + halo);
4102
+ const y1 = Math.min(mask.height, b.btm + halo);
4095
4103
  const ww = x1 - x0;
4096
4104
  const wh = y1 - y0;
4097
4105
  const m = new Float32Array(ww * wh);
@@ -4099,8 +4107,17 @@ function refineSelection(pixels, mask, params = {}) {
4099
4107
  const src = (y0 + y) * mask.width + x0;
4100
4108
  for (let x = 0; x < ww; x++) m[y * ww + x] = mask.data[src + x] / 255;
4101
4109
  }
4102
- const guide = pixels && p.radius > 0 ? guidePlanes(pixels, { x: x0, y: y0, width: ww, height: wh }) : null;
4103
- const refined = refineCoverage(guide, m, ww, wh, p);
4110
+ const guide = pixels && (p.radius > 0 || brush) ? guidePlanes(pixels, { x: x0, y: y0, width: ww, height: wh }) : null;
4111
+ let band = null;
4112
+ if (brush) {
4113
+ band = new Float32Array(ww * wh);
4114
+ const bd = brush.data;
4115
+ for (let y = 0; y < wh; y++) {
4116
+ const src = (y0 + y) * mask.width + x0;
4117
+ for (let x = 0; x < ww; x++) band[y * ww + x] = bd[src + x] / 255;
4118
+ }
4119
+ }
4120
+ const refined = refineCoverage(guide, m, ww, wh, p, band);
4104
4121
  const out = new Uint8Array(mask.width * mask.height);
4105
4122
  for (let y = 0; y < wh; y++) {
4106
4123
  const dst = (y0 + y) * mask.width + x0;
@@ -5302,6 +5319,78 @@ function segmentsToSelection(width, height, results) {
5302
5319
  return out;
5303
5320
  }
5304
5321
 
5322
+ // src/image-editor/selection/refine-edits.ts
5323
+ var REFINE_EDIT_KINDS = ["quick-select", "brush", "refine-brush", "lasso", "object"];
5324
+ function brushStrokeMask(width, height, points, size, hardness = 1) {
5325
+ const r = Math.max(0.5, size / 2);
5326
+ const solid = r * Math.min(1, Math.max(0, hardness));
5327
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
5328
+ for (const p of points) {
5329
+ x0 = Math.min(x0, p.x);
5330
+ y0 = Math.min(y0, p.y);
5331
+ x1 = Math.max(x1, p.x);
5332
+ y1 = Math.max(y1, p.y);
5333
+ }
5334
+ const bx = Math.max(0, Math.floor(x0 - r - 1));
5335
+ const by = Math.max(0, Math.floor(y0 - r - 1));
5336
+ const bw = Math.min(width, Math.ceil(x1 + r + 1)) - bx;
5337
+ const bh = Math.min(height, Math.ceil(y1 + r + 1)) - by;
5338
+ if (!(bw > 0 && bh > 0)) return createSelectionMask(width, height);
5339
+ const out = new Uint8Array(bw * bh);
5340
+ const segs = points.length === 1 ? [[points[0], points[0]]] : points.slice(1).map((p, i) => [points[i], p]);
5341
+ for (let y = 0; y < bh; y++) {
5342
+ for (let x = 0; x < bw; x++) {
5343
+ const px2 = bx + x + 0.5;
5344
+ const py = by + y + 0.5;
5345
+ let d = Infinity;
5346
+ for (const [a, b] of segs) {
5347
+ const dx = b.x - a.x;
5348
+ const dy = b.y - a.y;
5349
+ const len2 = dx * dx + dy * dy;
5350
+ const t = len2 > 0 ? Math.min(1, Math.max(0, ((px2 - a.x) * dx + (py - a.y) * dy) / len2)) : 0;
5351
+ d = Math.min(d, Math.hypot(px2 - a.x - t * dx, py - a.y - t * dy));
5352
+ }
5353
+ let v = d <= solid ? 1 : solid >= r ? 0 : 1 - (d - solid) / (r - solid);
5354
+ v = Math.min(v, Math.min(1, Math.max(0, r + 0.5 - d)));
5355
+ out[y * bw + x] = Math.round(Math.max(0, v) * 255);
5356
+ }
5357
+ }
5358
+ return selectionFromRegion(width, height, { x: bx, y: by, width: bw, height: bh }, out);
5359
+ }
5360
+ function applyRefineEdits(pixels, selection, edits, width, height) {
5361
+ let sel = selection?.bounds ? selection : null;
5362
+ let band = null;
5363
+ for (const e of edits) {
5364
+ if (e.kind === "object") {
5365
+ if (!pixels) throw new Error("Object Selection needs the image pixels.");
5366
+ if (!e.rect === !e.point) throw new Error("An object edit takes rect (a box around the object) or point (on it), not both.");
5367
+ const region2 = selectObjectIn(pixels, e.rect ? { rect: e.rect } : { point: e.point });
5368
+ sel = combineSelections(sel, region2, e.mode ?? "add");
5369
+ continue;
5370
+ }
5371
+ if (!e.points.length) throw new Error(`A ${e.kind} stroke needs at least one point.`);
5372
+ if (e.kind === "lasso") {
5373
+ if (e.points.length < 3) throw new Error("A lasso edit needs at least 3 points.");
5374
+ sel = combineSelections(sel, polygonSelection(width, height, e.points, {}), e.mode ?? "add");
5375
+ continue;
5376
+ }
5377
+ if (e.kind === "refine-brush") {
5378
+ band = combineSelections(band, brushStrokeMask(width, height, e.points, e.size ?? 40), "add");
5379
+ continue;
5380
+ }
5381
+ const mode = e.mode ?? "add";
5382
+ let region;
5383
+ if (e.kind === "quick-select") {
5384
+ if (!pixels) throw new Error("Quick Selection strokes need the image pixels.");
5385
+ region = quickSelectStroke(pixels, e.points, { size: e.size, selection: sel, grow: mode === "add" });
5386
+ } else {
5387
+ region = brushStrokeMask(width, height, e.points, e.size ?? 20, e.hardness ?? 1);
5388
+ }
5389
+ sel = combineSelections(sel, region, mode);
5390
+ }
5391
+ return { selection: sel, refineBrush: band };
5392
+ }
5393
+
5305
5394
  // src/image-editor/selection/stroke.ts
5306
5395
  function selectionRing(sel, width, location) {
5307
5396
  const half = location === "center" ? width / 2 : width;
@@ -10332,10 +10421,19 @@ var ImageEditorController = class {
10332
10421
  * - `new-layer-with-mask`: a copy of the layer masked by the selection, the source hidden.
10333
10422
  * `decontaminate` (0..100, new-layer outputs only) replaces the old background's colour in the
10334
10423
  * soft edge with the subject's own (see `decontaminateColors`).
10424
+ *
10425
+ * `edits` are the workspace's strokes, applied first in order (Quick Selection, Brush and
10426
+ * Refine Edge Brush; see `RefineEdit`); with them, an empty selection is a valid start.
10427
+ * `refineMode: 'object'` asks the segmentation provider for the object in the selection's
10428
+ * bounds and refines from its outline, kept to near the selection's edge.
10335
10429
  */
10336
10430
  async refineSelectionTo(params, output = "selection", options = {}) {
10337
- const sel = this.state.selection;
10338
- if (!sel?.bounds) throw new Error("Nothing is selected; select first.");
10431
+ const edits = options.edits ?? [];
10432
+ const refineMode = options.refineMode ?? "color";
10433
+ if (!this.state.selection?.bounds && !edits.length) throw new Error("Nothing is selected; select first, or pass edits that paint a selection.");
10434
+ if (refineMode === "object" && !this.segmentation) {
10435
+ throw new Error("Object aware refinement needs a segmentation model (setSegmentationProvider); use refineMode color.");
10436
+ }
10339
10437
  const amount = Math.min(100, Math.max(0, options.decontaminate ?? 0)) / 100;
10340
10438
  const toLayer = output === "new-layer" || output === "new-layer-with-mask";
10341
10439
  if (amount > 0 && !toLayer) throw new Error("Decontaminate colors writes the cleaned colours to a new layer: use output new-layer or new-layer-with-mask.");
@@ -10346,16 +10444,30 @@ var ImageEditorController = class {
10346
10444
  const layer = requireLayer(this.state.layers, layerId).layer;
10347
10445
  if (toLayer && layer.type !== "pixel") throw new LayerOperationError(`Output ${output} needs a pixel layer; "${layerId}" is a ${layer.type} layer.`);
10348
10446
  }
10349
- const pixels = (params.radius ?? 0) > 0 ? await this.requirePixels(options) : null;
10350
- const refined = refineSelection(pixels, sel, params);
10447
+ const needsPixels = (params.radius ?? 0) > 0 || edits.some((e) => e.kind !== "brush");
10448
+ const pixels = needsPixels ? await this.requirePixels(options) : null;
10449
+ const { width, height } = this.naturalSize;
10450
+ const edited = applyRefineEdits(pixels, this.state.selection ?? null, edits, width, height);
10451
+ let sel = edited.selection;
10452
+ if (!sel?.bounds) throw new Error("The edits leave nothing selected; paint with mode add first.");
10453
+ if (refineMode === "object") {
10454
+ const b = sel.bounds;
10455
+ const found = await this.modelObject({ rect: b }, options.signal);
10456
+ if (!found) throw new Error("The segmentation model found no object in the selection; use refineMode color.");
10457
+ const near = modifySelection(sel, "expand", Math.max(4, params.radius ?? 8));
10458
+ const clipped = combineSelections(found, near, "intersect");
10459
+ if (!clipped?.bounds) throw new Error("The segmentation model found no object in the selection; use refineMode color.");
10460
+ sel = clipped;
10461
+ }
10462
+ const refined = refineSelection(pixels, sel, params, edited.refineBrush);
10351
10463
  if (!refined.bounds) throw new Error("Refining left nothing selected; use a smaller shiftEdge or contrast.");
10352
10464
  return this.asOneStep(() => {
10353
10465
  this.setSelection(refined);
10354
- if (output === "selection") return { output };
10466
+ if (output === "selection") return { output, refineMode };
10355
10467
  if (output === "layer-mask") {
10356
10468
  this.addLayerMask(layerId, "selection");
10357
10469
  this.setSelection(null);
10358
- return { output, layerId };
10470
+ return { output, layerId, refineMode };
10359
10471
  }
10360
10472
  let newLayerId2;
10361
10473
  if (output === "new-layer") newLayerId2 = this.layerViaCopy({ layerId });
@@ -10367,7 +10479,7 @@ var ImageEditorController = class {
10367
10479
  if (amount > 0) this.decontaminateLayer(newLayerId2, layerId, refined, amount, Math.max(4, params.radius ?? 0));
10368
10480
  this.setLayerProperties(layerId, { visible: false });
10369
10481
  this.setSelection(null);
10370
- return { output, layerId, newLayerId: newLayerId2 };
10482
+ return { output, layerId, newLayerId: newLayerId2, refineMode };
10371
10483
  });
10372
10484
  }
10373
10485
  /**
@@ -13586,7 +13698,7 @@ function createImageSelectionTools(controller) {
13586
13698
  const refine = {
13587
13699
  name: "refine_selection",
13588
13700
  title: "Refine the selection edge",
13589
- description: "Select and Mask / Refine Edge: snaps the selection edge to the image within radius pixels (edge-aware guided filter), then smooth (0-100), feather (px), contrast (0-100) and shiftEdge (-100..100 %), and puts the result where output says: the selection (default), a layer-mask on layerId (default the active layer; then deselects), a new-layer of its selected pixels, or a new-layer-with-mask (a masked copy); the new-layer outputs hide the source layer. decontaminate (0-100, new-layer outputs only) replaces the old background colour in soft edges (hair, fur) with the subject's own. Use it after loading a coarse AI mask, or to cut a subject out cleanly. One undoable step. Returns the selection and, for layer outputs, layerId and newLayerId.",
13701
+ description: "Select and Mask / Refine Edge: snaps the selection edge to the image within radius pixels (edge-aware guided filter), then smooth (0-100), feather (px), contrast (0-100) and shiftEdge (-100..100 %), and puts the result where output says: the selection (default), a layer-mask on layerId (default the active layer; then deselects), a new-layer of its selected pixels, or a new-layer-with-mask (a masked copy); the new-layer outputs hide the source layer. decontaminate (0-100, new-layer outputs only) replaces the old background colour in soft edges (hair, fur) with the subject's own. Use it after loading a coarse AI mask, or to cut a subject out cleanly. edits are the Select and Mask workspace's strokes, applied first in order: quick-select (grows to the region's edges), brush (paints the selection in or out) and refine-brush (marks hair or fur for the edge to be re-estimated there); with edits, an empty selection is a valid start. refineMode object (needs a host segmentation model) refines from the model's outline of the object. One undoable step. Returns the selection, refineMode and, for layer outputs, layerId and newLayerId.",
13590
13702
  inputSchema: {
13591
13703
  type: "object",
13592
13704
  properties: {
@@ -13602,13 +13714,52 @@ function createImageSelectionTools(controller) {
13602
13714
  description: "Where the result goes (default selection). layer-mask keeps every pixel and is easiest to revise; new-layer-with-mask too, on a copy."
13603
13715
  },
13604
13716
  layerId: { type: "string", description: "The layer for layer outputs (default the active layer; see list_layers). New-layer outputs need a pixel layer." },
13605
- decontaminate: { type: "number", minimum: 0, maximum: 100, description: "Decontaminate Colors amount 0-100 (default 0), only with output new-layer or new-layer-with-mask." }
13717
+ decontaminate: { type: "number", minimum: 0, maximum: 100, description: "Decontaminate Colors amount 0-100 (default 0), only with output new-layer or new-layer-with-mask." },
13718
+ edits: {
13719
+ type: "array",
13720
+ description: "Strokes applied before refining, in order.",
13721
+ items: {
13722
+ type: "object",
13723
+ properties: {
13724
+ kind: {
13725
+ type: "string",
13726
+ enum: ["quick-select", "brush", "refine-brush", "lasso", "object"],
13727
+ description: "quick-select grows to edges; brush paints directly; refine-brush marks soft detail (hair) for the edge to be re-estimated; lasso adds or removes a polygon; object adds or removes the object in rect or under point."
13728
+ },
13729
+ points: { type: "array", minItems: 1, items: pointSchema, description: "The stroke path (or lasso outline, 3+ points) in natural image pixels; one point is a dab. Not for object." },
13730
+ rect: {
13731
+ type: "object",
13732
+ properties: rectProperties,
13733
+ required: ["x", "y", "width", "height"],
13734
+ additionalProperties: false,
13735
+ description: "object: a box around the object, in natural image pixels."
13736
+ },
13737
+ point: { ...pointSchema, description: "object: a point on the object, in natural image pixels." },
13738
+ size: { type: "number", minimum: 1, maximum: 2500, description: "Brush diameter in natural image pixels (default 30 quick-select, 20 brush, 40 refine-brush)." },
13739
+ mode: { type: "string", enum: ["add", "subtract"], description: "quick-select, brush, lasso and object: add (default) or subtract." },
13740
+ hardness: { type: "number", minimum: 0, maximum: 1, description: "brush: the solid share of the radius, 0..1 (default 1)." }
13741
+ },
13742
+ required: ["kind"],
13743
+ additionalProperties: false
13744
+ }
13745
+ },
13746
+ refineMode: { type: "string", enum: ["color", "object"], description: "color (default): refine from the image colours; object: from a host segmentation model's outline of the object (needs a model)." }
13606
13747
  },
13607
13748
  additionalProperties: false
13608
13749
  },
13609
13750
  annotations: writeAnnotations("Refine the selection edge"),
13610
13751
  handler: async (params) => {
13611
- if (!controller.getSelection()) throw new Error("Nothing is selected.");
13752
+ const edits = Array.isArray(params.edits) ? params.edits.map((e) => e.kind === "object" ? { ...e } : { ...e, points: (e.points ?? []).map((p) => ({ x: num2(p?.x, NaN), y: num2(p?.y, NaN) })) }) : [];
13753
+ for (const e of edits) {
13754
+ if (e.kind === "object") {
13755
+ if (!e.rect === !e.point) throw new Error("An object edit takes rect (a box around the object) or point (on it).");
13756
+ continue;
13757
+ }
13758
+ if (!e.points.length) throw new Error(`A ${e.kind} edit needs points.`);
13759
+ if (!e.points.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y))) throw new Error("Each edit needs points of { x, y } in natural image pixels.");
13760
+ if (e.kind === "refine-brush" && e.mode !== void 0) throw new Error("refine-brush strokes have no mode; they mark where the edge is re-estimated.");
13761
+ }
13762
+ if (!controller.getSelection() && !edits.length) throw new Error("Nothing is selected.");
13612
13763
  const result = await controller.refineSelectionTo(
13613
13764
  {
13614
13765
  radius: num2(params.radius, 8),
@@ -13619,7 +13770,12 @@ function createImageSelectionTools(controller) {
13619
13770
  shiftEdge: num2(params.shiftEdge, 0)
13620
13771
  },
13621
13772
  params.output ?? "selection",
13622
- { layerId: typeof params.layerId === "string" ? params.layerId : void 0, decontaminate: num2(params.decontaminate, 0) }
13773
+ {
13774
+ layerId: typeof params.layerId === "string" ? params.layerId : void 0,
13775
+ decontaminate: num2(params.decontaminate, 0),
13776
+ edits,
13777
+ refineMode: params.refineMode === "object" ? "object" : "color"
13778
+ }
13623
13779
  );
13624
13780
  return { ...summary(controller), ...result };
13625
13781
  }
@@ -19000,6 +19156,374 @@ function downloadMaskImage(controller, source, name = "mask") {
19000
19156
  return { fileName, width, height };
19001
19157
  }
19002
19158
 
19159
+ // src/image-editor/selection/select-and-mask.ts
19160
+ var SELECT_AND_MASK_VIEWS = [
19161
+ { id: "onion-skin", label: "Onion Skin", key: "o", opacity: 0.5 },
19162
+ { id: "marching-ants", label: "Marching Ants", key: "m", opacity: 1 },
19163
+ { id: "overlay", label: "Overlay", key: "v", opacity: 0.5 },
19164
+ { id: "on-black", label: "On Black", key: "a", opacity: 1 },
19165
+ { id: "on-white", label: "On White", key: "t", opacity: 1 },
19166
+ { id: "black-white", label: "Black & White", key: "k", opacity: 1 },
19167
+ { id: "on-layers", label: "On Layers", key: "y", opacity: 1 }
19168
+ ];
19169
+ var SELECT_AND_MASK_TOOLS = [
19170
+ { id: "quick-select", label: "Quick Selection", key: "w" },
19171
+ { id: "refine-brush", label: "Refine Edge Brush", key: "r" },
19172
+ { id: "brush", label: "Brush", key: "b" },
19173
+ { id: "object", label: "Object Selection", key: "q" },
19174
+ { id: "lasso", label: "Lasso", key: "l" },
19175
+ { id: "hand", label: "Hand", key: "h" },
19176
+ { id: "zoom", label: "Zoom", key: "z" }
19177
+ ];
19178
+ var DEFAULT_SELECT_AND_MASK_SETTINGS = {
19179
+ params: { ...DEFAULT_REFINE_EDGE_PARAMS },
19180
+ output: "selection",
19181
+ decontaminate: 0,
19182
+ refineMode: "color",
19183
+ view: "onion-skin",
19184
+ opacity: 0.5,
19185
+ showEdge: false,
19186
+ showOriginal: false,
19187
+ realTime: true,
19188
+ highQuality: false,
19189
+ remember: false,
19190
+ tool: "quick-select",
19191
+ brushSize: 30,
19192
+ brushMode: "add"
19193
+ };
19194
+ var PREVIEW_SIZE = 1024;
19195
+ var remembered = null;
19196
+ function scalePixels(pixels, w, h) {
19197
+ if (w === pixels.width && h === pixels.height) return pixels;
19198
+ const { width: W, height: H, data } = pixels;
19199
+ const sum = new Float64Array(w * h * 4);
19200
+ const count = new Float64Array(w * h);
19201
+ for (let y = 0; y < H; y++) {
19202
+ const row = Math.min(h - 1, Math.floor(y * h / H)) * w;
19203
+ for (let x = 0; x < W; x++) {
19204
+ const o = row + Math.min(w - 1, Math.floor(x * w / W));
19205
+ const p = (y * W + x) * 4;
19206
+ for (let c = 0; c < 4; c++) sum[o * 4 + c] += data[p + c];
19207
+ count[o]++;
19208
+ }
19209
+ }
19210
+ const out = new Uint8ClampedArray(w * h * 4);
19211
+ for (let i = 0; i < w * h; i++) for (let c = 0; c < 4; c++) out[i * 4 + c] = sum[i * 4 + c] / (count[i] || 1);
19212
+ return { width: w, height: h, data: out };
19213
+ }
19214
+ function scaleEdit(edit, s) {
19215
+ const pt = (p) => ({ x: p.x * s, y: p.y * s });
19216
+ if (edit.kind === "object") {
19217
+ return { ...edit, rect: edit.rect && { x: edit.rect.x * s, y: edit.rect.y * s, width: edit.rect.width * s, height: edit.rect.height * s }, point: edit.point && pt(edit.point) };
19218
+ }
19219
+ const scaled = { ...edit, points: edit.points.map(pt) };
19220
+ if ("size" in edit && edit.size !== void 0) scaled.size = Math.max(1, edit.size * s);
19221
+ return scaled;
19222
+ }
19223
+ function scaleParams(p, s) {
19224
+ return { ...p, radius: p.radius * s, feather: p.feather * s };
19225
+ }
19226
+ var SelectAndMaskSession = class {
19227
+ constructor(controller) {
19228
+ this.controller = controller;
19229
+ this.base = controller.getSelection();
19230
+ this.settingsValue = remembered ? { ...remembered, params: { ...remembered.params }, showOriginal: false } : { ...DEFAULT_SELECT_AND_MASK_SETTINGS, params: { ...DEFAULT_SELECT_AND_MASK_SETTINGS.params } };
19231
+ }
19232
+ controller;
19233
+ settingsValue;
19234
+ editsList = [];
19235
+ /** The edit being painted, shown in the preview but not yet in `edits`. */
19236
+ live = null;
19237
+ dragStart = null;
19238
+ pixels = null;
19239
+ previewPixels = null;
19240
+ base;
19241
+ previewMask = null;
19242
+ baseMask = null;
19243
+ listeners = /* @__PURE__ */ new Set();
19244
+ /** Why the preview could not be made (no pixels), for the UI. */
19245
+ error = null;
19246
+ /** A note for the UI, such as object aware refinement applying only on OK. */
19247
+ note = null;
19248
+ /** Bumped each time the preview changes, so a UI can redraw. */
19249
+ revision = 0;
19250
+ get settings() {
19251
+ return this.settingsValue;
19252
+ }
19253
+ get edits() {
19254
+ return this.editsList;
19255
+ }
19256
+ /** Reads the image and makes the first preview. Resolves to false when the pixels are unavailable. */
19257
+ async open() {
19258
+ try {
19259
+ this.pixels = await this.controller.getSamplePixels({});
19260
+ } catch (err) {
19261
+ this.error = err instanceof Error ? err.message : String(err);
19262
+ }
19263
+ if (!this.pixels) {
19264
+ this.error ??= "The image pixels are not available, so Select and Mask cannot preview.";
19265
+ this.emit();
19266
+ return false;
19267
+ }
19268
+ this.rescale();
19269
+ this.recompute();
19270
+ return true;
19271
+ }
19272
+ subscribe(listener) {
19273
+ this.listeners.add(listener);
19274
+ return () => this.listeners.delete(listener);
19275
+ }
19276
+ emit() {
19277
+ this.revision++;
19278
+ for (const l of this.listeners) l();
19279
+ }
19280
+ /** Preview size and the scale from natural pixels to it. */
19281
+ get previewSize() {
19282
+ const p = this.previewPixels;
19283
+ if (!p || !this.pixels) return { width: 0, height: 0, scale: 1 };
19284
+ return { width: p.width, height: p.height, scale: p.width / this.pixels.width };
19285
+ }
19286
+ /** Changes settings; the preview follows. Choosing a view also sets its usual opacity. */
19287
+ update(changes) {
19288
+ const next = { ...this.settingsValue, ...changes, params: { ...this.settingsValue.params, ...changes.params ?? {} } };
19289
+ if (changes.view && changes.view !== this.settingsValue.view && changes.opacity === void 0) {
19290
+ next.opacity = SELECT_AND_MASK_VIEWS.find((v) => v.id === changes.view).opacity;
19291
+ }
19292
+ const rescale = next.highQuality !== this.settingsValue.highQuality;
19293
+ this.settingsValue = next;
19294
+ if (rescale) this.rescale();
19295
+ this.recompute();
19296
+ }
19297
+ rescale() {
19298
+ const px2 = this.pixels;
19299
+ if (!px2) return;
19300
+ const s = this.settingsValue.highQuality ? 1 : Math.min(1, PREVIEW_SIZE / Math.max(px2.width, px2.height));
19301
+ const w = Math.max(1, Math.round(px2.width * s));
19302
+ const h = Math.max(1, Math.round(px2.height * s));
19303
+ this.previewPixels = scalePixels(px2, w, h);
19304
+ this.baseMask = this.base?.bounds ? s === 1 ? this.base : resizeSelection(this.base, w, h) : null;
19305
+ }
19306
+ /** Recomputes the preview mask from the base selection, the strokes and the settings. */
19307
+ recompute() {
19308
+ const px2 = this.previewPixels;
19309
+ if (!px2) return;
19310
+ const { scale } = this.previewSize;
19311
+ const edits = [...this.editsList, ...this.live ? [this.live] : []].map((e) => scaleEdit(e, scale));
19312
+ this.note = this.settingsValue.refineMode === "object" ? "Object aware refinement asks the segmentation model on OK; the preview refines by colour." : null;
19313
+ try {
19314
+ const { selection, refineBrush } = applyRefineEdits(px2, this.baseMask, edits, px2.width, px2.height);
19315
+ this.previewMask = selection?.bounds ? refineSelection(px2, selection, scaleParams(this.settingsValue.params, scale), refineBrush) : null;
19316
+ this.error = null;
19317
+ } catch (err) {
19318
+ this.previewMask = null;
19319
+ this.error = err instanceof Error ? err.message : String(err);
19320
+ }
19321
+ this.emit();
19322
+ }
19323
+ /** Coverage 0..1 of the preview at (x, y) in preview pixels (for tests and hover readouts). */
19324
+ coverageAt(x, y) {
19325
+ const m = this.settingsValue.showOriginal ? this.baseMask : this.previewMask;
19326
+ if (!m) return 0;
19327
+ const xi = Math.floor(x);
19328
+ const yi = Math.floor(y);
19329
+ if (xi < 0 || yi < 0 || xi >= m.width || yi >= m.height) return 0;
19330
+ return m.data[yi * m.width + xi] / 255;
19331
+ }
19332
+ /** The preview drawn in the current view mode: RGBA at {@link previewSize}. */
19333
+ render() {
19334
+ const px2 = this.previewPixels;
19335
+ if (!px2) return new Uint8ClampedArray(0);
19336
+ const { width: w, height: h } = px2;
19337
+ const n = w * h;
19338
+ const s = this.settingsValue;
19339
+ const mask = (s.showOriginal ? this.baseMask : this.previewMask) ?? createSelectionMask(w, h);
19340
+ const m = mask.data;
19341
+ const out = new Uint8ClampedArray(n * 4);
19342
+ const k = Math.min(1, Math.max(0, s.opacity));
19343
+ let edgeBand = null;
19344
+ if (s.showEdge) {
19345
+ edgeBand = new Uint8Array(n);
19346
+ const r = Math.max(1, Math.round(Math.max(s.params.radius, 2) * this.previewSize.scale));
19347
+ for (let y = 0; y < h; y++) {
19348
+ for (let x = 0; x < w; x++) {
19349
+ const v = m[y * w + x];
19350
+ if (v <= 3 || v >= 252) continue;
19351
+ for (let j = Math.max(0, y - r); j <= Math.min(h - 1, y + r); j++) edgeBand.fill(1, j * w + Math.max(0, x - r), j * w + Math.min(w, x + r + 1));
19352
+ }
19353
+ }
19354
+ }
19355
+ for (let i = 0; i < n; i++) {
19356
+ const a = m[i] / 255;
19357
+ const p = i * 4;
19358
+ const r = px2.data[p];
19359
+ const g = px2.data[p + 1];
19360
+ const b = px2.data[p + 2];
19361
+ let o;
19362
+ const x = i % w;
19363
+ const y = (i - x) / w;
19364
+ const checker = ((x >> 3) + (y >> 3)) % 2 ? 204 : 255;
19365
+ const hide = (1 - a) * k;
19366
+ switch (s.view) {
19367
+ case "black-white":
19368
+ o = [m[i], m[i], m[i]];
19369
+ break;
19370
+ case "overlay":
19371
+ o = [r + (255 - r) * hide, g * (1 - hide), b * (1 - hide)];
19372
+ break;
19373
+ case "on-black":
19374
+ o = [r * (1 - hide), g * (1 - hide), b * (1 - hide)];
19375
+ break;
19376
+ case "on-white":
19377
+ o = [r + (255 - r) * hide, g + (255 - g) * hide, b + (255 - b) * hide];
19378
+ break;
19379
+ case "marching-ants": {
19380
+ o = [r, g, b];
19381
+ const inside = m[i] >= 128;
19382
+ const edge = x > 0 && m[i - 1] >= 128 !== inside || x + 1 < w && m[i + 1] >= 128 !== inside || y > 0 && m[i - w] >= 128 !== inside || y + 1 < h && m[i + w] >= 128 !== inside;
19383
+ if (edge) {
19384
+ const v = (x + y >> 2) % 2 ? 0 : 255;
19385
+ o = [v, v, v];
19386
+ }
19387
+ break;
19388
+ }
19389
+ default:
19390
+ o = [r + (checker - r) * hide, g + (checker - g) * hide, b + (checker - b) * hide];
19391
+ }
19392
+ if (edgeBand && !edgeBand[i]) o = [o[0] * 0.15, o[1] * 0.15, o[2] * 0.15];
19393
+ out[p] = o[0];
19394
+ out[p + 1] = o[1];
19395
+ out[p + 2] = o[2];
19396
+ out[p + 3] = 255;
19397
+ }
19398
+ return out;
19399
+ }
19400
+ /**
19401
+ * Pointer down at `point` (natural pixels) with the current tool: starts a stroke (Quick
19402
+ * Selection, brushes, lasso) or a box (Object Selection). Alt (or the subtract mode) removes.
19403
+ * Hand and Zoom are the UI's own; they return false here.
19404
+ */
19405
+ begin(point, modifiers = {}) {
19406
+ const s = this.settingsValue;
19407
+ const mode = modifiers.altKey ? s.brushMode === "add" ? "subtract" : "add" : s.brushMode;
19408
+ this.dragStart = point;
19409
+ switch (s.tool) {
19410
+ case "quick-select":
19411
+ this.live = { kind: "quick-select", points: [point], size: s.brushSize, mode };
19412
+ break;
19413
+ case "brush":
19414
+ this.live = { kind: "brush", points: [point], size: s.brushSize, mode };
19415
+ break;
19416
+ case "refine-brush":
19417
+ this.live = { kind: "refine-brush", points: [point], size: s.brushSize };
19418
+ break;
19419
+ case "lasso":
19420
+ this.live = { kind: "lasso", points: [point], mode };
19421
+ break;
19422
+ case "object":
19423
+ this.live = { kind: "object", point, mode };
19424
+ break;
19425
+ default:
19426
+ this.dragStart = null;
19427
+ return false;
19428
+ }
19429
+ if (s.realTime && s.tool !== "lasso" && s.tool !== "object") this.recompute();
19430
+ return true;
19431
+ }
19432
+ /** Pointer move while a stroke is down. */
19433
+ move(point) {
19434
+ const live = this.live;
19435
+ if (!live || !this.dragStart) return;
19436
+ if (live.kind === "object") {
19437
+ const a = this.dragStart;
19438
+ const rect = { x: Math.min(a.x, point.x), y: Math.min(a.y, point.y), width: Math.abs(point.x - a.x), height: Math.abs(point.y - a.y) };
19439
+ this.live = rect.width >= 4 && rect.height >= 4 ? { kind: "object", rect, mode: live.mode } : { kind: "object", point: a, mode: live.mode };
19440
+ return;
19441
+ }
19442
+ const last = live.points[live.points.length - 1];
19443
+ if (Math.hypot(point.x - last.x, point.y - last.y) < 1) return;
19444
+ live.points.push(point);
19445
+ if (this.settingsValue.realTime && live.kind !== "lasso") this.recompute();
19446
+ }
19447
+ /** Pointer up: the stroke becomes an edit (one step of the workspace's own undo). */
19448
+ end() {
19449
+ const live = this.live;
19450
+ this.live = null;
19451
+ this.dragStart = null;
19452
+ if (!live) return;
19453
+ if (live.kind === "lasso" && live.points.length < 3) return;
19454
+ this.editsList.push(live);
19455
+ this.recompute();
19456
+ }
19457
+ /** Removes the last stroke (Ctrl/⌘+Z in the workspace). Returns false when there is none. */
19458
+ undoEdit() {
19459
+ if (!this.editsList.length) return false;
19460
+ this.editsList.pop();
19461
+ this.recompute();
19462
+ return true;
19463
+ }
19464
+ /** Clears the strokes and goes back to the default settings (Reset). */
19465
+ reset() {
19466
+ this.editsList.length = 0;
19467
+ this.settingsValue = { ...DEFAULT_SELECT_AND_MASK_SETTINGS, params: { ...DEFAULT_SELECT_AND_MASK_SETTINGS.params }, tool: this.settingsValue.tool };
19468
+ this.rescale();
19469
+ this.recompute();
19470
+ }
19471
+ /**
19472
+ * Photoshop's keys in the workspace: a view letter, F (next view), X (all views off and on:
19473
+ * Show Original), J (Show Edge), P (Show Original), a tool letter, [ and ] (brush size).
19474
+ * Returns true when the key was used.
19475
+ */
19476
+ handleKey(key) {
19477
+ const k = key.toLowerCase();
19478
+ const view = SELECT_AND_MASK_VIEWS.find((v) => v.key === k);
19479
+ if (view) {
19480
+ this.update({ view: view.id });
19481
+ return true;
19482
+ }
19483
+ const tool = SELECT_AND_MASK_TOOLS.find((t) => t.key === k);
19484
+ if (tool) {
19485
+ this.update({ tool: tool.id });
19486
+ return true;
19487
+ }
19488
+ if (k === "f") {
19489
+ const i = SELECT_AND_MASK_VIEWS.findIndex((v) => v.id === this.settingsValue.view);
19490
+ this.update({ view: SELECT_AND_MASK_VIEWS[(i + 1) % SELECT_AND_MASK_VIEWS.length].id });
19491
+ return true;
19492
+ }
19493
+ if (k === "p" || k === "x") {
19494
+ this.update({ showOriginal: !this.settingsValue.showOriginal });
19495
+ return true;
19496
+ }
19497
+ if (k === "j") {
19498
+ this.update({ showEdge: !this.settingsValue.showEdge });
19499
+ return true;
19500
+ }
19501
+ if (k === "[" || k === "]") {
19502
+ const size = this.settingsValue.brushSize;
19503
+ this.settingsValue = { ...this.settingsValue, brushSize: Math.max(1, Math.round(k === "]" ? size * 1.25 : size / 1.25)) };
19504
+ this.emit();
19505
+ return true;
19506
+ }
19507
+ return false;
19508
+ }
19509
+ /**
19510
+ * OK: applies the strokes and settings to the image as one undo step (the same call an agent
19511
+ * makes) and remembers the settings when asked to.
19512
+ */
19513
+ async commit() {
19514
+ const s = this.settingsValue;
19515
+ remembered = s.remember ? { ...s, params: { ...s.params } } : null;
19516
+ return this.controller.refineSelectionTo(s.params, s.output, {
19517
+ edits: [...this.editsList],
19518
+ decontaminate: s.output === "new-layer" || s.output === "new-layer-with-mask" ? s.decontaminate : 0,
19519
+ refineMode: s.refineMode
19520
+ });
19521
+ }
19522
+ };
19523
+ function forgetSelectAndMaskSettings() {
19524
+ remembered = null;
19525
+ }
19526
+
19003
19527
  // src/image-editor/mosaic-filter.ts
19004
19528
  function resolveMosaicRegionBounds(region, imgWidth, imgHeight) {
19005
19529
  if (imgWidth <= 0 || imgHeight <= 0 || region.width <= 0 || region.height <= 0) {
@@ -20699,6 +21223,7 @@ export {
20699
21223
  DEFAULT_REFINE_EDGE_PARAMS,
20700
21224
  DEFAULT_RETOUCH,
20701
21225
  DEFAULT_SELECTION_TOOL_OPTIONS,
21226
+ DEFAULT_SELECT_AND_MASK_SETTINGS,
20702
21227
  DEFAULT_SHAPE_TOOL_OPTIONS,
20703
21228
  DEFAULT_SMUDGE,
20704
21229
  DEFAULT_TEXT_STYLE,
@@ -20816,6 +21341,7 @@ export {
20816
21341
  PIGMENT_WAVELENGTHS,
20817
21342
  PLANE,
20818
21343
  PLANE_BYTES,
21344
+ PREVIEW_SIZE,
20819
21345
  PROOF_PROFILE_OPTIONS,
20820
21346
  PSD_BLEND_KEYS,
20821
21347
  PSD_FILE_ACCEPT,
@@ -20840,6 +21366,7 @@ export {
20840
21366
  PsdError,
20841
21367
  QUICK_MASK_TARGET,
20842
21368
  REFINE_EDGE_CONTROLS,
21369
+ REFINE_EDIT_KINDS,
20843
21370
  REFINE_OUTPUTS,
20844
21371
  RETOUCH_CHOICES,
20845
21372
  RETOUCH_OPTION_CONTROLS,
@@ -20852,6 +21379,8 @@ export {
20852
21379
  SELECTION_MODE_LABELS,
20853
21380
  SELECTION_MODIFY_OPS,
20854
21381
  SELECTION_TOOLS,
21382
+ SELECT_AND_MASK_TOOLS,
21383
+ SELECT_AND_MASK_VIEWS,
20855
21384
  SET_PLANES,
20856
21385
  SHADOW_AMBIENT,
20857
21386
  SHAPE_KIND_LABELS,
@@ -20865,6 +21394,7 @@ export {
20865
21394
  SRGB_TO_LINEAR,
20866
21395
  STRIP,
20867
21396
  SUPPORTED_ADJUSTMENT_KEYS,
21397
+ SelectAndMaskSession,
20868
21398
  SelectionGesture,
20869
21399
  ShapeDrawGesture,
20870
21400
  SmudgeTip,
@@ -20952,6 +21482,7 @@ export {
20952
21482
  applyParams,
20953
21483
  applyProcess2Adjustments,
20954
21484
  applyProcess2Float,
21485
+ applyRefineEdits,
20955
21486
  applyRegionBlur,
20956
21487
  applyRegionMosaic,
20957
21488
  applyRetouchToTile,
@@ -20984,6 +21515,7 @@ export {
20984
21515
  boxQuad,
20985
21516
  boxToCurvePoint,
20986
21517
  bradfordAdaptation,
21518
+ brushStrokeMask,
20987
21519
  brushstrokeDataUrl,
20988
21520
  brushstrokeKey,
20989
21521
  brushstrokeSamples,
@@ -21184,6 +21716,7 @@ export {
21184
21716
  focusArea,
21185
21717
  followLayerMove,
21186
21718
  fontOf,
21719
+ forgetSelectAndMaskSettings,
21187
21720
  formatControlValue,
21188
21721
  fullSelection,
21189
21722
  gaussianBlur,