@pixerate/editor 0.208.0 → 0.209.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.
@@ -5103,6 +5103,7 @@ var ImageEditorController = class {
5103
5103
  /** The segmentation provider behind `selectSubject` (see `setSegmentationProvider`). */
5104
5104
  segmentation = null;
5105
5105
  selectionWorker = null;
5106
+ documentOpener = null;
5106
5107
  /** The effects provider slot (see `setEffectsProvider`). */
5107
5108
  effects = new EffectsSlot();
5108
5109
  /** Renders provider effects: effect smart filter results and effect layers. */
@@ -5114,6 +5115,7 @@ var ImageEditorController = class {
5114
5115
  if (options.effects) this.effects.set(options.effects);
5115
5116
  if (options.segmentation) this.segmentation = options.segmentation;
5116
5117
  if (options.selectionWorker) this.selectionWorker = new SelectionWorkerClient(options.selectionWorker);
5118
+ if (options.openDocument) this.documentOpener = options.openDocument;
5117
5119
  this.maxHistoryDepth = options.maxHistoryDepth ?? 50;
5118
5120
  if (options.textShaper !== void 0) this.textShaperValue = options.textShaper;
5119
5121
  this.maxHistoryBytes = options.maxHistoryBytes ?? 512 * 1024 * 1024;
@@ -5311,6 +5313,70 @@ var ImageEditorController = class {
5311
5313
  this.selectionWorker?.terminate();
5312
5314
  this.selectionWorker = factory ? new SelectionWorkerClient(factory) : null;
5313
5315
  }
5316
+ /** Sets (or with `null` removes) how new documents are opened (see `DocumentOpener`). Not an edit. */
5317
+ setDocumentOpener(opener) {
5318
+ if (this.documentOpener === opener) return;
5319
+ this.documentOpener = opener;
5320
+ this.notify();
5321
+ }
5322
+ /** Whether the host can open new documents (the New Document outputs are available). */
5323
+ get canOpenDocuments() {
5324
+ return this.documentOpener !== null;
5325
+ }
5326
+ /**
5327
+ * A new one-layer document from the selection (or `options.selection`) on a pixel layer
5328
+ * (`layerId`, default the active one), the size of this image: the selected pixels on
5329
+ * transparency, or with `withMask` the whole layer masked by the selection. This document is
5330
+ * not changed. `decontaminate` (0..100) cleans the soft edge's colours as the new-layer
5331
+ * outputs do.
5332
+ */
5333
+ documentFromSelection(options = {}) {
5334
+ const id = options.layerId ?? this.state.activeLayerId;
5335
+ if (!id) throw new LayerOperationError("No layer given and none is active; pass layerId (see list_layers).");
5336
+ const layer = requireLayer(this.state.layers, id).layer;
5337
+ if (layer.type !== "pixel") throw new LayerOperationError(`A new document needs a pixel layer; "${id}" is a ${layer.type} layer.`);
5338
+ const sel = options.selection ?? this.state.selection;
5339
+ if (!sel?.bounds) throw new LayerOperationError("Nothing is selected; select what goes in the new document first.");
5340
+ const { width, height } = this.naturalSize;
5341
+ const surface = this.surfaceOf(layer);
5342
+ let copy;
5343
+ if (options.withMask) {
5344
+ let mask = this.maskFromSelection(sel);
5345
+ if (layer.offset.x || layer.offset.y) mask = { ...mask, surface: translateSurface(mask.surface, -layer.offset.x, -layer.offset.y) };
5346
+ copy = { ...layer, id: newLayerId(), surface, mask };
5347
+ } else {
5348
+ const sb = sel.bounds;
5349
+ const src = { x: sb.x - layer.offset.x, y: sb.y - layer.offset.y, width: sb.width, height: sb.height };
5350
+ const px2 = readRegion(surface, src);
5351
+ const data = sel.data;
5352
+ for (let y = 0; y < sb.height; y++) {
5353
+ 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));
5354
+ }
5355
+ copy = { ...layer, id: newLayerId(), surface: surfaceFromPixels({ width: src.width, height: src.height, data: px2 }, 4, src.x, src.y) };
5356
+ delete copy.mask;
5357
+ }
5358
+ delete copy.source;
5359
+ copy.visible = true;
5360
+ const amount = Math.min(100, Math.max(0, options.decontaminate ?? 0)) / 100;
5361
+ if (amount > 0) copy = { ...copy, surface: this.decontaminatedSurface(copy, layer, sel, amount, Math.max(4, options.radius ?? 0)) };
5362
+ const { layers, assets, smartContents } = serializeLayers([copy]);
5363
+ const out = {
5364
+ version: LAYERED_SCHEMA_VERSION,
5365
+ width,
5366
+ height,
5367
+ layers,
5368
+ assets,
5369
+ activeLayerId: copy.id,
5370
+ crop: null,
5371
+ transform: { ...DEFAULT_TRANSFORM },
5372
+ opacity: 1
5373
+ };
5374
+ if (smartContents) out.smartContents = smartContents;
5375
+ if (this.state.bitDepth === 16 || this.state.bitDepth === 32) out.bitDepth = this.state.bitDepth;
5376
+ if (this.state.colorProfile) out.colorProfile = serializeColorProfile(this.state.colorProfile);
5377
+ if (this.state.colorMode) out.colorMode = serializeColorMode(this.state.colorMode);
5378
+ return out;
5379
+ }
5314
5380
  /** Whether the long selections run in a worker. */
