@pixerate/editor 0.241.0 → 0.243.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 (75) hide show
  1. package/dist/animation-editor/controller.d.cts +10 -0
  2. package/dist/animation-editor/controller.d.ts +10 -0
  3. package/dist/animation-editor/effects.d.cts +1 -1
  4. package/dist/animation-editor/effects.d.ts +1 -1
  5. package/dist/animation-editor/index.cjs +195 -146
  6. package/dist/animation-editor/index.js +63 -14
  7. package/dist/{brushstrokeRender-MOYGEXOC.js → brushstrokeRender-274HGNZR.js} +3 -3
  8. package/dist/{brushstrokeRender-UKADOV5D.cjs → brushstrokeRender-C7RJZPMC.cjs} +5 -5
  9. package/dist/canvas/index.cjs +15 -15
  10. package/dist/canvas/index.js +2 -2
  11. package/dist/{chunk-KCMV2534.cjs → chunk-3WOZEBXZ.cjs} +2 -2
  12. package/dist/{chunk-ZDPLZDTT.js → chunk-4ZHXLFXS.js} +1 -1
  13. package/dist/{chunk-5BR7LC24.cjs → chunk-5FZS7J7C.cjs} +50 -4
  14. package/dist/{chunk-ILM6GYAX.js → chunk-6L3WWIDW.js} +1 -1
  15. package/dist/{chunk-A7PQNIBX.cjs → chunk-7V7QMFTI.cjs} +14 -14
  16. package/dist/{chunk-JAOSL334.cjs → chunk-GHCSC2WP.cjs} +38 -16
  17. package/dist/{chunk-UDBDYZET.js → chunk-H4Z72Z4Z.js} +1 -1
  18. package/dist/{chunk-XBZK47LV.js → chunk-HQZHOPNE.js} +49 -3
  19. package/dist/{chunk-QP2B3MID.js → chunk-IXI5KATU.js} +1 -1
  20. package/dist/{chunk-JWCKJNDR.cjs → chunk-K5DS2JTA.cjs} +6 -6
  21. package/dist/{chunk-HGGJ36HR.cjs → chunk-KK2FWMA2.cjs} +1 -1
  22. package/dist/{chunk-WDDUPWVG.js → chunk-L3JFOTBQ.js} +1 -1
  23. package/dist/{chunk-SIK3LT6M.js → chunk-OFH6Q67U.js} +28 -6
  24. package/dist/{chunk-AFLMXITL.cjs → chunk-OONFIFEU.cjs} +23 -23
  25. package/dist/{chunk-HW72ABGR.cjs → chunk-R2FVAKFH.cjs} +86 -86
  26. package/dist/{chunk-G3RUKJ5C.js → chunk-RXZL4NG5.js} +1 -1
  27. package/dist/{chunk-NE6OOKWJ.js → chunk-SRXWDOUI.js} +1 -1
  28. package/dist/{chunk-7F4G74UQ.cjs → chunk-UV7OOZJQ.cjs} +35 -35
  29. package/dist/{chunk-TEFQISBY.js → chunk-X3ZINLIP.js} +1 -1
  30. package/dist/{chunk-ZQIWBJGH.cjs → chunk-XNZFVYC2.cjs} +7 -7
  31. package/dist/effects/fake.d.cts +4 -0
  32. package/dist/effects/fake.d.ts +4 -0
  33. package/dist/effects/helpers.d.cts +6 -0
  34. package/dist/effects/helpers.d.ts +6 -0
  35. package/dist/effects/index.cjs +222 -94
  36. package/dist/effects/index.js +131 -3
  37. package/dist/effects/types.d.cts +31 -0
  38. package/dist/effects/types.d.ts +31 -0
  39. package/dist/image-editor/index.cjs +788 -788
  40. package/dist/image-editor/index.js +5 -5
  41. package/dist/paint-editor/brushes.d.cts +18 -2
  42. package/dist/paint-editor/brushes.d.ts +18 -2
  43. package/dist/paint-editor/controller.d.cts +28 -0
  44. package/dist/paint-editor/controller.d.ts +28 -0
  45. package/dist/paint-editor/document.d.cts +3 -0
  46. package/dist/paint-editor/document.d.ts +3 -0
  47. package/dist/paint-editor/index.cjs +1017 -499
  48. package/dist/paint-editor/index.js +562 -44
  49. package/dist/paint-editor/light.d.cts +74 -0
  50. package/dist/paint-editor/light.d.ts +74 -0
  51. package/dist/paint-editor/stroke.d.cts +14 -0
  52. package/dist/paint-editor/stroke.d.ts +14 -0
  53. package/dist/paint-editor/thick-tools.d.cts +2 -2
  54. package/dist/paint-editor/thick-tools.d.ts +2 -2
  55. package/dist/paint-editor/thick.d.cts +5 -1
  56. package/dist/paint-editor/thick.d.ts +5 -1
  57. package/dist/paint-editor/ui.d.cts +2 -0
  58. package/dist/paint-editor/ui.d.ts +2 -0
  59. package/dist/vector/index.cjs +968 -968
  60. package/dist/vector/index.js +5 -5
  61. package/dist/video-editor/controller.d.cts +49 -1
  62. package/dist/video-editor/controller.d.ts +49 -1
  63. package/dist/video-editor/effect-path.d.cts +66 -0
  64. package/dist/video-editor/effect-path.d.ts +66 -0
  65. package/dist/video-editor/index.cjs +616 -427
  66. package/dist/video-editor/index.d.cts +1 -0
  67. package/dist/video-editor/index.d.ts +1 -0
  68. package/dist/video-editor/index.js +193 -4
  69. package/package.json +1 -1
  70. package/skills/pixerate-animation-editor/SKILL.md +1 -1
  71. package/skills/pixerate-paint-editor/SKILL.md +9 -9
  72. package/skills/pixerate-paint-editor/references/canvas-hosted.md +8 -0
  73. package/skills/pixerate-paint-editor/references/light-paint.md +31 -0
  74. package/skills/pixerate-paint-oil-impasto/SKILL.md +1 -0
  75. package/skills/pixerate-video-editor/SKILL.md +9 -2
