@pixerate/editor 0.203.0 → 0.205.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.
@@ -4108,6 +4108,49 @@ function refineSelection(pixels, mask, params = {}) {
4108
4108
  }
4109
4109
  return createSelectionMask(mask.width, mask.height, out);
4110
4110
  }
4111
+ function decontaminateColors(rgba, coverage, w, h, amount, radius = 8) {
4112
+ const out = Uint8ClampedArray.from(rgba);
4113
+ const n = w * h;
4114
+ const k = Math.min(1, Math.max(0, amount));
4115
+ if (k === 0) return out;
4116
+ const r = Math.max(2, Math.round(radius));
4117
+ const fgW = new Float32Array(n);
4118
+ const bgW = new Float32Array(n);
4119
+ for (let i = 0; i < n; i++) {
4120
+ fgW[i] = coverage[i] >= 250 ? 1 : 0;
4121
+ bgW[i] = coverage[i] <= 5 ? 1 : 0;
4122
+ }
4123
+ const meanOf = (weights, radiusPx) => {
4124
+ const wm = boxMean(weights, w, h, radiusPx);
4125
+ const planes = [0, 1, 2].map((c) => {
4126
+ const v = new Float32Array(n);
4127
+ for (let i = 0; i < n; i++) v[i] = rgba[i * 4 + c] * weights[i];
4128
+ const m = boxMean(v, w, h, radiusPx);
4129
+ for (let i = 0; i < n; i++) m[i] = wm[i] > 1e-6 ? m[i] / wm[i] : NaN;
4130
+ return m;
4131
+ });
4132
+ return { weight: wm, planes };
4133
+ };
4134
+ const fg = meanOf(fgW, r);
4135
+ const fgWide = meanOf(fgW, r * 3);
4136
+ const bg = meanOf(bgW, r);
4137
+ for (let i = 0; i < n; i++) {
4138
+ const c = coverage[i];
4139
+ if (c <= 5 || c >= 250) continue;
4140
+ const a = c / 255;
4141
+ for (let ch = 0; ch < 3; ch++) {
4142
+ const C = rgba[i * 4 + ch];
4143
+ const B = bg.planes[ch][i];
4144
+ const Fm = Number.isNaN(fg.planes[ch][i]) ? fgWide.planes[ch][i] : fg.planes[ch][i];
4145
+ let F;
4146
+ if (a >= 0.5 && !Number.isNaN(B)) F = (C - (1 - a) * B) / a;
4147
+ else if (!Number.isNaN(Fm)) F = Fm;
4148
+ else F = C;
4149
+ out[i * 4 + ch] = Math.round(C + k * (Math.min(255, Math.max(0, F)) - C));
4150
+ }
4151
+ }
4152
+ return out;
4153
+ }
4111
4154
 
4112
4155
  // src/image-editor/selection/segment/guided.ts
