@pixerate/editor 0.178.0 → 0.180.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.
@@ -948,6 +948,24 @@ function quickMaskPixels(mask, style = {}) {
948
948
  }
949
949
  return out;
950
950
  }
951
+ var MARCHING_ANTS_STEP_MS = 100;
952
+ function prefersReducedMotion() {
953
+ try {
954
+ return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
955
+ } catch {
956
+ return false;
957
+ }
958
+ }
959
+ function startMarchingAnts(tick, stepMs = MARCHING_ANTS_STEP_MS) {
960
+ if (prefersReducedMotion()) return () => {
961
+ };
962
+ let offset = 0;
963
+ const id = setInterval(() => {
964
+ offset = (offset + 1) % 8;
965
+ tick(offset);
966
+ }, stepMs);
967
+ return () => clearInterval(id);
968
+ }
951
969
 
952
970
  // src/image-editor/layers/render.ts
953
971
  function isQuarterTurn(rotate) {
@@ -1353,8 +1371,8 @@ var ImageEditorRenderer = class {
1353
1371
  ctx.rect(region.x, region.y, region.width, region.height);
1354
1372
  ctx.clip();
1355
1373
  if (state.selection?.bounds && display === "quickMask") this.drawQuickMask(ctx, state.selection);
1356
- else if (state.selection?.bounds && display === "ants") this.drawAnts(ctx, state.selection, projection.scale, options.antsOffset ?? 0);
1357
- if (options.draftSelectionPath?.length) this.drawDraftPath(ctx, options.draftSelectionPath, projection.scale);
1374
+ else if (state.selection?.bounds && display === "ants") this.drawAnts(ctx, state.selection, projection.scale / u, options.antsOffset ?? 0);
1375
+ if (options.draftSelectionPath?.length) this.drawDraftPath(ctx, options.draftSelectionPath, projection.scale / u);
1358
1376
  } catch {
1359
1377
  }
1360
1378
  ctx.restore();
@@ -1460,6 +1478,12 @@ var ImageEditorRenderer = class {
1460
1478
  outline.forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y));
1461
1479
  ctx.closePath();
1462
1480
  }
1481
+ const rotate = t.rotate && t.outline.length >= 2 ? scr(t.rotate) : null;
1482
+ if (rotate) {
1483
+ const n = scr({ x: (t.outline[0].x + t.outline[1].x) / 2, y: (t.outline[0].y + t.outline[1].y) / 2 });
1484
+ ctx.moveTo(n.x, n.y);
1485
+ ctx.lineTo(rotate.x, rotate.y);
1486
+ }
1463
1487
  ctx.lineWidth = 3 * u;
1464
1488
  ctx.strokeStyle = "rgba(0,0,0,0.25)";
1465
1489
  ctx.stroke();
