@pixerate/editor 0.269.2 → 0.271.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 (74) hide show
  1. package/README.md +5 -2
  2. package/dist/agent/index.cjs +30 -30
  3. package/dist/agent/index.js +3 -3
  4. package/dist/animation-editor/index.cjs +62 -62
  5. package/dist/animation-editor/index.js +4 -4
  6. package/dist/canvas/editorDocument.d.cts +3 -1
  7. package/dist/canvas/editorDocument.d.ts +3 -1
  8. package/dist/canvas/index.cjs +53 -27
  9. package/dist/canvas/index.js +34 -8
  10. package/dist/chart/index.cjs +21 -21
  11. package/dist/chart/index.js +2 -2
  12. package/dist/{chunk-GBEF2D4P.js → chunk-23YL2EAB.js} +1 -1
  13. package/dist/{chunk-QGVTOCNB.js → chunk-6ZSFRRJI.js} +1 -1
  14. package/dist/{chunk-4YSPQGZW.js → chunk-B5W677L7.js} +1 -1
  15. package/dist/{chunk-4NMPUJUK.cjs → chunk-DDL3ZBHK.cjs} +86 -86
  16. package/dist/{chunk-T4JPGBW3.cjs → chunk-DGITFN73.cjs} +8 -8
  17. package/dist/{chunk-PY4GWSE2.cjs → chunk-EBFPMSJO.cjs} +27 -5
  18. package/dist/{chunk-NS62CQ2B.js → chunk-GAZPHCTV.js} +23 -2
  19. package/dist/{chunk-YYBW6DF2.cjs → chunk-GSLOH2IN.cjs} +2 -2
  20. package/dist/{chunk-56RHCIJV.cjs → chunk-JJ2H25JG.cjs} +237 -170
  21. package/dist/{chunk-CBVNCWJM.cjs → chunk-LBDC5434.cjs} +2 -2
  22. package/dist/{chunk-BENSE3VX.cjs → chunk-M2ZVPU7L.cjs} +23 -23
  23. package/dist/{chunk-MQ6MGIQM.js → chunk-NMZQMPFZ.js} +1 -1
  24. package/dist/{chunk-AI4F33IV.cjs → chunk-NQUKTRKN.cjs} +45 -45
  25. package/dist/{chunk-4CPOKP27.js → chunk-QYXUE37Q.js} +1 -1
  26. package/dist/{chunk-MWUZCEFT.js → chunk-S2XPXA47.js} +1 -1
  27. package/dist/{chunk-EHLOCVWB.js → chunk-T2URHAJX.js} +27 -5
  28. package/dist/{chunk-FUDP5QBB.js → chunk-TNHDCP4R.js} +70 -3
  29. package/dist/{chunk-VBRHE4AM.cjs → chunk-U2ASNGO6.cjs} +35 -35
  30. package/dist/{chunk-WKPXOGZZ.cjs → chunk-WPLGZBNE.cjs} +23 -2
  31. package/dist/{chunk-N3UMQVKX.js → chunk-X4FTNFIA.js} +1 -1
  32. package/dist/{chunk-2OQGPEHH.cjs → chunk-XUDMRMXQ.cjs} +2 -2
  33. package/dist/{chunk-THBJHPUL.js → chunk-YNFSH72F.js} +1 -1
  34. package/dist/diagram/index.cjs +2 -2
  35. package/dist/diagram/index.js +1 -1
  36. package/dist/effects/index.cjs +8 -5
  37. package/dist/effects/index.js +6 -3
  38. package/dist/effects/pool.d.cts +9 -0
  39. package/dist/effects/pool.d.ts +9 -0
  40. package/dist/effects/types.d.cts +2 -0
  41. package/dist/effects/types.d.ts +2 -0
  42. package/dist/extensions/index.cjs +5 -5
  43. package/dist/extensions/index.js +4 -4
  44. package/dist/gradient/index.cjs +2 -2
  45. package/dist/gradient/index.js +1 -1
  46. package/dist/history/HistoryManager.d.cts +9 -0
  47. package/dist/history/HistoryManager.d.ts +9 -0
  48. package/dist/history/index.cjs +2 -2
  49. package/dist/history/index.js +1 -1
  50. package/dist/history/types.d.cts +5 -2
  51. package/dist/history/types.d.ts +5 -2
  52. package/dist/image-editor/index.cjs +784 -784
  53. package/dist/image-editor/index.js +4 -4
  54. package/dist/index.cjs +10 -10
  55. package/dist/index.js +4 -4
  56. package/dist/paint-editor/controller.d.cts +75 -4
  57. package/dist/paint-editor/controller.d.ts +75 -4
  58. package/dist/paint-editor/effect-paint.d.cts +33 -8
  59. package/dist/paint-editor/effect-paint.d.ts +33 -8
  60. package/dist/paint-editor/effects.d.cts +4 -0
  61. package/dist/paint-editor/effects.d.ts +4 -0
  62. package/dist/paint-editor/index.cjs +692 -398
  63. package/dist/paint-editor/index.js +362 -68
  64. package/dist/paint-editor/stroke.d.cts +13 -5
  65. package/dist/paint-editor/stroke.d.ts +13 -5
  66. package/dist/parameters/index.cjs +2 -2
  67. package/dist/parameters/index.js +1 -1
  68. package/dist/vector/index.cjs +998 -998
  69. package/dist/vector/index.js +4 -4
  70. package/dist/video-editor/index.cjs +321 -321
  71. package/dist/video-editor/index.js +4 -4
  72. package/package.json +1 -1
  73. package/skills/pixerate-paint-editor/SKILL.md +2 -2
  74. package/skills/pixerate-paint-editor/references/effect-paint.md +28 -6
@@ -22,7 +22,7 @@ import {
22
22
  } from "../chunk-LETF75CA.js";
23
23
  import {
24
24
  toVideoFfmpeg
25
- } from "../chunk-MWUZCEFT.js";
25
+ } from "../chunk-S2XPXA47.js";
26
26
  import {
27
27
  ToolRegistry
28
28
  } from "../chunk-K4BGRCSH.js";
@@ -34,7 +34,7 @@ import {
34
34
  resolveExternalAssets,
35
35
  serializeLayers,
36
36
  serializeLayersExternal
37
- } from "../chunk-4YSPQGZW.js";
37
+ } from "../chunk-B5W677L7.js";
38
38
  import "../chunk-ADMRNWR7.js";
39
39
  import "../chunk-WJ4WQJCC.js";
