@pixerate/editor 0.185.0 → 0.186.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 (37) 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 +56 -0
  4. package/dist/image-editor/controller.d.ts +56 -0
  5. package/dist/image-editor/index.cjs +1461 -1030
  6. package/dist/image-editor/index.js +447 -16
  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 +1 -0
  26. package/dist/image-editor/selection/index.d.ts +1 -0
  27. package/dist/image-editor/selection/overlay.d.cts +2 -0
  28. package/dist/image-editor/selection/overlay.d.ts +2 -0
  29. package/dist/image-editor/tools.d.cts +3 -1
  30. package/dist/image-editor/tools.d.ts +3 -1
  31. package/dist/image-editor/types.d.cts +10 -0
  32. package/dist/image-editor/types.d.ts +10 -0
  33. package/dist/paint-editor/index.cjs +6 -6
  34. package/dist/paint-editor/index.js +1 -1
  35. package/package.json +1 -1
  36. package/skills/pixerate-image-editor/SKILL.md +7 -0
  37. package/skills/pixerate-image-selection-masking/SKILL.md +13 -1
@@ -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 };
@@ -8158,10 +8286,160 @@ var ImageEditorController = class {
8158
8286
  throw new Error(`Unknown selection display "${String(display)}". Use ants, quickMask or none.`);
8159
8287
  }
8160
8288
  if (this.getSelectionDisplay() === display) return;
8289
+ if (display !== "quickMask") delete this.state.quickMaskEditing;
8161
8290
  this.state.selectionDisplay = display;
8162
8291
  this.state.quickMask = display === "quickMask";
8163
8292
  this.notify();
8164
8293
  }
