@pixerate/editor 0.208.0 → 0.210.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/dist/{chunk-X4LUGTQX.cjs → chunk-L52VMQZS.cjs} +21 -10
  2. package/dist/{chunk-DWYQPL2A.js → chunk-LQ5SALHK.js} +13 -2
  3. package/dist/chunk-MQDIP4CF.cjs +462 -0
  4. package/dist/chunk-XI2NV5U7.js +462 -0
  5. package/dist/{chunk-3EHPLDFQ.cjs → chunk-YF3YZZ6G.cjs} +16 -453
  6. package/dist/{chunk-USQCKRZG.js → chunk-ZGJGSCUW.js} +8 -445
  7. package/dist/image-editor/content-aware-fill-session.d.cts +109 -0
  8. package/dist/image-editor/content-aware-fill-session.d.ts +109 -0
  9. package/dist/image-editor/controller.d.cts +85 -3
  10. package/dist/image-editor/controller.d.ts +85 -3
  11. package/dist/image-editor/index.cjs +847 -396
  12. package/dist/image-editor/index.d.cts +1 -0
  13. package/dist/image-editor/index.d.ts +1 -0
  14. package/dist/image-editor/index.js +492 -41
  15. package/dist/image-editor/selection/tool-options.d.cts +3 -1
  16. package/dist/image-editor/selection/tool-options.d.ts +3 -1
  17. package/dist/image-editor/selection/worker.d.cts +10 -0
  18. package/dist/image-editor/selection/worker.d.ts +10 -0
  19. package/dist/image-editor/selection-worker.cjs +4 -3
  20. package/dist/image-editor/selection-worker.js +2 -1
  21. package/dist/image-editor/types.d.cts +14 -0
  22. package/dist/image-editor/types.d.ts +14 -0
  23. package/dist/paint/heal.d.cts +3 -1
  24. package/dist/paint/heal.d.ts +3 -1
  25. package/dist/paint/index.cjs +4 -2
  26. package/dist/paint/index.js +10 -8
  27. package/package.json +1 -1
  28. package/skills/pixerate-image-editor/SKILL.md +21 -23
  29. package/skills/pixerate-image-editor/references/layer-effects.md +24 -0
  30. package/skills/pixerate-image-selection-masking/SKILL.md +2 -1
