@pixerate/editor 0.226.0 → 0.227.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/animation-editor/index.cjs +57 -57
  2. package/dist/animation-editor/index.js +3 -3
  3. package/dist/{brushstrokeRender-C3UU6GVC.js → brushstrokeRender-MAHOXL7K.js} +2 -2
  4. package/dist/{brushstrokeRender-EAJNE6WP.cjs → brushstrokeRender-WDTKD54B.cjs} +5 -5
  5. package/dist/canvas/index.cjs +8 -8
  6. package/dist/canvas/index.js +1 -1
  7. package/dist/{chunk-LCCMOWR7.cjs → chunk-ATKKG76G.cjs} +23 -23
  8. package/dist/{chunk-QAUIZPYI.cjs → chunk-BQE3Y6ZO.cjs} +2 -2
  9. package/dist/{chunk-G4E6RUPC.js → chunk-CDP2AKMA.js} +1 -1
  10. package/dist/{chunk-NDMXB4PR.js → chunk-CU6ANS5L.js} +1 -1
  11. package/dist/{chunk-ROVK6AXX.cjs → chunk-GNZI3KYK.cjs} +95 -23
  12. package/dist/{chunk-I7G3RSUJ.cjs → chunk-GXLZZVR5.cjs} +35 -35
  13. package/dist/{chunk-5YLOU7VF.js → chunk-KN3Y3FRY.js} +1 -1
  14. package/dist/{chunk-IWMDW7LB.cjs → chunk-MXJP77K4.cjs} +86 -86
  15. package/dist/{chunk-F7YA3MMC.js → chunk-TQOL6CF7.js} +76 -4
  16. package/dist/{chunk-ZMHPQ6HP.cjs → chunk-V3L7U63N.cjs} +1 -1
  17. package/dist/{chunk-DJBYJOHM.js → chunk-XSVUNSDJ.js} +1 -1
  18. package/dist/{chunk-BGOCAUXH.js → chunk-YMREOA67.js} +1 -1
  19. package/dist/image-editor/index.cjs +771 -771
  20. package/dist/image-editor/index.js +4 -4
  21. package/dist/paint-editor/controller.d.cts +54 -3
  22. package/dist/paint-editor/controller.d.ts +54 -3
  23. package/dist/paint-editor/dab-stroke.d.cts +3 -0
  24. package/dist/paint-editor/dab-stroke.d.ts +3 -0
  25. package/dist/paint-editor/effect-paint.d.cts +47 -0
  26. package/dist/paint-editor/effect-paint.d.ts +47 -0
  27. package/dist/paint-editor/effect-tools.d.cts +4 -2
  28. package/dist/paint-editor/effect-tools.d.ts +4 -2
  29. package/dist/paint-editor/effects.d.cts +3 -0
  30. package/dist/paint-editor/effects.d.ts +3 -0
  31. package/dist/paint-editor/engine.d.cts +4 -0
  32. package/dist/paint-editor/engine.d.ts +4 -0
  33. package/dist/paint-editor/index.cjs +536 -309
  34. package/dist/paint-editor/index.d.cts +1 -0
  35. package/dist/paint-editor/index.d.ts +1 -0
  36. package/dist/paint-editor/index.js +250 -23
  37. package/dist/paint-editor/procedural.d.cts +4 -0
  38. package/dist/paint-editor/procedural.d.ts +4 -0
  39. package/dist/paint-editor/stroke.d.cts +9 -0
  40. package/dist/paint-editor/stroke.d.ts +9 -0
  41. package/dist/paint-editor/strokes.d.cts +6 -2
  42. package/dist/paint-editor/strokes.d.ts +6 -2
  43. package/dist/vector/index.cjs +962 -962
  44. package/dist/vector/index.js +4 -4
  45. package/package.json +1 -1
  46. package/skills/pixerate-canvas/SKILL.md +2 -1
  47. package/skills/pixerate-paint-editor/SKILL.md +42 -6
  48. package/skills/pixerate-paint-sketch-to-finish/SKILL.md +7 -0
@@ -40,6 +40,7 @@ export * from './stickers.cjs';
40
40
  export * from './procedural.cjs';
41
41
  export * from './sticker-tools.cjs';
42
42
  export * from './effects.cjs';
43
+ export * from './effect-paint.cjs';
43
44
  export * from './effect-tools.cjs';
44
45
  export { WET_FLOW_KINDS, DEFAULT_FLOW, normalizeFlow, type WetFlowKind, type WetFlowSpec } from '../paint/wet/flow.cjs';
45
46
  export { DEFAULT_WET_PARAMS, WET_PARAM_KEYS, normalizeWetParams, type WetParams, type WetTilt } from '../paint/wet/model.cjs';
@@ -40,6 +40,7 @@ export * from './stickers.js';
40
40
  export * from './procedural.js';
41
41
  export * from './sticker-tools.js';
42
42
  export * from './effects.js';
43
+ export * from './effect-paint.js';
43
44
  export * from './effect-tools.js';
44
45
  export { WET_FLOW_KINDS, DEFAULT_FLOW, normalizeFlow, type WetFlowKind, type WetFlowSpec } from '../paint/wet/flow.js';
45
46
  export { DEFAULT_WET_PARAMS, WET_PARAM_KEYS, normalizeWetParams, type WetParams, type WetTilt } from '../paint/wet/model.js';
@@ -7,7 +7,7 @@ import {
7
7
  resolveExternalAssets,
8
8
  serializeLayers,
9
9
  serializeLayersExternal
10
- } from "../chunk-5YLOU7VF.js";
10
+ } from "../chunk-KN3Y3FRY.js";
11
11
  import "../chunk-3CI7FRMK.js";
12
12
  import "../chunk-ZNA3IQZF.js";