8294
+ // --- Alpha channels ---
8295
+ /** The alpha channels (saved selections), in panel order. */
8296
+ getChannels() {
8297
+ return this.state.channels ?? [];
8298
+ }
8299
+ getChannel(id) {
8300
+ return this.state.channels?.find((c) => c.id === id) ?? null;
8301
+ }
8302
+ requireChannel(id) {
8303
+ const channel = this.getChannel(id);
8304
+ if (!channel) {
8305
+ const ids = this.getChannels().map((c) => `${c.id} (${c.name})`);
8306
+ throw new LayerOperationError(`There is no alpha channel "${id}"; the channels are ${ids.join(", ") || "none (save a selection first)"}.`);
8307
+ }
8308
+ return channel;
8309
+ }
8310
+ setChannels(next, commitToHistory = true) {
8311
+ if (commitToHistory) this.recordHistory();
8312
+ if (next.length) this.state.channels = next;
8313
+ else delete this.state.channels;
8314
+ this.notify();
8315
+ }
8316
+ newChannelId() {
8317
+ const ids = new Set(this.getChannels().map((c) => c.id));
8318
+ let n = this.getChannels().length + 1;
8319
+ while (ids.has(`ch_${n}`)) n++;
8320
+ return `ch_${n}`;
8321
+ }
8322
+ /**
8323
+ * Select › Save Selection: a new alpha channel (`name`, default "Alpha n") holding the
8324
+ * selection, or with `channelId` the selection combined into that channel by `mode` (replace,
8325
+ * add, subtract, intersect). One undo step. Throws without a selection.
8326
+ */
8327
+ saveSelection(options = {}) {
8328
+ const sel = this.state.selection;
8329
+ if (!sel?.bounds) throw new LayerOperationError("Nothing is selected; select first, then save the selection to a channel.");
8330
+ const { width, height } = this.naturalSize;
8331
+ const sized = sel.width === width && sel.height === height ? sel : resizeSelection(sel, width, height);
8332
+ const channels = [...this.getChannels()];
8333
+ if (options.channelId !== void 0) {
8334
+ const existing = this.requireChannel(options.channelId);
8335
+ const mask = combineSelections(existing.mask.bounds ? existing.mask : null, sized, options.mode ?? "replace") ?? createSelectionMask(width, height);
8336
+ const next = { ...existing, mask, ...options.name?.trim() ? { name: options.name.trim() } : {} };
8337
+ this.setChannels(channels.map((c) => c.id === existing.id ? next : c));
8338
+ return next;
8339
+ }
8340
+ const channel = {
8341
+ id: this.newChannelId(),
8342
+ name: options.name?.trim() || `Alpha ${channels.length + 1}`,
8343
+ mask: sized,
8344
+ color: DEFAULT_CHANNEL_COLOR,
8345
+ opacity: 0.5,
8346
+ indicates: "masked",
8347
+ visible: false
8348
+ };
8349
+ this.setChannels([...channels, channel]);
8350
+ return channel;
8351
+ }
8352
+ /**
8353
+ * Select › Load Selection from an alpha channel (inverted with `invert`), combined with the
8354
+ * selection by `mode`. One undo step.
8355
+ */
8356
+ loadSelectionFromChannel(id, options = {}) {
8357
+ const channel = this.requireChannel(id);
8358
+ const mask = options.invert ? invertSelection(channel.mask) : channel.mask;
8359
+ return this.setSelection(mask.bounds ? mask : null, options.mode ?? "replace");
8360
+ }
8361
+ /** Changes a channel's name, overlay colour, opacity, what it indicates or visibility (one undo step). */
8362
+ setChannelProperties(id, props) {
8363
+ const channel = this.requireChannel(id);
8364
+ if (props.name !== void 0 && !props.name.trim()) throw new LayerOperationError("A channel name cannot be empty.");
8365
+ if (props.color !== void 0 && !/^#[0-9a-f]{6}$/i.test(props.color)) throw new LayerOperationError("color must be #rrggbb.");
8366
+ if (props.opacity !== void 0 && !(props.opacity >= 0 && props.opacity <= 1)) throw new LayerOperationError("opacity must be from 0 to 1.");
8367
+ if (props.indicates !== void 0 && props.indicates !== "masked" && props.indicates !== "selected") throw new LayerOperationError("indicates must be masked or selected.");
8368
+ const next = {
8369
+ ...channel,
8370
+ ...props.name !== void 0 ? { name: props.name.trim() } : {},
8371
+ ...props.color !== void 0 ? { color: props.color.toLowerCase() } : {},
8372
+ ...props.opacity !== void 0 ? { opacity: props.opacity } : {},
8373
+ ...props.indicates !== void 0 ? { indicates: props.indicates } : {},
8374
+ ...props.visible !== void 0 ? { visible: props.visible } : {}
8375
+ };
8376
+ this.setChannels(this.getChannels().map((c) => c.id === id ? next : c));
8377
+ return next;
8378
+ }
8379
+ /** Deletes an alpha channel (one undo step). */
8380
+ deleteChannel(id) {
8381
+ this.requireChannel(id);
8382
+ this.setChannels(this.getChannels().filter((c) => c.id !== id));
8383
+ }
8384
+ /** Duplicates an alpha channel just after it (one undo step). Returns the copy. */
8385
+ duplicateChannel(id, name) {
8386
+ const channel = this.requireChannel(id);
8387
+ const copy = { ...channel, id: this.newChannelId(), name: name?.trim() || `${channel.name} copy` };
8388
+ const channels = [...this.getChannels()];
8389
+ channels.splice(channels.findIndex((c) => c.id === id) + 1, 0, copy);
8390
+ this.setChannels(channels);
8391
+ return copy;
8392
+ }
8393
+ /** Channel summaries (no pixels) for panels and agents. */
8394
+ describeChannels() {
8395
+ return this.getChannels().map(describeChannel);
8396
+ }
8397
+ // --- Quick Mask mode ---
8398
+ /** Whether Quick Mask mode is on (painting edits the selection). */
8399
+ get quickMaskEditing() {
8400
+ return Boolean(this.state.quickMaskEditing);
8401
+ }
8402
+ /**
8403
+ * Quick Mask mode (Q): on, the selection shows as a tint and painting, fills and strokes that
8404
+ * name no layer edit the selection itself (white selects, black deselects, grey partly);
8405
+ * off, it shows as marching ants again. View state: no undo step (the edits are).
8406
+ */
8407
+ setQuickMaskMode(on) {
8408
+ if (this.quickMaskEditing === on) return;
8409
+ if (on) this.state.quickMaskEditing = true;
8410
+ else delete this.state.quickMaskEditing;
8411
+ this.setSelectionDisplay(on ? "quickMask" : "ants");
8412
+ this.notify();
8413
+ }
8414
+ /** The selection as a 1-channel surface to paint in Quick Mask mode. */
8415
+ quickMaskSurface() {
8416
+ const { width, height } = this.naturalSize;
8417
+ const sel = this.state.selection;
8418
+ 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);
8421
+ }
8422
+ /** The selection a painted Quick Mask surface stands for (`rects` changed since `base`). */
8423
+ selectionFromSurface(surface, base = null, rects) {
8424
+ 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));
8442
+ }
8165
8443
  // --- Layer masks ---