@@ -18,6 +18,7 @@ export * from './evaluate.cjs';
18
18
  export * from './audio.cjs';
19
19
  export * from './mix.cjs';
20
20
  export * from './mask.cjs';
21
+ export * from './effect-path.cjs';
21
22
  export * from './plan.cjs';
22
23
  export * from './grade.cjs';
23
24
  export * from './lut.cjs';
@@ -18,6 +18,7 @@ export * from './evaluate.js';
18
18
  export * from './audio.js';
19
19
  export * from './mix.js';
20
20
  export * from './mask.js';
21
+ export * from './effect-path.js';
21
22
  export * from './plan.js';
22
23
  export * from './grade.js';
23
24
  export * from './lut.js';
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  createEffectAuthoringSession
3
- } from "../chunk-TEFQISBY.js";
3
+ } from "../chunk-X3ZINLIP.js";
4
4
  import {
5
5
  VIDEO_ADJUSTMENT_SCOPES,
6
6
  VIDEO_BLEND_MODES,
@@ -149,7 +149,7 @@ import {
149
149
  videoTransitionEase,
150
150
  videoValueAt,
151
151
  videoWipeRect
152
- } from "../chunk-WDDUPWVG.js";
152
+ } from "../chunk-L3JFOTBQ.js";
153
153
  import {
154
154
  namedWithSuggestion
155
155
  } from "../chunk-EUKY4KFX.js";
@@ -159,7 +159,7 @@ import {
159
159
  effectSequenceTiming,
160
160
  effectTemplateTools,
161
161
  fillEffectTemplate
162
- } from "../chunk-G3RUKJ5C.js";
162
+ } from "../chunk-RXZL4NG5.js";
163
163
  import "../chunk-EHLOCVWB.js";
164
164
  import {
165
165
  CAMERA_MOVE_MAX_MS,
@@ -168,9 +168,10 @@ import {
168
168
  cameraMoveParts,
169
169
  createEffectToolProvider,
170
170
  isEffectError,
171
+ pathControlsOf,
171
172
  resolveCameraMoveSpec,
172
173
  sampleEffectCameraMove
173
- } from "../chunk-SIK3LT6M.js";
174
+ } from "../chunk-OFH6Q67U.js";
174
175
  import "../chunk-V2ZCJRDM.js";
175
176
  import {
176
177
  defineEditorDocument
@@ -2205,6 +2206,73 @@ var VideoAudioPeaksCache = class {
2205
2206
  }
2206
2207
  };
2207
2208
 