13
13
  import {
@@ -58,7 +58,7 @@ import {
58
58
  realPresenceClock,
59
59
  tweenPresenceCursor
60
60
  } from "../chunk-IVSWRVB4.js";
61
- import "../chunk-NDMXB4PR.js";
61
+ import "../chunk-CU6ANS5L.js";
62
62
  import {
63
63
  BRUSH_ID,
64
64
  BRUSH_ROTATIONS,
@@ -95,7 +95,9 @@ import {
95
95
  brushToJSON,
96
96
  clonePaper,
97
97
  drawSticker,
98
+ effectPaintKey,
98
99
  hexToRgb8,
100
+ isPaintEffectPaint,
99
101
  isWetBrush,
100
102
  isWetCapable,
101
103
  mixerTip,
@@ -115,15 +117,18 @@ import {
115
117
  paperPreset,
116
118
  parseBrushLibrary,
117
119
  penOrientation,
120
+ readPaintEffectPaint,
118
121
  renderPaper,
119
122
  renderStickers,
123
+ sampleEffectAt,
124
+ sampleEffectField,
120
125
  stampCoverage,
121
126
  stickerRect,
122
127
  stickerSurface,
123
128
  tiltToOrientation,
124
129
  toStrokePoint,
125
130
  usesPaintExchange
126
- } from "../chunk-F7YA3MMC.js";
131
+ } from "../chunk-TQOL6CF7.js";
127
132
  import "../chunk-M6DNZNVY.js";
128
133
  import "../chunk-WEIK3UYB.js";
129
134
  import "../chunk-PWBZH3FK.js";
@@ -449,7 +454,11 @@ var PaintDabStroke = class {
449
454
  const ex = st.exchange;
450
455
  ex.pickUp(this.medium.sample(rect, tip));
451
456
  let flow = 1;
452
- if (this.sampler && !this.gradient) {
457
+ if (this.options.effect) {
458
+ const rgb = [0, 0, 0];
459
+ flow = sampleEffectAt(this.options.effect, d.x, d.y, rgb);
460
+ ex.setReservoirColor(rgb[0], rgb[1], rgb[2]);
461
+ } else if (this.sampler && !this.gradient) {
453
462
  const g = this.sampler.dab(d, this.sample);
454
463
  ex.setReservoirColor(g.rgb[0], g.rgb[1], g.rgb[2]);
455
464
  flow = g.alpha;
@@ -984,6 +993,8 @@ var PaintStrokeSession = class {
984
993
  this.copies = init.copies.length ? init.copies : [IDENTITY_COPY];
985
994
  const brush = init.brush;
986
995
  if (init.wet) this.wetDriver = init.wet.driver;
996
+ const effect = init.effect?.pixels ?? null;
997
+ if (effect && (init.wet || init.thick || init.sticker)) throw new Error(`The ${brush.name} brush (${brush.engine} engine) cannot paint with effect paint.`);
987
998
  this.apply = {
988
999
  mode: brush.engine === "eraser" ? "erase" : "paint",
989
1000
  color: init.color,
@@ -1011,10 +1022,11 @@ var PaintStrokeSession = class {
1011
1022
  paperDepth: init.paperDepth,
1012
1023
  seed: init.seed,
1013
1024
  copies: this.copies,
1014
- paint: init.paint ?? null
1025
+ paint: init.paint ?? null,
1026
+ effect
1015
1027
  });
1016
1028
  } else {
1017
- const opts = { grain: init.grain, paperDepth: init.paperDepth, seed: init.seed, copies: this.copies, paint: init.paint ?? null };
1029
+ const opts = { grain: init.grain, paperDepth: init.paperDepth, seed: init.seed, copies: this.copies, paint: init.paint ?? null, effect };
1018
1030
  this.dry = brush.engine === "particle" ? new ParticleStroke(brush, opts) : brush.engine === "bristle" ? new BristleStroke(brush, opts) : new DryStroke(brush, opts);
1019
1031
  }
1020
1032
  }
@@ -1473,8 +1485,10 @@ function normalizeBrushStroke(raw) {
1473
1485
  }
1474
1486
  if (st.length) out.stencils = st;
1475
1487
  }
1476
- const paint = readPaintSource(r.paint);
1477
- if (paint && paint.type === "gradient") out.paint = paint;
1488
+ const effect = readPaintEffectPaint(r.paint);
1489
+ const paint = effect ? null : readPaintSource(r.paint);
1490
+ if (effect) out.paint = effect;
1491
+ else if (paint && paint.type === "gradient") out.paint = paint;
1478
1492
  if (Array.isArray(r.steps) && r.steps.length === points.length && r.steps.every((v) => Number.isInteger(v) && v >= 0)) out.steps = [...r.steps];
1479
1493
  return out;
1480
1494
  }
@@ -1512,7 +1526,7 @@ function varyStroke(stroke, m, v) {
1512
1526
  });
1513
1527
  const brush = s !== 1 ? normalizePaintBrush({ ...stroke.brush, tip: { ...stroke.brush.tip, size: stroke.brush.tip.size * s } }) : stroke.brush;
1514
1528
  const out = { ...stroke, brush, points };
1515
- if (stroke.paint && s !== 1) out.paint = scalePaintGradient(stroke.paint, s);
1529
+ if (stroke.paint?.type === "gradient" && s !== 1) out.paint = scalePaintGradient(stroke.paint, s);
1516
1530
  if (stroke.copies?.length) {
1517
1531
  const inv = invertAffine(m);
1518
1532
  out.copies = stroke.copies.map((c) => composeAffine(m, composeAffine(c, inv)));
@@ -3091,6 +3105,7 @@ async function replayPainting(source, target, options = {}) {
3091
3105
  const maxStrokes = options.strokes ?? source.strokes.length;
3092
3106
  const chunk = Math.max(1, options.wetChunk ?? 0) || Infinity;
3093
3107
  let strokes = 0;
3108
+ await target.prepareEffectPaints(source.strokes.slice(0, maxStrokes).flatMap((st) => st.paint?.type === "effect" ? [st.paint] : []));
3094
3109
  const advanceTo = async (clock) => {
3095
3110
  const now = target.getState().wet.clock;
3096
3111
  let left = clock - now;
@@ -3262,19 +3277,23 @@ var PAINT_EFFECT_PLACES = {
3262
3277
  /** An effect fill: composited onto a layer's pixels. */
3263
3278
  fill: ["generator", "overlay", "title"],
3264
3279
  /** Light (or any filter) on a layer's dried paint. */
3265
- light: ["filter"]
3280
+ light: ["filter"],
3281
+ /** Effect paint: brushes take each pixel's colour from it. */
3282
+ paint: ["generator", "overlay", "title"]
3266
3283
  };
3267
3284
  var PLACE_WORDS = {
3268
3285
  paper: "effect paper takes generator effects",
3269
3286
  layer: "an effect layer takes generator, overlay or title effects",
3270
3287
  fill: "an effect fill takes generator, overlay or title effects",
3271
- light: "light on dried paint takes filter effects"
3288
+ light: "light on dried paint takes filter effects",
3289
+ paint: "effect paint takes generator, overlay or title effects"
3272
3290
  };
3273
3291
  function checkPaintEffectPlace(d, place) {
3274
3292
  const allowed = PAINT_EFFECT_PLACES[place];
3275
3293
  if (d.kind !== "motion" && d.kind !== "vector" && d.uses.some((u) => allowed.includes(u))) return;
3276
3294
  let instead = "";
3277
- if (d.uses.includes("filter") && place !== "light") instead = "; run it on a layer's dried paint with apply_effect instead";
3295
+ if (d.uses.includes("filter") && place === "paint") instead = "; paint with a generator, overlay or title effect, or run the filter on the painted layer with apply_effect";
3296
+ else if (d.uses.includes("filter") && place !== "light") instead = "; run it on a layer's dried paint with apply_effect instead";
3278
3297
  else if (place === "light" && d.uses.some((u) => PAINT_EFFECT_PLACES.layer.includes(u))) instead = "; add it as an effect layer (add_effect_layer) or fill a layer with it (fill_layer_with_effect) instead";
3279
3298
  else if (place === "paper" && d.uses.some((u) => u === "overlay" || u === "title")) instead = "; add it as an effect layer with add_effect_layer instead";
3280
3299
  throw new EffectUnsupportedError(`"${d.id}" (${d.name}) is a ${d.uses.join(" / ") || d.kind} effect, and ${PLACE_WORDS[place]}${instead}.`, d.id);
@@ -3418,6 +3437,13 @@ var PaintEditorController = class _PaintEditorController {
3418
3437
  secondaryColor = "#ffffff";
3419
3438
  /** Gradient paint chosen with `setPaint` (`null`: the paint colour). */
3420
3439
  gradient = null;
3440
+ /** Effect paint chosen with `setEffectPaint` (rendered), and one still rendering. */
3441
+ effectPaint = null;
3442
+ effectPaintPending = null;
3443
+ /** Changes whenever the paint is chosen, so an effect paint that finishes rendering late does not take over. */
3444
+ paintEpoch = 0;
3445
+ /** Rendered effect paints by `effectPaintKey` (most recently used last). */
3446
+ effectPaintPixels = /* @__PURE__ */ new Map();
3421
3447
  mixerLoad = EMPTY_MIXER_LOAD;
3422
3448
  view = { ...DEFAULT_VIEW, pan: { ...DEFAULT_VIEW.pan } };
3423
3449
  hand;
@@ -3512,6 +3538,8 @@ var PaintEditorController = class _PaintEditorController {
3512
3538
  color: this.color,
3513
3539
  secondaryColor: this.secondaryColor,
3514
3540
  paint: this.gradient ?? { type: "color", color: this.color },
3541
+ effectPaint: this.effectPaintPending ?? this.effectPaint,
3542
+ effectPaintRendering: Boolean(this.effectPaintPending),
3515
3543
  mixerLoad: this.mixerLoad,
3516
3544
  view: this.view,
3517
3545
  hand: this.hand,
@@ -4296,6 +4324,141 @@ var PaintEditorController = class _PaintEditorController {
4296
4324
  this.commit("Effect fill", { ...this.doc, layers: mapLayer(this.doc.layers, now.id, () => ({ ...now, surface })) });
4297
4325
  return { layerId: now.id, source: this.sourceOf(d, values, options.time) };
4298
4326
  }
4327
+ // --- Effect paint ----------------------------------------------------------------
4328
+ /** At most this many rendered effect paints are kept (each is canvas-sized). */
4329
+ static EFFECT_PAINT_CACHE = 6;
4330
+ /** The effect paint strokes use (or the one rendering), or `null`. */
4331
+ getEffectPaint() {
4332
+ const p = this.effectPaintPending ?? this.effectPaint;
4333
+ return p ? { ...p, values: JSON.parse(JSON.stringify(p.values)) } : null;
4334
+ }
4335
+ /**
4336
+ * Paints with a provider generator, overlay or title effect: it is rendered once at the canvas
4337
+ * size, and brushes then take each pixel's colour (and alpha) from it, fixed to the canvas, as if
4338
+ * painting a hidden picture into view. `setColor` and `setPaint` go back to colour. Editor state,
4339
+ * not an undo step. Strokes that would paint with it throw while it renders. Returns the paint.
4340
+ */
4341
+ async setEffectPaint(source) {
4342
+ if (!source || typeof source.effect !== "string" || !source.effect) throw new PaintEditorError("effect is missing; pass an effect id from search_effects (use generator, overlay or title).");
4343
+ this.checkTime(source.time);
4344
+ const epoch = ++this.paintEpoch;
4345
+ const changed = (name) => new PaintEditorError(`The paint changed while "${name}" rendered (set_color, set_paint or another set_effect_paint); call set_effect_paint again to paint with it.`);
4346
+ const d = await this.describeEffect(source.effect, "paint");
4347
+ const values = this.effectValues(d, { ...d.defaults }, source.values);
4348
+ if (epoch !== this.paintEpoch) throw changed(d.name);
4349
+ const paint = { type: "effect", effect: d.id, provider: this.effects.require().id, values, time: source.time ?? 0 };
4350
+ this.effectPaintPending = paint;
4351
+ this.emit([]);
4352
+ try {
4353
+ await this.renderEffectPaint(paint);
4354
+ } catch (e) {
4355
+ if (this.effectPaintPending === paint) {
4356
+ this.effectPaintPending = null;
4357
+ this.emit([]);
4358
+ }
4359
+ throw e;
4360
+ }
4361
+ if (this.effectPaintPending !== paint || epoch !== this.paintEpoch) throw changed(d.name);
4362
+ this.effectPaintPending = null;
4363
+ this.effectPaint = paint;
4364
+ this.gradient = null;
4365
+ this.emit([]);
4366
+ return this.getEffectPaint();
4367
+ }
4368
+ /** The rendered pixels of an effect paint at the current canvas size, or `null` when it has not been rendered. */
4369
+ effectPaintImage(p) {
4370
+ const key = effectPaintKey(p);
4371
+ const img = this.effectPaintPixels.get(key);
4372
+ if (!img || img.width !== this.doc.canvas.width || img.height !== this.doc.canvas.height) return null;
4373
+ this.effectPaintPixels.delete(key);
4374
+ this.effectPaintPixels.set(key, img);
4375
+ return img;
4376
+ }
4377
+ /** Renders an effect paint at the canvas size into the cache (nothing when it is there already). */
4378
+ async renderEffectPaint(p) {
4379
+ const have = this.effectPaintImage(p);
4380
+ if (have) return have;
4381
+ const provider = this.effects.provider;
4382
+ const what = `the effect "${p.effect}"${p.provider ? ` from provider "${p.provider}"` : ""}`;
4383
+ if (!provider) throw new PaintEditorError(`Effect paint needs ${what}, but no effects provider is registered; register it (setEffectsProvider) and try again.`);
4384
+ if (p.provider && provider.id !== p.provider) throw new PaintEditorError(`Effect paint needs ${what}, but the registered provider is "${provider.id}"; register "${p.provider}" (setEffectsProvider) and try again.`);
4385
+ const { width, height } = this.doc.canvas;
4386
+ let pixels;
4387
+ try {
4388
+ pixels = await this.effectsRenderer.render(provider, p.effect, { width, height }, { time: p.time, values: p.values });
4389
+ } catch (e) {
4390
+ throw new PaintEditorError(`Effect paint could not render ${what}: ${e instanceof Error ? e.message : String(e)}`);
4391
+ }
4392
+ const img = { width, height, data: new Uint8ClampedArray(pixels.data) };
4393
+ this.effectPaintPixels.set(effectPaintKey(p), img);
4394
+ while (this.effectPaintPixels.size > _PaintEditorController.EFFECT_PAINT_CACHE) this.effectPaintPixels.delete(this.effectPaintPixels.keys().next().value);
4395
+ return img;
4396
+ }
4397
+ /**
4398
+ * Renders effect paints that are not rendered yet (strokes and replays need them rendered
4399
+ * first, since strokes are synchronous). Throws naming the effect and provider when one cannot be.
4400
+ */
4401
+ async prepareEffectPaints(paints) {
4402
+ const seen = /* @__PURE__ */ new Set();
4403
+ for (const p of paints) {
4404
+ const key = effectPaintKey(p);
4405
+ if (seen.has(key)) continue;
4406
+ seen.add(key);
4407
+ await this.renderEffectPaint(p);
4408
+ }
4409
+ }
4410
+ /** Renders the effect paints of recorded strokes (default all of them) before a replay. */
4411
+ async prepareStrokeEffects(strokeIds) {
4412
+ const ids = strokeIds ? new Set(strokeIds) : null;
4413
+ const paints = this.doc.strokes.filter((s) => (!ids || ids.has(s.id)) && s.paint?.type === "effect").map((s) => s.paint);
4414
+ await this.prepareEffectPaints(paints);
4415
+ }
4416
+ /** A small preview of the effect paint (a PNG data URL `size` px wide), or `null` without a rendered one. */
4417
+ effectPaintThumbnail(size = 48) {
4418
+ const p = this.effectPaint;
4419
+ const img = p ? this.effectPaintImage(p) : null;
4420
+ if (!img) return null;
4421
+ const w = Math.max(1, Math.min(img.width, Math.round(size)));
4422
+ const h = Math.max(1, Math.round(img.height / img.width * w));
4423
+ const out = new Uint8ClampedArray(w * h * 4);
4424
+ for (let y = 0; y < h; y++) {
4425
+ const sy = Math.min(img.height - 1, Math.floor((y + 0.5) * img.height / h));
4426
+ for (let x = 0; x < w; x++) {
4427
+ const sx = Math.min(img.width - 1, Math.floor((x + 0.5) * img.width / w));
4428
+ const i = (sy * img.width + sx) * 4;
4429
+ const o = (y * w + x) * 4;
4430
+ for (let c = 0; c < 4; c++) out[o + c] = img.data[i + c];
4431
+ }
4432
+ }
4433
+ return pngDataUrl(w, h, out);
4434
+ }
4435
+ /**
4436
+ * The effect paint a stroke paints with and its pixels, or `null`: the stroke's own effect
4437
+ * `paint`, else (without its own `paint` or `color`, and with no brush gradient) the editor's.
4438
+ * Throws while the editor's is still rendering, for an engine that cannot paint with it, or when
4439
+ * its pixels are not rendered.
4440
+ */
4441
+ strokeEffect(init, brush) {
4442
+ if (!brushTakesPaint(brush)) return null;
4443
+ let source = null;
4444
+ if (init.paint !== void 0) {
4445
+ if (!isPaintEffectPaint(init.paint)) return null;
4446
+ source = readPaintEffectPaint(init.paint);
4447
+ if (!source) throw new PaintEditorError("The stroke's effect paint needs an effect id and values (getEffectPaint returns one).");
4448
+ } else if (init.color === void 0 && !brush.paint) {
4449
+ if (this.effectPaintPending) throw new PaintEditorError(`The effect paint "${this.effectPaintPending.effect}" is still rendering; wait for set_effect_paint to finish, then paint.`);
4450
+ source = this.effectPaint;
4451
+ }
4452
+ if (!source) return null;
4453
+ if (brush.engine === "wet" || brush.engine === "impasto" || brush.engine === "sticker") {
4454
+ throw new PaintEditorError(
4455
+ `The ${brush.name} brush (${brush.engine} engine) cannot paint with effect paint: it carries one colour as ${brush.engine === "sticker" ? "stickers" : brush.engine === "wet" ? "wet pigment" : "thick paint"}. Paint with a dry brush (a dab, particle, bristle or mixer brush), or paint the shape first and fill it with fill_layer_with_effect {lockTransparency: true}.`
4456
+ );
4457
+ }
4458
+ const pixels = this.effectPaintImage(source);
4459
+ if (!pixels) throw new PaintEditorError(`The effect paint "${source.effect}"${source.provider ? ` from provider "${source.provider}"` : ""} is not rendered at this canvas size; call set_effect_paint (or prepareEffectPaints) first.`);
4460
+ return { source, pixels };
4461
+ }
4299
4462
  /**
4300
4463
  * Runs a filter effect (light, glow, colour grade …) on a layer's dried paint and bakes it in, at
4301
4464
  * `amount` strength (0..1, default 1). Transparent areas stay transparent; thick paint keeps its
@@ -4438,6 +4601,8 @@ var PaintEditorController = class _PaintEditorController {
4438
4601
  setColor(color) {
4439
4602
  this.color = parseColor(color);
4440
4603
  this.gradient = null;
4604
+ this.effectPaint = this.effectPaintPending = null;
4605
+ this.paintEpoch++;
4441
4606
  this.emit([]);
4442
4607
  return this.color;
4443
4608
  }
@@ -4460,6 +4625,8 @@ var PaintEditorController = class _PaintEditorController {
4460
4625
  return p;
4461
4626
  }
4462
4627
  this.gradient = p;
4628
+ this.effectPaint = this.effectPaintPending = null;
4629
+ this.paintEpoch++;
4463
4630
  this.emit([]);
4464
4631
  return p;
4465
4632
  }
@@ -4472,6 +4639,7 @@ var PaintEditorController = class _PaintEditorController {
4472
4639
  if (!brushTakesPaint(brush)) return null;
4473
4640
  let g = null;
4474
4641
  if (init.paint !== void 0) {
4642
+ if (isPaintEffectPaint(init.paint)) return null;
4475
4643
  const p = this.readStrokePaint(init.paint);
4476
4644
  g = p.type === "gradient" ? p : null;
4477
4645
  } else g = brush.paint ?? (init.color === void 0 ? this.gradient : null);
@@ -4561,7 +4729,7 @@ var PaintEditorController = class _PaintEditorController {
4561
4729
  return symmetryCopies(this.guides.symmetry);
4562
4730
  }
4563
4731
  sessionFor(doc, init, host) {
4564
- if (init.paint !== void 0) {
4732
+ if (init.paint !== void 0 && !isPaintEffectPaint(init.paint)) {
4565
4733
  const p = this.readStrokePaint(init.paint);
4566
4734
  if (p.type === "color") init = { ...init, color: p.color, paint: void 0 };
4567
4735
  else init = { ...init, paint: p };
@@ -4578,6 +4746,7 @@ var PaintEditorController = class _PaintEditorController {
4578
4746
  return l;
4579
4747
  })();
4580
4748
  const brush = this.strokeBrush(init);
4749
+ const effect = this.strokeEffect(init, brush);
4581
4750
  const stickerLayer = Boolean(doc.stickers?.[layer.id]?.length);
4582
4751
  if (brush.engine === "sticker") {
4583
4752
  if (!stickerLayer && contentBounds(layer.surface)) throw new PaintEditorError(`Layer "${layer.name}" already has paint; spray stickers onto an empty layer (add_layer) so they stay editable.`);
@@ -4659,7 +4828,8 @@ var PaintEditorController = class _PaintEditorController {
4659
4828
  wet,
4660
4829
  thick: brush.engine === "impasto" ? { channels: doc.thick?.[layer.id] ?? emptyThickChannels() } : void 0,
4661
4830
  sticker: brush.engine === "sticker" ? { nextId: this.nextStickerNumber(doc) } : void 0,
4662
- paint: this.strokeGradient(init, brush, color)
4831
+ paint: effect ? null : this.strokeGradient(init, brush, color),
4832
+ effect
4663
4833
  });
4664
4834
  if (wet) this.wet.driver = wet.driver;
4665
4835
  if (init.maskStencils) session.maskStencils = init.maskStencils;
@@ -4676,7 +4846,8 @@ var PaintEditorController = class _PaintEditorController {
4676
4846
  seed: init.seed
4677
4847
  };
4678
4848
  if (init.affineCopies.length) stroke.copies = init.affineCopies.map((m) => [...m]);
4679
- if (init.paint) stroke.paint = JSON.parse(JSON.stringify(init.paint));
4849
+ if (init.effect) stroke.paint = JSON.parse(JSON.stringify(init.effect.source));
4850
+ else if (init.paint) stroke.paint = JSON.parse(JSON.stringify(init.paint));
4680
4851
  if (init.mixerLoad.reservoir || init.mixerLoad.pickup) stroke.load = JSON.parse(JSON.stringify(init.mixerLoad));
4681
4852
  if (init.wet) stroke.steps = [...init.wet.driver.steps];
4682
4853
  if (init.selection) {
@@ -4844,6 +5015,13 @@ var PaintEditorController = class _PaintEditorController {
4844
5015
  if (options.color !== void 0) parseColor(options.color);
4845
5016
  if (options.brushId !== void 0) this.requireBrush(options.brushId);
4846
5017
  if (options.layerId !== void 0) this.paintTarget(options.layerId, "replay onto");
5018
+ if (options.color === void 0) {
5019
+ for (const s of strokes) {
5020
+ if (s.paint?.type === "effect" && !this.effectPaintImage(s.paint)) {
5021
+ throw new PaintEditorError(`Stroke "${s.id}" paints with the effect "${s.paint.effect}"${s.paint.provider ? ` from provider "${s.paint.provider}"` : ""}, which is not rendered in this editor; render it first (prepareStrokeEffects; the replay_strokes tool does this) or recolour the replay with color.`);
5022
+ }
5023
+ }
5024
+ }
4847
5025
  let origin = options.origin;
4848
5026
  if (!origin) {
4849
5027
  const b = strokeBounds(strokes.flatMap((s) => s.points));
@@ -4938,6 +5116,8 @@ var PaintEditorController = class _PaintEditorController {
4938
5116
  const paper = { ...this.doc.paper, scale: Math.min(8, this.doc.paper.scale * scale) };
4939
5117
  target.adopt({ state: { canvas: target.doc.canvas, paper, layers: emptiedLayers(this.doc.layers), activeLayerId: this.doc.activeLayerId, strokes: [] }, extensions: {}, problems: [] });
4940
5118
  target.lighting = { ...this.lighting };
5119
+ target.effects.set(this.effects.provider);
5120
+ if (w === width && h === height) for (const [key, img] of this.effectPaintPixels) target.effectPaintPixels.set(key, img);
4941
5121
  return target;
4942
5122
  }
4943
5123
  /**
@@ -4989,6 +5169,7 @@ var PaintEditorController = class _PaintEditorController {
4989
5169
  dispose() {
4990
5170
  this.wet.destroy();
4991
5171
  this.effectsRenderer.dispose();
5172
+ this.effectPaintPixels.clear();
4992
5173
  }
4993
5174
  /** Resolves when wet paint is between steps (edits, undo and exports can run). */
4994
5175
  wetIdle() {
@@ -7833,7 +8014,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
7833
8014
  add({
7834
8015
  name: "set_color",
7835
8016
  title: "Set the paint colour",
7836
- description: "Sets the paint colour (and optionally the second colour) that strokes and fills use by default. Editor state, not an undo step. Returns both colours.",
8017
+ description: "Sets the paint colour (and optionally the second colour) that strokes and fills use by default. Setting color goes back to one colour from gradient paint (set_paint) or effect paint (set_effect_paint). Editor state, not an undo step. Returns both colours.",
7837
8018
  inputSchema: {
7838
8019
  type: "object",
7839
8020
  properties: { color: { ...COLOR3, description: "Paint colour #rrggbb." }, secondaryColor: { ...COLOR3, description: "Second colour #rrggbb." } },
@@ -7850,7 +8031,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
7850
8031
  add({
7851
8032
  name: "set_paint",
7852
8033
  title: "Set the paint (colour or gradient)",
7853
- description: 'Sets what strokes paint with: one colour, or gradient paint that changes colour as the stroke goes. Gradient paint: 2..16 stops sorted by offset (#rrggbb, or "paint" / "secondary" for the two editor colours), space pigment (default: mixes like paint) / oklab / srgb, and map: distance (default: the colour changes as the stroke is dragged out, 300 px then mirrored back), pressure, tilt, velocity, direction, time, across-tip or random. Every stroking brush samples it per dab (dab, mixer, watercolour and gouache, oil and impasto, particle, bristle, sticker); erasers, blenders and smudges ignore it, and a brush with its own gradient (get_brush paint) paints that instead. set_color goes back to one colour. Editor state, not an undo step. Returns paint, color and secondaryColor.',
8034
+ description: 'Sets what strokes paint with: one colour, or gradient paint that changes colour as the stroke goes. Gradient paint: 2..16 stops sorted by offset (#rrggbb, or "paint" / "secondary" for the two editor colours), space pigment (default: mixes like paint) / oklab / srgb, and map: distance (default: the colour changes as the stroke is dragged out, 300 px then mirrored back), pressure, tilt, velocity, direction, time, across-tip or random. Every stroking brush samples it per dab (dab, mixer, watercolour and gouache, oil and impasto, particle, bristle, sticker); erasers, blenders and smudges ignore it, and a brush with its own gradient (get_brush paint) paints that instead. It also ends effect paint (set_effect_paint); set_color goes back to one colour. Editor state, not an undo step. Returns paint, color and secondaryColor.',
7854
8035
  inputSchema: { type: "object", properties: { paint: PAINT_SOURCE_JSON_SCHEMA }, required: ["paint"], additionalProperties: false },
7855
8036
  annotations: { title: "Set the paint (colour or gradient)", ...SET4 },
7856
8037
  handler: ({ paint }) => {
@@ -8001,8 +8182,9 @@ function createPaintEditorToolProvider(controller, options = {}) {
8001
8182
  },
8002
8183
  annotations: { title: "Replay strokes, varied", ...WRITE5 },
8003
8184
  coordinates: { in: ["origin"], out: ["bounds"] },
8004
- handler: (a) => {
8185
+ handler: async (a) => {
8005
8186
  const replay = a;
8187
+ if (replay.color === void 0) await c.prepareStrokeEffects(replay.strokeIds);
8006
8188
  return act({ kind: "replay", replay }, () => c.replayStrokes(replay));
8007
8189
  }
8008
8190
  });
@@ -8692,6 +8874,7 @@ function quickStencil(kind, canvas) {
8692
8874
 
8693
8875
  // src/paint-editor/effect-tools.ts
8694
8876
  var PAPER_TARGET = "paper";
8877
+ var PAINT_TARGET = "paint";
8695
8878
  var ref = (s) => ({ effect: s.effect, ...s.name ? { name: s.name } : {}, ...s.provider ? { provider: s.provider } : {} });
8696
8879
  function createPaintEffectsToolProvider(controller, options = {}) {
8697
8880
  const readOnlyReason = () => {
@@ -8702,6 +8885,10 @@ function createPaintEffectsToolProvider(controller, options = {}) {
8702
8885
  const p = controller.getPaperEffect();
8703
8886
  return p ? [{ id: PAPER_TARGET, ...ref(p), role: "effect paper", enabled: true, opacity: p.opacity, blend: p.blend, values: p.values, ...p.time ? { time: p.time } : {} }] : [];
8704
8887
  };
8888
+ const paintInfo = () => {
8889
+ const p = controller.getEffectPaint();
8890
+ return p ? [{ id: PAINT_TARGET, effect: p.effect, provider: p.provider, role: controller.getState().effectPaintRendering ? "effect paint (rendering)" : "effect paint", enabled: true, values: p.values, ...p.time ? { time: p.time } : {} }] : [];
8891
+ };
8705
8892
  const layerInfo = (layerId) => {
8706
8893
  const s = controller.getEffectLayer(layerId);
8707
8894
  return s ? [{ id: "effect", ...ref(s), role: "effect layer", enabled: true, values: s.values, ...s.time ? { time: s.time } : {} }] : [];
@@ -8714,14 +8901,14 @@ function createPaintEffectsToolProvider(controller, options = {}) {
8714
8901
  }
8715
8902
  return l;
8716
8903
  };
8717
- const effectsOf = (target) => target === PAPER_TARGET ? paperInfo() : layerInfo(target);
8904
+ const effectsOf = (target) => target === PAPER_TARGET ? paperInfo() : target === PAINT_TARGET ? paintInfo() : layerInfo(target);
8718
8905
  const instanceCheck = (target, instance) => {
8719
- const want = target === PAPER_TARGET ? PAPER_TARGET : "effect";
8720
- if (target !== PAPER_TARGET) requireLayer(target);
8906
+ const want = target === PAPER_TARGET || target === PAINT_TARGET ? target : "effect";
8907
+ if (target !== PAPER_TARGET && target !== PAINT_TARGET) requireLayer(target);
8721
8908
  if (instance !== want || !effectsOf(target).length) {
8722
8909
  const have = effectsOf(target).map((e) => `"${e.id}"`);
8723
8910
  throw new Error(
8724
- `${target === PAPER_TARGET ? "The paper" : `Layer "${target}"`} has no provider effect "${instance}"; its effects are ${have.join(", ") || "none"} (list_applied_effects). Light baked into paint is part of the pixels: undo takes it back.`
8911
+ `${target === PAPER_TARGET ? "The paper" : target === PAINT_TARGET ? "The paint" : `Layer "${target}"`} has no provider effect "${instance}"; its effects are ${have.join(", ") || "none"} (list_applied_effects). Light baked into paint is part of the pixels: undo takes it back.`
8725
8912
  );
8726
8913
  }
8727
8914
  };
@@ -8736,24 +8923,27 @@ function createPaintEffectsToolProvider(controller, options = {}) {
8736
8923
  title: "Paint effects",
8737
8924
  description: "Provider effects on paint layers and on the effect paper.",
8738
8925
  skill: "pixerate-paint-editor",
8739
- target: { noun: "layer", id: `A pixel layer id from list_layers ("${PAPER_TARGET}" is the effect paper, for list, update and remove).` },
8926
+ target: { noun: "layer", id: `A pixel layer id from list_layers ("${PAPER_TARGET}" is the effect paper and "${PAINT_TARGET}" the effect paint, for list, update and remove).` },
8740
8927
  applyUses: ["filter"],
8741
8928
  applyDetails: "Light on dried paint: the filter (search_effects with use filter, e.g. a light, glow or grade) runs on the layer's paint at the canvas size and is baked into its pixels; transparent areas stay transparent, and thick paint keeps its relief (an effect with a height input gets it). opacity is the strength (default 1). Wet paint is refused: dry it first (dry_paint). It is one undo step and is not listed afterwards (it is part of the pixels). Generators go on the paper (set_paper_effect), on their own layer (add_effect_layer) or into a layer (fill_layer_with_effect).",
8742
8929
  provider: () => controller.effectsProvider,
8743
8930
  readOnlyReason,
8744
8931
  list: (target) => {
8745
8932
  if (target === PAPER_TARGET) return [{ target: PAPER_TARGET, name: "Paper", effects: paperInfo() }];
8933
+ if (target === PAINT_TARGET) return [{ target: PAINT_TARGET, name: "Paint", effects: paintInfo() }];
8746
8934
  if (target) {
8747
8935
  const l = requireLayer(target);
8748
8936
  return [{ target, name: l.name, effects: layerInfo(target) }];
8749
8937
  }
8750
8938
  const out = [];
8939
+ if (paintInfo().length) out.push({ target: PAINT_TARGET, name: "Paint", effects: paintInfo() });
8751
8940
  if (paperInfo().length) out.push({ target: PAPER_TARGET, name: "Paper", effects: paperInfo() });
8752
8941
  for (const e of controller.listEffectLayers()) out.push({ target: e.layerId, name: e.name, effects: layerInfo(e.layerId) });
8753
8942
  return out;
8754
8943
  },
8755
8944
  apply: async (target, effect, args) => {
8756
8945
  if (target === PAPER_TARGET) throw new Error("The paper takes generator effects through set_paper_effect; apply_effect runs a filter on a layer's paint.");
8946
+ if (target === PAINT_TARGET) throw new Error("Brushes paint with an effect through set_effect_paint; apply_effect runs a filter on a layer's paint.");
8757
8947
  noStackArgs(args);
8758
8948
  requireLayer(target);
8759
8949
  const r = await controller.applyLightEffect(target, effect, { values: values(args.values), amount: args.opacity });
@@ -8763,6 +8953,13 @@ function createPaintEffectsToolProvider(controller, options = {}) {
8763
8953
  update: async (target, instance, changes) => {
8764
8954
  instanceCheck(target, instance);
8765
8955
  noStackArgs(changes);
8956
+ if (target === PAINT_TARGET) {
8957
+ if (changes.opacity !== void 0) throw new Error("opacity does not apply to the effect paint: change the brush opacity (set_brush tip.opacity).");
8958
+ const p = controller.getEffectPaint();
8959
+ await controller.setEffectPaint({ effect: p.effect, values: { ...p.values, ...changes.values ?? {} }, time: p.time });
8960
+ const effects2 = paintInfo();
8961
+ return { target, effect: effects2[0], effects: effects2 };
8962
+ }
8766
8963
  if (target === PAPER_TARGET) {
8767
8964
  await controller.updatePaperEffect({ values: changes.values, opacity: changes.opacity });
8768
8965
  const effects2 = paperInfo();
@@ -8776,6 +8973,10 @@ function createPaintEffectsToolProvider(controller, options = {}) {
8776
8973
  remove: async (target, instance) => {
8777
8974
  instanceCheck(target, instance);
8778
8975
  const was = effectsOf(target)[0];
8976
+ if (target === PAINT_TARGET) {
8977
+ controller.setColor(controller.getState().color);
8978
+ return { target, effect: was, effects: [] };
8979
+ }
8779
8980
  if (target === PAPER_TARGET) await controller.setPaperEffect(null);
8780
8981
  else controller.rasterizeEffectLayer(target);
8781
8982
  return { target, effect: was, effects: [], ...target === PAPER_TARGET ? {} : { keptPixels: true } };
@@ -8811,6 +9012,26 @@ function createPaintEffectsToolProvider(controller, options = {}) {
8811
9012
  return { paperEffect: await controller.setPaperEffect(args.effect, { values: values(args.values), opacity: args.opacity, blend: args.blend, time: args.time }) };
8812
9013
  }
8813
9014
  },
9015
+ {
9016
+ name: "set_effect_paint",
9017
+ title: "Paint with an effect",
9018
+ description: `Makes brushes paint with a provider generator, overlay or title effect (search_effects with use generator, overlay or title): it is rendered once at the canvas size, and each stroke then takes every pixel's colour from it, fixed to the canvas, as if painting a hidden picture into view (the effect's transparency paints nothing). Dab, particle and bristle brushes take the colour per pixel; mixer brushes and dab brushes that pick up paint take it per dab, at its centre. Wet, impasto and sticker brushes refuse it (paint the shape and use fill_layer_with_effect with lockTransparency instead); erasers, blenders and smudges ignore it. It replaces the paint colour or gradient until set_color or set_paint; a brush with its own gradient paints that instead. Strokes record it and replay exactly. Editor state, not an undo step. Returns the effect paint: { effect, provider, values (every control), time } (list_applied_effects target "paint" shows it; update_effect / remove_effect with target and effect "paint" change or clear it).`,
9019
+ inputSchema: {
9020
+ type: "object",
9021
+ properties: {
9022
+ effect: ctx.effectIdSchema(["generator", "overlay", "title"]),
9023
+ values: ctx.valuesSchema("Control values over the effect's defaults"),
9024
+ time: { type: "number", minimum: 0, description: "Which moment of an animated effect to paint with, in ms (default 0)." }
9025
+ },
9026
+ required: ["effect"],
9027
+ additionalProperties: false
9028
+ },
9029
+ annotations: { title: "Paint with an effect", ...ctx.annotations.writeIdempotent },
9030
+ handler: async (args) => {
9031
+ ctx.requireProvider();
9032
+ return { effectPaint: await controller.setEffectPaint({ effect: args.effect, values: values(args.values), time: args.time }) };
9033
+ }
9034
+ },
8814
9035
  {
8815
9036
  name: "add_effect_layer",
8816
9037
  title: "Add an effect layer",
@@ -8960,6 +9181,7 @@ export {
8960
9181
  PAINT_GRADIENT_SPACE_LABELS,
8961
9182
  PAINT_SCHEMA_VERSION,
8962
9183
  PAINT_SOURCE_JSON_SCHEMA,
9184
+ PAINT_TARGET,
8963
9185
  PAINT_TOOLS,
8964
9186
  PAPER_EFFECT_BLENDS,
8965
9187
  PAPER_EFFECT_NODE_ID,
@@ -9052,6 +9274,7 @@ export {
9052
9274
  drawSticker,
9053
9275
  drawWaterOverlay,
9054
9276
  effectLayersToJSON,
9277
+ effectPaintKey,
9055
9278
  emptiedLayers,
9056
9279
  emptyThickChannels,
9057
9280
  fitZoom,
@@ -9068,6 +9291,7 @@ export {
9068
9291
  invertAffine,
9069
9292
  isBlendMode,
9070
9293
  isGradientPaint,
9294
+ isPaintEffectPaint,
9071
9295
  isPainterlyBlendMode,
9072
9296
  isPalmTouch,
9073
9297
  isWetBrush,
@@ -9134,6 +9358,7 @@ export {
9134
9358
  quickStencil,
9135
9359
  rand01,
9136
9360
  randSigned,
9361
+ readPaintEffectPaint,
9137
9362
  readPaintEffectSource,
9138
9363
  readPaintSource,
9139
9364
  readPath,
@@ -9152,6 +9377,8 @@ export {
9152
9377
  rgbToHexColor,
9153
9378
  rgbToHsl,
9154
9379
  rgbToHsv,
9380
+ sampleEffectAt,
9381
+ sampleEffectField,
9155
9382
  samplePressure,
9156
9383
  scalePaintGradient,
9157
9384
  scaleStencil,
@@ -21,6 +21,7 @@ import { CoverageMap } from '../paint/brush.cjs';
21
21
  import { type PaintGradientSource } from '../paint/gradient-paint.cjs';
22
22
  import type { RasterRect } from '../raster/surface.cjs';
23
23
  import type { PaintBrush } from './brushes.cjs';
24
+ import { type EffectPaintPixels } from './effect-paint.cjs';
24
25
  import { PenDabWalker, PenSmoother, type PenDab, type PenPoint, type StrokeCopy } from './engine.cjs';
25
26
  import { type PaperGrain } from './paper.cjs';
26
27
  export interface ProceduralStrokeOptions {
@@ -30,6 +31,8 @@ export interface ProceduralStrokeOptions {
30
31
  copies?: readonly StrokeCopy[];
31
32
  /** Gradient paint (resolved): each mark takes the colour of the pen dab that made it. */
32
33
  paint?: PaintGradientSource | null;
34
+ /** Effect paint: each pixel of a mark takes its colour (and alpha) from these canvas-sized pixels. */
35
+ effect?: EffectPaintPixels | null;
33
36
  }
34
37
  /** What a coverage-building stroke engine provides to the stroke session. */
35
38
  export interface CoverageStroke {
@@ -49,6 +52,7 @@ declare abstract class MarkStroke implements CoverageStroke {
49
52
  protected readonly walkers: PenDabWalker[];
50
53
  protected readonly depth: number;
51
54
  private buf;
55
+ private field;
52
56
  private lastRaw;
53
57
  private lastFed;
54
58
  protected readonly seed: number;
@@ -21,6 +21,7 @@ import { CoverageMap } from '../paint/brush.js';
21
21
  import { type PaintGradientSource } from '../paint/gradient-paint.js';
22
22
  import type { RasterRect } from '../raster/surface.js';
23
23
  import type { PaintBrush } from './brushes.js';
24
+ import { type EffectPaintPixels } from './effect-paint.js';
24
25
  import { PenDabWalker, PenSmoother, type PenDab, type PenPoint, type StrokeCopy } from './engine.js';
25
26
  import { type PaperGrain } from './paper.js';
26
27
  export interface ProceduralStrokeOptions {
@@ -30,6 +31,8 @@ export interface ProceduralStrokeOptions {
30
31
  copies?: readonly StrokeCopy[];
31
32
  /** Gradient paint (resolved): each mark takes the colour of the pen dab that made it. */
32
33
  paint?: PaintGradientSource | null;
34
+ /** Effect paint: each pixel of a mark takes its colour (and alpha) from these canvas-sized pixels. */
35
+ effect?: EffectPaintPixels | null;
33
36
  }
34
37
  /** What a coverage-building stroke engine provides to the stroke session. */
35
38
  export interface CoverageStroke {
@@ -49,6 +52,7 @@ declare abstract class MarkStroke implements CoverageStroke {
49
52
  protected readonly walkers: PenDabWalker[];
50
53
  protected readonly depth: number;
51
54
  private buf;
55
+ private field;
52
56
  private lastRaw;
53
57
  private lastFed;
54
58
  protected readonly seed: number;