8166
8444
  maskFromSelection(sel, props = {}) {
8167
8445
  const { width, height } = this.naturalSize;
@@ -9275,6 +9553,17 @@ var ImageEditorController = class {
9275
9553
  s.outputOpacity = 1;
9276
9554
  delete s.legacyOutputBlur;
9277
9555
  s.selection = null;
9556
+ const psdChannels = (result.alphaChannels ?? []).map((c, k) => ({
9557
+ id: `ch_${k + 1}`,
9558
+ name: c.name,
9559
+ mask: createSelectionMask(result.width, result.height, c.data),
9560
+ color: c.color,
9561
+ opacity: c.opacity,
9562
+ indicates: c.indicates,
9563
+ visible: false
9564
+ }));
9565
+ if (psdChannels.length) s.channels = psdChannels;
9566
+ else delete s.channels;
9278
9567
  s.selectedAnnotationId = null;
9279
9568
  s.activeLayerId = defaultActiveLayerId(s.layers);
9280
9569
  s.activeTarget = "pixels";
@@ -9465,7 +9754,14 @@ var ImageEditorController = class {
9465
9754
  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
9755
  const depth = color.colorMode && this.bitDepth === 32 ? 16 : this.bitDepth;
9467
9756
  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 });
9757
+ const alphaChannels = this.getChannels().map((c) => ({
9758
+ name: c.name,
9759
+ data: c.mask.width === width && c.mask.height === height ? c.mask.data : resizeSelection(c.mask, width, height).data,
9760
+ color: c.color,
9761
+ opacity: c.opacity,
9762
+ indicates: c.indicates
9763
+ }));
9764
+ const result = exportPsd({ width, height, layers: prepared, merged, mergedDeep, resolution: this.psdResolution, patterns: this.state.patterns, globalLight: this.state.globalLight, depth, alphaChannels, ...color });
9469
9765
  warnings.push(...result.warnings);
9470
9766
  return { bytes: result.bytes, warnings, psb: result.psb, width, height };
9471
9767
  }
