@pixerate/editor 0.185.0 → 0.187.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 (40) hide show
  1. package/dist/{chunk-GYYSPMVU.js → chunk-FW7N64XK.js} +295 -188
  2. package/dist/{chunk-C6TRAAG4.cjs → chunk-X2KQQZBT.cjs} +355 -248
  3. package/dist/image-editor/controller.d.cts +89 -0
  4. package/dist/image-editor/controller.d.ts +89 -0
  5. package/dist/image-editor/index.cjs +1602 -1030
  6. package/dist/image-editor/index.js +591 -19
  7. package/dist/image-editor/layers/model.d.cts +5 -1
  8. package/dist/image-editor/layers/model.d.ts +5 -1
  9. package/dist/image-editor/layers/paint-ui.d.cts +2 -1
  10. package/dist/image-editor/layers/paint-ui.d.ts +2 -1
  11. package/dist/image-editor/layers/serialize.d.cts +3 -0
  12. package/dist/image-editor/layers/serialize.d.ts +3 -0
  13. package/dist/image-editor/psd/channels.d.cts +24 -0
  14. package/dist/image-editor/psd/channels.d.ts +24 -0
  15. package/dist/image-editor/psd/export.d.cts +3 -0
  16. package/dist/image-editor/psd/export.d.ts +3 -0
  17. package/dist/image-editor/psd/import.d.cts +10 -0
  18. package/dist/image-editor/psd/import.d.ts +10 -0
  19. package/dist/image-editor/renderer.d.cts +6 -0
  20. package/dist/image-editor/renderer.d.ts +6 -0
  21. package/dist/image-editor/selection/channels.d.cts +52 -0
  22. package/dist/image-editor/selection/channels.d.ts +52 -0
  23. package/dist/image-editor/selection/gesture.d.cts +7 -0
  24. package/dist/image-editor/selection/gesture.d.ts +7 -0
  25. package/dist/image-editor/selection/index.d.cts +2 -0
  26. package/dist/image-editor/selection/index.d.ts +2 -0
  27. package/dist/image-editor/selection/mask-files.d.cts +20 -0
  28. package/dist/image-editor/selection/mask-files.d.ts +20 -0
  29. package/dist/image-editor/selection/overlay.d.cts +2 -0
  30. package/dist/image-editor/selection/overlay.d.ts +2 -0
  31. package/dist/image-editor/tools.d.cts +3 -1
  32. package/dist/image-editor/tools.d.ts +3 -1
  33. package/dist/image-editor/types.d.cts +10 -0
  34. package/dist/image-editor/types.d.ts +10 -0
  35. package/dist/paint-editor/index.cjs +6 -6
  36. package/dist/paint-editor/index.js +1 -1
  37. package/package.json +1 -1
  38. package/skills/pixerate-image-editor/SKILL.md +12 -24
  39. package/skills/pixerate-image-editor/references/painting.md +29 -0
  40. package/skills/pixerate-image-selection-masking/SKILL.md +20 -2
@@ -370,7 +370,7 @@ import {
370
370
  writePsd,
371
371
  xyToTempTint,
372
372
  xyToUv
373
- } from "../chunk-GYYSPMVU.js";
373
+ } from "../chunk-FW7N64XK.js";
374
374
  import {
375
375
  parseCssColor
376
376
  } from "../chunk-3CI7FRMK.js";