2209
+ // src/video-editor/effect-path.ts
2210
+ function videoClipPicture(project, clip) {
2211
+ const asset = clip.type === "media" ? project.assets.find((a) => a.id === clip.asset) : void 0;
2212
+ const size = asset ? { width: asset.width ?? project.width, height: asset.height ?? project.height } : { width: project.width, height: project.height };
2213
+ const fit = clip.fit ?? (clip.type === "media" || clip.type === "sequence" ? "contain" : "fill");
2214
+ const rect = placeVideoRect(project, size, clip.type === "text" ? "fill" : fit, clip.transform, 0);
2215
+ return { size, rect, rotation: videoTransformAt(clip.transform, 0).rotation };
2216
+ }
2217
+ function turn(x, y, cx, cy, deg) {
2218
+ if (!deg) return [x, y];
2219
+ const a = deg * Math.PI / 180;
2220
+ const dx = x - cx;
2221
+ const dy = y - cy;
2222
+ return [cx + dx * Math.cos(a) - dy * Math.sin(a), cy + dx * Math.sin(a) + dy * Math.cos(a)];
2223
+ }
2224
+ function framePointsToPicture(points, picture) {
2225
+ const { rect, size, rotation } = picture;
2226
+ const cx = rect.x + rect.width / 2;
2227
+ const cy = rect.y + rect.height / 2;
2228
+ const out = [];
2229
+ for (let i = 0; i + 1 < points.length; i += 2) {
2230
+ const [x, y] = turn(points[i], points[i + 1], cx, cy, -rotation);
2231
+ out.push(Math.round((x - rect.x) / rect.width * size.width * 100) / 100, Math.round((y - rect.y) / rect.height * size.height * 100) / 100);
2232
+ }
2233
+ return out;
2234
+ }
2235
+ function videoClipMaskOutline(project, clip) {
2236
+ if (!clip.mask) return null;
2237
+ const picture = videoClipPicture(project, clip);
2238
+ const { rect, rotation } = picture;
2239
+ const cx = rect.x + rect.width / 2;
2240
+ const cy = rect.y + rect.height / 2;
2241
+ const polygon = videoMaskPolygon(clip.mask, rect).map(([x, y]) => turn(x, y, cx, cy, rotation));
2242
+ if (polygon.length < 2) return null;
2243
+ return [...polygon, polygon[0]].flatMap(([x, y]) => [Math.round(x * 100) / 100, Math.round(y * 100) / 100]);
2244
+ }
2245
+ var VideoPathGesture = class {
2246
+ constructor(frame, spacing = 3) {
2247
+ this.frame = frame;
2248
+ this.spacing = spacing;
2249
+ }
2250
+ frame;
2251
+ spacing;
2252
+ points = [];
2253
+ drawing = false;
2254
+ at(e, box) {
2255
+ return [Math.round((e.clientX - box.left) / box.width * this.frame.width * 100) / 100, Math.round((e.clientY - box.top) / box.height * this.frame.height * 100) / 100];
2256
+ }
2257
+ down(e, box) {
2258
+ this.drawing = true;
2259
+ this.points = this.at(e, box);
2260
+ }
2261
+ move(e, box) {
2262
+ if (!this.drawing) return false;
2263
+ const [x, y] = this.at(e, box);
2264
+ const n = this.points.length;
2265
+ if (Math.hypot(x - this.points[n - 2], y - this.points[n - 1]) < this.spacing) return false;
2266
+ this.points.push(x, y);
2267
+ return true;
2268
+ }
2269
+ /** Ends the stroke; returns its points, or `null` for a tap (fewer than two points). */
2270
+ up() {
2271
+ this.drawing = false;
2272
+ return this.points.length >= 4 ? [...this.points] : null;
2273
+ }
2274
+ };
2275
+
2208
2276
  // src/video-editor/plan.ts
2209
2277
  function clipIndex(project) {
2210
2278
  const out = /* @__PURE__ */ new Map();
@@ -3660,6 +3728,7 @@ var VideoEditorController = class _VideoEditorController {
3660
3728
  previewScale: this.previewScale,
3661
3729
  effectsProvider: this.effects.provider,
3662
3730
  plans: this.plans,
3731
+ pathDrawing: this.pathDrawing,
3663
3732
  revision: this.revision
3664
3733
  };
3665
3734
  }
@@ -4176,6 +4245,81 @@ var VideoEditorController = class _VideoEditorController {
4176
4245
  return { edit: { op: "set_transition", clip, transition: { ...t, effect: { ...t.effect, values: merged } } }, label: `Change transition ${d.name}`, clip };
4177
4246
  }, options.merge);
4178
4247
  }