@@ -9538,6 +9834,7 @@ var ImageEditorController = class {
9538
9834
  try {
9539
9835
  out = this.serializeState();
9540
9836
  if (this.state.selection?.bounds) out.selection = serializeSelection(this.state.selection);
9837
+ if (this.state.channels?.length) out.channels = serializeChannels(this.state.channels);
9541
9838
  } finally {
9542
9839
  if (root) {
9543
9840
  this.state = current;
@@ -9578,6 +9875,11 @@ var ImageEditorController = class {
9578
9875
  }
9579
9876
  this.state.selection = selection;
9580
9877
  this.dropMismatchedSelection();
9878
+ const problems = [];
9879
+ const channels = deserializeChannels(data.channels, this.naturalSize, problems);
9880
+ if (channels.length) this.state.channels = channels;
9881
+ else delete this.state.channels;
9882
+ if (problems.length) this.lastLoadProblems = [...this.lastLoadProblems, ...problems];
9581
9883
  this.notify();
9582
9884
  }
9583
9885
  /** Problems found by the last `loadJSON` / `initialState` of a v3 document (unreadable layers or assets). */
@@ -10549,7 +10851,7 @@ var DEPTH_MASK_CONTROLS = [
10549
10851
  // src/image-editor/playback.ts
10550
10852
  var IMAGE_PLAYBACK_KIND = "layer";
10551
10853
  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)$/;
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)$/;
10553
10855
  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
10856
  var CROP = /^(set_crop|crop_to_selection|reset_crop|set_rotation|flip_image|transform_layer|warp_layer|flip_layer|offset_layer)$/;
10555
10857
  var ADJUST = /^(set_adjustments|add_adjustment_layer|update_adjustment_layer|remove_adjustment_layer|move_adjustment_layer)$/;
@@ -11458,6 +11760,113 @@ function createImageSelectionTools(controller) {
11458
11760
  annotations: writeAnnotations("Paste pixels"),
11459
11761
  handler: (args) => controller.pastePixels(args.mode ?? "new-layer")
11460
11762
  };
11763
+ const channelIdSchema = { type: "string", minLength: 1, description: "The alpha channel (see list_channels)." };
11764
+ const channelResult = (id) => controller.describeChannels().find((c) => c.id === id);
11765
+ const listChannels = {
11766
+ name: "list_channels",
11767
+ title: "List alpha channels",
11768
+ 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.",
11769
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
11770
+ annotations: { title: "List alpha channels", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
11771
+ handler: () => ({ channels: controller.describeChannels(), quickMask: controller.quickMaskEditing })
11772
+ };
11773
+ const saveSelectionTool = {
11774
+ name: "save_selection",
11775
+ title: "Save the selection to a channel",
11776
+ 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.',
11777
+ inputSchema: {
11778
+ type: "object",
11779
+ properties: { channelId: channelIdSchema, name: { type: "string", minLength: 1, description: "Name of a new channel, or a new name for channelId." }, mode: modeSchema },
11780
+ additionalProperties: false
11781
+ },
11782
+ annotations: writeAnnotations("Save the selection to a channel"),
11783
+ handler: (args) => {
11784
+ if (!controller.getSelection()) throw new Error("Nothing is selected; select first, then save it.");
11785
+ const c = controller.saveSelection({ channelId: args.channelId, name: args.name, mode: parseSelectionMode(args.mode) });
11786
+ return { channel: channelResult(c.id) };
11787
+ }
11788
+ };
11789
+ const loadChannelTool = {
11790
+ name: "load_selection_from_channel",
11791
+ title: "Load a selection from a channel",
11792
+ 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.",
11793
+ inputSchema: {
11794
+ type: "object",
11795
+ properties: { channelId: channelIdSchema, invert: { type: "boolean", description: "Load the inverse (default false)." }, mode: modeSchema },
11796
+ required: ["channelId"],
11797
+ additionalProperties: false
11798
+ },
11799
+ annotations: writeAnnotations("Load a selection from a channel", true),
11800
+ handler: (args) => {
11801
+ controller.loadSelectionFromChannel(String(args.channelId), { mode: parseSelectionMode(args.mode), invert: args.invert === true });
11802
+ return summary(controller);
11803
+ }
11804
+ };
11805
+ const channelPropsTool = {
11806
+ name: "set_channel_properties",
11807
+ title: "Change an alpha channel",
11808
+ 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.",
11809
+ inputSchema: {
11810
+ type: "object",
11811
+ properties: {
11812
+ channelId: channelIdSchema,
11813
+ name: { type: "string", minLength: 1, description: "New name." },
11814
+ color: { type: "string", pattern: "^#[0-9a-fA-F]{6}$", description: "Overlay colour #rrggbb." },
11815
+ opacity: { type: "number", minimum: 0, maximum: 1, description: "Overlay opacity 0..1." },
11816
+ indicates: { type: "string", enum: ["masked", "selected"], description: "Which area the tint covers." },
11817
+ visible: { type: "boolean", description: "Show the channel as a tint on the canvas." }
11818
+ },
11819
+ required: ["channelId"],
11820
+ additionalProperties: false
11821
+ },
11822
+ annotations: writeAnnotations("Change an alpha channel", true),
11823
+ handler: (args) => {
11824
+ const { channelId, ...props } = args;
11825
+ const c = controller.setChannelProperties(String(channelId), props);
11826
+ return { channel: channelResult(c.id) };
11827
+ }
11828
+ };
11829
+ const duplicateChannelTool = {
11830
+ name: "duplicate_channel",
11831
+ title: "Duplicate an alpha channel",
11832
+ 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.',
11833
+ inputSchema: {
11834
+ type: "object",
11835
+ properties: { channelId: channelIdSchema, name: { type: "string", minLength: 1, description: "Name of the copy." } },
11836
+ required: ["channelId"],
11837
+ additionalProperties: false
11838
+ },
11839
+ annotations: writeAnnotations("Duplicate an alpha channel"),
11840
+ handler: (args) => ({ channel: channelResult(controller.duplicateChannel(String(args.channelId), args.name).id) })
11841
+ };
11842
+ const deleteChannelTool = {
11843
+ name: "delete_channel",
11844
+ title: "Delete an alpha channel",
11845
+ description: "Deletes an alpha channel (undo_image brings it back). Returns the remaining channels.",
11846
+ inputSchema: { type: "object", properties: { channelId: channelIdSchema }, required: ["channelId"], additionalProperties: false },
11847
+ annotations: { title: "Delete an alpha channel", readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
11848
+ handler: (args) => {
11849
+ controller.deleteChannel(String(args.channelId));
11850
+ return { channels: controller.describeChannels() };
11851
+ }
11852
+ };
11853
+ const quickMaskTool = {
11854
+ name: "set_quick_mask",
11855
+ title: "Quick Mask mode",
11856
+ 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.",
11857
+ inputSchema: {
11858
+ type: "object",
11859
+ properties: { enabled: { type: "boolean", description: "true to enter Quick Mask mode, false to leave it." } },
11860
+ required: ["enabled"],
11861
+ additionalProperties: false
11862
+ },
11863
+ annotations: writeAnnotations("Quick Mask mode", true),
11864
+ handler: (args) => {
11865
+ if (typeof args.enabled !== "boolean") throw new Error("enabled must be true or false.");
11866
+ controller.setQuickMaskMode(args.enabled);
11867
+ return { quickMask: controller.quickMaskEditing, selection: summary(controller) };
11868
+ }
11869
+ };
11461
11870
  return [
11462
11871
  getSelection,
11463
11872
  selectAll,
@@ -11486,7 +11895,14 @@ function createImageSelectionTools(controller) {
11486
11895
  fillTool,
11487
11896
  copyTool,
11488
11897
  cutTool,
11489
- pasteTool
11898
+ pasteTool,
11899
+ listChannels,
11900
+ saveSelectionTool,
11901
+ loadChannelTool,
11902
+ channelPropsTool,
11903
+ duplicateChannelTool,
11904
+ deleteChannelTool,
11905
+ quickMaskTool
11490
11906
  ];
11491
11907
  }
11492
11908
  function createImageSubjectSelectionTool(controller) {
@@ -12907,6 +13323,9 @@ function handlePaintShortcut(controller, e) {
12907
13323
  const set = (b) => controller.setPaintOptions({ brush: b });
12908
13324
  const key = e.key;
12909
13325
  switch (key.toLowerCase()) {
13326
+ case "q":
13327
+ controller.setQuickMaskMode(!controller.quickMaskEditing);
13328
+ return true;
12910
13329
  case "b":
12911
13330
  controller.setTool(e.shiftKey || key === "B" ? "mixer" : "brush");
12912
13331
  return true;
@@ -13046,8 +13465,8 @@ var TARGET = {
13046
13465
  layerId: { type: "string", description: "Layer to paint (default the active layer; see list_layers)." },
13047
13466
  channel: {
13048
13467
  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."
13468
+ enum: ["pixels", "mask", "filter-mask", "quick-mask"],
13469
+ 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
13470
  }
13052
13471
  };
13053
13472
  var BRUSH = {
@@ -13435,8 +13854,8 @@ var TARGET2 = {
13435
13854
  layerId: { type: "string", description: "Layer to retouch (default the active layer; see list_layers)." },
13436
13855
  channel: {
13437
13856
  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."
13857
+ enum: ["pixels", "mask", "filter-mask", "quick-mask"],
13858
+ 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
13859
  }
13441
13860
  };
13442
13861
  var SAMPLE = {
@@ -15538,6 +15957,11 @@ function handleSelectionShortcut(controller, e, onError) {
15538
15957
  if (key === "i" && e.shiftKey) return run(() => controller.invertSelection());
15539
15958
  return false;
15540
15959
  }
15960
+ function handleQuickMaskKey(controller, e) {
15961
+ if (e.ctrlKey || e.metaKey || e.altKey || e.key.toLowerCase() !== "q") return false;
15962
+ controller.setQuickMaskMode(!controller.quickMaskEditing);
15963
+ return true;
15964
+ }
15541
15965
 
15542
15966
  // src/image-editor/mosaic-filter.ts
15543
15967
  function resolveMosaicRegionBounds(region, imgWidth, imgHeight) {
@@ -17124,6 +17548,7 @@ export {
17124
17548
  DEFAULT_BLENDER,
17125
17549
  DEFAULT_BRUSH,
17126
17550
  DEFAULT_CANVAS_VIEWPORT,
17551
+ DEFAULT_CHANNEL_COLOR,
17127
17552
  DEFAULT_DEPTH_MASK,
17128
17553
  DEFAULT_FLOW,
17129
17554
  DEFAULT_GLOBAL_LIGHT,
@@ -17268,6 +17693,7 @@ export {
17268
17693
  PatternPlacement,
17269
17694
  PatternTile,
17270
17695
  PsdError,
17696
+ QUICK_MASK_TARGET,
17271
17697
  REFINE_EDGE_CONTROLS,
17272
17698
  RETOUCH_CHOICES,
17273
17699
  RETOUCH_OPTION_CONTROLS,
@@ -17425,6 +17851,7 @@ export {
17425
17851
  canvasToImagePoint,
17426
17852
  caretAt,
17427
17853
  changedTiles,
17854
+ channelThumbnail,
17428
17855
  checkEffectAnimation,
17429
17856
  checkEffectTime,
17430
17857
  checkImageEffectPlace,
@@ -17523,10 +17950,12 @@ export {
17523
17950
  depthMaskAlpha,
17524
17951
  depthOf,
17525
17952
  depthRasterMask,
17953
+ describeChannel,
17526
17954
  describeLayer,
17527
17955
  describeLayerEffects,
17528
17956
  describeSelection,
17529
17957
  describeTransform,
17958
+ deserializeChannels,
17530
17959
  deserializeColorMode,
17531
17960
  deserializeColorProfile,
17532
17961
  deserializeEffects,
@@ -17617,6 +18046,7 @@ export {
17617
18046
  guidePlanes,
17618
18047
  guidedFilterColor,
17619
18048
  handlePaintShortcut,
18049
+ handleQuickMaskKey,
17620
18050
  handleSelectionShortcut,
17621
18051
  hasActiveEffects,
17622
18052
  hasLayersBelow,
@@ -17944,6 +18374,7 @@ export {
17944
18374
  selectionToCoverage,
17945
18375
  selectionToPixels,
17946
18376
  selectionValueAt,
18377
+ serializeChannels,
17947
18378
  serializeColorMode,
17948
18379
  serializeColorProfile,
17949
18380
  serializeContents,