@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.
- package/dist/{chunk-GYYSPMVU.js → chunk-FW7N64XK.js} +295 -188
- package/dist/{chunk-C6TRAAG4.cjs → chunk-X2KQQZBT.cjs} +355 -248
- package/dist/image-editor/controller.d.cts +89 -0
- package/dist/image-editor/controller.d.ts +89 -0
- package/dist/image-editor/index.cjs +1602 -1030
- package/dist/image-editor/index.js +591 -19
- package/dist/image-editor/layers/model.d.cts +5 -1
- package/dist/image-editor/layers/model.d.ts +5 -1
- package/dist/image-editor/layers/paint-ui.d.cts +2 -1
- package/dist/image-editor/layers/paint-ui.d.ts +2 -1
- package/dist/image-editor/layers/serialize.d.cts +3 -0
- package/dist/image-editor/layers/serialize.d.ts +3 -0
- package/dist/image-editor/psd/channels.d.cts +24 -0
- package/dist/image-editor/psd/channels.d.ts +24 -0
- package/dist/image-editor/psd/export.d.cts +3 -0
- package/dist/image-editor/psd/export.d.ts +3 -0
- package/dist/image-editor/psd/import.d.cts +10 -0
- package/dist/image-editor/psd/import.d.ts +10 -0
- package/dist/image-editor/renderer.d.cts +6 -0
- package/dist/image-editor/renderer.d.ts +6 -0
- package/dist/image-editor/selection/channels.d.cts +52 -0
- package/dist/image-editor/selection/channels.d.ts +52 -0
- package/dist/image-editor/selection/gesture.d.cts +7 -0
- package/dist/image-editor/selection/gesture.d.ts +7 -0
- package/dist/image-editor/selection/index.d.cts +2 -0
- package/dist/image-editor/selection/index.d.ts +2 -0
- package/dist/image-editor/selection/mask-files.d.cts +20 -0
- package/dist/image-editor/selection/mask-files.d.ts +20 -0
- package/dist/image-editor/selection/overlay.d.cts +2 -0
- package/dist/image-editor/selection/overlay.d.ts +2 -0
- package/dist/image-editor/tools.d.cts +3 -1
- package/dist/image-editor/tools.d.ts +3 -1
- package/dist/image-editor/types.d.cts +10 -0
- package/dist/image-editor/types.d.ts +10 -0
- package/dist/paint-editor/index.cjs +6 -6
- package/dist/paint-editor/index.js +1 -1
- package/package.json +1 -1
- package/skills/pixerate-image-editor/SKILL.md +12 -24
- package/skills/pixerate-image-editor/references/painting.md +29 -0
- 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-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|