40
40
  import {
@@ -79,6 +79,7 @@ import {
79
79
  EFFECT_STRIP_HEIGHT,
80
80
  GPU_LEAD,
81
81
  IDENTITY_COPY,
82
+ MAX_EFFECT_PATH_POINTS,
82
83
  PAPER_FAMILIES,
83
84
  PAPER_LIBRARY,
84
85
  PaintDabStroke,
@@ -95,6 +96,7 @@ import {
95
96
  effectPaintKey,
96
97
  effectPaintSize,
97
98
  hexToRgb8,
99
+ inferEffectPathControl,
98
100
  isPaintEffectPaint,
99
101
  mixerTip,
100
102
  multiColorForDab,
@@ -109,11 +111,13 @@ import {
109
111
  sampleEffectAt,
110
112
  sampleEffectField,
111
113
  stampSampler,
114
+ strokePathPoints,
112
115
  tiltToOrientation,
113
116
  toStrokePoint,
114
117
  wetPaperInput,
115
- wetPigmentOf
116
- } from "../chunk-FUDP5QBB.js";
118
+ wetPigmentOf,
119
+ withStrokePath
120
+ } from "../chunk-TNHDCP4R.js";
117
121
  import {
118
122
  BRUSH_ID,
119
123
  BRUSH_PRESETS,
@@ -201,11 +205,12 @@ import {
201
205
  } from "../chunk-SZTI6EER.js";
202
206
  import {
203
207
  EffectRendererPool
204
- } from "../chunk-EHLOCVWB.js";
208
+ } from "../chunk-T2URHAJX.js";
205
209
  import {
206
210
  EffectUnsupportedError,
207
211
  EffectsSlot,
208
212
  createEffectToolProvider,
213
+ pathControlsOf,
209
214
  resolveEffectValues
210
215
  } from "../chunk-MV7CYFMJ.js";
211
216
  import "../chunk-V2ZCJRDM.js";
@@ -2103,7 +2108,6 @@ var PaintStrokeSession = class _PaintStrokeSession {
2103
2108
  const brush = init.brush;
2104
2109
  if (init.wet) this.wetDriver = init.wet.driver;
2105
2110
  if (init.light && !init.wet && !init.sticker) this.lightTwin = this.makeLightTwin(init.light.surface);
2106
- const effect = init.effect?.pixels ?? null;
2107
2111
  this.apply = {
2108
2112
  mode: brush.engine === "eraser" ? "erase" : "paint",
2109
2113
  color: init.color,
@@ -2114,31 +2118,7 @@ var PaintStrokeSession = class _PaintStrokeSession {
2114
2118
  selection: init.selection ?? null,
2115
2119
  offset: init.offset
2116
2120
  };
2117
- if (this.wetDriver) {
2118
- } else if (init.sticker) {
2119
- this.stickerSpray = new StickerSpray({ brush, target: this.tileTarget(), color: init.colorHex, seed: init.seed, copies: this.copies, nextId: init.sticker.nextId, paint: init.paint ?? null, effect, art: init.sticker.art, variant: init.sticker.variant });
2120
- } else if (init.thick) {
2121
- const rgb = (c) => [c[0] / 255, c[1] / 255, c[2] / 255];
2122
- this.thickDriver = new ThickStrokeDriver({ brush, target: this.tileTarget(), channels: init.thick.channels, color: rgb(init.color), load: init.mixerLoad, seed: init.seed, copies: this.copies, paint: init.paint ?? null, effect });
2123
- } else if (brush.engine === "mixer" || brush.engine === "blender" || brush.engine === "smudge" || usesPaintExchange(brush, Boolean(init.paint))) {
2124
- const rgb = (c) => [c[0] / 255, c[1] / 255, c[2] / 255];
2125
- this.wet = new PaintDabStroke(brush, {
2126
- target: this.tileTarget(),
2127
- color: rgb(init.color),
2128
- secondary: rgb(init.secondary ?? init.background),
2129
- load: init.mixerLoad,
2130
- grain: init.grain,
2131
- paperDepth: init.paperDepth,
2132
- seed: init.seed,
2133
- copies: this.copies,
2134
- paint: init.paint ?? null,
2135
- effect,
2136
- stampArt: init.stampArt
2137
- });
2138
- } else {
2139
- const opts = { grain: init.grain, paperDepth: init.paperDepth, seed: init.seed, copies: this.copies, paint: init.paint ?? null, effect, stampArt: init.stampArt };
2140
- this.dry = brush.engine === "particle" ? new ParticleStroke(brush, opts) : brush.engine === "bristle" ? new BristleStroke(brush, opts) : new DryStroke(brush, opts);
2141
- }
2121
+ if (!this.wetDriver) this.build(init.effect?.pixels ?? null);
2142
2122
  }
2143
2123
  host;
2144
2124
  init;
@@ -2167,6 +2147,52 @@ var PaintStrokeSession = class _PaintStrokeSession {
2167
2147
  result = null;
2168
2148
  /** The mask stencils this stroke was painted with when they are not the editor's (replays). */
2169
2149
  maskStencils = null;
2150
+ /** Sets up the marks' engine (every kind but wet, which the wet layer paints), sampling `effect` for effect paint. */
2151
+ build(effect) {
2152
+ const init = this.init;
2153
+ const brush = init.brush;
2154
+ this.dry = this.wet = this.thickDriver = this.stickerSpray = null;
2155
+ if (init.sticker) {
2156
+ this.stickerSpray = new StickerSpray({ brush, target: this.tileTarget(), color: init.colorHex, seed: init.seed, copies: this.copies, nextId: init.sticker.nextId, paint: init.paint ?? null, effect, art: init.sticker.art, variant: init.sticker.variant });
2157
+ } else if (init.thick) {
2158
+ const rgb = (c) => [c[0] / 255, c[1] / 255, c[2] / 255];
2159
+ this.thickDriver = new ThickStrokeDriver({ brush, target: this.tileTarget(), channels: init.thick.channels, color: rgb(init.color), load: init.mixerLoad, seed: init.seed, copies: this.copies, paint: init.paint ?? null, effect });
2160
+ } else if (brush.engine === "mixer" || brush.engine === "blender" || brush.engine === "smudge" || usesPaintExchange(brush, Boolean(init.paint))) {
2161
+ const rgb = (c) => [c[0] / 255, c[1] / 255, c[2] / 255];
2162
+ this.wet = new PaintDabStroke(brush, {
2163
+ target: this.tileTarget(),
2164
+ color: rgb(init.color),
2165
+ secondary: rgb(init.secondary ?? init.background),
2166
+ load: init.mixerLoad,
2167
+ grain: init.grain,
2168
+ paperDepth: init.paperDepth,
2169
+ seed: init.seed,
2170
+ copies: this.copies,
2171
+ paint: init.paint ?? null,
2172
+ effect,
2173
+ stampArt: init.stampArt
2174
+ });
2175
+ } else {
2176
+ const opts = { grain: init.grain, paperDepth: init.paperDepth, seed: init.seed, copies: this.copies, paint: init.paint ?? null, effect, stampArt: init.stampArt };
2177
+ this.dry = brush.engine === "particle" ? new ParticleStroke(brush, opts) : brush.engine === "bristle" ? new BristleStroke(brush, opts) : new DryStroke(brush, opts);
2178
+ }
2179
+ }
2180
+ /**
2181
+ * Paints the stroke so far again, sampling new effect pixels: a path-mapped effect rendered
2182
+ * along the path drawn so far (`map: { by: 'path' }`). Only the live preview changes; the stroke
2183
+ * keeps recording its own paint. Nothing for wet strokes or once the stroke has ended.
2184
+ */
2185
+ repaintEffect(pixels) {
2186
+ if (this.closed || this.wetDriver || !this.init.effect) return;
2187
+ const was = this.bounds;
2188
+ this.live = new Map(this.base.tiles);
2189
+ this.touched.clear();
2190
+ this.fresh.clear();
2191
+ this.bounds = null;
2192
+ this.build(pixels);
2193
+ const rects = this.points.length ? this.dry ? this.dry.push(this.points) : this.thickDriver ? this.thickDriver.push(this.points) : this.stickerSpray ? this.stickerSpray.push(this.points) : this.wet.push(this.points) : [];
2194
+ this.update(was ? [...rects, was] : rects);
2195
+ }
2170
2196
  /** The stroke's twin over the light channel: the same marks in grey at the brush's light amount (see `light.ts`). */
2171
2197
  makeLightTwin(light) {
2172
2198
  const init = this.init;
@@ -2703,7 +2729,8 @@ function varyStroke(stroke, m, v) {
2703
2729
  const brush = s !== 1 ? normalizePaintBrush({ ...stroke.brush, tip: { ...stroke.brush.tip, size: stroke.brush.tip.size * s } }) : stroke.brush;
2704
2730
  const out = { ...stroke, brush, points };
2705
2731
  if (stroke.paint?.type === "gradient" && s !== 1) out.paint = scalePaintGradient(stroke.paint, s);
2706
- if (stroke.paint?.type === "effect" && stroke.paint.map && s !== 1) out.paint = { ...stroke.paint, map: { ...stroke.paint.map, length: Math.min(4096, Math.max(16, Math.round(stroke.paint.map.length * s))) } };
2732
+ if (stroke.paint?.type === "effect" && stroke.paint.map?.by === "stroke" && s !== 1) out.paint = { ...stroke.paint, map: { ...stroke.paint.map, length: Math.min(4096, Math.max(16, Math.round(stroke.paint.map.length * s))) } };
2733
+ if (stroke.paint?.type === "effect" && stroke.paint.map?.by === "path") out.paint = withStrokePath(stroke.paint, points);
2707
2734
  if (stroke.copies?.length) {
2708
2735
  const inv = invertAffine(m);
2709
2736
  out.copies = stroke.copies.map((c) => composeAffine(m, composeAffine(c, inv)));
@@ -4067,7 +4094,7 @@ async function replayPainting(source, target, options = {}) {
4067
4094
  const chunk = Math.max(1, options.wetChunk ?? 0) || Infinity;
4068
4095
  let strokes = 0;
4069
4096
  const scaled = source.strokes.slice(0, maxStrokes).map((st) => s === 1 ? st : varyStroke(st, scaleM, { scale: s }));
4070
- await target.prepareEffectPaints(scaled.flatMap((st) => st.paint?.type === "effect" ? [st.paint] : []));
4097
+ await target.prepareEffectPaints(scaled.flatMap((st) => st.paint?.type === "effect" && st.paint.map?.by !== "path" ? [st.paint] : []));
4071
4098
  const advanceTo = async (clock) => {
4072
4099
  const now = target.getState().wet.clock;
4073
4100
  let left = clock - now;
@@ -4139,6 +4166,7 @@ async function replayPainting(source, target, options = {}) {
4139
4166
  const st = s === 1 ? item.stroke : varyStroke(item.stroke, scaleM, { scale: s });
4140
4167
  strokes++;
4141
4168
  if (!target.getLayer(st.layerId)) continue;
4169
+ if (st.paint?.type === "effect" && st.paint.map?.by === "path") await target.prepareEffectPaints([st.paint]);
4142
4170
  const init = { layerId: st.layerId, color: st.color, brush: st.brush, seed: st.seed, copies: st.copies ?? [], ...st.load ? { load: st.load } : {}, ...st.paint ? { paint: st.paint } : {}, snapToPerspective: false, stencils: false, maskStencils: (st.stencils ?? []).map((x) => s === 1 ? x : scaleStencil(x, s)) };
4143
4171
  if (st.brush.engine === "wet" && st.steps?.length === st.points.length) {
4144
4172
  await target.wetIdle();
@@ -4321,6 +4349,12 @@ var PaintEffectsRenderer = class {
4321
4349
  }
4322
4350
  return out;
4323
4351
  }
4352
+ renderSync(provider, id, size, request) {
4353
+ this.follow(provider);
4354
+ const out = this.pool.renderSync(provider, id, { ...size, depth: 8 }, request);
4355
+ if (!out || out.width !== size.width || out.height !== size.height || out.data.length !== size.width * size.height * 4) return null;
4356
+ return out;
4357
+ }
4324
4358
  dispose() {
4325
4359
  this.pool.clear();
4326
4360
  this.descriptions.clear();
@@ -4439,6 +4473,17 @@ var PaintEditorController = class _PaintEditorController {
4439
4473
  paintEpoch = 0;
4440
4474
  /** Rendered effect paints by `effectPaintKey` (most recently used last). */
4441
4475
  effectPaintPixels = /* @__PURE__ */ new Map();
4476
+ /** Primary path control name (`format: 'svg-path'`) per effect id, learned from `describeEffect`. */
4477
+ effectPathControls = /* @__PURE__ */ new Map();
4478
+ /** How many renders the running batch needs together (`prepareEffectPaints`; a synchronous replay reads them all). */
4479
+ effectPaintBatch = 0;
4480
+ /** The hand-painted stroke waiting for its path render (`finishPathStroke`), or `null`. */
4481
+ pathStroke = null;
4482
+ /**
4483
+ * The hand-painted path stroke's live preview: the effect rendered along the path drawn so far
4484
+ * (one render at a time; the newest path when it lands), repainted into the stroke as it lands.
4485
+ */
4486
+ pathPreview = null;
4442
4487
  mixerLoad = EMPTY_MIXER_LOAD;
4443
4488
  view = { ...DEFAULT_VIEW, pan: { ...DEFAULT_VIEW.pan } };
4444
4489
  hand;
@@ -4775,7 +4820,7 @@ var PaintEditorController = class _PaintEditorController {
4775
4820
  secondaryColor: this.secondaryColor,
4776
4821
  paint: this.gradient ?? { type: "color", color: this.color },
4777
4822
  effectPaint: this.effectPaintPending ?? this.effectPaint,
4778
- effectPaintRendering: Boolean(this.effectPaintPending),
4823
+ effectPaintRendering: Boolean(this.effectPaintPending || this.pathStroke),
4779
4824
  mixerLoad: this.mixerLoad,
4780
4825
  view: this.view,
4781
4826
  hand: this.hand,
@@ -4837,6 +4882,7 @@ var PaintEditorController = class _PaintEditorController {
4837
4882
  }
4838
4883
  requireIdle(what) {
4839
4884
  if (this.session) throw new PaintEditorError(`A stroke is in progress; end or cancel it before ${what}.`);
4885
+ if (this.pathStroke) throw new PaintEditorError(`The last stroke is still rendering the effect "${this.pathStroke.effect}" along its path; wait for it (strokeRendering) before ${what}.`);
4840
4886
  if (this.grouping?.pending && !this.grouping.inside) throw new PaintEditorError(`"${this.grouping.label}" is still being painted (one undo step); wait for it to finish before ${what}.`);
4841
4887
  }
4842
4888
  commit(label, next, before = this.doc) {
@@ -5481,6 +5527,8 @@ var PaintEditorController = class _PaintEditorController {
5481
5527
  /** The provider's description of an effect, checked for `place` (rejects without a provider or for an unknown id). */
5482
5528
  async describeEffect(effect, place) {
5483
5529
  const d = await this.effectsRenderer.describe(this.effects.require(), effect);
5530
+ const pcs = pathControlsOf(d);
5531
+ if (pcs.length) this.effectPathControls.set(d.id, pcs[0]);
5484
5532
  if (place) checkPaintEffectPlace(d, place);
5485
5533
  return d;
5486
5534
  }
@@ -5681,7 +5729,11 @@ var PaintEditorController = class _PaintEditorController {
5681
5729
  return { layerId: now.id, source: this.sourceOf(d, values, options.time) };
5682
5730
  }
5683
5731
  // --- Effect paint ----------------------------------------------------------------
5684
- /** At most this many rendered effect paints are kept (each is canvas-sized). */
5732
+ /**
5733
+ * At most this many rendered effect paints are kept (each is canvas-sized), more only while
5734
+ * something needs them all: a replay's batch, and the path-mapped strokes on stroke layers,
5735
+ * which each have a render of their own and are redrawn synchronously.
5736
+ */
5685
5737
  static EFFECT_PAINT_CACHE = 6;
5686
5738
  /** The effect paint strokes use (or the one rendering), or `null`. */
5687
5739
  getEffectPaint() {
@@ -5693,8 +5745,10 @@ var PaintEditorController = class _PaintEditorController {
5693
5745
  * size, and brushes then take each pixel's colour (and alpha) from it, fixed to the canvas, as if
5694
5746
  * painting a hidden picture into view. `setColor` and `setPaint` go back to colour. Editor state,
5695
5747
  * not an undo step. Strokes that would paint with it throw while it renders. Returns the paint.
5696
- * With `map: { by: 'stroke' }` the effect lies along each stroke instead of the canvas; with
5697
- * `live`, strokes on a stroke layer keep playing it (see `setEffectPlayback`).
5748
+ * With `map: { by: 'stroke' }` the effect lies along each stroke as a strip; with
5749
+ * `map: { by: 'path' }` an effect with a path control (`format: 'svg-path'`) draws itself along
5750
+ * each stroke's centreline in canvas pixels; with `live`, strokes on a stroke layer keep playing
5751
+ * it (see `setEffectPlayback`).
5698
5752
  */
5699
5753
  async setEffectPaint(source) {
5700
5754
  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).");
@@ -5709,6 +5763,9 @@ var PaintEditorController = class _PaintEditorController {
5709
5763
  const epoch = ++this.paintEpoch;
5710
5764
  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.`);
5711
5765
  const d = await this.describeEffect(source.effect, "paint");
5766
+ if (map?.by === "path" && !pathControlsOf(d).length) {
5767
+ throw new PaintEditorError(`Effect "${d.id}" has no path control (format "svg-path"); use by: "stroke" (bent along each stroke) or by: "canvas" (fixed to the canvas).`);
5768
+ }
5712
5769
  const values = this.effectValues(d, { ...d.defaults }, source.values);
5713
5770
  if (epoch !== this.paintEpoch) throw changed(d.name);
5714
5771
  const paint = { type: "effect", effect: d.id, provider: this.effects.require().id, values, time: source.time ?? 0, ...map ? { map } : {}, ...source.live ? { live: true } : {} };
@@ -5740,10 +5797,38 @@ var PaintEditorController = class _PaintEditorController {
5740
5797
  this.effectPaintPixels.set(key, img);
5741
5798
  return img;
5742
5799
  }
5800
+ cacheEffectPaintPixels(p, pixels) {
5801
+ return this.cacheEffectPaintImage(p, toEffectPaintPixels(p, pixels));
5802
+ }
5803
+ cacheEffectPaintImage(p, img) {
5804
+ this.effectPaintPixels.set(effectPaintKey(p), img);
5805
+ this.trimEffectPaints();
5806
+ return img;
5807
+ }
5808
+ trimEffectPaints() {
5809
+ let live = 0;
5810
+ for (const list of Object.values(this.doc.liveStrokes ?? {})) for (const s of list) if (s.paint?.type === "effect" && s.paint.map?.by === "path") live++;
5811
+ const limit = Math.max(_PaintEditorController.EFFECT_PAINT_CACHE, this.effectPaintBatch, live + 1);
5812
+ while (this.effectPaintPixels.size > limit) this.effectPaintPixels.delete(this.effectPaintPixels.keys().next().value);
5813
+ }
5814
+ /** Renders an effect paint on the caller's stack when its renderer is warm and has `renderSync`; otherwise `null`. */
5815
+ renderEffectPaintSync(p) {
5816
+ const have = this.effectPaintImage(p);
5817
+ if (have) return have;
5818
+ const provider = this.effects.provider;
5819
+ if (!provider || p.provider && provider.id !== p.provider) return null;
5820
+ const size = effectPaintSize(p, this.doc.canvas);
5821
+ const pixels = this.effectsRenderer.renderSync(provider, p.effect, size, { time: p.time, values: p.values });
5822
+ return pixels ? this.cacheEffectPaintPixels(p, pixels) : null;
5823
+ }
5743
5824
  /** Renders an effect paint at the canvas size into the cache (nothing when it is there already). */
5744
5825
  async renderEffectPaint(p) {
5745
5826
  const have = this.effectPaintImage(p);
5746
5827
  if (have) return have;
5828
+ return this.cacheEffectPaintImage(p, await this.renderEffectPaintPixels(p));
5829
+ }
5830
+ /** Renders an effect paint at the canvas size without keeping it. */
5831
+ async renderEffectPaintPixels(p) {
5747
5832
  const provider = this.effects.provider;
5748
5833
  const what = `the effect "${p.effect}"${p.provider ? ` from provider "${p.provider}"` : ""}`;
5749
5834
  if (!provider) throw new PaintEditorError(`Effect paint needs ${what}, but no effects provider is registered; register it (setEffectsProvider) and try again.`);
@@ -5755,28 +5840,107 @@ var PaintEditorController = class _PaintEditorController {
5755
5840
  } catch (e) {
5756
5841
  throw new PaintEditorError(`Effect paint could not render ${what}: ${e instanceof Error ? e.message : String(e)}`);
5757
5842
  }
5758
- const img = { width, height, data: new Uint8ClampedArray(pixels.data), ...p.map ? { map: p.map } : {} };
5759
- this.effectPaintPixels.set(effectPaintKey(p), img);
5760
- while (this.effectPaintPixels.size > _PaintEditorController.EFFECT_PAINT_CACHE) this.effectPaintPixels.delete(this.effectPaintPixels.keys().next().value);
5761
- return img;
5843
+ return toEffectPaintPixels(p, pixels);
5762
5844
  }
5763
5845
  /**
5764
5846
  * Renders effect paints that are not rendered yet (strokes and replays need them rendered
5765
5847
  * first, since strokes are synchronous). Throws naming the effect and provider when one cannot be.
5766
5848
  */
5767
5849
  async prepareEffectPaints(paints2) {
5850
+ const list = [...paints2];
5851
+ this.effectPaintBatch = new Set(list.map(effectPaintKey)).size;
5768
5852
  const seen = /* @__PURE__ */ new Set();
5769
- for (const p of paints2) {
5853
+ for (const p of list) {
5770
5854
  const key = effectPaintKey(p);
5771
5855
  if (seen.has(key)) continue;
5772
5856
  seen.add(key);
5773
5857
  await this.renderEffectPaint(p);
5774
5858
  }
5775
5859
  }
5860
+ /**
5861
+ * Renders the path-mapped effect paint for one or more strokes' points before a synchronous
5862
+ * stroke call runs (`paint_stroke` and `stroke_path` do; `paintStroke` throws without it on a
5863
+ * provider that can't render synchronously). For a stroke layer edit, see `prepareStrokeEdit`.
5864
+ */
5865
+ async prepareStrokePathEffect(init, pointsList) {
5866
+ const brush = this.strokeBrush(init);
5867
+ if (!brushTakesPaint(brush)) return;
5868
+ let source = null;
5869
+ if (init.paint !== void 0) {
5870
+ if (!isPaintEffectPaint(init.paint)) return;
5871
+ source = readPaintEffectPaint(init.paint);
5872
+ } else if (init.color === void 0 && !brush.paint && !this.effectPaintPending) {
5873
+ source = this.effectPaint;
5874
+ }
5875
+ if (!source || source.map?.by !== "path") return;
5876
+ if (!this.effectPathControls.has(source.effect)) await this.describeEffect(source.effect);
5877
+ const control = this.effectPathControls.get(source.effect);
5878
+ const paints2 = pointsList.filter((pts) => pts.length > 0).map((pts) => withStrokePath(source, pts, control));
5879
+ if (paints2.length) await this.prepareEffectPaints(paints2);
5880
+ }
5881
+ /**
5882
+ * Renders what an edit of a stroke layer's stroke paints with (`updateStroke`, `editStrokePoints`):
5883
+ * a new effect paint, or a path-mapped stroke's new path. Those calls are synchronous, and throw
5884
+ * without it on a provider that can't render synchronously. Bad edits are left for them to refuse.
5885
+ */
5886
+ async prepareStrokeEdit(id, edit) {
5887
+ const p = this.strokeEditPaint(id, edit);
5888
+ if (!p) return;
5889
+ if (p.map?.by === "path" && !this.effectPathControls.has(p.effect)) await this.describeEffect(p.effect);
5890
+ await this.prepareEffectPaints([this.strokeEditPaint(id, edit) ?? p]);
5891
+ }
5892
+ /** Whether `prepareStrokeEdit` has to be awaited before this edit can run. */
5893
+ strokeEditNeedsRender(id, edit) {
5894
+ const p = this.strokeEditPaint(id, edit);
5895
+ return Boolean(p && !this.effectPaintImage(p) && !this.renderEffectPaintSync(p));
5896
+ }
5897
+ strokeEditPaint(id, edit) {
5898
+ const was = Object.values(this.doc.liveStrokes ?? {}).flat().find((s) => s.id === id);
5899
+ if (!was) return null;
5900
+ try {
5901
+ if (edit.paint !== void 0) {
5902
+ if (!isPaintEffectPaint(edit.paint)) return null;
5903
+ const p = readPaintEffectPaint(edit.paint);
5904
+ return p && withStrokePath(p, was.points, this.effectPathControls.get(p.effect));
5905
+ }
5906
+ if (was.paint?.type !== "effect" || was.paint.map?.by !== "path") return null;
5907
+ if (edit.points !== void 0) return withStrokePath(was.paint, this.checkPoints(edit.points), this.effectPathControls.get(was.paint.effect));
5908
+ if (!edit.transform) return null;
5909
+ const varied = varyStroke(was, this.strokeTransform(was, edit.transform), {}).paint;
5910
+ return varied?.type === "effect" ? varied : null;
5911
+ } catch {
5912
+ return null;
5913
+ }
5914
+ }
5915
+ /** A stroke edit's transform as an affine map, checked. */
5916
+ strokeTransform(stroke, t) {
5917
+ if (t.scale !== void 0 && !(t.scale > 0 && t.scale <= 20)) throw new PaintEditorError(`scale ${t.scale} is out of range; use above 0 up to 20.`);
5918
+ for (const k of ["dx", "dy", "rotate"]) if (t[k] !== void 0 && !Number.isFinite(t[k])) throw new PaintEditorError(`${k} must be a number.`);
5919
+ const b = strokeBounds(stroke.points);
5920
+ return variationAffine(t, t.origin ?? { x: b.x + b.width / 2, y: b.y + b.height / 2 });
5921
+ }
5776
5922
  /** Renders the effect paints of recorded strokes (default all of them) before a replay. */
5777
- async prepareStrokeEffects(strokeIds) {
5923
+ async prepareStrokeEffects(strokeIds, variation) {
5778
5924
  const ids = strokeIds ? new Set(strokeIds) : null;
5779
- const paints2 = this.doc.strokes.filter((s) => (!ids || ids.has(s.id)) && s.paint?.type === "effect").map((s) => s.paint);
5925
+ const strokes = this.doc.strokes.filter((s) => (!ids || ids.has(s.id)) && s.paint?.type === "effect");
5926
+ const paints2 = strokes.map((s) => s.paint);
5927
+ if (variation && strokes.length) {
5928
+ const repeat = Math.max(1, Math.min(100, variation.repeat ?? 1));
5929
+ let origin = variation.origin;
5930
+ if (!origin) {
5931
+ const b = strokeBounds(strokes.flatMap((s) => s.points));
5932
+ origin = { x: b.x + b.width / 2, y: b.y + b.height / 2 };
5933
+ }
5934
+ const step = variationAffine(variation, origin);
5935
+ let m = IDENTITY_AFFINE;
5936
+ for (let k = 0; k < repeat; k++) {
5937
+ m = [step[0] * m[0] + step[1] * m[2], step[0] * m[1] + step[1] * m[3], step[2] * m[0] + step[3] * m[2], step[2] * m[1] + step[3] * m[3], step[0] * m[4] + step[1] * m[5] + step[4], step[2] * m[4] + step[3] * m[5] + step[5]];
5938
+ for (const s of strokes) {
5939
+ const v = varyStroke(s, m, variation);
5940
+ if (v.paint?.type === "effect") paints2.push(v.paint);
5941
+ }
5942
+ }
5943
+ }
5780
5944
  await this.prepareEffectPaints(paints2);
5781
5945
  }
5782
5946
  /** A small preview of the effect paint (a PNG data URL `size` px wide), or `null` without a rendered one. */
@@ -5804,7 +5968,7 @@ var PaintEditorController = class _PaintEditorController {
5804
5968
  * Throws while the editor's is still rendering, for an engine that cannot paint with it, or when
5805
5969
  * its pixels are not rendered.
5806
5970
  */
5807
- strokeEffect(init, brush) {
5971
+ strokeEffect(init, brush, points) {
5808
5972
  if (!brushTakesPaint(brush)) return null;
5809
5973
  let source = null;
5810
5974
  if (init.paint !== void 0) {
@@ -5816,8 +5980,10 @@ var PaintEditorController = class _PaintEditorController {
5816
5980
  source = this.effectPaint;
5817
5981
  }
5818
5982
  if (!source) return null;
5819
- const pixels = this.effectPaintImage(source);
5820
- 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.`);
5983
+ const path = source.map?.by === "path";
5984
+ if (path && points?.length) source = withStrokePath(source, points, this.effectPathControls.get(source.effect));
5985
+ const pixels = this.effectPaintImage(source) ?? (path ? this.renderEffectPaintSync(source) : null);
5986
+ if (!pixels) throw new PaintEditorError(`The effect paint "${source.effect}"${source.provider ? ` from provider "${source.provider}"` : ""} is not rendered at this canvas size; call ${path ? "prepareStrokePathEffect (the paint_stroke tool does this) or prepareStrokeEdit" : "set_effect_paint (or prepareEffectPaints)"} first.`);
5821
5987
  return { source, pixels };
5822
5988
  }
5823
5989
  /**
@@ -6205,7 +6371,7 @@ var PaintEditorController = class _PaintEditorController {
6205
6371
  strokeCopies() {
6206
6372
  return symmetryCopies(this.guides.symmetry);
6207
6373
  }
6208
- sessionFor(doc, init, host) {
6374
+ sessionFor(doc, init, host, points) {
6209
6375
  if (init.paint !== void 0 && !isPaintEffectPaint(init.paint)) {
6210
6376
  const p = this.readStrokePaint(init.paint);
6211
6377
  if (p.type === "color") init = { ...init, color: p.color, paint: void 0 };
@@ -6224,7 +6390,10 @@ var PaintEditorController = class _PaintEditorController {
6224
6390
  })();
6225
6391
  const brush = this.strokeBrush(init);
6226
6392
  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.`);
6227
- const effect = this.strokeEffect(init, brush);
6393
+ const effect = this.strokeEffect(init, brush, points);
6394
+ if (!points && init.paint === void 0 && effect?.source.map?.by === "path" && isWetBrush(brush)) {
6395
+ throw new PaintEditorError(`The ${brush.name} brush paints wet media, which can't follow a path that isn't known until the stroke ends; paint with a dry brush, set_effect_paint with by: "stroke", or give the points to paint_stroke.`);
6396
+ }
6228
6397
  if (brush.light.amount > 0) {
6229
6398
  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).`);
6230
6399
  }
@@ -6374,9 +6543,19 @@ var PaintEditorController = class _PaintEditorController {
6374
6543
  this.liveThick = ch ? { layerId, channels: ch } : null;
6375
6544
  this.emit(rects);
6376
6545
  },
6377
- sampled: (s, points) => this.notifySample("points", s, points),
6546
+ sampled: (s, points) => {
6547
+ this.notifySample("points", s, points);
6548
+ this.previewPathStroke(s);
6549
+ },
6378
6550
  commit: (s, surface, cancelled) => {
6379
6551
  this.session = null;
6552
+ const preview = this.pathPreview?.session === s ? this.pathPreview : null;
6553
+ this.pathPreview = null;
6554
+ const pathPaint = cancelled ? null : this.pathStrokePaint(s);
6555
+ if (pathPaint) {
6556
+ this.finishPathStroke(s, pathPaint, preview);
6557
+ return;
6558
+ }
6380
6559
  this.live = null;
6381
6560
  this.liveLayers = null;
6382
6561
  this.liveThick = null;
@@ -6425,14 +6604,99 @@ var PaintEditorController = class _PaintEditorController {
6425
6604
  throw e;
6426
6605
  }
6427
6606
  this.session = session;
6607
+ if (session.init.effect?.source.map?.by === "path" && !session.init.wet) this.pathPreview = { session, key: "", pending: null, landed: null };
6428
6608
  this.emit([]);
6429
6609
  this.notifySample("begin", session);
6430
6610
  return session;
6431
6611
  }
6612
+ /**
6613
+ * Renders a hand-painted path stroke's effect along the path drawn so far and paints the stroke
6614
+ * again with it when it lands, so the effect follows the pen while it draws. One render at a
6615
+ * time: when one lands, the next takes the newest path. A failed render leaves the preview as it
6616
+ * was; the render on release reports it.
6617
+ */
6618
+ previewPathStroke(s) {
6619
+ const pv = this.pathPreview;
6620
+ if (!pv || pv.session !== s || pv.pending || !s.open) return;
6621
+ const paint = this.pathStrokePaint(s);
6622
+ if (!paint) return;
6623
+ const key = effectPaintKey(paint);
6624
+ if (key === pv.key) return;
6625
+ pv.key = key;
6626
+ const cached = this.effectPaintImage(paint);
6627
+ const pending = cached ? Promise.resolve(cached) : this.renderEffectPaintPixels(paint);
6628
+ pv.pending = pending;
6629
+ pending.then(
6630
+ (pixels) => {
6631
+ pv.pending = null;
6632
+ pv.landed = { key, pixels };
6633
+ if (this.pathPreview !== pv || !s.open) return;
6634
+ s.repaintEffect(pixels);
6635
+ this.previewPathStroke(s);
6636
+ },
6637
+ () => {
6638
+ pv.pending = null;
6639
+ }
6640
+ );
6641
+ }
6432
6642
  /** The current live stroke, if any. */
6433
6643
  get activeStroke() {
6434
6644
  return this.session;
6435
6645
  }
6646
+ /**
6647
+ * While a hand-painted stroke waits for its path render (`map: { by: 'path' }`), settles when it
6648
+ * has landed and rejects if it could not (the stroke is dropped); `null` otherwise. Until then
6649
+ * the editor is busy, as during a stroke, and shows the stroke as it was painted.
6650
+ */
6651
+ get strokeRendering() {
6652
+ return this.pathStroke?.done ?? null;
6653
+ }
6654
+ /** The paint a finished hand-painted stroke must be painted again with, or `null` when it already painted with it. */
6655
+ pathStrokePaint(s) {
6656
+ const source = s.init.effect?.source;
6657
+ if (source?.map?.by !== "path" || !s.points.length) return null;
6658
+ const paint = withStrokePath(source, s.points, this.effectPathControls.get(source.effect));
6659
+ return effectPaintKey(paint) === effectPaintKey(source) ? null : paint;
6660
+ }
6661
+ /**
6662
+ * Ends a hand-painted stroke with a path-mapped effect. Its whole path isn't known until now, so
6663
+ * the live stroke was only a preview from renders along the path so far (`previewPathStroke`):
6664
+ * nothing of it was committed. Paints it again with the path's own render, as one stroke and one undo step, at
6665
+ * once if that can render synchronously and when the render lands if not.
6666
+ */
6667
+ finishPathStroke(s, paint, preview) {
6668
+ const finish = () => {
6669
+ this.pathStroke = null;
6670
+ this.live = null;
6671
+ this.liveLayers = null;
6672
+ this.liveThick = null;
6673
+ const masks = s.init.selection ? (s.maskStencils ?? this.guides.stencils).filter((x) => x.enabled && x.mode === "mask") : [];
6674
+ const load = s.init.mixerLoad;
6675
+ const result = this.paintStroke({ layerId: s.layerId, points: s.points, color: s.init.colorHex, brush: brushToJSON(s.init.brush), seed: s.init.seed, copies: s.init.affineCopies, ...load.reservoir || load.pickup ? { load } : {}, paint, snapToPerspective: false, stencils: false, maskStencils: masks });
6676
+ Object.assign(s.result, result);
6677
+ this.notifySample("end", s, [], result.strokeId);
6678
+ };
6679
+ const key = effectPaintKey(paint);
6680
+ if (preview?.landed?.key === key) this.cacheEffectPaintImage(paint, preview.landed.pixels);
6681
+ if (this.effectPaintImage(paint) || this.renderEffectPaintSync(paint)) {
6682
+ finish();
6683
+ return;
6684
+ }
6685
+ const render = preview?.pending && preview.key === key ? preview.pending.then((img) => this.cacheEffectPaintImage(paint, img)) : this.renderEffectPaint(paint);
6686
+ const done = render.then(finish, (e) => {
6687
+ this.pathStroke = null;
6688
+ this.live = null;
6689
+ this.liveLayers = null;
6690
+ this.liveThick = null;
6691
+ this.emit();
6692
+ this.notifySample("cancel", s, []);
6693
+ throw e;
6694
+ });
6695
+ done.catch(() => {
6696
+ });
6697
+ this.pathStroke = { effect: paint.effect, done };
6698
+ this.emit([]);
6699
+ }
6436
6700
  /** Runs a whole stroke against `doc` without touching the editor; returns the new document. */
6437
6701
  runStroke(doc, init, points) {
6438
6702
  let out = doc;
@@ -6459,7 +6723,7 @@ var PaintEditorController = class _PaintEditorController {
6459
6723
  }, stroke);
6460
6724
  }
6461
6725
  };
6462
- const session = this.sessionFor(doc, init, host);
6726
+ const session = this.sessionFor(doc, init, host, points);
6463
6727
  session.push(points);
6464
6728
  const result = session.end();
6465
6729
  if (session.init.wet) {
@@ -6609,6 +6873,8 @@ var PaintEditorController = class _PaintEditorController {
6609
6873
  }
6610
6874
  }
6611
6875
  if (doc !== this.doc) this.commit(strokes.length === 1 && repeat === 1 ? "Replay stroke" : "Replay strokes", doc);
6876
+ this.effectPaintBatch = 0;
6877
+ this.trimEffectPaints();
6612
6878
  return { strokeIds: ids, bounds, layerIds: [...layerIds] };
6613
6879
  }
6614
6880
  // --- Light paint --------------------------------------------------------------------
@@ -7912,8 +8178,9 @@ var PaintEditorController = class _PaintEditorController {
7912
8178
  if (update.paint !== void 0) {
7913
8179
  if (update.paint === null) delete next.paint;
7914
8180
  else if (isPaintEffectPaint(update.paint)) {
7915
- if (!this.effectPaintImage(update.paint)) throw new PaintEditorError(`The effect "${update.paint.effect}" is not rendered yet; render it first (prepareEffectPaints; the update_stroke tool does this).`);
7916
- next.paint = JSON.parse(JSON.stringify(update.paint));
8181
+ const ep = update.paint.map?.by === "path" ? withStrokePath(update.paint, next.points, this.effectPathControls.get(update.paint.effect)) : update.paint;
8182
+ if (!this.effectPaintImage(ep) && !this.renderEffectPaintSync(ep)) throw new PaintEditorError(`The effect "${update.paint.effect}" is not rendered yet; render it first (prepareEffectPaints; the update_stroke tool does this).`);
8183
+ next.paint = JSON.parse(JSON.stringify(ep));
7917
8184
  } else {
7918
8185
  const p = this.readStrokePaint(update.paint);
7919
8186
  if (p.type === "color") {
@@ -7944,13 +8211,11 @@ var PaintEditorController = class _PaintEditorController {
7944
8211
  if (edit.points !== void 0) {
7945
8212
  const points = this.checkPoints(edit.points);
7946
8213
  next = { ...was, points: points.map((p) => ({ ...p })) };
8214
+ if (next.paint?.type === "effect" && next.paint.map?.by === "path") {
8215
+ next.paint = withStrokePath(next.paint, next.points, this.effectPathControls.get(next.paint.effect));
8216
+ }
7947
8217
  } else {
7948
- const t = edit.transform;
7949
- if (t.scale !== void 0 && !(t.scale > 0 && t.scale <= 20)) throw new PaintEditorError(`scale ${t.scale} is out of range; use above 0 up to 20.`);
7950
- for (const k of ["dx", "dy", "rotate"]) if (t[k] !== void 0 && !Number.isFinite(t[k])) throw new PaintEditorError(`${k} must be a number.`);
7951
- const b = strokeBounds(was.points);
7952
- const m = variationAffine(t, t.origin ?? { x: b.x + b.width / 2, y: b.y + b.height / 2 });
7953
- next = varyStroke(was, m, {});
8218
+ next = varyStroke(was, this.strokeTransform(was, edit.transform), {});
7954
8219
  if (was.stencils) next.stencils = was.stencils;
7955
8220
  }
7956
8221
  const list = strokes.map((x, i) => i === index ? next : x);
@@ -8606,6 +8871,9 @@ function clipToCanvas(rect, canvas) {
8606
8871
  const y1 = Math.min(canvas.height, rect.y + rect.height);
8607
8872
  return x1 > x0 && y1 > y0 ? { x: x0, y: y0, width: x1 - x0, height: y1 - y0 } : null;
8608
8873
  }
8874
+ function toEffectPaintPixels(p, pixels) {
8875
+ return { width: pixels.width, height: pixels.height, data: new Uint8ClampedArray(pixels.data), ...p.map?.by === "stroke" ? { map: p.map } : {} };
8876
+ }
8609
8877
 
8610
8878
  // src/paint-editor/presence.ts
8611
8879
  var PaintPresence = class extends PresenceStore {
@@ -10320,6 +10588,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
10320
10588
  if (refs.length) await c.prepareStickers(refs);
10321
10589
  }
10322
10590
  const stroke = { points: a.points, layerId: a.layerId, brushId: a.brushId, brush, color: a.color, paint: a.paint, seed: a.seed, snapToPerspective: a.snapToPerspective };
10591
+ await c.prepareStrokePathEffect(stroke, [stroke.points]);
10323
10592
  const r = await act({ kind: "stroke", stroke }, () => c.paintStroke(stroke));
10324
10593
  return r.mixerLoad ? { ...r, mixerLoad: loadResult(r.mixerLoad) } : r;
10325
10594
  }
@@ -10357,6 +10626,13 @@ function createPaintEditorToolProvider(controller, options = {}) {
10357
10626
  }
10358
10627
  const path = options.origin && typeof a.path === "string" && a.path.trim() ? bezierPathToSvg(a.path, { offset: { x: -options.origin.x, y: -options.origin.y } }) : a.path;
10359
10628
  const init = { path, spacing: a.spacing, pressure: a.pressure, speed: a.speed, layerId: a.layerId, brushId: a.brushId, brush, color: a.color, paint: a.paint, seed: a.seed };
10629
+ if (typeof path === "string" && path.trim()) {
10630
+ try {
10631
+ const sampled = samplePath(path, init);
10632
+ await c.prepareStrokePathEffect(init, sampled);
10633
+ } catch {
10634
+ }
10635
+ }
10360
10636
  const r = await act(
10361
10637
  () => {
10362
10638
  if (typeof path !== "string" || !path.trim()) throw new PaintEditorError('path is empty; give SVG path data in document pixels, e.g. "M 10 10 C 40 0 80 60 120 30".');
@@ -10508,7 +10784,8 @@ function createPaintEditorToolProvider(controller, options = {}) {
10508
10784
  },
10509
10785
  annotations: { title: "Move or reshape a stroke", ...WRITE5 },
10510
10786
  coordinates: { in: ["points[]", "transform.origin"], out: ["bounds", "stroke.bounds", "stroke.points[]"] },
10511
- handler: ({ id, points, transform }) => {
10787
+ handler: async ({ id, points, transform }) => {
10788
+ await c.prepareStrokeEdit(id, { points, transform });
10512
10789
  const r = c.editStrokePoints(id, { points, transform });
10513
10790
  return { stroke: strokeSummary(r.stroke, true, true), bounds: r.bounds };
10514
10791
  }
@@ -10591,7 +10868,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
10591
10868
  coordinates: { in: ["origin"], out: ["bounds"] },
10592
10869
  handler: async (a) => {
10593
10870
  const replay = a;
10594
- if (replay.color === void 0) await c.prepareStrokeEffects(replay.strokeIds);
10871
+ if (replay.color === void 0) await c.prepareStrokeEffects(replay.strokeIds, replay);
10595
10872
  return act({ kind: "replay", replay }, () => c.replayStrokes(replay));
10596
10873
  }
10597
10874
  });
@@ -10967,15 +11244,28 @@ var PaintGesture = class {
10967
11244
  if (!m || m.pointerId !== e.pointerId) return;
10968
11245
  this.mode = null;
10969
11246
  this.predicted = [];
10970
- if (m.kind === "stroke") this.controller.activeStroke?.end();
11247
+ if (m.kind === "stroke") {
11248
+ this.controller.activeStroke?.end();
11249
+ this.controller.strokeRendering?.catch((err) => {
11250
+ this.error = err instanceof Error ? err.message : String(err);
11251
+ });
11252
+ }
10971
11253
  if (m.kind === "move-stroke") {
10972
11254
  const dx = Math.round((this.doc(e).x - m.from.x) * 100) / 100;
10973
11255
  const dy = Math.round((this.doc(e).y - m.from.y) * 100) / 100;
10974
11256
  if (dx || dy) {
10975
- try {
10976
- this.controller.editStrokePoints(m.id, { transform: { dx, dy } });
10977
- } catch (err) {
11257
+ const edit = { transform: { dx, dy } };
11258
+ const failed = (err) => {
10978
11259
  this.error = err instanceof Error ? err.message : String(err);
11260
+ };
11261
+ if (this.controller.strokeEditNeedsRender(m.id, edit)) {
11262
+ this.controller.prepareStrokeEdit(m.id, edit).then(() => this.controller.editStrokePoints(m.id, edit)).catch(failed);
11263
+ } else {
11264
+ try {
11265
+ this.controller.editStrokePoints(m.id, edit);
11266
+ } catch (err) {
11267
+ failed(err);
11268
+ }
10979
11269
  }
10980
11270
  }
10981
11271
  }
@@ -11482,9 +11772,9 @@ function createPaintEffectsToolProvider(controller, options = {}) {
11482
11772
  time: { type: "number", minimum: 0, description: "Which moment of an animated effect to paint with, in ms (default 0)." },
11483
11773
  map: {
11484
11774
  type: "object",
11485
- description: "How the effect lies on the paint: by canvas (default: fixed to the canvas, painting reveals a hidden picture) or by stroke (along each stroke: the effect is rendered as a strip length px long, and each pixel takes the colour at its distance along the stroke and its place across the tip).",
11775
+ description: `How the effect lies on the paint: by canvas (default: fixed to the canvas, painting reveals a hidden picture), by stroke (along each stroke: the effect is rendered as a strip length px long, and each pixel takes the colour at its distance along the stroke and its place across the tip), or by path (for effects with a path control format svg-path, e.g. search_effects accepts ["path"]: passes each stroke's centreline [x0, y0, x1, y1, \u2026] in canvas pixels to that control so the effect draws itself along the stroke; strokes longer than 256 points are simplified rather than refused).`,
11486
11776
  properties: {
11487
- by: { type: "string", enum: ["canvas", "stroke"], description: "canvas (default) or stroke." },
11777
+ by: { type: "string", enum: ["canvas", "stroke", "path"], description: "canvas (default), stroke (bent strip along each stroke) or path (native path control for effects with format svg-path)." },
11488
11778
  length: { type: "number", minimum: 16, maximum: 4096, description: "by stroke: px of stroke per repeat of the effect (default 300)." },
11489
11779
  repeat: { type: "string", enum: ["repeat", "mirror", "clamp"], description: "by stroke: past the strip end start again (default), run back, or hold the last colour." }
11490
11780
  },
@@ -12209,6 +12499,7 @@ export {
12209
12499
  MAX_CANVAS_SIDE,
12210
12500
  MAX_COORDINATE,
12211
12501
  MAX_DOT_TEXTURES,
12502
+ MAX_EFFECT_PATH_POINTS,
12212
12503
  MAX_IMAGE_SIDE,
12213
12504
  MAX_LIVE_EFFECT_SOURCES,
12214
12505
  MAX_PATH_SAMPLES,
@@ -12370,6 +12661,7 @@ export {
12370
12661
  hslToRgb,
12371
12662
  hsvToRgb,
12372
12663
  hueToWheel,
12664
+ inferEffectPathControl,
12373
12665
  invertAffine,
12374
12666
  isBlendMode,
12375
12667
  isGradientPaint,
@@ -12492,6 +12784,7 @@ export {
12492
12784
  stickerSurface,
12493
12785
  strokeBounds,
12494
12786
  strokeDuration,
12787
+ strokePathPoints,
12495
12788
  strokeSchedule,
12496
12789
  strokeToJSON,
12497
12790
  sweepPath,
@@ -12524,5 +12817,6 @@ export {
12524
12817
  wheelPick,
12525
12818
  wheelPoint,
12526
12819
  wheelToHue,
12527
- withPaintPlayback
12820
+ withPaintPlayback,
12821
+ withStrokePath
12528
12822
  };