@@ -1467,6 +1491,15 @@ var ImageEditorRenderer = class {
1467
1491
  ctx.strokeStyle = "#ffffff";
1468
1492
  ctx.stroke();
1469
1493
  drawOverlayHandles(ctx, [...t.handles, ...t.warp?.anchors ?? []].map(scr), u);
1494
+ if (rotate) {
1495
+ ctx.beginPath();
1496
+ ctx.arc(rotate.x, rotate.y, 5 * u, 0, Math.PI * 2);
1497
+ ctx.fillStyle = "#ffffff";
1498
+ ctx.fill();
1499
+ ctx.lineWidth = 1.5 * u;
1500
+ ctx.strokeStyle = OVERLAY_HANDLE_EDGE;
1501
+ ctx.stroke();
1502
+ }
1470
1503
  for (const h of (t.warp?.handles ?? []).map((w) => scr(w.point))) {
1471
1504
  ctx.beginPath();
1472
1505
  ctx.arc(h.x, h.y, 3.5 * u, 0, Math.PI * 2);
@@ -1757,7 +1790,10 @@ var ImageEditorRenderer = class {
1757
1790
  }
1758
1791
  ctx.drawImage(canvas, 0, 0, selection.width, selection.height);
1759
1792
  }
1760
- /** Marching ants: a white line under a black dashed one, one screen pixel wide. */
1793
+ /**
1794
+ * Marching ants: a white line under a black dashed one, one screen pixel wide. `scale` is
1795
+ * screen pixels per natural pixel (the projection scale times the host's CSS scale).
1796
+ */
1761
1797
  drawAnts(ctx, selection, scale, offset) {
1762
1798
  let segs = this.outlineCache.get(selection);
1763
1799
  if (!segs) {
@@ -3067,6 +3103,35 @@ function hueRange(pixels, center) {
3067
3103
  return createSelectionMask(pixels.width, pixels.height, out);
3068
3104
  }
3069
3105
 
3106
+ // src/image-editor/selection/segmentation.ts
3107
+ var isPoint = (p) => !!p && typeof p === "object" && Number.isFinite(p.x) && Number.isFinite(p.y);
3108
+ function polygonsOf(result) {
3109
+ const p = result.polygon;
3110
+ if (!Array.isArray(p) || !p.length) return [];
3111
+ const rings = Array.isArray(p[0]) ? p : [p];
3112
+ return rings.map((ring) => Array.isArray(ring) ? ring.filter(isPoint) : []).filter((ring) => ring.length >= 3);
3113
+ }
3114
+ function segmentToSelection(width, height, result) {
3115
+ let out = null;
3116
+ for (const ring of polygonsOf(result)) {
3117
+ out = combineSelections(out, polygonSelection(width, height, ring), "add");
3118
+ }
3119
+ if (result.mask && result.mask.width > 0 && result.mask.height > 0) {
3120
+ const raw = selectionFromPixels(result.mask, { channel: "luminance" });
3121
+ const sized = raw.width === width && raw.height === height ? raw : resizeSelection(raw, width, height);
3122
+ out = combineSelections(out, sized, "add");
3123
+ }
3124
+ return out?.bounds ? out : null;
3125
+ }
3126
+ function segmentsToSelection(width, height, results) {
3127
+ let out = null;
3128
+ for (const r of results) {
3129
+ const sel = segmentToSelection(width, height, r);
3130
+ if (sel) out = combineSelections(out, sel, "add");
3131
+ }
3132
+ return out;
3133
+ }
3134
+
3070
3135
  // src/image-editor/selection/refine.ts
3071
3136
  var DEFAULT_REFINE_EDGE_PARAMS = {
3072
3137
  radius: 0,
@@ -4654,6 +4719,8 @@ var ImageEditorController = class {
4654
4719
  viewKey = [];
4655
4720
  /** For `compositePixels` and mask evaluation (the renderer keeps its own for the viewport). */
4656
4721
  compositor = new LayerCompositor();
4722
+ /** The segmentation provider behind `selectSubject` (see `setSegmentationProvider`). */
4723
+ segmentation = null;
4657
4724
  /** The effects provider slot (see `setEffectsProvider`). */
4658
4725
  effects = new EffectsSlot();
4659
4726
  /** Renders provider effects: effect smart filter results and effect layers. */
@@ -4663,6 +4730,7 @@ var ImageEditorController = class {
4663
4730
  this.effects.subscribe(() => this.queueEffectRefresh("all"));
4664
4731
  this.effectsEngine.subscribe(() => this.queueEffectRefresh("incomplete"));
4665
4732
  if (options.effects) this.effects.set(options.effects);
4733
+ if (options.segmentation) this.segmentation = options.segmentation;
4666
4734
  this.maxHistoryDepth = options.maxHistoryDepth ?? 50;
4667
4735
  if (options.textShaper !== void 0) this.textShaperValue = options.textShaper;
4668
4736
  this.maxHistoryBytes = options.maxHistoryBytes ?? 512 * 1024 * 1024;
@@ -4844,6 +4912,16 @@ var ImageEditorController = class {
4844
4912
  this.state.layers = next;
4845
4913
  this.notify();
4846
4914
  }
4915
+ /** The registered segmentation provider, or `null` (then `selectSubject` is unavailable). */
4916
+ get segmentationProvider() {
4917
+ return this.segmentation;
4918
+ }
4919
+ /** Registers (or with `null` removes) the segmentation provider. Not an edit: no undo step. */
4920
+ setSegmentationProvider(provider) {
4921
+ if (this.segmentation === provider) return;
4922
+ this.segmentation = provider;
4923
+ this.notify();
4924
+ }
4847
4925
  /** The registered effects provider, or `null`. */
4848
4926
  get effectsProvider() {
4849
4927
  return this.effects.provider;
@@ -7600,23 +7678,50 @@ var ImageEditorController = class {
7600
7678
  * RGBA pixels, by luminance (white = selected) or a channel. Any size; it is resampled.
7601
7679
  */
7602
7680
  async loadSelectionFromImage(source, options = {}) {
7603
- let pixels;
7604
- if (typeof source === "string") {
7605
- const img = await this.renderer.imageCache.get(source);
7606
- const w = img.naturalWidth || img.width;
7607
- const h = img.naturalHeight || img.height;
7608
- const canvas = this.renderer.createCanvas(w, h);
7609
- const ctx = canvas.getContext("2d");
7610
- if (ctx) ctx.drawImage(img, 0, 0, w, h);
7611
- const data = ctx?.getImageData?.(0, 0, w, h);
7612
- if (!data) throw new Error("The mask image pixels are not available (no canvas access); pass pixels.");
7613
- pixels = { width: w, height: h, data: data.data };
7614
- } else {
7615
- pixels = source;
7616
- }
7681
+ const pixels = await this.decodeMaskImage(source);
7617
7682
  this.setSelection(selectionFromPixels(pixels, options), options.mode ?? "replace");
7618
7683
  return this.getSelection();
7619
7684
  }
7685
+ /** A mask image (data URL / URL, or pixels as they are) as RGBA pixels. */
7686
+ async decodeMaskImage(source) {
7687
+ if (typeof source !== "string") return source;
7688
+ const img = await this.renderer.imageCache.get(source);
7689
+ const w = img.naturalWidth || img.width;
7690
+ const h = img.naturalHeight || img.height;
7691
+ const canvas = this.renderer.createCanvas(w, h);
7692
+ const ctx = canvas.getContext("2d");
7693
+ if (ctx) ctx.drawImage(img, 0, 0, w, h);
7694
+ const data = ctx?.getImageData?.(0, 0, w, h);
7695
+ if (!data) throw new Error("The mask image pixels are not available (no canvas access); pass pixels.");
7696
+ return { width: w, height: h, data: data.data };
7697
+ }
7698
+ /**
7699
+ * Select › Subject: asks the segmentation provider for the objects matching `target` ("the
7700
+ * dog", "the sky") in the flattened image and selects their shapes, combined with the
7701
+ * selection by `mode`, as one undo step. Returns what was found (labels and bounds in natural
7702
+ * pixels); an empty list leaves the selection as it was. Throws without a provider.
7703
+ */
7704
+ async selectSubject(target, options = {}) {
7705
+ const provider = this.segmentation;
7706
+ if (!provider) throw new Error("Select Subject needs a segmentation provider (setSegmentationProvider).");
7707
+ const description = target.trim();
7708
+ if (!description) throw new Error("Describe the subject to select.");
7709
+ const { width, height } = this.naturalSize;
7710
+ const image = await this.toDataURL({ format: "image/png" });
7711
+ const results = await provider.segment({ image, width, height, target: description, maxSegments: options.maxSegments, signal: options.signal });
7712
+ options.signal?.throwIfAborted();
7713
+ const decoded = [];
7714
+ for (const r of results ?? []) {
7715
+ decoded.push({ ...r, mask: r.mask !== void 0 ? await this.decodeMaskImage(r.mask) : void 0 });
7716
+ }
7717
+ const segments = decoded.map((r) => {
7718
+ const one = segmentsToSelection(width, height, [r]);
7719
+ return { label: r.label || description, bounds: one?.bounds ?? null, ...r.score !== void 0 ? { score: r.score } : {} };
7720
+ });
7721
+ const mask = segmentsToSelection(width, height, decoded);
7722
+ if (mask) this.shapeCommand(mask, options);
7723
+ return { segments: segments.filter((s) => s.bounds) };
7724
+ }
7620
7725
  /** Quick Mask view (tints the unselected area). View state: no undo step. */
7621
7726
  setQuickMask(on) {
7622
7727
  if (Boolean(this.state.quickMask) === on) return;
@@ -10007,7 +10112,7 @@ var DEPTH_MASK_CONTROLS = [
10007
10112
  // src/image-editor/playback.ts
10008
10113
  var IMAGE_PLAYBACK_KIND = "layer";
10009
10114
  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)$/;
10010
- var SELECT = /^(select_region|select_by_color|modify_selection|refine_selection|transform_selection|apply_selection_as_mask|layer_via_copy)$/;
10115
+ var SELECT = /^(select_region|select_subject|select_by_color|modify_selection|refine_selection|transform_selection|apply_selection_as_mask|layer_via_copy)$/;
10011
10116
  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)$/;
10012
10117
  var CROP = /^(set_crop|reset_crop|set_rotation|flip_image|transform_layer|warp_layer|flip_layer|offset_layer)$/;
10013
10118
  var ADJUST = /^(set_adjustments|add_adjustment_layer|update_adjustment_layer|remove_adjustment_layer|move_adjustment_layer)$/;
@@ -10028,7 +10133,7 @@ function createImagePlaybackLocator(controller) {
10028
10133
  };
10029
10134
  }
10030
10135
  var num = (v) => typeof v === "number" && Number.isFinite(v);
10031
- var isPoint = (p) => !!p && typeof p === "object" && num(p.x) && num(p.y);
10136
+ var isPoint2 = (p) => !!p && typeof p === "object" && num(p.x) && num(p.y);
10032
10137
  function trace(points, max = 32) {
10033
10138
  const list = points.length <= max ? points : Array.from({ length: max }, (_, i) => points[Math.round(i * (points.length - 1) / (max - 1))]);
10034
10139
  if (!list.length) return [];
@@ -10064,7 +10169,7 @@ function planImageToolPlayback(tool, args) {
10064
10169
  const steps = [];
10065
10170
  const panel = imagePlaybackPanel(tool);
10066
10171
  if (panel) steps.push({ type: "open", ref: { kind: "panel", id: panel } });
10067
- const points = Array.isArray(args.points) ? args.points.filter(isPoint) : [];
10172
+ const points = Array.isArray(args.points) ? args.points.filter(isPoint2) : [];
10068
10173
  const box = boxOf(args);
10069
10174
  if (points.length) steps.push(...trace(points));
10070
10175
  else if (box) steps.push({ type: "move", to: box.from }, { type: "press" }, { type: "drag", to: box.to }, { type: "release" });
@@ -10197,15 +10302,26 @@ function createImageSelectionTools(controller) {
10197
10302
  const selectRegion = {
10198
10303
  name: "select_region",
10199
10304
  title: "Select a region",
10200
- description: 'Selects a rectangle or ellipse (x, y, width, height in natural image pixels), everything ("all"), nothing ("none") or the inverse of the current selection ("invert"). One undoable step.',
10305
+ description: 'Selects a rectangle or ellipse (x, y, width, height in natural image pixels), a polygon (the lasso: points along an outline, closed automatically), everything ("all"), nothing ("none") or the inverse of the current selection ("invert"). To cut out a subject, trace its contour with polygon (then refine_selection for soft edges such as fur or foliage) rather than selecting its bounding box. One undoable step.',
10201
10306
  inputSchema: {
10202
10307
  type: "object",
10203
10308
  properties: {
10204
- shape: { type: "string", enum: ["rect", "ellipse", "all", "none", "invert"], description: "What to select." },
10309
+ shape: { type: "string", enum: ["rect", "ellipse", "polygon", "all", "none", "invert"], description: "What to select." },
10205
10310
  x: { type: "number", description: "rect / ellipse: left edge in natural image pixels." },
10206
10311
  y: { type: "number", description: "rect / ellipse: top edge in natural image pixels." },
10207
10312
  width: { type: "number", description: "rect / ellipse: width in natural image pixels." },
10208
10313
  height: { type: "number", description: "rect / ellipse: height in natural image pixels." },
10314
+ points: {
10315
+ type: "array",
10316
+ minItems: 3,
10317
+ items: {
10318
+ type: "object",
10319
+ properties: { x: { type: "number" }, y: { type: "number" } },
10320
+ required: ["x", "y"],
10321
+ additionalProperties: false
10322
+ },
10323
+ description: "polygon: at least 3 vertices along the outline, in order, in natural image pixels. Follow the edge closely; more points give a tighter contour."
10324
+ },
10209
10325
  mode: modeSchema,
10210
10326
  feather: { type: "number", minimum: 0, description: "Feather radius in pixels (default 0)." }
10211
10327
  },
@@ -10213,7 +10329,7 @@ function createImageSelectionTools(controller) {
10213
10329
  additionalProperties: false
10214
10330
  },
10215
10331
  annotations: writeAnnotations("Select a region", true),
10216
- handler: ({ shape, x, y, width, height, mode, feather }) => {
10332
+ handler: ({ shape, x, y, width, height, points, mode, feather }) => {
10217
10333
  const opts = { mode: parseSelectionMode(mode), feather: num2(feather, 0) };
10218
10334
  switch (shape) {
10219
10335
  case "all":
@@ -10235,8 +10351,16 @@ function createImageSelectionTools(controller) {
10235
10351
  else controller.selectEllipse(rect, opts);
10236
10352
  break;
10237
10353
  }
10354
+ case "polygon": {
10355
+ const list = Array.isArray(points) ? points.map((p) => ({ x: num2(p?.x, NaN), y: num2(p?.y, NaN) })) : [];
10356
+ if (list.length < 3 || !list.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y))) {
10357
+ throw new Error("polygon needs points: at least 3 { x, y } vertices.");
10358
+ }
10359
+ controller.selectPolygon(list, opts);
10360
+ break;
10361
+ }
10238
10362
  default:
10239
- throw new Error(`Unknown shape "${String(shape)}". Use rect, ellipse, all, none or invert.`);
10363
+ throw new Error(`Unknown shape "${String(shape)}". Use rect, ellipse, polygon, all, none or invert.`);
10240
10364
  }
10241
10365
  return summary(controller);
10242
10366
  }
@@ -10376,6 +10500,53 @@ function createImageSelectionTools(controller) {
10376
10500
  };
10377
10501
  return [getSelection, selectRegion, selectByColor, modify, refine, applyMask];
10378
10502
  }
10503
+ function createImageSubjectSelectionTool(controller) {
10504
+ return {
10505
+ name: "select_subject",
10506
+ title: "Select a subject",
10507
+ description: 'Select \u203A Subject: an AI segmentation model finds the objects matching target (a description such as "the dog", "the house", "the sky") in the flattened image and selects their outlines, combined with the selection by mode. Use it to cut a subject out by its contour (then layer_via_copy for a layer of just that subject); describe one object precisely to get only that one. One undoable step. Returns the selection and the objects found (label, bounds); none found leaves the selection unchanged.',
10508
+ inputSchema: {
10509
+ type: "object",
10510
+ properties: {
10511
+ target: { type: "string", minLength: 1, description: "What to select, in words." },
10512
+ maxSegments: {
10513
+ type: "integer",
10514
+ minimum: 1,
10515
+ maximum: 32,
10516
+ description: "Select at most this many matching objects (default the model\u2019s; 1 for a single subject)."
10517
+ },
10518
+ mode: modeSchema,
10519
+ feather: { type: "number", minimum: 0, description: "Feather radius in pixels (default 0)." }
10520
+ },
10521
+ required: ["target"],
10522
+ additionalProperties: false
10523
+ },
10524
+ annotations: { ...writeAnnotations("Select a subject"), openWorldHint: true },
10525
+ handler: async ({ target, maxSegments, mode, feather }) => {
10526
+ if (typeof target !== "string" || !target.trim()) throw new Error("target must describe what to select.");
10527
+ const { segments } = await controller.selectSubject(target, {
10528
+ mode: parseSelectionMode(mode),
10529
+ feather: num2(feather, 0),
10530
+ maxSegments: typeof maxSegments === "number" ? Math.max(1, Math.round(maxSegments)) : void 0
10531
+ });
10532
+ return { ...summary(controller), found: segments };
10533
+ }
10534
+ };
10535
+ }
10536
+ function createImageSegmentationToolProvider(controller, options = {}) {
10537
+ const tool = createImageSubjectSelectionTool(controller);
10538
+ return withImagePlayback(
10539
+ {
10540
+ id: options.id ?? "image-segmentation",
10541
+ namespace: options.namespace,
10542
+ title: "Image segmentation",
10543
+ description: "Select \u203A Subject: select objects in the image by description through an AI segmentation model.",
10544
+ skill: "pixerate-image-editor",
10545
+ tools: () => controller.segmentationProvider ? [tool] : []
10546
+ },
10547
+ options.playbackSurface
10548
+ );
10549
+ }
10379
10550
 
10380
10551
  // src/image-editor/capture.ts
10381
10552
  var NO_CANVAS = "Rendering is not available in this environment.";
@@ -11161,7 +11332,7 @@ function createImageLayerTools(controller) {
11161
11332
  const viaCopy = {
11162
11333
  name: "layer_via_copy",
11163
11334
  title: "New layer from the selection",
11164
- description: "Layer via Copy: copies the selected pixels of a pixel layer (layerId, default the active layer) to a new layer directly above it and makes it active. cut: true also removes them from the source (Layer via Cut). Needs a selection. One undo step. Returns the new layer.",
11335
+ description: "Layer via Copy: copies the selected pixels of a pixel layer (layerId, default the active layer) to a new layer directly above it and makes it active. cut: true also removes them from the source (Layer via Cut). Needs a selection; the new layer takes the selection\u2019s shape, so select the subject\u2019s outline (select_region polygon, select_by_color) to isolate it, not its bounding box. One undo step. Returns the new layer.",
11165
11336
  inputSchema: {
11166
11337
  type: "object",
11167
11338
  properties: {
@@ -14731,6 +14902,7 @@ function layerPanels(layer, options = {}) {
14731
14902
  if (layer.mask) {
14732
14903
  panels.push({ id: "mask", title: "Mask Options" });
14733
14904
  }
14905
+ panels.push({ id: "selection", title: "Selection" });
14734
14906
  return panels;
14735
14907
  }
14736
14908
 
@@ -14967,6 +15139,7 @@ async function downloadPsd(controller, name = "image") {
14967
15139
  }
14968
15140
 
14969
15141
  // src/image-editor/layers/transform-ui.ts
15142
+ var TRANSFORM_ROTATE_HANDLE_OFFSET = 24;
14970
15143
  var CORNERS = ["nw", "ne", "se", "sw"];
14971
15144
  var SIDES = ["n", "e", "s", "w"];
14972
15145
  var CORNER_INDEX = { nw: 0, ne: 1, se: 2, sw: 3 };
@@ -15019,9 +15192,18 @@ var CURSOR = {
15019
15192
  e: "ew-resize",
15020
15193
  w: "ew-resize",
15021
15194
  reference: "move",
15195
+ rotate: "grab",
15022
15196
  inside: "move",
15023
15197
  outside: "alias"
15024
15198
  };
15199
+ function rotateHandleAt(nw, ne, centre, offset) {
15200
+ const len = Math.hypot(ne.x - nw.x, ne.y - nw.y);
15201
+ if (!(offset > 0) || !len) return void 0;
15202
+ const n = { x: (nw.x + ne.x) / 2, y: (nw.y + ne.y) / 2 };
15203
+ let dir = { x: (ne.y - nw.y) / len, y: -(ne.x - nw.x) / len };
15204
+ if (dir.x * (centre.x - n.x) + dir.y * (centre.y - n.y) > 0) dir = { x: -dir.x, y: -dir.y };
15205
+ return { x: n.x + dir.x * offset, y: n.y + dir.y * offset };
15206
+ }
15025
15207
  var meshIndex = (h) => h && h.startsWith("mesh:") ? Number(h.slice(5)) : -1;
15026
15208
  var mod = (m) => Boolean(m.ctrlKey || m.metaKey);
15027
15209
  var add = (a, b) => ({ x: a.x + b.x, y: a.y + b.y });
@@ -15054,30 +15236,38 @@ function insidePolygon(poly, p) {
15054
15236
  }
15055
15237
  return inside;
15056
15238
  }
15057
- function quadFrameOverlay(corners, handles = true) {
15239
+ function quadFrameOverlay(corners, handles = true, rotateOffset = 0) {
15058
15240
  const [nw, ne, se, sw] = corners;
15059
15241
  const mid = (a, b) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
15060
15242
  const at = { nw, ne, se, sw, n: mid(nw, ne), e: mid(ne, se), s: mid(se, sw), w: mid(sw, nw) };
15243
+ const reference = mid(nw, se);
15244
+ const rotate = handles ? rotateHandleAt(nw, ne, reference, rotateOffset) : void 0;
15061
15245
  return {
15062
15246
  outline: CORNERS.map((id) => ({ ...at[id] })),
15063
15247
  handles: handles ? [...CORNERS, ...SIDES].map((id) => ({ id, x: at[id].x, y: at[id].y })) : [],
15064
- reference: mid(nw, se)
15248
+ reference,
15249
+ ...rotate ? { rotate } : {}
15065
15250
  };
15066
15251
  }
15252
+ function nearestHandle(overlay, p, tolerance) {
15253
+ let handle = null;
15254
+ let distance = tolerance;
15255
+ const points = overlay.rotate ? [...overlay.handles, { id: "rotate", ...overlay.rotate }] : overlay.handles;
15256
+ for (const h of points) {
15257
+ const d = Math.hypot(h.x - p.x, h.y - p.y);
15258
+ if (d <= distance) {
15259
+ handle = h.id;
15260
+ distance = d;
15261
+ }
15262
+ }
15263
+ return { handle, distance };
15264
+ }
15067
15265
  function boxFrameOverlay(box, handles = true) {
15068
15266
  const { x, y, width: w, height: h } = box;
15069
15267
  return quadFrameOverlay([{ x, y }, { x: x + w, y }, { x: x + w, y: y + h }, { x, y: y + h }], handles);
15070
15268
  }
15071
15269
  function boxFrameHit(overlay, p, tolerance, band) {
15072
- let best = null;
15073
- let bestD = tolerance;
15074
- for (const h of overlay.handles) {
15075
- const d = Math.hypot(h.x - p.x, h.y - p.y);
15076
- if (d <= bestD) {
15077
- best = h.id;
15078
- bestD = d;
15079
- }
15080
- }
15270
+ const best = nearestHandle(overlay, p, tolerance).handle;
15081
15271
  if (best) return best;
15082
15272
  if (insideQuad(overlay.outline, p)) return "inside";
15083
15273
  const o = overlay.outline;
@@ -15246,12 +15436,15 @@ var FreeTransformTool = class {
15246
15436
  return { id, x: p.x, y: p.y };
15247
15437
  });
15248
15438
  const out = { outline: this.corners.map((p) => ({ x: p.x, y: p.y })), handles, reference: this.referencePoint() ?? { x: 0, y: 0 } };
15439
+ const rotate = rotateHandleAt(handles[0], handles[1], applyHomography(h, { x: s.box.x + s.box.width / 2, y: s.box.y + s.box.height / 2 }) ?? out.reference, TRANSFORM_ROTATE_HANDLE_OFFSET / this.viewScale);
15440
+ if (rotate) out.rotate = rotate;
15249
15441
  if (this.modeValue === "warp" && this.mesh) {
15250
15442
  const m = this.mesh;
15251
15443
  const at = (p) => applyHomography(h, p) ?? p;
15252
15444
  const pts = m.points.map(at);
15253
15445
  const edit = warpMeshEditPoints(m);
15254
15446
  out.handles = [];
15447
+ delete out.rotate;
15255
15448
  out.outline = warpMeshGridCurves({ ...m, points: pts }).length ? this.meshOutline(pts, m) : out.outline;
15256
15449
  out.warp = {
15257
15450
  curves: warpMeshGridCurves({ ...m, points: pts }),
@@ -15293,15 +15486,7 @@ var FreeTransformTool = class {
15293
15486
  if (best2 >= 0) return `mesh:${best2}`;
15294
15487
  return insidePolygon(o.outline, p) ? "inside" : "outside";
15295
15488
  }
15296
- let best = null;
15297
- let bestD = tolerance;
15298
- for (const h of o.handles) {
15299
- const d = Math.hypot(h.x - p.x, h.y - p.y);
15300
- if (d <= bestD) {
15301
- best = h.id;
15302
- bestD = d;
15303
- }
15304
- }
15489
+ let { handle: best, distance: bestD } = nearestHandle(o, p, tolerance);
15305
15490
  if (Math.hypot(o.reference.x - p.x, o.reference.y - p.y) < bestD) best = "reference";
15306
15491
  if (best) return best;
15307
15492
  return insideQuad(o.outline, p) ? "inside" : "outside";
@@ -15383,7 +15568,7 @@ var FreeTransformTool = class {
15383
15568
  const handle = d.handle;
15384
15569
  const isCorner = handle in CORNER_INDEX;
15385
15570
  const isSide = handle in SIDE_CORNERS;
15386
- const rotating = handle === "outside" || this.modeValue === "rotate" && (isCorner || isSide);
15571
+ const rotating = handle === "outside" || handle === "rotate" || this.modeValue === "rotate" && (isCorner || isSide);
15387
15572
  if (handle === "inside") {
15388
15573
  if (m.shiftKey) delta = Math.abs(delta.x) >= Math.abs(delta.y) ? { x: delta.x, y: 0 } : { x: 0, y: delta.y };
15389
15574
  return map((q2) => add(q2, delta));
@@ -15852,6 +16037,7 @@ export {
15852
16037
  LayerCompositor,
15853
16038
  LayerEffectError,
15854
16039
  LayerOperationError,
16040
+ MARCHING_ANTS_STEP_MS,
15855
16041
  MAX_BATCH,
15856
16042
  MAX_BRUSH_SIZE,
15857
16043
  MAX_CANVAS_BACKING_SIDE,
@@ -15961,6 +16147,7 @@ export {
15961
16147
  TONE_RANGES,
15962
16148
  TONE_RANGE_PRESETS,
15963
16149
  TRANSFORM_ANCHORS,
16150
+ TRANSFORM_ROTATE_HANDLE_OFFSET,
15964
16151
  TextEditSession,
15965
16152
  TileMedium,
15966
16153
  TilePixelSource,
@@ -16130,9 +16317,11 @@ export {
16130
16317
  createImagePlaybackLocator,
16131
16318
  createImagePsdTools,
16132
16319
  createImageRetouchTools,
16320
+ createImageSegmentationToolProvider,
16133
16321
  createImageSelectionTools,
16134
16322
  createImageShapeTools,
16135
16323
  createImageSmartObjectTools,
16324
+ createImageSubjectSelectionTool,
16136
16325
  createImageTransformTools,
16137
16326
  createImageTypeTools,
16138
16327
  createMetricTextShaper,
@@ -16573,6 +16762,8 @@ export {
16573
16762
  scalePaintGradient,
16574
16763
  seamlessClone,
16575
16764
  seedFromString,
16765
+ segmentToSelection,
16766
+ segmentsToSelection,
16576
16767
  selectionArea,
16577
16768
  selectionFromCoverage,
16578
16769
  selectionFromPixels,
@@ -16630,6 +16821,7 @@ export {
16630
16821
  srgbEncode,
16631
16822
  srgbToLinear,
16632
16823
  standardBlendMode,
16824
+ startMarchingAnts,
16633
16825
  stepSlotTs,
16634
16826
  strokeGrainCatch,
16635
16827
  styleAt,
@@ -123,6 +123,7 @@ export interface LayerPanelsOptions {
123
123
  *
124
124
  * `properties` ("Layer Properties") is first for every layer and includes the layer's Layer Style
125
125
  * (drop shadow, stroke, glow…: `layerEffectsPanel`) for layers that can take one, with or without a
126
- * provider. There is no separate `effects` (Layer Effects) or `smart-filters` panel.
126
+ * provider. There is no separate `effects` (Layer Effects) or `smart-filters` panel. `selection`
127
+ * is last for every layer, and with no active layer follows `canvas-size`.
127
128
  */
128
129
  export declare function layerPanels(layer: ImageLayer | null, options?: LayerPanelsOptions): LayerPanelDescriptor[];
@@ -123,6 +123,7 @@ export interface LayerPanelsOptions {
123
123
  *
124
124
  * `properties` ("Layer Properties") is first for every layer and includes the layer's Layer Style
125
125
  * (drop shadow, stroke, glow…: `layerEffectsPanel`) for layers that can take one, with or without a
126
- * provider. There is no separate `effects` (Layer Effects) or `smart-filters` panel.
126
+ * provider. There is no separate `effects` (Layer Effects) or `smart-filters` panel. `selection`
127
+ * is last for every layer, and with no active layer follows `canvas-size`.
127
128
  */
128
129
  export declare function layerPanels(layer: ImageLayer | null, options?: LayerPanelsOptions): LayerPanelDescriptor[];
@@ -10,8 +10,9 @@
10
10
  * proportional; side: scale one axis;
11
11
  * - Mod (Ctrl / Cmd) + corner: distort that corner; Mod + side: skew along the side (Alt: about
12
12
  * the centre); Mod + Alt + Shift + corner: perspective;
13
- * - outside the box: rotate about the reference point (Shift: 15° steps); inside: move (Shift:
14
- * along one axis); the reference point: move it;
13
+ * - the rotate handle on its stem above the top edge, or outside the box: rotate about the
14
+ * reference point (Shift: 15° steps); inside: move (Shift: along one axis); the reference
15
+ * point: move it;
15
16
  * - the mode switch (rotate, skew, distort, perspective) makes every handle do that;
16
17
  * - Warp: drag a mesh anchor or handle (the grid stays smooth across its lines), or anywhere
17
18
  * inside to pull the surface; presets and the grid size come from the options bar. The warp is
@@ -26,7 +27,9 @@ import type { Interpolation } from '../../raster/transform.cjs';
26
27
  import type { CropBox, ImagePoint } from '../types.cjs';
27
28
  import type { ShortcutEvent } from './paint-ui.cjs';
28
29
  /** What a pointer is over (`mesh:<index>`: a warp mesh control point). */
29
- export type TransformHandle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'reference' | 'inside' | 'outside' | `mesh:${number}`;
30
+ export type TransformHandle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'rotate' | 'reference' | 'inside' | 'outside' | `mesh:${number}`;
31
+ /** Screen pixels from the top edge's midpoint to the rotate handle (as the vector editor's). */
32
+ export declare const TRANSFORM_ROTATE_HANDLE_OFFSET = 24;
30
33
  export interface TransformModifiers {
31
34
  shiftKey?: boolean;
32
35
  altKey?: boolean;
@@ -115,6 +118,8 @@ export interface TransformOverlay {
115
118
  y: number;
116
119
  }>;
117
120
  reference: ImagePoint;
121
+ /** The rotate handle, on a stem out from the top edge's midpoint (none without handles or in Warp mode). */
122
+ rotate?: ImagePoint;
118
123
  /** In Warp mode: the mesh's grid lines as cubics, its anchors, and its handles with the anchor each belongs to. */
119
124
  warp?: {
120
125
  curves: ImagePoint[][];
@@ -135,14 +140,17 @@ export interface FreeTransformToolOptions {
135
140
  * A Free Transform frame with no transform open (a host's selected-layer frame) from where its
136
141
  * corners lie (top-left, top-right, bottom-right, bottom-left, as `getLayerFrame` gives them): the
137
142
  * outline, the eight handles (none with `handles = false`, e.g. for a layer locked in place) and the
138
- * centre as the reference point. Same shape and handle order as `FreeTransformTool.overlay`.
143
+ * centre as the reference point. With handles and a `rotateOffset` (document pixels, usually
144
+ * `TRANSFORM_ROTATE_HANDLE_OFFSET / scale`) it also has the rotate handle. Same shape and handle
145
+ * order as `FreeTransformTool.overlay`.
139
146
  */
140
- export declare function quadFrameOverlay(corners: readonly ImagePoint[], handles?: boolean): TransformOverlay;
147
+ export declare function quadFrameOverlay(corners: readonly ImagePoint[], handles?: boolean, rotateOffset?: number): TransformOverlay;
141
148
  /** `quadFrameOverlay` for an axis-aligned box. */
142
149
  export declare function boxFrameOverlay(box: CropBox, handles?: boolean): TransformOverlay;
143
150
  /**
144
- * What a document point is over on a frame from `quadFrameOverlay`: a handle (within `tolerance`),
145
- * `inside`, `outside` within `band` of the outline (where a drag rotates), or `null` beyond that.
151
+ * What a document point is over on a frame from `quadFrameOverlay`: a handle or the rotate handle
152
+ * (within `tolerance`), `inside`, `outside` within `band` of the outline (where a drag rotates), or
153
+ * `null` beyond that.
146
154
  */
147
155
  export declare function boxFrameHit(overlay: TransformOverlay, p: ImagePoint, tolerance: number, band: number): TransformHandle | null;
148
156
  /**
@@ -10,8 +10,9 @@
10
10
  * proportional; side: scale one axis;
11
11
  * - Mod (Ctrl / Cmd) + corner: distort that corner; Mod + side: skew along the side (Alt: about
12
12
  * the centre); Mod + Alt + Shift + corner: perspective;
13
- * - outside the box: rotate about the reference point (Shift: 15° steps); inside: move (Shift:
14
- * along one axis); the reference point: move it;
13
+ * - the rotate handle on its stem above the top edge, or outside the box: rotate about the
14
+ * reference point (Shift: 15° steps); inside: move (Shift: along one axis); the reference
15
+ * point: move it;
15
16
  * - the mode switch (rotate, skew, distort, perspective) makes every handle do that;
16
17
  * - Warp: drag a mesh anchor or handle (the grid stays smooth across its lines), or anywhere
17
18
  * inside to pull the surface; presets and the grid size come from the options bar. The warp is
@@ -26,7 +27,9 @@ import type { Interpolation } from '../../raster/transform.js';
26
27
  import type { CropBox, ImagePoint } from '../types.js';
27
28
  import type { ShortcutEvent } from './paint-ui.js';
28
29
  /** What a pointer is over (`mesh:<index>`: a warp mesh control point). */
29
- export type TransformHandle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'reference' | 'inside' | 'outside' | `mesh:${number}`;
30
+ export type TransformHandle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'rotate' | 'reference' | 'inside' | 'outside' | `mesh:${number}`;
31
+ /** Screen pixels from the top edge's midpoint to the rotate handle (as the vector editor's). */
32
+ export declare const TRANSFORM_ROTATE_HANDLE_OFFSET = 24;
30
33
  export interface TransformModifiers {
31
34
  shiftKey?: boolean;
32
35
  altKey?: boolean;
@@ -115,6 +118,8 @@ export interface TransformOverlay {
115
118
  y: number;
116
119
  }>;
117
120
  reference: ImagePoint;
121
+ /** The rotate handle, on a stem out from the top edge's midpoint (none without handles or in Warp mode). */
122
+ rotate?: ImagePoint;
118
123
  /** In Warp mode: the mesh's grid lines as cubics, its anchors, and its handles with the anchor each belongs to. */
119
124
  warp?: {
120
125
  curves: ImagePoint[][];
@@ -135,14 +140,17 @@ export interface FreeTransformToolOptions {
135
140
  * A Free Transform frame with no transform open (a host's selected-layer frame) from where its
136
141
  * corners lie (top-left, top-right, bottom-right, bottom-left, as `getLayerFrame` gives them): the
137
142
  * outline, the eight handles (none with `handles = false`, e.g. for a layer locked in place) and the
138
- * centre as the reference point. Same shape and handle order as `FreeTransformTool.overlay`.
143
+ * centre as the reference point. With handles and a `rotateOffset` (document pixels, usually
144
+ * `TRANSFORM_ROTATE_HANDLE_OFFSET / scale`) it also has the rotate handle. Same shape and handle
145
+ * order as `FreeTransformTool.overlay`.
139
146
  */
140
- export declare function quadFrameOverlay(corners: readonly ImagePoint[], handles?: boolean): TransformOverlay;
147
+ export declare function quadFrameOverlay(corners: readonly ImagePoint[], handles?: boolean, rotateOffset?: number): TransformOverlay;
141
148
  /** `quadFrameOverlay` for an axis-aligned box. */
142
149
  export declare function boxFrameOverlay(box: CropBox, handles?: boolean): TransformOverlay;
143
150
  /**
144
- * What a document point is over on a frame from `quadFrameOverlay`: a handle (within `tolerance`),
145
- * `inside`, `outside` within `band` of the outline (where a drag rotates), or `null` beyond that.
151
+ * What a document point is over on a frame from `quadFrameOverlay`: a handle or the rotate handle
152
+ * (within `tolerance`), `inside`, `outside` within `band` of the outline (where a drag rotates), or
153
+ * `null` beyond that.
146
154
  */
147
155
  export declare function boxFrameHit(overlay: TransformOverlay, p: ImagePoint, tolerance: number, band: number): TransformHandle | null;
148
156
  /**