@@ -52,7 +52,7 @@ import {
52
52
  workImage,
53
53
  workLuma,
54
54
  workPlanes
55
- } from "../chunk-DWYQPL2A.js";
55
+ } from "../chunk-LQ5SALHK.js";
56
56
  import {
57
57
  ADJUSTMENT_KEYS,
58
58
  ADJUSTMENT_KINDS,
@@ -523,33 +523,35 @@ import {
523
523
  TransformedPixelSource,
524
524
  applyRetouchToTile,
525
525
  applyToneToDeepTile,
526
- bestOffset,
527
526
  boxBlurPixels,
528
527
  cloneSamplePoint,
529
- contentAwareFill,
530
- dilateMask,
531
528
  dodgeBurn,
532
529
  dodgeBurnCurve,
533
530
  healPixels,
534
531
  isIdentityCloneTransform,
535
532
  luma,
536
- maskBounds,
537
- membraneFill,
538
533
  normalizeRetouch,
539
534
  perDabStrength,
540
535
  readSourceRegion,
541
536
  retouchFilter,
542
537
  rgbaViewOfGrey,
543
538
  saturationOf,
544
- seamlessClone,
545
539
  smudgeEffect,
546
- solveMembrane,
547
540
  sponge,
548
541
  spongeEffect,
549
542
  spotHealPixels,
550
543
  spotHealWindow,
551
544
  toneOf
552
- } from "../chunk-USQCKRZG.js";
545
+ } from "../chunk-ZGJGSCUW.js";
546
+ import {
547
+ bestOffset,
548
+ contentAwareFill,
549
+ dilateMask,
550
+ maskBounds,
551
+ membraneFill,
552
+ seamlessClone,
553
+ solveMembrane
554
+ } from "../chunk-XI2NV5U7.js";
553
555
  import {
554
556
  BODY_SCATTER,
555
557
  DEFAULT_FLOW,
@@ -5103,6 +5105,7 @@ var ImageEditorController = class {
5103
5105
  /** The segmentation provider behind `selectSubject` (see `setSegmentationProvider`). */
5104
5106
  segmentation = null;
5105
5107
  selectionWorker = null;
5108
+ documentOpener = null;
5106
5109
  /** The effects provider slot (see `setEffectsProvider`). */
5107
5110
  effects = new EffectsSlot();
5108
5111
  /** Renders provider effects: effect smart filter results and effect layers. */
@@ -5114,6 +5117,7 @@ var ImageEditorController = class {
5114
5117
  if (options.effects) this.effects.set(options.effects);
5115
5118
  if (options.segmentation) this.segmentation = options.segmentation;
5116
5119
  if (options.selectionWorker) this.selectionWorker = new SelectionWorkerClient(options.selectionWorker);
5120
+ if (options.openDocument) this.documentOpener = options.openDocument;
5117
5121
  this.maxHistoryDepth = options.maxHistoryDepth ?? 50;
5118
5122
  if (options.textShaper !== void 0) this.textShaperValue = options.textShaper;
5119
5123
  this.maxHistoryBytes = options.maxHistoryBytes ?? 512 * 1024 * 1024;
@@ -5311,6 +5315,70 @@ var ImageEditorController = class {
5311
5315
  this.selectionWorker?.terminate();
5312
5316
  this.selectionWorker = factory ? new SelectionWorkerClient(factory) : null;
5313
5317
  }
5318
+ /** Sets (or with `null` removes) how new documents are opened (see `DocumentOpener`). Not an edit. */
5319
+ setDocumentOpener(opener) {
5320
+ if (this.documentOpener === opener) return;
5321
+ this.documentOpener = opener;
5322
+ this.notify();
5323
+ }
5324
+ /** Whether the host can open new documents (the New Document outputs are available). */
5325
+ get canOpenDocuments() {
5326
+ return this.documentOpener !== null;
5327
+ }
5328
+ /**
5329
+ * A new one-layer document from the selection (or `options.selection`) on a pixel layer
5330
+ * (`layerId`, default the active one), the size of this image: the selected pixels on
5331
+ * transparency, or with `withMask` the whole layer masked by the selection. This document is
5332
+ * not changed. `decontaminate` (0..100) cleans the soft edge's colours as the new-layer
5333
+ * outputs do.
5334
+ */
5335
+ documentFromSelection(options = {}) {
5336
+ const id = options.layerId ?? this.state.activeLayerId;
5337
+ if (!id) throw new LayerOperationError("No layer given and none is active; pass layerId (see list_layers).");
5338
+ const layer = requireLayer(this.state.layers, id).layer;
5339
+ if (layer.type !== "pixel") throw new LayerOperationError(`A new document needs a pixel layer; "${id}" is a ${layer.type} layer.`);
5340
+ const sel = options.selection ?? this.state.selection;
5341
+ if (!sel?.bounds) throw new LayerOperationError("Nothing is selected; select what goes in the new document first.");
5342
+ const { width, height } = this.naturalSize;
5343
+ const surface = this.surfaceOf(layer);
5344
+ let copy;
5345
+ if (options.withMask) {
5346
+ let mask = this.maskFromSelection(sel);
5347
+ if (layer.offset.x || layer.offset.y) mask = { ...mask, surface: translateSurface(mask.surface, -layer.offset.x, -layer.offset.y) };
5348
+ copy = { ...layer, id: newLayerId(), surface, mask };
5349
+ } else {
5350
+ const sb = sel.bounds;
5351
+ const src = { x: sb.x - layer.offset.x, y: sb.y - layer.offset.y, width: sb.width, height: sb.height };
5352
+ const px2 = readRegion(surface, src);
5353
+ const data = sel.data;
5354
+ for (let y = 0; y < sb.height; y++) {
5355
+ for (let x = 0; x < sb.width; x++) px2[(y * sb.width + x) * 4 + 3] = Math.round(px2[(y * sb.width + x) * 4 + 3] * (data[(sb.y + y) * width + sb.x + x] / 255));
5356
+ }
5357
+ copy = { ...layer, id: newLayerId(), surface: surfaceFromPixels({ width: src.width, height: src.height, data: px2 }, 4, src.x, src.y) };
5358
+ delete copy.mask;
5359
+ }
5360
+ delete copy.source;
5361
+ copy.visible = true;
5362
+ const amount = Math.min(100, Math.max(0, options.decontaminate ?? 0)) / 100;
5363
+ if (amount > 0) copy = { ...copy, surface: this.decontaminatedSurface(copy, layer, sel, amount, Math.max(4, options.radius ?? 0)) };
5364
+ const { layers, assets, smartContents } = serializeLayers([copy]);
5365
+ const out = {
5366
+ version: LAYERED_SCHEMA_VERSION,
5367
+ width,
5368
+ height,
5369
+ layers,
5370
+ assets,
5371
+ activeLayerId: copy.id,
5372
+ crop: null,
5373
+ transform: { ...DEFAULT_TRANSFORM },
5374
+ opacity: 1
5375
+ };
5376
+ if (smartContents) out.smartContents = smartContents;
5377
+ if (this.state.bitDepth === 16 || this.state.bitDepth === 32) out.bitDepth = this.state.bitDepth;
5378
+ if (this.state.colorProfile) out.colorProfile = serializeColorProfile(this.state.colorProfile);
5379
+ if (this.state.colorMode) out.colorMode = serializeColorMode(this.state.colorMode);
5380
+ return out;
5381
+ }
5314
5382
  /** Whether the long selections run in a worker. */
5315
5383
  get hasSelectionWorker() {
5316
5384
  return this.selectionWorker !== null;
@@ -7837,9 +7905,46 @@ var ImageEditorController = class {
7837
7905
  * (PatchMatch, as the Spot Healing Brush's content-aware mode), blended by the selection's
7838
7906
  * coverage. `output` `current` paints into the layer; `new-layer` puts the fill on a new layer
7839
7907
  * above it. Samples within `sampleRadius` px of the selection (default its larger side, at
7840
- * least 32). Deterministic for a `seed`. One undo step. 8-bit documents only for now.
7908
+ * least 32), or from the given sampling area. Deterministic for a `seed`. One undo step.
7909
+ * 8-bit documents only for now. Runs here; see `contentAwareFillAsync` for the worker.
7841
7910
  */
7842
7911
  contentAwareFill(options = {}) {
7912
+ const plan = this.prepareContentAwareFill(options);
7913
+ const filled = contentAwareFill(plan.window.width, plan.window.height, 4, plan.before, plan.hole, { seed: options.seed ?? 0, exclude: plan.exclude });
7914
+ return this.applyContentAwareFill(plan, Uint8ClampedArray.from(filled), options.output ?? "current");
7915
+ }
7916
+ /**
7917
+ * `contentAwareFill` with the synthesis in the selection worker when there is one, with
7918
+ * `signal` and `onProgress`. The layer is checked again before the result lands.
7919
+ */
7920
+ async contentAwareFillAsync(options = {}) {
7921
+ const plan = this.prepareContentAwareFill(options);
7922
+ const filled = await this.synthesiseFill(plan, options.seed ?? 0, options);
7923
+ return this.applyContentAwareFill(plan, filled, options.output ?? "current");
7924
+ }
7925
+ /**
7926
+ * The fill without changing anything: the window, its pixels before and after (RGBA, the fill
7927
+ * blended by the selection's coverage) and the sampling area used, for a preview.
7928
+ */
7929
+ async previewContentAwareFill(options = {}) {
7930
+ const plan = this.prepareContentAwareFill(options);
7931
+ const filled = await this.synthesiseFill(plan, options.seed ?? 0, options);
7932
+ return { plan, after: this.blendFill(plan, filled, "current") };
7933
+ }
7934
+ async synthesiseFill(plan, seed, options) {
7935
+ const { width, height } = plan.window;
7936
+ const { pixels } = await this.runSelectionJob(
7937
+ { op: "fill", hole: plan.hole, exclude: plan.exclude, seed },
7938
+ { width, height, data: plan.before },
7939
+ options
7940
+ );
7941
+ return pixels;
7942
+ }
7943
+ /**
7944
+ * Checks and gathers what a Content-Aware Fill needs: the selection, the 8-bit pixel layer, the
7945
+ * window around the selection (and the sampling area), and which pixels it may copy from.
7946
+ */
7947
+ prepareContentAwareFill(options = {}) {
7843
7948
  const sel = this.state.selection;
7844
7949
  if (!sel?.bounds) throw new LayerOperationError("Nothing is selected; select what to remove, then fill it.");
7845
7950
  if (this.bitDepth !== 8) throw new LayerOperationError("Content-Aware Fill works on 8-bit documents for now; set_bit_depth 8 first, or use spot_heal.");
@@ -7848,33 +7953,70 @@ var ImageEditorController = class {
7848
7953
  const target = this.paintTarget(options.layerId, "pixels", "fill");
7849
7954
  const { width, height } = this.naturalSize;
7850
7955
  const b = sel.bounds;
7851
- const margin = Math.max(32, Math.round(options.sampleRadius ?? Math.max(b.width, b.height)));
7852
- const x0 = Math.max(0, b.x - margin);
7853
- const y0 = Math.max(0, b.y - margin);
7854
- const win = { x: x0, y: y0, width: Math.min(width, b.x + b.width + margin) - x0, height: Math.min(height, b.y + b.height + margin) - y0 };
7855
- if (win.width * win.height > 16e6) throw new LayerOperationError("The area to fill and sample is too large (over 16 MP); fill a smaller selection or lower sampleRadius.");
7856
- const layerRect = { x: win.x - target.offset.x, y: win.y - target.offset.y, width: win.width, height: win.height };
7857
- const before = readRegion(target.surface, layerRect);
7858
- const img = new Float32Array(before.length);
7859
- for (let i = 0; i < before.length; i++) img[i] = before[i];
7860
- const coverage = new Float32Array(win.width * win.height);
7861
- const hole = new Uint8Array(win.width * win.height);
7956
+ let area = options.samplingArea ?? null;
7957
+ if (!area && options.samplingChannelId) area = this.requireChannel(options.samplingChannelId).mask;
7958
+ if (!area && options.sampleRects?.length) {
7959
+ for (const r of options.sampleRects) area = combineSelections(area, rectSelection(width, height, r, { antiAlias: false }), "add");
7960
+ }
7961
+ if (area && !area.bounds) throw new LayerOperationError("The sampling area is empty; paint or give an area to sample from.");
7962
+ let win;
7963
+ if (area?.bounds) {
7964
+ const a = area.bounds;
7965
+ const x0 = Math.max(0, Math.min(b.x - 8, a.x));
7966
+ const y0 = Math.max(0, Math.min(b.y - 8, a.y));
7967
+ win = { x: x0, y: y0, width: Math.min(width, Math.max(b.x + b.width + 8, a.x + a.width)) - x0, height: Math.min(height, Math.max(b.y + b.height + 8, a.y + a.height)) - y0 };
7968
+ } else {
7969
+ const margin = Math.max(32, Math.round(options.sampleRadius ?? Math.max(b.width, b.height)));
7970
+ const x0 = Math.max(0, b.x - margin);
7971
+ const y0 = Math.max(0, b.y - margin);
7972
+ win = { x: x0, y: y0, width: Math.min(width, b.x + b.width + margin) - x0, height: Math.min(height, b.y + b.height + margin) - y0 };
7973
+ }
7974
+ if (win.width * win.height > 16e6) throw new LayerOperationError("The area to fill and sample is too large (over 16 MP); fill a smaller selection or sample from a smaller area.");
7975
+ const before = readRegion(target.surface, { x: win.x - target.offset.x, y: win.y - target.offset.y, width: win.width, height: win.height });
7976
+ const n = win.width * win.height;
7977
+ const coverage = new Uint8Array(n);
7978
+ const hole = new Uint8Array(n);
7979
+ const selData = sel.data;
7862
7980
  for (let y = 0; y < win.height; y++) {
7863
7981
  for (let x = 0; x < win.width; x++) {
7864
- const c = sel.data[(win.y + y) * sel.width + win.x + x] / 255;
7982
+ const c = selData[(win.y + y) * sel.width + win.x + x];
7865
7983
  coverage[y * win.width + x] = c;
7866
7984
  hole[y * win.width + x] = c > 0 ? 1 : 0;
7867
7985
  }
7868
7986
  }
7869
- const filled = contentAwareFill(win.width, win.height, 4, img, hole, { seed: options.seed ?? 0 });
7987
+ let exclude = null;
7988
+ if (area || options.excludeRects?.length) {
7989
+ exclude = new Uint8Array(n);
7990
+ if (area) {
7991
+ const ad = area.data;
7992
+ for (let y = 0; y < win.height; y++) for (let x = 0; x < win.width; x++) if (ad[(win.y + y) * width + win.x + x] < 128) exclude[y * win.width + x] = 1;
7993
+ }
7994
+ for (const r of options.excludeRects ?? []) {
7995
+ for (let y = Math.max(0, Math.floor(r.y) - win.y); y < Math.min(win.height, Math.ceil(r.y + r.height) - win.y); y++) {
7996
+ for (let x = Math.max(0, Math.floor(r.x) - win.x); x < Math.min(win.width, Math.ceil(r.x + r.width) - win.x); x++) exclude[y * win.width + x] = 1;
7997
+ }
7998
+ }
7999
+ }
8000
+ return { layerId: target.id, window: win, bounds: { ...b }, before, hole, coverage, exclude };
8001
+ }
8002
+ /** The window's pixels with `filled` blended in by the coverage (`current`), or the fill alone, its alpha by the coverage (`new-layer`). */
8003
+ blendFill(plan, filled, output) {
8004
+ const { before, coverage } = plan;
7870
8005
  const out = new Uint8ClampedArray(before.length);
7871
8006
  for (let i = 0; i < coverage.length; i++) {
7872
- const k = coverage[i];
8007
+ const k = coverage[i] / 255;
7873
8008
  for (let c = 0; c < 4; c++) {
7874
8009
  const p = i * 4 + c;
7875
8010
  out[p] = output === "new-layer" ? c === 3 ? Math.round(filled[p] * k) : filled[p] : before[p] + (filled[p] - before[p]) * k;
7876
8011
  }
7877
8012
  }
8013
+ return out;
8014
+ }
8015
+ /** Puts a computed fill into the document as one undo step. */
8016
+ applyContentAwareFill(plan, filled, output) {
8017
+ const target = this.paintTarget(plan.layerId, "pixels", "fill");
8018
+ const { window: win, bounds: b } = plan;
8019
+ const out = this.blendFill(plan, filled, output);
7878
8020
  if (output === "new-layer") {
7879
8021
  const crop = { x: b.x - win.x, y: b.y - win.y, width: b.width, height: b.height };
7880
8022
  const data = new Uint8ClampedArray(b.width * b.height * 4);
@@ -7882,6 +8024,7 @@ var ImageEditorController = class {
7882
8024
  const id = this.addPixelLayer({ name: "Content-Aware Fill", pixels: { width: b.width, height: b.height, data }, x: b.x, y: b.y, aboveId: target.id });
7883
8025
  return { layerId: id, bounds: { ...b }, output };
7884
8026
  }
8027
+ const layerRect = { x: win.x - target.offset.x, y: win.y - target.offset.y, width: win.width, height: win.height };
7885
8028
  const surface = writeRegion(target.surface, layerRect, out);
7886
8029
  this.commitLayers(mapLayer(this.state.layers, target.id, (l) => this.withSurface(l, "pixels", surface)));
7887
8030
  return { layerId: target.id, bounds: { ...b }, output };
@@ -8752,8 +8895,12 @@ var ImageEditorController = class {
8752
8895
  throw new Error("Object aware refinement needs a segmentation model (setSegmentationProvider); use refineMode color.");
8753
8896
  }
8754
8897
  const amount = Math.min(100, Math.max(0, options.decontaminate ?? 0)) / 100;
8755
- const toLayer = output === "new-layer" || output === "new-layer-with-mask";
8756
- 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.");
8898
+ const toDocument = output === "new-document" || output === "new-document-with-mask";
8899
+ const toLayer = output === "new-layer" || output === "new-layer-with-mask" || toDocument;
8900
+ if (amount > 0 && !toLayer) throw new Error("Decontaminate colors writes the cleaned colours to a new layer or document: use output new-layer, new-layer-with-mask, new-document or new-document-with-mask.");
8901
+ if (toDocument && !this.documentOpener) {
8902
+ throw new Error("This editor cannot open new documents (no document opener is set); use output new-layer or new-layer-with-mask instead.");
8903
+ }
8757
8904
  let layerId;
8758
8905
  if (output !== "selection") {
8759
8906
  layerId = options.layerId ?? this.state.activeLayerId ?? void 0;
@@ -8778,6 +8925,12 @@ var ImageEditorController = class {
8778
8925
  }
8779
8926
  const refined = refineSelection(pixels, sel, params, edited.refineBrush);
8780
8927
  if (!refined.bounds) throw new Error("Refining left nothing selected; use a smaller shiftEdge or contrast.");
8928
+ if (toDocument) {
8929
+ const document2 = this.documentFromSelection({ layerId, withMask: output === "new-document-with-mask", selection: refined, decontaminate: amount * 100, radius: params.radius });
8930
+ const name = `${requireLayer(this.state.layers, layerId).layer.name || "Layer"} ${output === "new-document" ? "cutout" : "masked"}`;
8931
+ const documentId = await this.documentOpener(document2, { name, from: "select-and-mask" });
8932
+ return { output, layerId, refineMode, ...documentId ? { documentId } : {} };
8933
+ }
8781
8934
  return this.asOneStep(() => {
8782
8935
  this.setSelection(refined);
8783
8936
  if (output === "selection") return { output, refineMode };
@@ -8808,6 +8961,16 @@ var ImageEditorController = class {
8808
8961
  const layer = requireLayer(this.state.layers, id).layer;
8809
8962
  const source = requireLayer(this.state.layers, sourceId).layer;
8810
8963
  if (layer.type !== "pixel" || source.type !== "pixel" || !coverage.bounds) return;
8964
+ const surface = this.decontaminatedSurface(layer, source, coverage, amount, radius);
8965
+ const next = mapLayer(this.state.layers, id, (l) => {
8966
+ const updated = { ...l, surface };
8967
+ delete updated.source;
8968
+ return updated;
8969
+ });
8970
+ this.commitLayers(next);
8971
+ }
8972
+ /** `layer`'s surface with Decontaminate Colors applied along `coverage`'s soft edge, colours read from `source`. */
8973
+ decontaminatedSurface(layer, source, coverage, amount, radius) {
8811
8974
  const { width, height } = this.naturalSize;
8812
8975
  const pad = Math.ceil(radius * 3);
8813
8976
  const b = coverage.bounds;
@@ -8823,12 +8986,7 @@ var ImageEditorController = class {
8823
8986
  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);
8824
8987
  const cleaned = decontaminateColors(original, cov, box.width, box.height, amount, radius);
8825
8988
  for (let i = 3; i < cleaned.length; i += 4) cleaned[i] = rgba[i];
8826
- const next = mapLayer(this.state.layers, id, (l) => {
8827
- const updated = { ...l, surface: writeRegion(surface, src, cleaned) };
8828
- delete updated.source;
8829
- return updated;
8830
- });
8831
- this.commitLayers(next);
8989
+ return writeRegion(surface, src, cleaned);
8832
8990
  }
8833
8991
  /**
8834
8992
  * Loads a selection from a mask image (an AI segmentation, a saved mask): a data URL / URL or
@@ -11805,7 +11963,9 @@ var REFINE_OUTPUTS = [
11805
11963
  { id: "selection", label: "Selection" },
11806
11964
  { id: "layer-mask", label: "Layer Mask" },
11807
11965
  { id: "new-layer", label: "New Layer" },
11808
- { id: "new-layer-with-mask", label: "New Layer with Layer Mask" }
11966
+ { id: "new-layer-with-mask", label: "New Layer with Layer Mask" },
11967
+ { id: "new-document", label: "New Document", needsDocuments: true },
11968
+ { id: "new-document-with-mask", label: "New Document with Layer Mask", needsDocuments: true }
11809
11969
  ];
11810
11970
  function describeSelection(mask) {
11811
11971
  if (!mask || !mask.bounds) return "No selection";
@@ -11841,6 +12001,13 @@ var rectProperties = {
11841
12001
  width: { type: "number", exclusiveMinimum: 0, description: "Width in natural image pixels." },
11842
12002
  height: { type: "number", exclusiveMinimum: 0, description: "Height in natural image pixels." }
11843
12003
  };
12004
+ var rectSchemaStrict = {
12005
+ type: "object",
12006
+ properties: rectProperties,
12007
+ required: ["x", "y", "width", "height"],
12008
+ additionalProperties: false,
12009
+ description: "A rectangle in natural image pixels."
12010
+ };
11844
12011
  function shapeOptions(args) {
11845
12012
  return { mode: parseSelectionMode(args.mode), feather: num2(args.feather, 0), antiAlias: args.antiAlias !== false };
11846
12013
  }
@@ -12027,11 +12194,11 @@ function createImageSelectionTools(controller) {
12027
12194
  shiftEdge: { type: "number", minimum: -100, maximum: 100, description: "Move the edge out (+) or in (-), in percent." },
12028
12195
  output: {
12029
12196
  type: "string",
12030
- enum: ["selection", "layer-mask", "new-layer", "new-layer-with-mask"],
12031
- 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."
12197
+ enum: ["selection", "layer-mask", "new-layer", "new-layer-with-mask", "new-document", "new-document-with-mask"],
12198
+ 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. new-document and new-document-with-mask hand a new one-layer document to the host (only when the host can open documents; this one is unchanged) and return its documentId."
12032
12199
  },
12033
12200
  layerId: { type: "string", description: "The layer for layer outputs (default the active layer; see list_layers). New-layer outputs need a pixel layer." },
12034
- 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." },
12201
+ decontaminate: { type: "number", minimum: 0, maximum: 100, description: "Decontaminate Colors amount 0-100 (default 0), only with the new-layer and new-document outputs." },
12035
12202
  edits: {
12036
12203
  type: "array",
12037
12204
  description: "Strokes applied before refining, in order.",
@@ -12694,25 +12861,33 @@ function createImageSelectionTools(controller) {
12694
12861
  const fillTool = {
12695
12862
  name: "content_aware_fill",
12696
12863
  title: "Content-Aware Fill",
12697
- description: "Edit \u203A Content-Aware Fill: removes what is selected by filling it with texture and edges synthesised from around it (on a pixel layer, default the active one). Select the object with a small margin (expand 2\u20134 px) first. output current paints into the layer; new-layer puts the fill on its own layer above it, so it can be masked or removed (recommended). sampleRadius limits where it samples from. Deterministic for a seed. 8-bit documents. One undo step. Returns the layer id and filled bounds.",
12864
+ description: "Edit \u203A Content-Aware Fill: removes what is selected by filling it with texture and edges synthesised from around it (on a pixel layer, default the active one). Select the object with a small margin (expand 2\u20134 px) first. output current paints into the layer; new-layer puts the fill on its own layer above it, so it can be masked or removed (recommended). sampleRadius limits where it samples from; sampleRects (only these areas), samplingChannelId (a saved alpha channel) and excludeRects (never these, e.g. another object that would be copied in) set the sampling area as the Content-Aware Fill workspace does. Deterministic for a seed. 8-bit documents. One undo step. Returns the layer id and filled bounds.",
12698
12865
  inputSchema: {
12699
12866
  type: "object",
12700
12867
  properties: {
12701
12868
  output: { type: "string", enum: ["new-layer", "current"], description: "new-layer or current (default current)." },
12702
12869
  sampleRadius: { type: "number", minimum: 32, maximum: 4e3, description: "Sample within this many pixels of the selection (default its larger side)." },
12703
12870
  seed: { type: "integer", minimum: 0, description: "Random seed; another seed gives another fill to choose from (default 0)." },
12871
+ sampleRects: { type: "array", minItems: 1, items: rectSchemaStrict, description: "Sample only from these rectangles (natural image pixels), instead of the ring around the selection." },
12872
+ excludeRects: { type: "array", minItems: 1, items: rectSchemaStrict, description: "Never sample from these rectangles (natural image pixels)." },
12873
+ samplingChannelId: { type: "string", description: "Sample only where this alpha channel is selected (see list_channels)." },
12704
12874
  layerId: layerIdSchema
12705
12875
  },
12706
12876
  additionalProperties: false
12707
12877
  },
12708
12878
  annotations: writeAnnotations("Content-Aware Fill"),
12709
- handler: (args) => {
12879
+ handler: async (args, context) => {
12710
12880
  if (!controller.getSelection()) throw new Error("Nothing is selected; select what to remove first.");
12711
- return controller.contentAwareFill({
12881
+ const rects = (v) => Array.isArray(v) ? v.map((r) => ({ x: num2(r?.x, NaN), y: num2(r?.y, NaN), width: num2(r?.width, NaN), height: num2(r?.height, NaN) })) : void 0;
12882
+ return controller.contentAwareFillAsync({
12712
12883
  output: args.output,
12713
12884
  sampleRadius: args.sampleRadius,
12714
12885
  seed: args.seed,
12715
- layerId: requireLayerId(args.layerId)
12886
+ layerId: requireLayerId(args.layerId),
12887
+ sampleRects: rects(args.sampleRects),
12888
+ excludeRects: rects(args.excludeRects),
12889
+ samplingChannelId: typeof args.samplingChannelId === "string" ? args.samplingChannelId : void 0,
12890
+ signal: context?.signal
12716
12891
  });
12717
12892
  }
12718
12893
  };
@@ -17877,7 +18052,7 @@ var SelectAndMaskSession = class {
17877
18052
  remembered = s.remember ? { ...s, params: { ...s.params } } : null;
17878
18053
  return this.controller.refineSelectionTo(s.params, s.output, {
17879
18054
  edits: [...this.editsList],
17880
- decontaminate: s.output === "new-layer" || s.output === "new-layer-with-mask" ? s.decontaminate : 0,
18055
+ decontaminate: s.output === "selection" || s.output === "layer-mask" ? 0 : s.decontaminate,
17881
18056
  refineMode: s.refineMode
17882
18057
  });
17883
18058
  }
@@ -19539,6 +19714,278 @@ function createImageEditorDocument(controller, options = {}) {
19539
19714
  }
19540
19715
  });
19541
19716
  }
19717
+
19718
+ // src/image-editor/content-aware-fill-session.ts
19719
+ var CONTENT_AWARE_FILL_TOOLS = [
19720
+ { id: "sampling-brush", label: "Sampling Brush", key: "b" },
19721
+ { id: "hand", label: "Hand", key: "h" },
19722
+ { id: "zoom", label: "Zoom", key: "z" }
19723
+ ];
19724
+ var CONTENT_AWARE_FILL_SAMPLING = [
19725
+ { id: "auto", label: "Auto" },
19726
+ { id: "rectangular", label: "Rectangular" },
19727
+ { id: "custom", label: "Custom" }
19728
+ ];
19729
+ var DEFAULT_CONTENT_AWARE_FILL_SETTINGS = {
19730
+ sampling: "auto",
19731
+ showSampling: true,
19732
+ opacity: 0.5,
19733
+ tool: "sampling-brush",
19734
+ brushSize: 40,
19735
+ brushMode: "add",
19736
+ seed: 0,
19737
+ output: "new-layer",
19738
+ livePreview: true
19739
+ };
19740
+ var ContentAwareFillSession = class {
19741
+ constructor(controller) {
19742
+ this.controller = controller;
19743
+ }
19744
+ controller;
19745
+ settingsValue = { ...DEFAULT_CONTENT_AWARE_FILL_SETTINGS };
19746
+ plan = null;
19747
+ /** The source view's window (document pixels): the selection with room around it to sample. */
19748
+ win = null;
19749
+ before = null;
19750
+ hole = null;
19751
+ /** 1 where the fill may copy from, per window pixel. */
19752
+ area = null;
19753
+ after = null;
19754
+ lastPoint = null;
19755
+ listeners = /* @__PURE__ */ new Set();
19756
+ dirty = false;
19757
+ /** Why the workspace can't fill (nothing selected, not a pixel layer), for the UI. */
19758
+ error = null;
19759
+ computing = false;
19760
+ revision = 0;
19761
+ get settings() {
19762
+ return this.settingsValue;
19763
+ }
19764
+ /** The source view's window in document pixels (`null` until opened). */
19765
+ get window() {
19766
+ return this.win;
19767
+ }
19768
+ subscribe(listener) {
19769
+ this.listeners.add(listener);
19770
+ return () => this.listeners.delete(listener);
19771
+ }
19772
+ emit() {
19773
+ this.revision++;
19774
+ for (const l of this.listeners) l();
19775
+ }
19776
+ /**
19777
+ * Reads the layer around the selection and makes the first preview. The window holds the
19778
+ * selection and room around it to sample from (its larger side, at least 64 px, each way).
19779
+ */
19780
+ async open() {
19781
+ try {
19782
+ const sel = this.controller.getSelection();
19783
+ const b = sel?.bounds;
19784
+ const margin = b ? Math.max(64, Math.max(b.width, b.height)) : 64;
19785
+ this.plan = this.controller.prepareContentAwareFill({ sampleRadius: margin });
19786
+ } catch (err) {
19787
+ this.error = err instanceof Error ? err.message : String(err);
19788
+ this.emit();
19789
+ return false;
19790
+ }
19791
+ this.win = this.plan.window;
19792
+ this.before = this.plan.before;
19793
+ this.hole = this.plan.hole;
19794
+ this.applySampling("auto");
19795
+ this.emit();
19796
+ await this.refresh();
19797
+ return true;
19798
+ }
19799
+ /** Sets the area to a preset: Auto (a band around the selection), Rectangular (the whole window). */
19800
+ applySampling(mode) {
19801
+ const win = this.win;
19802
+ const hole = this.hole;
19803
+ const n = win.width * win.height;
19804
+ const area = this.area && mode === "custom" ? this.area : new Uint8Array(n);
19805
+ if (mode === "rectangular") {
19806
+ for (let i = 0; i < n; i++) area[i] = hole[i] ? 0 : 1;
19807
+ } else if (mode === "auto") {
19808
+ const b = this.plan.bounds;
19809
+ const reach = Math.max(24, Math.max(b.width, b.height) / 2);
19810
+ const d = distanceTransform(hole, win.width, win.height);
19811
+ for (let i = 0; i < n; i++) area[i] = !hole[i] && d[i] <= reach ? 1 : 0;
19812
+ }
19813
+ this.area = area;
19814
+ }
19815
+ /** Changes settings; a sampling preset replaces the area, and the preview follows when live. */
19816
+ update(changes) {
19817
+ const prev = this.settingsValue;
19818
+ this.settingsValue = { ...prev, ...changes };
19819
+ if (!this.win) {
19820
+ this.emit();
19821
+ return;
19822
+ }
19823
+ if (changes.sampling && changes.sampling !== prev.sampling && changes.sampling !== "custom") this.applySampling(changes.sampling);
19824
+ this.emit();
19825
+ const fillChanged = changes.sampling !== void 0 || changes.seed !== void 0;
19826
+ if (fillChanged && this.settingsValue.livePreview) void this.refresh();
19827
+ }
19828
+ /** The sampling area as a selection of the whole image (what OK samples from). */
19829
+ samplingMask() {
19830
+ if (!this.win || !this.area) return null;
19831
+ const { width, height } = this.controller.getState().imageDimensions;
19832
+ const bytes = new Uint8Array(this.area.length);
19833
+ for (let i = 0; i < bytes.length; i++) bytes[i] = this.area[i] ? 255 : 0;
19834
+ return selectionFromRegion(width, height, this.win, bytes);
19835
+ }
19836
+ /** Whether (x, y) in document pixels is in the sampling area. */
19837
+ samplesAt(x, y) {
19838
+ const win = this.win;
19839
+ if (!win || !this.area) return false;
19840
+ const xi = Math.floor(x - win.x);
19841
+ const yi = Math.floor(y - win.y);
19842
+ if (xi < 0 || yi < 0 || xi >= win.width || yi >= win.height) return false;
19843
+ return this.area[yi * win.width + xi] === 1;
19844
+ }
19845
+ /** Paints the sampling area with the brush along a stroke segment (document pixels). */
19846
+ paint(from, to, add2) {
19847
+ const win = this.win;
19848
+ const area = this.area;
19849
+ const hole = this.hole;
19850
+ const r = Math.max(0.5, this.settingsValue.brushSize / 2);
19851
+ const x0 = Math.max(0, Math.floor(Math.min(from.x, to.x) - r - win.x));
19852
+ const y0 = Math.max(0, Math.floor(Math.min(from.y, to.y) - r - win.y));
19853
+ const x1 = Math.min(win.width - 1, Math.ceil(Math.max(from.x, to.x) + r - win.x));
19854
+ const y1 = Math.min(win.height - 1, Math.ceil(Math.max(from.y, to.y) + r - win.y));
19855
+ const dx = to.x - from.x;
19856
+ const dy = to.y - from.y;
19857
+ const len2 = dx * dx + dy * dy;
19858
+ for (let y = y0; y <= y1; y++) {
19859
+ for (let x = x0; x <= x1; x++) {
19860
+ const px2 = win.x + x + 0.5 - from.x;
19861
+ const py = win.y + y + 0.5 - from.y;
19862
+ const t = len2 > 0 ? Math.min(1, Math.max(0, (px2 * dx + py * dy) / len2)) : 0;
19863
+ if ((px2 - t * dx) ** 2 + (py - t * dy) ** 2 > r * r) continue;
19864
+ const i = y * win.width + x;
19865
+ area[i] = add2 && !hole[i] ? 1 : 0;
19866
+ }
19867
+ }
19868
+ }
19869
+ /** Pointer down with the Sampling Brush (Alt flips add and subtract). Returns false for other tools. */
19870
+ begin(point, modifiers = {}) {
19871
+ if (this.settingsValue.tool !== "sampling-brush" || !this.win) return false;
19872
+ const add2 = this.settingsValue.brushMode === "add" !== Boolean(modifiers.altKey);
19873
+ this.settingsValue = { ...this.settingsValue, sampling: "custom" };
19874
+ this.lastPoint = point;
19875
+ this.strokeAdds = add2;
19876
+ this.paint(point, point, add2);
19877
+ this.emit();
19878
+ return true;
19879
+ }
19880
+ strokeAdds = true;
19881
+ move(point) {
19882
+ if (!this.lastPoint) return;
19883
+ this.paint(this.lastPoint, point, this.strokeAdds);
19884
+ this.lastPoint = point;
19885
+ this.emit();
19886
+ }
19887
+ /** Pointer up: the preview follows the new area when live. */
19888
+ end() {
19889
+ if (!this.lastPoint) return;
19890
+ this.lastPoint = null;
19891
+ if (this.settingsValue.livePreview) void this.refresh();
19892
+ }
19893
+ /** Recomputes the preview: one at a time, the newest area following when one is running. */
19894
+ async refresh() {
19895
+ if (!this.win) return;
19896
+ if (this.computing) {
19897
+ this.dirty = true;
19898
+ return;
19899
+ }
19900
+ this.computing = true;
19901
+ this.emit();
19902
+ do {
19903
+ this.dirty = false;
19904
+ try {
19905
+ const area = this.samplingMask();
19906
+ if (!area?.bounds) throw new Error("The sampling area is empty; paint where the fill may copy from.");
19907
+ const { plan, after } = await this.controller.previewContentAwareFill({ layerId: this.plan.layerId, samplingArea: area, seed: this.settingsValue.seed });
19908
+ const win = this.win;
19909
+ const out = Uint8ClampedArray.from(this.before);
19910
+ const pw = plan.window;
19911
+ for (let y = 0; y < pw.height; y++) {
19912
+ out.set(after.subarray(y * pw.width * 4, (y + 1) * pw.width * 4), ((pw.y - win.y + y) * win.width + (pw.x - win.x)) * 4);
19913
+ }
19914
+ this.after = out;
19915
+ this.error = null;
19916
+ } catch (err) {
19917
+ this.after = null;
19918
+ this.error = err instanceof Error ? err.message : String(err);
19919
+ }
19920
+ this.emit();
19921
+ } while (this.dirty);
19922
+ this.computing = false;
19923
+ this.emit();
19924
+ }
19925
+ /** Resolves once no preview is being computed. */
19926
+ async idle() {
19927
+ while (this.computing) await new Promise((r) => setTimeout(r, 5));
19928
+ }
19929
+ /** The source view: the layer's pixels, the selection tinted red, the sampling area green. RGBA at the window's size. */
19930
+ renderSource() {
19931
+ if (!this.before || !this.area || !this.hole) return new Uint8ClampedArray(0);
19932
+ const out = Uint8ClampedArray.from(this.before);
19933
+ const k = this.settingsValue.showSampling ? Math.min(1, Math.max(0, this.settingsValue.opacity)) : 0;
19934
+ for (let i = 0; i < this.area.length; i++) {
19935
+ const p = i * 4;
19936
+ if (this.hole[i]) {
19937
+ out[p] = out[p] + (220 - out[p]) * 0.5;
19938
+ out[p + 1] *= 0.5;
19939
+ out[p + 2] *= 0.5;
19940
+ } else if (this.area[i] && k) {
19941
+ out[p] *= 1 - k;
19942
+ out[p + 1] = out[p + 1] + (200 - out[p + 1]) * k;
19943
+ out[p + 2] *= 1 - k;
19944
+ }
19945
+ out[p + 3] = 255;
19946
+ }
19947
+ return out;
19948
+ }
19949
+ /** The preview: the window with the fill in place (the source until the first preview). */
19950
+ renderPreview() {
19951
+ const src = this.after ?? this.before;
19952
+ if (!src) return new Uint8ClampedArray(0);
19953
+ const out = Uint8ClampedArray.from(src);
19954
+ for (let i = 3; i < out.length; i += 4) out[i] = 255;
19955
+ return out;
19956
+ }
19957
+ /** Clears painting and goes back to Auto. */
19958
+ reset() {
19959
+ if (!this.win) return;
19960
+ this.settingsValue = { ...DEFAULT_CONTENT_AWARE_FILL_SETTINGS, tool: this.settingsValue.tool };
19961
+ this.applySampling("auto");
19962
+ this.emit();
19963
+ if (this.settingsValue.livePreview) void this.refresh();
19964
+ }
19965
+ /**
19966
+ * Keys: B, H, Z for the tools, [ and ] for the brush size. Returns true when the key was used.
19967
+ */
19968
+ handleKey(key) {
19969
+ const k = key.toLowerCase();
19970
+ const tool = CONTENT_AWARE_FILL_TOOLS.find((t) => t.key === k);
19971
+ if (tool) {
19972
+ this.update({ tool: tool.id });
19973
+ return true;
19974
+ }
19975
+ if (k === "[" || k === "]") {
19976
+ const size = this.settingsValue.brushSize;
19977
+ this.update({ brushSize: Math.max(1, Math.round(k === "]" ? size * 1.25 : size / 1.25)) });
19978
+ return true;
19979
+ }
19980
+ return false;
19981
+ }
19982
+ /** OK: fills with this sampling area, seed and output as one undo step. */
19983
+ async commit(options = {}) {
19984
+ const area = this.samplingMask();
19985
+ if (!this.plan || !area?.bounds) throw new Error(this.error ?? "The sampling area is empty; paint where the fill may copy from.");
19986
+ return this.controller.contentAwareFillAsync({ layerId: this.plan.layerId, samplingArea: area, seed: this.settingsValue.seed, output: this.settingsValue.output, signal: options.signal });
19987
+ }
19988
+ };
19542
19989
  export {
19543
19990
  ADJUSTMENT_KEYS,
19544
19991
  ADJUSTMENT_KINDS,
@@ -19564,7 +20011,10 @@ export {
19564
20011
  COLOR_MODE_OPTIONS,
19565
20012
  COLOR_RANGE_PRESETS,
19566
20013
  COLOR_RANGE_PRESET_LABELS,
20014
+ CONTENT_AWARE_FILL_SAMPLING,
20015
+ CONTENT_AWARE_FILL_TOOLS,
19567
20016
  ColorGmm,
20017
+ ContentAwareFillSession,
19568
20018
  CoverageMap,
19569
20019
  D50,
19570
20020
  D65,
@@ -19574,6 +20024,7 @@ export {
19574
20024
  DEFAULT_BRUSH,
19575
20025
  DEFAULT_CANVAS_VIEWPORT,
19576
20026
  DEFAULT_CHANNEL_COLOR,
20027
+ DEFAULT_CONTENT_AWARE_FILL_SETTINGS,
19577
20028
  DEFAULT_DEPTH_MASK,
19578
20029
  DEFAULT_FLOW,
19579
20030
  DEFAULT_GLOBAL_LIGHT,