@pixerate/editor 0.123.0 → 0.124.1

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 (116) hide show
  1. package/dist/agent/index.cjs +2 -2
  2. package/dist/agent/index.cjs.map +1 -1
  3. package/dist/agent/index.js +3 -3
  4. package/dist/animation/index.d.cts +2 -1
  5. package/dist/animation/index.d.ts +2 -1
  6. package/dist/animation-editor/index.cjs.map +1 -1
  7. package/dist/animation-editor/index.d.cts +11 -733
  8. package/dist/animation-editor/index.d.ts +11 -733
  9. package/dist/animation-editor/index.js.map +1 -1
  10. package/dist/{bezier-C-W9LFyD.d.cts → bezier-CBzKKDjl.d.cts} +2 -66
  11. package/dist/{bezier-DQZE5dxA.d.ts → bezier-CWv6LQal.d.ts} +2 -66
  12. package/dist/{brushstrokeRender-M4S65DHT.cjs → brushstrokeRender-EELTV7U4.cjs} +8 -8
  13. package/dist/{brushstrokeRender-M4S65DHT.cjs.map → brushstrokeRender-EELTV7U4.cjs.map} +1 -1
  14. package/dist/{brushstrokeRender-CDDRSV2W.js → brushstrokeRender-LRV7GXGD.js} +3 -3
  15. package/dist/canvas/index.cjs +6 -6
  16. package/dist/canvas/index.d.cts +10 -6
  17. package/dist/canvas/index.d.ts +10 -6
  18. package/dist/canvas/index.js +1 -1
  19. package/dist/{chunk-SMJGE7HM.cjs → chunk-4QQZV5Y5.cjs} +512 -39
  20. package/dist/chunk-4QQZV5Y5.cjs.map +1 -0
  21. package/dist/{chunk-TVXFHE4T.js → chunk-64PYNEHJ.js} +45 -12
  22. package/dist/chunk-64PYNEHJ.js.map +1 -0
  23. package/dist/{chunk-2IW3BWRT.cjs → chunk-7LQO6OHH.cjs} +2 -2
  24. package/dist/{chunk-2IW3BWRT.cjs.map → chunk-7LQO6OHH.cjs.map} +1 -1
  25. package/dist/chunk-CN4PJHUG.cjs +234 -0
  26. package/dist/chunk-CN4PJHUG.cjs.map +1 -0
  27. package/dist/chunk-FHYDQOPC.cjs +215 -0
  28. package/dist/chunk-FHYDQOPC.cjs.map +1 -0
  29. package/dist/{chunk-KMBC4HNV.js → chunk-J67OLQUQ.js} +2 -2
  30. package/dist/chunk-JRXJTPES.js +215 -0
  31. package/dist/chunk-JRXJTPES.js.map +1 -0
  32. package/dist/{chunk-IPFRWILU.js → chunk-ORHMVUR6.js} +488 -15
  33. package/dist/chunk-ORHMVUR6.js.map +1 -0
  34. package/dist/{chunk-6CJRDJCU.cjs → chunk-PEHYWUVV.cjs} +176 -111
  35. package/dist/chunk-PEHYWUVV.cjs.map +1 -0
  36. package/dist/chunk-QEG3GRN2.js +234 -0
  37. package/dist/chunk-QEG3GRN2.js.map +1 -0
  38. package/dist/{chunk-6ZBR4J62.js → chunk-WATAQ2Z2.js} +79 -14
  39. package/dist/chunk-WATAQ2Z2.js.map +1 -0
  40. package/dist/{chunk-RLRUAPBJ.cjs → chunk-Z6OUCS2K.cjs} +47 -14
  41. package/dist/chunk-Z6OUCS2K.cjs.map +1 -0
  42. package/dist/effects/index.cjs +605 -0
  43. package/dist/effects/index.cjs.map +1 -0
  44. package/dist/effects/index.d.cts +163 -0
  45. package/dist/effects/index.d.ts +163 -0
  46. package/dist/effects/index.js +605 -0
  47. package/dist/effects/index.js.map +1 -0
  48. package/dist/evaluate-2S3vSPLy.d.cts +55 -0
  49. package/dist/evaluate-CfmT6xA3.d.ts +55 -0
  50. package/dist/{export-DfjzHAJI.d.ts → export-BGtXgMJ3.d.ts} +536 -128
  51. package/dist/{export-Dv0-ZVrV.d.cts → export-BdfY_lH0.d.ts} +392 -5
  52. package/dist/{export-Dv0-ZVrV.d.ts → export-CbpjwxaI.d.cts} +392 -5
  53. package/dist/{export-lU5qSpHE.d.cts → export-WmYoWmLM.d.cts} +536 -128
  54. package/dist/flair-CxB3fOcU.d.ts +739 -0
  55. package/dist/flair-D7BxyBwL.d.cts +739 -0
  56. package/dist/gradient-KDfCvhuj.d.cts +66 -0
  57. package/dist/gradient-KDfCvhuj.d.ts +66 -0
  58. package/dist/helpers-777mC6dc.d.cts +253 -0
  59. package/dist/helpers-DX2_7L5t.d.ts +253 -0
  60. package/dist/image-editor/index.cjs +1296 -805
  61. package/dist/image-editor/index.cjs.map +1 -1
  62. package/dist/image-editor/index.d.cts +39 -182
  63. package/dist/image-editor/index.d.ts +39 -182
  64. package/dist/image-editor/index.js +497 -6
  65. package/dist/image-editor/index.js.map +1 -1
  66. package/dist/{opentype-p8Xxf-Wh.d.ts → opentype-14PDI5D2.d.ts} +1 -1
  67. package/dist/{opentype-BV7wWatY.d.cts → opentype-D80AVmMu.d.cts} +1 -1
  68. package/dist/paint/index.cjs +2 -2
  69. package/dist/paint/index.d.cts +32 -10
  70. package/dist/paint/index.d.ts +32 -10
  71. package/dist/paint/index.js +1 -1
  72. package/dist/paint-editor/index.cjs +10 -8
  73. package/dist/paint-editor/index.cjs.map +1 -1
  74. package/dist/paint-editor/index.d.cts +11 -5
  75. package/dist/paint-editor/index.d.ts +11 -5
  76. package/dist/paint-editor/index.js +4 -2
  77. package/dist/paint-editor/index.js.map +1 -1
  78. package/dist/parameters/index.d.cts +4 -184
  79. package/dist/parameters/index.d.ts +4 -184
  80. package/dist/{pathfinder-IxcyhO00.d.cts → pathfinder-C0UoFypx.d.cts} +1 -1
  81. package/dist/{pathfinder-StVChJOT.d.ts → pathfinder-DjISxkUv.d.ts} +1 -1
  82. package/dist/{player-DrcP2xqj.d.cts → player-CBtEoU2x.d.cts} +2 -54
  83. package/dist/{player-DV4Jujdq.d.ts → player-cGLrngL_.d.ts} +2 -54
  84. package/dist/{svg-BtND4TJu.d.cts → svg-BDrjRnL1.d.cts} +3 -2
  85. package/dist/{svg-BfVXG7hL.d.ts → svg-C-vZ3q50.d.ts} +3 -2
  86. package/dist/tools/index.cjs +2 -2
  87. package/dist/tools/index.cjs.map +1 -1
  88. package/dist/tools/index.js +3 -3
  89. package/dist/tools-C7JtLYzo.d.ts +126 -0
  90. package/dist/tools-DHKGHiSK.d.cts +126 -0
  91. package/dist/{types-fuXEme9j.d.ts → types-BcX7sCsn.d.ts} +2 -2
  92. package/dist/{types-DJF3Ghn5.d.cts → types-CznabG7m.d.cts} +2 -2
  93. package/dist/types-D3-J3TFf.d.ts +185 -0
  94. package/dist/types-DMcf_z4J.d.cts +185 -0
  95. package/dist/vector/index.d.cts +10 -10
  96. package/dist/vector/index.d.ts +10 -10
  97. package/dist/video-editor/index.cjs +749 -277
  98. package/dist/video-editor/index.cjs.map +1 -1
  99. package/dist/video-editor/index.d.cts +265 -138
  100. package/dist/video-editor/index.d.ts +265 -138
  101. package/dist/video-editor/index.js +490 -18
  102. package/dist/video-editor/index.js.map +1 -1
  103. package/dist/{warp-BjmAgQwq.d.cts → warp-CH9MM435.d.cts} +1 -1
  104. package/dist/{warp-Ca5fedzS.d.ts → warp-k_PdtXOG.d.ts} +1 -1
  105. package/package.json +6 -1
  106. package/skills/pixerate-image-editor/SKILL.md +7 -0
  107. package/skills/pixerate-image-editor/references/provider-effects.md +35 -0
  108. package/skills/pixerate-video-editor/SKILL.md +39 -1
  109. package/dist/chunk-6CJRDJCU.cjs.map +0 -1
  110. package/dist/chunk-6ZBR4J62.js.map +0 -1
  111. package/dist/chunk-IPFRWILU.js.map +0 -1
  112. package/dist/chunk-RLRUAPBJ.cjs.map +0 -1
  113. package/dist/chunk-SMJGE7HM.cjs.map +0 -1
  114. package/dist/chunk-TVXFHE4T.js.map +0 -1
  115. /package/dist/{brushstrokeRender-CDDRSV2W.js.map → brushstrokeRender-LRV7GXGD.js.map} +0 -0
  116. /package/dist/{chunk-KMBC4HNV.js.map → chunk-J67OLQUQ.js.map} +0 -0
