@pixerate/editor 0.204.0 → 0.206.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;
@@ -4108,6 +4125,49 @@ function refineSelection(pixels, mask, params = {}) {
4108
4125
  }
4109
4126
  return createSelectionMask(mask.width, mask.height, out);
4110
4127
  }
4128
+ function decontaminateColors(rgba, coverage, w, h, amount, radius = 8) {
4129
+ const out = Uint8ClampedArray.from(rgba);
4130
+ const n = w * h;
4131
+ const k = Math.min(1, Math.max(0, amount));
4132
+ if (k === 0) return out;
4133
+ const r = Math.max(2, Math.round(radius));
4134
+ const fgW = new Float32Array(n);
4135
+ const bgW = new Float32Array(n);
4136
+ for (let i = 0; i < n; i++) {
4137
+ fgW[i] = coverage[i] >= 250 ? 1 : 0;
4138
+ bgW[i] = coverage[i] <= 5 ? 1 : 0;
4139
+ }
4140
+ const meanOf = (weights, radiusPx) => {
4141
+ const wm = boxMean(weights, w, h, radiusPx);
4142
+ const planes = [0, 1, 2].map((c) => {
4143
+ const v = new Float32Array(n);
4144
+ for (let i = 0; i < n; i++) v[i] = rgba[i * 4 + c] * weights[i];
4145
+ const m = boxMean(v, w, h, radiusPx);
4146
+ for (let i = 0; i < n; i++) m[i] = wm[i] > 1e-6 ? m[i] / wm[i] : NaN;
4147
+ return m;
4148
+ });
4149
+ return { weight: wm, planes };
4150
+ };
4151
+ const fg = meanOf(fgW, r);
4152
+ const fgWide = meanOf(fgW, r * 3);
4153
+ const bg = meanOf(bgW, r);
4154
+ for (let i = 0; i < n; i++) {
4155
+ const c = coverage[i];
4156
+ if (c <= 5 || c >= 250) continue;
4157
+ const a = c / 255;
4158
+ for (let ch = 0; ch < 3; ch++) {
4159
+ const C = rgba[i * 4 + ch];
4160
+ const B = bg.planes[ch][i];
4161
+ const Fm = Number.isNaN(fg.planes[ch][i]) ? fgWide.planes[ch][i] : fg.planes[ch][i];
4162
+ let F;
4163
+ if (a >= 0.5 && !Number.isNaN(B)) F = (C - (1 - a) * B) / a;
4164
+ else if (!Number.isNaN(Fm)) F = Fm;
4165
+ else F = C;
4166
+ out[i * 4 + ch] = Math.round(C + k * (Math.min(255, Math.max(0, F)) - C));
4167
+ }
4168
+ }
4169
+ return out;
4170
+ }
4111
4171
 
4112
4172
  // src/image-editor/selection/segment/guided.ts
4113
4173
  function workPlanes(work) {
@@ -5259,6 +5319,66 @@ function segmentsToSelection(width, height, results) {
5259
5319
  return out;
5260
5320
  }
5261
5321
 
5322
+ // src/image-editor/selection/refine-edits.ts
5323
+ var REFINE_EDIT_KINDS = ["quick-select", "brush", "refine-brush"];
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.points.length) throw new Error(`A ${e.kind} stroke needs at least one point.`);
5365
+ if (e.kind === "refine-brush") {
5366
+ band = combineSelections(band, brushStrokeMask(width, height, e.points, e.size ?? 40), "add");
5367
+ continue;
5368
+ }
5369
+ const mode = e.mode ?? "add";
5370
+ let region;
5371
+ if (e.kind === "quick-select") {
5372
+ if (!pixels) throw new Error("Quick Selection strokes need the image pixels.");
5373
+ region = quickSelectStroke(pixels, e.points, { size: e.size, selection: sel, grow: mode === "add" });
5374
+ } else {
5375
+ region = brushStrokeMask(width, height, e.points, e.size ?? 20, e.hardness ?? 1);
5376
+ }
5377
+ sel = combineSelections(sel, region, mode);
5378
+ }
5379
+ return { selection: sel, refineBrush: band };
5380
+ }
5381
+
5262
5382
  // src/image-editor/selection/stroke.ts
