@pixerate/editor 0.125.0 → 0.127.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 (124) hide show
  1. package/dist/animation/index.d.cts +2 -3
  2. package/dist/animation/index.d.ts +2 -3
  3. package/dist/animation-editor/index.cjs +71 -73
  4. package/dist/animation-editor/index.cjs.map +1 -1
  5. package/dist/animation-editor/index.d.cts +6 -8
  6. package/dist/animation-editor/index.d.ts +6 -8
  7. package/dist/animation-editor/index.js +11 -13
  8. package/dist/animation-editor/index.js.map +1 -1
  9. package/dist/{bezier-CBzKKDjl.d.cts → bezier-DBz0IgEt.d.cts} +22 -2
  10. package/dist/{bezier-CWv6LQal.d.ts → bezier-DWI5MEkI.d.ts} +22 -2
  11. package/dist/{brushstrokeRender-LRV7GXGD.js → brushstrokeRender-7RUITO42.js} +3 -3
  12. package/dist/{brushstrokeRender-EELTV7U4.cjs → brushstrokeRender-OW6ENCZG.cjs} +7 -7
  13. package/dist/{brushstrokeRender-EELTV7U4.cjs.map → brushstrokeRender-OW6ENCZG.cjs.map} +1 -1
  14. package/dist/canvas/index.cjs +423 -37
  15. package/dist/canvas/index.cjs.map +1 -1
  16. package/dist/canvas/index.d.cts +189 -11
  17. package/dist/canvas/index.d.ts +189 -11
  18. package/dist/canvas/index.js +395 -9
  19. package/dist/canvas/index.js.map +1 -1
  20. package/dist/{chunk-7LQO6OHH.cjs → chunk-33SO3QWP.cjs} +4 -4
  21. package/dist/{chunk-7LQO6OHH.cjs.map → chunk-33SO3QWP.cjs.map} +1 -1
  22. package/dist/{chunk-MPQAO33O.js → chunk-A6KHMKI4.js} +3 -3
  23. package/dist/{chunk-V5YGPRCZ.cjs → chunk-BEGEWQHD.cjs} +41 -41
  24. package/dist/{chunk-V5YGPRCZ.cjs.map → chunk-BEGEWQHD.cjs.map} +1 -1
  25. package/dist/{chunk-N3FVUOZD.cjs → chunk-BJTCSLF6.cjs} +7 -7
  26. package/dist/{chunk-N3FVUOZD.cjs.map → chunk-BJTCSLF6.cjs.map} +1 -1
  27. package/dist/{chunk-WUDPFWAN.js → chunk-F7Q2NE2Y.js} +3 -3
  28. package/dist/{chunk-G6WXWS4Y.js → chunk-FICSDDDF.js} +2 -2
  29. package/dist/{chunk-GUZ47CJM.cjs → chunk-IZ4WRXZA.cjs} +1 -1
  30. package/dist/{chunk-GUZ47CJM.cjs.map → chunk-IZ4WRXZA.cjs.map} +1 -1
  31. package/dist/{chunk-QDJKUBWE.cjs → chunk-JODTP4DP.cjs} +176 -5
  32. package/dist/chunk-JODTP4DP.cjs.map +1 -0
  33. package/dist/{chunk-GLWZXQ4Q.js → chunk-KHNZ6MOU.js} +1 -1
  34. package/dist/chunk-KHNZ6MOU.js.map +1 -0
  35. package/dist/{chunk-GCJWMEYW.cjs → chunk-KSJLDRHK.cjs} +46 -46
  36. package/dist/{chunk-GCJWMEYW.cjs.map → chunk-KSJLDRHK.cjs.map} +1 -1
  37. package/dist/{chunk-QQKTOA4U.cjs → chunk-ORE334I6.cjs} +132 -132
  38. package/dist/{chunk-QQKTOA4U.cjs.map → chunk-ORE334I6.cjs.map} +1 -1
  39. package/dist/{chunk-X6KTSFAE.js → chunk-Q4NBEVV5.js} +2 -2
  40. package/dist/{chunk-XZWOLXWA.js → chunk-QFSTQHZY.js} +174 -3
  41. package/dist/chunk-QFSTQHZY.js.map +1 -0
  42. package/dist/{chunk-FPRIUR4T.js → chunk-ROZFX4QI.js} +3 -3
  43. package/dist/{chunk-UXFI3CDZ.cjs → chunk-SFCPGMCT.cjs} +8 -8
  44. package/dist/{chunk-UXFI3CDZ.cjs.map → chunk-SFCPGMCT.cjs.map} +1 -1
  45. package/dist/{chunk-J67OLQUQ.js → chunk-UABQTYQF.js} +3 -3
  46. package/dist/{chunk-O5RVCDEC.cjs → chunk-XLUYXUBD.cjs} +88 -88
  47. package/dist/{chunk-O5RVCDEC.cjs.map → chunk-XLUYXUBD.cjs.map} +1 -1
  48. package/dist/{chunk-FG274FYT.js → chunk-YGJIER2R.js} +2 -2
  49. package/dist/effects/index.cjs +32 -34
  50. package/dist/effects/index.cjs.map +1 -1
  51. package/dist/effects/index.d.cts +6 -38
  52. package/dist/effects/index.d.ts +6 -38
  53. package/dist/effects/index.js +3 -5
  54. package/dist/effects/index.js.map +1 -1
  55. package/dist/evaluate-BbCeF6pt.d.ts +114 -0
  56. package/dist/evaluate-BsxYieLN.d.cts +114 -0
  57. package/dist/{export-DVl4fWy5.d.cts → export-Cbr9IqpJ.d.cts} +1 -1
  58. package/dist/{export-Bp7ji2r4.d.cts → export-CcldobdN.d.cts} +3 -3
  59. package/dist/{export-C1s4eolZ.d.ts → export-ClPKUB-v.d.ts} +1 -1
  60. package/dist/{export-DAyPY94f.d.ts → export-PGhOpDmz.d.ts} +3 -3
  61. package/dist/{helpers-i95Ob6Vi.d.cts → helpers-C5aAd44p.d.cts} +67 -4
  62. package/dist/{helpers-BBLlekuM.d.ts → helpers-CPWcK504.d.ts} +67 -4
  63. package/dist/image-editor/index.cjs +619 -621
  64. package/dist/image-editor/index.cjs.map +1 -1
  65. package/dist/image-editor/index.d.cts +9 -11
  66. package/dist/image-editor/index.d.ts +9 -11
  67. package/dist/image-editor/index.js +6 -8
  68. package/dist/image-editor/index.js.map +1 -1
  69. package/dist/{opentype-14PDI5D2.d.ts → opentype-01rMIuQR.d.ts} +1 -1
  70. package/dist/{opentype-D80AVmMu.d.cts → opentype-BgqvmGT-.d.cts} +1 -1
  71. package/dist/paint-editor/index.cjs +737 -117
  72. package/dist/paint-editor/index.cjs.map +1 -1
  73. package/dist/paint-editor/index.d.cts +248 -13
  74. package/dist/paint-editor/index.d.ts +248 -13
  75. package/dist/paint-editor/index.js +635 -15
  76. package/dist/paint-editor/index.js.map +1 -1
  77. package/dist/{pathfinder-DjISxkUv.d.ts → pathfinder-D-h0Imus.d.ts} +1 -1
  78. package/dist/{pathfinder-C0UoFypx.d.cts → pathfinder-DgG0Z9Lr.d.cts} +1 -1
  79. package/dist/{player-cGLrngL_.d.ts → player-BK2kKbfd.d.ts} +1 -1
  80. package/dist/{player-CBtEoU2x.d.cts → player-CmbY3218.d.cts} +1 -1
  81. package/dist/pool-C7GtrL34.d.cts +34 -0
  82. package/dist/pool-CTrtciaN.d.ts +34 -0
  83. package/dist/{svg-DJKriJ4A.d.ts → svg-BRLwcibA.d.ts} +2 -3
  84. package/dist/{svg-BkRL-fZJ.d.cts → svg-DLBBPQP9.d.cts} +2 -3
  85. package/dist/{tools-CM8Oe8F3.d.ts → tools-AugT22z3.d.ts} +1 -1
  86. package/dist/{tools-DZCv2kX1.d.cts → tools-BpCmUNxb.d.cts} +1 -1
  87. package/dist/vector/index.cjs +923 -925
  88. package/dist/vector/index.cjs.map +1 -1
  89. package/dist/vector/index.d.cts +10 -12
  90. package/dist/vector/index.d.ts +10 -12
  91. package/dist/vector/index.js +11 -13
  92. package/dist/vector/index.js.map +1 -1
  93. package/dist/video-editor/index.cjs +269 -271
  94. package/dist/video-editor/index.cjs.map +1 -1
  95. package/dist/video-editor/index.d.cts +6 -8
  96. package/dist/video-editor/index.d.ts +6 -8
  97. package/dist/video-editor/index.js +3 -5
  98. package/dist/video-editor/index.js.map +1 -1
  99. package/dist/{warp-CH9MM435.d.cts → warp-C13L_GRn.d.cts} +1 -1
  100. package/dist/{warp-k_PdtXOG.d.ts → warp-q8FK5HHi.d.ts} +1 -1
  101. package/package.json +1 -1
  102. package/skills/pixerate-canvas/SKILL.md +19 -0
  103. package/skills/pixerate-paint-editor/SKILL.md +40 -1
  104. package/dist/behavior-Ymbld6jL.d.cts +0 -60
  105. package/dist/behavior-Ymbld6jL.d.ts +0 -60
  106. package/dist/chunk-GLWZXQ4Q.js.map +0 -1
  107. package/dist/chunk-IOXKXQNW.cjs +0 -178
  108. package/dist/chunk-IOXKXQNW.cjs.map +0 -1
  109. package/dist/chunk-QDJKUBWE.cjs.map +0 -1
  110. package/dist/chunk-TGKT5QAB.js +0 -178
  111. package/dist/chunk-TGKT5QAB.js.map +0 -1
  112. package/dist/chunk-XZWOLXWA.js.map +0 -1
  113. package/dist/evaluate-2S3vSPLy.d.cts +0 -55
  114. package/dist/evaluate-CfmT6xA3.d.ts +0 -55
  115. package/dist/gradient-KDfCvhuj.d.cts +0 -66
  116. package/dist/gradient-KDfCvhuj.d.ts +0 -66
  117. /package/dist/{brushstrokeRender-LRV7GXGD.js.map → brushstrokeRender-7RUITO42.js.map} +0 -0
  118. /package/dist/{chunk-MPQAO33O.js.map → chunk-A6KHMKI4.js.map} +0 -0
  119. /package/dist/{chunk-WUDPFWAN.js.map → chunk-F7Q2NE2Y.js.map} +0 -0
  120. /package/dist/{chunk-G6WXWS4Y.js.map → chunk-FICSDDDF.js.map} +0 -0
  121. /package/dist/{chunk-X6KTSFAE.js.map → chunk-Q4NBEVV5.js.map} +0 -0
  122. /package/dist/{chunk-FPRIUR4T.js.map → chunk-ROZFX4QI.js.map} +0 -0
  123. /package/dist/{chunk-J67OLQUQ.js.map → chunk-UABQTYQF.js.map} +0 -0
  124. /package/dist/{chunk-FG274FYT.js.map → chunk-YGJIER2R.js.map} +0 -0
