@pixerate/editor 0.255.0 → 0.256.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.
@@ -370,8 +370,8 @@ function normalizePaper(raw = {}, base) {
370
370
  ...readOffset(r.offset, b.offset)
371
371
  };
372
372
  }
373
- function hexToRgb8(hex) {
374
- const n = parseInt(hex.slice(1, 7), 16);
373
+ function hexToRgb8(hex2) {
374
+ const n = parseInt(hex2.slice(1, 7), 16);
375
375
  return [n >> 16 & 255, n >> 8 & 255, n & 255];
376
376
  }
377
377
  function renderPaper(paper, rect, grain = new PaperGrain(paper), light) {
@@ -1929,8 +1929,8 @@ function sampleArt(art, x, y, out) {
1929
1929
  }
1930
1930
 
1931
1931
  // src/paint-editor/stickers.ts
1932
- function hexRgb(hex) {
1933
- const n = parseInt(hex.slice(1, 7), 16);
1932
+ function hexRgb(hex2) {
1933
+ const n = parseInt(hex2.slice(1, 7), 16);
1934
1934
  return [(n >> 16 & 255) / 255, (n >> 8 & 255) / 255, (n & 255) / 255];
1935
1935
  }
1936
1936
  function stickerRect(s) {
@@ -2014,8 +2014,8 @@ function drawArtSticker(medium, s, art) {
2014
2014
  medium.deposit(rect, cov, s.opacity, [r, g, b], null, field);
2015
2015
  return rect;
2016
2016
  }
2017
- function jitterColor(hex, amount, seed, i) {
2018
- const [r, g, b] = hexRgb(hex);
2017
+ function jitterColor(hex2, amount, seed, i) {
2018
+ const [r, g, b] = hexRgb(hex2);
2019
2019
  const lch = oklabToOklch(rgbToOklab({ r, g, b, a: 1 }));
2020
2020
  const l = Math.min(1, Math.max(0, lch.l + (rand01(seed, i, 61) - 0.5) * amount * 0.3));
2021
2021
  const h = lch.h + (rand01(seed, i, 63) - 0.5) * amount * 80;
@@ -2897,7 +2897,7 @@ var PaintStrokeSession = class _PaintStrokeSession {
2897
2897
  const init = this.init;
2898
2898
  const grey = Math.round(init.brush.light.amount * 255);
2899
2899
  const g = grey / 255;
2900
- const hex = "#" + grey.toString(16).padStart(2, "0").repeat(3);
2900
+ const hex2 = "#" + grey.toString(16).padStart(2, "0").repeat(3);
2901
2901
  const host = {
2902
2902
  update: () => {
2903
2903
  },
@@ -2906,14 +2906,14 @@ var PaintStrokeSession = class _PaintStrokeSession {
2906
2906
  }
2907
2907
  };
2908
2908
  const effect = init.effect ? { source: init.effect.source, pixels: { ...init.effect.pixels, data: greyPixels(init.effect.pixels.data, grey) } } : null;
2909
- const paint = init.paint ? { ...init.paint, stops: init.paint.stops.map((st) => ({ ...st, color: hex })) } : null;
2909
+ const paint = init.paint ? { ...init.paint, stops: init.paint.stops.map((st) => ({ ...st, color: hex2 })) } : null;
2910
2910
  return new _PaintStrokeSession(host, {
2911
2911
  ...init,
2912
2912
  surface: lightToShadow(light),
2913
2913
  lockTransparency: false,
2914
2914
  brush: lightTwinBrush(init.brush),
2915
2915
  color: [grey, grey, grey, 255],
2916
- colorHex: hex,
2916
+ colorHex: hex2,
2917
2917
  secondary: [grey, grey, grey, 255],
2918
2918
  inputFilter: void 0,
2919
2919
  mixerLoad: { reservoir: init.mixerLoad.reservoir ? [g, g, g] : null, amount: init.mixerLoad.amount, pickup: null },
@@ -4461,8 +4461,8 @@ var PaintWetLayers = class {
4461
4461
  this.gpu = null;
4462
4462
  }
4463
4463
  };
4464
- function wetPigmentOf(hex, body) {
4465
- const [r, g, b] = hexToRgb8(hex);
4464
+ function wetPigmentOf(hex2, body) {
4465
+ const [r, g, b] = hexToRgb8(hex2);
4466
4466
  return Array.from(pigmentOf([r / 255, g / 255, b / 255], body));
4467
4467
  }
4468
4468
 
@@ -4934,8 +4934,8 @@ var MixingPad = class {
4934
4934
  A += a;
4935
4935
  }
4936
4936
  if (A === 0) return null;
4937
- const hex = (v) => Math.round(v / A).toString(16).padStart(2, "0");
4938
- return `#${hex(R)}${hex(G)}${hex(B)}`;
4937
+ const hex2 = (v) => Math.round(v / A).toString(16).padStart(2, "0");
4938
+ return `#${hex2(R)}${hex2(G)}${hex2(B)}`;
4939
4939
  }
4940
4940
  clear() {
4941
4941
  this.surface = emptySurface(4);
@@ -4996,8 +4996,8 @@ var MixingPad = class {
4996
4996
  paints2.map((p) => p.parts)
4997
4997
  );
4998
4998
  const core = normalizePaintBrush({ tip: { ...PAD_BLOB.tip, size: R * 1.1, hardness: 0.55 } }, PAD_BLOB);
4999
- const hex = "#" + mixed.map((v) => Math.round(v * 255).toString(16).padStart(2, "0")).join("");
5000
- this.run(core, [{ x: cx, y: cy }], hex);
4999
+ const hex2 = "#" + mixed.map((v) => Math.round(v * 255).toString(16).padStart(2, "0")).join("");
5000
+ this.run(core, [{ x: cx, y: cy }], hex2);
5001
5001
  return { color: this.pick(cx, cy, 2), bounds };
5002
5002
  }
5003
5003
  /** The pad's pixels as a PNG data URL (with alpha), for saving. */
@@ -5699,8 +5699,8 @@ function parseColor(value, what = "color") {
5699
5699
  if (typeof value !== "string" || !HEX3.test(value.trim())) throw new PaintEditorError(`${what} is ${JSON.stringify(value)}; use #rrggbb, e.g. "#2a4b8d".`);
5700
5700
  return value.trim().toLowerCase();
5701
5701
  }
5702
- function rgbaOf(hex) {
5703
- const [r, g, b] = hexToRgb8(hex);
5702
+ function rgbaOf(hex2) {
5703
+ const [r, g, b] = hexToRgb8(hex2);
5704
5704
  return [r, g, b, 255];
5705
5705
  }
5706
5706
  function rgbToHex(r, g, b) {
@@ -5753,6 +5753,13 @@ var PaintEditorController = class _PaintEditorController {
5753
5753
  stickerPreview = null;
5754
5754
  encodeImage;
5755
5755
  listeners = /* @__PURE__ */ new Set();
5756
+ commitListeners = /* @__PURE__ */ new Set();
5757
+ /** Origins set by `withOrigin`, innermost last. */
5758
+ origins = [];
5759
+ /** `group` in progress: commits fold into one step, recorded when it ends. */
5760
+ grouping = null;
5761
+ /** `previewPaint` in progress: nothing is emitted or recorded. */
5762
+ previewing = false;
5756
5763
  version = 0;
5757
5764
  cached = null;
5758
5765
  session = null;
@@ -5860,7 +5867,125 @@ var PaintEditorController = class _PaintEditorController {
5860
5867
  this.listeners.add(listener);
5861
5868
  return () => this.listeners.delete(listener);
5862
5869
  }
5870
+ /** Hears each undo step recorded, merged, undone or redone, with who made it. Returns an unsubscribe function. */
5871
+ onCommit(listener) {
5872
+ this.commitListeners.add(listener);
5873
+ return () => {
5874
+ this.commitListeners.delete(listener);
5875
+ };
5876
+ }
5877
+ notifyCommit(event) {
5878
+ if (this.previewing) return;
5879
+ for (const l of [...this.commitListeners]) l(event);
5880
+ }
5881
+ /** The origin steps are recorded with now: the innermost `withOrigin`, else `user`. */
5882
+ getOrigin() {
5883
+ return this.origins[this.origins.length - 1] ?? "user";
5884
+ }
5885
+ /**
5886
+ * Records the steps `fn` makes as made by `origin` (`agent:<id>` for an agent). When `fn` returns
5887
+ * a promise, the origin holds until it settles. Satisfies `createOriginMiddleware`'s history target,
5888
+ * so agent tool calls are recorded as the calling agent's.
5889
+ */
5890
+ withOrigin(origin, fn) {
5891
+ this.origins.push(origin);
5892
+ let pending = false;
5893
+ const pop = () => {
5894
+ const i = this.origins.lastIndexOf(origin);
5895
+ if (i >= 0) this.origins.splice(i, 1);
5896
+ };
5897
+ try {
5898
+ const result = fn();
5899
+ if (result && typeof result.then === "function") {
5900
+ pending = true;
5901
+ return Promise.resolve(result).finally(pop);
5902
+ }
5903
+ return result;
5904
+ } finally {
5905
+ if (!pending) pop();
5906
+ }
5907
+ }
5908
+ /**
5909
+ * Runs `fn` as one undo step named `label`: every change it makes (strokes, fills, new layers) is
5910
+ * undone together. Wet paint can't be grouped. Nested groups join the outer one. If `fn` throws,
5911
+ * its changes are put back and nothing is recorded.
5912
+ */
5913
+ group(label, fn) {
5914
+ if (this.grouping) return fn();
5915
+ this.requireIdle("grouping changes");
5916
+ const before = this.doc;
5917
+ const revision = this.docRevision;
5918
+ this.grouping = { label, before, changed: false };
5919
+ let result;
5920
+ try {
5921
+ result = fn();
5922
+ } catch (e) {
5923
+ this.grouping = null;
5924
+ if (this.doc !== before) {
5925
+ this.doc = before;
5926
+ this.docRevision = revision + 1;
5927
+ this.emit();
5928
+ }
5929
+ throw e;
5930
+ }
5931
+ const g = this.grouping;
5932
+ this.grouping = null;
5933
+ if (g.changed && this.doc !== before) {
5934
+ this.undoStack.push({ label, origin: this.getOrigin(), before, after: this.doc });
5935
+ if (this.undoStack.length > this.historyLimit) this.undoStack.shift();
5936
+ this.redoStack = [];
5937
+ this.notifyCommit({ kind: "execute", label, origin: this.getOrigin() });
5938
+ this.emit();
5939
+ }
5940
+ return result;
5941
+ }
5942
+ /**
5943
+ * What `fn` would paint, without changing anything: it runs on a new empty layer (above the
5944
+ * active layer, another one, or at the bottom), and the layer's pixels and the picture with them
5945
+ * come back while the document, history and listeners stay as they were. `fn` gets that layer's id and must paint on it with dry brushes (strokes, replays and
5946
+ * fills); wet paint can't be previewed. For ghost paint an agent suggests (`createPaintProposals`).
5947
+ */
5948
+ previewPaint(fn, placement = {}) {
5949
+ this.requireIdle("previewing");
5950
+ if (this.previewing) throw new PaintEditorError("A preview is already running.");
5951
+ const saved = {
5952
+ doc: this.doc,
5953
+ undo: [...this.undoStack],
5954
+ redo: this.redoStack,
5955
+ revision: this.docRevision,
5956
+ strokeCounter: this.strokeCounter,
5957
+ seedCounter: this.seedCounter,
5958
+ mixerLoad: this.mixerLoad
5959
+ };
5960
+ this.previewing = true;
5961
+ try {
5962
+ const layer = this.addLayer({ name: "Preview", ...placement.aboveId ? { aboveId: placement.aboveId } : {} });
5963
+ if (placement.bottom) this.moveLayer(layer.id, 0, null);
5964
+ fn(layer.id);
5965
+ if (this.wetOp || this.pendingWetEvents.length) throw new PaintEditorError("Wet paint can't be previewed; suggest it with a dry brush.");
5966
+ const found = findLayer(this.doc.layers, layer.id)?.layer;
5967
+ const surface = found && isPixelLayer(found) ? found.surface : null;
5968
+ const bounds = surface ? contentBounds(surface) : null;
5969
+ const box = bounds ? clipToCanvas(bounds, this.doc.canvas) : null;
5970
+ if (!surface || !box) return { bounds: null, pixels: new Uint8ClampedArray(0), composite: new Uint8ClampedArray(0) };
5971
+ const composite = this.exportCompositor.composite(this.renderLayers({ wet: "display" }), box, this.sources());
5972
+ return { bounds: box, pixels: readRegion(surface, box), composite };
5973
+ } finally {
5974
+ this.wetOp = null;
5975
+ this.pendingWetEvents = [];
5976
+ this.doc = saved.doc;
5977
+ this.undoStack = saved.undo;
5978
+ this.redoStack = saved.redo;
5979
+ this.docRevision = saved.revision;
5980
+ this.strokeCounter = saved.strokeCounter;
5981
+ this.seedCounter = saved.seedCounter;
5982
+ this.mixerLoad = saved.mixerLoad;
5983
+ this.cached = null;
5984
+ this.previewing = false;
5985
+ }
5986
+ }
5863
5987
  emit(dirty = "all") {
5988
+ if (this.previewing) return;
5864
5989
  if (dirty === "all" || this.dirty === "all") this.dirty = "all";
5865
5990
  else this.dirty.push(...dirty);
5866
5991
  this.version++;
@@ -5943,7 +6068,13 @@ var PaintEditorController = class _PaintEditorController {
5943
6068
  if (this.session) throw new PaintEditorError(`A stroke is in progress; end or cancel it before ${what}.`);
5944
6069
  }
5945
6070
  commit(label, next, before = this.doc) {
5946
- const entry = { label, before, after: next };
6071
+ if (this.grouping) {
6072
+ this.grouping.changed = true;
6073
+ this.doc = next;
6074
+ this.docRevision++;
6075
+ return;
6076
+ }
6077
+ const entry = { label, origin: this.getOrigin(), before, after: next };
5947
6078
  if (this.wetOp) {
5948
6079
  entry.wet = { before: this.wetOp.before, after: this.wet.snapshot(), lost: false };
5949
6080
  this.wetOp = null;
@@ -5954,6 +6085,7 @@ var PaintEditorController = class _PaintEditorController {
5954
6085
  this.doc = next;
5955
6086
  this.docRevision++;
5956
6087
  this.trimWetHistory();
6088
+ this.notifyCommit({ kind: "execute", label, origin: entry.origin });
5957
6089
  this.emit();
5958
6090
  }
5959
6091
  /**
@@ -5962,7 +6094,7 @@ var PaintEditorController = class _PaintEditorController {
5962
6094
  */
5963
6095
  commitOrAmend(label, next, commitToHistory) {
5964
6096
  const last = this.undoStack[this.undoStack.length - 1];
5965
- if (commitToHistory || !last || last.label !== label || last.wet || this.wetOp) {
6097
+ if (commitToHistory || !last || last.label !== label || last.wet || this.wetOp || this.grouping) {
5966
6098
  this.commit(label, next);
5967
6099
  return;
5968
6100
  }
@@ -5970,6 +6102,7 @@ var PaintEditorController = class _PaintEditorController {
5970
6102
  this.redoStack = [];
5971
6103
  this.doc = next;
5972
6104
  this.docRevision++;
6105
+ this.notifyCommit({ kind: "merge", label, origin: last.origin });
5973
6106
  this.emit();
5974
6107
  }
5975
6108
  /** Keeps the wet snapshots in history within the wet undo memory, dropping the oldest. */
@@ -6039,6 +6172,7 @@ var PaintEditorController = class _PaintEditorController {
6039
6172
  this.wet.restore(e.wet.lost ? null : e.wet.before);
6040
6173
  if (paper !== this.doc.paper) this.wet.paperChanged();
6041
6174
  }
6175
+ this.notifyCommit({ kind: "undo", label: e.label, origin: this.getOrigin() });
6042
6176
  this.emit();
6043
6177
  return e.label;
6044
6178
  }
@@ -6057,6 +6191,7 @@ var PaintEditorController = class _PaintEditorController {
6057
6191
  this.wet.restore(e.wet.lost ? null : e.wet.after);
6058
6192
  if (paper !== this.doc.paper) this.wet.paperChanged();
6059
6193
  }
6194
+ this.notifyCommit({ kind: "redo", label: e.label, origin: this.getOrigin() });
6060
6195
  this.emit();
6061
6196
  return e.label;
6062
6197
  }
@@ -7263,6 +7398,7 @@ var PaintEditorController = class _PaintEditorController {
7263
7398
  return l;
7264
7399
  })();
7265
7400
  const brush = this.strokeBrush(init);
7401
+ if (this.previewing && isWetBrush(brush)) throw new PaintEditorError(`Wet paint can't be previewed; the ${brush.name} brush paints wet media. Suggest it with a dry brush.`);
7266
7402
  const effect = this.strokeEffect(init, brush);
7267
7403
  if (brush.light.amount > 0) {
7268
7404
  if (brush.engine === "sticker") throw new PaintEditorError(`The ${brush.name} brush sprays stickers, which don't take light yet; paint light with a dab or particle light brush (glow-gel, neon-marker; list_brush_presets lists them), or set light.amount to 0 (set_brush).`);
@@ -9566,6 +9702,13 @@ function resampleInto(src, rect, width, height) {
9566
9702
  }
9567
9703
  return { width, height, data: out };
9568
9704
  }
9705
+ function clipToCanvas(rect, canvas) {
9706
+ const x0 = Math.max(0, rect.x);
9707
+ const y0 = Math.max(0, rect.y);
9708
+ const x1 = Math.min(canvas.width, rect.x + rect.width);
9709
+ const y1 = Math.min(canvas.height, rect.y + rect.height);
9710
+ return x1 > x0 && y1 > y0 ? { x: x0, y: y0, width: x1 - x0, height: y1 - y0 } : null;
9711
+ }
9569
9712
 
9570
9713
  // src/paint-editor/presence.ts
9571
9714
  var PaintPresence = class extends PresenceStore {
@@ -10786,8 +10929,8 @@ var POINTS = {
10786
10929
  }
10787
10930
  };
10788
10931
  function loadResult(load) {
10789
- const hex = (c) => c ? "#" + c.map((v) => Math.round(Math.min(1, Math.max(0, v)) * 255).toString(16).padStart(2, "0")).join("") : null;
10790
- return { reservoir: hex(load.reservoir), amount: Math.round(load.amount * 1e3) / 1e3, pickup: hex(load.pickup) };
10932
+ const hex2 = (c) => c ? "#" + c.map((v) => Math.round(Math.min(1, Math.max(0, v)) * 255).toString(16).padStart(2, "0")).join("") : null;
10933
+ return { reservoir: hex2(load.reservoir), amount: Math.round(load.amount * 1e3) / 1e3, pickup: hex2(load.pickup) };
10791
10934
  }
10792
10935
  function createPaintEditorToolProvider(controller, options = {}) {
10793
10936
  const c = typeof controller === "function" ? lazyController(controller) : controller;
@@ -12405,6 +12548,392 @@ function createPaintEditorDocument(controller, options = {}) {
12405
12548
  readOnly: () => controller.busyReason() ?? false
12406
12549
  });
12407
12550
  }
12551
+
12552
+ // src/paint-editor/collaboration.ts
12553
+ var LAYER_FIELDS = ["name", "visible", "opacity", "blend"];
12554
+ function createPaintChangeFeed(controller, options = {}) {
12555
+ const now = options.now ?? (() => Date.now());
12556
+ const listeners = /* @__PURE__ */ new Set();
12557
+ let seq = 0;
12558
+ let pending;
12559
+ const take = () => {
12560
+ const state = controller.getState();
12561
+ const doc = state.document;
12562
+ const layers = /* @__PURE__ */ new Map();
12563
+ for (const l of controller.listLayers()) layers.set(l.id, { name: l.name, visible: l.visible, opacity: l.opacity, blend: l.blend });
12564
+ return { doc, strokes: new Map(doc.strokes.map((s) => [s.id, s])), events: doc.paintEvents?.length ?? 0, layers, stroking: state.stroking };
12565
+ };
12566
+ let last = take();
12567
+ const check = () => {
12568
+ const state = controller.getState();
12569
+ if (state.document === last.doc && state.stroking === last.stroking) return null;
12570
+ const next = take();
12571
+ const addedStrokes = [...next.strokes.values()].filter((s) => !last.strokes.has(s.id));
12572
+ const removedStrokes = [...last.strokes.values()].filter((s) => !next.strokes.has(s.id));
12573
+ const allEvents = state.document.paintEvents ?? [];
12574
+ const events = next.events > last.events ? allEvents.slice(last.events) : [];
12575
+ const addedLayers = [];
12576
+ const changedLayers = [];
12577
+ for (const [id, l] of next.layers) {
12578
+ const before = last.layers.get(id);
12579
+ if (!before) addedLayers.push({ id, name: String(l.name) });
12580
+ else {
12581
+ const fields = LAYER_FIELDS.filter((f) => before[f] !== l[f]);
12582
+ if (fields.length) changedLayers.push({ id, name: String(l.name), fields });
12583
+ }
12584
+ }
12585
+ const removedLayers = [...last.layers].filter(([id]) => !next.layers.has(id)).map(([id, l]) => ({ id, name: String(l.name) }));
12586
+ const gestureStarted = next.stroking && !last.stroking;
12587
+ const gestureEnded = !next.stroking && last.stroking;
12588
+ last = next;
12589
+ const edited = addedStrokes.length + removedStrokes.length + events.length + addedLayers.length + removedLayers.length + changedLayers.length > 0;
12590
+ if (!edited && !gestureStarted && !gestureEnded) return null;
12591
+ const commit = edited ? pending : void 0;
12592
+ if (edited) pending = void 0;
12593
+ const change = {
12594
+ seq: ++seq,
12595
+ origin: commit?.origin ?? "user",
12596
+ ...commit ? { commit } : {},
12597
+ addedStrokes,
12598
+ removedStrokes,
12599
+ events,
12600
+ addedLayers,
12601
+ removedLayers,
12602
+ changedLayers,
12603
+ inGesture: next.stroking,
12604
+ ...gestureStarted ? { gestureStarted: true } : {},
12605
+ ...gestureEnded ? { gestureEnded: true } : {},
12606
+ at: now()
12607
+ };
12608
+ listeners.forEach((l) => l(change));
12609
+ return change;
12610
+ };
12611
+ const offCommit = controller.onCommit((event) => {
12612
+ pending = event;
12613
+ });
12614
+ const offState = controller.subscribe(() => {
12615
+ check();
12616
+ });
12617
+ return {
12618
+ check,
12619
+ subscribe(listener) {
12620
+ listeners.add(listener);
12621
+ return () => {
12622
+ listeners.delete(listener);
12623
+ };
12624
+ },
12625
+ reset() {
12626
+ last = take();
12627
+ pending = void 0;
12628
+ },
12629
+ dispose() {
12630
+ offCommit();
12631
+ offState();
12632
+ listeners.clear();
12633
+ }
12634
+ };
12635
+ }
12636
+ var HEX4 = /^#[0-9a-f]{6}$/i;
12637
+ var DRY_ENGINES = /* @__PURE__ */ new Set(["dab", "particle", "bristle", "mixer"]);
12638
+ function suggestionBrushRefusal(brush) {
12639
+ if (!DRY_ENGINES.has(brush.engine)) return `The ${brush.name} brush (${brush.engine}) can't paint a suggestion: suggestions are dry paint on a layer of their own. Use a dab, particle, bristle or mixer brush.`;
12640
+ if (brush.behavior.pickup > 0) return `The ${brush.name} brush picks up paint, which a suggestion on its own layer can't; choose a brush without pickup.`;
12641
+ return null;
12642
+ }
12643
+ function createPaintProposals(options) {
12644
+ const { controller } = options;
12645
+ const now = options.now ?? (() => Date.now());
12646
+ const maxPoints = options.maxPoints ?? 4e3;
12647
+ let seq = 0;
12648
+ const makeId = options.idGenerator ?? (() => `paint-proposal-${++seq}`);
12649
+ let state = { proposals: [] };
12650
+ const listeners = /* @__PURE__ */ new Set();
12651
+ const set = (proposals) => {
12652
+ state = { proposals };
12653
+ listeners.forEach((l) => l(state));
12654
+ };
12655
+ const find = (id) => {
12656
+ const p = state.proposals.find((x) => x.id === id);
12657
+ if (!p) throw new PaintEditorError(`No pending paint proposal "${id}". Call list_paint_proposals for the ids.`);
12658
+ return p;
12659
+ };
12660
+ const strokeIds = () => new Set(controller.getStrokes().map((s) => s.id));
12661
+ const validate = (input) => {
12662
+ if (!input.title?.trim()) throw new PaintEditorError("A proposal needs a title: what it adds, in a few words.");
12663
+ if (!Array.isArray(input.ops) || !input.ops.length) throw new PaintEditorError("A proposal needs at least one op (stroke, wash or echo).");
12664
+ const place = input.place ?? "top";
12665
+ if (place === "above") {
12666
+ if (!input.layerId) throw new PaintEditorError('place "above" needs layerId: the layer to put the suggestion above (list_layers).');
12667
+ if (!controller.listLayers().some((l) => l.id === input.layerId)) throw new PaintEditorError(`Unknown layer "${input.layerId}". Call list_layers for the ids.`);
12668
+ }
12669
+ const known = strokeIds();
12670
+ let points = 0;
12671
+ input.ops.forEach((op, i) => {
12672
+ const at = `ops[${i}]`;
12673
+ if (op.kind === "stroke") {
12674
+ if (!op.points?.length) throw new PaintEditorError(`${at}: a stroke needs points in document pixels.`);
12675
+ points += op.points.length;
12676
+ if (op.color !== void 0 && !HEX4.test(op.color)) throw new PaintEditorError(`${at}: color "${op.color}" is not #rrggbb.`);
12677
+ const brush = op.brushId ? controller.getBrush(op.brushId) : controller.getState().brush;
12678
+ if (!brush) throw new PaintEditorError(`${at}: unknown brush "${op.brushId}". Call list_brush_presets for the ids.`);
12679
+ const refusal = suggestionBrushRefusal(brush);
12680
+ if (refusal) throw new PaintEditorError(`${at}: ${refusal}`);
12681
+ if (op.size !== void 0 && !(op.size > 0)) throw new PaintEditorError(`${at}: size must be above 0 (document pixels).`);
12682
+ } else if (op.kind === "wash") {
12683
+ if (!HEX4.test(op.color ?? "")) throw new PaintEditorError(`${at}: a wash needs color as #rrggbb.`);
12684
+ if (op.opacity !== void 0 && !(op.opacity > 0 && op.opacity <= 1)) throw new PaintEditorError(`${at}: opacity must be in (0, 1].`);
12685
+ } else if (op.kind === "echo") {
12686
+ if (!op.strokeIds?.length) throw new PaintEditorError(`${at}: an echo needs strokeIds (list_strokes).`);
12687
+ const unknown = op.strokeIds.filter((id) => !known.has(id));
12688
+ if (unknown.length) throw new PaintEditorError(`${at}: unknown stroke ${unknown.map((u) => `"${u}"`).join(", ")}. Call list_strokes for the ids.`);
12689
+ if (op.color !== void 0 && !HEX4.test(op.color)) throw new PaintEditorError(`${at}: color "${op.color}" is not #rrggbb.`);
12690
+ if (op.repeat !== void 0 && !(Number.isInteger(op.repeat) && op.repeat >= 1 && op.repeat <= 50)) throw new PaintEditorError(`${at}: repeat must be a whole number from 1 to 50.`);
12691
+ for (const id of op.strokeIds) {
12692
+ const stroke = controller.getStrokes().find((s) => s.id === id);
12693
+ const refusal = suggestionBrushRefusal(stroke.brush);
12694
+ if (refusal) throw new PaintEditorError(`${at}: stroke "${id}": ${refusal}`);
12695
+ points += stroke.points.length * (op.repeat ?? 1);
12696
+ }
12697
+ } else {
12698
+ throw new PaintEditorError(`${at}: unknown kind "${op.kind}"; use stroke, wash or echo.`);
12699
+ }
12700
+ });
12701
+ if (points > maxPoints) throw new PaintEditorError(`The proposal paints ${points} points; the most is ${maxPoints}. Suggest less, or echo fewer strokes.`);
12702
+ };
12703
+ const paint = (ops, layerId) => {
12704
+ const before = strokeIds();
12705
+ for (const op of ops) {
12706
+ if (op.kind === "stroke") {
12707
+ controller.paintStroke({
12708
+ layerId,
12709
+ points: op.points,
12710
+ ...op.brushId ? { brushId: op.brushId } : {},
12711
+ ...op.color ? { color: op.color } : {},
12712
+ ...op.size ? { brush: { tip: { size: op.size } } } : {},
12713
+ stencils: false
12714
+ });
12715
+ } else if (op.kind === "wash") {
12716
+ controller.fillLayer({ layerId, color: op.color, opacity: op.opacity ?? 0.35, grain: op.grain ?? 0.4 });
12717
+ } else {
12718
+ const { kind: _kind, strokeIds: ids, color, repeat, ...variation } = op;
12719
+ controller.replayStrokes({ strokeIds: ids, layerId, ...color ? { color } : {}, ...repeat ? { repeat } : {}, ...variation });
12720
+ }
12721
+ }
12722
+ return controller.getStrokes().map((s) => s.id).filter((id) => !before.has(id));
12723
+ };
12724
+ const missing = (id) => {
12725
+ const p = state.proposals.find((x) => x.id === id);
12726
+ if (!p) return [];
12727
+ const known = strokeIds();
12728
+ return p.anchors.filter((a) => !known.has(a));
12729
+ };
12730
+ const placementOf = (input) => ({
12731
+ place: input.place ?? "top",
12732
+ ...input.layerId ? { layerId: input.layerId } : {},
12733
+ ...input.layerName ? { layerName: input.layerName } : {}
12734
+ });
12735
+ const topLayerId = () => controller.listLayers().find((l) => l.parentId === null)?.id;
12736
+ return {
12737
+ getState: () => state,
12738
+ subscribe(listener) {
12739
+ listeners.add(listener);
12740
+ return () => {
12741
+ listeners.delete(listener);
12742
+ };
12743
+ },
12744
+ propose(input) {
12745
+ validate(input);
12746
+ const placement = placementOf(input);
12747
+ const preview = controller.previewPaint(
12748
+ (layerId) => {
12749
+ paint(input.ops, layerId);
12750
+ },
12751
+ placement.place === "bottom" ? { bottom: true } : { aboveId: placement.place === "above" ? placement.layerId : topLayerId() }
12752
+ );
12753
+ const proposal = {
12754
+ id: makeId(),
12755
+ title: input.title.trim().slice(0, 120),
12756
+ ...input.rationale ? { rationale: input.rationale } : {},
12757
+ origin: input.origin ?? options.origin ?? "agent:anonymous",
12758
+ ops: input.ops.map((op) => ({ ...op })),
12759
+ placement,
12760
+ bounds: preview.bounds,
12761
+ ghost: preview.composite,
12762
+ anchors: [...new Set(input.ops.flatMap((op) => op.kind === "echo" ? op.strokeIds : []))],
12763
+ createdAt: now()
12764
+ };
12765
+ set([...state.proposals, proposal]);
12766
+ return proposal;
12767
+ },
12768
+ accept(id) {
12769
+ const p = find(id);
12770
+ const gone = missing(id);
12771
+ if (gone.length) throw new PaintEditorError(`Paint proposal "${p.title}" echoes strokes that are no longer on the canvas (${gone.join(", ")}); dismiss it or propose it again.`);
12772
+ const placement = p.placement;
12773
+ if (placement.place === "above" && !controller.listLayers().some((l) => l.id === placement.layerId)) {
12774
+ throw new PaintEditorError(`Paint proposal "${p.title}" goes above layer "${placement.layerId}", which is gone; dismiss it or propose it again.`);
12775
+ }
12776
+ const active = controller.getState().document.activeLayerId;
12777
+ const result = controller.withOrigin(
12778
+ p.origin,
12779
+ () => controller.group(p.title, () => {
12780
+ const layer = controller.addLayer({ name: placement.layerName ?? p.title, ...placement.place === "above" ? { aboveId: placement.layerId } : placement.place === "top" ? { aboveId: topLayerId() } : {} });
12781
+ if (placement.place === "bottom") controller.moveLayer(layer.id, 0, null);
12782
+ return { layerId: layer.id, strokeIds: paint(p.ops, layer.id) };
12783
+ })
12784
+ );
12785
+ if (active && controller.listLayers().some((l) => l.id === active)) controller.setActiveLayer(active);
12786
+ set(state.proposals.filter((x) => x.id !== id));
12787
+ return { proposalId: id, layerId: result.layerId, strokeIds: result.strokeIds, label: p.title };
12788
+ },
12789
+ reject(id) {
12790
+ const p = find(id);
12791
+ set(state.proposals.filter((x) => x.id !== id));
12792
+ return p;
12793
+ },
12794
+ clear(origin) {
12795
+ const dropped = state.proposals.filter((p) => !origin || p.origin === origin);
12796
+ if (dropped.length) set(state.proposals.filter((p) => !dropped.includes(p)));
12797
+ return dropped.map((p) => p.id);
12798
+ },
12799
+ get: (id) => state.proposals.find((p) => p.id === id),
12800
+ missing
12801
+ };
12802
+ }
12803
+ function paintProposalGhost(proposal) {
12804
+ const b = proposal.bounds;
12805
+ if (!b || !b.width || !b.height || proposal.ghost.length !== b.width * b.height * 4) return null;
12806
+ return { x: b.x, y: b.y, width: b.width, height: b.height, data: proposal.ghost };
12807
+ }
12808
+
12809
+ // src/paint-editor/collaboration-tools.ts
12810
+ var READ7 = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
12811
+ var WRITE6 = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
12812
+ var proposalIdArg = {
12813
+ type: "object",
12814
+ properties: { proposalId: { type: "string", minLength: 1, description: "Id of a pending paint proposal, from propose_paint or list_paint_proposals." } },
12815
+ required: ["proposalId"],
12816
+ additionalProperties: false
12817
+ };
12818
+ var hex = { type: "string", pattern: "^#[0-9a-fA-F]{6}$" };
12819
+ var opSchema = {
12820
+ type: "object",
12821
+ description: `One thing to paint. kind "stroke": a stroke along points with a dry brush. kind "wash": an even wash over the suggestion's layer (a ground, a glaze). kind "echo": strokes already on the canvas painted again, moved, turned, scaled or mirrored (grass along a horizon, rain, hatching).`,
12822
+ properties: {
12823
+ kind: { type: "string", enum: ["stroke", "wash", "echo"], description: "What this op paints (see above)." },
12824
+ points: {
12825
+ type: "array",
12826
+ minItems: 1,
12827
+ description: "stroke: pen samples in document pixels, in drawing order.",
12828
+ items: {
12829
+ type: "object",
12830
+ properties: {
12831
+ x: { type: "number", description: "Document x in pixels." },
12832
+ y: { type: "number", description: "Document y in pixels." },
12833
+ pressure: { type: "number", minimum: 0, maximum: 1, description: "Pen pressure 0..1 (default 0.5)." }
12834
+ },
12835
+ required: ["x", "y"],
12836
+ additionalProperties: false
12837
+ }
12838
+ },
12839
+ brushId: { type: "string", description: "stroke: a dry brush id from list_brush_presets (dab, particle, bristle or mixer without pickup). Default the editor brush." },
12840
+ size: { type: "number", exclusiveMinimum: 0, description: "stroke: brush size in document pixels. Default the brush's." },
12841
+ color: { ...hex, description: "stroke and echo: #rrggbb (stroke default the editor colour; echo default each stroke's colour). wash: required." },
12842
+ opacity: { type: "number", exclusiveMinimum: 0, maximum: 1, description: "wash: 0..1 (default 0.35)." },
12843
+ grain: { type: "number", minimum: 0, maximum: 1, description: "wash: how much it catches the paper grain, 0..1 (default 0.4)." },
12844
+ strokeIds: { type: "array", minItems: 1, items: { type: "string" }, description: "echo: ids of strokes on the canvas (list_strokes)." },
12845
+ dx: { type: "number", description: "echo: move right by this many document pixels (default 0)." },
12846
+ dy: { type: "number", description: "echo: move down by this many document pixels (default 0)." },
12847
+ rotate: { type: "number", description: "echo: turn this many degrees clockwise about the strokes' centre (default 0)." },
12848
+ scale: { type: "number", exclusiveMinimum: 0, description: "echo: scale about the centre; brush size scales too (default 1)." },
12849
+ flipX: { type: "boolean", description: "echo: mirror horizontally about the centre (default false)." },
12850
+ repeat: { type: "integer", minimum: 1, maximum: 50, description: "echo: repeat the move this many times, each on top of the last (default 1)." }
12851
+ },
12852
+ required: ["kind"],
12853
+ additionalProperties: false
12854
+ };
12855
+ function createPaintProposalToolProvider(proposals, options = {}) {
12856
+ const readOnlyReason = () => {
12857
+ const ro = typeof options.readOnly === "function" ? options.readOnly() : options.readOnly;
12858
+ return ro ? typeof ro === "string" ? ro : "The painting is read-only: paint proposals can be made and rejected but not accepted." : null;
12859
+ };
12860
+ const describe = (p) => ({
12861
+ id: p.id,
12862
+ title: p.title,
12863
+ ...p.rationale ? { rationale: p.rationale } : {},
12864
+ origin: p.origin,
12865
+ ops: p.ops.map((op) => op.kind),
12866
+ placement: p.placement,
12867
+ bounds: p.bounds,
12868
+ anchors: p.anchors,
12869
+ missing: proposals.missing(p.id),
12870
+ createdAt: new Date(p.createdAt).toISOString()
12871
+ });
12872
+ const proposeTool = {
12873
+ name: "propose_paint",
12874
+ title: "Propose paint",
12875
+ description: "Suggests paint without painting it: the user sees it as a ghost over the canvas and accepts or dismisses it, so use it for contributions they did not ask for. Accepted, it is painted on a new layer of its own (on top by default, at the bottom for a ground, or above a layer) as one undo step recorded as yours. Prefer what agents do well: a wash or ground, an echo of the user's strokes, a few deliberate strokes. Dry brushes only. Returns the proposal id, where the ghost sits (bounds, document pixels) and the strokes it builds on. Follow with accept_paint_proposal only when the user asked for it.",
12876
+ inputSchema: {
12877
+ type: "object",
12878
+ properties: {
12879
+ title: { type: "string", minLength: 1, maxLength: 120, description: `What the suggestion adds, in a few words, e.g. "Warm ground behind the figure". The undo step's name once accepted.` },
12880
+ rationale: { type: "string", description: "Why it fits what the user is painting, in one sentence. Shown beside the ghost." },
12881
+ ops: { type: "array", minItems: 1, items: opSchema, description: "What to paint, in order, onto the suggestion's layer." },
12882
+ place: { type: "string", enum: ["top", "bottom", "above"], description: "Where the new layer goes: top (default), bottom (behind everything: grounds and washes), or above layerId." },
12883
+ layerId: { type: "string", description: 'With place "above": the layer to put it above (list_layers).' },
12884
+ layerName: { type: "string", description: "The new layer's name (default the title)." }
12885
+ },
12886
+ required: ["title", "ops"],
12887
+ additionalProperties: false
12888
+ },
12889
+ annotations: { title: "Propose paint", ...WRITE6 },
12890
+ handler: (args, context) => {
12891
+ const origin = context.actor?.id ? `agent:${context.actor.id}` : options.fallbackOrigin;
12892
+ const proposal = proposals.propose({ ...args, ...origin ? { origin } : {} });
12893
+ const { id: _id, missing: _missing, createdAt: _createdAt, ...rest } = describe(proposal);
12894
+ return { proposalId: proposal.id, ...rest };
12895
+ }
12896
+ };
12897
+ const listTool = {
12898
+ name: "list_paint_proposals",
12899
+ title: "List paint proposals",
12900
+ description: "Lists the pending paint proposals (ghost paint not yet on the canvas): id, title, rationale, who proposed it, its op kinds, where it goes, where the ghost sits, the strokes it echoes and any of those since removed (missing: it can no longer be accepted), and whether accepting is allowed. Call before accept_paint_proposal or reject_paint_proposal.",
12901
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
12902
+ annotations: { title: "List paint proposals", ...READ7 },
12903
+ handler: () => ({ proposals: proposals.getState().proposals.map(describe), readOnly: readOnlyReason() })
12904
+ };
12905
+ const acceptTool = {
12906
+ name: "accept_paint_proposal",
12907
+ title: "Accept a paint proposal",
12908
+ description: "Paints a pending proposal on a new layer as one undo step, recorded as made by whoever proposed it, and drops it from the pending list; the user's active layer stays theirs. Refuses, changing nothing, when the painting is read-only or a stroke it echoes was removed. Returns the new layer id, the stroke ids painted and the undo step name.",
12909
+ inputSchema: proposalIdArg,
12910
+ annotations: { title: "Accept a paint proposal", ...WRITE6 },
12911
+ handler: (args) => {
12912
+ const reason = readOnlyReason();
12913
+ if (reason) throw new Error(reason);
12914
+ return proposals.accept(args.proposalId);
12915
+ }
12916
+ };
12917
+ const rejectTool = {
12918
+ name: "reject_paint_proposal",
12919
+ title: "Reject a paint proposal",
12920
+ description: "Dismisses a pending paint proposal: its ghost disappears and the painting and its history are unchanged. Use it to withdraw a suggestion that no longer fits. Returns the dismissed proposal id and title.",
12921
+ inputSchema: proposalIdArg,
12922
+ annotations: { title: "Reject a paint proposal", ...WRITE6 },
12923
+ handler: (args) => {
12924
+ const p = proposals.reject(args.proposalId);
12925
+ return { proposalId: p.id, title: p.title, rejected: true };
12926
+ }
12927
+ };
12928
+ return {
12929
+ id: options.id ?? "paint-proposals",
12930
+ ...options.namespace !== void 0 ? { namespace: options.namespace } : {},
12931
+ title: "Paint proposals",
12932
+ description: "Suggest paint as a ghost the user accepts or dismisses, and accept or reject pending suggestions.",
12933
+ skill: "pixerate-paint-editor",
12934
+ tools: [proposeTool, listTool, acceptTool, rejectTool]
12935
+ };
12936
+ }
12408
12937
  export {
12409
12938
  BACKLIGHT_SPILL,
12410
12939
  BLEND_MENU_GROUPS,
@@ -12565,12 +13094,15 @@ export {
12565
13094
  composeAffine,
12566
13095
  contactSheet,
12567
13096
  createPaintBrushLibraryToolProvider,
13097
+ createPaintChangeFeed,
12568
13098
  createPaintCompositor,
12569
13099
  createPaintDocument,
12570
13100
  createPaintEditorDocument,
12571
13101
  createPaintEditorToolProvider,
12572
13102
  createPaintEffectsToolProvider,
12573
13103
  createPaintLayer,
13104
+ createPaintProposalToolProvider,
13105
+ createPaintProposals,
12574
13106
  createPaintReplayTools,
12575
13107
  createPaintStickerTools,
12576
13108
  createPaintThickTools,
@@ -12655,6 +13187,7 @@ export {
12655
13187
  padSampleRadius,
12656
13188
  paintGradientMappingFor,
12657
13189
  paintPlaybackPanel,
13190
+ paintProposalGhost,
12658
13191
  paintSourceColor,
12659
13192
  paintSourceCss,
12660
13193
  paintToMultiColor,