5263
5383
  function selectionRing(sel, width, location) {
5264
5384
  const half = location === "center" ? width / 2 : width;
@@ -10280,6 +10400,107 @@ var ImageEditorController = class {
10280
10400
  const pixels = (params.radius ?? 0) > 0 ? await this.requirePixels(options) : null;
10281
10401
  return this.setSelection(refineSelection(pixels, sel, params));
10282
10402
  }
10403
+ /**
10404
+ * Select and Mask's output: refines the selection with `params`, then puts the result where
10405
+ * `output` says, as one undo step:
10406
+ * - `selection`: the refined selection;
10407
+ * - `layer-mask`: a mask on the layer (`layerId`, default the active one), then deselects;
10408
+ * - `new-layer`: a new layer of the layer's selected pixels (Layer via Copy), the source hidden;
10409
+ * - `new-layer-with-mask`: a copy of the layer masked by the selection, the source hidden.
10410
+ * `decontaminate` (0..100, new-layer outputs only) replaces the old background's colour in the
10411
+ * soft edge with the subject's own (see `decontaminateColors`).
10412
+ *
10413
+ * `edits` are the workspace's strokes, applied first in order (Quick Selection, Brush and
10414
+ * Refine Edge Brush; see `RefineEdit`); with them, an empty selection is a valid start.
10415
+ * `refineMode: 'object'` asks the segmentation provider for the object in the selection's
10416
+ * bounds and refines from its outline, kept to near the selection's edge.
10417
+ */
10418
+ async refineSelectionTo(params, output = "selection", options = {}) {
10419
+ const edits = options.edits ?? [];
10420
+ const refineMode = options.refineMode ?? "color";
10421
+ if (!this.state.selection?.bounds && !edits.length) throw new Error("Nothing is selected; select first, or pass edits that paint a selection.");
10422
+ if (refineMode === "object" && !this.segmentation) {
10423
+ throw new Error("Object aware refinement needs a segmentation model (setSegmentationProvider); use refineMode color.");
10424
+ }
10425
+ const amount = Math.min(100, Math.max(0, options.decontaminate ?? 0)) / 100;
10426
+ const toLayer = output === "new-layer" || output === "new-layer-with-mask";
10427
+ 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.");
10428
+ let layerId;
10429
+ if (output !== "selection") {
10430
+ layerId = options.layerId ?? this.state.activeLayerId ?? void 0;
10431
+ if (!layerId) throw new LayerOperationError("No layer given and none is active; pass layerId (see list_layers).");
10432
+ const layer = requireLayer(this.state.layers, layerId).layer;
10433
+ if (toLayer && layer.type !== "pixel") throw new LayerOperationError(`Output ${output} needs a pixel layer; "${layerId}" is a ${layer.type} layer.`);
10434
+ }
10435
+ const needsPixels = (params.radius ?? 0) > 0 || edits.some((e) => e.kind !== "brush");
10436
+ const pixels = needsPixels ? await this.requirePixels(options) : null;
10437
+ const { width, height } = this.naturalSize;
10438
+ const edited = applyRefineEdits(pixels, this.state.selection ?? null, edits, width, height);
10439
+ let sel = edited.selection;
10440
+ if (!sel?.bounds) throw new Error("The edits leave nothing selected; paint with mode add first.");
10441
+ if (refineMode === "object") {
10442
+ const b = sel.bounds;
10443
+ const found = await this.modelObject({ rect: b }, options.signal);
10444
+ if (!found) throw new Error("The segmentation model found no object in the selection; use refineMode color.");
10445
+ const near = modifySelection(sel, "expand", Math.max(4, params.radius ?? 8));
10446
+ const clipped = combineSelections(found, near, "intersect");
10447
+ if (!clipped?.bounds) throw new Error("The segmentation model found no object in the selection; use refineMode color.");
10448
+ sel = clipped;
10449
+ }
10450
+ const refined = refineSelection(pixels, sel, params, edited.refineBrush);
10451
+ if (!refined.bounds) throw new Error("Refining left nothing selected; use a smaller shiftEdge or contrast.");
10452
+ return this.asOneStep(() => {
10453
+ this.setSelection(refined);
10454
+ if (output === "selection") return { output, refineMode };
10455
+ if (output === "layer-mask") {
10456
+ this.addLayerMask(layerId, "selection");
10457
+ this.setSelection(null);
10458
+ return { output, layerId, refineMode };
10459
+ }
10460
+ let newLayerId2;
10461
+ if (output === "new-layer") newLayerId2 = this.layerViaCopy({ layerId });
10462
+ else {
10463
+ newLayerId2 = this.duplicateLayer(layerId);
10464
+ this.addLayerMask(newLayerId2, "selection");
10465
+ this.state.activeTarget = "pixels";
10466
+ }
10467
+ if (amount > 0) this.decontaminateLayer(newLayerId2, layerId, refined, amount, Math.max(4, params.radius ?? 0));
10468
+ this.setLayerProperties(layerId, { visible: false });
10469
+ this.setSelection(null);
10470
+ return { output, layerId, newLayerId: newLayerId2, refineMode };
10471
+ });
10472
+ }
10473
+ /**
10474
+ * Decontaminate Colors along the soft edge of `coverage`: the colours are estimated from the
10475
+ * source layer (whose background is still there) and written into layer `id`'s RGB, keeping
10476
+ * its alpha.
10477
+ */
10478
+ decontaminateLayer(id, sourceId, coverage, amount, radius) {
10479
+ const layer = requireLayer(this.state.layers, id).layer;
10480
+ const source = requireLayer(this.state.layers, sourceId).layer;
10481
+ if (layer.type !== "pixel" || source.type !== "pixel" || !coverage.bounds) return;
10482
+ const { width, height } = this.naturalSize;
10483
+ const pad = Math.ceil(radius * 3);
10484
+ const b = coverage.bounds;
10485
+ const x0 = Math.max(0, b.x - pad);
10486
+ const y0 = Math.max(0, b.y - pad);
10487
+ const box = { x: x0, y: y0, width: Math.min(width, b.x + b.width + pad) - x0, height: Math.min(height, b.y + b.height + pad) - y0 };
10488
+ const src = { x: box.x - layer.offset.x, y: box.y - layer.offset.y, width: box.width, height: box.height };
10489
+ const surface = this.surfaceOf(layer);
10490
+ const rgba = readRegion(surface, src);
10491
+ const original = readRegion(this.surfaceOf(source), { x: box.x - source.offset.x, y: box.y - source.offset.y, width: box.width, height: box.height });
10492
+ const cov = new Uint8Array(box.width * box.height);
10493
+ const all = coverage.data;
10494
+ for (let y = 0; y < box.height; y++) cov.set(all.subarray((box.y + y) * width + box.x, (box.y + y) * width + box.x + box.width), y * box.width);
10495
+ const cleaned = decontaminateColors(original, cov, box.width, box.height, amount, radius);
10496
+ for (let i = 3; i < cleaned.length; i += 4) cleaned[i] = rgba[i];
10497
+ const next = mapLayer(this.state.layers, id, (l) => {
10498
+ const updated = { ...l, surface: writeRegion(surface, src, cleaned) };
10499
+ delete updated.source;
10500
+ return updated;
10501
+ });
10502
+ this.commitLayers(next);
10503
+ }
10283
10504
  /**
10284
10505
  * Loads a selection from a mask image (an AI segmentation, a saved mask): a data URL / URL or
10285
10506
  * RGBA pixels, by luminance (white = selected) or a channel. Any size; it is resampled.
@@ -13251,6 +13472,12 @@ var REFINE_EDGE_CONTROLS = [
13251
13472
  { id: "contrast", label: "Contrast", min: 0, max: 100, step: 1, unit: "%" },
13252
13473
  { id: "shiftEdge", label: "Shift Edge", min: -100, max: 100, step: 1, unit: "%" }
13253
13474
  ];
13475
+ var REFINE_OUTPUTS = [
13476
+ { id: "selection", label: "Selection" },
13477
+ { id: "layer-mask", label: "Layer Mask" },
13478
+ { id: "new-layer", label: "New Layer" },
13479
+ { id: "new-layer-with-mask", label: "New Layer with Layer Mask" }
13480
+ ];
13254
13481
  function describeSelection(mask) {
13255
13482
  if (!mask || !mask.bounds) return "No selection";
13256
13483
  const pct2 = selectionArea(mask) / (mask.width * mask.height) * 100;
@@ -13459,31 +13686,69 @@ function createImageSelectionTools(controller) {
13459
13686
  const refine = {
13460
13687
  name: "refine_selection",
13461
13688
  title: "Refine the selection edge",
13462
- description: "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 %). Use it after loading a coarse AI mask. One undoable step.",
13689
+ 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.",
13463
13690
  inputSchema: {
13464
13691
  type: "object",
13465
13692
  properties: {
13466
- radius: { type: "number", minimum: 0, maximum: 64, description: "Edge detection radius in pixels." },
13693
+ radius: { type: "number", minimum: 0, maximum: 64, description: "Edge detection radius in pixels (default 8)." },
13467
13694
  smartRadius: { type: "boolean", description: "Adapt the radius to hard and soft edges." },
13468
13695
  smooth: { type: "number", minimum: 0, maximum: 100, description: "Smooth the edge outline (0-100)." },
13469
13696
  feather: { type: "number", minimum: 0, maximum: 250, description: "Feather the edge (pixels)." },
13470
13697
  contrast: { type: "number", minimum: 0, maximum: 100, description: "Sharpen the edge transition (0-100)." },
13471
- shiftEdge: { type: "number", minimum: -100, maximum: 100, description: "Move the edge out (+) or in (-), in percent." }
13698
+ shiftEdge: { type: "number", minimum: -100, maximum: 100, description: "Move the edge out (+) or in (-), in percent." },
13699
+ output: {
13700
+ type: "string",
13701
+ enum: ["selection", "layer-mask", "new-layer", "new-layer-with-mask"],
13702
+ 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."
13703
+ },
13704
+ layerId: { type: "string", description: "The layer for layer outputs (default the active layer; see list_layers). New-layer outputs need a pixel layer." },
13705
+ 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." },
13706
+ edits: {
13707
+ type: "array",
13708
+ description: "Strokes applied before refining, in order.",
13709
+ items: {
13710
+ type: "object",
13711
+ properties: {
13712
+ kind: { type: "string", enum: ["quick-select", "brush", "refine-brush"], description: "quick-select grows to edges; brush paints directly; refine-brush marks soft detail (hair) for the edge to be re-estimated." },
13713
+ points: { type: "array", minItems: 1, items: pointSchema, description: "The stroke path in natural image pixels (one point is a dab)." },
13714
+ size: { type: "number", minimum: 1, maximum: 2500, description: "Brush diameter in natural image pixels (default 30 quick-select, 20 brush, 40 refine-brush)." },
13715
+ mode: { type: "string", enum: ["add", "subtract"], description: "quick-select and brush: add (default) or subtract." },
13716
+ hardness: { type: "number", minimum: 0, maximum: 1, description: "brush: the solid share of the radius, 0..1 (default 1)." }
13717
+ },
13718
+ required: ["kind", "points"],
13719
+ additionalProperties: false
13720
+ }
13721
+ },
13722
+ 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)." }
13472
13723
  },
13473
13724
  additionalProperties: false
13474
13725
  },
13475
13726
  annotations: writeAnnotations("Refine the selection edge"),
13476
13727
  handler: async (params) => {
13477
- if (!controller.getSelection()) throw new Error("Nothing is selected.");
13478
- await controller.refineSelection({
13479
- radius: num2(params.radius, 8),
13480
- smartRadius: params.smartRadius === true,
13481
- smooth: num2(params.smooth, 0),
13482
- feather: num2(params.feather, 0),
13483
- contrast: num2(params.contrast, 0),
13484
- shiftEdge: num2(params.shiftEdge, 0)
13485
- });
13486
- return summary(controller);
13728
+ const edits = Array.isArray(params.edits) ? params.edits.map((e) => ({ ...e, points: (e.points ?? []).map((p) => ({ x: num2(p?.x, NaN), y: num2(p?.y, NaN) })) })) : [];
13729
+ for (const e of edits) {
13730
+ 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.");
13731
+ 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.");
13732
+ }
13733
+ if (!controller.getSelection() && !edits.length) throw new Error("Nothing is selected.");
13734
+ const result = await controller.refineSelectionTo(
13735
+ {
13736
+ radius: num2(params.radius, 8),
13737
+ smartRadius: params.smartRadius === true,
13738
+ smooth: num2(params.smooth, 0),
13739
+ feather: num2(params.feather, 0),
13740
+ contrast: num2(params.contrast, 0),
13741
+ shiftEdge: num2(params.shiftEdge, 0)
13742
+ },
13743
+ params.output ?? "selection",
13744
+ {
13745
+ layerId: typeof params.layerId === "string" ? params.layerId : void 0,
13746
+ decontaminate: num2(params.decontaminate, 0),
13747
+ edits,
13748
+ refineMode: params.refineMode === "object" ? "object" : "color"
13749
+ }
13750
+ );
13751
+ return { ...summary(controller), ...result };
13487
13752
  }
13488
13753
  };
13489
13754
  const applyMask = {
@@ -20702,6 +20967,8 @@ export {
20702
20967
  PsdError,
20703
20968
  QUICK_MASK_TARGET,
20704
20969
  REFINE_EDGE_CONTROLS,
20970
+ REFINE_EDIT_KINDS,
20971
+ REFINE_OUTPUTS,
20705
20972
  RETOUCH_CHOICES,
20706
20973
  RETOUCH_OPTION_CONTROLS,
20707
20974
  RETOUCH_SAMPLES,
@@ -20813,6 +21080,7 @@ export {
20813
21080
  applyParams,
20814
21081
  applyProcess2Adjustments,
20815
21082
  applyProcess2Float,
21083
+ applyRefineEdits,
20816
21084
  applyRegionBlur,
20817
21085
  applyRegionMosaic,
20818
21086
  applyRetouchToTile,
@@ -20845,6 +21113,7 @@ export {
20845
21113
  boxQuad,
20846
21114
  boxToCurvePoint,
20847
21115
  bradfordAdaptation,
21116
+ brushStrokeMask,
20848
21117
  brushstrokeDataUrl,
20849
21118
  brushstrokeKey,
20850
21119
  brushstrokeSamples,
@@ -20950,6 +21219,7 @@ export {
20950
21219
  decodePng,
20951
21220
  decodePngDataUrl,
20952
21221
  decodeSelectionRle,
21222
+ decontaminateColors,
20953
21223
  deepBytes,
20954
21224
  deepTile,
20955
21225
  defaultActiveLayerId,
@@ -14,3 +14,4 @@ export * from './mask-files.cjs';
14
14
  export * from './path.cjs';
15
15
  export * from './magnetic.cjs';
16
16
  export * from './segment/index.cjs';
17
+ export * from './refine-edits.cjs';
@@ -14,3 +14,4 @@ export * from './mask-files.js';
14
14
  export * from './path.js';
15
15
  export * from './magnetic.js';
16
16
  export * from './segment/index.js';
17
+ export * from './refine-edits.js';
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The Select and Mask workspace's strokes as plain data, so an agent can send what a person
3
+ * paints: Quick Selection strokes (grow to the region's edges), Brush strokes (paint the
4
+ * selection in or out directly) and Refine Edge Brush strokes (mark where the edge should be
5
+ * re-estimated from the image, e.g. over hair).
6
+ */
7
+ import type { ImagePoint } from '../types.cjs';
8
+ import { type PixelBuffer, type SelectionMask } from './mask.cjs';
9
+ /** One stroke in the workspace, in natural image pixels. */
10
+ export type RefineEdit = {
11
+ kind: 'quick-select';
12
+ points: ImagePoint[];
13
+ size?: number;
14
+ mode?: 'add' | 'subtract';
15
+ } | {
16
+ kind: 'brush';
17
+ points: ImagePoint[];
18
+ size?: number;
19
+ mode?: 'add' | 'subtract';
20
+ hardness?: number;
21
+ } | {
22
+ kind: 'refine-brush';
23
+ points: ImagePoint[];
24
+ size?: number;
25
+ };
26
+ export declare const REFINE_EDIT_KINDS: readonly ["quick-select", "brush", "refine-brush"];
27
+ /**
28
+ * Coverage of a round brush of diameter `size` dragged along `points` (one point is a dab), over
29
+ * a `width × height` image. `hardness` 0..1 (default 1): the solid share of the radius; the rest
30
+ * fades out. The edge is anti-aliased either way.
31
+ */
32
+ export declare function brushStrokeMask(width: number, height: number, points: readonly ImagePoint[], size: number, hardness?: number): SelectionMask;
33
+ /**
34
+ * Applies `edits` in order to `selection` (`null` starts empty) over `pixels` (needed for
35
+ * Quick Selection strokes). Returns the edited selection and the Refine Edge Brush's painted
36
+ * area (`null` when there were no such strokes), for `refineSelection`.
37
+ */
38
+ export declare function applyRefineEdits(pixels: PixelBuffer | null, selection: SelectionMask | null, edits: readonly RefineEdit[], width: number, height: number): {
39
+ selection: SelectionMask | null;
40
+ refineBrush: SelectionMask | null;
41
+ };
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The Select and Mask workspace's strokes as plain data, so an agent can send what a person
3
+ * paints: Quick Selection strokes (grow to the region's edges), Brush strokes (paint the
4
+ * selection in or out directly) and Refine Edge Brush strokes (mark where the edge should be
5
+ * re-estimated from the image, e.g. over hair).
6
+ */
7
+ import type { ImagePoint } from '../types.js';
8
+ import { type PixelBuffer, type SelectionMask } from './mask.js';
9
+ /** One stroke in the workspace, in natural image pixels. */
10
+ export type RefineEdit = {
11
+ kind: 'quick-select';
12
+ points: ImagePoint[];
13
+ size?: number;
14
+ mode?: 'add' | 'subtract';
15
+ } | {
16
+ kind: 'brush';
17
+ points: ImagePoint[];
18
+ size?: number;
19
+ mode?: 'add' | 'subtract';
20
+ hardness?: number;
21
+ } | {
22
+ kind: 'refine-brush';
23
+ points: ImagePoint[];
24
+ size?: number;
25
+ };
26
+ export declare const REFINE_EDIT_KINDS: readonly ["quick-select", "brush", "refine-brush"];
27
+ /**
28
+ * Coverage of a round brush of diameter `size` dragged along `points` (one point is a dab), over
29
+ * a `width × height` image. `hardness` 0..1 (default 1): the solid share of the radius; the rest
30
+ * fades out. The edge is anti-aliased either way.
31
+ */
32
+ export declare function brushStrokeMask(width: number, height: number, points: readonly ImagePoint[], size: number, hardness?: number): SelectionMask;
33
+ /**
34
+ * Applies `edits` in order to `selection` (`null` starts empty) over `pixels` (needed for
35
+ * Quick Selection strokes). Returns the edited selection and the Refine Edge Brush's painted
36
+ * area (`null` when there were no such strokes), for `refineSelection`.
37
+ */
38
+ export declare function applyRefineEdits(pixels: PixelBuffer | null, selection: SelectionMask | null, edits: readonly RefineEdit[], width: number, height: number): {
39
+ selection: SelectionMask | null;
40
+ refineBrush: SelectionMask | null;
41
+ };
@@ -42,10 +42,12 @@ export declare function guidedFilterColor(guide: readonly [Float32Array, Float32
42
42
  export declare function edgeWidth(guide: readonly [Float32Array, Float32Array, Float32Array], w: number, h: number, r: number): Float32Array;
43
43
  /**
44
44
  * Refines soft coverage `m` (`w × h`, 0..1) with `guide` (three 0..1 planes, required when
45
- * `radius > 0`): edge-aware re-estimation in a band around the edge, then smooth, feather,
46
- * contrast and shift edge, in that order.
45
+ * `radius > 0` or a `band` is given): edge-aware re-estimation in a band around the edge, then
46
+ * smooth, feather, contrast and shift edge, in that order. `band` (0..1 per pixel, the Refine
47
+ * Edge Brush) adds to the band where it is painted, so hair or fur far from the edge is
48
+ * re-estimated too.
47
49
  */
48
- export declare function refineCoverage(guide: readonly [Float32Array, Float32Array, Float32Array] | null, m: Float32Array, w: number, h: number, params?: RefineEdgeParams): Float32Array;
50
+ export declare function refineCoverage(guide: readonly [Float32Array, Float32Array, Float32Array] | null, m: Float32Array, w: number, h: number, params?: RefineEdgeParams, band?: ArrayLike<number> | null): Float32Array;
49
51
  /** Three 0..1 planes of a window of an RGBA buffer. */
50
52
  export declare function guidePlanes(pixels: PixelBuffer, win?: CropBox): [Float32Array, Float32Array, Float32Array];
51
53
  /**
@@ -53,4 +55,13 @@ export declare function guidePlanes(pixels: PixelBuffer, win?: CropBox): [Float3
53
55
  * same size). Work is confined to the mask's bounds grown by {@link refineEdgeHalo}, so a small
54
56
  * selection on a large image stays cheap.
55
57
  */
56
- export declare function refineSelection(pixels: PixelBuffer | null, mask: SelectionMask, params?: RefineEdgeParams): SelectionMask;
58
+ export declare function refineSelection(pixels: PixelBuffer | null, mask: SelectionMask, params?: RefineEdgeParams, refineBrush?: SelectionMask | null): SelectionMask;
59
+ /**
60
+ * Decontaminate Colors: along a soft edge, a pixel's colour is part subject and part the old
61
+ * background (C = αF + (1 − α)B), which shows as a fringe once the subject is on a new background.
62
+ * Each edge pixel's colour is replaced (by `amount`, 0..1) with an estimate of the subject's own
63
+ * colour: unmixed from the background's local mean where the pixel is mostly subject, and the
64
+ * subject's local mean where it is mostly background. Only RGB changes; pixels fully in or out
65
+ * are kept. `rgba` and `coverage` (0..255) cover the same `w × h` region; returns new RGBA.
66
+ */
67
+ export declare function decontaminateColors(rgba: ArrayLike<number>, coverage: ArrayLike<number>, w: number, h: number, amount: number, radius?: number): Uint8ClampedArray;
@@ -42,10 +42,12 @@ export declare function guidedFilterColor(guide: readonly [Float32Array, Float32
42
42
  export declare function edgeWidth(guide: readonly [Float32Array, Float32Array, Float32Array], w: number, h: number, r: number): Float32Array;
43
43
  /**
44
44
  * Refines soft coverage `m` (`w × h`, 0..1) with `guide` (three 0..1 planes, required when
45
- * `radius > 0`): edge-aware re-estimation in a band around the edge, then smooth, feather,
46
- * contrast and shift edge, in that order.
45
+ * `radius > 0` or a `band` is given): edge-aware re-estimation in a band around the edge, then
46
+ * smooth, feather, contrast and shift edge, in that order. `band` (0..1 per pixel, the Refine
47
+ * Edge Brush) adds to the band where it is painted, so hair or fur far from the edge is
48
+ * re-estimated too.
47
49
  */
48
- export declare function refineCoverage(guide: readonly [Float32Array, Float32Array, Float32Array] | null, m: Float32Array, w: number, h: number, params?: RefineEdgeParams): Float32Array;
50
+ export declare function refineCoverage(guide: readonly [Float32Array, Float32Array, Float32Array] | null, m: Float32Array, w: number, h: number, params?: RefineEdgeParams, band?: ArrayLike<number> | null): Float32Array;
49
51
  /** Three 0..1 planes of a window of an RGBA buffer. */
50
52
  export declare function guidePlanes(pixels: PixelBuffer, win?: CropBox): [Float32Array, Float32Array, Float32Array];
51
53
  /**
@@ -53,4 +55,13 @@ export declare function guidePlanes(pixels: PixelBuffer, win?: CropBox): [Float3
53
55
  * same size). Work is confined to the mask's bounds grown by {@link refineEdgeHalo}, so a small
54
56
  * selection on a large image stays cheap.
55
57
  */
56
- export declare function refineSelection(pixels: PixelBuffer | null, mask: SelectionMask, params?: RefineEdgeParams): SelectionMask;
58
+ export declare function refineSelection(pixels: PixelBuffer | null, mask: SelectionMask, params?: RefineEdgeParams, refineBrush?: SelectionMask | null): SelectionMask;
59
+ /**
60
+ * Decontaminate Colors: along a soft edge, a pixel's colour is part subject and part the old
61
+ * background (C = αF + (1 − α)B), which shows as a fringe once the subject is on a new background.
62
+ * Each edge pixel's colour is replaced (by `amount`, 0..1) with an estimate of the subject's own
63
+ * colour: unmixed from the background's local mean where the pixel is mostly subject, and the
64
+ * subject's local mean where it is mostly background. Only RGB changes; pixels fully in or out
65
+ * are kept. `rgba` and `coverage` (0..255) cover the same `w × h` region; returns new RGBA.
66
+ */
67
+ export declare function decontaminateColors(rgba: ArrayLike<number>, coverage: ArrayLike<number>, w: number, h: number, amount: number, radius?: number): Uint8ClampedArray;
@@ -79,5 +79,10 @@ export interface RefineEdgeControl {
79
79
  }
80
80
  /** Sliders of the Refine Edge panel. */
81
81
  export declare const REFINE_EDGE_CONTROLS: readonly RefineEdgeControl[];
82
+ /** Select and Mask's outputs, in menu order. */
83
+ export declare const REFINE_OUTPUTS: readonly {
84
+ id: 'selection' | 'layer-mask' | 'new-layer' | 'new-layer-with-mask';
85
+ label: string;
86
+ }[];
82
87
  /** A one-line status: "No selection" or "W × H px selected (n % of the image)". */
83
88
  export declare function describeSelection(mask: SelectionMask | null | undefined): string;
@@ -79,5 +79,10 @@ export interface RefineEdgeControl {
79
79
  }
80
80
  /** Sliders of the Refine Edge panel. */
81
81
  export declare const REFINE_EDGE_CONTROLS: readonly RefineEdgeControl[];
82
+ /** Select and Mask's outputs, in menu order. */
83
+ export declare const REFINE_OUTPUTS: readonly {
84
+ id: 'selection' | 'layer-mask' | 'new-layer' | 'new-layer-with-mask';
85
+ label: string;
86
+ }[];
82
87
  /** A one-line status: "No selection" or "W × H px selected (n % of the image)". */
83
88
  export declare function describeSelection(mask: SelectionMask | null | undefined): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixerate/editor",
3
- "version": "0.204.0",
3
+ "version": "0.206.0",
4
4
  "description": "UI-agnostic rich text and media editing core library",
5
5
  "author": "Pixerate",
6
6
  "license": "MIT",
@@ -269,7 +269,7 @@ in the source frame at the document's bit depth, colour mode and profile. Read
269
269
  | `grow_selection` | Select › Grow: add connected pixels within `tolerance` (default 32) of a selected colour; follows colour, not distance. `sample`, `sampleMerged`. Fails when nothing is selected. Returns the summary. |
270
270
  | `select_similar` | Select › Similar: add every pixel in the image within `tolerance` of a selected colour, connected or not. Fails when nothing is selected. Returns the summary. |
271
271
  | `reselect` | Bring back the last selection `deselect` cleared (one undo step). Fails when there is none. Returns the summary. |
272
- | `refine_selection` | Select and Mask: snap the selection edge to image edges within `radius` px (0..64, default 8), with `smartRadius`, `smooth` 0..100, `feather` px, `contrast` 0..100 and `shiftEdge` -100..100 %, then `output` to `selection` (default), `layer-mask`, `new-layer` or `new-layer-with-mask` (on `layerId`, default active; new layers hide the source), with `decontaminate` 0..100 for new layers. One step; returns `layerId` / `newLayerId`. Use on hair, fur, foliage or after a coarse AI mask. |
272
+ | `refine_selection` | Select and Mask: snap the selection edge to image edges within `radius` px (0..64, default 8), with `smartRadius`, `smooth` 0..100, `feather` px, `contrast` 0..100 and `shiftEdge` -100..100 %, then `output` to `selection` (default), `layer-mask`, `new-layer` or `new-layer-with-mask` (on `layerId`, default active; new layers hide the source), with `decontaminate` 0..100 for new layers. `edits` (`quick-select`, `brush` add/subtract, `refine-brush` over hair) apply first; `refineMode: "object"` uses the host model. One step; returns `refineMode`, `layerId` / `newLayerId`. Use on hair, fur, foliage or a coarse AI mask. |
273
273
  | `update_adjustment_layer` | Change a layer's `enabled` (false hides it without deleting), `blend`, `opacity` (0..1) or `name` in one undo step. Returns the layer. Its parameters change with `set_adjustments`. Toggle `enabled` to compare a layer's effect before and after. |
274
274
  | `move_adjustment_layer` | Move a layer to index `to` (0 = bottom, applied first; past the top means top). Order matters: a curves layer above a hue/saturation layer works on its output. Fails when the layer is already there. Returns the ids bottom to top. |
275
275
  | `remove_adjustment_layer` | Delete a layer and its mask. Prefer hiding it with `update_adjustment_layer` when the user may want it back. Returns the remaining ids. |