@@ -73,16 +73,18 @@ import {
73
73
  } from "../chunk-UYQUPUR6.js";
74
74
  import {
75
75
  toVideoFfmpeg
76
- } from "../chunk-G6WXWS4Y.js";
76
+ } from "../chunk-FICSDDDF.js";
77
77
  import {
78
78
  deserializeLayers,
79
79
  exportPsd,
80
80
  resolveExternalAssets,
81
81
  serializeLayers,
82
82
  serializeLayersExternal
83
- } from "../chunk-WUDPFWAN.js";
83
+ } from "../chunk-F7Q2NE2Y.js";
84
84
  import "../chunk-CTTF4KYV.js";
85
- import "../chunk-FJ3324TZ.js";
85
+ import {
86
+ EffectRendererPool
87
+ } from "../chunk-FJ3324TZ.js";
86
88
  import {
87
89
  RasterCompositor,
88
90
  findLayer,
@@ -209,10 +211,15 @@ import {
209
211
  realPresenceClock,
210
212
  tweenPresenceCursor
211
213
  } from "../chunk-B6DSIEXA.js";
212
- import "../chunk-FPRIUR4T.js";
213
- import "../chunk-TGKT5QAB.js";
214
- import "../chunk-FG274FYT.js";
215
- import "../chunk-GLWZXQ4Q.js";
214
+ import "../chunk-ROZFX4QI.js";
215
+ import "../chunk-YGJIER2R.js";
216
+ import "../chunk-KHNZ6MOU.js";
217
+ import {
218
+ EffectUnsupportedError,
219
+ EffectsSlot,
220
+ createEffectToolProvider,
221
+ resolveEffectValues
222
+ } from "../chunk-QFSTQHZY.js";
216
223
  import "../chunk-4FX5ACVG.js";
217
224
  import "../chunk-33AHNSEB.js";
218
225
  import "../chunk-ZRJMJIQU.js";
@@ -1483,6 +1490,7 @@ function varyStroke(stroke, m, v) {
1483
1490
  // src/paint-editor/render.ts
1484
1491
  var PAPER_NODE_ID = "__paper";
1485
1492
  var TRACING_NODE_ID = "__tracing";
1493
+ var PAPER_EFFECT_NODE_ID = "__paper_effect";
1486
1494
  function paperKey(p, light) {
1487
1495
  const metal = p.metallic ?? 0;
1488
1496
  return JSON.stringify([p.pattern, p.seed, p.color, p.scale, p.tiling, p.shading, p.dry, metal, metal > 0 && light ? [light.azimuth, light.elevation] : null]);
@@ -3216,6 +3224,130 @@ ${tl.ffmpeg.command}
3216
3224
  return writeZip(entries, { compression: "store" });
3217
3225
  }
3218
3226
 
3227
+ // src/paint-editor/effects.ts
3228
+ var PAINT_EFFECT_PLACES = {
3229
+ /** Effect paper: drawn over the whole sheet. */
3230
+ paper: ["generator"],
3231
+ /** An effect layer: drawn at the canvas size. */
3232
+ layer: ["generator", "overlay", "title"],
3233
+ /** An effect fill: composited onto a layer's pixels. */
3234
+ fill: ["generator", "overlay", "title"],
3235
+ /** Light (or any filter) on a layer's dried paint. */
3236
+ light: ["filter"]
3237
+ };
3238
+ var PLACE_WORDS = {
3239
+ paper: "effect paper takes generator effects",
3240
+ layer: "an effect layer takes generator, overlay or title effects",
3241
+ fill: "an effect fill takes generator, overlay or title effects",
3242
+ light: "light on dried paint takes filter effects"
3243
+ };
3244
+ function checkPaintEffectPlace(d, place) {
3245
+ const allowed = PAINT_EFFECT_PLACES[place];
3246
+ if (d.kind !== "motion" && d.kind !== "vector" && d.uses.some((u) => allowed.includes(u))) return;
3247
+ let instead = "";
3248
+ if (d.uses.includes("filter") && place !== "light") instead = "; run it on a layer's dried paint with apply_effect instead";
3249
+ else if (place === "light" && d.uses.some((u) => PAINT_EFFECT_PLACES.layer.includes(u))) instead = "; add it as an effect layer (add_effect_layer) or fill a layer with it (fill_layer_with_effect) instead";
3250
+ else if (place === "paper" && d.uses.some((u) => u === "overlay" || u === "title")) instead = "; add it as an effect layer with add_effect_layer instead";
3251
+ throw new EffectUnsupportedError(`"${d.id}" (${d.name}) is a ${d.uses.join(" / ") || d.kind} effect, and ${PLACE_WORDS[place]}${instead}.`, d.id);
3252
+ }
3253
+ var PAPER_EFFECT_BLENDS = ["multiply", "normal", "overlay", "soft-light", "screen", "color"];
3254
+ var paperVersion = 1;
3255
+ function nextPaperEffectVersion() {
3256
+ return paperVersion++;
3257
+ }
3258
+ function overPixels(base, top, opacity = 1) {
3259
+ const out = new Uint8ClampedArray(base.length);
3260
+ for (let i = 0; i < out.length; i += 4) {
3261
+ const ta = top[i + 3] / 255 * opacity;
3262
+ const ba = base[i + 3] / 255;
3263
+ const a = ta + ba * (1 - ta);
3264
+ out[i + 3] = Math.round(a * 255);
3265
+ if (a > 0) for (let c = 0; c < 3; c++) out[i + c] = (top[i + c] * ta + base[i + c] * ba * (1 - ta)) / a;
3266
+ }
3267
+ return out;
3268
+ }
3269
+ function mixPixels(before, after, amount) {
3270
+ const out = new Uint8ClampedArray(before.length);
3271
+ for (let i = 0; i < out.length; i += 4) {
3272
+ for (let c = 0; c < 3; c++) out[i + c] = before[i + c] + (after[i + c] - before[i + c]) * amount;
3273
+ out[i + 3] = before[i + 3];
3274
+ }
3275
+ return out;
3276
+ }
3277
+ var PaintEffectsRenderer = class {
3278
+ pool = new EffectRendererPool({ maxRenderers: 3 });
3279
+ descriptions = /* @__PURE__ */ new Map();
3280
+ provider = null;
3281
+ /** Forgets cached descriptions and renderers when the provider changes. */
3282
+ follow(provider) {
3283
+ if (provider === this.provider) return;
3284
+ this.provider = provider;
3285
+ this.descriptions.clear();
3286
+ this.pool.clear();
3287
+ }
3288
+ describe(provider, id) {
3289
+ this.follow(provider);
3290
+ let d = this.descriptions.get(id);
3291
+ if (!d) {
3292
+ d = Promise.resolve().then(() => provider.describe(id));
3293
+ d.catch(() => this.descriptions.delete(id));
3294
+ this.descriptions.set(id, d);
3295
+ }
3296
+ return d;
3297
+ }
3298
+ async render(provider, id, size, request) {
3299
+ this.follow(provider);
3300
+ const out = await this.pool.render(provider, id, { ...size, depth: 8 }, request);
3301
+ if (out.width !== size.width || out.height !== size.height || out.data.length !== size.width * size.height * 4) {
3302
+ throw new Error(`The effect "${id}" returned ${out.width}\xD7${out.height} pixels for a ${size.width}\xD7${size.height} canvas.`);
3303
+ }
3304
+ return out;
3305
+ }
3306
+ dispose() {
3307
+ this.pool.clear();
3308
+ this.descriptions.clear();
3309
+ }
3310
+ };
3311
+ var sourceOut = (s) => ({
3312
+ effect: s.effect,
3313
+ ...s.provider ? { provider: s.provider } : {},
3314
+ ...s.name ? { name: s.name } : {},
3315
+ values: JSON.parse(JSON.stringify(s.values)),
3316
+ ...s.time ? { time: s.time } : {}
3317
+ });
3318
+ function readPaintEffectSource(raw) {
3319
+ if (!raw || typeof raw !== "object") return null;
3320
+ const r = raw;
3321
+ if (typeof r.effect !== "string" || !r.effect || !r.values || typeof r.values !== "object" || Array.isArray(r.values)) return null;
3322
+ return {
3323
+ effect: r.effect,
3324
+ ...typeof r.provider === "string" ? { provider: r.provider } : {},
3325
+ ...typeof r.name === "string" ? { name: r.name } : {},
3326
+ values: r.values,
3327
+ ...typeof r.time === "number" && r.time > 0 ? { time: r.time } : {}
3328
+ };
3329
+ }
3330
+ function paperEffectToJSON(p) {
3331
+ return { ...sourceOut(p), opacity: p.opacity, blend: p.blend, png: pngDataUrl(p.image.width, p.image.height, p.image.data) };
3332
+ }
3333
+ function paperEffectFromJSON(raw, width, height) {
3334
+ const source = readPaintEffectSource(raw);
3335
+ if (!source) throw new Error("it is not an effect (effect id and values)");
3336
+ const r = raw;
3337
+ const parsed = typeof r.png === "string" ? parseDataUrl(r.png) : null;
3338
+ if (!parsed) throw new Error("its pixels are missing");
3339
+ const image = decodePng(parsed.bytes);
3340
+ if (image.width !== width || image.height !== height) throw new Error(`its pixels are ${image.width}\xD7${image.height}, not the canvas size ${width}\xD7${height}`);
3341
+ const opacity = typeof r.opacity === "number" && r.opacity >= 0 && r.opacity <= 1 ? r.opacity : 1;
3342
+ const blend = PAPER_EFFECT_BLENDS.includes(r.blend) ? r.blend : "multiply";
3343
+ return { ...source, opacity, blend, image: { width, height, data: new Uint8ClampedArray(image.data) }, version: nextPaperEffectVersion() };
3344
+ }
3345
+ function effectLayersToJSON(map, keep) {
3346
+ const out = {};
3347
+ for (const [id, s] of Object.entries(map)) if (keep(id)) out[id] = sourceOut(s);
3348
+ return Object.keys(out).length ? out : void 0;
3349
+ }
3350
+
3219
3351
  // src/paint-editor/controller.ts
3220
3352
  var PaintEditorError = class extends Error {
3221
3353
  constructor(message) {
@@ -3291,6 +3423,10 @@ var PaintEditorController = class _PaintEditorController {
3291
3423
  liveThick = null;
3292
3424
  /** Set while an operation that changes wet paint runs: the simulation before it (for undo). */
3293
3425
  wetOp = null;
3426
+ /** The effects provider slot (see `setEffectsProvider`). */
3427
+ effects = new EffectsSlot();
3428
+ effectsRenderer = new PaintEffectsRenderer();
3429
+ paperEffectNodeCache = null;
3294
3430
  constructor(options = {}) {
3295
3431
  this.historyLimit = Math.max(1, options.historyLimit ?? 200);
3296
3432
  this.doc = createPaintDocument({ width: options.width, height: options.height, dpi: options.dpi, paper: this.resolvePaper(options.paper) });
@@ -3404,6 +3540,21 @@ var PaintEditorController = class _PaintEditorController {
3404
3540
  this.trimWetHistory();
3405
3541
  this.emit();
3406
3542
  }
3543
+ /**
3544
+ * Commits `next` as a new undo step, or with `commitToHistory` false folds it into the last step
3545
+ * when that step has the same label (the later values of one slider drag).
3546
+ */
3547
+ commitOrAmend(label, next, commitToHistory) {
3548
+ const last = this.undoStack[this.undoStack.length - 1];
3549
+ if (commitToHistory || !last || last.label !== label || last.wet || this.wetOp) {
3550
+ this.commit(label, next);
3551
+ return;
3552
+ }
3553
+ last.after = next;
3554
+ this.redoStack = [];
3555
+ this.doc = next;
3556
+ this.emit();
3557
+ }
3407
3558
  /** Keeps the wet snapshots in history within the wet undo memory, dropping the oldest. */
3408
3559
  trimWetHistory() {
3409
3560
  const seen = /* @__PURE__ */ new Set();
@@ -3812,6 +3963,257 @@ var PaintEditorController = class _PaintEditorController {
3812
3963
  }
3813
3964
  return new TiledSurface(4, tiles, 0);
3814
3965
  }
3966
+ // --- Provider effects ------------------------------------------------------------
3967
+ /** The registered effects provider, or `null`. */
3968
+ get effectsProvider() {
3969
+ return this.effects.provider;
3970
+ }
3971
+ /** Registers (or with `null` removes) the effects provider. Not an edit: no undo step. */
3972
+ setEffectsProvider(provider) {
3973
+ this.effects.set(provider);
3974
+ }
3975
+ /** Registers `provider` and returns a function that removes it again (if it is still the one set). */
3976
+ useEffects(provider) {
3977
+ return this.effects.use(provider);
3978
+ }
3979
+ /** The provider's description of an effect, checked for `place` (rejects without a provider or for an unknown id). */
3980
+ async describeEffect(effect, place) {
3981
+ const d = await this.effectsRenderer.describe(this.effects.require(), effect);
3982
+ if (place) checkPaintEffectPlace(d, place);
3983
+ return d;
3984
+ }
3985
+ /** Every effect layer (top first) with its effect. */
3986
+ listEffectLayers() {
3987
+ const map = this.doc.effectLayers ?? {};
3988
+ return this.listLayers().filter((l) => map[l.id]).map((l) => ({ layerId: l.id, name: l.name, source: map[l.id] }));
3989
+ }
3990
+ /** The effect a layer is drawn from, or `undefined` for other layers. */
3991
+ getEffectLayer(layerId) {
3992
+ return findLayer(this.doc.layers, layerId) ? this.doc.effectLayers?.[layerId] : void 0;
3993
+ }
3994
+ /** The effect paper (without its pixels), or `null`. */
3995
+ getPaperEffect() {
3996
+ const p = this.doc.paperEffect;
3997
+ if (!p) return null;
3998
+ const { image: _i, version: _v, ...rest } = p;
3999
+ return { ...rest, values: { ...rest.values } };
4000
+ }
4001
+ /** Values over `base` (`null` resets a control to its default), checked against the effect's controls. */
4002
+ effectValues(d, base, changes) {
4003
+ const merged = { ...base };
4004
+ const given = {};
4005
+ for (const [k, v] of Object.entries(changes ?? {})) {
4006
+ if (!(k in d.controls.properties)) throw new PaintEditorError(`"${k}" is not a control of "${d.id}"; its controls are ${Object.keys(d.controls.properties).join(", ") || "none"} (describe_effect).`);
4007
+ if (v === null) merged[k] = d.defaults[k];
4008
+ else given[k] = v;
4009
+ }
4010
+ return { ...merged, ...resolveEffectValues({ ...d, defaults: merged }, given) };
4011
+ }
4012
+ /** Renders a generator, overlay or title effect at the canvas size. */
4013
+ renderEffectCanvas(d, values, time = 0) {
4014
+ const { width, height } = this.doc.canvas;
4015
+ return this.effectsRenderer.render(this.effects.require(), d.id, { width, height }, { time, values });
4016
+ }
4017
+ sourceOf(d, values, time) {
4018
+ return { effect: d.id, provider: this.effects.require().id, name: d.name, values, ...time ? { time } : {} };
4019
+ }
4020
+ checkTime(time) {
4021
+ if (time !== void 0 && !(Number.isFinite(time) && time >= 0)) throw new PaintEditorError(`time ${time} is not a moment of the effect; use 0 or more milliseconds.`);
4022
+ }
4023
+ checkOpacity(opacity) {
4024
+ if (opacity !== void 0 && !(opacity >= 0 && opacity <= 1)) throw new PaintEditorError(`opacity ${opacity} is out of range; use 0..1.`);
4025
+ }
4026
+ /** A pixel layer that takes effect pixels: unlocked, visible, no sticker layer, no wet paint. */
4027
+ effectTarget(layerId, what) {
4028
+ const l = this.paintTarget(layerId, what);
4029
+ if (this.doc.stickers?.[l.id]?.length) throw new PaintEditorError(`Layer "${l.name}" is a sticker layer; ${what} another layer (add_layer).`);
4030
+ if (this.wet.wetLayerIds().includes(l.id)) throw new PaintEditorError(`Layer "${l.name}" holds wet paint; effects go on dried paint, so dry it first (dry_paint) and try again.`);
4031
+ return l;
4032
+ }
4033
+ /** After an effect rendered: the layer must be as it was when the render started. */
4034
+ sameLayerAfterRender(before) {
4035
+ this.requireIdle("applying an effect");
4036
+ const now = this.getLayer(before.id);
4037
+ if (!isPixelLayer(now)) throw new PaintEditorError(`Layer "${before.name}" was removed while the effect rendered.`);
4038
+ if (now.surface !== before.surface || now.offset.x !== before.offset.x || now.offset.y !== before.offset.y) throw new PaintEditorError(`Layer "${before.name}" changed while the effect rendered; apply the effect again.`);
4039
+ return now;
4040
+ }
4041
+ /** The canvas rectangle in a layer's own coordinates. */
4042
+ canvasRectOf(l) {
4043
+ return { x: -l.offset.x, y: -l.offset.y, width: this.doc.canvas.width, height: this.doc.canvas.height };
4044
+ }
4045
+ /**
4046
+ * Sets the effect paper: a provider generator drawn over the paper and under the paint, at the
4047
+ * canvas size (`multiply` by default, so the paper's colour and grain still show). `null` removes
4048
+ * it. One undo step. Returns the effect paper (without pixels), or `null`.
4049
+ */
4050
+ async setPaperEffect(effect, options = {}) {
4051
+ this.requireIdle("changing the paper");
4052
+ if (effect === null) {
4053
+ if (!this.doc.paperEffect) return null;
4054
+ const { paperEffect: _p, ...rest } = this.doc;
4055
+ this.commit("Remove effect paper", rest);
4056
+ return null;
4057
+ }
4058
+ this.checkOpacity(options.opacity);
4059
+ this.checkTime(options.time);
4060
+ if (options.blend !== void 0 && !PAPER_EFFECT_BLENDS.includes(options.blend)) throw new PaintEditorError(`Unknown effect paper blend "${options.blend}"; use ${PAPER_EFFECT_BLENDS.join(", ")}.`);
4061
+ const d = await this.describeEffect(effect, "paper");
4062
+ const values = this.effectValues(d, { ...d.defaults }, options.values);
4063
+ const pixels = await this.renderEffectCanvas(d, values, options.time);
4064
+ this.requireIdle("changing the paper");
4065
+ const was = this.doc.paperEffect;
4066
+ const paperEffect = {
4067
+ ...this.sourceOf(d, values, options.time),
4068
+ opacity: options.opacity ?? was?.opacity ?? 1,
4069
+ blend: options.blend ?? was?.blend ?? "multiply",
4070
+ image: { width: pixels.width, height: pixels.height, data: new Uint8ClampedArray(pixels.data) },
4071
+ version: nextPaperEffectVersion()
4072
+ };
4073
+ this.commit("Effect paper", { ...this.doc, paperEffect });
4074
+ return this.getPaperEffect();
4075
+ }
4076
+ /**
4077
+ * Changes the effect paper: new control `values` (merged over its own; `null` resets one) or
4078
+ * `time` draw it again at the canvas size; `opacity` and `blend` only change how it is drawn.
4079
+ * One undo step; with `commitToHistory` false it joins the previous effect paper step (a slider
4080
+ * drag). Returns the effect paper (without pixels).
4081
+ */
4082
+ async updatePaperEffect(changes, commitToHistory = true) {
4083
+ this.requireIdle("changing the paper");
4084
+ const was = this.doc.paperEffect;
4085
+ if (!was) throw new PaintEditorError("There is no effect paper; set one with set_paper_effect.");
4086
+ this.checkOpacity(changes.opacity);
4087
+ this.checkTime(changes.time);
4088
+ if (changes.blend !== void 0 && !PAPER_EFFECT_BLENDS.includes(changes.blend)) throw new PaintEditorError(`Unknown effect paper blend "${changes.blend}"; use ${PAPER_EFFECT_BLENDS.join(", ")}.`);
4089
+ let next = { ...was, ...changes.opacity !== void 0 ? { opacity: changes.opacity } : {}, ...changes.blend !== void 0 ? { blend: changes.blend } : {} };
4090
+ const { width, height } = this.doc.canvas;
4091
+ if (changes.values !== void 0 || changes.time !== void 0 || was.image.width !== width || was.image.height !== height) {
4092
+ const d = await this.describeEffect(was.effect, "paper");
4093
+ const values = this.effectValues(d, was.values, changes.values);
4094
+ const time = changes.time ?? was.time ?? 0;
4095
+ const pixels = await this.renderEffectCanvas(d, values, time);
4096
+ this.requireIdle("changing the paper");
4097
+ if (this.doc.paperEffect !== was) throw new PaintEditorError("The effect paper changed while it rendered; change it again.");
4098
+ next = { ...next, ...this.sourceOf(d, values, time), image: { width: pixels.width, height: pixels.height, data: new Uint8ClampedArray(pixels.data) }, version: nextPaperEffectVersion() };
4099
+ if (!time) delete next.time;
4100
+ }
4101
+ this.commitOrAmend("Effect paper", { ...this.doc, paperEffect: next }, commitToHistory);
4102
+ return this.getPaperEffect();
4103
+ }
4104
+ /**
4105
+ * Adds an effect layer above the active layer (or `aboveId`): a pixel layer drawn by a provider
4106
+ * generator, overlay or title effect at the canvas size. It keeps its effect, so
4107
+ * `updateEffectLayer` draws it again. One undo step. Returns the layer and its effect.
4108
+ */
4109
+ async addEffectLayer(effect, options = {}) {
4110
+ this.requireIdle("adding a layer");
4111
+ this.checkOpacity(options.opacity);
4112
+ this.checkTime(options.time);
4113
+ if (options.blend !== void 0 && !isBlendMode(options.blend)) throw new PaintEditorError(`Unknown blend mode "${options.blend}". Call get_canvas_info for the blend modes.`);
4114
+ if (options.aboveId !== void 0) this.requireLayer(options.aboveId);
4115
+ const d = await this.describeEffect(effect, "layer");
4116
+ const values = this.effectValues(d, { ...d.defaults }, options.values);
4117
+ const pixels = await this.renderEffectCanvas(d, values, options.time);
4118
+ this.requireIdle("adding a layer");
4119
+ const above = options.aboveId ?? this.doc.activeLayerId;
4120
+ const loc = above ? findLayer(this.doc.layers, above) : null;
4121
+ if (options.aboveId !== void 0 && !loc) throw new PaintEditorError(`Layer "${options.aboveId}" was removed while the effect rendered.`);
4122
+ const layer = { ...createPaintLayer(options.name?.trim() || d.name), surface: surfaceFromPixels(pixels, 4), opacity: options.opacity ?? 1, blend: options.blend ?? "normal" };
4123
+ const layers = insertLayer(this.doc.layers, layer, loc?.parent?.id ?? null, loc ? loc.index + 1 : void 0);
4124
+ const source = this.sourceOf(d, values, options.time);
4125
+ this.commit("Add effect layer", { ...this.doc, layers, activeLayerId: layer.id, effectLayers: { ...this.doc.effectLayers ?? {}, [layer.id]: source } });
4126
+ return { layer: this.describeLayer(layer), source };
4127
+ }
4128
+ /**
4129
+ * Draws an effect layer again with new `values` (merged over its own; `null` resets one) or
4130
+ * `time`, at the canvas size, replacing its pixels (paint on it is lost). One undo step; with
4131
+ * `commitToHistory` false it joins the previous effect layer step (a slider drag).
4132
+ */
4133
+ async updateEffectLayer(layerId, changes, commitToHistory = true) {
4134
+ this.requireIdle("changing an effect layer");
4135
+ const was = this.getEffectLayer(layerId);
4136
+ if (!was) throw new PaintEditorError(`"${layerId}" is not an effect layer; add one with add_effect_layer.`);
4137
+ this.checkTime(changes.time);
4138
+ const l = this.effectTarget(layerId, "draw the effect on");
4139
+ const d = await this.describeEffect(was.effect, "layer");
4140
+ const values = this.effectValues(d, was.values, changes.values);
4141
+ const time = changes.time ?? was.time ?? 0;
4142
+ const pixels = await this.renderEffectCanvas(d, values, time);
4143
+ const now = this.getLayer(l.id);
4144
+ this.requireIdle("changing an effect layer");
4145
+ if (!isPixelLayer(now)) throw new PaintEditorError(`Layer "${l.name}" was removed while the effect rendered.`);
4146
+ const source = this.sourceOf(d, values, time);
4147
+ this.commitOrAmend("Effect layer", { ...this.doc, layers: mapLayer(this.doc.layers, l.id, () => ({ ...now, surface: surfaceFromPixels(pixels, 4), offset: { x: 0, y: 0 } })), effectLayers: { ...this.doc.effectLayers ?? {}, [l.id]: source } }, commitToHistory);
4148
+ return source;
4149
+ }
4150
+ /** Turns an effect layer into a plain pixel layer, keeping its pixels (one undo step). */
4151
+ rasterizeEffectLayer(layerId) {
4152
+ this.requireIdle("changing an effect layer");
4153
+ if (!this.getEffectLayer(layerId)) throw new PaintEditorError(`"${layerId}" is not an effect layer; list_applied_effects lists them.`);
4154
+ const { [layerId]: _gone, ...rest } = this.doc.effectLayers ?? {};
4155
+ this.commit("Rasterize effect layer", { ...this.doc, effectLayers: rest });
4156
+ return this.describeLayer(this.requireLayer(layerId));
4157
+ }
4158
+ /**
4159
+ * Fills a layer with a provider generator, overlay or title effect: its pixels, rendered at the
4160
+ * canvas size, are composited onto the layer's paint at `opacity` (only where it is already
4161
+ * painted with `lockTransparency`, or when the layer's transparency is locked). One undo step.
4162
+ */
4163
+ async fillLayerWithEffect(effect, options = {}) {
4164
+ this.requireIdle("filling");
4165
+ this.checkOpacity(options.opacity);
4166
+ this.checkTime(options.time);
4167
+ const l = this.effectTarget(options.layerId, "fill");
4168
+ const d = await this.describeEffect(effect, "fill");
4169
+ const values = this.effectValues(d, { ...d.defaults }, options.values);
4170
+ const pixels = await this.renderEffectCanvas(d, values, options.time);
4171
+ const now = this.sameLayerAfterRender(l);
4172
+ const rect = this.canvasRectOf(now);
4173
+ const before = readRegion(now.surface, rect);
4174
+ let after = overPixels(before, pixels.data, options.opacity ?? 1);
4175
+ if (options.lockTransparency || isLayerLocked(now, "transparency")) after = mixPixels(before, after, 1);
4176
+ const surface = writeRegion(now.surface, rect, after);
4177
+ this.commit("Effect fill", { ...this.doc, layers: mapLayer(this.doc.layers, now.id, () => ({ ...now, surface })) });
4178
+ return { layerId: now.id, source: this.sourceOf(d, values, options.time) };
4179
+ }
4180
+ /**
4181
+ * Runs a filter effect (light, glow, colour grade …) on a layer's dried paint and bakes it in, at
4182
+ * `amount` strength (0..1, default 1). Transparent areas stay transparent; thick paint keeps its
4183
+ * relief, and an effect with a `height` input gets the relief as a height map. Wet paint is
4184
+ * refused (dry it first). One undo step.
4185
+ */
4186
+ async applyLightEffect(layerId, effect, options = {}) {
4187
+ this.requireIdle("applying an effect");
4188
+ const amount = options.amount ?? 1;
4189
+ if (!(amount >= 0 && amount <= 1)) throw new PaintEditorError(`amount ${options.amount} is out of range; use 0..1.`);
4190
+ this.checkTime(options.time);
4191
+ const l = this.effectTarget(layerId, "light");
4192
+ if (!contentBounds(l.surface)) throw new PaintEditorError(`Layer "${l.name}" has no paint to light; paint on it first, or add an effect layer (add_effect_layer).`);
4193
+ const d = await this.describeEffect(effect, "light");
4194
+ const values = this.effectValues(d, { ...d.defaults }, options.values);
4195
+ const rect = this.canvasRectOf(l);
4196
+ const before = readRegion(l.surface, rect);
4197
+ const inputs = { source: { width: rect.width, height: rect.height, data: before } };
4198
+ const heightInput = d.inputs.find((i) => i.kind === "image" && /^(height|depth|relief)$/i.test(i.name));
4199
+ const relief = this.doc.thick?.[l.id];
4200
+ if (heightInput && relief) {
4201
+ const h = readRegion(relief.height, rect);
4202
+ const rgba = new Uint8ClampedArray(rect.width * rect.height * 4);
4203
+ for (let i = 0; i < h.length; i++) rgba.set([h[i], h[i], h[i], 255], i * 4);
4204
+ inputs[heightInput.name] = { width: rect.width, height: rect.height, data: rgba };
4205
+ }
4206
+ const sourceInput = d.inputs.find((i) => i.kind === "image" && i.name !== heightInput?.name)?.name ?? "source";
4207
+ if (sourceInput !== "source") {
4208
+ inputs[sourceInput] = inputs.source;
4209
+ delete inputs.source;
4210
+ }
4211
+ const out = await this.effectsRenderer.render(this.effects.require(), d.id, { width: rect.width, height: rect.height }, { time: options.time ?? 0, values, inputs });
4212
+ const now = this.sameLayerAfterRender(l);
4213
+ const surface = writeRegion(now.surface, rect, mixPixels(before, out.data, amount));
4214
+ this.commit("Light effect", { ...this.doc, layers: mapLayer(this.doc.layers, now.id, () => ({ ...now, surface })) });
4215
+ return { layerId: now.id, source: this.sourceOf(d, values, options.time), amount };
4216
+ }
3815
4217
  // --- Brushes -------------------------------------------------------------------
3816
4218
  findBrush(id) {
3817
4219
  return BUILTIN_BRUSHES.find((b) => b.id === id) ?? this.userBrushes.find((b) => b.id === id);
@@ -4464,6 +4866,7 @@ var PaintEditorController = class _PaintEditorController {
4464
4866
  /** Stops the wet simulation's worker threads (call when the editor goes away). */
4465
4867
  dispose() {
4466
4868
  this.wet.destroy();
4869
+ this.effectsRenderer.dispose();
4467
4870
  }
4468
4871
  /** Resolves when wet paint is between steps (edits, undo and exports can run). */
4469
4872
  wetIdle() {
@@ -4651,6 +5054,14 @@ var PaintEditorController = class _PaintEditorController {
4651
5054
  if (!t || !t.visible) return [];
4652
5055
  return [this.tracingNode(t)];
4653
5056
  }
5057
+ /** The effect paper as a compositor layer (one object per effect paper, so caches hold). */
5058
+ paperEffectNode(p) {
5059
+ const c = this.paperEffectNodeCache;
5060
+ if (c && c.image === p.image && c.opacity === p.opacity && c.blend === p.blend) return c;
5061
+ const node = { type: "image", id: PAPER_EFFECT_NODE_ID, name: "Effect paper", visible: true, opacity: p.opacity, blend: p.blend, image: p.image, version: p.version };
5062
+ this.paperEffectNodeCache = node;
5063
+ return node;
5064
+ }
4654
5065
  /** The tracing image resampled onto the canvas (cached until it or the canvas changes). */
4655
5066
  tracingNode(t) {
4656
5067
  const { width, height } = this.doc.canvas;
@@ -4700,6 +5111,7 @@ var PaintEditorController = class _PaintEditorController {
4700
5111
  layers = picked.map((l) => ({ ...l, visible: true }));
4701
5112
  }
4702
5113
  const under = options.paper === false ? [] : [paperNode(this.doc.paper, this.lighting)];
5114
+ if (options.paper !== false && this.doc.paperEffect) under.push(this.paperEffectNode(this.doc.paperEffect));
4703
5115
  if (view) under.push(...this.underlayLayers());
4704
5116
  return [...under, ...layers];
4705
5117
  }
@@ -4774,6 +5186,11 @@ var PaintEditorController = class _PaintEditorController {
4774
5186
  for (const [id, list] of Object.entries(this.doc.stickers ?? {})) if (list.length && findLayer(this.doc.layers, id)) stickers[id] = list.map((x) => ({ ...x }));
4775
5187
  if (Object.keys(stickers).length) ext.stickers = stickers;
4776
5188
  else delete ext.stickers;
5189
+ const effectLayers = effectLayersToJSON(this.doc.effectLayers ?? {}, (id) => isPixelLayer(findLayer(this.doc.layers, id)?.layer));
5190
+ if (effectLayers) ext.effectLayers = effectLayers;
5191
+ else delete ext.effectLayers;
5192
+ if (this.doc.paperEffect) ext.paperEffect = paperEffectToJSON(this.doc.paperEffect);
5193
+ else delete ext.paperEffect;
4777
5194
  if (this.lighting.azimuth !== DEFAULT_LIGHTING.azimuth || this.lighting.elevation !== DEFAULT_LIGHTING.elevation || this.lighting.relief !== DEFAULT_LIGHTING.relief) ext.lighting = { ...this.lighting };
4778
5195
  else delete ext.lighting;
4779
5196
  return ext;
@@ -4792,6 +5209,23 @@ var PaintEditorController = class _PaintEditorController {
4792
5209
  }
4793
5210
  if (Object.keys(stickers).length) state = { ...state, stickers };
4794
5211
  }
5212
+ if (ext.effectLayers && typeof ext.effectLayers === "object") {
5213
+ const effectLayers = {};
5214
+ for (const [id, v] of Object.entries(ext.effectLayers)) {
5215
+ if (!isPixelLayer(findLayer(state.layers, id)?.layer)) continue;
5216
+ const source = readPaintEffectSource(v);
5217
+ if (source) effectLayers[id] = source;
5218
+ else problems.push(`The effect of layer "${id}" could not be read; it loads as a plain layer.`);
5219
+ }
5220
+ if (Object.keys(effectLayers).length) state = { ...state, effectLayers };
5221
+ }
5222
+ if (ext.paperEffect !== void 0) {
5223
+ try {
5224
+ state = { ...state, paperEffect: paperEffectFromJSON(ext.paperEffect, state.canvas.width, state.canvas.height) };
5225
+ } catch (e) {
5226
+ problems.push(`The effect paper could not be read (${e.message}); the plain paper shows.`);
5227
+ }
5228
+ }
4795
5229
  const raw = ext.layerChannels;
4796
5230
  if (!raw || typeof raw !== "object") return state;
4797
5231
  const thick = {};
@@ -4828,6 +5262,8 @@ var PaintEditorController = class _PaintEditorController {
4828
5262
  delete this.extensions.layerChannels;
4829
5263
  delete this.extensions.lighting;
4830
5264
  delete this.extensions.stickers;
5265
+ delete this.extensions.effectLayers;
5266
+ delete this.extensions.paperEffect;
4831
5267
  this.viewCompositor.clear();
4832
5268
  this.emit();
4833
5269
  return loaded.problems;
@@ -5025,7 +5461,7 @@ var PaintEditorController = class _PaintEditorController {
5025
5461
  /** Adds a floating reference image to look at and pick colours from. */
5026
5462
  addReferenceImage(options) {
5027
5463
  const { src, pixels } = this.importImage(options, "The reference image");
5028
- const ref = {
5464
+ const ref2 = {
5029
5465
  id: `ref${this.nextReferenceId++}`,
5030
5466
  name: options.name?.trim() || `Reference ${this.guides.references.length + 1}`,
5031
5467
  src,
@@ -5036,8 +5472,8 @@ var PaintEditorController = class _PaintEditorController {
5036
5472
  displayWidth: options.displayWidth ?? Math.min(240, pixels.width),
5037
5473
  visible: true
5038
5474
  };
5039
- this.setGuides({ ...this.guides, references: [...this.guides.references, ref] });
5040
- return ref;
5475
+ this.setGuides({ ...this.guides, references: [...this.guides.references, ref2] });
5476
+ return ref2;
5041
5477
  }
5042
5478
  requireReference(id) {
5043
5479
  const r = this.guides.references.find((x) => x.id === id);
@@ -5101,12 +5537,12 @@ var PaintEditorController = class _PaintEditorController {
5101
5537
  }
5102
5538
  }
5103
5539
  if (Array.isArray(r.references)) {
5104
- for (const ref of r.references) {
5105
- if (!ref || typeof ref.src !== "string" || typeof ref.id !== "string") continue;
5540
+ for (const ref2 of r.references) {
5541
+ if (!ref2 || typeof ref2.src !== "string" || typeof ref2.id !== "string") continue;
5106
5542
  try {
5107
- const px = this.decodeImage(ref.src, `Reference "${ref.name}"`);
5108
- guides.references.push({ id: ref.id, name: String(ref.name ?? ref.id), src: ref.src, width: px.width, height: px.height, x: Number(ref.x) || 0, y: Number(ref.y) || 0, displayWidth: Number(ref.displayWidth) || 240, visible: ref.visible !== false });
5109
- const n = Number(/^ref(\d+)$/.exec(ref.id)?.[1] ?? 0);
5543
+ const px = this.decodeImage(ref2.src, `Reference "${ref2.name}"`);
5544
+ guides.references.push({ id: ref2.id, name: String(ref2.name ?? ref2.id), src: ref2.src, width: px.width, height: px.height, x: Number(ref2.x) || 0, y: Number(ref2.y) || 0, displayWidth: Number(ref2.displayWidth) || 240, visible: ref2.visible !== false });
5545
+ const n = Number(/^ref(\d+)$/.exec(ref2.id)?.[1] ?? 0);
5110
5546
  if (n >= this.nextReferenceId) this.nextReferenceId = n + 1;
5111
5547
  } catch (e) {
5112
5548
  problems.push(e.message);
@@ -7938,6 +8374,176 @@ function quickStencil(kind, canvas) {
7938
8374
  return { kind: "line", from: { x: w / 2, y: 0 }, to: { x: w / 2, y: h } };
7939
8375
  }
7940
8376
  }
8377
+
8378
+ // src/paint-editor/effect-tools.ts
8379
+ var PAPER_TARGET = "paper";
8380
+ var ref = (s) => ({ effect: s.effect, ...s.name ? { name: s.name } : {}, ...s.provider ? { provider: s.provider } : {} });
8381
+ function createPaintEffectsToolProvider(controller, options = {}) {
8382
+ const readOnlyReason = () => {
8383
+ const ro = typeof options.readOnly === "function" ? options.readOnly() : options.readOnly;
8384
+ return ro ? typeof ro === "string" ? ro : "The painting is read-only." : null;
8385
+ };
8386
+ const paperInfo = () => {
8387
+ const p = controller.getPaperEffect();
8388
+ return p ? [{ id: PAPER_TARGET, ...ref(p), role: "effect paper", enabled: true, opacity: p.opacity, blend: p.blend, values: p.values, ...p.time ? { time: p.time } : {} }] : [];
8389
+ };
8390
+ const layerInfo = (layerId) => {
8391
+ const s = controller.getEffectLayer(layerId);
8392
+ return s ? [{ id: "effect", ...ref(s), role: "effect layer", enabled: true, values: s.values, ...s.time ? { time: s.time } : {} }] : [];
8393
+ };
8394
+ const requireLayer = (id) => {
8395
+ const l = controller.getLayer(id);
8396
+ if (!l) {
8397
+ const ids = controller.listLayers().map((x) => `"${x.id}"`);
8398
+ throw new Error(`There is no layer "${id}"; the targets are "${PAPER_TARGET}" and the layers ${ids.join(", ") || "(none)"} (list_layers lists them).`);
8399
+ }
8400
+ return l;
8401
+ };
8402
+ const effectsOf = (target) => target === PAPER_TARGET ? paperInfo() : layerInfo(target);
8403
+ const instanceCheck = (target, instance) => {
8404
+ const want = target === PAPER_TARGET ? PAPER_TARGET : "effect";
8405
+ if (target !== PAPER_TARGET) requireLayer(target);
8406
+ if (instance !== want || !effectsOf(target).length) {
8407
+ const have = effectsOf(target).map((e) => `"${e.id}"`);
8408
+ throw new Error(
8409
+ `${target === PAPER_TARGET ? "The paper" : `Layer "${target}"`} has no provider effect "${instance}"; its effects are ${have.join(", ") || "none"} (list_applied_effects). Light baked into paint is part of the pixels: undo takes it back.`
8410
+ );
8411
+ }
8412
+ };
8413
+ const noStackArgs = (changes) => {
8414
+ if (changes.index !== void 0) throw new Error("index does not apply: the paper and each effect layer have one effect.");
8415
+ if (changes.enabled !== void 0) throw new Error("enabled does not apply here: hide an effect layer with update_layer visible false, or remove the effect paper.");
8416
+ };
8417
+ const values = (v) => v;
8418
+ return createEffectToolProvider({
8419
+ id: options.id ?? "paint-effects",
8420
+ namespace: options.namespace,
8421
+ target: { noun: "layer", id: `A pixel layer id from list_layers ("${PAPER_TARGET}" is the effect paper, for list, update and remove).` },
8422
+ applyUses: ["filter"],
8423
+ applyDetails: "Light on dried paint: the filter (search_effects with use filter, e.g. a light, glow or grade) runs on the layer's paint at the canvas size and is baked into its pixels; transparent areas stay transparent, and thick paint keeps its relief (an effect with a height input gets it). opacity is the strength (default 1). Wet paint is refused: dry it first (dry_paint). It is one undo step and is not listed afterwards (it is part of the pixels). Generators go on the paper (set_paper_effect), on their own layer (add_effect_layer) or into a layer (fill_layer_with_effect).",
8424
+ provider: () => controller.effectsProvider,
8425
+ readOnlyReason,
8426
+ list: (target) => {
8427
+ if (target === PAPER_TARGET) return [{ target: PAPER_TARGET, name: "Paper", effects: paperInfo() }];
8428
+ if (target) {
8429
+ const l = requireLayer(target);
8430
+ return [{ target, name: l.name, effects: layerInfo(target) }];
8431
+ }
8432
+ const out = [];
8433
+ if (paperInfo().length) out.push({ target: PAPER_TARGET, name: "Paper", effects: paperInfo() });
8434
+ for (const e of controller.listEffectLayers()) out.push({ target: e.layerId, name: e.name, effects: layerInfo(e.layerId) });
8435
+ return out;
8436
+ },
8437
+ apply: async (target, effect, args) => {
8438
+ if (target === PAPER_TARGET) throw new Error("The paper takes generator effects through set_paper_effect; apply_effect runs a filter on a layer's paint.");
8439
+ noStackArgs(args);
8440
+ requireLayer(target);
8441
+ const r = await controller.applyLightEffect(target, effect, { values: values(args.values), amount: args.opacity });
8442
+ const applied = { id: "light", ...ref(r.source), role: "baked light", enabled: true, opacity: r.amount, values: r.source.values };
8443
+ return { target, effect: applied, effects: layerInfo(target), baked: true };
8444
+ },
8445
+ update: async (target, instance, changes) => {
8446
+ instanceCheck(target, instance);
8447
+ noStackArgs(changes);
8448
+ if (target === PAPER_TARGET) {
8449
+ await controller.updatePaperEffect({ values: changes.values, opacity: changes.opacity });
8450
+ const effects2 = paperInfo();
8451
+ return { target, effect: effects2[0], effects: effects2 };
8452
+ }
8453
+ if (changes.opacity !== void 0) throw new Error("An effect layer's opacity is the layer's: change it with update_layer.");
8454
+ await controller.updateEffectLayer(target, { values: changes.values });
8455
+ const effects = layerInfo(target);
8456
+ return { target, effect: effects[0], effects };
8457
+ },
8458
+ remove: async (target, instance) => {
8459
+ instanceCheck(target, instance);
8460
+ const was = effectsOf(target)[0];
8461
+ if (target === PAPER_TARGET) await controller.setPaperEffect(null);
8462
+ else controller.rasterizeEffectLayer(target);
8463
+ return { target, effect: was, effects: [], ...target === PAPER_TARGET ? {} : { keptPixels: true } };
8464
+ },
8465
+ subscribe: (notify) => {
8466
+ const a = controller.subscribe(() => notify());
8467
+ const b = controller.effects.subscribe(() => notify());
8468
+ return () => {
8469
+ a();
8470
+ b();
8471
+ };
8472
+ },
8473
+ extraTools: (ctx) => [
8474
+ {
8475
+ name: "set_paper_effect",
8476
+ title: "Set the effect paper",
8477
+ description: `Draws a provider generator (search_effects with use generator) over the paper and under the paint, at the canvas size: multiplied by default, so the paper's colour and grain still show. Replaces any effect paper; it is saved and exported with the painting. effect null removes it. One undo step. Returns the effect paper (update_effect / remove_effect with target and effect "paper" change it later).`,
8478
+ inputSchema: {
8479
+ type: "object",
8480
+ properties: {
8481
+ effect: { anyOf: [ctx.effectIdSchema(["generator"]), { type: "null" }], description: "A generator effect id from search_effects, or null to remove the effect paper." },
8482
+ values: ctx.valuesSchema("Control values over the effect's defaults"),
8483
+ opacity: { type: "number", minimum: 0, maximum: 1, description: "Strength 0\u20131 (default 1, or the current effect paper's)." },
8484
+ blend: { type: "string", enum: [...PAPER_EFFECT_BLENDS], description: "How it draws over the paper (default multiply, which keeps the paper's grain; normal covers it)." },
8485
+ time: { type: "number", minimum: 0, description: "Which moment of an animated effect to draw, in ms (default 0)." }
8486
+ },
8487
+ required: ["effect"],
8488
+ additionalProperties: false
8489
+ },
8490
+ annotations: { title: "Set the effect paper", ...ctx.annotations.writeIdempotent },
8491
+ handler: async (args) => {
8492
+ if (args.effect !== null) ctx.requireProvider();
8493
+ return { paperEffect: await controller.setPaperEffect(args.effect, { values: values(args.values), opacity: args.opacity, blend: args.blend, time: args.time }) };
8494
+ }
8495
+ },
8496
+ {
8497
+ name: "add_effect_layer",
8498
+ title: "Add an effect layer",
8499
+ description: 'Adds a pixel layer drawn by a provider generator, overlay or title effect (search_effects with use generator, overlay or title) at the canvas size, above the active layer unless aboveId is given, and makes it active. Paint on it like any layer; update_effect (effect "effect") draws it again with new values, replacing its pixels; remove_effect keeps its pixels as a plain layer. One undo step. Returns the new layer and its effect.',
8500
+ inputSchema: {
8501
+ type: "object",
8502
+ properties: {
8503
+ effect: ctx.effectIdSchema(["generator", "overlay", "title"]),
8504
+ values: ctx.valuesSchema("Control values over the effect's defaults"),
8505
+ name: { type: "string", minLength: 1, description: "The layer name (default: the effect's name)." },
8506
+ aboveId: { type: "string", minLength: 1, description: "Put it directly above this layer (default: above the active layer, else on top)." },
8507
+ opacity: { type: "number", minimum: 0, maximum: 1, description: "Layer opacity 0\u20131 (default 1)." },
8508
+ blend: { type: "string", enum: BLEND_MODES.map((m) => m.id), description: "Layer blend mode (default normal); screen or linear-dodge for light, multiply for textures." },
8509
+ time: { type: "number", minimum: 0, description: "Which moment of an animated effect to draw, in ms (default 0)." }
8510
+ },
8511
+ required: ["effect"],
8512
+ additionalProperties: false
8513
+ },
8514
+ annotations: { title: "Add an effect layer", ...ctx.annotations.write },
8515
+ handler: async (args) => {
8516
+ ctx.requireProvider();
8517
+ const r = await controller.addEffectLayer(args.effect, { values: values(args.values), name: args.name, aboveId: args.aboveId, opacity: args.opacity, blend: args.blend, time: args.time });
8518
+ return { layer: r.layer, effects: layerInfo(r.layer.id) };
8519
+ }
8520
+ },
8521
+ {
8522
+ name: "fill_layer_with_effect",
8523
+ title: "Fill a layer with an effect",
8524
+ description: "Composites a provider generator, overlay or title effect (rendered at the canvas size) onto a layer's pixels at opacity, as a fill: the layer keeps no link to the effect. lockTransparency (or a transparency-locked layer) only colours what is already painted. Wet paint is refused: dry it first (dry_paint). One undo step. Returns the layer id and the effect used.",
8525
+ inputSchema: {
8526
+ type: "object",
8527
+ properties: {
8528
+ effect: ctx.effectIdSchema(["generator", "overlay", "title"]),
8529
+ layerId: { type: "string", minLength: 1, description: "The pixel layer to fill (default: the active layer)." },
8530
+ values: ctx.valuesSchema("Control values over the effect's defaults"),
8531
+ opacity: { type: "number", minimum: 0, maximum: 1, description: "Fill strength 0\u20131 (default 1)." },
8532
+ lockTransparency: { type: "boolean", description: "Only colour pixels that are already painted (default false)." },
8533
+ time: { type: "number", minimum: 0, description: "Which moment of an animated effect to draw, in ms (default 0)." }
8534
+ },
8535
+ required: ["effect"],
8536
+ additionalProperties: false
8537
+ },
8538
+ annotations: { title: "Fill a layer with an effect", ...ctx.annotations.write },
8539
+ handler: async (args) => {
8540
+ ctx.requireProvider();
8541
+ return controller.fillLayerWithEffect(args.effect, { layerId: args.layerId, values: values(args.values), opacity: args.opacity, lockTransparency: args.lockTransparency, time: args.time });
8542
+ }
8543
+ }
8544
+ ]
8545
+ });
8546
+ }
7941
8547
  export {
7942
8548
  BLEND_MENU_GROUPS,
7943
8549
  BLEND_MODES,
@@ -7991,6 +8597,7 @@ export {
7991
8597
  PAINT_BRUSH_CATEGORIES,
7992
8598
  PAINT_BRUSH_ENGINES,
7993
8599
  PAINT_COLOR_JSON_SCHEMA,
8600
+ PAINT_EFFECT_PLACES,
7994
8601
  PAINT_FORMAT,
7995
8602
  PAINT_GRADIENT_FULL_SPEED,
7996
8603
  PAINT_GRADIENT_JSON_SCHEMA,
@@ -8005,10 +8612,13 @@ export {
8005
8612
  PAINT_SCHEMA_VERSION,
8006
8613
  PAINT_SOURCE_JSON_SCHEMA,
8007
8614
  PAINT_TOOLS,
8615
+ PAPER_EFFECT_BLENDS,
8616
+ PAPER_EFFECT_NODE_ID,
8008
8617
  PAPER_FAMILIES,
8009
8618
  PAPER_LIBRARY,
8010
8619
  PAPER_NODE_ID,
8011
8620
  PAPER_SLIDERS,
8621
+ PAPER_TARGET,
8012
8622
  PARTICLE_KINDS,
8013
8623
  PARTICLE_KIND_LABELS,
8014
8624
  PARTICLE_RENDERS,
@@ -8019,6 +8629,7 @@ export {
8019
8629
  PaintDocumentError,
8020
8630
  PaintEditorController,
8021
8631
  PaintEditorError,
8632
+ PaintEffectsRenderer,
8022
8633
  PaintGesture,
8023
8634
  PaintGradientSampler,
8024
8635
  PaintPresence,
@@ -8065,6 +8676,7 @@ export {
8065
8676
  brushTakesPaint,
8066
8677
  brushToJSON,
8067
8678
  buildTimeLapse,
8679
+ checkPaintEffectPlace,
8068
8680
  clonePaper,
8069
8681
  colorHarmony,
8070
8682
  composeAffine,
@@ -8072,6 +8684,7 @@ export {
8072
8684
  createPaintCompositor,
8073
8685
  createPaintDocument,
8074
8686
  createPaintEditorToolProvider,
8687
+ createPaintEffectsToolProvider,
8075
8688
  createPaintLayer,
8076
8689
  createPaintReplayTools,
8077
8690
  createPaintStickerTools,
@@ -8088,6 +8701,7 @@ export {
8088
8701
  downsampleRgba,
8089
8702
  drawSticker,
8090
8703
  drawWaterOverlay,
8704
+ effectLayersToJSON,
8091
8705
  emptiedLayers,
8092
8706
  emptyThickChannels,
8093
8707
  fitZoom,
@@ -8112,6 +8726,7 @@ export {
8112
8726
  lightingKey,
8113
8727
  mapPenPoint,
8114
8728
  mix32,
8729
+ mixPixels,
8115
8730
  mixerLoadForStroke,
8116
8731
  mixerTip,
8117
8732
  multiColorForDab,
@@ -8120,6 +8735,7 @@ export {
8120
8735
  multiColorSliders,
8121
8736
  multiColorToPaint,
8122
8737
  newPaintLayerId,
8738
+ nextPaperEffectVersion,
8123
8739
  normalizeBehavior,
8124
8740
  normalizeBristle,
8125
8741
  normalizeBrushStroke,
@@ -8142,11 +8758,14 @@ export {
8142
8758
  normalizeView,
8143
8759
  normalizeWetBrush,
8144
8760
  normalizeWetParams,
8761
+ overPixels,
8145
8762
  padSampleRadius,
8146
8763
  paintGradientMappingFor,
8147
8764
  paintSourceColor,
8148
8765
  paintSourceCss,
8149
8766
  paintToMultiColor,
8767
+ paperEffectFromJSON,
8768
+ paperEffectToJSON,
8150
8769
  paperGrain,
8151
8770
  paperKey,
8152
8771
  paperNode,
@@ -8163,6 +8782,7 @@ export {
8163
8782
  quickStencil,
8164
8783
  rand01,
8165
8784
  randSigned,
8785
+ readPaintEffectSource,
8166
8786
  readPaintSource,
8167
8787
  readPath,
8168
8788
  readSwatch,