@pixerate/editor 0.270.0 → 0.272.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 (77) hide show
  1. package/dist/animation-editor/index.cjs +62 -62
  2. package/dist/animation-editor/index.js +5 -5
  3. package/dist/brushes/index.cjs +69 -66
  4. package/dist/brushes/index.js +6 -3
  5. package/dist/brushes/patch.d.cts +1 -1
  6. package/dist/brushes/patch.d.ts +1 -1
  7. package/dist/brushes/schema.d.cts +1 -0
  8. package/dist/brushes/schema.d.ts +1 -0
  9. package/dist/brushes/types.d.cts +8 -0
  10. package/dist/brushes/types.d.ts +8 -0
  11. package/dist/{brushstrokeRender-WR2N5HHY.cjs → brushstrokeRender-5YBCLFDQ.cjs} +15 -15
  12. package/dist/{brushstrokeRender-A727TTNQ.js → brushstrokeRender-DZGFU3J4.js} +3 -3
  13. package/dist/canvas/index.cjs +15 -15
  14. package/dist/canvas/index.js +2 -2
  15. package/dist/{chunk-THBJHPUL.js → chunk-2F3YDXCN.js} +1 -1
  16. package/dist/{chunk-4CPOKP27.js → chunk-2XDNF73E.js} +1 -1
  17. package/dist/{chunk-3KNQ2W6X.cjs → chunk-3JKCREEU.cjs} +59 -53
  18. package/dist/{chunk-4YSPQGZW.js → chunk-5HP2VSAS.js} +1 -1
  19. package/dist/{chunk-BPJSI4NT.cjs → chunk-7W2EQQBP.cjs} +2 -2
  20. package/dist/{chunk-LETF75CA.js → chunk-CLB2I2FU.js} +1 -1
  21. package/dist/{chunk-BAZMCFVL.js → chunk-DZVRH63Y.js} +1 -1
  22. package/dist/{chunk-PY4GWSE2.cjs → chunk-EBFPMSJO.cjs} +27 -5
  23. package/dist/{chunk-VBRHE4AM.cjs → chunk-FXVCB6YG.cjs} +35 -35
  24. package/dist/{chunk-EU2ZZA6L.cjs → chunk-GHGBIAWQ.cjs} +126 -39
  25. package/dist/{chunk-CBVNCWJM.cjs → chunk-LBDC5434.cjs} +2 -2
  26. package/dist/{chunk-2WOGXEDX.cjs → chunk-M3RZOF3G.cjs} +1 -1
  27. package/dist/{chunk-GSTSZPSR.js → chunk-N3SKPQEH.js} +225 -18
  28. package/dist/{chunk-J5VQYK7T.cjs → chunk-N5IMDAHM.cjs} +251 -44
  29. package/dist/{chunk-ININNHLM.js → chunk-NAHJV3O6.js} +11 -5
  30. package/dist/{chunk-MWUZCEFT.js → chunk-S2XPXA47.js} +1 -1
  31. package/dist/{chunk-56RHCIJV.cjs → chunk-SNAFFCAX.cjs} +296 -203
  32. package/dist/{chunk-EHLOCVWB.js → chunk-T2URHAJX.js} +27 -5
  33. package/dist/{chunk-BENSE3VX.cjs → chunk-TNLKFW7D.cjs} +23 -23
  34. package/dist/{chunk-KZI3YWKW.js → chunk-UXYCY3HP.js} +93 -6
  35. package/dist/{chunk-4NMPUJUK.cjs → chunk-YJANF2BL.cjs} +86 -86
  36. package/dist/{chunk-FUDP5QBB.js → chunk-ZEI7ZEA4.js} +101 -8
  37. package/dist/effects/index.cjs +5 -2
  38. package/dist/effects/index.js +4 -1
  39. package/dist/effects/pool.d.cts +9 -0
  40. package/dist/effects/pool.d.ts +9 -0
  41. package/dist/effects/types.d.cts +2 -0
  42. package/dist/effects/types.d.ts +2 -0
  43. package/dist/image-editor/index.cjs +791 -791
  44. package/dist/image-editor/index.js +6 -6
  45. package/dist/paint/index.cjs +2 -2
  46. package/dist/paint/index.js +1 -1
  47. package/dist/paint/wet/world.d.cts +37 -1
  48. package/dist/paint/wet/world.d.ts +37 -1
  49. package/dist/paint-editor/brush-controls.d.cts +2 -0
  50. package/dist/paint-editor/brush-controls.d.ts +2 -0
  51. package/dist/paint-editor/brush-model.d.cts +14 -1
  52. package/dist/paint-editor/brush-model.d.ts +14 -1
  53. package/dist/paint-editor/controller.d.cts +75 -4
  54. package/dist/paint-editor/controller.d.ts +75 -4
  55. package/dist/paint-editor/effect-paint.d.cts +33 -8
  56. package/dist/paint-editor/effect-paint.d.ts +33 -8
  57. package/dist/paint-editor/effects.d.cts +4 -0
  58. package/dist/paint-editor/effects.d.ts +4 -0
  59. package/dist/paint-editor/index.cjs +814 -483
  60. package/dist/paint-editor/index.js +408 -77
  61. package/dist/paint-editor/stroke.d.cts +13 -5
  62. package/dist/paint-editor/stroke.d.ts +13 -5
  63. package/dist/paint-editor/ui.d.cts +1 -1
  64. package/dist/paint-editor/ui.d.ts +1 -1
  65. package/dist/paint-editor/wet.d.cts +8 -1
  66. package/dist/paint-editor/wet.d.ts +8 -1
  67. package/dist/vector/index.cjs +999 -999
  68. package/dist/vector/index.js +5 -5
  69. package/dist/video-editor/index.cjs +317 -317
  70. package/dist/video-editor/index.js +2 -2
  71. package/package.json +1 -1
  72. package/skills/pixerate-brushes/SKILL.md +1 -0
  73. package/skills/pixerate-paint-editor/SKILL.md +6 -13
  74. package/skills/pixerate-paint-editor/references/brush-facets.md +1 -0
  75. package/skills/pixerate-paint-editor/references/effect-paint.md +28 -6
  76. package/skills/pixerate-paint-editor/references/ghost.md +47 -0
  77. package/skills/pixerate-paint-watercolour/SKILL.md +1 -0
@@ -6,12 +6,13 @@ import {
6
6
  BRUSH_PATCH_PROPERTIES,
7
7
  BRUSH_SLIDERS,
8
8
  BRUSH_UNSET_PROPERTY,
9
+ GHOST_SLIDERS,
9
10
  brushEngineControls,
10
11
  createBrushLibraryToolProvider,
11
12
  mergeBrushPatch,
12
13
  multiColorSliders,
13
14
  patchBrushValue
14
- } from "../chunk-ININNHLM.js";
15
+ } from "../chunk-NAHJV3O6.js";
15
16
  import {
16
17
  createStickerTools,
17
18
  describeStickerRef
@@ -19,10 +20,10 @@ import {
19
20
  import {
20
21
  BRUSH_PREVIEW_DARK_PAPER,
21
22
  brushPreviewsOnDark
22
- } from "../chunk-LETF75CA.js";
23
+ } from "../chunk-CLB2I2FU.js";
23
24
  import {
24
25
  toVideoFfmpeg
25
- } from "../chunk-MWUZCEFT.js";
26
+ } from "../chunk-S2XPXA47.js";
26
27
  import {
27
28
  ToolRegistry
28
29
  } from "../chunk-K4BGRCSH.js";