4113
4156
  function workPlanes(work) {
@@ -10280,6 +10323,84 @@ var ImageEditorController = class {
10280
10323
  const pixels = (params.radius ?? 0) > 0 ? await this.requirePixels(options) : null;
10281
10324
  return this.setSelection(refineSelection(pixels, sel, params));
10282
10325
  }
10326
+ /**
10327
+ * Select and Mask's output: refines the selection with `params`, then puts the result where
10328
+ * `output` says, as one undo step:
10329
+ * - `selection`: the refined selection;
10330
+ * - `layer-mask`: a mask on the layer (`layerId`, default the active one), then deselects;
10331
+ * - `new-layer`: a new layer of the layer's selected pixels (Layer via Copy), the source hidden;
10332
+ * - `new-layer-with-mask`: a copy of the layer masked by the selection, the source hidden.
10333
+ * `decontaminate` (0..100, new-layer outputs only) replaces the old background's colour in the
10334
+ * soft edge with the subject's own (see `decontaminateColors`).
10335
+ */
10336
+ async refineSelectionTo(params, output = "selection", options = {}) {
10337
+ const sel = this.state.selection;
10338
+ if (!sel?.bounds) throw new Error("Nothing is selected; select first.");
10339
+ const amount = Math.min(100, Math.max(0, options.decontaminate ?? 0)) / 100;
10340
+ const toLayer = output === "new-layer" || output === "new-layer-with-mask";
10341
+ 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.");
10342
+ let layerId;
10343
+ if (output !== "selection") {
10344
+ layerId = options.layerId ?? this.state.activeLayerId ?? void 0;
10345
+ if (!layerId) throw new LayerOperationError("No layer given and none is active; pass layerId (see list_layers).");
10346
+ const layer = requireLayer(this.state.layers, layerId).layer;
10347
+ if (toLayer && layer.type !== "pixel") throw new LayerOperationError(`Output ${output} needs a pixel layer; "${layerId}" is a ${layer.type} layer.`);
10348
+ }
10349
+ const pixels = (params.radius ?? 0) > 0 ? await this.requirePixels(options) : null;
10350
+ const refined = refineSelection(pixels, sel, params);
10351
+ if (!refined.bounds) throw new Error("Refining left nothing selected; use a smaller shiftEdge or contrast.");
10352
+ return this.asOneStep(() => {
10353
+ this.setSelection(refined);
10354
+ if (output === "selection") return { output };
10355
+ if (output === "layer-mask") {
10356
+ this.addLayerMask(layerId, "selection");
10357
+ this.setSelection(null);
10358
+ return { output, layerId };
10359
+ }
10360
+ let newLayerId2;
10361
+ if (output === "new-layer") newLayerId2 = this.layerViaCopy({ layerId });
10362
+ else {
10363
+ newLayerId2 = this.duplicateLayer(layerId);
10364
+ this.addLayerMask(newLayerId2, "selection");
10365
+ this.state.activeTarget = "pixels";
10366
+ }
10367
+ if (amount > 0) this.decontaminateLayer(newLayerId2, layerId, refined, amount, Math.max(4, params.radius ?? 0));
10368
+ this.setLayerProperties(layerId, { visible: false });
10369
+ this.setSelection(null);
10370
+ return { output, layerId, newLayerId: newLayerId2 };
10371
+ });
10372
+ }
10373
+ /**
10374
+ * Decontaminate Colors along the soft edge of `coverage`: the colours are estimated from the
10375
+ * source layer (whose background is still there) and written into layer `id`'s RGB, keeping
10376
+ * its alpha.
10377
+ */
10378
+ decontaminateLayer(id, sourceId, coverage, amount, radius) {
10379
+ const layer = requireLayer(this.state.layers, id).layer;
10380
+ const source = requireLayer(this.state.layers, sourceId).layer;
10381
+ if (layer.type !== "pixel" || source.type !== "pixel" || !coverage.bounds) return;
10382
+ const { width, height } = this.naturalSize;
10383
+ const pad = Math.ceil(radius * 3);
10384
+ const b = coverage.bounds;
10385
+ const x0 = Math.max(0, b.x - pad);
10386
+ const y0 = Math.max(0, b.y - pad);
10387
+ 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 };
10388
+ const src = { x: box.x - layer.offset.x, y: box.y - layer.offset.y, width: box.width, height: box.height };
10389
+ const surface = this.surfaceOf(layer);
10390
+ const rgba = readRegion(surface, src);
10391
+ const original = readRegion(this.surfaceOf(source), { x: box.x - source.offset.x, y: box.y - source.offset.y, width: box.width, height: box.height });
10392
+ const cov = new Uint8Array(box.width * box.height);
10393
+ const all = coverage.data;
10394
+ 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);
10395
+ const cleaned = decontaminateColors(original, cov, box.width, box.height, amount, radius);
10396
+ for (let i = 3; i < cleaned.length; i += 4) cleaned[i] = rgba[i];
10397
+ const next = mapLayer(this.state.layers, id, (l) => {
10398
+ const updated = { ...l, surface: writeRegion(surface, src, cleaned) };
10399
+ delete updated.source;
10400
+ return updated;
10401
+ });
10402
+ this.commitLayers(next);
10403
+ }
10283
10404
  /**
10284
10405
  * Loads a selection from a mask image (an AI segmentation, a saved mask): a data URL / URL or
10285
10406
  * RGBA pixels, by luminance (white = selected) or a channel. Any size; it is resampled.
@@ -13251,6 +13372,12 @@ var REFINE_EDGE_CONTROLS = [
13251
13372
  { id: "contrast", label: "Contrast", min: 0, max: 100, step: 1, unit: "%" },
13252
13373
  { id: "shiftEdge", label: "Shift Edge", min: -100, max: 100, step: 1, unit: "%" }
13253
13374
  ];
13375
+ var REFINE_OUTPUTS = [
13376
+ { id: "selection", label: "Selection" },
13377
+ { id: "layer-mask", label: "Layer Mask" },
13378
+ { id: "new-layer", label: "New Layer" },
13379
+ { id: "new-layer-with-mask", label: "New Layer with Layer Mask" }
13380
+ ];
13254
13381
  function describeSelection(mask) {
13255
13382
  if (!mask || !mask.bounds) return "No selection";
13256
13383
  const pct2 = selectionArea(mask) / (mask.width * mask.height) * 100;
@@ -13459,31 +13586,42 @@ function createImageSelectionTools(controller) {
13459
13586
  const refine = {
13460
13587
  name: "refine_selection",
13461
13588
  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.",
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.",
13463
13590
  inputSchema: {
13464
13591
  type: "object",
13465
13592
  properties: {
13466
- radius: { type: "number", minimum: 0, maximum: 64, description: "Edge detection radius in pixels." },
13593
+ radius: { type: "number", minimum: 0, maximum: 64, description: "Edge detection radius in pixels (default 8)." },
13467
13594
  smartRadius: { type: "boolean", description: "Adapt the radius to hard and soft edges." },
13468
13595
  smooth: { type: "number", minimum: 0, maximum: 100, description: "Smooth the edge outline (0-100)." },
13469
13596
  feather: { type: "number", minimum: 0, maximum: 250, description: "Feather the edge (pixels)." },
13470
13597
  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." }
13598
+ shiftEdge: { type: "number", minimum: -100, maximum: 100, description: "Move the edge out (+) or in (-), in percent." },
13599
+ output: {
13600
+ type: "string",
13601
+ enum: ["selection", "layer-mask", "new-layer", "new-layer-with-mask"],
13602
+ 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
+ },
13604
+ 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." }
13472
13606
  },
13473
13607
  additionalProperties: false
13474
13608
  },
13475
13609
  annotations: writeAnnotations("Refine the selection edge"),
13476
13610
  handler: async (params) => {
13477
13611
  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);
13612
+ const result = await controller.refineSelectionTo(
13613
+ {
13614
+ radius: num2(params.radius, 8),
13615
+ smartRadius: params.smartRadius === true,
13616
+ smooth: num2(params.smooth, 0),
13617
+ feather: num2(params.feather, 0),
13618
+ contrast: num2(params.contrast, 0),
13619
+ shiftEdge: num2(params.shiftEdge, 0)
13620
+ },
13621
+ params.output ?? "selection",
13622
+ { layerId: typeof params.layerId === "string" ? params.layerId : void 0, decontaminate: num2(params.decontaminate, 0) }
13623
+ );
13624
+ return { ...summary(controller), ...result };
13487
13625
  }
13488
13626
  };
13489
13627
  const applyMask = {
@@ -20702,6 +20840,7 @@ export {
20702
20840
  PsdError,
20703
20841
  QUICK_MASK_TARGET,
20704
20842
  REFINE_EDGE_CONTROLS,
20843
+ REFINE_OUTPUTS,
20705
20844
  RETOUCH_CHOICES,
20706
20845
  RETOUCH_OPTION_CONTROLS,
20707
20846
  RETOUCH_SAMPLES,
@@ -20950,6 +21089,7 @@ export {
20950
21089
  decodePng,
20951
21090
  decodePngDataUrl,
20952
21091
  decodeSelectionRle,
21092
+ decontaminateColors,
20953
21093
  deepBytes,
20954
21094
  deepTile,
20955
21095
  defaultActiveLayerId,
@@ -54,3 +54,12 @@ export declare function guidePlanes(pixels: PixelBuffer, win?: CropBox): [Float3
54
54
  * selection on a large image stays cheap.
55
55
  */
