@pixerate/editor 0.186.0 → 0.188.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.
@@ -298,6 +298,8 @@ import {
298
298
  selectionArea,
299
299
  selectionFromCoverage,
300
300
  selectionFromPixels,
301
+ selectionFromRegion,
302
+ selectionFromTiles,
301
303
  selectionToCoverage,
302
304
  selectionToPixels,
303
305
  selectionValueAt,
@@ -370,7 +372,7 @@ import {
370
372
  writePsd,
371
373
  xyToTempTint,
372
374
  xyToUv
373
- } from "../chunk-FW7N64XK.js";
375
+ } from "../chunk-IGMNJM2H.js";
374
376
  import {
375
377
  parseCssColor
376
378
  } from "../chunk-3CI7FRMK.js";
@@ -2835,8 +2837,33 @@ var TypeToolGesture = class {
2835
2837
 
2836
2838
  // src/image-editor/selection/shapes.ts
2837
2839
  function polygonCoverage(points, width, height, antiAlias = true) {
2840
+ const { box, coverage } = polygonCoverageBox(points, width, height, antiAlias);
2841
+ if (!box || box.width === width && box.height === height) return box ? coverage : new Float32Array(width * height);
2838
2842
  const m = new Float32Array(width * height);
2839
- if (points.length < 3 || width <= 0 || height <= 0) return m;
2843
+ for (let y = 0; y < box.height; y++) m.set(coverage.subarray(y * box.width, (y + 1) * box.width), (box.y + y) * width + box.x);
2844
+ return m;
2845
+ }
2846
+ function polygonCoverageBox(points, width, height, antiAlias = true) {
2847
+ const none = { box: null, coverage: new Float32Array(0) };
2848
+ if (points.length < 3 || width <= 0 || height <= 0) return none;
2849
+ let minX = Infinity;
2850
+ let minY = Infinity;
2851
+ let maxX = -Infinity;
2852
+ let maxY = -Infinity;
2853
+ for (const p of points) {
2854
+ if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) continue;
2855
+ minX = Math.min(minX, p.x);
2856
+ minY = Math.min(minY, p.y);
2857
+ maxX = Math.max(maxX, p.x);
2858
+ maxY = Math.max(maxY, p.y);
2859
+ }
2860
+ const bx0 = Math.max(0, Math.floor(minX));
2861
+ const by0 = Math.max(0, Math.floor(minY));
2862
+ const bx1 = Math.min(width, Math.ceil(maxX));
2863
+ const by1 = Math.min(height, Math.ceil(maxY));
2864
+ if (!(bx1 > bx0 && by1 > by0)) return none;
2865
+ const bw = bx1 - bx0;
2866
+ const m = new Float32Array(bw * (by1 - by0));
2840
2867
  const sub2 = antiAlias ? 4 : 1;
2841
2868
  const edges = [];
2842
2869
  for (let i = 0; i < points.length; i++) {
@@ -2850,8 +2877,8 @@ function polygonCoverage(points, width, height, antiAlias = true) {
2850
2877
  let next = 0;
2851
2878
  let active = [];
2852
2879
  const xs = [];
2853
- for (let py = 0; py < height; py++) {
2854
- const row = py * width;
2880
+ for (let py = by0; py < by1; py++) {
2881
+ const row = (py - by0) * bw - bx0;
2855
2882
  for (let s = 0; s < sub2; s++) {
2856
2883
  const y = py + (s + 0.5) / sub2;
2857
2884
  while (next < edges.length && edges[next].top <= y) active.push(edges[next++]);
@@ -2881,15 +2908,14 @@ function polygonCoverage(points, width, height, antiAlias = true) {
2881
2908
  }
2882
2909
  }
2883
2910
  for (let i = 0; i < m.length; i++) if (m[i] > 1) m[i] = 1;
2884
- return m;
2911
+ return { box: { x: bx0, y: by0, width: bw, height: by1 - by0 }, coverage: m };
2885
2912
  }
2886
- function toMask(width, height, coverage) {
2913
+ function polygonSelection(width, height, points, options = {}) {
2914
+ const { box, coverage } = polygonCoverageBox(points, width, height, options.antiAlias ?? true);
2915
+ if (!box) return createSelectionMask(width, height);
2887
2916
  const out = new Uint8Array(coverage.length);
2888
2917
  for (let i = 0; i < out.length; i++) out[i] = coverageToByte(coverage[i]);
2889
- return createSelectionMask(width, height, out);
2890
- }
2891
- function polygonSelection(width, height, points, options = {}) {
2892
- return toMask(width, height, polygonCoverage(points, width, height, options.antiAlias ?? true));
2918
+ return selectionFromRegion(width, height, box, out);
2893
2919
  }
2894
2920
  function normalizeRect(rect) {
2895
2921
  const x = rect.width < 0 ? rect.x + rect.width : rect.x;
@@ -3531,9 +3557,7 @@ function selectionRing(sel, width, location) {
3531
3557
  var DEFAULT_CHANNEL_COLOR = "#ff0000";
3532
3558
  function describeChannel(channel) {
3533
3559
  const { mask, ...rest } = channel;
3534
- let sum = 0;
3535
- for (let i = 0; i < mask.data.length; i++) sum += mask.data[i];
3536
- const percent = Math.round(sum / 255 / Math.max(1, mask.data.length) * 1e4) / 100;
3560
+ const percent = Math.round(selectionArea(mask) / Math.max(1, mask.width * mask.height) * 1e4) / 100;
3537
3561
  return { ...rest, bounds: mask.bounds, percent };
3538
3562
  }
3539
3563
  function serializeChannels(channels) {
@@ -4144,8 +4168,7 @@ var FreeTransformSession = class {
4144
4168
  const result = part.base ? compositeSurfaceOver(part.base, moved) : moved;
4145
4169
  if (part.what === "selection") {
4146
4170
  const { width, height } = this.host.canvas();
4147
- const data = new Uint8Array(readRegion(result, { x: 0, y: 0, width, height }).buffer);
4148
- const next = createSelectionMask(width, height, data);
4171
+ const next = selectionFromTiles(width, height, result);
4149
4172
  selection = next.bounds ? next : null;
4150
4173
  } else if (part.what === "pixels") {
4151
4174
  layers = mapLayer(layers, targetId, (l) => {
@@ -5471,14 +5494,18 @@ var ImageEditorController = class {
5471
5494
  this.trimHistoryBytes();
5472
5495
  }
5473
5496
  /**
5474
- * Bytes of pixel tiles held only by undo / redo steps (not by the current state). The oldest
5475
- * undo steps are dropped while it exceeds `maxHistoryBytes`.
5497
+ * Bytes of pixel, selection and channel tiles held only by undo / redo steps (not by the
5498
+ * current state). The oldest undo steps are dropped while it exceeds `maxHistoryBytes`.
5476
5499
  */
5477
5500
  get historyBytes() {
5478
5501
  const live = /* @__PURE__ */ new Set();
5479
5502
  collectStateTiles(this.state.layers, live);
5503
+ collectSelectionTiles(this.state.selection, this.state.channels, live);
5480
5504
  const held = /* @__PURE__ */ new Set();
5481
- for (const e of [...this.undoStack, ...this.redoStack]) collectStateTiles(e.layers, held);
5505
+ for (const e of [...this.undoStack, ...this.redoStack]) {
5506
+ collectStateTiles(e.layers, held);
5507
+ collectSelectionTiles(e.selection, e.channels, held);
5508
+ }
5482
5509
  let n = 0;
5483
5510
  for (const t of held) if (!live.has(t)) n += t.data.byteLength;
5484
5511
  return n;
@@ -7376,7 +7403,7 @@ var ImageEditorController = class {
7376
7403
  /** @internal Shows a stroke's live surface (no history). */
7377
7404
  _strokeUpdate(layerId, channel, surface, rects) {
7378
7405
  if (channel === "quick-mask") {
7379
- this.state.selection = this.selectionFromSurface(surface, this.state.selection ?? null, rects);
7406
+ this.state.selection = this.selectionFromSurface(surface);
7380
7407
  this.notify();
7381
7408
  return;
7382
7409
  }
@@ -8220,6 +8247,39 @@ var ImageEditorController = class {
8220
8247
  this.setSelection(selectionFromPixels(pixels, options), options.mode ?? "replace");
8221
8248
  return this.getSelection();
8222
8249
  }
8250
+ /**
8251
+ * Loads a mask image (a data URL / URL or RGBA pixels, any size, resampled) into a new alpha
8252
+ * channel, read like `loadSelectionFromImage`. The selection does not change. One undo step.
8253
+ */
8254
+ async loadChannelFromImage(source, options = {}) {
8255
+ const mask = selectionFromPixels(await this.decodeMaskImage(source), options);
8256
+ const { width, height } = this.naturalSize;
8257
+ return this.addChannel(mask.width === width && mask.height === height ? mask : resizeSelection(mask, width, height), options.name);
8258
+ }
8259
+ /**
8260
+ * A full-size greyscale PNG (white = selected / revealed) of the selection, an alpha channel or
8261
+ * a layer's painted mask, as a data URL; `invert` swaps black and white, `maxSide` scales it
8262
+ * down. Encoded in code, so it works without a canvas. Throws when the source is missing.
8263
+ */
8264
+ exportMaskImage(source, options = {}) {
8265
+ let mask;
8266
+ if (source === "selection") {
8267
+ const sel = this.state.selection;
8268
+ if (!sel?.bounds) throw new LayerOperationError("Nothing is selected; select first, or export a channel or a layer mask.");
8269
+ mask = sel;
8270
+ } else if ("channelId" in source) {
8271
+ mask = this.requireChannel(source.channelId).mask;
8272
+ } else {
8273
+ const layer = requireLayer(this.state.layers, source.layerId).layer;
8274
+ const coverage = this.layerMaskCoverage(source.layerId);
8275
+ if (!coverage) throw new LayerOperationError(`Layer "${layer.name || source.layerId}" has no painted mask to export.`);
8276
+ mask = coverage;
8277
+ }
8278
+ const k = options.maxSide ? Math.min(1, options.maxSide / Math.max(mask.width, mask.height)) : 1;
8279
+ if (k < 1) mask = resizeSelection(mask, Math.max(1, Math.round(mask.width * k)), Math.max(1, Math.round(mask.height * k)));
8280
+ if (options.invert) mask = invertSelection(mask);
8281
+ return { width: mask.width, height: mask.height, dataUrl: pngDataUrl(mask.width, mask.height, mask.data, 1) };
8282
+ }
8223
8283
  /** A mask image (data URL / URL, or pixels as they are) as RGBA pixels. */
8224
8284
  async decodeMaskImage(source) {
8225
8285
  if (typeof source !== "string") return source;
@@ -8337,10 +8397,15 @@ var ImageEditorController = class {
8337
8397
  this.setChannels(channels.map((c) => c.id === existing.id ? next : c));
8338
8398
  return next;
8339
8399
  }
8400
+ return this.addChannel(sized, options.name);
8401
+ }
8402
+ /** Appends a new channel holding `mask` (canvas-sized) as one undo step. */
8403
+ addChannel(mask, name) {
8404
+ const channels = this.getChannels();
8340
8405
  const channel = {
8341
8406
  id: this.newChannelId(),
8342
- name: options.name?.trim() || `Alpha ${channels.length + 1}`,
8343
- mask: sized,
8407
+ name: name?.trim() || `Alpha ${channels.length + 1}`,
8408
+ mask,
8344
8409
  color: DEFAULT_CHANNEL_COLOR,
8345
8410
  opacity: 0.5,
8346
8411
  indicates: "masked",
@@ -8416,35 +8481,18 @@ var ImageEditorController = class {
8416
8481
  const { width, height } = this.naturalSize;
8417
8482
  const sel = this.state.selection;
8418
8483
  if (!sel?.bounds) return emptySurface(1, 0);
8419
- const sized = sel.width === width && sel.height === height ? sel : resizeSelection(sel, width, height);
8420
- return surfaceFromPixels({ width, height, data: sized.data }, 1, 0, 0, 0);
8484
+ return (sel.width === width && sel.height === height ? sel : resizeSelection(sel, width, height)).surface;
8421
8485
  }
8422
- /** The selection a painted Quick Mask surface stands for (`rects` changed since `base`). */
8423
- selectionFromSurface(surface, base = null, rects) {
8486
+ /** The selection a painted Quick Mask surface stands for (it shares the surface's tiles). */
8487
+ selectionFromSurface(surface) {
8424
8488
  const { width, height } = this.naturalSize;
8425
- let data;
8426
- if (base && rects && base.width === width && base.height === height) {
8427
- data = new Uint8Array(base.data);
8428
- for (const r of rects) {
8429
- const x0 = Math.max(0, Math.floor(r.x));
8430
- const y0 = Math.max(0, Math.floor(r.y));
8431
- const x1 = Math.min(width, Math.ceil(r.x + r.width));
8432
- const y1 = Math.min(height, Math.ceil(r.y + r.height));
8433
- if (x1 <= x0 || y1 <= y0) continue;
8434
- const part = readRegion(surface, { x: x0, y: y0, width: x1 - x0, height: y1 - y0 });
8435
- for (let y = y0; y < y1; y++) data.set(part.subarray((y - y0) * (x1 - x0), (y - y0 + 1) * (x1 - x0)), y * width + x0);
8436
- }
8437
- } else {
8438
- const all = readRegion(surface, { x: 0, y: 0, width, height });
8439
- data = new Uint8Array(all.buffer, all.byteOffset, all.length);
8440
- }
8441
- return normalizeSelection(createSelectionMask(width, height, data));
8489
+ return normalizeSelection(selectionFromTiles(width, height, surface));
8442
8490
  }
8443
8491
  // --- Layer masks ---
8444
8492
  maskFromSelection(sel, props = {}) {
8445
8493
  const { width, height } = this.naturalSize;
8446
8494
  const sized = sel.width === width && sel.height === height ? sel : resizeSelection(sel, width, height);
8447
- const mask = { surface: surfaceFromPixels({ width, height, data: sized.data }, 1, 0, 0, 0), defaultValue: 0 };
8495
+ const mask = { surface: sized.surface, defaultValue: 0 };
8448
8496
  return withMaskProps(mask, props);
8449
8497
  }
8450
8498
  /**
@@ -8486,14 +8534,19 @@ var ImageEditorController = class {
8486
8534
  * with `invert`. False without a painted mask (a depth mask is computed, not painted).
8487
8535
  */
8488
8536
  selectionFromLayerMask(layerId, mode = "replace", invert2 = false) {
8537
+ const sel = this.layerMaskCoverage(layerId);
8538
+ if (!sel) return false;
8539
+ return this.setSelection(invert2 ? invertSelection(sel) : sel, mode);
8540
+ }
8541
+ /** A layer's painted mask over the canvas as a selection mask; null without one (or for a depth mask). */
8542
+ layerMaskCoverage(layerId) {
8489
8543
  const layer = this.getLayer(layerId);
8490
- if (!layer?.mask || layer.mask.source) return false;
8544
+ if (!layer?.mask || layer.mask.source) return null;
8491
8545
  const { width, height } = this.naturalSize;
8492
8546
  const ox = layer.type === "pixel" ? layer.offset.x : 0;
8493
8547
  const oy = layer.type === "pixel" ? layer.offset.y : 0;
8494
8548
  const data = readRegion(layer.mask.surface, { x: -ox, y: -oy, width, height });
8495
- const sel = createSelectionMask(width, height, new Uint8Array(data.buffer, data.byteOffset, data.length));
8496
- return this.setSelection(invert2 ? invertSelection(sel) : sel, mode);
8549
+ return createSelectionMask(width, height, new Uint8Array(data.buffer, data.byteOffset, data.length));
8497
8550
  }
8498
8551
  // --- Histogram ---
8499
8552
  /**
@@ -10111,6 +10164,10 @@ function isOpaqueInside(surface, width, height) {
10111
10164
  }
10112
10165
  return true;
10113
10166
  }
10167
+ function collectSelectionTiles(selection, channels, into) {
10168
+ if (selection) collectTiles(selection.surface, into);
10169
+ for (const c of channels ?? []) collectTiles(c.mask.surface, into);
10170
+ }
10114
10171
  function collectStateTiles(layers, into) {
10115
10172
  for (const l of flattenLayers(layers)) {
10116
10173
  if (l.type === "pixel" && l.source !== "document") collectTiles(l.surface, into);
@@ -10851,7 +10908,7 @@ var DEPTH_MASK_CONTROLS = [
10851
10908
  // src/image-editor/playback.ts
10852
10909
  var IMAGE_PLAYBACK_KIND = "layer";
10853
10910
  var PAINT = /^(paint_stroke|clone_stamp|heal_brush|spot_heal|dodge|burn|sponge|smudge|blur_stroke|sharpen_stroke|history_brush|set_clone_source|set_clone_transform|fill_pixels|clear_pixels|set_paint|set_retouch_options|load_mixer_brush|clean_mixer_brush)$/;
10854
- var SELECT = /^(select_region|select_subject|select_by_color|select_all|deselect|invert_selection|select_rect|select_ellipse|select_polygon|select_line|magic_wand|grow_selection|select_similar|reselect|select_layer_transparency|select_color_range|load_selection_from_image|load_selection_from_mask|set_selection_display|modify_selection|refine_selection|transform_selection|apply_selection_as_mask|layer_via_copy|stroke_selection|content_aware_fill|copy_pixels|cut_pixels|paste_pixels|save_selection|load_selection_from_channel|set_channel_properties|duplicate_channel|delete_channel|set_quick_mask)$/;
10911
+ var SELECT = /^(select_region|select_subject|select_by_color|select_all|deselect|invert_selection|select_rect|select_ellipse|select_polygon|select_line|magic_wand|grow_selection|select_similar|reselect|select_layer_transparency|select_color_range|load_selection_from_image|load_selection_from_mask|set_selection_display|modify_selection|refine_selection|transform_selection|apply_selection_as_mask|layer_via_copy|stroke_selection|content_aware_fill|copy_pixels|cut_pixels|paste_pixels|save_selection|load_selection_from_channel|set_channel_properties|duplicate_channel|delete_channel|load_channel_from_image|set_quick_mask)$/;
10855
10912
  var ANNOTATE = /^(add_shape_layer|update_shape|add_shape_component|set_shape_component|remove_shape_component|merge_shape_components|add_text_layer|set_text|set_text_style|set_paragraph_style|set_text_frame|convert_text_to_shape)$/;
10856
10913
  var CROP = /^(set_crop|crop_to_selection|reset_crop|set_rotation|flip_image|transform_layer|warp_layer|flip_layer|offset_layer)$/;
10857
10914
  var ADJUST = /^(set_adjustments|add_adjustment_layer|update_adjustment_layer|remove_adjustment_layer|move_adjustment_layer)$/;
@@ -11020,9 +11077,7 @@ var REFINE_EDGE_CONTROLS = [
11020
11077
  ];
11021
11078
  function describeSelection(mask) {
11022
11079
  if (!mask || !mask.bounds) return "No selection";
11023
- let sum = 0;
11024
- for (let i = 0; i < mask.data.length; i++) sum += mask.data[i];
11025
- const pct2 = sum / 255 / mask.data.length * 100;
11080
+ const pct2 = selectionArea(mask) / (mask.width * mask.height) * 100;
11026
11081
  const shown = pct2 >= 10 ? Math.round(pct2) : Math.round(pct2 * 10) / 10;
11027
11082
  return `${mask.bounds.width} \xD7 ${mask.bounds.height} px selected (${shown}% of the image)`;
11028
11083
  }
@@ -11867,6 +11922,64 @@ function createImageSelectionTools(controller) {
11867
11922
  return { quickMask: controller.quickMaskEditing, selection: summary(controller) };
11868
11923
  }
11869
11924
  };
11925
+ const exportMaskTool = {
11926
+ name: "export_mask_image",
11927
+ title: "Export a mask as a PNG",
11928
+ description: "Writes the selection, an alpha channel (channelId) or a layer's painted mask (layerId) as a greyscale PNG: white = selected / revealed. Use it to look at a mask on its own, or to hand it to another tool or service; load_channel_from_image and load_selection_from_image read it back. Give at most one of channelId and layerId (neither = the selection). Changes nothing. Returns the image and its size.",
11929
+ inputSchema: {
11930
+ type: "object",
11931
+ properties: {
11932
+ channelId: channelIdSchema,
11933
+ layerId: { type: "string", minLength: 1, description: "A layer whose painted mask to export (see list_layers)." },
11934
+ invert: { type: "boolean", description: "Black = selected instead (default false)." },
11935
+ maxSide: { type: "integer", minimum: 16, maximum: 16384, description: "Longest side in pixels; larger masks are scaled down (default 1024, never scaled up)." }
11936
+ },
11937
+ additionalProperties: false
11938
+ },
11939
+ annotations: { title: "Export a mask as a PNG", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
11940
+ handler: (args) => {
11941
+ if (args.channelId !== void 0 && args.layerId !== void 0) throw new Error("Give channelId or layerId, not both (neither exports the selection).");
11942
+ if (typeof args.layerId === "string" && !controller.getLayer(args.layerId)) throw new Error(`No layer "${args.layerId}". Call list_layers for ids.`);
11943
+ const source = typeof args.channelId === "string" ? { channelId: args.channelId } : typeof args.layerId === "string" ? { layerId: args.layerId } : "selection";
11944
+ const out = controller.exportMaskImage(source, { invert: args.invert === true, maxSide: typeof args.maxSide === "number" ? args.maxSide : 1024 });
11945
+ const what = source === "selection" ? "Selection" : "channelId" in source ? `Channel ${source.channelId}` : `Mask of layer ${source.layerId}`;
11946
+ return {
11947
+ content: [
11948
+ { type: "image", data: out.dataUrl.slice(out.dataUrl.indexOf(",") + 1), mimeType: "image/png" },
11949
+ { type: "text", text: `${what}, ${out.width}\xD7${out.height}` }
11950
+ ],
11951
+ structuredContent: { width: out.width, height: out.height }
11952
+ };
11953
+ }
11954
+ };
11955
+ const loadChannelFromImageTool = {
11956
+ name: "load_channel_from_image",
11957
+ title: "Load a mask image into a new channel",
11958
+ description: "Stores a mask image (an AI segmentation, a matte, a PNG from export_mask_image) in a new alpha channel without touching the selection: white = selected by luminance, or one channel. Any size; it is resampled to the image. Load it as the selection later with load_selection_from_channel. One undo step. Returns the channel.",
11959
+ inputSchema: {
11960
+ type: "object",
11961
+ properties: {
11962
+ image: { type: "string", minLength: 1, description: "The mask as a data URL (data:image/png;base64,\u2026) or an image URL." },
11963
+ name: { type: "string", minLength: 1, description: 'Channel name (default "Alpha n").' },
11964
+ channel: { type: "string", enum: ["luminance", "alpha", "red", "green", "blue"], description: "What to read as coverage (default luminance; alpha for a cutout PNG)." },
11965
+ threshold: { type: "number", minimum: 0, maximum: 255, description: "Values at or above this are selected, the rest not (default: keep soft values)." },
11966
+ invert: { type: "boolean", description: "Black = selected instead (default false)." }
11967
+ },
11968
+ required: ["image"],
11969
+ additionalProperties: false
11970
+ },
11971
+ annotations: { ...writeAnnotations("Load a mask image into a new channel"), openWorldHint: true },
11972
+ handler: async (args) => {
11973
+ if (typeof args.image !== "string" || !args.image.trim()) throw new Error("image must be a data URL or image URL.");
11974
+ const c = await controller.loadChannelFromImage(args.image, {
11975
+ name: args.name,
11976
+ channel: args.channel ?? "luminance",
11977
+ invert: args.invert === true,
11978
+ threshold: typeof args.threshold === "number" ? Math.min(255, Math.max(0, args.threshold)) : void 0
11979
+ });
11980
+ return { channel: channelResult(c.id) };
11981
+ }
11982
+ };
11870
11983
  return [
11871
11984
  getSelection,
11872
11985
  selectAll,
@@ -11902,6 +12015,8 @@ function createImageSelectionTools(controller) {
11902
12015
  channelPropsTool,
11903
12016
  duplicateChannelTool,
11904
12017
  deleteChannelTool,
12018
+ exportMaskTool,
12019
+ loadChannelFromImageTool,
11905
12020
  quickMaskTool
11906
12021
  ];
11907
12022
  }
@@ -15963,6 +16078,39 @@ function handleQuickMaskKey(controller, e) {
15963
16078
  return true;
15964
16079
  }
15965
16080
 
16081
+ // src/image-editor/selection/mask-files.ts
16082
+ var MASK_FILE_ACCEPT = "image/png,image/jpeg,image/webp,image/gif,image/bmp";
16083
+ function blobDataUrl(file) {
16084
+ return new Promise((resolve, reject) => {
16085
+ const reader = new FileReader();
16086
+ reader.onload = () => resolve(String(reader.result));
16087
+ reader.onerror = () => reject(reader.error ?? new Error("The file could not be read."));
16088
+ reader.readAsDataURL(file);
16089
+ });
16090
+ }
16091
+ function maskNameFromFile(file) {
16092
+ const name = file.name;
16093
+ return name ? name.replace(/\.[a-z0-9]{2,4}$/i, "").trim() || void 0 : void 0;
16094
+ }
16095
+ async function openMaskFile(controller, file) {
16096
+ return controller.loadChannelFromImage(await blobDataUrl(file), { name: maskNameFromFile(file) });
16097
+ }
16098
+ function maskFileName(name) {
16099
+ const base = name.replace(/\.[a-z0-9]{2,4}$/i, "").replace(/[\\/:*?"<>|]+/g, "-").trim() || "mask";
16100
+ return `${base}.png`;
16101
+ }
16102
+ function downloadMaskImage(controller, source, name = "mask") {
16103
+ const { dataUrl, width, height } = controller.exportMaskImage(source);
16104
+ const fileName = maskFileName(name);
16105
+ if (typeof document !== "undefined") {
16106
+ const a = document.createElement("a");
16107
+ a.href = dataUrl;
16108
+ a.download = fileName;
16109
+ a.click();
16110
+ }
16111
+ return { fileName, width, height };
16112
+ }
16113
+
15966
16114
  // src/image-editor/mosaic-filter.ts
15967
16115
  function resolveMosaicRegionBounds(region, imgWidth, imgHeight) {
15968
16116
  if (imgWidth <= 0 || imgHeight <= 0 || region.width <= 0 || region.height <= 0) {
@@ -17624,6 +17772,7 @@ export {
17624
17772
  LayerEffectError,
17625
17773
  LayerOperationError,
17626
17774
  MARCHING_ANTS_STEP_MS,
17775
+ MASK_FILE_ACCEPT,
17627
17776
  MAX_BATCH,
17628
17777
  MAX_BRUSH_SIZE,
17629
17778
  MAX_CANVAS_BACKING_SIDE,
@@ -17975,6 +18124,7 @@ export {
17975
18124
  documentProfile,
17976
18125
  dodgeBurn,
17977
18126
  dodgeBurnCurve,
18127
+ downloadMaskImage,
17978
18128
  downloadPsd,
17979
18129
  dpow,
17980
18130
  dragWarpMeshPoint,
@@ -18167,6 +18317,8 @@ export {
18167
18317
  mapLayer,
18168
18318
  mapSubpaths,
18169
18319
  maskBounds,
18320
+ maskFileName,
18321
+ maskNameFromFile,
18170
18322
  maskSummary,
18171
18323
  maskThumbnailClick,
18172
18324
  matrixFromCorners,
@@ -18224,6 +18376,7 @@ export {
18224
18376
  offsetLayer,
18225
18377
  offsetShape,
18226
18378
  offsetSmartObject,
18379
+ openMaskFile,
18227
18380
  openPsdFile,
18228
18381
  packRgba,
18229
18382
  packWetParams,
@@ -18269,6 +18422,7 @@ export {
18269
18422
  pointerStrokeSample,
18270
18423
  pointerStrokeSamples,
18271
18424
  polygonCoverage,
18425
+ polygonCoverageBox,
18272
18426
  polygonSelection,
18273
18427
  posterizeValue,
18274
18428
  presetWarp,
@@ -18368,6 +18522,8 @@ export {
18368
18522
  selectionArea,
18369
18523
  selectionFromCoverage,
18370
18524
  selectionFromPixels,
18525
+ selectionFromRegion,
18526
+ selectionFromTiles,
18371
18527
  selectionModeFromModifiers,
18372
18528
  selectionOutline,
18373
18529
  selectionRing,
@@ -10,3 +10,4 @@ export * from './gesture.cjs';
10
10
  export * from './segmentation.cjs';
11
11
  export * from './stroke.cjs';
12
12
  export * from './channels.cjs';
13
+ export * from './mask-files.cjs';
@@ -10,3 +10,4 @@ export * from './gesture.js';
10
10
  export * from './segmentation.js';
11
11
  export * from './stroke.js';
12
12
  export * from './channels.js';
13
+ export * from './mask-files.js';
@@ -0,0 +1,20 @@
1
+ /** Framework-free helpers behind the Import and Export PNG buttons of the React and Svelte Channels panels. */
2
+ import type { ImageEditorController, MaskImageSource } from '../controller.cjs';
3
+ import type { AlphaChannel } from './channels.cjs';
4
+ /** Types the mask file pickers accept. */
5
+ export declare const MASK_FILE_ACCEPT = "image/png,image/jpeg,image/webp,image/gif,image/bmp";
6
+ /** A channel name from a file name: without its extension, or `Alpha n`. */
7
+ export declare function maskNameFromFile(file: Blob): string | undefined;
8
+ /** Reads a picked mask image (white = selected) into a new alpha channel named after the file (one undo step). */
9
+ export declare function openMaskFile(controller: ImageEditorController, file: Blob): Promise<AlphaChannel>;
10
+ /** A file name for an exported mask: `name` with a `.png` extension. */
11
+ export declare function maskFileName(name: string): string;
12
+ /**
13
+ * Writes the selection, a channel or a layer mask as a full-size greyscale PNG and offers it as a
14
+ * download (in a browser). Returns the file name and size.
15
+ */
16
+ export declare function downloadMaskImage(controller: ImageEditorController, source: MaskImageSource, name?: string): {
17
+ fileName: string;
18
+ width: number;
19
+ height: number;
20
+ };
@@ -0,0 +1,20 @@
1
+ /** Framework-free helpers behind the Import and Export PNG buttons of the React and Svelte Channels panels. */
2
+ import type { ImageEditorController, MaskImageSource } from '../controller.js';
3
+ import type { AlphaChannel } from './channels.js';
4
+ /** Types the mask file pickers accept. */
5
+ export declare const MASK_FILE_ACCEPT = "image/png,image/jpeg,image/webp,image/gif,image/bmp";
6
+ /** A channel name from a file name: without its extension, or `Alpha n`. */
7
+ export declare function maskNameFromFile(file: Blob): string | undefined;
8
+ /** Reads a picked mask image (white = selected) into a new alpha channel named after the file (one undo step). */
9
+ export declare function openMaskFile(controller: ImageEditorController, file: Blob): Promise<AlphaChannel>;
10
+ /** A file name for an exported mask: `name` with a `.png` extension. */
11
+ export declare function maskFileName(name: string): string;
12
+ /**
13
+ * Writes the selection, a channel or a layer mask as a full-size greyscale PNG and offers it as a
14
+ * download (in a browser). Returns the file name and size.
15
+ */
16
+ export declare function downloadMaskImage(controller: ImageEditorController, source: MaskImageSource, name?: string): {
17
+ fileName: string;
18
+ width: number;
19
+ height: number;
20
+ };
@@ -1,11 +1,17 @@
1
1
  import type { CropBox } from '../types.cjs';
2
+ import { TiledSurface } from '../../raster/surface.cjs';
2
3
  /**
3
4
  * A selection: one 8-bit coverage value per image pixel (0 = not selected, 255 = fully
4
5
  * selected), row-major over the whole source image in natural pixels — the same space as the
5
6
  * crop box and annotations. Soft values are partial selection (feathered or anti-aliased edges).
6
7
  *
8
+ * The coverage is stored as 256 × 256 tiles (`surface`, one byte per pixel, missing tiles are 0
9
+ * and every pixel outside the image is 0), so an empty or fully selected area costs nothing and
10
+ * masks share the tiles an operation did not change. `data` is the same coverage as one array,
11
+ * built on first read and cached for the few masks read most recently.
12
+ *
7
13
  * Treat a mask as immutable: every operation returns a new one, so history entries can share
8
- * them without copying.
14
+ * them without copying. Never write to `data`.
9
15
  */
10
16
  export interface SelectionMask {
11
17
  readonly width: number;
@@ -13,6 +19,8 @@ export interface SelectionMask {
13
19
  readonly data: Uint8Array;
14
20
  /** Bounding box of the non-zero coverage, or `null` when nothing is selected. */
15
21
  readonly bounds: CropBox | null;
22
+ /** The coverage as tiles (1 channel, default 0), shared with other masks where equal. */
23
+ readonly surface: TiledSurface;
16
24
  }
17
25
  /** How a new selection combines with the current one. */
18
26
  export type SelectionMode = 'replace' | 'add' | 'subtract' | 'intersect';
@@ -35,8 +43,19 @@ export interface SerializedSelection {
35
43
  }
36
44
  /** Bounding box of non-zero bytes. */
37
45
  export declare function computeSelectionBounds(width: number, height: number, data: ArrayLike<number>): CropBox | null;
46
+ /**
47
+ * A selection over `width × height` from a 1-channel tiled surface in image space (Quick Mask
48
+ * paint, a mask): its tiles are shared, and coverage outside the image is dropped. A surface
49
+ * whose default is not 0 is read out instead.
50
+ */
51
+ export declare function selectionFromTiles(width: number, height: number, surface: TiledSurface): SelectionMask;
38
52
  /** Wraps coverage bytes (taken over, not copied) as a selection, computing its bounds. */
39
53
  export declare function createSelectionMask(width: number, height: number, data?: Uint8Array): SelectionMask;
54
+ /**
55
+ * A selection that is `data` (`rect.width × rect.height` coverage bytes) inside `rect` and
56
+ * nothing elsewhere, without allocating the whole image. `rect` must lie inside the image.
57
+ */
58
+ export declare function selectionFromRegion(width: number, height: number, rect: CropBox, data: ArrayLike<number>): SelectionMask;
40
59
  /** Select › All. */
41
60
  export declare function fullSelection(width: number, height: number): SelectionMask;
42
61
  export declare function isSelectionEmpty(mask: SelectionMask | null | undefined): boolean;
@@ -1,11 +1,17 @@
1
1
  import type { CropBox } from '../types.js';
2
+ import { TiledSurface } from '../../raster/surface.js';
2
3
  /**
3
4
  * A selection: one 8-bit coverage value per image pixel (0 = not selected, 255 = fully
4
5
  * selected), row-major over the whole source image in natural pixels — the same space as the
5
6
  * crop box and annotations. Soft values are partial selection (feathered or anti-aliased edges).
6
7
  *
8
+ * The coverage is stored as 256 × 256 tiles (`surface`, one byte per pixel, missing tiles are 0
9
+ * and every pixel outside the image is 0), so an empty or fully selected area costs nothing and
10
+ * masks share the tiles an operation did not change. `data` is the same coverage as one array,
11
+ * built on first read and cached for the few masks read most recently.
12
+ *
7
13
  * Treat a mask as immutable: every operation returns a new one, so history entries can share
8
- * them without copying.
14
+ * them without copying. Never write to `data`.
9
15
  */
10
16
  export interface SelectionMask {
11
17
  readonly width: number;
@@ -13,6 +19,8 @@ export interface SelectionMask {
13
19
  readonly data: Uint8Array;
14
20
  /** Bounding box of the non-zero coverage, or `null` when nothing is selected. */
15
21
  readonly bounds: CropBox | null;
22
+ /** The coverage as tiles (1 channel, default 0), shared with other masks where equal. */
23
+ readonly surface: TiledSurface;
16
24
  }
17
25
  /** How a new selection combines with the current one. */
18
26
  export type SelectionMode = 'replace' | 'add' | 'subtract' | 'intersect';
@@ -35,8 +43,19 @@ export interface SerializedSelection {
35
43
  }
36
44
  /** Bounding box of non-zero bytes. */
37
45
  export declare function computeSelectionBounds(width: number, height: number, data: ArrayLike<number>): CropBox | null;
46
+ /**
47
+ * A selection over `width × height` from a 1-channel tiled surface in image space (Quick Mask
48
+ * paint, a mask): its tiles are shared, and coverage outside the image is dropped. A surface
49
+ * whose default is not 0 is read out instead.
50
+ */
51
+ export declare function selectionFromTiles(width: number, height: number, surface: TiledSurface): SelectionMask;
38
52
  /** Wraps coverage bytes (taken over, not copied) as a selection, computing its bounds. */
39
53
  export declare function createSelectionMask(width: number, height: number, data?: Uint8Array): SelectionMask;
54
+ /**
55
+ * A selection that is `data` (`rect.width × rect.height` coverage bytes) inside `rect` and
56
+ * nothing elsewhere, without allocating the whole image. `rect` must lie inside the image.
57
+ */
58
+ export declare function selectionFromRegion(width: number, height: number, rect: CropBox, data: ArrayLike<number>): SelectionMask;
40
59
  /** Select › All. */
41
60
  export declare function fullSelection(width: number, height: number): SelectionMask;
42
61
  export declare function isSelectionEmpty(mask: SelectionMask | null | undefined): boolean;
@@ -11,6 +11,14 @@ export interface ShapeSelectionOptions {
11
11
  * their top), so the cost is about pixels + edges × rows they span.
12
12
  */
13
13
  export declare function polygonCoverage(points: readonly ImagePoint[], width: number, height: number, antiAlias?: boolean): Float32Array;
14
+ /**
15
+ * {@link polygonCoverage} over just the polygon's bounding box (clipped to the image): `coverage`
16
+ * is `box.width × box.height`; `box` is `null` when the polygon covers no pixel row or column.
17
+ */
18
+ export declare function polygonCoverageBox(points: readonly ImagePoint[], width: number, height: number, antiAlias?: boolean): {
19
+ box: CropBox | null;
20
+ coverage: Float32Array;
21
+ };
14
22
  /** Lasso / polygonal lasso: an even-odd polygon in natural image pixels. */
15
23
  export declare function polygonSelection(width: number, height: number, points: readonly ImagePoint[], options?: ShapeSelectionOptions): SelectionMask;
16
24
  /** Normalises a rectangle with a negative width or height. */
@@ -11,6 +11,14 @@ export interface ShapeSelectionOptions {
11
11
  * their top), so the cost is about pixels + edges × rows they span.
12
12
  */
13
13
  export declare function polygonCoverage(points: readonly ImagePoint[], width: number, height: number, antiAlias?: boolean): Float32Array;
14
+ /**
15
+ * {@link polygonCoverage} over just the polygon's bounding box (clipped to the image): `coverage`
16
+ * is `box.width × box.height`; `box` is `null` when the polygon covers no pixel row or column.
17
+ */
18
+ export declare function polygonCoverageBox(points: readonly ImagePoint[], width: number, height: number, antiAlias?: boolean): {
19
+ box: CropBox | null;
20
+ coverage: Float32Array;
21
+ };
14
22
  /** Lasso / polygonal lasso: an even-odd polygon in natural image pixels. */
15
23
  export declare function polygonSelection(width: number, height: number, points: readonly ImagePoint[], options?: ShapeSelectionOptions): SelectionMask;
16
24
  /** Normalises a rectangle with a negative width or height. */
@@ -1,4 +1,4 @@
1
- import type { SelectionMask, SelectionMode } from './mask.cjs';
1
+ import { type SelectionMask, type SelectionMode } from './mask.cjs';
2
2
  import type { ColorDistance, ColorRangePreset } from './wand.cjs';
3
3
  import type { RefineEdgeParams } from './refine.cjs';
4
4
  /** Selection tools of the image editor UI. */
@@ -1,4 +1,4 @@
1
- import type { SelectionMask, SelectionMode } from './mask.js';
1
+ import { type SelectionMask, type SelectionMode } from './mask.js';
2
2
  import type { ColorDistance, ColorRangePreset } from './wand.js';
3
3
  import type { RefineEdgeParams } from './refine.js';
4
4
  /** Selection tools of the image editor UI. */