@@ -34,7 +35,7 @@ import {
34
35
  resolveExternalAssets,
35
36
  serializeLayers,
36
37
  serializeLayersExternal
37
- } from "../chunk-4YSPQGZW.js";
38
+ } from "../chunk-5HP2VSAS.js";
38
39
  import "../chunk-ADMRNWR7.js";
39
40
  import "../chunk-WJ4WQJCC.js";
40
41
  import {
@@ -79,6 +80,7 @@ import {
79
80
  EFFECT_STRIP_HEIGHT,
80
81
  GPU_LEAD,
81
82
  IDENTITY_COPY,
83
+ MAX_EFFECT_PATH_POINTS,
82
84
  PAPER_FAMILIES,
83
85
  PAPER_LIBRARY,
84
86
  PaintDabStroke,
@@ -95,6 +97,7 @@ import {
95
97
  effectPaintKey,
96
98
  effectPaintSize,
97
99
  hexToRgb8,
100
+ inferEffectPathControl,
98
101
  isPaintEffectPaint,
99
102
  mixerTip,
100
103
  multiColorForDab,
@@ -109,11 +112,13 @@ import {
109
112
  sampleEffectAt,
110
113
  sampleEffectField,
111
114
  stampSampler,
115
+ strokePathPoints,
112
116
  tiltToOrientation,
113
117
  toStrokePoint,
114
118
  wetPaperInput,
115
- wetPigmentOf
116
- } from "../chunk-FUDP5QBB.js";
119
+ wetPigmentOf,
120
+ withStrokePath
121
+ } from "../chunk-ZEI7ZEA4.js";
117
122
  import {
118
123
  BRUSH_ID,
119
124
  BRUSH_PRESETS,
@@ -125,6 +130,7 @@ import {
125
130
  DEFAULT_BRISTLE,
126
131
  DEFAULT_BRUSH_ID,
127
132
  DEFAULT_DYNAMICS,
133
+ DEFAULT_GHOST,
128
134
  DEFAULT_IMPASTO,
129
135
  DEFAULT_LIGHT,
130
136
  DEFAULT_MULTI_COLOR,
@@ -164,6 +170,7 @@ import {
164
170
  normalizeBristle,
165
171
  normalizeBrushSpec,
166
172
  normalizeDynamics,
173
+ normalizeGhost,
167
174
  normalizeImpasto,
168
175
  normalizeLight,
169
176
  normalizeMultiColor,
@@ -176,7 +183,7 @@ import {
176
183
  readBrushSource,
177
184
  resolveBrush,
178
185
  usesPaintExchange
179
- } from "../chunk-KZI3YWKW.js";
186
+ } from "../chunk-UXYCY3HP.js";
180
187
  import {
181
188
  STAMP_KINDS,
182
189
  StickersSlot,
@@ -201,11 +208,12 @@ import {
201
208
  } from "../chunk-SZTI6EER.js";
202
209
  import {
203
210
  EffectRendererPool
204
- } from "../chunk-EHLOCVWB.js";
211
+ } from "../chunk-T2URHAJX.js";
205
212
  import {
206
213
  EffectUnsupportedError,
207
214
  EffectsSlot,
208
215
  createEffectToolProvider,
216
+ pathControlsOf,
209
217
  resolveEffectValues
210
218
  } from "../chunk-MV7CYFMJ.js";
211
219
  import "../chunk-V2ZCJRDM.js";
@@ -223,7 +231,7 @@ import {
223
231
  normalizeWetParams,
224
232
  pigmentOf,
225
233
  wetSupport
226
- } from "../chunk-GSTSZPSR.js";
234
+ } from "../chunk-N3SKPQEH.js";
227
235
  import "../chunk-W6JDUXP3.js";
228
236
  import {
229
237
  CoverageMap,
@@ -2103,7 +2111,6 @@ var PaintStrokeSession = class _PaintStrokeSession {
2103
2111
  const brush = init.brush;
2104
2112
  if (init.wet) this.wetDriver = init.wet.driver;
2105
2113
  if (init.light && !init.wet && !init.sticker) this.lightTwin = this.makeLightTwin(init.light.surface);
2106
- const effect = init.effect?.pixels ?? null;
2107
2114
  this.apply = {
2108
2115
  mode: brush.engine === "eraser" ? "erase" : "paint",
2109
2116
  color: init.color,
@@ -2114,31 +2121,7 @@ var PaintStrokeSession = class _PaintStrokeSession {
2114
2121
  selection: init.selection ?? null,
2115
2122
  offset: init.offset
2116
2123
  };
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
- }
2124
+ if (!this.wetDriver) this.build(init.effect?.pixels ?? null);
2142
2125
  }
2143
2126
  host;
2144
2127
  init;
@@ -2167,6 +2150,52 @@ var PaintStrokeSession = class _PaintStrokeSession {
2167
2150
  result = null;
2168
2151
  /** The mask stencils this stroke was painted with when they are not the editor's (replays). */
2169
2152
  maskStencils = null;
2153
+ /** Sets up the marks' engine (every kind but wet, which the wet layer paints), sampling `effect` for effect paint. */
2154
+ build(effect) {
2155
+ const init = this.init;
2156
+ const brush = init.brush;
2157
+ this.dry = this.wet = this.thickDriver = this.stickerSpray = null;
2158
+ if (init.sticker) {
2159
+ 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 });
2160
+ } else if (init.thick) {
2161
+ const rgb = (c) => [c[0] / 255, c[1] / 255, c[2] / 255];
2162
+ 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 });
2163
+ } else if (brush.engine === "mixer" || brush.engine === "blender" || brush.engine === "smudge" || usesPaintExchange(brush, Boolean(init.paint))) {
2164
+ const rgb = (c) => [c[0] / 255, c[1] / 255, c[2] / 255];
2165
+ this.wet = new PaintDabStroke(brush, {
2166
+ target: this.tileTarget(),
2167
+ color: rgb(init.color),
2168
+ secondary: rgb(init.secondary ?? init.background),
2169
+ load: init.mixerLoad,
2170
+ grain: init.grain,
2171
+ paperDepth: init.paperDepth,
2172
+ seed: init.seed,
2173
+ copies: this.copies,
2174
+ paint: init.paint ?? null,
2175
+ effect,
2176
+ stampArt: init.stampArt
2177
+ });
2178
+ } else {
2179
+ const opts = { grain: init.grain, paperDepth: init.paperDepth, seed: init.seed, copies: this.copies, paint: init.paint ?? null, effect, stampArt: init.stampArt };
2180
+ this.dry = brush.engine === "particle" ? new ParticleStroke(brush, opts) : brush.engine === "bristle" ? new BristleStroke(brush, opts) : new DryStroke(brush, opts);
2181
+ }
2182
+ }
2183
+ /**
2184
+ * Paints the stroke so far again, sampling new effect pixels: a path-mapped effect rendered
2185
+ * along the path drawn so far (`map: { by: 'path' }`). Only the live preview changes; the stroke
2186
+ * keeps recording its own paint. Nothing for wet strokes or once the stroke has ended.
2187
+ */
2188
+ repaintEffect(pixels) {
2189
+ if (this.closed || this.wetDriver || !this.init.effect) return;
2190
+ const was = this.bounds;
2191
+ this.live = new Map(this.base.tiles);
2192
+ this.touched.clear();
2193
+ this.fresh.clear();
2194
+ this.bounds = null;
2195
+ this.build(pixels);
2196
+ 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) : [];
2197
+ this.update(was ? [...rects, was] : rects);
2198
+ }
2170
2199
  /** The stroke's twin over the light channel: the same marks in grey at the brush's light amount (see `light.ts`). */