56
56
  export declare function refineSelection(pixels: PixelBuffer | null, mask: SelectionMask, params?: RefineEdgeParams): SelectionMask;
57
+ /**
58
+ * Decontaminate Colors: along a soft edge, a pixel's colour is part subject and part the old
59
+ * background (C = αF + (1 − α)B), which shows as a fringe once the subject is on a new background.
60
+ * Each edge pixel's colour is replaced (by `amount`, 0..1) with an estimate of the subject's own
61
+ * colour: unmixed from the background's local mean where the pixel is mostly subject, and the
62
+ * subject's local mean where it is mostly background. Only RGB changes; pixels fully in or out
63
+ * are kept. `rgba` and `coverage` (0..255) cover the same `w × h` region; returns new RGBA.
64
+ */
65
+ export declare function decontaminateColors(rgba: ArrayLike<number>, coverage: ArrayLike<number>, w: number, h: number, amount: number, radius?: number): Uint8ClampedArray;
@@ -54,3 +54,12 @@ export declare function guidePlanes(pixels: PixelBuffer, win?: CropBox): [Float3
54
54
  * selection on a large image stays cheap.
55
55
  */
56
56
  export declare function refineSelection(pixels: PixelBuffer | null, mask: SelectionMask, params?: RefineEdgeParams): SelectionMask;
57
+ /**
58
+ * Decontaminate Colors: along a soft edge, a pixel's colour is part subject and part the old
59
+ * background (C = αF + (1 − α)B), which shows as a fringe once the subject is on a new background.
60
+ * Each edge pixel's colour is replaced (by `amount`, 0..1) with an estimate of the subject's own
61
+ * colour: unmixed from the background's local mean where the pixel is mostly subject, and the
62
+ * subject's local mean where it is mostly background. Only RGB changes; pixels fully in or out
63
+ * are kept. `rgba` and `coverage` (0..255) cover the same `w × h` region; returns new RGBA.
64
+ */
65
+ 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;
@@ -1378,6 +1378,16 @@ export declare class VectorEditorController {
1378
1378
  * placed by position.
1379
1379
  */
1380
1380
  reorderNode(id: string, index: number): VectorActionResult;
1381
+ /**
1382
+ * Moves a node into group `parentId` (null: the top level; left out: where it is) at `index`
1383
+ * among its new siblings (bottom to top, 0 the bottom; left out: the top), as dragging it in a
1384
+ * layers panel does. It stays where it is on the canvas. Clipping groups and blends keep their
1385
+ * children: their mask and keys are placed by position.
1386
+ */
1387
+ moveNode(id: string, options?: {
1388
+ parentId?: string | null;
1389
+ index?: number;
1390
+ }): VectorActionResult;
1381
1391
  /**
1382
1392
  * Combines the selected paths (two or more) into one with a boolean operation. `subtract`
1383
1393
  * removes the upper shapes from the lowest one. The result takes the lowest shape's style
@@ -1378,6 +1378,16 @@ export declare class VectorEditorController {
1378
1378
  * placed by position.
1379
1379
  */
1380
1380
  reorderNode(id: string, index: number): VectorActionResult;
1381
+ /**
1382
+ * Moves a node into group `parentId` (null: the top level; left out: where it is) at `index`
1383
+ * among its new siblings (bottom to top, 0 the bottom; left out: the top), as dragging it in a
1384
+ * layers panel does. It stays where it is on the canvas. Clipping groups and blends keep their
1385
+ * children: their mask and keys are placed by position.
1386
+ */
1387
+ moveNode(id: string, options?: {
1388
+ parentId?: string | null;
1389
+ index?: number;
1390
+ }): VectorActionResult;
1381
1391
  /**
1382
1392
  * Combines the selected paths (two or more) into one with a boolean operation. `subtract`
1383
1393
  * removes the upper shapes from the lowest one. The result takes the lowest shape's style