4248
+ /**
4249
+ * Gives an effect that draws along a path (see `pathControlsOf`) its path: on an effect clip
4250
+ * (`instance` "generator") or a clip effect (its id). The path is frame points (`points`
4251
+ * `[x0, y0, …]`, mapped into the pixels of the picture the effect renders: the project frame for
4252
+ * an effect clip, the clip's own picture for a clip effect), SVG path data (`d`, which the effect
4253
+ * fits as its control says; `""` clears it), the outline of a clip's mask (`mask`), or words the
4254
+ * provider writes as a single-stroke path (`text`). `control` picks the path control when there
4255
+ * are several. Checked before anything changes; one undo step. Returns the edit, the control and
4256
+ * the value it took.
4257
+ */
4258
+ async setEffectPath(clip, instance, path, options = {}) {
4259
+ const fail2 = (error) => ({ ok: false, error });
4260
+ try {
4261
+ const current = this.clipOrThrow(clip);
4262
+ const effect = instance === "generator" ? current.type === "effect" ? current.effect : null : current.effects?.find((e) => e.id === instance)?.effect;
4263
+ if (!effect) {
4264
+ const ids = ["generator"].filter(() => current.type === "effect").concat((current.effects ?? []).map((e) => e.id));
4265
+ return fail2(`Clip "${clip}" has no effect "${instance}"; its effects are ${ids.map((i) => `"${i}"`).join(", ") || "none"} ("generator" is an effect clip's own picture).`);
4266
+ }
4267
+ const d = await this.describeEffect(effect);
4268
+ const controls = pathControlsOf(d);
4269
+ if (!controls.length) return fail2(`"${d.id}" does not draw along a path. Find one that does with search_effects accepts ["path"].`);
4270
+ const control = options.control ?? (controls.length === 1 ? controls[0] : void 0);
4271
+ if (!control) return fail2(`"${d.id}" has several path controls (${controls.join(", ")}); pass control.`);
4272
+ if (!controls.includes(control)) return fail2(`"${control}" is not a path control of "${d.id}"; its path controls are ${controls.join(", ")}.`);
4273
+ const picture = videoClipPicture(this.project, current);
4274
+ let value;
4275
+ if ("points" in path) {
4276
+ const pts = path.points;
4277
+ if (!Array.isArray(pts) || pts.length < 4 || pts.length % 2 || !pts.every(Number.isFinite)) return fail2("points must be at least two points as [x0, y0, x1, y1, \u2026] in frame pixels.");
4278
+ value = framePointsToPicture(pts, picture);
4279
+ } else if ("d" in path) {
4280
+ if (typeof path.d !== "string") return fail2('d must be SVG path data (a string), or "" to clear the path.');
4281
+ value = path.d;
4282
+ } else if ("mask" in path) {
4283
+ const from = findVideoClip(this.project, path.mask.clip)?.clip;
4284
+ if (!from) return fail2(`There is no clip "${path.mask.clip}" to take a mask from.`);
4285
+ const outline = videoClipMaskOutline(this.project, from);
4286
+ if (!outline) return fail2(`Clip "${path.mask.clip}" has no mask; add one with set_video_clip_mask, or pass points or d.`);
4287
+ value = framePointsToPicture(outline, picture);
4288
+ } else if ("text" in path) {
4289
+ const provider = this.effects.require();
4290
+ if (!provider.pathFromText) return fail2(`The effects provider "${provider.id}" cannot write words as a path; pass points or d.`);
4291
+ if (typeof path.text !== "string" || !path.text.trim()) return fail2("text is empty; pass the words to write.");
4292
+ value = await provider.pathFromText(path.text, { ...path.font ? { font: path.font } : {}, ...path.align ? { align: path.align } : {} });
4293
+ } else return fail2("Pass the path as one of points, d, mask or text.");
4294
+ const r = instance === "generator" ? await this.updateEffectValues(clip, "generator", { [control]: value }) : await this.updateClipEffect(clip, instance, { values: { [control]: value } });
4295
+ return r.ok ? { ...r, control, value } : r;
4296
+ } catch (e) {
4297
+ return fail2(e instanceof Error ? e.message : String(e));
4298
+ }
4299
+ }
4300
+ pathDrawing = null;
4301
+ /**
4302
+ * Starts drawing a path on the preview for an effect that draws along one (the preview then
4303
+ * takes the pointer; `finishPathDrawing` with the frame points applies it). Editor state.
4304
+ */
4305
+ startPathDrawing(target) {
4306
+ this.pathDrawing = { ...target };
4307
+ this.notify();
4308
+ }
4309
+ /** Stops drawing a path without changing anything. */
4310
+ cancelPathDrawing() {
4311
+ if (!this.pathDrawing) return;
4312
+ this.pathDrawing = null;
4313
+ this.notify();
4314
+ }
4315
+ /** Applies the drawn path (frame points) to the effect being drawn for, and stops drawing. One undo step. */
4316
+ async finishPathDrawing(points) {
4317
+ const target = this.pathDrawing;
4318
+ this.pathDrawing = null;
4319
+ this.notify();
4320
+ if (!target) return { ok: false, error: "No path is being drawn; start one from the effect's Draw path button." };
4321
+ return this.setEffectPath(target.clip, target.instance, { points }, { control: target.control });
4322
+ }
4179
4323
  /** Keys for one numeric control of a clip effect (clip-relative ms), or `null` to stop animating it (keeping its value at the clip's start). */
4180
4324
  setClipEffectKeyframes(clip, instance, control, keys) {
4181
4325
  const current = findVideoClip(this.project, clip)?.clip;
@@ -6117,6 +6261,47 @@ function createVideoEffectsToolProvider(controller, options = {}) {
6117
6261
  return { clip: args.clip, transition: clipOf(args.clip).transition, effects: videoClipAppliedEffects(clipOf(args.clip)) };
6118
6262
  }
6119
6263
  },