@@ -939,12 +939,13 @@ function selectionOutline(mask, threshold = 128) {
939
939
  function quickMaskPixels(mask, style = {}) {
940
940
  const [r, g, b] = style.color ?? [255, 0, 0];
941
941
  const k = Math.min(1, Math.max(0, style.opacity ?? 0.5));
942
+ const selected = style.indicates === "selected";
942
943
  const out = new Uint8ClampedArray(mask.data.length * 4);
943
944
  for (let i = 0, p = 0; i < mask.data.length; i++, p += 4) {
944
945
  out[p] = r;
945
946
  out[p + 1] = g;
946
947
  out[p + 2] = b;
947
- out[p + 3] = Math.round((255 - mask.data[i]) * k);
948
+ out[p + 3] = Math.round((selected ? mask.data[i] : 255 - mask.data[i]) * k);
948
949
  }
949
950
  return out;
950
951
  }
@@ -1363,14 +1364,30 @@ var ImageEditorRenderer = class {
1363
1364
  ctx.restore();
1364
1365
  }
1365
1366
  const display = options.selectionDisplay ?? state.selectionDisplay ?? (state.quickMask ? "quickMask" : "ants");
1366
- if (state.selection?.bounds && display !== "none" || options.draftSelectionPath?.length) {
1367
+ const shownChannels = (state.channels ?? []).filter((c) => c.visible);
1368
+ if (shownChannels.length) {
1367
1369
  ctx.save();
1368
1370
  try {
1369
1371
  applyImageProjectionToContext(ctx, projection);
1370
1372
  ctx.beginPath();
1371
1373
  ctx.rect(region.x, region.y, region.width, region.height);
1372
1374
  ctx.clip();
1373
- if (state.selection?.bounds && display === "quickMask") this.drawQuickMask(ctx, state.selection);
1375
+ for (const c of shownChannels) this.drawMaskOverlay(ctx, c.mask, { color: hexRgb(c.color), opacity: c.opacity, indicates: c.indicates });
1376
+ } catch {
1377
+ }
1378
+ ctx.restore();
1379
+ }
1380
+ const { width: iw, height: ih } = state.imageDimensions;
1381
+ const quickMaskSel = display === "quickMask" && state.quickMaskEditing && !state.selection?.bounds ? this.emptyMask(Math.round(iw), Math.round(ih)) : null;
1382
+ if (state.selection?.bounds && display !== "none" || quickMaskSel || options.draftSelectionPath?.length) {
1383
+ ctx.save();
1384
+ try {
1385
+ applyImageProjectionToContext(ctx, projection);
1386
+ ctx.beginPath();
1387
+ ctx.rect(region.x, region.y, region.width, region.height);
1388
+ ctx.clip();
1389
+ if (quickMaskSel) this.drawQuickMask(ctx, quickMaskSel);
1390
+ else if (state.selection?.bounds && display === "quickMask") this.drawQuickMask(ctx, state.selection);
1374
1391
  else if (state.selection?.bounds && display === "ants") this.drawAnts(ctx, state.selection, projection.scale / u, options.antsOffset ?? 0);
1375
1392
  if (options.draftSelectionPath?.length) this.drawDraftPath(ctx, options.draftSelectionPath, projection.scale / u);
1376
1393
  } catch {
@@ -1776,6 +1793,31 @@ var ImageEditorRenderer = class {
1776
1793
  }
1777
1794
  quickMaskCache = /* @__PURE__ */ new WeakMap();
1778
1795
  outlineCache = /* @__PURE__ */ new WeakMap();
1796
+ emptyMaskCache = null;
1797
+ /** An empty selection of the image's size (Quick Mask mode with nothing selected). */
1798
+ emptyMask(width, height) {
1799
+ const m = this.emptyMaskCache;
1800
+ if (m && m.width === width && m.height === height) return m;
1801
+ this.emptyMaskCache = createSelectionMask(Math.max(1, width), Math.max(1, height));
1802
+ return this.emptyMaskCache;
1803
+ }
1804
+ overlayCache = /* @__PURE__ */ new WeakMap();
1805
+ /** A channel's tint (colour, opacity, masked or selected area), drawn in natural image space. */
1806
+ drawMaskOverlay(ctx, mask, style) {
1807
+ const key = `${style.color?.join(",")}|${style.opacity}|${style.indicates}`;
1808
+ let cached = this.overlayCache.get(mask);
1809
+ if (!cached || cached.key !== key) {
1810
+ const canvas = this.createCanvas(mask.width, mask.height);
1811
+ const mctx = canvas.getContext("2d");
1812
+ if (!mctx || typeof mctx.createImageData !== "function") return;
1813
+ const img = mctx.createImageData(mask.width, mask.height);
1814
+ img.data.set(quickMaskPixels(mask, style));
1815
+ mctx.putImageData(img, 0, 0);
1816
+ cached = { key, canvas };
1817
+ this.overlayCache.set(mask, cached);
1818
+ }
1819
+ ctx.drawImage(cached.canvas, 0, 0, mask.width, mask.height);
1820
+ }
1779
1821
  /** Rubylith over the unselected area, drawn in natural image space. */
1780
1822
  drawQuickMask(ctx, selection) {
1781
1823
  let canvas = this.quickMaskCache.get(selection);
@@ -2320,6 +2362,12 @@ function removeAnnotationLayers(layers) {
2320
2362
  }
2321
2363
  return out;
2322
2364
  }
2365
+ function hexRgb(hex) {
2366
+ const m = /^#?([0-9a-f]{6})$/i.exec(hex);
2367
+ if (!m) return [255, 0, 0];
2368
+ const v = parseInt(m[1], 16);
2369
+ return [v >> 16 & 255, v >> 8 & 255, v & 255];
2370
+ }
2323
2371
 
2324
2372
  // src/image-editor/layers/shape-type-ui.ts
2325
2373
  var DEFAULT_SHAPE_TOOL_OPTIONS = {
@@ -3479,6 +3527,57 @@ function selectionRing(sel, width, location) {
3479
3527
  return createSelectionMask(sel.width, sel.height, out);
3480
3528
  }
3481
3529
 
3530
+ // src/image-editor/selection/channels.ts
3531
+ var DEFAULT_CHANNEL_COLOR = "#ff0000";
3532
+ function describeChannel(channel) {
3533
+ 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;
3537
+ return { ...rest, bounds: mask.bounds, percent };
3538
+ }
3539
+ function serializeChannels(channels) {
3540
+ return channels.map((c) => ({ ...c, mask: serializeSelection(c.mask) }));
3541
+ }
3542
+ function deserializeChannels(value, size, problems = []) {
3543
+ if (!Array.isArray(value)) return [];
3544
+ const out = [];
3545
+ for (const raw of value) {
3546
+ const v = raw;
3547
+ try {
3548
+ const mask = deserializeSelection(v.mask);
3549
+ if (mask.width !== size.width || mask.height !== size.height) throw new Error("the channel is a different size from the canvas");
3550
+ out.push({
3551
+ id: typeof v.id === "string" && v.id ? v.id : `ch_${out.length + 1}`,
3552
+ name: typeof v.name === "string" && v.name.trim() ? v.name : `Alpha ${out.length + 1}`,
3553
+ mask,
3554
+ color: typeof v.color === "string" && /^#[0-9a-f]{6}$/i.test(v.color) ? v.color : DEFAULT_CHANNEL_COLOR,
3555
+ opacity: typeof v.opacity === "number" && v.opacity >= 0 && v.opacity <= 1 ? v.opacity : 0.5,
3556
+ indicates: v.indicates === "selected" ? "selected" : "masked",
3557
+ visible: v.visible === true
3558
+ });
3559
+ } catch (err) {
3560
+ problems.push(`Alpha channel "${String(v?.name ?? "?")}" could not be read: ${err instanceof Error ? err.message : String(err)}.`);
3561
+ }
3562
+ }
3563
+ return out;
3564
+ }
3565
+ function channelThumbnail(channel, maxSide = 64) {
3566
+ const { width: w, height: h, data } = channel.mask;
3567
+ const k = Math.min(1, maxSide / Math.max(w, h));
3568
+ const tw = Math.max(1, Math.round(w * k));
3569
+ const th = Math.max(1, Math.round(h * k));
3570
+ const out = new Uint8ClampedArray(tw * th * 4);
3571
+ for (let y = 0; y < th; y++) {
3572
+ const sy = Math.min(h - 1, Math.floor((y + 0.5) / k));
3573
+ for (let x = 0; x < tw; x++) {
3574
+ const v = data[sy * w + Math.min(w - 1, Math.floor((x + 0.5) / k))];
3575
+ out.set([v, v, v, 255], (y * tw + x) * 4);
3576
+ }
3577
+ }
3578
+ return { width: tw, height: th, data: out };
3579
+ }
3580
+
3482
3581
  // src/image-editor/effect-layers.ts
3483
3582
  function hasLayersBelow(layers, id) {
3484
3583
  const all = flattenLayers(layers);
@@ -4389,6 +4488,7 @@ var IMAGE_EDITOR_SCHEMA_VERSION = LAYERED_SCHEMA_VERSION;
4389
4488
  var RETOUCH_TOOLS = ["clone", "heal", "spot-heal", "smudge", "dodge", "burn", "sponge", "blur", "sharpen", "history"];
4390
4489
  var isRetouchTool = (tool) => RETOUCH_TOOLS.includes(tool);
4391
4490
  var STROKE_TOOLS = ["brush", "eraser", "mixer", ...RETOUCH_TOOLS];
4491
+ var QUICK_MASK_TARGET = "quick-mask";
4392
4492
  var PASTE_MODES = ["new-layer", "in-place", "into", "outside"];
4393
4493
  function paintCoverageInto(base, coverage, apply, rects, depth = null) {
4394
4494
  const tiles = new Map(base.tiles);
@@ -4899,6 +4999,10 @@ var ImageEditorController = class {
4899
4999
  } catch {
4900
5000
  }
4901
5001
  }
5002
+ if (options.initialState?.channels) {
5003
+ const channels = deserializeChannels(options.initialState.channels, this.naturalSize, this.lastLoadProblems);
5004
+ if (channels.length) this.state.channels = channels;
5005
+ }
4902
5006
  this.initialSnapshot = this.captureHistoryEntry();
4903
5007
  if (typeof options.image === "string") {
4904
5008
  this.loadImage(options.image);
@@ -5279,6 +5383,7 @@ var ImageEditorController = class {
5279
5383
  transform: { ...s.transform },
5280
5384
  outputOpacity: s.outputOpacity,
5281
5385
  selection: s.selection ?? null,
5386
+ ...s.channels?.length ? { channels: s.channels } : {},
5282
5387
  source: {
5283
5388
  sourceUrl: s.sourceUrl,
5284
5389
  element: this.imageElement,
@@ -5314,6 +5419,8 @@ var ImageEditorController = class {
5314
5419
  if (entry.colorMode) s.colorMode = entry.colorMode;
5315
5420
  else delete s.colorMode;
5316
5421
  s.selection = entry.selection;
5422
+ if (entry.channels?.length) s.channels = entry.channels;
5423
+ else delete s.channels;
5317
5424
  if (this.imageElement !== entry.source.element && !this.samplePixelsOverride) this.samplePixels = null;
5318
5425
  this.imageElement = entry.source.element;
5319
5426
  s.sourceUrl = entry.source.sourceUrl;
@@ -7025,6 +7132,12 @@ var ImageEditorController = class {
7025
7132
  }
7026
7133
  /** Resolves and validates what a stroke, fill or clear paints into. */
7027
7134
  paintTarget(layerId, channel, action) {
7135
+ if (channel === "quick-mask" || channel === void 0 && layerId === void 0 && this.state.quickMaskEditing) {
7136
+ if (!this.state.quickMaskEditing) {
7137
+ throw new LayerOperationError(`Quick Mask is off; turn it on (set_quick_mask) to ${action} on the selection, or target a layer.`, `Turn on Quick Mask (Q) to ${action} on the selection.`);
7138
+ }
7139
+ return { id: QUICK_MASK_TARGET, layer: null, channel: "quick-mask", offset: { x: 0, y: 0 }, surface: this.quickMaskSurface(), lockTransparency: false };
7140
+ }
7028
7141
  const id = layerId ?? this.state.activeLayerId;
7029
7142
  if (!id) throw new LayerOperationError(`No layer given and none is active; pass layerId (see list_layers) to ${action}.`, `Pick a layer to ${action} on in the Layers panel.`);
7030
7143
  const layer = requireLayer(this.state.layers, id).layer;
@@ -7093,10 +7206,11 @@ var ImageEditorController = class {
7093
7206
  opacity: brush.opacity,
7094
7207
  blend: target.channel !== "pixels" ? "normal" : brush.blend,
7095
7208
  lockTransparency: target.lockTransparency,
7096
- selection: this.state.selection?.bounds ? this.state.selection : null,
7209
+ // In Quick Mask mode the selection is what is painted, so it does not limit the stroke.
7210
+ selection: target.channel !== "quick-mask" && this.state.selection?.bounds ? this.state.selection : null,
7097
7211
  offset: target.offset
7098
7212
  };
7099
- const depth = target.channel !== "filter-mask" && this.bitDepth !== 8 ? this.bitDepth : null;
7213
+ const depth = target.channel !== "filter-mask" && target.channel !== "quick-mask" && this.bitDepth !== 8 ? this.bitDepth : null;
7100
7214
  if (tool === "mixer") return new PaintStrokeSession(this, target, this.mixerStroke(init, brush), apply, depth);
7101
7215
  if (isRetouchTool(tool)) return new PaintStrokeSession(this, target, this.retouchStroke(tool, init, brush, target, apply), apply, depth);
7102
7216
  const gradient = tool === "brush" && strokePaint?.type === "gradient" ? resolvePaintGradient(strokePaint, { paint: toHex(color), secondary: toHex(background) }) : null;
@@ -7261,12 +7375,19 @@ var ImageEditorController = class {
7261
7375
  }
7262
7376
  /** @internal Shows a stroke's live surface (no history). */
7263
7377
  _strokeUpdate(layerId, channel, surface, rects) {
7378
+ if (channel === "quick-mask") {
7379
+ this.state.selection = this.selectionFromSurface(surface, this.state.selection ?? null, rects);
7380
+ this.notify();
7381
+ return;
7382
+ }
7264
7383
  this.state.layers = mapLayer(this.state.layers, layerId, (l) => this.withSurface(l, channel, surface));
7265
7384
  this.notify(channel === "filter-mask" ? void 0 : rects);
7266
7385
  }
7267
7386
  /** @internal Commits a finished stroke as one undo step (`before` is the pre-stroke state). */
7268
7387
  _strokeCommit(before, layerId, channel, surface) {
7269
- if (surface) {
7388
+ if (channel === "quick-mask") {
7389
+ this.state.selection = surface ? this.selectionFromSurface(surface) : before.selection;
7390
+ } else if (surface) {
7270
7391
  const prev = before.layers;
7271
7392
  this.state.layers = mapLayer(this.state.layers, layerId, (l) => this.withSurface(l, channel, surface));
7272
7393
  if (this.bitDepth !== 8) this.state.layers = keepDeepLayers(prev, this.state.layers);
@@ -7482,7 +7603,8 @@ var ImageEditorController = class {
7482
7603
  const opacity = options.opacity ?? 1;
7483
7604
  if (!(opacity >= 0 && opacity <= 1)) throw new LayerOperationError("opacity must be from 0 to 1.");
7484
7605
  const blend = parseBlend(options.blend) ?? "normal";
7485
- const sel = coverageMask ?? (options.useSelection !== false && this.state.selection?.bounds ? this.state.selection : null);
7606
+ const quickMask = target.channel === "quick-mask";
7607
+ const sel = coverageMask ?? (!quickMask && options.useSelection !== false && this.state.selection?.bounds ? this.state.selection : null);
7486
7608
  const region = sel?.bounds ?? { x: 0, y: 0, width, height };
7487
7609
  const coverage = new CoverageMap();
7488
7610
  const ones = new Float32Array(region.width * region.height);
@@ -7508,8 +7630,14 @@ var ImageEditorController = class {
7508
7630
  selection: null,
7509
7631
  offset: target.offset
7510
7632
  };
7511
- const depth = target.channel !== "filter-mask" && this.bitDepth !== 8 ? this.bitDepth : null;
7633
+ const depth = target.channel !== "filter-mask" && target.channel !== "quick-mask" && this.bitDepth !== 8 ? this.bitDepth : null;
7512
7634
  const surface = paintCoverageInto(target.surface, coverage, apply, [region], depth);
7635
+ if (quickMask) {
7636
+ this.recordHistory();
7637
+ this.state.selection = this.selectionFromSurface(surface);
7638
+ this.notify();
7639
+ return { layerId: target.id, channel: target.channel, bounds: region, dabs: 0 };
7640
+ }
7513
7641
  const next = mapLayer(this.state.layers, target.id, (l) => this.withSurface(l, target.channel, surface));
7514
7642
  this.commitLayers(next);
7515
7643
  return { layerId: target.id, channel: target.channel, bounds: region, dabs: 0 };
@@ -8092,6 +8220,39 @@ var ImageEditorController = class {
8092
8220
  this.setSelection(selectionFromPixels(pixels, options), options.mode ?? "replace");
8093
8221
  return this.getSelection();
8094
8222
  }
8223
+ /**
8224
+ * Loads a mask image (a data URL / URL or RGBA pixels, any size, resampled) into a new alpha
8225
+ * channel, read like `loadSelectionFromImage`. The selection does not change. One undo step.
8226
+ */
8227
+ async loadChannelFromImage(source, options = {}) {
8228
+ const mask = selectionFromPixels(await this.decodeMaskImage(source), options);
8229
+ const { width, height } = this.naturalSize;
8230
+ return this.addChannel(mask.width === width && mask.height === height ? mask : resizeSelection(mask, width, height), options.name);
8231
+ }
8232
+ /**
8233
+ * A full-size greyscale PNG (white = selected / revealed) of the selection, an alpha channel or
8234
+ * a layer's painted mask, as a data URL; `invert` swaps black and white, `maxSide` scales it
8235
+ * down. Encoded in code, so it works without a canvas. Throws when the source is missing.
8236
+ */
8237
+ exportMaskImage(source, options = {}) {
8238
+ let mask;
8239
+ if (source === "selection") {
8240
+ const sel = this.state.selection;
8241
+ if (!sel?.bounds) throw new LayerOperationError("Nothing is selected; select first, or export a channel or a layer mask.");
8242
+ mask = sel;
8243
+ } else if ("channelId" in source) {
8244
+ mask = this.requireChannel(source.channelId).mask;
8245
+ } else {
8246
+ const layer = requireLayer(this.state.layers, source.layerId).layer;
8247
+ const coverage = this.layerMaskCoverage(source.layerId);
8248
+ if (!coverage) throw new LayerOperationError(`Layer "${layer.name || source.layerId}" has no painted mask to export.`);
8249
+ mask = coverage;
8250
+ }
8251
+ const k = options.maxSide ? Math.min(1, options.maxSide / Math.max(mask.width, mask.height)) : 1;
8252
+ if (k < 1) mask = resizeSelection(mask, Math.max(1, Math.round(mask.width * k)), Math.max(1, Math.round(mask.height * k)));
8253
+ if (options.invert) mask = invertSelection(mask);
8254
+ return { width: mask.width, height: mask.height, dataUrl: pngDataUrl(mask.width, mask.height, mask.data, 1) };
8255
+ }
8095
8256
  /** A mask image (data URL / URL, or pixels as they are) as RGBA pixels. */
8096
8257
  async decodeMaskImage(source) {
8097
8258
  if (typeof source !== "string") return source;
@@ -8158,10 +8319,165 @@ var ImageEditorController = class {
8158
8319
  throw new Error(`Unknown selection display "${String(display)}". Use ants, quickMask or none.`);
8159
8320
  }
8160
8321
  if (this.getSelectionDisplay() === display) return;
8322
+ if (display !== "quickMask") delete this.state.quickMaskEditing;
8161
8323
  this.state.selectionDisplay = display;
8162
8324
  this.state.quickMask = display === "quickMask";
8163
8325
  this.notify();
8164
8326
  }
8327
+ // --- Alpha channels ---
8328
+ /** The alpha channels (saved selections), in panel order. */
8329
+ getChannels() {
8330
+ return this.state.channels ?? [];
8331
+ }
8332
+ getChannel(id) {
8333
+ return this.state.channels?.find((c) => c.id === id) ?? null;
8334
+ }
8335
+ requireChannel(id) {
8336
+ const channel = this.getChannel(id);
8337
+ if (!channel) {
8338
+ const ids = this.getChannels().map((c) => `${c.id} (${c.name})`);
8339
+ throw new LayerOperationError(`There is no alpha channel "${id}"; the channels are ${ids.join(", ") || "none (save a selection first)"}.`);
8340
+ }
8341
+ return channel;
8342
+ }
8343
+ setChannels(next, commitToHistory = true) {
8344
+ if (commitToHistory) this.recordHistory();
8345
+ if (next.length) this.state.channels = next;
8346
+ else delete this.state.channels;
8347
+ this.notify();
8348
+ }
8349
+ newChannelId() {
8350
+ const ids = new Set(this.getChannels().map((c) => c.id));
8351
+ let n = this.getChannels().length + 1;
8352
+ while (ids.has(`ch_${n}`)) n++;
8353
+ return `ch_${n}`;
8354
+ }
8355
+ /**
8356
+ * Select › Save Selection: a new alpha channel (`name`, default "Alpha n") holding the
8357
+ * selection, or with `channelId` the selection combined into that channel by `mode` (replace,
8358
+ * add, subtract, intersect). One undo step. Throws without a selection.
8359
+ */
8360
+ saveSelection(options = {}) {
8361
+ const sel = this.state.selection;
8362
+ if (!sel?.bounds) throw new LayerOperationError("Nothing is selected; select first, then save the selection to a channel.");
8363
+ const { width, height } = this.naturalSize;
8364
+ const sized = sel.width === width && sel.height === height ? sel : resizeSelection(sel, width, height);
8365
+ const channels = [...this.getChannels()];
8366
+ if (options.channelId !== void 0) {
8367
+ const existing = this.requireChannel(options.channelId);
8368
+ const mask = combineSelections(existing.mask.bounds ? existing.mask : null, sized, options.mode ?? "replace") ?? createSelectionMask(width, height);
8369
+ const next = { ...existing, mask, ...options.name?.trim() ? { name: options.name.trim() } : {} };
8370
+ this.setChannels(channels.map((c) => c.id === existing.id ? next : c));
8371
+ return next;
8372
+ }
8373
+ return this.addChannel(sized, options.name);
8374
+ }
8375
+ /** Appends a new channel holding `mask` (canvas-sized) as one undo step. */
8376
+ addChannel(mask, name) {
8377
+ const channels = this.getChannels();
8378
+ const channel = {
8379
+ id: this.newChannelId(),
8380
+ name: name?.trim() || `Alpha ${channels.length + 1}`,
8381
+ mask,
8382
+ color: DEFAULT_CHANNEL_COLOR,
8383
+ opacity: 0.5,
8384
+ indicates: "masked",
8385
+ visible: false
8386
+ };
8387
+ this.setChannels([...channels, channel]);
8388
+ return channel;
8389
+ }
8390
+ /**
8391
+ * Select › Load Selection from an alpha channel (inverted with `invert`), combined with the
8392
+ * selection by `mode`. One undo step.
8393
+ */
8394
+ loadSelectionFromChannel(id, options = {}) {
8395
+ const channel = this.requireChannel(id);
8396
+ const mask = options.invert ? invertSelection(channel.mask) : channel.mask;
8397
+ return this.setSelection(mask.bounds ? mask : null, options.mode ?? "replace");
8398
+ }
8399
+ /** Changes a channel's name, overlay colour, opacity, what it indicates or visibility (one undo step). */
8400
+ setChannelProperties(id, props) {
8401
+ const channel = this.requireChannel(id);
8402
+ if (props.name !== void 0 && !props.name.trim()) throw new LayerOperationError("A channel name cannot be empty.");
8403
+ if (props.color !== void 0 && !/^#[0-9a-f]{6}$/i.test(props.color)) throw new LayerOperationError("color must be #rrggbb.");
8404
+ if (props.opacity !== void 0 && !(props.opacity >= 0 && props.opacity <= 1)) throw new LayerOperationError("opacity must be from 0 to 1.");
8405
+ if (props.indicates !== void 0 && props.indicates !== "masked" && props.indicates !== "selected") throw new LayerOperationError("indicates must be masked or selected.");
8406
+ const next = {
8407
+ ...channel,
8408
+ ...props.name !== void 0 ? { name: props.name.trim() } : {},
8409
+ ...props.color !== void 0 ? { color: props.color.toLowerCase() } : {},
8410
+ ...props.opacity !== void 0 ? { opacity: props.opacity } : {},
8411
+ ...props.indicates !== void 0 ? { indicates: props.indicates } : {},
8412
+ ...props.visible !== void 0 ? { visible: props.visible } : {}
8413
+ };
8414
+ this.setChannels(this.getChannels().map((c) => c.id === id ? next : c));
8415
+ return next;
8416
+ }
8417
+ /** Deletes an alpha channel (one undo step). */
8418
+ deleteChannel(id) {
8419
+ this.requireChannel(id);
8420
+ this.setChannels(this.getChannels().filter((c) => c.id !== id));
8421
+ }
8422
+ /** Duplicates an alpha channel just after it (one undo step). Returns the copy. */
8423
+ duplicateChannel(id, name) {
8424
+ const channel = this.requireChannel(id);
8425
+ const copy = { ...channel, id: this.newChannelId(), name: name?.trim() || `${channel.name} copy` };
8426
+ const channels = [...this.getChannels()];
8427
+ channels.splice(channels.findIndex((c) => c.id === id) + 1, 0, copy);
8428
+ this.setChannels(channels);
8429
+ return copy;
8430
+ }
8431
+ /** Channel summaries (no pixels) for panels and agents. */
8432
+ describeChannels() {
8433
+ return this.getChannels().map(describeChannel);
8434
+ }
8435
+ // --- Quick Mask mode ---
8436
+ /** Whether Quick Mask mode is on (painting edits the selection). */
8437
+ get quickMaskEditing() {
8438
+ return Boolean(this.state.quickMaskEditing);
8439
+ }
8440
+ /**
8441
+ * Quick Mask mode (Q): on, the selection shows as a tint and painting, fills and strokes that
8442
+ * name no layer edit the selection itself (white selects, black deselects, grey partly);
8443
+ * off, it shows as marching ants again. View state: no undo step (the edits are).
8444
+ */
8445
+ setQuickMaskMode(on) {
8446
+ if (this.quickMaskEditing === on) return;
8447
+ if (on) this.state.quickMaskEditing = true;
8448
+ else delete this.state.quickMaskEditing;
8449
+ this.setSelectionDisplay(on ? "quickMask" : "ants");
8450
+ this.notify();
8451
+ }
8452
+ /** The selection as a 1-channel surface to paint in Quick Mask mode. */
8453
+ quickMaskSurface() {
8454
+ const { width, height } = this.naturalSize;
8455
+ const sel = this.state.selection;
8456
+ if (!sel?.bounds) return emptySurface(1, 0);
8457
+ const sized = sel.width === width && sel.height === height ? sel : resizeSelection(sel, width, height);
8458
+ return surfaceFromPixels({ width, height, data: sized.data }, 1, 0, 0, 0);
8459
+ }
8460
+ /** The selection a painted Quick Mask surface stands for (`rects` changed since `base`). */
8461
+ selectionFromSurface(surface, base = null, rects) {
8462
+ const { width, height } = this.naturalSize;
8463
+ let data;
8464
+ if (base && rects && base.width === width && base.height === height) {
8465
+ data = new Uint8Array(base.data);
8466
+ for (const r of rects) {
8467
+ const x0 = Math.max(0, Math.floor(r.x));
8468
+ const y0 = Math.max(0, Math.floor(r.y));
8469
+ const x1 = Math.min(width, Math.ceil(r.x + r.width));
8470
+ const y1 = Math.min(height, Math.ceil(r.y + r.height));
8471
+ if (x1 <= x0 || y1 <= y0) continue;
8472
+ const part = readRegion(surface, { x: x0, y: y0, width: x1 - x0, height: y1 - y0 });
8473
+ for (let y = y0; y < y1; y++) data.set(part.subarray((y - y0) * (x1 - x0), (y - y0 + 1) * (x1 - x0)), y * width + x0);
8474
+ }
8475
+ } else {
8476
+ const all = readRegion(surface, { x: 0, y: 0, width, height });
8477
+ data = new Uint8Array(all.buffer, all.byteOffset, all.length);
8478
+ }
8479
+ return normalizeSelection(createSelectionMask(width, height, data));
8480
+ }
8165
8481
  // --- Layer masks ---
8166
8482
  maskFromSelection(sel, props = {}) {
8167
8483
  const { width, height } = this.naturalSize;
@@ -8208,14 +8524,19 @@ var ImageEditorController = class {
8208
8524
  * with `invert`. False without a painted mask (a depth mask is computed, not painted).
8209
8525
  */
8210
8526
  selectionFromLayerMask(layerId, mode = "replace", invert2 = false) {
8527
+ const sel = this.layerMaskCoverage(layerId);
8528
+ if (!sel) return false;
8529
+ return this.setSelection(invert2 ? invertSelection(sel) : sel, mode);
8530
+ }
8531
+ /** A layer's painted mask over the canvas as a selection mask; null without one (or for a depth mask). */
8532
+ layerMaskCoverage(layerId) {
8211
8533
  const layer = this.getLayer(layerId);
8212
- if (!layer?.mask || layer.mask.source) return false;
8534
+ if (!layer?.mask || layer.mask.source) return null;
8213
8535
  const { width, height } = this.naturalSize;
8214
8536
  const ox = layer.type === "pixel" ? layer.offset.x : 0;
8215
8537
  const oy = layer.type === "pixel" ? layer.offset.y : 0;
8216
8538
  const data = readRegion(layer.mask.surface, { x: -ox, y: -oy, width, height });
8217
- const sel = createSelectionMask(width, height, new Uint8Array(data.buffer, data.byteOffset, data.length));
8218
- return this.setSelection(invert2 ? invertSelection(sel) : sel, mode);
8539
+ return createSelectionMask(width, height, new Uint8Array(data.buffer, data.byteOffset, data.length));
8219
8540
  }
8220
8541
  // --- Histogram ---
8221
8542
  /**
@@ -9275,6 +9596,17 @@ var ImageEditorController = class {
9275
9596
  s.outputOpacity = 1;
9276
9597
  delete s.legacyOutputBlur;
9277
9598
  s.selection = null;
9599
+ const psdChannels = (result.alphaChannels ?? []).map((c, k) => ({
9600
+ id: `ch_${k + 1}`,
9601
+ name: c.name,
9602
+ mask: createSelectionMask(result.width, result.height, c.data),
9603
+ color: c.color,
9604
+ opacity: c.opacity,
9605
+ indicates: c.indicates,
9606
+ visible: false
9607
+ }));
9608
+ if (psdChannels.length) s.channels = psdChannels;
9609
+ else delete s.channels;
9278
9610
  s.selectedAnnotationId = null;
9279
9611
  s.activeLayerId = defaultActiveLayerId(s.layers);
9280
9612
  s.activeTarget = "pixels";
@@ -9465,7 +9797,14 @@ var ImageEditorController = class {
9465
9797
  if (color.colorMode && this.bitDepth === 32) warnings.push(`${color.colorMode.toUpperCase()} files are written at 16 bits per channel at most; the 32-bit samples were converted (colour above 1 clipped).`);
9466
9798
  const depth = color.colorMode && this.bitDepth === 32 ? 16 : this.bitDepth;
9467
9799
  const mergedDeep = depth === 8 ? void 0 : workingToSamples(compositeDeep(prepared, canvas, sources, depth), depth);
9468
- const result = exportPsd({ width, height, layers: prepared, merged, mergedDeep, resolution: this.psdResolution, patterns: this.state.patterns, globalLight: this.state.globalLight, depth, ...color });
9800
+ const alphaChannels = this.getChannels().map((c) => ({
9801
+ name: c.name,
9802
+ data: c.mask.width === width && c.mask.height === height ? c.mask.data : resizeSelection(c.mask, width, height).data,
9803
+ color: c.color,
9804
+ opacity: c.opacity,
9805
+ indicates: c.indicates
9806
+ }));
9807
+ const result = exportPsd({ width, height, layers: prepared, merged, mergedDeep, resolution: this.psdResolution, patterns: this.state.patterns, globalLight: this.state.globalLight, depth, alphaChannels, ...color });
9469
9808
  warnings.push(...result.warnings);
9470
9809
  return { bytes: result.bytes, warnings, psb: result.psb, width, height };
9471
9810
  }
@@ -9538,6 +9877,7 @@ var ImageEditorController = class {
9538
9877
  try {
9539
9878
  out = this.serializeState();
9540
9879
  if (this.state.selection?.bounds) out.selection = serializeSelection(this.state.selection);
9880
+ if (this.state.channels?.length) out.channels = serializeChannels(this.state.channels);
9541
9881
  } finally {
9542
9882
  if (root) {
9543
9883
  this.state = current;
@@ -9578,6 +9918,11 @@ var ImageEditorController = class {
9578
9918
  }
9579
9919
  this.state.selection = selection;
9580
9920
  this.dropMismatchedSelection();
9921
+ const problems = [];
9922
+ const channels = deserializeChannels(data.channels, this.naturalSize, problems);
9923
+ if (channels.length) this.state.channels = channels;
9924
+ else delete this.state.channels;
9925
+ if (problems.length) this.lastLoadProblems = [...this.lastLoadProblems, ...problems];
9581
9926
  this.notify();
9582
9927
  }
9583
9928
  /** Problems found by the last `loadJSON` / `initialState` of a v3 document (unreadable layers or assets). */
@@ -10549,7 +10894,7 @@ var DEPTH_MASK_CONTROLS = [
10549
10894
  // src/image-editor/playback.ts
10550
10895
  var IMAGE_PLAYBACK_KIND = "layer";
10551
10896
  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)$/;
10552
- 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)$/;
10897
+ 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)$/;
10553
10898
  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)$/;
10554
10899
  var CROP = /^(set_crop|crop_to_selection|reset_crop|set_rotation|flip_image|transform_layer|warp_layer|flip_layer|offset_layer)$/;
10555
10900
  var ADJUST = /^(set_adjustments|add_adjustment_layer|update_adjustment_layer|remove_adjustment_layer|move_adjustment_layer)$/;
@@ -11458,6 +11803,171 @@ function createImageSelectionTools(controller) {
11458
11803
  annotations: writeAnnotations("Paste pixels"),
11459
11804
  handler: (args) => controller.pastePixels(args.mode ?? "new-layer")
11460
11805
  };
11806
+ const channelIdSchema = { type: "string", minLength: 1, description: "The alpha channel (see list_channels)." };
11807
+ const channelResult = (id) => controller.describeChannels().find((c) => c.id === id);
11808
+ const listChannels = {
11809
+ name: "list_channels",
11810
+ title: "List alpha channels",
11811
+ description: "Lists the alpha channels (saved selections kept with the document): id, name, bounds, percent of the image covered, overlay color, opacity, what the tint indicates and whether it is visible. Also whether Quick Mask mode is on.",
11812
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
11813
+ annotations: { title: "List alpha channels", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
11814
+ handler: () => ({ channels: controller.describeChannels(), quickMask: controller.quickMaskEditing })
11815
+ };
11816
+ const saveSelectionTool = {
11817
+ name: "save_selection",
11818
+ title: "Save the selection to a channel",
11819
+ description: 'Select \u203A Save Selection: stores the selection in a new alpha channel (name, default "Alpha n"), or with channelId combines it into that channel by mode (replace, add, subtract, intersect). Save any selection that took work, so it can be loaded again with load_selection_from_channel. Fails when nothing is selected. One undo step. Returns the channel.',
11820
+ inputSchema: {
11821
+ type: "object",
11822
+ properties: { channelId: channelIdSchema, name: { type: "string", minLength: 1, description: "Name of a new channel, or a new name for channelId." }, mode: modeSchema },
11823
+ additionalProperties: false
11824
+ },
11825
+ annotations: writeAnnotations("Save the selection to a channel"),
11826
+ handler: (args) => {
11827
+ if (!controller.getSelection()) throw new Error("Nothing is selected; select first, then save it.");
11828
+ const c = controller.saveSelection({ channelId: args.channelId, name: args.name, mode: parseSelectionMode(args.mode) });
11829
+ return { channel: channelResult(c.id) };
11830
+ }
11831
+ };
11832
+ const loadChannelTool = {
11833
+ name: "load_selection_from_channel",
11834
+ title: "Load a selection from a channel",
11835
+ description: "Select \u203A Load Selection from an alpha channel (invert for its inverse), combined with the current selection by mode. One undo step. Returns the selection.",
11836
+ inputSchema: {
11837
+ type: "object",
11838
+ properties: { channelId: channelIdSchema, invert: { type: "boolean", description: "Load the inverse (default false)." }, mode: modeSchema },
11839
+ required: ["channelId"],
11840
+ additionalProperties: false
11841
+ },
11842
+ annotations: writeAnnotations("Load a selection from a channel", true),
11843
+ handler: (args) => {
11844
+ controller.loadSelectionFromChannel(String(args.channelId), { mode: parseSelectionMode(args.mode), invert: args.invert === true });
11845
+ return summary(controller);
11846
+ }
11847
+ };
11848
+ const channelPropsTool = {
11849
+ name: "set_channel_properties",
11850
+ title: "Change an alpha channel",
11851
+ description: "Renames an alpha channel or changes its overlay: color (#rrggbb), opacity (0..1), indicates (masked: tint the unselected area, or selected) and visible (show the tint on the canvas). Pixels do not change. One undo step. Returns the channel.",
11852
+ inputSchema: {
11853
+ type: "object",
11854
+ properties: {
11855
+ channelId: channelIdSchema,
11856
+ name: { type: "string", minLength: 1, description: "New name." },
11857
+ color: { type: "string", pattern: "^#[0-9a-fA-F]{6}$", description: "Overlay colour #rrggbb." },
11858
+ opacity: { type: "number", minimum: 0, maximum: 1, description: "Overlay opacity 0..1." },
11859
+ indicates: { type: "string", enum: ["masked", "selected"], description: "Which area the tint covers." },
11860
+ visible: { type: "boolean", description: "Show the channel as a tint on the canvas." }
11861
+ },
11862
+ required: ["channelId"],
11863
+ additionalProperties: false
11864
+ },
11865
+ annotations: writeAnnotations("Change an alpha channel", true),
11866
+ handler: (args) => {
11867
+ const { channelId, ...props } = args;
11868
+ const c = controller.setChannelProperties(String(channelId), props);
11869
+ return { channel: channelResult(c.id) };
11870
+ }
11871
+ };
11872
+ const duplicateChannelTool = {
11873
+ name: "duplicate_channel",
11874
+ title: "Duplicate an alpha channel",
11875
+ description: 'Copies an alpha channel (name, default "<name> copy") just after it, to keep a version before changing it. One undo step. Returns the copy.',
11876
+ inputSchema: {
11877
+ type: "object",
11878
+ properties: { channelId: channelIdSchema, name: { type: "string", minLength: 1, description: "Name of the copy." } },
11879
+ required: ["channelId"],
11880
+ additionalProperties: false
11881
+ },
11882
+ annotations: writeAnnotations("Duplicate an alpha channel"),
11883
+ handler: (args) => ({ channel: channelResult(controller.duplicateChannel(String(args.channelId), args.name).id) })
11884
+ };
11885
+ const deleteChannelTool = {
11886
+ name: "delete_channel",
11887
+ title: "Delete an alpha channel",
11888
+ description: "Deletes an alpha channel (undo_image brings it back). Returns the remaining channels.",
11889
+ inputSchema: { type: "object", properties: { channelId: channelIdSchema }, required: ["channelId"], additionalProperties: false },
11890
+ annotations: { title: "Delete an alpha channel", readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
11891
+ handler: (args) => {
11892
+ controller.deleteChannel(String(args.channelId));
11893
+ return { channels: controller.describeChannels() };
11894
+ }
11895
+ };
11896
+ const quickMaskTool = {
11897
+ name: "set_quick_mask",
11898
+ title: "Quick Mask mode",
11899
+ description: "Turns Quick Mask mode on or off (Q). On, the selection shows as a red tint and paint_stroke, fill_pixels, clear_pixels, stroke_selection and the retouch tools called without layerId edit the selection itself: white selects, black deselects, grey selects partly, and blur_stroke softens its edge. Each edit is one undo step. Turn it off before painting layers again. Returns the mode and the selection.",
11900
+ inputSchema: {
11901
+ type: "object",
11902
+ properties: { enabled: { type: "boolean", description: "true to enter Quick Mask mode, false to leave it." } },
11903
+ required: ["enabled"],
11904
+ additionalProperties: false
11905
+ },
11906
+ annotations: writeAnnotations("Quick Mask mode", true),
11907
+ handler: (args) => {
11908
+ if (typeof args.enabled !== "boolean") throw new Error("enabled must be true or false.");
11909
+ controller.setQuickMaskMode(args.enabled);
11910
+ return { quickMask: controller.quickMaskEditing, selection: summary(controller) };
11911
+ }
11912
+ };
11913
+ const exportMaskTool = {
11914
+ name: "export_mask_image",
11915
+ title: "Export a mask as a PNG",
11916
+ 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.",
11917
+ inputSchema: {
11918
+ type: "object",
11919
+ properties: {
11920
+ channelId: channelIdSchema,
11921
+ layerId: { type: "string", minLength: 1, description: "A layer whose painted mask to export (see list_layers)." },
11922
+ invert: { type: "boolean", description: "Black = selected instead (default false)." },
11923
+ maxSide: { type: "integer", minimum: 16, maximum: 16384, description: "Longest side in pixels; larger masks are scaled down (default 1024, never scaled up)." }
11924
+ },
11925
+ additionalProperties: false
11926
+ },
11927
+ annotations: { title: "Export a mask as a PNG", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
11928
+ handler: (args) => {
11929
+ if (args.channelId !== void 0 && args.layerId !== void 0) throw new Error("Give channelId or layerId, not both (neither exports the selection).");
11930
+ if (typeof args.layerId === "string" && !controller.getLayer(args.layerId)) throw new Error(`No layer "${args.layerId}". Call list_layers for ids.`);
11931
+ const source = typeof args.channelId === "string" ? { channelId: args.channelId } : typeof args.layerId === "string" ? { layerId: args.layerId } : "selection";
11932
+ const out = controller.exportMaskImage(source, { invert: args.invert === true, maxSide: typeof args.maxSide === "number" ? args.maxSide : 1024 });
11933
+ const what = source === "selection" ? "Selection" : "channelId" in source ? `Channel ${source.channelId}` : `Mask of layer ${source.layerId}`;
11934
+ return {
11935
+ content: [
11936
+ { type: "image", data: out.dataUrl.slice(out.dataUrl.indexOf(",") + 1), mimeType: "image/png" },
11937
+ { type: "text", text: `${what}, ${out.width}\xD7${out.height}` }
11938
+ ],
11939
+ structuredContent: { width: out.width, height: out.height }
11940
+ };
11941
+ }
11942
+ };
11943
+ const loadChannelFromImageTool = {
11944
+ name: "load_channel_from_image",
11945
+ title: "Load a mask image into a new channel",
11946
+ 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.",
11947
+ inputSchema: {
11948
+ type: "object",
11949
+ properties: {
11950
+ image: { type: "string", minLength: 1, description: "The mask as a data URL (data:image/png;base64,\u2026) or an image URL." },
11951
+ name: { type: "string", minLength: 1, description: 'Channel name (default "Alpha n").' },
11952
+ channel: { type: "string", enum: ["luminance", "alpha", "red", "green", "blue"], description: "What to read as coverage (default luminance; alpha for a cutout PNG)." },
11953
+ threshold: { type: "number", minimum: 0, maximum: 255, description: "Values at or above this are selected, the rest not (default: keep soft values)." },
11954
+ invert: { type: "boolean", description: "Black = selected instead (default false)." }
11955
+ },
11956
+ required: ["image"],
11957
+ additionalProperties: false
11958
+ },
11959
+ annotations: { ...writeAnnotations("Load a mask image into a new channel"), openWorldHint: true },
11960
+ handler: async (args) => {
11961
+ if (typeof args.image !== "string" || !args.image.trim()) throw new Error("image must be a data URL or image URL.");
11962
+ const c = await controller.loadChannelFromImage(args.image, {
11963
+ name: args.name,
11964
+ channel: args.channel ?? "luminance",
11965
+ invert: args.invert === true,
11966
+ threshold: typeof args.threshold === "number" ? Math.min(255, Math.max(0, args.threshold)) : void 0
11967
+ });
11968
+ return { channel: channelResult(c.id) };
11969
+ }
11970
+ };
11461
11971
  return [
11462
11972
  getSelection,
11463
11973
  selectAll,
@@ -11486,7 +11996,16 @@ function createImageSelectionTools(controller) {
11486
11996
  fillTool,
11487
11997
  copyTool,
11488
11998
  cutTool,
11489
- pasteTool
11999
+ pasteTool,
12000
+ listChannels,
12001
+ saveSelectionTool,
12002
+ loadChannelTool,
12003
+ channelPropsTool,
12004
+ duplicateChannelTool,
12005
+ deleteChannelTool,
12006
+ exportMaskTool,
12007
+ loadChannelFromImageTool,
12008
+ quickMaskTool
11490
12009
  ];
11491
12010
  }
11492
12011
  function createImageSubjectSelectionTool(controller) {
@@ -12907,6 +13426,9 @@ function handlePaintShortcut(controller, e) {
12907
13426
  const set = (b) => controller.setPaintOptions({ brush: b });
12908
13427
  const key = e.key;
12909
13428
  switch (key.toLowerCase()) {
13429
+ case "q":
13430
+ controller.setQuickMaskMode(!controller.quickMaskEditing);
13431
+ return true;
12910
13432
  case "b":
12911
13433
  controller.setTool(e.shiftKey || key === "B" ? "mixer" : "brush");
12912
13434
  return true;
@@ -13046,8 +13568,8 @@ var TARGET = {
13046
13568
  layerId: { type: "string", description: "Layer to paint (default the active layer; see list_layers)." },
13047
13569
  channel: {
13048
13570
  type: "string",
13049
- enum: ["pixels", "mask", "filter-mask"],
13050
- description: "pixels (default for a pixel layer), the layer's mask, or a smart object's smart filter mask (filter-mask): black hides, white reveals."
13571
+ enum: ["pixels", "mask", "filter-mask", "quick-mask"],
13572
+ description: "pixels (default for a pixel layer), the layer's mask, or a smart object's smart filter mask (filter-mask): black hides, white reveals. In Quick Mask mode (set_quick_mask), quick-mask (the default when no layerId is given) paints the selection: white selects, black deselects."
13051
13573
  }
13052
13574
  };
13053
13575
  var BRUSH = {
@@ -13435,8 +13957,8 @@ var TARGET2 = {
13435
13957
  layerId: { type: "string", description: "Layer to retouch (default the active layer; see list_layers)." },
13436
13958
  channel: {
13437
13959
  type: "string",
13438
- enum: ["pixels", "mask", "filter-mask"],
13439
- description: "pixels (default for a pixel layer), the layer's mask, or a smart object's filter mask. Masks are grey values and sample only themselves."
13960
+ enum: ["pixels", "mask", "filter-mask", "quick-mask"],
13961
+ description: "pixels (default for a pixel layer), the layer's mask, or a smart object's filter mask. Masks are grey values and sample only themselves. In Quick Mask mode (set_quick_mask), quick-mask (the default when no layerId is given) paints the selection: white selects, black deselects."
13440
13962
  }
13441
13963
  };
13442
13964
  var SAMPLE = {
@@ -15538,6 +16060,44 @@ function handleSelectionShortcut(controller, e, onError) {
15538
16060
  if (key === "i" && e.shiftKey) return run(() => controller.invertSelection());
15539
16061
  return false;
15540
16062
  }
16063
+ function handleQuickMaskKey(controller, e) {
16064
+ if (e.ctrlKey || e.metaKey || e.altKey || e.key.toLowerCase() !== "q") return false;
16065
+ controller.setQuickMaskMode(!controller.quickMaskEditing);
16066
+ return true;
16067
+ }
16068
+
16069
+ // src/image-editor/selection/mask-files.ts
16070
+ var MASK_FILE_ACCEPT = "image/png,image/jpeg,image/webp,image/gif,image/bmp";
16071
+ function blobDataUrl(file) {
16072
+ return new Promise((resolve, reject) => {
16073
+ const reader = new FileReader();
16074
+ reader.onload = () => resolve(String(reader.result));
16075
+ reader.onerror = () => reject(reader.error ?? new Error("The file could not be read."));
16076
+ reader.readAsDataURL(file);
16077
+ });
16078
+ }
16079
+ function maskNameFromFile(file) {
16080
+ const name = file.name;
16081
+ return name ? name.replace(/\.[a-z0-9]{2,4}$/i, "").trim() || void 0 : void 0;
16082
+ }
16083
+ async function openMaskFile(controller, file) {
16084
+ return controller.loadChannelFromImage(await blobDataUrl(file), { name: maskNameFromFile(file) });
16085
+ }
16086
+ function maskFileName(name) {
16087
+ const base = name.replace(/\.[a-z0-9]{2,4}$/i, "").replace(/[\\/:*?"<>|]+/g, "-").trim() || "mask";
16088
+ return `${base}.png`;
16089
+ }
16090
+ function downloadMaskImage(controller, source, name = "mask") {
16091
+ const { dataUrl, width, height } = controller.exportMaskImage(source);
16092
+ const fileName = maskFileName(name);
16093
+ if (typeof document !== "undefined") {
16094
+ const a = document.createElement("a");
16095
+ a.href = dataUrl;
16096
+ a.download = fileName;
16097
+ a.click();
16098
+ }
16099
+ return { fileName, width, height };
16100
+ }
15541
16101
 
15542
16102
  // src/image-editor/mosaic-filter.ts
15543
16103
  function resolveMosaicRegionBounds(region, imgWidth, imgHeight) {
@@ -17124,6 +17684,7 @@ export {
17124
17684
  DEFAULT_BLENDER,
17125
17685
  DEFAULT_BRUSH,
17126
17686
  DEFAULT_CANVAS_VIEWPORT,
17687
+ DEFAULT_CHANNEL_COLOR,
17127
17688
  DEFAULT_DEPTH_MASK,
17128
17689
  DEFAULT_FLOW,
17129
17690
  DEFAULT_GLOBAL_LIGHT,
@@ -17199,6 +17760,7 @@ export {
17199
17760
  LayerEffectError,
17200
17761
  LayerOperationError,
17201
17762
  MARCHING_ANTS_STEP_MS,
17763
+ MASK_FILE_ACCEPT,
17202
17764
  MAX_BATCH,
17203
17765
  MAX_BRUSH_SIZE,
17204
17766
  MAX_CANVAS_BACKING_SIDE,
@@ -17268,6 +17830,7 @@ export {
17268
17830
  PatternPlacement,
17269
17831
  PatternTile,
17270
17832
  PsdError,
17833
+ QUICK_MASK_TARGET,
17271
17834
  REFINE_EDGE_CONTROLS,
17272
17835
  RETOUCH_CHOICES,
17273
17836
  RETOUCH_OPTION_CONTROLS,
@@ -17425,6 +17988,7 @@ export {
17425
17988
  canvasToImagePoint,
17426
17989
  caretAt,
17427
17990
  changedTiles,
17991
+ channelThumbnail,
17428
17992
  checkEffectAnimation,
17429
17993
  checkEffectTime,
17430
17994
  checkImageEffectPlace,
@@ -17523,10 +18087,12 @@ export {
17523
18087
  depthMaskAlpha,
17524
18088
  depthOf,
17525
18089
  depthRasterMask,
18090
+ describeChannel,
17526
18091
  describeLayer,
17527
18092
  describeLayerEffects,
17528
18093
  describeSelection,
17529
18094
  describeTransform,
18095
+ deserializeChannels,
17530
18096
  deserializeColorMode,
17531
18097
  deserializeColorProfile,
17532
18098
  deserializeEffects,
@@ -17546,6 +18112,7 @@ export {
17546
18112
  documentProfile,
17547
18113
  dodgeBurn,
17548
18114
  dodgeBurnCurve,
18115
+ downloadMaskImage,
17549
18116
  downloadPsd,
17550
18117
  dpow,
17551
18118
  dragWarpMeshPoint,
@@ -17617,6 +18184,7 @@ export {
17617
18184
  guidePlanes,
17618
18185
  guidedFilterColor,
17619
18186
  handlePaintShortcut,
18187
+ handleQuickMaskKey,
17620
18188
  handleSelectionShortcut,
17621
18189
  hasActiveEffects,
17622
18190
  hasLayersBelow,
@@ -17737,6 +18305,8 @@ export {
17737
18305
  mapLayer,
17738
18306
  mapSubpaths,
17739
18307
  maskBounds,
18308
+ maskFileName,
18309
+ maskNameFromFile,
17740
18310
  maskSummary,
17741
18311
  maskThumbnailClick,
17742
18312
  matrixFromCorners,
@@ -17794,6 +18364,7 @@ export {
17794
18364
  offsetLayer,
17795
18365
  offsetShape,
17796
18366
  offsetSmartObject,
18367
+ openMaskFile,
17797
18368
  openPsdFile,
17798
18369
  packRgba,
17799
18370
  packWetParams,
@@ -17944,6 +18515,7 @@ export {
17944
18515
  selectionToCoverage,
17945
18516
  selectionToPixels,
17946
18517
  selectionValueAt,
18518
+ serializeChannels,
17947
18519
  serializeColorMode,
17948
18520
  serializeColorProfile,
17949
18521
  serializeContents,