2171
2200
  makeLightTwin(light) {
2172
2201
  const init = this.init;
@@ -2703,7 +2732,8 @@ function varyStroke(stroke, m, v) {
2703
2732
  const brush = s !== 1 ? normalizePaintBrush({ ...stroke.brush, tip: { ...stroke.brush.tip, size: stroke.brush.tip.size * s } }) : stroke.brush;
2704
2733
  const out = { ...stroke, brush, points };
2705
2734
  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))) } };
2735
+ 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))) } };
2736
+ if (stroke.paint?.type === "effect" && stroke.paint.map?.by === "path") out.paint = withStrokePath(stroke.paint, points);
2707
2737
  if (stroke.copies?.length) {
2708
2738
  const inv = invertAffine(m);
2709
2739
  out.copies = stroke.copies.map((c) => composeAffine(m, composeAffine(c, inv)));
@@ -4067,7 +4097,7 @@ async function replayPainting(source, target, options = {}) {
4067
4097
  const chunk = Math.max(1, options.wetChunk ?? 0) || Infinity;
4068
4098
  let strokes = 0;
4069
4099
  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] : []));
4100
+ await target.prepareEffectPaints(scaled.flatMap((st) => st.paint?.type === "effect" && st.paint.map?.by !== "path" ? [st.paint] : []));
4071
4101
  const advanceTo = async (clock) => {
4072
4102
  const now = target.getState().wet.clock;
4073
4103
  let left = clock - now;
@@ -4139,6 +4169,7 @@ async function replayPainting(source, target, options = {}) {
4139
4169
  const st = s === 1 ? item.stroke : varyStroke(item.stroke, scaleM, { scale: s });
4140
4170
  strokes++;
4141
4171
  if (!target.getLayer(st.layerId)) continue;
4172
+ if (st.paint?.type === "effect" && st.paint.map?.by === "path") await target.prepareEffectPaints([st.paint]);
4142
4173
  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
4174
  if (st.brush.engine === "wet" && st.steps?.length === st.points.length) {
4144
4175
  await target.wetIdle();
@@ -4321,6 +4352,12 @@ var PaintEffectsRenderer = class {
4321
4352
  }
4322
4353
  return out;
4323
4354
  }
4355
+ renderSync(provider, id, size, request) {
4356
+ this.follow(provider);
4357
+ const out = this.pool.renderSync(provider, id, { ...size, depth: 8 }, request);
4358
+ if (!out || out.width !== size.width || out.height !== size.height || out.data.length !== size.width * size.height * 4) return null;
4359
+ return out;
4360
+ }
4324
4361
  dispose() {
4325
4362
  this.pool.clear();
4326
4363
  this.descriptions.clear();
@@ -4439,6 +4476,17 @@ var PaintEditorController = class _PaintEditorController {
4439
4476
  paintEpoch = 0;
4440
4477
  /** Rendered effect paints by `effectPaintKey` (most recently used last). */
4441
4478
  effectPaintPixels = /* @__PURE__ */ new Map();
4479
+ /** Primary path control name (`format: 'svg-path'`) per effect id, learned from `describeEffect`. */
4480
+ effectPathControls = /* @__PURE__ */ new Map();
4481
+ /** How many renders the running batch needs together (`prepareEffectPaints`; a synchronous replay reads them all). */
4482
+ effectPaintBatch = 0;
4483
+ /** The hand-painted stroke waiting for its path render (`finishPathStroke`), or `null`. */
4484
+ pathStroke = null;
4485
+ /**
4486
+ * The hand-painted path stroke's live preview: the effect rendered along the path drawn so far
4487
+ * (one render at a time; the newest path when it lands), repainted into the stroke as it lands.
4488
+ */
4489
+ pathPreview = null;
4442
4490
  mixerLoad = EMPTY_MIXER_LOAD;
4443
4491
  view = { ...DEFAULT_VIEW, pan: { ...DEFAULT_VIEW.pan } };
4444
4492
  hand;
@@ -4775,7 +4823,7 @@ var PaintEditorController = class _PaintEditorController {
4775
4823
  secondaryColor: this.secondaryColor,
4776
4824
  paint: this.gradient ?? { type: "color", color: this.color },
4777
4825
  effectPaint: this.effectPaintPending ?? this.effectPaint,
4778
- effectPaintRendering: Boolean(this.effectPaintPending),
4826
+ effectPaintRendering: Boolean(this.effectPaintPending || this.pathStroke),
4779
4827
  mixerLoad: this.mixerLoad,
4780
4828
  view: this.view,
4781
4829
  hand: this.hand,
@@ -4837,6 +4885,7 @@ var PaintEditorController = class _PaintEditorController {
4837
4885
  }
4838
4886
  requireIdle(what) {
4839
4887
  if (this.session) throw new PaintEditorError(`A stroke is in progress; end or cancel it before ${what}.`);
4888
+ 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
4889
  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
4890
  }
4842
4891
  commit(label, next, before = this.doc) {
@@ -5481,6 +5530,8 @@ var PaintEditorController = class _PaintEditorController {
5481
5530
  /** The provider's description of an effect, checked for `place` (rejects without a provider or for an unknown id). */
5482
5531
  async describeEffect(effect, place) {
5483
5532
  const d = await this.effectsRenderer.describe(this.effects.require(), effect);
5533
+ const pcs = pathControlsOf(d);
5534
+ if (pcs.length) this.effectPathControls.set(d.id, pcs[0]);
5484
5535
  if (place) checkPaintEffectPlace(d, place);
5485
5536
  return d;
5486
5537
  }
@@ -5681,7 +5732,11 @@ var PaintEditorController = class _PaintEditorController {
5681
5732
  return { layerId: now.id, source: this.sourceOf(d, values, options.time) };
5682
5733
  }
5683
5734
  // --- Effect paint ----------------------------------------------------------------
5684
- /** At most this many rendered effect paints are kept (each is canvas-sized). */
5735
+ /**
5736
+ * At most this many rendered effect paints are kept (each is canvas-sized), more only while
5737
+ * something needs them all: a replay's batch, and the path-mapped strokes on stroke layers,
5738
+ * which each have a render of their own and are redrawn synchronously.
5739
+ */
5685
5740
  static EFFECT_PAINT_CACHE = 6;
5686
5741
  /** The effect paint strokes use (or the one rendering), or `null`. */
5687
5742
  getEffectPaint() {
@@ -5693,8 +5748,10 @@ var PaintEditorController = class _PaintEditorController {
5693
5748
  * size, and brushes then take each pixel's colour (and alpha) from it, fixed to the canvas, as if
5694
5749
  * painting a hidden picture into view. `setColor` and `setPaint` go back to colour. Editor state,
5695
5750
  * 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`).
5751
+ * With `map: { by: 'stroke' }` the effect lies along each stroke as a strip; with
5752
+ * `map: { by: 'path' }` an effect with a path control (`format: 'svg-path'`) draws itself along
5753
+ * each stroke's centreline in canvas pixels; with `live`, strokes on a stroke layer keep playing
5754
+ * it (see `setEffectPlayback`).
5698
5755
  */
5699
5756
  async setEffectPaint(source) {
5700
5757
  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 +5766,9 @@ var PaintEditorController = class _PaintEditorController {
5709
5766
  const epoch = ++this.paintEpoch;
5710
5767
  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
5768
  const d = await this.describeEffect(source.effect, "paint");
5769
+ if (map?.by === "path" && !pathControlsOf(d).length) {
5770
+ 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).`);
5771
+ }
5712
5772
  const values = this.effectValues(d, { ...d.defaults }, source.values);
5713
5773
  if (epoch !== this.paintEpoch) throw changed(d.name);
5714
5774
  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 +5800,38 @@ var PaintEditorController = class _PaintEditorController {
5740
5800
  this.effectPaintPixels.set(key, img);
5741
5801
  return img;
5742
5802
  }
5803
+ cacheEffectPaintPixels(p, pixels) {
5804
+ return this.cacheEffectPaintImage(p, toEffectPaintPixels(p, pixels));
5805
+ }
5806
+ cacheEffectPaintImage(p, img) {
5807
+ this.effectPaintPixels.set(effectPaintKey(p), img);
5808
+ this.trimEffectPaints();
5809
+ return img;
5810
+ }
5811
+ trimEffectPaints() {
5812
+ let live = 0;
5813
+ 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++;
5814
+ const limit = Math.max(_PaintEditorController.EFFECT_PAINT_CACHE, this.effectPaintBatch, live + 1);
5815
+ while (this.effectPaintPixels.size > limit) this.effectPaintPixels.delete(this.effectPaintPixels.keys().next().value);
5816
+ }
5817
+ /** Renders an effect paint on the caller's stack when its renderer is warm and has `renderSync`; otherwise `null`. */
5818
+ renderEffectPaintSync(p) {
5819
+ const have = this.effectPaintImage(p);
5820
+ if (have) return have;
5821
+ const provider = this.effects.provider;
5822
+ if (!provider || p.provider && provider.id !== p.provider) return null;
5823
+ const size = effectPaintSize(p, this.doc.canvas);
5824
+ const pixels = this.effectsRenderer.renderSync(provider, p.effect, size, { time: p.time, values: p.values });
5825
+ return pixels ? this.cacheEffectPaintPixels(p, pixels) : null;
5826
+ }
5743
5827
  /** Renders an effect paint at the canvas size into the cache (nothing when it is there already). */
5744
5828
  async renderEffectPaint(p) {
5745
5829
  const have = this.effectPaintImage(p);
5746
5830
  if (have) return have;
5831
+ return this.cacheEffectPaintImage(p, await this.renderEffectPaintPixels(p));
5832
+ }
5833
+ /** Renders an effect paint at the canvas size without keeping it. */
5834
+ async renderEffectPaintPixels(p) {
5747
5835
  const provider = this.effects.provider;
5748
5836
  const what = `the effect "${p.effect}"${p.provider ? ` from provider "${p.provider}"` : ""}`;
5749
5837
  if (!provider) throw new PaintEditorError(`Effect paint needs ${what}, but no effects provider is registered; register it (setEffectsProvider) and try again.`);
@@ -5755,28 +5843,107 @@ var PaintEditorController = class _PaintEditorController {
5755
5843
  } catch (e) {
5756
5844
  throw new PaintEditorError(`Effect paint could not render ${what}: ${e instanceof Error ? e.message : String(e)}`);
5757
5845
  }
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;
5846
+ return toEffectPaintPixels(p, pixels);
5762
5847
  }
5763
5848
  /**
5764
5849
  * Renders effect paints that are not rendered yet (strokes and replays need them rendered
5765
5850
  * first, since strokes are synchronous). Throws naming the effect and provider when one cannot be.
5766
5851
  */
5767
5852
  async prepareEffectPaints(paints2) {
5853
+ const list = [...paints2];
5854
+ this.effectPaintBatch = new Set(list.map(effectPaintKey)).size;
5768
5855
  const seen = /* @__PURE__ */ new Set();
5769
- for (const p of paints2) {
5856
+ for (const p of list) {
5770
5857
  const key = effectPaintKey(p);
5771
5858
  if (seen.has(key)) continue;
5772
5859
  seen.add(key);
5773
5860
  await this.renderEffectPaint(p);
5774
5861
  }
5775
5862
  }
5863
+ /**
5864
+ * Renders the path-mapped effect paint for one or more strokes' points before a synchronous
5865
+ * stroke call runs (`paint_stroke` and `stroke_path` do; `paintStroke` throws without it on a
5866
+ * provider that can't render synchronously). For a stroke layer edit, see `prepareStrokeEdit`.
5867
+ */
5868
+ async prepareStrokePathEffect(init, pointsList) {
5869
+ const brush = this.strokeBrush(init);
5870
+ if (!brushTakesPaint(brush)) return;
5871
+ let source = null;
5872
+ if (init.paint !== void 0) {
5873
+ if (!isPaintEffectPaint(init.paint)) return;
5874
+ source = readPaintEffectPaint(init.paint);
5875
+ } else if (init.color === void 0 && !brush.paint && !this.effectPaintPending) {
5876
+ source = this.effectPaint;
5877
+ }
5878
+ if (!source || source.map?.by !== "path") return;
5879
+ if (!this.effectPathControls.has(source.effect)) await this.describeEffect(source.effect);
5880
+ const control = this.effectPathControls.get(source.effect);
5881
+ const paints2 = pointsList.filter((pts) => pts.length > 0).map((pts) => withStrokePath(source, pts, control));
5882
+ if (paints2.length) await this.prepareEffectPaints(paints2);
5883
+ }
5884
+ /**
5885
+ * Renders what an edit of a stroke layer's stroke paints with (`updateStroke`, `editStrokePoints`):
5886
+ * a new effect paint, or a path-mapped stroke's new path. Those calls are synchronous, and throw
5887
+ * without it on a provider that can't render synchronously. Bad edits are left for them to refuse.
5888
+ */
5889
+ async prepareStrokeEdit(id, edit) {
5890
+ const p = this.strokeEditPaint(id, edit);
5891
+ if (!p) return;
5892
+ if (p.map?.by === "path" && !this.effectPathControls.has(p.effect)) await this.describeEffect(p.effect);
5893
+ await this.prepareEffectPaints([this.strokeEditPaint(id, edit) ?? p]);
5894
+ }
5895
+ /** Whether `prepareStrokeEdit` has to be awaited before this edit can run. */
5896
+ strokeEditNeedsRender(id, edit) {
5897
+ const p = this.strokeEditPaint(id, edit);
5898
+ return Boolean(p && !this.effectPaintImage(p) && !this.renderEffectPaintSync(p));
5899
+ }
5900
+ strokeEditPaint(id, edit) {
5901
+ const was = Object.values(this.doc.liveStrokes ?? {}).flat().find((s) => s.id === id);
5902
+ if (!was) return null;
5903
+ try {
5904
+ if (edit.paint !== void 0) {
5905
+ if (!isPaintEffectPaint(edit.paint)) return null;
5906
+ const p = readPaintEffectPaint(edit.paint);
5907
+ return p && withStrokePath(p, was.points, this.effectPathControls.get(p.effect));
5908
+ }
5909
+ if (was.paint?.type !== "effect" || was.paint.map?.by !== "path") return null;
5910
+ if (edit.points !== void 0) return withStrokePath(was.paint, this.checkPoints(edit.points), this.effectPathControls.get(was.paint.effect));
5911
+ if (!edit.transform) return null;
5912
+ const varied = varyStroke(was, this.strokeTransform(was, edit.transform), {}).paint;
5913
+ return varied?.type === "effect" ? varied : null;
5914
+ } catch {
5915
+ return null;
5916
+ }
5917
+ }
5918
+ /** A stroke edit's transform as an affine map, checked. */
5919
+ strokeTransform(stroke, t) {
5920
+ 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.`);
5921
+ for (const k of ["dx", "dy", "rotate"]) if (t[k] !== void 0 && !Number.isFinite(t[k])) throw new PaintEditorError(`${k} must be a number.`);
5922
+ const b = strokeBounds(stroke.points);
5923
+ return variationAffine(t, t.origin ?? { x: b.x + b.width / 2, y: b.y + b.height / 2 });
5924
+ }
5776
5925
  /** Renders the effect paints of recorded strokes (default all of them) before a replay. */
5777
- async prepareStrokeEffects(strokeIds) {
5926
+ async prepareStrokeEffects(strokeIds, variation) {
5778
5927
  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);
5928
+ const strokes = this.doc.strokes.filter((s) => (!ids || ids.has(s.id)) && s.paint?.type === "effect");
5929
+ const paints2 = strokes.map((s) => s.paint);
5930
+ if (variation && strokes.length) {
5931
+ const repeat = Math.max(1, Math.min(100, variation.repeat ?? 1));
5932
+ let origin = variation.origin;
5933
+ if (!origin) {
5934
+ const b = strokeBounds(strokes.flatMap((s) => s.points));
5935
+ origin = { x: b.x + b.width / 2, y: b.y + b.height / 2 };
5936
+ }
5937
+ const step = variationAffine(variation, origin);
5938
+ let m = IDENTITY_AFFINE;
5939
+ for (let k = 0; k < repeat; k++) {
5940
+ 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]];
5941
+ for (const s of strokes) {
5942
+ const v = varyStroke(s, m, variation);
5943
+ if (v.paint?.type === "effect") paints2.push(v.paint);
5944
+ }
5945
+ }
5946
+ }
5780
5947
  await this.prepareEffectPaints(paints2);
5781
5948
  }
5782
5949
  /** A small preview of the effect paint (a PNG data URL `size` px wide), or `null` without a rendered one. */
@@ -5804,7 +5971,7 @@ var PaintEditorController = class _PaintEditorController {
5804
5971
  * Throws while the editor's is still rendering, for an engine that cannot paint with it, or when
5805
5972
  * its pixels are not rendered.
5806
5973
  */
5807
- strokeEffect(init, brush) {
5974
+ strokeEffect(init, brush, points) {
5808
5975
  if (!brushTakesPaint(brush)) return null;
5809
5976
  let source = null;
5810
5977
  if (init.paint !== void 0) {
@@ -5816,8 +5983,10 @@ var PaintEditorController = class _PaintEditorController {
5816
5983
  source = this.effectPaint;
5817
5984
  }
5818
5985
  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.`);
5986
+ const path = source.map?.by === "path";
5987
+ if (path && points?.length) source = withStrokePath(source, points, this.effectPathControls.get(source.effect));
5988
+ const pixels = this.effectPaintImage(source) ?? (path ? this.renderEffectPaintSync(source) : null);
5989
+ 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
5990
  return { source, pixels };
5822
5991
  }
5823
5992
  /**
@@ -6205,7 +6374,7 @@ var PaintEditorController = class _PaintEditorController {
6205
6374
  strokeCopies() {
6206
6375
  return symmetryCopies(this.guides.symmetry);
6207
6376
  }
6208
- sessionFor(doc, init, host) {
6377
+ sessionFor(doc, init, host, points) {
6209
6378
  if (init.paint !== void 0 && !isPaintEffectPaint(init.paint)) {
6210
6379
  const p = this.readStrokePaint(init.paint);
6211
6380
  if (p.type === "color") init = { ...init, color: p.color, paint: void 0 };
@@ -6224,9 +6393,27 @@ var PaintEditorController = class _PaintEditorController {
6224
6393
  })();
6225
6394
  const brush = this.strokeBrush(init);
6226
6395
  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);
6396
+ if (brush.ghost) {
6397
+ if (brush.light.amount > 0) throw new PaintEditorError(`Ghost paint (${brush.name}) shares the wet light channel and can't emit light; set light.amount to 0 or ghost to null (set_brush).`);
6398
+ if (isPaintEffectPaint(init.paint) || init.paint === void 0 && init.color === void 0 && Boolean(this.effectPaint)) {
6399
+ throw new PaintEditorError(`Ghost paint (${brush.name}) needs a single colour to subtract as it fades, so it can't paint with an effect; set a solid colour (set_color) or pass color to paint_stroke.`);
6400
+ }
6401
+ if (brush.behavior.multicolor.mode !== "off") throw new PaintEditorError(`Ghost paint (${brush.name}) needs a single colour to subtract as it fades, so it can't use multi-colour paint; set behavior.multicolor.mode to "off" (set_brush).`);
6402
+ const gCol = init.color === void 0 ? this.ownColour(init.brushId) ?? this.color : parseColor(init.color);
6403
+ if (this.strokeGradient(init, brush, gCol)) throw new PaintEditorError(`Ghost paint (${brush.name}) needs a single colour to subtract as it fades, so it can't paint a gradient; set a solid colour (set_color) or pass color to paint_stroke.`);
6404
+ if (Boolean(doc.light?.[layer.id]?.tiles.size) || Boolean(this.wet.world?.hasLane(layer.id) && this.wet.world.isLit(layer.id))) {
6405
+ throw new PaintEditorError(`Layer "${layer.name}" holds light paint, and ghost paint shares the wet light channel; paint on another layer (add_layer) or clear the light first (clear_light).`);
6406
+ }
6407
+ }
6408
+ const effect = this.strokeEffect(init, brush, points);
6409
+ if (!points && init.paint === void 0 && effect?.source.map?.by === "path" && isWetBrush(brush)) {
6410
+ 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.`);
6411
+ }
6228
6412
  if (brush.light.amount > 0) {
6229
6413
  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).`);
6414
+ if (Boolean(this.wet.world?.hasLane(layer.id) && this.wet.world.getGhost(layer.id)) || Boolean(this.wet.settings.get(layer.id)?.ghost)) {
6415
+ throw new PaintEditorError(`Layer "${layer.name}" has ghost paint fading on it, which uses the wet light channel; paint light on another layer (add_layer), or finish the ghost first (dry_paint).`);
6416
+ }
6230
6417
  }
6231
6418
  if (doc.liveStrokes?.[layer.id]) {
6232
6419
  const why = liveStrokeRefusal(brush);
@@ -6279,7 +6466,8 @@ var PaintEditorController = class _PaintEditorController {
6279
6466
  copies: [IDENTITY_AFFINE, ...copiesW].map(affineCopy),
6280
6467
  mask: init.maskStencils ? this.stencilSelection(init.maskStencils) : init.stencils !== false ? this.stencilSelection() : null,
6281
6468
  paint: effect ? null : wetGradient,
6282
- effect: effect?.pixels ?? null
6469
+ effect: effect?.pixels ?? null,
6470
+ speed: this.wet.speed
6283
6471
  });
6284
6472
  init = { ...init, seed: seedW, load };
6285
6473
  wet = { driver, surface: () => world.display(id).surface };
@@ -6374,9 +6562,19 @@ var PaintEditorController = class _PaintEditorController {
6374
6562
  this.liveThick = ch ? { layerId, channels: ch } : null;
6375
6563
  this.emit(rects);
6376
6564
  },
6377
- sampled: (s, points) => this.notifySample("points", s, points),
6565
+ sampled: (s, points) => {
6566
+ this.notifySample("points", s, points);
6567
+ this.previewPathStroke(s);
6568
+ },
6378
6569
  commit: (s, surface, cancelled) => {
6379
6570
  this.session = null;
6571
+ const preview = this.pathPreview?.session === s ? this.pathPreview : null;
6572
+ this.pathPreview = null;
6573
+ const pathPaint = cancelled ? null : this.pathStrokePaint(s);
6574
+ if (pathPaint) {
6575
+ this.finishPathStroke(s, pathPaint, preview);
6576
+ return;
6577
+ }
6380
6578
  this.live = null;
6381
6579
  this.liveLayers = null;
6382
6580
  this.liveThick = null;
@@ -6425,14 +6623,99 @@ var PaintEditorController = class _PaintEditorController {
6425
6623
  throw e;
6426
6624
  }
6427
6625
  this.session = session;
6626
+ if (session.init.effect?.source.map?.by === "path" && !session.init.wet) this.pathPreview = { session, key: "", pending: null, landed: null };
6428
6627
  this.emit([]);
6429
6628
  this.notifySample("begin", session);
6430
6629
  return session;
6431
6630
  }
6631
+ /**
6632
+ * Renders a hand-painted path stroke's effect along the path drawn so far and paints the stroke
6633
+ * again with it when it lands, so the effect follows the pen while it draws. One render at a
6634
+ * time: when one lands, the next takes the newest path. A failed render leaves the preview as it
6635
+ * was; the render on release reports it.
6636
+ */
6637
+ previewPathStroke(s) {
6638
+ const pv = this.pathPreview;
6639
+ if (!pv || pv.session !== s || pv.pending || !s.open) return;
6640
+ const paint = this.pathStrokePaint(s);
6641
+ if (!paint) return;
6642
+ const key = effectPaintKey(paint);
6643
+ if (key === pv.key) return;
6644
+ pv.key = key;
6645
+ const cached = this.effectPaintImage(paint);
6646
+ const pending = cached ? Promise.resolve(cached) : this.renderEffectPaintPixels(paint);
6647
+ pv.pending = pending;
6648
+ pending.then(
6649
+ (pixels) => {
6650
+ pv.pending = null;
6651
+ pv.landed = { key, pixels };
6652
+ if (this.pathPreview !== pv || !s.open) return;
6653
+ s.repaintEffect(pixels);
6654
+ this.previewPathStroke(s);
6655
+ },
6656
+ () => {
6657
+ pv.pending = null;
6658
+ }
6659
+ );
6660
+ }
6432
6661
  /** The current live stroke, if any. */
6433
6662
  get activeStroke() {
6434
6663
  return this.session;
6435
6664
  }
6665
+ /**
6666
+ * While a hand-painted stroke waits for its path render (`map: { by: 'path' }`), settles when it
6667
+ * has landed and rejects if it could not (the stroke is dropped); `null` otherwise. Until then
6668
+ * the editor is busy, as during a stroke, and shows the stroke as it was painted.
6669
+ */
6670
+ get strokeRendering() {
6671
+ return this.pathStroke?.done ?? null;
6672
+ }
6673
+ /** The paint a finished hand-painted stroke must be painted again with, or `null` when it already painted with it. */
6674
+ pathStrokePaint(s) {
6675
+ const source = s.init.effect?.source;
6676
+ if (source?.map?.by !== "path" || !s.points.length) return null;
6677
+ const paint = withStrokePath(source, s.points, this.effectPathControls.get(source.effect));
6678
+ return effectPaintKey(paint) === effectPaintKey(source) ? null : paint;
6679
+ }
6680
+ /**
6681
+ * Ends a hand-painted stroke with a path-mapped effect. Its whole path isn't known until now, so
6682
+ * the live stroke was only a preview from renders along the path so far (`previewPathStroke`):
6683
+ * nothing of it was committed. Paints it again with the path's own render, as one stroke and one undo step, at
6684
+ * once if that can render synchronously and when the render lands if not.
6685
+ */
6686
+ finishPathStroke(s, paint, preview) {
6687
+ const finish = () => {
6688
+ this.pathStroke = null;
6689
+ this.live = null;
6690
+ this.liveLayers = null;
6691
+ this.liveThick = null;
6692
+ const masks = s.init.selection ? (s.maskStencils ?? this.guides.stencils).filter((x) => x.enabled && x.mode === "mask") : [];
6693
+ const load = s.init.mixerLoad;
6694
+ 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 });
6695
+ Object.assign(s.result, result);
6696
+ this.notifySample("end", s, [], result.strokeId);
6697
+ };
6698
+ const key = effectPaintKey(paint);
6699
+ if (preview?.landed?.key === key) this.cacheEffectPaintImage(paint, preview.landed.pixels);
6700
+ if (this.effectPaintImage(paint) || this.renderEffectPaintSync(paint)) {
6701
+ finish();
6702
+ return;
6703
+ }
6704
+ const render = preview?.pending && preview.key === key ? preview.pending.then((img) => this.cacheEffectPaintImage(paint, img)) : this.renderEffectPaint(paint);
6705
+ const done = render.then(finish, (e) => {
6706
+ this.pathStroke = null;
6707
+ this.live = null;
6708
+ this.liveLayers = null;
6709
+ this.liveThick = null;
6710
+ this.emit();
6711
+ this.notifySample("cancel", s, []);
6712
+ throw e;
6713
+ });
6714
+ done.catch(() => {
6715
+ });
6716
+ this.pathStroke = { effect: paint.effect, done };
6717
+ this.emit([]);
6718
+ }
6436
6719
  /** Runs a whole stroke against `doc` without touching the editor; returns the new document. */
6437
6720
  runStroke(doc, init, points) {
6438
6721
  let out = doc;
@@ -6459,7 +6742,7 @@ var PaintEditorController = class _PaintEditorController {
6459
6742
  }, stroke);
6460
6743
  }
6461
6744
  };
6462
- const session = this.sessionFor(doc, init, host);
6745
+ const session = this.sessionFor(doc, init, host, points);
6463
6746
  session.push(points);
6464
6747
  const result = session.end();
6465
6748
  if (session.init.wet) {
@@ -6609,6 +6892,8 @@ var PaintEditorController = class _PaintEditorController {
6609
6892
  }
6610
6893
  }
6611
6894
  if (doc !== this.doc) this.commit(strokes.length === 1 && repeat === 1 ? "Replay stroke" : "Replay strokes", doc);
6895
+ this.effectPaintBatch = 0;
6896
+ this.trimEffectPaints();
6612
6897
  return { strokeIds: ids, bounds, layerIds: [...layerIds] };
6613
6898
  }
6614
6899
  // --- Light paint --------------------------------------------------------------------
@@ -7104,7 +7389,11 @@ var PaintEditorController = class _PaintEditorController {
7104
7389
  /** Wet settings saved with the document (`null` when they are all defaults). */
7105
7390
  wetJSON() {
7106
7391
  const layers = {};
7107
- for (const [id, set] of this.wet.settings) if (findLayer(this.doc.layers, id)) layers[id] = { params: { ...set.params }, flow: { ...set.flow } };
7392
+ for (const [id, set] of this.wet.settings) {
7393
+ if (!findLayer(this.doc.layers, id)) continue;
7394
+ const ghost = this.wet.world?.hasLane(id) ? this.wet.world.getGhost(id) : set.ghost ?? null;
7395
+ layers[id] = { params: { ...set.params }, flow: { ...set.flow }, ...ghost ? { ghost } : {} };
7396
+ }
7108
7397
  const flat = this.wet.tilt.angle === 0;
7109
7398
  if (!Object.keys(layers).length && flat) return void 0;
7110
7399
  return { tilt: { ...this.wet.tilt }, layers };
@@ -7121,7 +7410,8 @@ var PaintEditorController = class _PaintEditorController {
7121
7410
  continue;
7122
7411
  }
7123
7412
  const x = v;
7124
- this.wet.settings.set(id, { params: normalizeWetParams(x.params ?? {}), flow: normalizeFlow(x.flow ?? {}) });
7413
+ const ghost = readWetGhost(x.ghost);
7414
+ this.wet.settings.set(id, { params: normalizeWetParams(x.params ?? {}), flow: normalizeFlow(x.flow ?? {}), ...ghost ? { ghost } : {} });
7125
7415
  }
7126
7416
  }
7127
7417
  }
@@ -7912,8 +8202,9 @@ var PaintEditorController = class _PaintEditorController {
7912
8202
  if (update.paint !== void 0) {
7913
8203
  if (update.paint === null) delete next.paint;
7914
8204
  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));
8205
+ const ep = update.paint.map?.by === "path" ? withStrokePath(update.paint, next.points, this.effectPathControls.get(update.paint.effect)) : update.paint;
8206
+ 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).`);
8207
+ next.paint = JSON.parse(JSON.stringify(ep));
7917
8208
  } else {
7918
8209
  const p = this.readStrokePaint(update.paint);
7919
8210
  if (p.type === "color") {
@@ -7944,13 +8235,11 @@ var PaintEditorController = class _PaintEditorController {
7944
8235
  if (edit.points !== void 0) {
7945
8236
  const points = this.checkPoints(edit.points);
7946
8237
  next = { ...was, points: points.map((p) => ({ ...p })) };
8238
+ if (next.paint?.type === "effect" && next.paint.map?.by === "path") {
8239
+ next.paint = withStrokePath(next.paint, next.points, this.effectPathControls.get(next.paint.effect));
8240
+ }
7947
8241
  } 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, {});
8242
+ next = varyStroke(was, this.strokeTransform(was, edit.transform), {});
7954
8243
  if (was.stencils) next.stencils = was.stencils;
7955
8244
  }
7956
8245
  const list = strokes.map((x, i) => i === index ? next : x);
@@ -8606,6 +8895,19 @@ function clipToCanvas(rect, canvas) {
8606
8895
  const y1 = Math.min(canvas.height, rect.y + rect.height);
8607
8896
  return x1 > x0 && y1 > y0 ? { x: x0, y: y0, width: x1 - x0, height: y1 - y0 } : null;
8608
8897
  }
8898
+ function toEffectPaintPixels(p, pixels) {
8899
+ return { width: pixels.width, height: pixels.height, data: new Uint8ClampedArray(pixels.data), ...p.map?.by === "stroke" ? { map: p.map } : {} };
8900
+ }
8901
+ function readWetGhost(raw) {
8902
+ if (!raw || typeof raw !== "object") return null;
8903
+ const g = raw;
8904
+ if (!Array.isArray(g.pigment) || g.pigment.length !== 4 || !g.pigment.every((x) => typeof x === "number" && Number.isFinite(x))) return null;
8905
+ const remaining = typeof g.remaining === "number" && Number.isFinite(g.remaining) ? Math.max(0, Math.round(g.remaining)) : 0;
8906
+ if (remaining <= 0) return null;
8907
+ const total = typeof g.total === "number" && Number.isFinite(g.total) ? Math.max(1, Math.round(g.total)) : remaining;
8908
+ const linger = typeof g.linger === "number" && Number.isFinite(g.linger) ? Math.max(0.5, Math.min(60, g.linger)) : 4;
8909
+ return { pigment: [g.pigment[0], g.pigment[1], g.pigment[2], g.pigment[3]], remaining, total, linger };
8910
+ }
8609
8911
 
8610
8912
  // src/paint-editor/presence.ts
8611
8913
  var PaintPresence = class extends PresenceStore {
@@ -9528,7 +9830,7 @@ function createPaintWetTools(c) {
9528
9830
  {
9529
9831
  name: "get_wet_paint",
9530
9832
  title: "Get the wet paint",
9531
- description: "Reads the wet media: whether any paint is still wet, the simulation clock (steps), whether time is paused, its speed (steps per second), the canvas tilt, the kernel running it (typescript, wasm, wasm-threads with the thread count, and a message when it runs slower than it could, e.g. without cross-origin isolation), and each wet layer: wet cells, where the water is (wetBounds), live tiles, its settings and flow map. Also engine: Engine B, the GPU accelerator: WebGPU in browsers, Metal in the Swift editors (mode auto/off, state, reason, adapter, minTiles, activeTiles). Call it after wet strokes to see what is still wet.",
9833
+ description: "Reads the wet media: whether any paint is still wet, the simulation clock (steps), whether time is paused, its speed (steps per second), the canvas tilt, the kernel running it (typescript, wasm, wasm-threads with the thread count, and a message when it runs slower than it could, e.g. without cross-origin isolation), and each wet layer: wet cells, where the water is (wetBounds), live tiles, its settings, flow map, and ghost: { remaining } (seconds of simulated time left when ghost paint is fading on the layer). Also engine: Engine B, the GPU accelerator: WebGPU in browsers, Metal in the Swift editors (mode auto/off, state, reason, adapter, minTiles, activeTiles). Call it after wet strokes to see what is still wet.",
9532
9834
  inputSchema: EMPTY2,
9533
9835
  annotations: { title: "Get the wet paint", ...READ3 },
9534
9836
  coordinates: { out: ["layers[].wetBounds", "layers[].flow.center"] },
@@ -10320,6 +10622,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
10320
10622
  if (refs.length) await c.prepareStickers(refs);
10321
10623
  }
10322
10624
  const stroke = { points: a.points, layerId: a.layerId, brushId: a.brushId, brush, color: a.color, paint: a.paint, seed: a.seed, snapToPerspective: a.snapToPerspective };
10625
+ await c.prepareStrokePathEffect(stroke, [stroke.points]);
10323
10626
  const r = await act({ kind: "stroke", stroke }, () => c.paintStroke(stroke));
10324
10627
  return r.mixerLoad ? { ...r, mixerLoad: loadResult(r.mixerLoad) } : r;
10325
10628
  }
@@ -10357,6 +10660,13 @@ function createPaintEditorToolProvider(controller, options = {}) {
10357
10660
  }
10358
10661
  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
10662
  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 };
10663
+ if (typeof path === "string" && path.trim()) {
10664
+ try {
10665
+ const sampled = samplePath(path, init);
10666
+ await c.prepareStrokePathEffect(init, sampled);
10667
+ } catch {
10668
+ }
10669
+ }
10360
10670
  const r = await act(
10361
10671
  () => {
10362
10672
  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 +10818,8 @@ function createPaintEditorToolProvider(controller, options = {}) {
10508
10818
  },
10509
10819
  annotations: { title: "Move or reshape a stroke", ...WRITE5 },
10510
10820
  coordinates: { in: ["points[]", "transform.origin"], out: ["bounds", "stroke.bounds", "stroke.points[]"] },
10511
- handler: ({ id, points, transform }) => {
10821
+ handler: async ({ id, points, transform }) => {
10822
+ await c.prepareStrokeEdit(id, { points, transform });
10512
10823
  const r = c.editStrokePoints(id, { points, transform });
10513
10824
  return { stroke: strokeSummary(r.stroke, true, true), bounds: r.bounds };
10514
10825
  }
@@ -10591,7 +10902,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
10591
10902
  coordinates: { in: ["origin"], out: ["bounds"] },
10592
10903
  handler: async (a) => {
10593
10904
  const replay = a;
10594
- if (replay.color === void 0) await c.prepareStrokeEffects(replay.strokeIds);
10905
+ if (replay.color === void 0) await c.prepareStrokeEffects(replay.strokeIds, replay);
10595
10906
  return act({ kind: "replay", replay }, () => c.replayStrokes(replay));
10596
10907
  }
10597
10908
  });
@@ -10967,15 +11278,28 @@ var PaintGesture = class {
10967
11278
  if (!m || m.pointerId !== e.pointerId) return;
10968
11279
  this.mode = null;
10969
11280
  this.predicted = [];
10970
- if (m.kind === "stroke") this.controller.activeStroke?.end();
11281
+ if (m.kind === "stroke") {
11282
+ this.controller.activeStroke?.end();
11283
+ this.controller.strokeRendering?.catch((err) => {
11284
+ this.error = err instanceof Error ? err.message : String(err);
11285
+ });
11286
+ }
10971
11287
  if (m.kind === "move-stroke") {
10972
11288
  const dx = Math.round((this.doc(e).x - m.from.x) * 100) / 100;
10973
11289
  const dy = Math.round((this.doc(e).y - m.from.y) * 100) / 100;
10974
11290
  if (dx || dy) {
10975
- try {
10976
- this.controller.editStrokePoints(m.id, { transform: { dx, dy } });
10977
- } catch (err) {
11291
+ const edit = { transform: { dx, dy } };
11292
+ const failed = (err) => {
10978
11293
  this.error = err instanceof Error ? err.message : String(err);
11294
+ };
11295
+ if (this.controller.strokeEditNeedsRender(m.id, edit)) {
11296
+ this.controller.prepareStrokeEdit(m.id, edit).then(() => this.controller.editStrokePoints(m.id, edit)).catch(failed);
11297
+ } else {
11298
+ try {
11299
+ this.controller.editStrokePoints(m.id, edit);
11300
+ } catch (err) {
11301
+ failed(err);
11302
+ }
10979
11303
  }
10980
11304
  }
10981
11305
  }
@@ -11482,9 +11806,9 @@ function createPaintEffectsToolProvider(controller, options = {}) {
11482
11806
  time: { type: "number", minimum: 0, description: "Which moment of an animated effect to paint with, in ms (default 0)." },
11483
11807
  map: {
11484
11808
  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).",
11809
+ 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
11810
  properties: {
11487
- by: { type: "string", enum: ["canvas", "stroke"], description: "canvas (default) or stroke." },
11811
+ 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
11812
  length: { type: "number", minimum: 16, maximum: 4096, description: "by stroke: px of stroke per repeat of the effect (default 300)." },
11489
11813
  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
11814
  },
@@ -12095,7 +12419,7 @@ function uiTables() {
12095
12419
  brushOptions: BRUSH_OPTION_CONTROLS,
12096
12420
  blendMenu: BLEND_MENU_GROUPS,
12097
12421
  layerEffects: LAYER_EFFECT_MENU.map(({ kind, label }) => ({ kind, label, controls: layerEffectControls(defaultLayerEffect(kind, kind)) })),
12098
- sliders: { paper: PAPER_SLIDERS, wet: WET_PARAM_SLIDERS, wetTilt: WET_TILT_SLIDERS, wetSpeed: WET_SPEED_SLIDER, light: LIGHT_SLIDERS, stroke: STROKE_SLIDERS, sticker: STICKER_SLIDERS },
12422
+ sliders: { paper: PAPER_SLIDERS, wet: WET_PARAM_SLIDERS, wetTilt: WET_TILT_SLIDERS, wetSpeed: WET_SPEED_SLIDER, light: LIGHT_SLIDERS, ghost: GHOST_SLIDERS, stroke: STROKE_SLIDERS, sticker: STICKER_SLIDERS },
12099
12423
  pathStrokeTapers: PATH_STROKE_TAPERS,
12100
12424
  padSampleSizes: PAD_SAMPLE_SIZES,
12101
12425
  wetGpu: WET_GPU_OPTIONS,
@@ -12172,6 +12496,7 @@ export {
12172
12496
  DEFAULT_DPI,
12173
12497
  DEFAULT_DYNAMICS,
12174
12498
  DEFAULT_FLOW,
12499
+ DEFAULT_GHOST,
12175
12500
  DEFAULT_IMPASTO,
12176
12501
  DEFAULT_LIGHT,
12177
12502
  DEFAULT_LIGHTING,
@@ -12194,6 +12519,7 @@ export {
12194
12519
  DryStroke,
12195
12520
  EFFECT_MAP_REPEATS,
12196
12521
  EFFECT_STRIP_HEIGHT,
12522
+ GHOST_SLIDERS,
12197
12523
  GPU_LEAD,
12198
12524
  GRAIN_PATTERNS,
12199
12525
  GRAIN_TILE,
@@ -12209,6 +12535,7 @@ export {
12209
12535
  MAX_CANVAS_SIDE,
12210
12536
  MAX_COORDINATE,
12211
12537
  MAX_DOT_TEXTURES,
12538
+ MAX_EFFECT_PATH_POINTS,
12212
12539
  MAX_IMAGE_SIDE,
12213
12540
  MAX_LIVE_EFFECT_SOURCES,
12214
12541
  MAX_PATH_SAMPLES,
@@ -12370,6 +12697,7 @@ export {
12370
12697
  hslToRgb,
12371
12698
  hsvToRgb,
12372
12699
  hueToWheel,
12700
+ inferEffectPathControl,
12373
12701
  invertAffine,
12374
12702
  isBlendMode,
12375
12703
  isGradientPaint,
@@ -12400,6 +12728,7 @@ export {
12400
12728
  normalizeCanvas,
12401
12729
  normalizeDynamics,
12402
12730
  normalizeFlow,
12731
+ normalizeGhost,
12403
12732
  normalizeHexColor,
12404
12733
  normalizeImpasto,
12405
12734
  normalizeLight,
@@ -12492,6 +12821,7 @@ export {
12492
12821
  stickerSurface,
12493
12822
  strokeBounds,
12494
12823
  strokeDuration,
12824
+ strokePathPoints,
12495
12825
  strokeSchedule,
12496
12826
  strokeToJSON,
12497
12827
  sweepPath,
@@ -12524,5 +12854,6 @@ export {
12524
12854
  wheelPick,
12525
12855
  wheelPoint,
12526
12856
  wheelToHue,
12527
- withPaintPlayback
12857
+ withPaintPlayback,
12858
+ withStrokePath
12528
12859
  };