5315
5381
  get hasSelectionWorker() {
5316
5382
  return this.selectionWorker !== null;
@@ -8752,8 +8818,12 @@ var ImageEditorController = class {
8752
8818
  throw new Error("Object aware refinement needs a segmentation model (setSegmentationProvider); use refineMode color.");
8753
8819
  }
8754
8820
  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.");
8821
+ const toDocument = output === "new-document" || output === "new-document-with-mask";
8822
+ const toLayer = output === "new-layer" || output === "new-layer-with-mask" || toDocument;
8823
+ 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.");
8824
+ if (toDocument && !this.documentOpener) {
8825
+ throw new Error("This editor cannot open new documents (no document opener is set); use output new-layer or new-layer-with-mask instead.");
8826
+ }
8757
8827
  let layerId;
8758
8828
  if (output !== "selection") {
8759
8829
  layerId = options.layerId ?? this.state.activeLayerId ?? void 0;
@@ -8778,6 +8848,12 @@ var ImageEditorController = class {
8778
8848
  }
8779
8849
  const refined = refineSelection(pixels, sel, params, edited.refineBrush);
8780
8850
  if (!refined.bounds) throw new Error("Refining left nothing selected; use a smaller shiftEdge or contrast.");
8851
+ if (toDocument) {
8852
+ const document2 = this.documentFromSelection({ layerId, withMask: output === "new-document-with-mask", selection: refined, decontaminate: amount * 100, radius: params.radius });
8853
+ const name = `${requireLayer(this.state.layers, layerId).layer.name || "Layer"} ${output === "new-document" ? "cutout" : "masked"}`;
8854
+ const documentId = await this.documentOpener(document2, { name, from: "select-and-mask" });
8855
+ return { output, layerId, refineMode, ...documentId ? { documentId } : {} };
8856
+ }
8781
8857
  return this.asOneStep(() => {
8782
8858
  this.setSelection(refined);
8783
8859
  if (output === "selection") return { output, refineMode };
@@ -8808,6 +8884,16 @@ var ImageEditorController = class {
8808
8884
  const layer = requireLayer(this.state.layers, id).layer;
8809
8885
  const source = requireLayer(this.state.layers, sourceId).layer;
8810
8886
  if (layer.type !== "pixel" || source.type !== "pixel" || !coverage.bounds) return;
8887
+ const surface = this.decontaminatedSurface(layer, source, coverage, amount, radius);
8888
+ const next = mapLayer(this.state.layers, id, (l) => {
8889
+ const updated = { ...l, surface };
8890
+ delete updated.source;
8891
+ return updated;
8892
+ });
8893
+ this.commitLayers(next);
8894
+ }
8895
+ /** `layer`'s surface with Decontaminate Colors applied along `coverage`'s soft edge, colours read from `source`. */
8896
+ decontaminatedSurface(layer, source, coverage, amount, radius) {
8811
8897
  const { width, height } = this.naturalSize;
8812
8898
  const pad = Math.ceil(radius * 3);
8813
8899
  const b = coverage.bounds;
@@ -8823,12 +8909,7 @@ var ImageEditorController = class {
8823
8909
  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
8910
  const cleaned = decontaminateColors(original, cov, box.width, box.height, amount, radius);
8825
8911
  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);
8912
+ return writeRegion(surface, src, cleaned);
8832
8913
  }
8833
8914
  /**
8834
8915
  * Loads a selection from a mask image (an AI segmentation, a saved mask): a data URL / URL or
@@ -11805,7 +11886,9 @@ var REFINE_OUTPUTS = [
11805
11886
  { id: "selection", label: "Selection" },
11806
11887
  { id: "layer-mask", label: "Layer Mask" },
11807
11888
  { id: "new-layer", label: "New Layer" },
11808
- { id: "new-layer-with-mask", label: "New Layer with Layer Mask" }
11889
+ { id: "new-layer-with-mask", label: "New Layer with Layer Mask" },
11890
+ { id: "new-document", label: "New Document", needsDocuments: true },
11891
+ { id: "new-document-with-mask", label: "New Document with Layer Mask", needsDocuments: true }
11809
11892
  ];
11810
11893
  function describeSelection(mask) {
11811
11894
  if (!mask || !mask.bounds) return "No selection";
@@ -12027,11 +12110,11 @@ function createImageSelectionTools(controller) {
12027
12110
  shiftEdge: { type: "number", minimum: -100, maximum: 100, description: "Move the edge out (+) or in (-), in percent." },
12028
12111
  output: {
12029
12112
  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."
12113
+ enum: ["selection", "layer-mask", "new-layer", "new-layer-with-mask", "new-document", "new-document-with-mask"],
12114
+ 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
12115
  },
12033
12116
  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." },
12117
+ 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
12118
  edits: {
12036
12119
  type: "array",
12037
12120
  description: "Strokes applied before refining, in order.",
@@ -17877,7 +17960,7 @@ var SelectAndMaskSession = class {
17877
17960
  remembered = s.remember ? { ...s, params: { ...s.params } } : null;
17878
17961
  return this.controller.refineSelectionTo(s.params, s.output, {
17879
17962
  edits: [...this.editsList],
17880
- decontaminate: s.output === "new-layer" || s.output === "new-layer-with-mask" ? s.decontaminate : 0,
17963
+ decontaminate: s.output === "selection" || s.output === "layer-mask" ? 0 : s.decontaminate,
17881
17964
  refineMode: s.refineMode
17882
17965
  });
17883
17966
  }
@@ -81,8 +81,10 @@ export interface RefineEdgeControl {
81
81
  export declare const REFINE_EDGE_CONTROLS: readonly RefineEdgeControl[];
82
82
  /** Select and Mask's outputs, in menu order. */
83
83
  export declare const REFINE_OUTPUTS: readonly {
84
- id: 'selection' | 'layer-mask' | 'new-layer' | 'new-layer-with-mask';
84
+ id: 'selection' | 'layer-mask' | 'new-layer' | 'new-layer-with-mask' | 'new-document' | 'new-document-with-mask';
85
85
  label: string;
86
+ /** Needs a host that can open documents (`controller.canOpenDocuments`). */
87
+ needsDocuments?: boolean;
86
88
  }[];
87
89
  /** A one-line status: "No selection" or "W × H px selected (n % of the image)". */
88
90
  export declare function describeSelection(mask: SelectionMask | null | undefined): string;
@@ -81,8 +81,10 @@ export interface RefineEdgeControl {
81
81
  export declare const REFINE_EDGE_CONTROLS: readonly RefineEdgeControl[];
82
82
  /** Select and Mask's outputs, in menu order. */
83
83
  export declare const REFINE_OUTPUTS: readonly {
84
- id: 'selection' | 'layer-mask' | 'new-layer' | 'new-layer-with-mask';
84
+ id: 'selection' | 'layer-mask' | 'new-layer' | 'new-layer-with-mask' | 'new-document' | 'new-document-with-mask';
85
85
  label: string;
86
+ /** Needs a host that can open documents (`controller.canOpenDocuments`). */
87
+ needsDocuments?: boolean;
86
88
  }[];
87
89
  /** A one-line status: "No selection" or "W × H px selected (n % of the image)". */
88
90
  export declare function describeSelection(mask: SelectionMask | null | undefined): string;
@@ -456,7 +456,21 @@ export interface ImageEditorOptions {
456
456
  * Without one they run on the page. Change it later with `setSelectionWorker`.
457
457
  */
458
458
  selectionWorker?: SelectionWorkerFactory | null;
459
+ /**
460
+ * Opens a new image document the editor made (Select and Mask's New Document outputs) the way
461
+ * the host shows documents: a tab, a window, a file. Without it those outputs are unavailable.
462
+ * Change it later with `setDocumentOpener`.
463
+ */
464
+ openDocument?: DocumentOpener | null;
459
465
  }
466
+ /**
467
+ * Opens a new image document for the host: `document` loads with `loadJSON`; `info.name` is a
468
+ * suggested name. May return the new document's id (handed back to the caller and to agents).
469
+ */
470
+ export type DocumentOpener = (document: SerializedImageEditorState, info: {
471
+ name: string;
472
+ from: 'select-and-mask';
473
+ }) => string | void | Promise<string | void>;
460
474
  export interface InpaintOptions {
461
475
  prompt: string;
462
476
  imageDataUrl: string;
@@ -456,7 +456,21 @@ export interface ImageEditorOptions {
456
456
  * Without one they run on the page. Change it later with `setSelectionWorker`.
457
457
  */
458
458
  selectionWorker?: SelectionWorkerFactory | null;
459
+ /**
460
+ * Opens a new image document the editor made (Select and Mask's New Document outputs) the way
461
+ * the host shows documents: a tab, a window, a file. Without it those outputs are unavailable.
462
+ * Change it later with `setDocumentOpener`.
463
+ */
464
+ openDocument?: DocumentOpener | null;
459
465
  }
466
+ /**
467
+ * Opens a new image document for the host: `document` loads with `loadJSON`; `info.name` is a
468
+ * suggested name. May return the new document's id (handed back to the caller and to agents).
469
+ */
470
+ export type DocumentOpener = (document: SerializedImageEditorState, info: {
471
+ name: string;
472
+ from: 'select-and-mask';
473
+ }) => string | void | Promise<string | void>;
460
474
  export interface InpaintOptions {
461
475
  prompt: string;
462
476
  imageDataUrl: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixerate/editor",
3
- "version": "0.208.0",
3
+ "version": "0.209.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. `edits` (`quick-select`, `brush`, `lasso`, `object` 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. |
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`, `new-layer-with-mask` (on `layerId`, default active; new layers hide the source), or `new-document` / `new-document-with-mask` (host must open documents; returns `documentId`), with `decontaminate` 0..100 for new layers and documents. `edits` (`quick-select`, `brush`, `lasso`, `object` 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. |