6264
+ {
6265
+ name: "set_video_effect_path",
6266
+ title: "Run an effect along a path",
6267
+ description: `Gives an effect that draws along a path (search_effects with accepts ["path"]; describe_effect lists its pathControls) the path to draw along: on an effect clip (instance "generator", added with add_video_effect_clip) or a clip effect (its id, from apply_effect). Pass exactly one of: points (frame pixels from the top left, as on the preview; mapped into the pixels of the picture the effect renders, so a clip effect lines up with its moved or scaled clip), d (SVG path data the effect fits into its picture; "" clears the path), mask (use the outline of a clip's mask, in frame pixels), or text (words the provider writes as a single-stroke path, when it can). Refuses an effect without a path control, naming how to find one. One undo step. Returns the clip, instance, the control set and the value it took (points in picture pixels, or path data).`,
6268
+ inputSchema: {
6269
+ type: "object",
6270
+ properties: {
6271
+ clip: { type: "string", minLength: 1, description: "The clip id from get_video_project." },
6272
+ instance: { type: "string", minLength: 1, description: `"generator" for an effect clip's own picture, or a clip effect id from list_applied_effects.` },
6273
+ control: { type: "string", minLength: 1, description: "Which path control (default: the effect's only one; describe_effect pathControls lists them)." },
6274
+ points: { type: "array", minItems: 4, maxItems: 4096, items: { type: "number", description: "x or y, frame pixels." }, description: "The path as frame points [x0, y0, x1, y1, \u2026] in pixels from the frame's top left." },
6275
+ d: { type: "string", description: `SVG path data, fitted into the effect's picture as its control says; "" clears the path.` },
6276
+ mask: { type: "object", properties: { clip: { type: "string", minLength: 1, description: "The clip whose mask outline to use." } }, required: ["clip"], additionalProperties: false, description: "Use the outline of this clip's mask (set_video_clip_mask), in frame pixels." },
6277
+ text: {
6278
+ type: "object",
6279
+ properties: {
6280
+ text: { type: "string", minLength: 1, description: "The words to write." },
6281
+ font: { type: "string", minLength: 1, description: "A single-stroke font the provider offers (default its first)." },
6282
+ align: { type: "string", enum: ["start", "middle", "end"], description: "Where the line sits (default start)." }
6283
+ },
6284
+ required: ["text"],
6285
+ additionalProperties: false,
6286
+ description: "Words the provider writes as a single-stroke path (a signature, handwriting)."
6287
+ }
6288
+ },
6289
+ required: ["clip", "instance"],
6290
+ oneOf: [{ required: ["points"] }, { required: ["d"] }, { required: ["mask"] }, { required: ["text"] }],
6291
+ additionalProperties: false
6292
+ },
6293
+ annotations: { title: "Run an effect along a path", ...ctx.annotations.write },
6294
+ handler: async (args) => {
6295
+ ctx.requireProvider();
6296
+ const given = ["points", "d", "mask", "text"].filter((k2) => args[k2] !== void 0);
6297
+ if (given.length !== 1) throw new Error(`Pass exactly one of points, d, mask or text${given.length ? ` (got ${given.join(", ")})` : ""}.`);
6298
+ const k = given[0];
6299
+ const path = k === "text" ? { ...args.text } : { [k]: args[k] };
6300
+ const r = await controller.setEffectPath(args.clip, args.instance, path, { control: args.control });
6301
+ if (!r.ok) throw new Error(r.error);
6302
+ return { clip: args.clip, instance: args.instance, control: r.control, value: r.value };
6303
+ }
6304
+ },
6120
6305
  {
6121
6306
  name: "set_video_effect_keyframes",
6122
6307
  title: "Keyframe an effect control",
@@ -6675,6 +6860,7 @@ export {
6675
6860
  VideoEffectsEngine,
6676
6861
  VideoFrameCache,
6677
6862
  VideoLutCache,
6863
+ VideoPathGesture,
6678
6864
  VideoPreviewEngine,
6679
6865
  VideoPreviewPixels,
6680
6866
  allVideoClipIds,
@@ -6718,6 +6904,7 @@ export {
6718
6904
  formatVideoSeconds,
6719
6905
  formatVideoTimecode,
6720
6906
  formatVideoTimecodeFrames,
6907
+ framePointsToPicture,
6721
6908
  isVideoDropFrameRate,
6722
6909
  isVideoKeyframes,
6723
6910
  measureVideoLoudness,
@@ -6809,7 +6996,9 @@ export {
6809
6996
  videoClipHeading,
6810
6997
  videoClipKeyframes,
6811
6998
  videoClipLabel,
6999
+ videoClipMaskOutline,
6812
7000
  videoClipOverhang,
7001
+ videoClipPicture,
6813
7002
  videoClipShowsWaveform,
6814
7003
  videoClipSoundGain,
6815
7004
  videoClipSourceRange,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixerate/editor",
3
- "version": "0.241.0",
3
+ "version": "0.243.0",
4
4
  "description": "UI-agnostic rich text and media editing core library",
5
5
  "author": "Pixerate",
6
6
  "license": "MIT",
@@ -161,7 +161,7 @@ refuse.
161
161
  | `apply_effect` | Animate a node (`target`, a group animates as one) with a motion effect: the changes become ordinary commands, tagged with the effect, in a new shot after the last. Returns the instance id. |
162
162
  | `update_effect` | Make an applied effect again with new `values`, in the same place (`effect` is its instance id). |
163
163
  | `remove_effect` | Remove an applied effect: its commands, shots it alone filled and a vector effect's group. |
164
- | `add_vector_effect` | Add a vector effect's own picture as an artwork group (fitted into `box`) with its motion as new shots. Returns the instance and group ids. |
164
+ | `add_vector_effect` | Add a vector effect's own picture as an artwork group (fitted into `box`) with its motion as new shots. For an effect that draws along a path (`search_effects` `accepts: ["path"]`: a signature, a route), `path: { node }` runs it along a path node and lands it on the node, `path: { text }` writes words. Use it when the effect should trace the path itself; use `follow` or a draw-on to move or reveal your own art. Returns the instance and group ids. |
165
165
  | `list_camera_moves` | List the provider's camera moves for a `role` (`transition`: how the camera gets into a shot; `hold`: how it moves while the shot holds), with `durationMs` and `cut`. Returns `available: false` without camera moves. Call before `set_shot_camera_move`; `describe_effect` gives a move's controls and presets. |
166
166
  | `set_shot_camera_move` | Give a shot a camera move (`shot`, `role`, `move`, optional `ms` for a transition, `preset`, `values`): it is saved on the shot's camera as `in` or `during`. Returns the shot's camera and the `window` (ms) the move plays in. A wrong-role id is refused naming the moves that fit. |
167
167
  | `clear_shot_camera_move` | Remove a shot's move for a `role`, keeping its framing. Returns the shot's camera (null when nothing is left). |
@@ -159,8 +159,9 @@ read [references/wet-media.md](references/wet-media.md) for the engine and the w
159
159
 
160
160
  ## Oils and thick paint
161
161
 
162
- Wet oil barely flows and blends where the brush meets it; impasto brushes add relief lit by
163
- `set_lighting`. Load `pixerate-paint-oil-impasto`, and read [references/oils.md](references/oils.md).
162
+ Wet oil blends where the brush meets it; impasto adds relief lit by `set_lighting`: load
163
+ `pixerate-paint-oil-impasto` and read [references/oils.md](references/oils.md). **Light paint**
164
+ (neon, glowing glass: `light.amount`, the Light category): [references/light-paint.md](references/light-paint.md).
164
165
 
165
166
  ## Gradient paint
166
167
 
@@ -261,12 +262,8 @@ When to use which:
261
262
 
262
263
  ## Canvas-hosted layer
263
264
 
264
- A host can mount the painting as a layer of a larger canvas (`createPaintEditorToolProvider` with
265
- `origin`). Then every position you pass or read back is a canvas coordinate, not a document
266
- pixel: stroke points, picks, bounds, guides, stencils, stickers and wet areas alike.
267
- `get_canvas_info` `bounds` says where the painting lies on that canvas; paint inside it. The
268
- host may leave out tools it owns (`new_canvas`, `set_canvas`, `extend_canvas`, exports, the
269
- view), so work with the tools you are given.
265
+ When `get_canvas_info` has `bounds`, the painting is a layer of a larger canvas and every position
266
+ is a canvas coordinate: read [references/canvas-hosted.md](references/canvas-hosted.md).
270
267
 
271
268
  ## Tool reference
272
269
 
@@ -375,7 +372,10 @@ view), so work with the tools you are given.
375
372
  | --- | --- |
376
373
  | `set_lighting` | Move the light for thick paint and metallic paper: `azimuth` (degrees clockwise from the top), `elevation` (5..90), `relief` (0..1). Nothing is repainted; not an undo step. Returns the lighting. Use a low elevation to show brushwork. |
377
374
  | `get_relief` | Read a layer's relief (`bounds`, `maxHeight` 0..255, null when flat) and the lighting. Changes nothing. |
378
- | `flatten_relief` | Remove a layer's relief and material, keeping its colour. One undo step. Returns whether anything was flattened. |
375
+ | `flatten_relief` | Remove a layer's relief and material (rim and clarity too), keeping its colour and light. One undo step. Returns whether anything was flattened. |
376
+ | `get_light` | Read a layer's light paint: `hasLight`, `bounds`, `peak` 0..255 and its `spill`. Changes nothing. |
377
+ | `set_light_spill` | How far a layer's light spills (`radius` px, 0 off) and how strongly (`strength` 0..2). Not an undo step; nothing is repainted. |
378
+ | `clear_light` | Take a layer's light away, keeping its colour. One undo step. |
379
379
 
380
380
  ### Stickers
381
381
 
@@ -0,0 +1,8 @@
1
+ # Canvas-hosted layer
2
+
3
+ A host can mount the painting as a layer of a larger canvas (`createPaintEditorToolProvider` with
4
+ `origin`). Then every position you pass or read back is a canvas coordinate, not a document
5
+ pixel: stroke points, picks, bounds, guides, stencils, stickers and wet areas alike.
6
+ `get_canvas_info` `bounds` says where the painting lies on that canvas; paint inside it. The
7
+ host may leave out tools it owns (`new_canvas`, `set_canvas`, `extend_canvas`, exports, the
8
+ view), so work with the tools you are given.
@@ -0,0 +1,31 @@
1
+ # Light paint
2
+
3
+ Light paint is paint that gives off light: neon, light painting, glow-in-the-dark, glowing glassy
4
+ tubes. Any dab, particle, bristle, Mixer Brush or impasto brush lays it with `light.amount` above 0
5
+ (`set_brush`); the Light category presets are ready-made (`glow-gel`, `neon-marker`,
6
+ `light-airbrush`, `spark-trail`).
7
+
8
+ - **What it does.** Each layer keeps a light channel next to its colour. The light's colour is the
9
+ paint's colour at that pixel, so recolouring paint recolours its light. It glows (the paint
10
+ itself is brighter, whatever `set_lighting` says) and spills onto everything around and below it
11
+ in each stroke's own colour.
12
+ - **It behaves like paint.** Plain paint over glowing paint dims it; erasers remove it; smudges,
13
+ blenders, the knife, the Mixer Brush and pick-up brushes carry it with the colour they move. A
14
+ smeared neon line glows along the smear. `clear_light` takes it away and keeps the colour.
15
+ - **Spill** (`set_light_spill`, per layer): `radius` px (default 24; 0 turns it off) and
16
+ `strength` 0..2 (default 1). It is not an undo step and repaints nothing; it shows the
17
+ painting as committed, so a stroke's spill appears when the stroke ends. It spills onto
18
+ everything below, paper included, so it reads best on dark paper (`set_paper` colour).
19
+ - **Glassy beads** (impasto): `impasto.rim` makes a bead's light stronger at its edges, where its
20
+ surface turns away (backlit glass); `impasto.clarity` lets the middle of a bead show the layers
21
+ below while its edges stay solid. `glow-gel` uses both: paint loops with a distance-mapped
22
+ gradient (`set_paint`) for glowing glass tubes.
23
+ - **Crossings.** Beads on one layer fuse where they cross (their heights combine by maximum), so
24
+ clarity only shows other layers: paint one loop per layer for glassy crossings.
25
+ - **Spill vs a glow layer style.** Spill takes each stroke's colour and strength. For a one-colour
26
+ halo around everything on a layer, use a blend or an effect layer instead.
27
+ - **Not yet:** wet brushes (glowing watercolour is planned, #494), sticker sprays and stroke layers
28
+ refuse light above 0, with the alternatives.
29
+ - **PSD.** `export_psd` writes each layer's pixels with their glow, and its spill as a
30
+ "‹layer› light" Linear Dodge (Add) layer directly above it. Re-importing gives plain pixel
31
+ layers, not editable light.
@@ -66,6 +66,7 @@ This skill is the method. `pixerate-paint-editor` has the full tool reference an
66
66
  | Scraped, smeared texture | `palette-knife` |
67
67
  | Blobs of paint straight from the tube | `paint-tube` |
68
68
  | A glossy bead or line | `gloop-pen` |
69
+ | A glowing, glassy bead (a neon or glass tube) | `glow-gel` (light paint with rim and clarity, on dark paper; one loop per layer where they cross) |
69
70
  | Dry, broken strokes over texture | `dry-brush`, or a lower `impasto.load` |
70
71
  | A metallic highlight | `metallic-paint` |
71
72
  | Paint that stays exactly as laid | `set_instant_dry` on |
@@ -82,7 +82,9 @@ the pitfalls, and uses the tools below.
82
82
  controls, then `apply_effect` (filters and overlays on a clip), `set_video_effect_transition`
83
83
  or `add_video_effect_clip` (generators and titles). Tune with `update_effect` and
84
84
  `set_video_effect_keyframes`; check with `get_video_frame` (layers list `effects`) and
85
- `list_applied_effects`.
85
+ `list_applied_effects`. **An effect along a path** (a beam along a border, a trail, words
86
+ written on): `search_effects` with `accepts: ["path"]`, add it as a clip or clip effect, then
87
+ `set_video_effect_path` with frame `points`, path data `d`, a clip's `mask` outline or `text`.
86
88
  12. **Camera moves** (a provider with camera moves, such as Flair): a clip is the shot.
87
89
  `list_video_camera_moves` with a `role`, then `set_clip_camera_move`: `transition` moves
88
90
  from `from` to `framing` from the clip's start (a glide, whip pan, punch zoom); `hold`
@@ -298,7 +300,7 @@ and every write tool is refused with the reason.
298
300
 
299
301
  | Tool | Use it to |
300
302
  | --- | --- |
301
- | `search_effects` | Find effects: words in `query`, filtered by `kind`, `category` or `use` (filter, overlay for clips; transition; generator, title). Returns ids, names, uses, whether they are transparent or loop, and their length. Camera moves (`kind` camera) are listed but go on clips with `set_clip_camera_move`, not `apply_effect`. Call `describe_effect` next. |
303
+ | `search_effects` | Find effects: words in `query`, filtered by `kind`, `category`, `use` (filter, overlay for clips; transition; generator, title) or `accepts: ["path"]` (effects that draw along a path you give). Returns ids, names, uses, whether they are transparent or loop, and their length. Camera moves (`kind` camera) are listed but go on clips with `set_clip_camera_move`, not `apply_effect`. Call `describe_effect` next. |
302
304
  | `describe_effect` | Read one effect's `controls` (a strict JSON Schema: names, types, ranges, units), `defaults`, `presets` and `inputs`, and whether it `appliesHere` (as a clip effect). Use the names and ranges for values. |
303
305
  | `list_applied_effects` | See the effects in the project per clip, in order: clip effects (`fx1`...), an effect clip's own effect (`generator`) and an effect transition (`transition`), with their saved values and `available` (false when the provider lacks it: drawn as a placeholder). |
304
306
  | `apply_effect` | Add a filter or overlay effect to a clip's stack (`target` clip id, `effect`, `values`, `index`, `enabled`, `opacity`). Effects run in order on the clip's picture before its grade and placement. Returns the effect's new id and the clip's effects. |
@@ -306,6 +308,7 @@ and every write tool is refused with the reason.
306
308
  | `remove_effect` | Take an effect off a clip (`transition` removes an effect transition; an effect clip is deleted with `delete_video_clips`). Returns what was removed and what is left. |
307
309
  | `add_video_effect_clip` | Put a generator, title or overlay effect on a video `track` as a clip filling the frame (`start`, `duration` default the effect's own length, `values`, `mode`). Transparent effects show the tracks beneath. Returns the clip id. |
308
310
  | `set_video_effect_transition` | Use a transition effect from the effects provider into a `clip` from the one ending where it starts (`duration` centred on the cut, `values`). Needs handles like a crossfade. For a cross dissolve, dip, wipe, push, slide or iris use the built-in `set_video_transition` instead. |
311
+ | `set_video_effect_path` | Give an effect that draws along a path (`describe_effect` `pathControls`) its path, on an effect clip (`instance` "generator") or a clip effect: frame `points` `[x0, y0, …]` (mapped into the picture the effect renders, so a clip effect follows its moved or scaled clip), path data `d` (`""` clears), a clip's `mask` outline, or `text` the provider writes. One undo step. Returns the control and the value it took. |
309
312
  | `set_video_effect_keyframes` | Animate one number `control` of a clip effect (or an effect clip's `generator`) with `keys` in clip ms, or `null` to stop animating it. |
310
313
  | `list_video_camera_moves` | List the provider's camera moves for a `role` (`transition` into a clip, `hold` across it) with `durationMs` and `cut`. Returns `available: false` without camera moves. Call before `set_clip_camera_move`. |
311
314
  | `set_clip_camera_move` | Play a camera move on a clip's picture (`clip`, `role`, `move`, `ms` for a transition, `preset`, `values`, `from` and `framing` as `{ zoom, x, y }`): baked into its transform keys, one per frame, two at a cut. Returns the camera, key counts, `cuts`, `windows` and `notes` (tilt, turn and blur cannot be baked). Refuses a clip keyed by hand. |
@@ -328,6 +331,10 @@ The host may add the shared document tools (see `pixerate-agent-tools`). A video
328
331
 
329
332
  ## Pitfalls
330
333
 
334
+ - **Path points are frame pixels**, as on the preview: `set_video_effect_path` maps them into the
335
+ picture the effect renders. Do not pass a clip effect points in its source's pixels yourself, and
336
+ give it the path again after moving or scaling the clip a lot (it maps the clip's first frame).
337
+
331
338
  - **Camera moves are baked keys.** After trimming or splitting a clip, `check_video_project`
332
339
  reports `camera-stale`: set the move again to re-bake it. Editing the clip's x, y, scale or
333
340
  rotation keys by hand leaves the moves no longer describing them; clear the moves first.