@@ -4,6 +4,7 @@ import {
4
4
  VIDEO_CLIP_ANIMATION_EFFECTS,
5
5
  VIDEO_CLIP_TYPES,
6
6
  VIDEO_EASE_NAMES,
7
+ VIDEO_EFFECT_PLACES,
7
8
  VIDEO_FADE_CURVES,
8
9
  VIDEO_FITS,
9
10
  VIDEO_GRADE_CONTROLS,
@@ -17,12 +18,15 @@ import {
17
18
  VIDEO_TRANSFORM_PROPERTIES,
18
19
  VIDEO_TRANSITION_TYPES,
19
20
  VIDEO_WIPE_DIRECTIONS,
21
+ VideoEffectsEngine,
20
22
  VideoLutCache,
21
23
  allVideoClipIds,
22
24
  allVideoTracks,
23
25
  applyVideoGrade,
24
26
  applyVideoLut,
25
27
  applyVideoLutToPixels,
28
+ checkVideoEffectPlace,
29
+ checkVideoEffectValues,
26
30
  checkVideoTimeRemap,
27
31
  drawVideoFrame,
28
32
  ffmpegFilterValue,
@@ -35,13 +39,16 @@ import {
35
39
  formatVideoTimecodeFrames,
36
40
  isVideoDropFrameRate,
37
41
  isVideoKeyframes,
42
+ mixEffectPixels,
38
43
  nextFreeVideoSlot,
39
44
  normalizeVideoGrade,
40
45
  normalizeVideoGradeLut,
46
+ overEffectPixels,
41
47
  parseVideoCubeLut,
42
48
  parseVideoLoudnessMeasurement,
43
49
  placeVideoRect,
44
50
  removeVideoKey,
51
+ renderVideoFrameExact,
45
52
  retimeVideoAnimatable,
46
53
  setVideoKey,
47
54
  snapToVideoFrame,
@@ -59,6 +66,7 @@ import {
59
66
  videoClipAnimationProperties,
60
67
  videoClipAnimationSpans,
61
68
  videoClipBefore,
69
+ videoClipEffectUses,
62
70
  videoClipEnd,
63
71
  videoClipHandles,
64
72
  videoClipOverhang,
@@ -71,7 +79,11 @@ import {
71
79
  videoContactSheetSvg,
72
80
  videoEaseBezier,
73
81
  videoEaseProgress,
82
+ videoEffectFrameTime,
83
+ videoEffectLayerKey,
84
+ videoEffectValuesAt,
74
85
  videoFadeCurveGain,
86
+ videoFfmpegUnsupportedEffects,
75
87
  videoFrameAt,
76
88
  videoFrameDuration,
77
89
  videoFrameLeafLayers,
@@ -93,12 +105,15 @@ import {
93
105
  videoKeyframeBreakpoints,
94
106
  videoKeyframeFfmpegExpr,
95
107
  videoKeysOf,
108
+ videoLayersUseEffects,
109
+ videoLayersWithoutTransitionEffects,
96
110
  videoLutFromFunction,
97
111
  videoLutSvgApproximation,
98
112
  videoLutSvgPrimitives,
99
113
  videoLutToCube,
100
114
  videoOverlap,
101
115
  videoProjectDuration,
116
+ videoProjectEffects,
102
117
  videoProjectSvg,
103
118
  videoSequenceChildren,
104
119
  videoSequenceDuration,
@@ -119,7 +134,15 @@ import {
119
134
  videoTransitionEase,
120
135
  videoValueAt,
121
136
  videoWipeRect
122
- } from "../chunk-IPFRWILU.js";
137
+ } from "../chunk-ORHMVUR6.js";
138
+ import {
139
+ createEffectToolProvider
140
+ } from "../chunk-JRXJTPES.js";
141
+ import {
142
+ EffectsSlot,
143
+ isEffectError
144
+ } from "../chunk-QEG3GRN2.js";
145
+ import "../chunk-4FX5ACVG.js";
123
146
  import {
124
147
  namedWithSuggestion
125
148
  } from "../chunk-INZJALPY.js";
@@ -239,13 +262,52 @@ function parseVideoGradeLut(v, path) {
239
262
  function parseVideoTransition(v, path) {
240
263
  if (v === void 0) return void 0;
241
264
  if (!isObject(v)) return fail(path, "must be an object { type, duration }");
265
+ const type = oneOf(v.type, VIDEO_TRANSITION_TYPES, `${path}.type`, false);
266
+ if (type === "effect" && v.effect === void 0) fail(`${path}.effect`, 'is required for an effect transition: { effect: "<effect id>", values }');
267
+ if (type !== "effect" && v.effect !== void 0) fail(`${path}.effect`, 'is only for transitions of type "effect"');
242
268
  return compact({
243
- type: oneOf(v.type, VIDEO_TRANSITION_TYPES, `${path}.type`, false),
269
+ type,
244
270
  duration: num(v.duration, `${path}.duration`, { positive: true }),
245
271
  color: str(v.color, `${path}.color`, true),
246
272
  direction: oneOf(v.direction, VIDEO_WIPE_DIRECTIONS, `${path}.direction`),
247
- audioCurve: oneOf(v.audioCurve, VIDEO_FADE_CURVES, `${path}.audioCurve`)
273
+ audioCurve: oneOf(v.audioCurve, VIDEO_FADE_CURVES, `${path}.audioCurve`),
274
+ effect: v.effect === void 0 ? void 0 : parseVideoEffectRef(v.effect, `${path}.effect`, false)
275
+ });
276
+ }
277
+ var isEffectValue = (x) => typeof x === "string" || typeof x === "boolean" || typeof x === "number" && Number.isFinite(x) || Array.isArray(x) && x.every((n) => typeof n === "number" && Number.isFinite(n));
278
+ function parseVideoEffectValues(v, path, keyframes = true) {
279
+ if (v === void 0) return void 0;
280
+ if (!isObject(v)) return fail(path, "must be an object of control values from describe_effect");
281
+ const out = {};
282
+ for (const [k, x] of Object.entries(v)) {
283
+ if (isEffectValue(x)) out[k] = Array.isArray(x) ? [...x] : x;
284
+ else if (keyframes && isObject(x)) out[k] = parseVideoKeyframes(x, `${path}.${k}`);
285
+ else fail(`${path}.${k}`, keyframes ? "must be a number, string, true / false, a list of numbers, or keyframes { keys }" : "must be a number, string, true / false or a list of numbers");
286
+ }
287
+ return out;
288
+ }
289
+ function parseVideoEffectRef(v, path, keyframes = true) {
290
+ if (!isObject(v)) return fail(path, "must be an object { effect, values }");
291
+ const unknown = Object.keys(v).find((k) => !["effect", "provider", "name", "values"].includes(k));
292
+ if (unknown) fail(`${path}.${unknown}`, "is not an effect field; use effect, provider, name, values");
293
+ return compact({ effect: str(v.effect, `${path}.effect`), provider: str(v.provider, `${path}.provider`, true), name: str(v.name, `${path}.name`, true), values: parseVideoEffectValues(v.values, `${path}.values`, keyframes) });
294
+ }
295
+ function parseVideoClipEffects(v, path) {
296
+ if (v === void 0) return void 0;
297
+ if (!Array.isArray(v)) return fail(path, "must be a list of effects { id, effect, values }");
298
+ const ids = /* @__PURE__ */ new Set();
299
+ const out = v.map((e, i) => {
300
+ const at = `${path}[${i}]`;
301
+ if (!isObject(e)) return fail(at, "must be an object { id, effect, values }");
302
+ const unknown = Object.keys(e).find((k) => !["id", "effect", "provider", "name", "values", "enabled", "opacity"].includes(k));
303
+ if (unknown) fail(`${at}.${unknown}`, "is not a clip effect field; use id, effect, provider, name, values, enabled, opacity");
304
+ const id = str(e.id, `${at}.id`);
305
+ if (ids.has(id)) fail(`${at}.id`, `repeats "${id}"; each effect on a clip needs its own id`);
306
+ ids.add(id);
307
+ const { effect, provider, name, values } = parseVideoEffectRef({ effect: e.effect, provider: e.provider, name: e.name, values: e.values }, at);
308
+ return compact({ id, effect, provider, name, values, enabled: bool(e.enabled, `${at}.enabled`), opacity: parseVideoAnimatable(e.opacity, `${at}.opacity`, { optional: true, min: 0, max: 1 }) });
248
309
  });
310
+ return out.length ? out : void 0;
249
311
  }
250
312
  function parseVideoTranscriptWords(v, path) {
251
313
  if (v === void 0) return void 0;
@@ -316,7 +378,8 @@ function parseVideoClip(v, path = "clip") {
316
378
  animateOut: parseVideoClipAnimation(v.animateOut, `${path}.animateOut`),
317
379
  fadeIn: num(v.fadeIn, `${path}.fadeIn`, { optional: true, min: 0 }),
318
380
  fadeOut: num(v.fadeOut, `${path}.fadeOut`, { optional: true, min: 0 }),
319
- transition: parseVideoTransition(v.transition, `${path}.transition`)
381
+ transition: parseVideoTransition(v.transition, `${path}.transition`),
382
+ effects: parseVideoClipEffects(v.effects, `${path}.effects`)
320
383
  };
321
384
  if (type === "media")
322
385
  return compact({
@@ -356,6 +419,10 @@ function parseVideoClip(v, path = "clip") {
356
419
  fontWeight: num(v.fontWeight, `${path}.fontWeight`, { optional: true, min: 100, max: 1e3 }),
357
420
  background: str(v.background, `${path}.background`, true)
358
421
  });
422
+ if (type === "effect") {
423
+ const ref = parseVideoEffectRef({ effect: v.effect, provider: v.provider, name: v.name === void 0 ? void 0 : v.name, values: v.values }, path);
424
+ return compact({ type, ...base, ...ref, name: base.name });
425
+ }
359
426
  return compact({ type, ...base, color: str(v.color, `${path}.color`) });
360
427
  }
361
428
  function parseVideoTrack(v, path = "track") {
@@ -653,7 +720,7 @@ function retimeClipKeys(clip, fn) {
653
720
  if (isVideoKeyframes(v)) a.set(fn(v));
654
721
  }
655
722
  }
656
- var OPTIONAL_CLIP_FIELDS = ["name", "opacity", "transform", "fit", "blend", "grade", "animateIn", "animateOut", "fadeIn", "fadeOut", "fadeInCurve", "fadeOutCurve", "transition", "in", "speed", "timeRemap", "volume", "muted", "color", "fontSize", "fontFamily", "fontWeight", "background"];
723
+ var OPTIONAL_CLIP_FIELDS = ["name", "effects", "values", "provider", "opacity", "transform", "fit", "blend", "grade", "animateIn", "animateOut", "fadeIn", "fadeOut", "fadeInCurve", "fadeOutCurve", "transition", "in", "speed", "timeRemap", "volume", "muted", "color", "fontSize", "fontFamily", "fontWeight", "background"];
657
724
  var TIME_FIELDS = { slip_clip: ["delta"], roll_edit: ["delta"], slide_clip: ["delta"], lift_range: ["from", "to"], extract_range: ["from", "to"], split_at: ["at"] };
658
725
  var Editor = class {
659
726
  constructor(p) {
@@ -1099,7 +1166,7 @@ var Editor = class {
1099
1166
  const { track, clip } = this.clip(e.clip);
1100
1167
  this.unlocked(track);
1101
1168
  if (clip.type === "sequence") refuse(`Clip "${clip.id}" is a compound clip, which plays its sequence at normal speed; ramp the clips inside the sequence instead.`);
1102
- if (clip.type !== "media") refuse(`Clip "${clip.id}" is a ${clip.type === "text" ? "title" : "colour"}, so it has no source to ramp; only video and audio clips change speed.`);
1169
+ if (clip.type !== "media") refuse(`Clip "${clip.id}" is a ${clip.type === "text" ? "title" : clip.type === "effect" ? "effect clip" : "colour"}, so it has no source to ramp; only video and audio clips change speed.`);
1103
1170
  const asset = this.asset(clip.asset);
1104
1171
  if (asset.kind === "image") refuse(`Clip "${clip.id}" is a still ("${asset.id}"), so it has no motion to ramp.`);
1105
1172
  const media = clip;
@@ -1156,7 +1223,7 @@ var Editor = class {
1156
1223
  case "slip_clip": {
1157
1224
  const { track, clip } = this.clip(e.clip);
1158
1225
  this.unlocked(track);
1159
- if (clip.type !== "media" && clip.type !== "sequence") refuse(`Clip "${clip.id}" is a ${clip.type === "text" ? "title" : "colour"}, so it has no source to slip; only video, audio and compound clips slip.`);
1226
+ if (clip.type !== "media" && clip.type !== "sequence") refuse(`Clip "${clip.id}" is a ${clip.type === "text" ? "title" : clip.type === "effect" ? "effect clip" : "colour"}, so it has no source to slip; only video, audio and compound clips slip.`);
1160
1227
  const source = clip.type === "sequence" ? `sequence "${clip.sequence}"` : `"${this.asset(clip.asset).id}"`;
1161
1228
  if (clip.type === "media" && this.asset(clip.asset).kind === "image") refuse(`Clip "${clip.id}" is a still ("${clip.asset}"), so it has no source to slip; only video and audio clips slip.`);
1162
1229
  const handles = videoClipHandles(clip, videoAssetMap(this.p), videoSequenceMap(this.p));
@@ -2451,6 +2518,9 @@ var VideoPreviewEngine = class {
2451
2518
  this.frameCache = options.frameCache !== void 0 ? options.frameCache : source.frameCache ?? null;
2452
2519
  this.unsubscribeFrames = this.frameCache ? this.frameCache.subscribe(() => this.redraw()) : () => {
2453
2520
  };
2521
+ this.effects = options.effects !== void 0 ? options.effects : source.effectsEngine ?? null;
2522
+ this.unsubscribeEffects = this.effects ? this.effects.subscribe(() => this.redraw()) : () => {
2523
+ };
2454
2524
  const pixels = options.pixels !== void 0 ? options.pixels : source.previewPixels;
2455
2525
  this.detachPixels = pixels ? pixels.attach(() => this.readPixels()) : () => {
2456
2526
  };
@@ -2480,6 +2550,8 @@ var VideoPreviewEngine = class {
2480
2550
  fps = 30;
2481
2551
  unsubscribeLuts;
2482
2552
  scratchCanvases = /* @__PURE__ */ new Map();
2553
+ effects;
2554
+ unsubscribeEffects;
2483
2555
  /** The canvas's pixels and the frame they show, or `null` when its context cannot be read. */
2484
2556
  readPixels() {
2485
2557
  const ctx = this.ctx;
@@ -2568,14 +2640,22 @@ var VideoPreviewEngine = class {
2568
2640
  }
2569
2641
  for (const a of sounding.values()) sync(a.clip, a.src, "audio", a.sourceTime, a.speed, a.gain);
2570
2642
  for (const entry of [...this.media.values()]) if (!active.has(entry.clip)) this.release(entry);
2571
- this.draw(frame);
2643
+ this.draw(this.drawable(project, frame));
2644
+ }
2645
+ /** With effects, the frame is drawn at the start of its frame, so effect pictures are cached once per frame. */
2646
+ drawable(project, frame) {
2647
+ if (!this.effects?.available || !videoLayersUseEffects(frame.layers)) return frame;
2648
+ const at = videoEffectFrameTime(frame.time, project.fps);
2649
+ return at === frame.time ? frame : { ...videoFrameAt(project, at), time: frame.time };
2572
2650
  }
2573
2651
  redraw() {
2574
2652
  if (this.scheduled || this.destroyed) return;
2575
2653
  this.scheduled = true;
2576
2654
  queueMicrotask(() => {
2577
2655
  this.scheduled = false;
2578
- if (!this.destroyed) this.draw(videoFrameAt(this.source.getState().project, this.source.getState().time));
2656
+ if (this.destroyed) return;
2657
+ const { project, time } = this.source.getState();
2658
+ this.draw(this.drawable(project, videoFrameAt(project, time)));
2579
2659
  });
2580
2660
  }
2581
2661
  sourceFor = (layer) => {
@@ -2613,11 +2693,20 @@ var VideoPreviewEngine = class {
2613
2693
  if (this.canvas.height !== h) this.canvas.height = h;
2614
2694
  this.complete = true;
2615
2695
  const sourceFor = (layer) => {
2696
+ if (layer.type === "effect") return null;
2616
2697
  const source = this.sourceFor(layer);
2617
2698
  if (!source) this.complete = false;
2618
2699
  return source;
2619
2700
  };
2620
- drawVideoFrame(this.ctx, frame, sourceFor, resolution, { defineSvgFilter: this.defineSvgFilter, lutFor: this.lutFor, scratch: this.scratch });
2701
+ drawVideoFrame(this.ctx, frame, sourceFor, resolution, {
2702
+ defineSvgFilter: this.defineSvgFilter,
2703
+ lutFor: this.lutFor,
2704
+ scratch: this.scratch,
2705
+ effects: this.effects,
2706
+ onEffectWaiting: () => {
2707
+ this.complete = false;
2708
+ }
2709
+ });
2621
2710
  this.drawn = { time: frame.time, complete: this.complete };
2622
2711
  this.options.onFrame?.(frame);
2623
2712
  this.pixelsTarget?.drawn(frame.time, this.complete);
@@ -2668,6 +2757,7 @@ var VideoPreviewEngine = class {
2668
2757
  this.detachPixels();
2669
2758
  this.unsubscribeLuts();
2670
2759
  this.unsubscribeFrames();
2760
+ this.unsubscribeEffects();
2671
2761
  this.filterHost?.remove();
2672
2762
  this.filterHost = null;
2673
2763
  this.filters.clear();
@@ -2689,6 +2779,10 @@ var VideoEditorController = class {
2689
2779
  luts;
2690
2780
  /** The host's frame cache, if it gave one (the preview fills and reads it). */
2691
2781
  frameCache;
2782
+ /** The effects provider slot (see `setEffectsProvider`). */
2783
+ effects;
2784
+ /** Rendered effect pictures for the preview and frame-exact exports. */
2785
+ effectsEngine;
2692
2786
  project;
2693
2787
  issues;
2694
2788
  selection = [];
@@ -2706,6 +2800,9 @@ var VideoEditorController = class {
2706
2800
  this.audioPeaks = options.audioPeaks instanceof VideoAudioPeaksCache ? options.audioPeaks : new VideoAudioPeaksCache(options.audioPeaks);
2707
2801
  this.luts = options.luts instanceof VideoLutCache ? options.luts : new VideoLutCache(options.luts);
2708
2802
  this.frameCache = options.frameCache ?? null;
2803
+ this.effects = new EffectsSlot();
2804
+ if (options.effects) this.effects.set(options.effects);
2805
+ this.effectsEngine = new VideoEffectsEngine({ slot: this.effects });
2709
2806
  this.project = options.project ? parseVideoProject(options.project) : createVideoProject();
2710
2807
  this.issues = checkVideoProject(this.project);
2711
2808
  this.readOnly = options.readOnly ?? false;
@@ -2721,12 +2818,14 @@ var VideoEditorController = class {
2721
2818
  this.player.subscribe(() => this.notify()),
2722
2819
  this.history.subscribe(() => {
2723
2820
  if (!this.applying) this.notify();
2724
- })
2821
+ }),
2822
+ this.effects.subscribe(() => this.notify())
2725
2823
  );
2726
2824
  }
2727
2825
  /** Pauses and stops listening to the player and history. Subscribing again reconnects (React StrictMode). */
2728
2826
  destroy() {
2729
2827
  this.player.pause();
2828
+ this.effectsEngine.reset();
2730
2829
  this.unsubscribers.splice(0).forEach((u) => u());
2731
2830
  }
2732
2831
  getState = () => this.state;
@@ -2760,6 +2859,7 @@ var VideoEditorController = class {
2760
2859
  undoLabel: h.undoName,
2761
2860
  redoLabel: h.redoName,
2762
2861
  previewScale: this.previewScale,
2862
+ effectsProvider: this.effects.provider,
2763
2863
  revision: this.revision
2764
2864
  };
2765
2865
  }
@@ -2789,7 +2889,7 @@ var VideoEditorController = class {
2789
2889
  this.notify();
2790
2890
  this.onChange?.(project);
2791
2891
  }
2792
- commit(label, next) {
2892
+ commit(label, next, merge) {
2793
2893
  const before = this.project;
2794
2894
  this.error = null;
2795
2895
  if (next === before) {
@@ -2803,7 +2903,8 @@ var VideoEditorController = class {
2803
2903
  undo: () => this.setProject(before),
2804
2904
  redo: () => this.setProject(next)
2805
2905
  };
2806
- this.history.execute(command);
2906
+ if (merge) this.history.executeMerged(command, { key: merge });
2907
+ else this.history.execute(command);
2807
2908
  }
2808
2909
  refuse(message) {
2809
2910
  this.error = message;
@@ -2816,14 +2917,15 @@ var VideoEditorController = class {
2816
2917
  }
2817
2918
  /**
2818
2919
  * Applies edits as one undo step, all or nothing (see `applyVideoEdits`). With `dryRun` it
2819
- * reports what would happen and changes nothing.
2920
+ * reports what would happen and changes nothing. Edits with the same `merge` key in a row
2921
+ * (a slider drag) fold into one undo step.
2820
2922
  */
2821
2923
  apply(edits, label = "Edit video", options = {}) {
2822
2924
  const reason = this.readOnlyReason();
2823
2925
  if (reason) return this.refuse(reason);
2824
2926
  const result = applyVideoEdits(this.project, edits);
2825
2927
  if (!result.ok) return options.dryRun ? result : this.refuse(result.error);
2826
- if (!options.dryRun) this.commit(label, result.project);
2928
+ if (!options.dryRun) this.commit(label, result.project, options.merge);
2827
2929
  return result;
2828
2930
  }
2829
2931
  /** Opens a project (JSON or object). `clearHistory` drops the undo stack (opening a file); otherwise it is one undo step. */
@@ -2923,6 +3025,181 @@ var VideoEditorController = class {
2923
3025
  if (!this.selection.length) return this.refuse("Select clips to delete first.");
2924
3026
  return this.apply({ op: "delete_clips", clips: [...this.selection], ripple: options.ripple }, options.ripple ? "Ripple delete" : "Delete clips");
2925
3027
  }
3028
+ // -----------------------------------------------------------------------------------------
3029
+ // Effects
3030
+ // -----------------------------------------------------------------------------------------
3031
+ /** The registered effects provider, or `null`. */
3032
+ get effectsProvider() {
3033
+ return this.effects.provider;
3034
+ }
3035
+ /** Registers (or with `null` removes) the effects provider. Not an edit: no undo step. */
3036
+ setEffectsProvider(provider) {
3037
+ this.effects.set(provider);
3038
+ }
3039
+ /** Registers `provider` and returns a function that removes it again (if it is still the one set). */
3040
+ useEffects(provider) {
3041
+ return this.effects.use(provider);
3042
+ }
3043
+ /** The provider's description of an effect, checked for `place` (rejects without a provider or for an unknown id). */
3044
+ async describeEffect(effect, place) {
3045
+ const d = await this.effects.require().describe(effect);
3046
+ if (place) checkVideoEffectPlace(d, place);
3047
+ return d;
3048
+ }
3049
+ refused(message) {
3050
+ return this.refuse(message);
3051
+ }
3052
+ async effectEdit(run, merge) {
3053
+ const reason = this.readOnlyReason();
3054
+ if (reason) return this.refused(reason);
3055
+ let plan;
3056
+ try {
3057
+ plan = await run();
3058
+ } catch (error) {
3059
+ if (isEffectError(error) || error instanceof Error) return this.refused(error.message);
3060
+ throw error;
3061
+ }
3062
+ const result = this.apply(plan.edit, plan.label, { merge });
3063
+ if (!result.ok) return result;
3064
+ return { ...result, clip: plan.clip, ...plan.effect ? { effect: plan.effect() } : {} };
3065
+ }
3066
+ clipOrThrow(id) {
3067
+ const at = findVideoClip(this.project, id);
3068
+ if (!at) throw new Error(`There is no clip "${id}"; list_applied_effects or get_video_project lists the clips.`);
3069
+ return at.clip;
3070
+ }
3071
+ /**
3072
+ * Adds a filter or overlay effect to a clip's effect stack (at `index`, default the end: applied
3073
+ * last), with `values` over the effect's defaults (numbers may be keyframes). One undo step.
3074
+ * Returns the effect with its new id.
3075
+ */
3076
+ applyClipEffect(clip, effect, options = {}) {
3077
+ return this.effectEdit(async () => {
3078
+ const current = this.clipOrThrow(clip);
3079
+ const d = await this.describeEffect(effect, "clip");
3080
+ const values = checkVideoEffectValues(d, options.values, { keyframes: true });
3081
+ const stack = [...current.effects ?? []];
3082
+ const taken = new Set(stack.map((e) => e.id));
3083
+ let n = 1;
3084
+ while (taken.has(`fx${n}`)) n++;
3085
+ const added = { id: `fx${n}`, effect: d.id, provider: this.effects.require().id, name: d.name, values };
3086
+ if (options.enabled === false) added.enabled = false;
3087
+ if (options.opacity !== void 0) added.opacity = options.opacity;
3088
+ const index = options.index ?? stack.length;
3089
+ if (!(Number.isInteger(index) && index >= 0 && index <= stack.length)) throw new Error(`"index" must be a whole number from 0 to ${stack.length}.`);
3090
+ stack.splice(index, 0, added);
3091
+ return { edit: { op: "update_clip", clip, set: { effects: stack } }, label: `Add effect ${d.name}`, clip, effect: () => added };
3092
+ });
3093
+ }
3094
+ /**
3095
+ * Changes an effect on a clip: `values` (merged over its current ones; numbers may be
3096
+ * keyframes, `null` resets a control to its default), `enabled`, `opacity` or its place in the
3097
+ * stack (`index`). Values are checked against the provider's controls. One undo step.
3098
+ */
3099
+ updateClipEffect(clip, instance, changes, options = {}) {
3100
+ return this.effectEdit(async () => {
3101
+ const current = this.clipOrThrow(clip);
3102
+ const stack = [...current.effects ?? []];
3103
+ const i = stack.findIndex((e) => e.id === instance);
3104
+ if (i < 0) throw new Error(`Clip "${clip}" has no effect "${instance}"; its effects are ${stack.map((e) => `"${e.id}" (${e.name ?? e.effect})`).join(", ") || "none"}.`);
3105
+ const was = stack[i];
3106
+ const next = { ...was };
3107
+ if (changes.values) {
3108
+ const d = await this.describeEffect(was.effect);
3109
+ const reset = Object.keys(changes.values).filter((k) => changes.values[k] === null);
3110
+ const given = Object.fromEntries(Object.entries(changes.values).filter(([, v]) => v !== null));
3111
+ const base = { ...was.values ?? {} };
3112
+ for (const k of reset) {
3113
+ if (!(k in d.controls.properties)) throw new Error(`"${k}" is not a control of "${d.id}"; its controls are ${Object.keys(d.controls.properties).join(", ")}.`);
3114
+ base[k] = d.defaults[k];
3115
+ }
3116
+ next.values = checkVideoEffectValues(d, given, { base, keyframes: true });
3117
+ }
3118
+ if (changes.enabled !== void 0) {
3119
+ if (changes.enabled) delete next.enabled;
3120
+ else next.enabled = false;
3121
+ }
3122
+ if (changes.opacity !== void 0) next.opacity = changes.opacity;
3123
+ stack.splice(i, 1);
3124
+ const index = changes.index ?? i;
3125
+ if (!(Number.isInteger(index) && index >= 0 && index <= stack.length)) throw new Error(`"index" must be a whole number from 0 to ${stack.length}.`);
3126
+ stack.splice(index, 0, next);
3127
+ return { edit: { op: "update_clip", clip, set: { effects: stack } }, label: `Change effect ${was.name ?? was.effect}`, clip, effect: () => next };
3128
+ }, options.merge);
3129
+ }
3130
+ /** Removes an effect from a clip's stack (works without a provider, so placeholders can be cleared). One undo step. */
3131
+ removeClipEffect(clip, instance) {
3132
+ const reason = this.readOnlyReason();
3133
+ if (reason) return this.refused(reason);
3134
+ const at = findVideoClip(this.project, clip);
3135
+ if (!at) return this.refused(`There is no clip "${clip}".`);
3136
+ const stack = at.clip.effects ?? [];
3137
+ const was = stack.find((e) => e.id === instance);
3138
+ if (!was) return this.refused(`Clip "${clip}" has no effect "${instance}"; its effects are ${stack.map((e) => `"${e.id}"`).join(", ") || "none"}.`);
3139
+ const rest = stack.filter((e) => e !== was);
3140
+ const result = rest.length ? this.apply({ op: "update_clip", clip, set: { effects: rest } }, `Remove effect ${was.name ?? was.effect}`) : this.apply({ op: "update_clip", clip, remove: ["effects"] }, `Remove effect ${was.name ?? was.effect}`);
3141
+ return result.ok ? { ...result, clip, effect: was } : result;
3142
+ }
3143
+ /**
3144
+ * Sets a provider transition into `clip` from the clip that ends where it starts (centred on the
3145
+ * cut, `duration` ms, default the effect's own length or 1000). One undo step.
3146
+ */
3147
+ setEffectTransition(clip, effect, options = {}) {
3148
+ return this.effectEdit(async () => {
3149
+ this.clipOrThrow(clip);
3150
+ const d = await this.describeEffect(effect, "transition");
3151
+ const values = checkVideoEffectValues(d, options.values);
3152
+ const duration = options.duration ?? d.duration ?? 1e3;
3153
+ return { edit: { op: "set_transition", clip, transition: { type: "effect", duration, effect: { effect: d.id, provider: this.effects.require().id, name: d.name, values } } }, label: `Transition ${d.name}`, clip };
3154
+ });
3155
+ }
3156
+ /**
3157
+ * Adds an effect clip: a generator, title or overlay effect drawn over the whole frame for
3158
+ * `duration` ms (default the effect's own length, else 5 s) at `start` (default the end of the
3159
+ * track). One undo step. Returns the new clip's id.
3160
+ */
3161
+ addEffectClip(track, effect, options = {}) {
3162
+ return this.effectEdit(async () => {
3163
+ const d = await this.describeEffect(effect, "generator");
3164
+ const values = checkVideoEffectValues(d, options.values, { keyframes: true });
3165
+ const duration = options.duration ?? d.duration ?? 5e3;
3166
+ const label = `Add ${d.name}`;
3167
+ const clipId = options.id ?? "";
3168
+ return {
3169
+ edit: { op: "add_clip", track, mode: options.mode, ripple: options.ripple, clip: { type: "effect", ...options.id ? { id: options.id } : {}, effect: d.id, provider: this.effects.require().id, name: options.name ?? d.name, values, start: options.start, duration } },
3170
+ label,
3171
+ clip: clipId
3172
+ };
3173
+ }).then((r) => r.ok && !r.clip ? { ...r, clip: r.outcomes[0]?.clip ?? "" } : r);
3174
+ }
3175
+ /**
3176
+ * Changes the values of an effect clip's picture (`target` its id) or of the effect transition
3177
+ * into a clip, merged over the current ones and checked against the provider's controls.
3178
+ * Transition values cannot be keyframed. One undo step.
3179
+ */
3180
+ updateEffectValues(clip, part, values, options = {}) {
3181
+ return this.effectEdit(async () => {
3182
+ const current = this.clipOrThrow(clip);
3183
+ if (part === "generator") {
3184
+ if (current.type !== "effect") throw new Error(`Clip "${clip}" is not an effect clip.`);
3185
+ const d2 = await this.describeEffect(current.effect);
3186
+ return { edit: { op: "update_clip", clip, set: { values: checkVideoEffectValues(d2, values, { base: current.values ?? {}, keyframes: true }) } }, label: `Change ${current.name ?? d2.name}`, clip };
3187
+ }
3188
+ const t = current.transition;
3189
+ if (t?.type !== "effect" || !t.effect) throw new Error(`Clip "${clip}" has no effect transition.`);
3190
+ const d = await this.describeEffect(t.effect.effect);
3191
+ const merged = checkVideoEffectValues(d, values, { base: t.effect.values ?? {} });
3192
+ return { edit: { op: "set_transition", clip, transition: { ...t, effect: { ...t.effect, values: merged } } }, label: `Change transition ${d.name}`, clip };
3193
+ }, options.merge);
3194
+ }
3195
+ /** 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). */
3196
+ setClipEffectKeyframes(clip, instance, control, keys) {
3197
+ const current = findVideoClip(this.project, clip)?.clip;
3198
+ const was = current?.effects?.find((e) => e.id === instance);
3199
+ const value = was?.values?.[control];
3200
+ const plain = keys === null ? isVideoKeyframes(value) ? value.keys[0]?.v : value : { keys: [...keys].sort((a, b) => a.t - b.t) };
3201
+ return this.updateClipEffect(clip, instance, { values: { [control]: plain ?? null } });
3202
+ }
2926
3203
  /** What plays at `time` (default: the playhead). */
2927
3204
  frame(time = this.player.currentTime) {
2928
3205
  return videoFrameAt(this.project, time);
@@ -2983,7 +3260,8 @@ var VIDEO_TRANSITION_LABELS = {
2983
3260
  wipe: "Wipe",
2984
3261
  push: "Push",
2985
3262
  slide: "Slide",
2986
- iris: "Iris"
3263
+ iris: "Iris",
3264
+ effect: "Effect"
2987
3265
  };
2988
3266
  var videoTransitionHasDirection = (type) => type === "wipe" || type === "push" || type === "slide";
2989
3267
  var VIDEO_BLEND_MODE_LABELS = {
@@ -3012,6 +3290,7 @@ function videoClipTint(clip, asset) {
3012
3290
  if (clip.type === "text") return "#a855f7";
3013
3291
  if (clip.type === "sequence") return "#0d9488";
3014
3292
  if (clip.type === "color") return clip.color;
3293
+ if (clip.type === "effect") return "#db2777";
3015
3294
  if (!asset) return "#ef4444";
3016
3295
  return asset.kind === "audio" ? "#10b981" : asset.kind === "image" ? "#f59e0b" : "#3b82f6";
3017
3296
  }
@@ -3020,6 +3299,7 @@ function videoClipLabel(clip, asset, sequence) {
3020
3299
  if (clip.type === "text") return clip.text.split("\n")[0];
3021
3300
  if (clip.type === "color") return clip.color;
3022
3301
  if (clip.type === "sequence") return `Compound: ${sequence?.name ?? clip.sequence}`;
3302
+ if (clip.type === "effect") return `Effect: ${clip.effect}`;
3023
3303
  const base = asset?.name ?? asset?.src.replace(/^.*[\\/]/, "") ?? clip.asset;
3024
3304
  const speed = videoClipSpeed(clip);
3025
3305
  return speed !== 1 ? `${base} (${Math.round(speed * 100)}%)` : base;
@@ -3226,7 +3506,7 @@ var TRANSFORM = object(
3226
3506
  );
3227
3507
  var TRANSITION = object(
3228
3508
  {
3229
- type: { type: "string", enum: [...VIDEO_TRANSITION_TYPES], description: "crossfade (this clip fades up over the one before), dip (down into a colour and up out of it), wipe (an edge travels across revealing this clip), push (this clip slides in and pushes the one before out), slide (this clip slides in over the one before) or iris (a circle opens from the centre)." },
3509
+ type: { type: "string", enum: VIDEO_TRANSITION_TYPES.filter((t) => t !== "effect"), description: "crossfade (this clip fades up over the one before), dip (down into a colour and up out of it), wipe (an edge travels across revealing this clip), push (this clip slides in and pushes the one before out), slide (this clip slides in over the one before) or iris (a circle opens from the centre). For a transition effect from the effects provider use set_video_effect_transition." },
3230
3510
  duration: { type: "number", exclusiveMinimum: 0, description: "Length in ms, centred on the cut. Every type but dip needs half of it in media past the cut on both sides (handles); a dip needs none." },
3231
3511
  color: COLOR("For dip: the colour passed through (default black)"),
3232
3512
  direction: { type: "string", enum: [...VIDEO_WIPE_DIRECTIONS], description: "For wipe, push and slide: the way the edge or picture travels (default right, coming in from the left)." },
@@ -4007,7 +4287,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
4007
4287
  if (!at) throw new Error(`There is no clip "${id}"; get_video_project lists the clips.`);
4008
4288
  const { clip } = at;
4009
4289
  if (clip.type === "sequence") throw new Error(`Clip "${id}" is a compound clip, which plays its sequence at normal speed; change the speed of the clips inside the sequence instead.`);
4010
- if (clip.type !== "media") throw new Error(`Clip "${id}" is a ${clip.type === "text" ? "title" : "colour"}, so it has no source to change the speed of; use a video or audio clip.`);
4290
+ if (clip.type !== "media") throw new Error(`Clip "${id}" is a ${clip.type === "text" ? "title" : clip.type === "effect" ? "effect clip" : "colour"}, so it has no source to change the speed of; use a video or audio clip.`);
4011
4291
  if (project().assets.find((a) => a.id === clip.asset)?.kind === "image") throw new Error(`Clip "${id}" is a still, so it has no motion to change the speed of.`);
4012
4292
  return clip;
4013
4293
  };
@@ -4355,6 +4635,178 @@ function createVideoEditorToolProvider(controller, options = {}) {
4355
4635
  }
4356
4636
  };
4357
4637
  }
4638
+
4639
+ // src/video-editor/effect-tools.ts
4640
+ function videoClipAppliedEffects(clip) {
4641
+ const out = [];
4642
+ const ref = (r) => ({ effect: r.effect, ...r.name ? { name: r.name } : {}, ...r.provider ? { provider: r.provider } : {} });
4643
+ if (clip.type === "effect") out.push({ id: "generator", ...ref(clip), role: "effect clip", enabled: true, values: clip.values ?? {} });
4644
+ if (clip.transition?.type === "effect" && clip.transition.effect) out.push({ id: "transition", ...ref(clip.transition.effect), role: "transition", enabled: true, values: { ...clip.transition.effect.values ?? {} }, duration: clip.transition.duration });
4645
+ (clip.effects ?? []).forEach((e, index) => out.push({ id: e.id, ...ref(e), role: "clip effect", enabled: e.enabled !== false, ...e.opacity !== void 0 ? { opacity: e.opacity } : {}, values: e.values ?? {}, index }));
4646
+ return out;
4647
+ }
4648
+ var MS2 = (what) => ({ type: "number", minimum: 0, description: `${what}, in milliseconds.` });
4649
+ var KEYS2 = {
4650
+ type: "array",
4651
+ minItems: 1,
4652
+ items: {
4653
+ type: "object",
4654
+ properties: {
4655
+ t: { type: "number", description: "Clip-relative time in ms (0 = the clip's start)." },
4656
+ v: { type: "number", description: "The control's value at t, within its range from describe_effect." },
4657
+ ease: { anyOf: [{ type: "string", enum: [...VIDEO_EASE_NAMES] }, { type: "array", items: { type: "number" }, minItems: 4, maxItems: 4 }], description: "How the value moves to the next key: linear (default), hold, ease-in, ease-out, ease-in-out, or CSS cubic-bezier [x1, y1, x2, y2]." }
4658
+ },
4659
+ required: ["t", "v"],
4660
+ additionalProperties: false
4661
+ },
4662
+ description: "Keys in any order (sorted for you), one per time."
4663
+ };
4664
+ function createVideoEffectsToolProvider(controller, options = {}) {
4665
+ const readOnlyReason = () => {
4666
+ const ro = typeof options.readOnly === "function" ? options.readOnly() : options.readOnly;
4667
+ if (ro) return typeof ro === "string" ? ro : "The video project is read-only.";
4668
+ return controller.readOnlyReason();
4669
+ };
4670
+ const clipOf = (id) => {
4671
+ const at = findVideoClip(controller.getProject(), id);
4672
+ if (!at) {
4673
+ const ids = controller.getProject().tracks.flatMap((t) => t.clips.map((c) => c.id));
4674
+ throw new Error(`There is no clip "${id}"; the clips are ${ids.map((c) => `"${c}"`).join(", ") || "none"} (get_video_project lists them).`);
4675
+ }
4676
+ return at.clip;
4677
+ };
4678
+ const result = (r, target, instance, extra = {}) => {
4679
+ if (!r.ok) throw new Error(r.error);
4680
+ const effects = videoClipAppliedEffects(clipOf(target));
4681
+ const effect = effects.find((e) => e.id === (instance ?? r.effect?.id)) ?? (r.effect ? { id: r.effect.id, effect: r.effect.effect, role: "clip effect", enabled: r.effect.enabled !== false, values: r.effect.values ?? {} } : effects[0]);
4682
+ return { target, effect, effects, ...extra };
4683
+ };
4684
+ return createEffectToolProvider({
4685
+ id: options.id ?? "video-effects",
4686
+ namespace: options.namespace,
4687
+ target: { noun: "clip", id: 'A clip id from get_video_project (e.g. "c1").' },
4688
+ applyUses: ["filter", "overlay"],
4689
+ applyDetails: "Filters change the clip's picture and overlays draw over it, in stack order, on the picture as it comes from the source, before the clip's grade, placement, opacity and blend; numbers may be keyframed (clip-relative ms). Transitions go through set_video_effect_transition and generators or titles through add_video_effect_clip. The preview renders them frame by frame; the ffmpeg export then needs rendered frames (see export_video_ffmpeg).",
4690
+ provider: () => controller.effectsProvider,
4691
+ readOnlyReason,
4692
+ list: (target) => {
4693
+ const clips = target ? [clipOf(target)] : controller.getProject().tracks.flatMap((t) => t.clips);
4694
+ return clips.map((c) => ({ target: c.id, ...c.name ? { name: c.name } : {}, effects: videoClipAppliedEffects(c) })).filter((t) => target || t.effects.length);
4695
+ },
4696
+ apply: async (target, effect, args) => {
4697
+ clipOf(target);
4698
+ return result(await controller.applyClipEffect(target, effect, { values: args.values, index: args.index, enabled: args.enabled, opacity: args.opacity }), target);
4699
+ },
4700
+ update: async (target, instance, changes) => {
4701
+ clipOf(target);
4702
+ if (instance === "generator" || instance === "transition") {
4703
+ const extra = ["enabled", "opacity", "index"].filter((k) => changes[k] !== void 0);
4704
+ if (extra.length) throw new Error(`An ${instance === "generator" ? "effect clip" : "effect transition"} only takes values; change its ${extra.join(", ")} with update_video_clip${instance === "transition" ? " or set_video_effect_transition" : ""}.`);
4705
+ const values = Object.fromEntries(Object.entries(changes.values ?? {}).filter(([, v]) => v !== null));
4706
+ return result(await controller.updateEffectValues(target, instance, values), target, instance);
4707
+ }
4708
+ return result(await controller.updateClipEffect(target, instance, changes), target, instance);
4709
+ },
4710
+ remove: async (target, instance) => {
4711
+ const clip = clipOf(target);
4712
+ const was = videoClipAppliedEffects(clip).find((e) => e.id === instance);
4713
+ if (!was) throw new Error(`Clip "${target}" has no effect "${instance}"; its effects are ${videoClipAppliedEffects(clip).map((e) => `"${e.id}"`).join(", ") || "none"}.`);
4714
+ if (instance === "generator") throw new Error(`"${target}" is an effect clip: its effect is the clip itself. Delete the clip with delete_video_clips.`);
4715
+ if (instance === "transition") {
4716
+ const r2 = controller.apply({ op: "set_transition", clip: target, transition: null }, "Remove transition");
4717
+ if (!r2.ok) throw new Error(r2.error);
4718
+ return { target, effect: was, effects: videoClipAppliedEffects(clipOf(target)) };
4719
+ }
4720
+ const r = controller.removeClipEffect(target, instance);
4721
+ if (!r.ok) throw new Error(r.error);
4722
+ return { target, effect: was, effects: videoClipAppliedEffects(clipOf(target)) };
4723
+ },
4724
+ subscribe: (notify) => controller.subscribe(() => notify()),
4725
+ extraTools: (ctx) => [
4726
+ {
4727
+ name: "add_video_effect_clip",
4728
+ title: "Add an effect clip",
4729
+ description: "Adds a clip drawn by a provider generator, title or overlay effect (search_effects with use generator, title or overlay), filling the frame like a colour clip; transparent effects show the tracks beneath. It can be moved, trimmed, faded, graded and given clip effects like any clip. One undo step. Returns the new clip id and its effect. The ffmpeg export then needs rendered frames.",
4730
+ inputSchema: {
4731
+ type: "object",
4732
+ properties: {
4733
+ track: { type: "string", minLength: 1, description: 'A video track id from get_video_project (e.g. "v1"); earlier tracks draw on top.' },
4734
+ effect: ctx.effectIdSchema(["generator", "title", "overlay"]),
4735
+ start: MS2("Where it starts on the timeline (default: the end of the track)"),
4736
+ duration: { type: "number", exclusiveMinimum: 0, description: "Length in ms (default: the effect's own length, else 5000)." },
4737
+ values: ctx.valuesSchema("Control values over the effect's defaults", true),
4738
+ name: { type: "string", minLength: 1, description: "A label for the clip (default: the effect's name)." },
4739
+ mode: { type: "string", enum: [...VIDEO_PLACEMENT_MODES], description: "What to do with clips already there: refuse (default), overwrite or insert." }
4740
+ },
4741
+ required: ["track", "effect"],
4742
+ additionalProperties: false
4743
+ },
4744
+ annotations: { title: "Add an effect clip", ...ctx.annotations.write },
4745
+ handler: async (args) => {
4746
+ ctx.requireProvider();
4747
+ const r = await controller.addEffectClip(args.track, args.effect, { start: args.start, duration: args.duration, values: args.values, name: args.name, mode: args.mode });
4748
+ if (!r.ok) throw new Error(r.error);
4749
+ return { clip: r.clip, track: args.track, effects: videoClipAppliedEffects(clipOf(r.clip)) };
4750
+ }
4751
+ },
4752
+ {
4753
+ name: "set_video_effect_transition",
4754
+ title: "Set an effect transition",
4755
+ description: `Sets a provider transition effect (search_effects with use transition) into a clip from the clip that ends where it starts on the same track, centred on the cut; like a crossfade it needs half its length in media past the cut on both sides. Replaces any transition there. One undo step. Returns the clip's effects. Remove it with remove_effect (effect "transition") or set_video_transition null.`,
4756
+ inputSchema: {
4757
+ type: "object",
4758
+ properties: {
4759
+ clip: { type: "string", minLength: 1, description: "The incoming clip (the one after the cut), from get_video_project." },
4760
+ effect: ctx.effectIdSchema(["transition"]),
4761
+ duration: { type: "number", exclusiveMinimum: 0, description: "Length in ms, centred on the cut (default: the effect's own length, else 1000)." },
4762
+ values: ctx.valuesSchema("Control values over the effect's defaults (plain values; transitions cannot be keyframed)")
4763
+ },
4764
+ required: ["clip", "effect"],
4765
+ additionalProperties: false
4766
+ },
4767
+ annotations: { title: "Set an effect transition", ...ctx.annotations.writeIdempotent },
4768
+ handler: async (args) => {
4769
+ ctx.requireProvider();
4770
+ const r = await controller.setEffectTransition(args.clip, args.effect, { duration: args.duration, values: args.values });
4771
+ if (!r.ok) throw new Error(r.error);
4772
+ return { clip: args.clip, transition: clipOf(args.clip).transition, effects: videoClipAppliedEffects(clipOf(args.clip)) };
4773
+ }
4774
+ },
4775
+ {
4776
+ name: "set_video_effect_keyframes",
4777
+ title: "Keyframe an effect control",
4778
+ description: "Animates one number control of a clip effect or an effect clip with keys at clip-relative times (ms), or with keys null stops animating it, keeping its value at the clip's start. Values are checked against the control's range from describe_effect. One undo step. Returns the effect.",
4779
+ inputSchema: {
4780
+ type: "object",
4781
+ properties: {
4782
+ clip: { type: "string", minLength: 1, description: "A clip id from list_applied_effects." },
4783
+ effect: { type: "string", minLength: 1, description: `The effect's id on the clip ("fx1", or "generator" for an effect clip's own effect).` },
4784
+ control: { type: "string", minLength: 1, description: "A number control name from describe_effect." },
4785
+ keys: { anyOf: [KEYS2, { type: "null" }], description: "The keys, or null to stop animating the control." }
4786
+ },
4787
+ required: ["clip", "effect", "control", "keys"],
4788
+ additionalProperties: false
4789
+ },
4790
+ annotations: { title: "Keyframe an effect control", ...ctx.annotations.writeIdempotent },
4791
+ handler: async (args) => {
4792
+ ctx.requireProvider();
4793
+ const keys = args.keys;
4794
+ let r;
4795
+ if (args.effect === "generator") {
4796
+ const clip = clipOf(args.clip);
4797
+ if (clip.type !== "effect") throw new Error(`Clip "${args.clip}" is not an effect clip; give the id of one of its effects (list_applied_effects).`);
4798
+ const was = clip.values?.[args.control];
4799
+ const plain = keys === null ? isVideoKeyframes(was) ? was.keys[0]?.v : was : { keys: [...keys].sort((a, b) => a.t - b.t) };
4800
+ if (plain === void 0) throw new Error(`"${args.control}" has no value to keep; give keys instead.`);
4801
+ r = await controller.updateEffectValues(args.clip, "generator", { [args.control]: plain });
4802
+ } else r = await controller.setClipEffectKeyframes(args.clip, args.effect, args.control, keys);
4803
+ if (!r.ok) throw new Error(r.error);
4804
+ return { clip: args.clip, effect: videoClipAppliedEffects(clipOf(args.clip)).find((e) => e.id === args.effect) };
4805
+ }
4806
+ }
4807
+ ]
4808
+ });
4809
+ }
4358
4810
  export {
4359
4811
  VIDEO_BLEND_MODES,
4360
4812
  VIDEO_BLEND_MODE_LABELS,
@@ -4367,6 +4819,7 @@ export {
4367
4819
  VIDEO_DEFAULT_TITLE_DURATION,
4368
4820
  VIDEO_EASE_NAMES,
4369
4821
  VIDEO_EDIT_OPS,
4822
+ VIDEO_EFFECT_PLACES,
4370
4823
  VIDEO_FADE_CURVES,
4371
4824
  VIDEO_FADE_CURVE_LABELS,
4372
4825
  VIDEO_FITS,
@@ -4390,6 +4843,7 @@ export {
4390
4843
  VIDEO_WIPE_DIRECTIONS,
4391
4844
  VideoAudioPeaksCache,
4392
4845
  VideoEditorController,
4846
+ VideoEffectsEngine,
4393
4847
  VideoFrameCache,
4394
4848
  VideoLutCache,
4395
4849
  VideoPreviewEngine,
@@ -4401,9 +4855,12 @@ export {
4401
4855
  applyVideoLut,
4402
4856
  applyVideoLutToPixels,
4403
4857
  buildVideoAudioPeaks,
4858
+ checkVideoEffectPlace,
4859
+ checkVideoEffectValues,
4404
4860
  checkVideoProject,
4405
4861
  checkVideoTimeRemap,
4406
4862
  createVideoEditorToolProvider,
4863
+ createVideoEffectsToolProvider,
4407
4864
  createVideoElementDecoder,
4408
4865
  createVideoProject,
4409
4866
  decodeVideoAudioPeaks,
@@ -4425,16 +4882,21 @@ export {
4425
4882
  isVideoDropFrameRate,
4426
4883
  isVideoKeyframes,
4427
4884
  mergeVideoRanges,
4885
+ mixEffectPixels,
4428
4886
  nextFreeVideoSlot,
4429
4887
  normalizeVideoGrade,
4430
4888
  normalizeVideoGradeLut,
4431
4889
  normalizeVideoWord,
4890
+ overEffectPixels,
4432
4891
  parseVideoAnimatable,
4433
4892
  parseVideoAsset,
4434
4893
  parseVideoAssetProxies,
4435
4894
  parseVideoClip,
4436
4895
  parseVideoClipAnimation,
4896
+ parseVideoClipEffects,
4437
4897
  parseVideoCubeLut,
4898
+ parseVideoEffectRef,
4899
+ parseVideoEffectValues,
4438
4900
  parseVideoGrade,
4439
4901
  parseVideoGradeLut,
4440
4902
  parseVideoKeyframes,
@@ -4455,6 +4917,7 @@ export {
4455
4917
  probeVideoMedia,
4456
4918
  readVideoScopesPanel,
4457
4919
  removeVideoKey,
4920
+ renderVideoFrameExact,
4458
4921
  resolveVideoPreviewScale,
4459
4922
  retimeVideoAnimatable,
4460
4923
  setVideoKey,
@@ -4482,7 +4945,9 @@ export {
4482
4945
  videoClipAnimated,
4483
4946
  videoClipAnimationProperties,
4484
4947
  videoClipAnimationSpans,
4948
+ videoClipAppliedEffects,
4485
4949
  videoClipBefore,
4950
+ videoClipEffectUses,
4486
4951
  videoClipEnd,
4487
4952
  videoClipHandles,
4488
4953
  videoClipKeyframes,
@@ -4503,9 +4968,13 @@ export {
4503
4968
  videoDropTracks,
4504
4969
  videoEaseBezier,
4505
4970
  videoEaseProgress,
4971
+ videoEffectFrameTime,
4972
+ videoEffectLayerKey,
4973
+ videoEffectValuesAt,
4506
4974
  videoExposureNotes,
4507
4975
  videoExposureStats,
4508
4976
  videoFadeCurveGain,
4977
+ videoFfmpegUnsupportedEffects,
4509
4978
  videoFrameAt,
4510
4979
  videoFrameCacheKey,
4511
4980
  videoFrameDuration,
@@ -4535,6 +5004,8 @@ export {
4535
5004
  videoKeyframeToggleEdit,
4536
5005
  videoKeyframeValueEdit,
4537
5006
  videoKeysOf,
5007
+ videoLayersUseEffects,
5008
+ videoLayersWithoutTransitionEffects,
4538
5009
  videoLuma,
4539
5010
  videoLumaWaveform,
4540
5011
  videoLutEdits,
@@ -4546,6 +5017,7 @@ export {
4546
5017
  videoPeakDb,
4547
5018
  videoPreviewSrc,
4548
5019
  videoProjectDuration,
5020
+ videoProjectEffects,
4549
5021
  videoProjectHasProxies,
4550
5022
  videoProjectSvg,
4551
5023
  videoProxyFfmpeg,