@pixerate/editor 0.83.0 → 0.84.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.
@@ -13,8 +13,11 @@ import "../chunk-AGR4P4UZ.js";
13
13
 
14
14
  // src/video-editor/types.ts
15
15
  var VIDEO_FITS = ["contain", "cover", "fill", "none"];
16
- var VIDEO_TRANSITION_TYPES = ["crossfade", "dip", "wipe"];
16
+ var VIDEO_TRANSITION_TYPES = ["crossfade", "dip", "wipe", "push", "slide", "iris"];
17
17
  var VIDEO_WIPE_DIRECTIONS = ["left", "right", "up", "down"];
18
+ var videoTransitionAudioCurve = (t) => t.audioCurve ?? (t.type === "dip" ? "linear" : "equal-power");
19
+ var VIDEO_BLEND_MODES = ["normal", "multiply", "screen", "overlay", "darken", "lighten", "color-dodge", "color-burn", "hard-light", "soft-light", "difference", "exclusion", "add"];
20
+ var VIDEO_CLIP_ANIMATION_EFFECTS = ["fade", "rise", "drop", "slide-left", "slide-right", "zoom", "pop"];
18
21
  var VIDEO_FADE_CURVES = ["linear", "equal-power", "s-curve", "exponential", "logarithmic"];
19
22
  var VIDEO_PLACEMENT_MODES = ["refuse", "overwrite", "insert"];
20
23
  var VIDEO_CLIP_TYPES = ["media", "text", "color"];
@@ -207,6 +210,136 @@ function videoKeyframeFfmpegExpr(prop, time, fallback, step) {
207
210
  return `if(lt(${time},${fmt(points[0][0])}),${fmt(points[0][1])},${expr})`;
208
211
  }
209
212
 
213
+ // src/video-editor/grade.ts
214
+ var VIDEO_GRADE_CONTROLS = [
215
+ { key: "exposure", label: "Exposure", min: -3, max: 3, step: 0.1, description: "Exposure in stops: +1 doubles every channel, -1 halves it (default 0)." },
216
+ { key: "contrast", label: "Contrast", min: -1, max: 1, step: 0.05, description: "Contrast around mid grey, -1 (flat grey) to 1 (twice as steep); default 0." },
217
+ { key: "saturation", label: "Saturation", min: -1, max: 1, step: 0.05, description: "Saturation, -1 (black and white) to 1 (twice as saturated); default 0." },
218
+ { key: "hue", label: "Hue", min: -180, max: 180, step: 1, description: "Hue rotation in degrees (default 0)." },
219
+ { key: "temperature", label: "Temperature", min: -1, max: 1, step: 0.05, description: "White balance, -1 (cool, bluer) to 1 (warm, more orange); default 0." },
220
+ { key: "tint", label: "Tint", min: -1, max: 1, step: 0.05, description: "White balance, -1 (greener) to 1 (more magenta); default 0." }
221
+ ];
222
+ var VIDEO_GRADE_PRESETS = {
223
+ warm: { temperature: 0.4, saturation: 0.1 },
224
+ cool: { temperature: -0.4, tint: -0.05 },
225
+ punchy: { contrast: 0.25, saturation: 0.3 },
226
+ faded: { exposure: 0.15, contrast: -0.3, saturation: -0.3 },
227
+ "black-and-white": { saturation: -1, contrast: 0.15 },
228
+ "bleach-bypass": { saturation: -0.6, contrast: 0.4 },
229
+ "day-for-night": { exposure: -1.5, saturation: -0.4, temperature: -0.6 }
230
+ };
231
+ var VIDEO_GRADE_PRESET_NAMES = Object.keys(VIDEO_GRADE_PRESETS);
232
+ var clampTo = (c, v) => Math.min(c.max, Math.max(c.min, v));
233
+ function normalizeVideoGrade(grade) {
234
+ if (!grade) return void 0;
235
+ const out = {};
236
+ for (const c of VIDEO_GRADE_CONTROLS) {
237
+ const v = grade[c.key];
238
+ if (typeof v === "number" && Number.isFinite(v) && v !== 0) out[c.key] = Math.round(clampTo(c, v) * 1e3) / 1e3;
239
+ }
240
+ return Object.keys(out).length ? out : void 0;
241
+ }
242
+ function videoGradePreset(name, amount = 1) {
243
+ const preset = VIDEO_GRADE_PRESETS[name];
244
+ if (!preset) throw new Error(`There is no grade preset "${name}"; the presets are ${VIDEO_GRADE_PRESET_NAMES.join(", ")}.`);
245
+ const k = Math.min(1, Math.max(0, amount));
246
+ return normalizeVideoGrade(Object.fromEntries(Object.entries(preset).map(([key, v]) => [key, v * k])));
247
+ }
248
+ function videoGradeSteps(grade) {
249
+ const g = normalizeVideoGrade(grade);
250
+ if (!g) return [];
251
+ const steps = [];
252
+ if (g.exposure) steps.push({ kind: "brightness", amount: 2 ** g.exposure });
253
+ if (g.contrast) steps.push({ kind: "contrast", amount: 1 + g.contrast });
254
+ if (g.saturation) steps.push({ kind: "saturate", amount: 1 + g.saturation });
255
+ if (g.hue) steps.push({ kind: "hue-rotate", degrees: g.hue });
256
+ if (g.temperature || g.tint) {
257
+ const t = g.temperature ?? 0;
258
+ steps.push({ kind: "gains", r: 1 + 0.25 * t, g: 1 - 0.25 * (g.tint ?? 0), b: 1 - 0.25 * t });
259
+ }
260
+ return steps;
261
+ }
262
+ function videoGradeStepMatrix(step) {
263
+ switch (step.kind) {
264
+ case "brightness":
265
+ return [
266
+ [step.amount, 0, 0, 0],
267
+ [0, step.amount, 0, 0],
268
+ [0, 0, step.amount, 0]
269
+ ];
270
+ case "contrast": {
271
+ const c = step.amount;
272
+ const o = 0.5 - 0.5 * c;
273
+ return [
274
+ [c, 0, 0, o],
275
+ [0, c, 0, o],
276
+ [0, 0, c, o]
277
+ ];
278
+ }
279
+ case "gains":
280
+ return [
281
+ [step.r, 0, 0, 0],
282
+ [0, step.g, 0, 0],
283
+ [0, 0, step.b, 0]
284
+ ];
285
+ case "saturate": {
286
+ const s = step.amount;
287
+ return [
288
+ [0.213 + 0.787 * s, 0.715 - 0.715 * s, 0.072 - 0.072 * s, 0],
289
+ [0.213 - 0.213 * s, 0.715 + 0.285 * s, 0.072 - 0.072 * s, 0],
290
+ [0.213 - 0.213 * s, 0.715 - 0.715 * s, 0.072 + 0.928 * s, 0]
291
+ ];
292
+ }
293
+ case "hue-rotate": {
294
+ const a = step.degrees * Math.PI / 180;
295
+ const cos = Math.cos(a);
296
+ const sin = Math.sin(a);
297
+ return [
298
+ [0.213 + cos * 0.787 - sin * 0.213, 0.715 - cos * 0.715 - sin * 0.715, 0.072 - cos * 0.072 + sin * 0.928, 0],
299
+ [0.213 - cos * 0.213 + sin * 0.143, 0.715 + cos * 0.285 + sin * 0.14, 0.072 - cos * 0.072 - sin * 0.283, 0],
300
+ [0.213 - cos * 0.213 - sin * 0.787, 0.715 - cos * 0.715 + sin * 0.715, 0.072 + cos * 0.928 + sin * 0.072, 0]
301
+ ];
302
+ }
303
+ }
304
+ }
305
+ function applyVideoGrade(grade, rgb) {
306
+ let v = rgb.map((x) => x / 255);
307
+ for (const step of videoGradeSteps(grade)) {
308
+ const m = videoGradeStepMatrix(step);
309
+ v = m.map((row) => Math.min(1, Math.max(0, row[0] * v[0] + row[1] * v[1] + row[2] * v[2] + row[3])));
310
+ }
311
+ return v.map((x) => Math.round(x * 255));
312
+ }
313
+ var fmt2 = (v) => String(Math.round(v * 1e5) / 1e5);
314
+ function videoGradeStepSvgValues(step) {
315
+ return videoGradeStepMatrix(step).map((row) => [row[0], row[1], row[2], 0, row[3]].map(fmt2).join(" ")).concat("0 0 0 1 0").join(" ");
316
+ }
317
+ function videoGradeSvgFilter(id, grade) {
318
+ const steps = videoGradeSteps(grade);
319
+ return `<filter id="${id}" color-interpolation-filters="sRGB" x="0" y="0" width="100%" height="100%">${steps.map((s) => `<feColorMatrix type="matrix" values="${videoGradeStepSvgValues(s)}"/>`).join("")}</filter>`;
320
+ }
321
+ var videoGradeGainsFilterId = (step) => `pixerate-wb-${[step.r, step.g, step.b].map((v) => Math.round(v * 1e3)).join("-")}`;
322
+ function videoGradeCssFilter(grade) {
323
+ return videoGradeSteps(grade).map((s) => s.kind === "brightness" ? `brightness(${fmt2(s.amount)})` : s.kind === "contrast" ? `contrast(${fmt2(s.amount)})` : s.kind === "saturate" ? `saturate(${fmt2(s.amount)})` : s.kind === "hue-rotate" ? `hue-rotate(${fmt2(s.degrees)}deg)` : `url(#${videoGradeGainsFilterId(s)})`).join(" ");
324
+ }
325
+ function videoGradeSvgDefs(grade) {
326
+ return videoGradeSteps(grade).filter((s) => s.kind === "gains").map((s) => {
327
+ const id = videoGradeGainsFilterId(s);
328
+ return { id, markup: `<filter id="${id}" color-interpolation-filters="sRGB"><feColorMatrix type="matrix" values="${videoGradeStepSvgValues(s)}"/></filter>` };
329
+ });
330
+ }
331
+ function videoGradeFfmpegFilters(grade) {
332
+ return videoGradeSteps(grade).map((step) => {
333
+ const m = videoGradeStepMatrix(step);
334
+ if (step.kind === "brightness" || step.kind === "contrast") {
335
+ const lut = (i) => `clip(val*${fmt2(m[i][i])}+${fmt2(m[i][3] * 255)},0,255)`;
336
+ return `lutrgb=r='${lut(0)}':g='${lut(1)}':b='${lut(2)}'`;
337
+ }
338
+ const k = ["r", "g", "b"];
339
+ return `colorchannelmixer=${m.flatMap((row, i) => row.slice(0, 3).map((v, j) => `${k[i]}${k[j]}=${fmt2(v)}`)).join(":")}`;
340
+ });
341
+ }
342
+
210
343
  // src/video-editor/parse.ts
211
344
  var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
212
345
  var fail = (path, what) => {
@@ -275,6 +408,22 @@ function parseVideoTransform(v, path) {
275
408
  rotation: parseVideoAnimatable(v.rotation, `${path}.rotation`, { optional: true })
276
409
  });
277
410
  }
411
+ function parseVideoClipAnimation(v, path) {
412
+ if (v === void 0) return void 0;
413
+ if (!isObject(v)) return fail(path, "must be an object { effect, duration }");
414
+ return compact({
415
+ effect: oneOf(v.effect, VIDEO_CLIP_ANIMATION_EFFECTS, `${path}.effect`, false),
416
+ duration: num(v.duration, `${path}.duration`, { optional: true, positive: true })
417
+ });
418
+ }
419
+ function parseVideoGrade(v, path) {
420
+ if (v === void 0) return void 0;
421
+ if (!isObject(v)) return fail(path, "must be an object such as { exposure, contrast, saturation }");
422
+ const known = new Set(VIDEO_GRADE_CONTROLS.map((c) => c.key));
423
+ const unknown = Object.keys(v).find((k) => !known.has(k));
424
+ if (unknown) fail(`${path}.${unknown}`, `is not a grade setting; use ${[...known].join(", ")}`);
425
+ return compact(Object.fromEntries(VIDEO_GRADE_CONTROLS.map((c) => [c.key, num(v[c.key], `${path}.${c.key}`, { optional: true, min: c.min, max: c.max })])));
426
+ }
278
427
  function parseVideoTransition(v, path) {
279
428
  if (v === void 0) return void 0;
280
429
  if (!isObject(v)) return fail(path, "must be an object { type, duration }");
@@ -282,7 +431,8 @@ function parseVideoTransition(v, path) {
282
431
  type: oneOf(v.type, VIDEO_TRANSITION_TYPES, `${path}.type`, false),
283
432
  duration: num(v.duration, `${path}.duration`, { positive: true }),
284
433
  color: str(v.color, `${path}.color`, true),
285
- direction: oneOf(v.direction, VIDEO_WIPE_DIRECTIONS, `${path}.direction`)
434
+ direction: oneOf(v.direction, VIDEO_WIPE_DIRECTIONS, `${path}.direction`),
435
+ audioCurve: oneOf(v.audioCurve, VIDEO_FADE_CURVES, `${path}.audioCurve`)
286
436
  });
287
437
  }
288
438
  function parseVideoAsset(v, path = "asset") {
@@ -313,6 +463,10 @@ function parseVideoClip(v, path = "clip") {
313
463
  opacity: parseVideoAnimatable(v.opacity, `${path}.opacity`, { optional: true, min: 0, max: 1 }),
314
464
  transform: parseVideoTransform(v.transform, `${path}.transform`),
315
465
  fit: oneOf(v.fit, VIDEO_FITS, `${path}.fit`),
466
+ blend: oneOf(v.blend, VIDEO_BLEND_MODES, `${path}.blend`),
467
+ grade: parseVideoGrade(v.grade, `${path}.grade`),
468
+ animateIn: parseVideoClipAnimation(v.animateIn, `${path}.animateIn`),
469
+ animateOut: parseVideoClipAnimation(v.animateOut, `${path}.animateOut`),
316
470
  fadeIn: num(v.fadeIn, `${path}.fadeIn`, { optional: true, min: 0 }),
317
471
  fadeOut: num(v.fadeOut, `${path}.fadeOut`, { optional: true, min: 0 }),
318
472
  transition: parseVideoTransition(v.transition, `${path}.transition`)
@@ -563,6 +717,66 @@ function videoClipsAt(project, time) {
563
717
  return out;
564
718
  }
565
719
 
720
+ // src/video-editor/clip-animation.ts
721
+ var VIDEO_CLIP_ANIMATION_DURATION = 500;
722
+ var EFFECTS = {
723
+ fade: { easeIn: "linear", easeOut: "linear" },
724
+ rise: { y: 0.06, easeIn: "ease-out", easeOut: "ease-in" },
725
+ drop: { y: -0.06, easeIn: "ease-out", easeOut: "ease-in" },
726
+ "slide-left": { x: 0.15, easeIn: "ease-out", easeOut: "ease-in" },
727
+ "slide-right": { x: -0.15, easeIn: "ease-out", easeOut: "ease-in" },
728
+ zoom: { scale: 0.6, easeIn: "ease-out", easeOut: "ease-in" },
729
+ // Overshoots past full size, then settles (a "back" curve).
730
+ pop: { scale: 0.3, easeIn: [0.34, 1.56, 0.64, 1], easeOut: [0.36, 0, 0.66, -0.56] }
731
+ };
732
+ var PROPS = ["opacity", "x", "y", "scale"];
733
+ function videoClipAnimationProperties(animation) {
734
+ const spec = EFFECTS[animation.effect];
735
+ return PROPS.filter((p) => p === "opacity" || spec[p] !== void 0);
736
+ }
737
+ function videoClipAnimationSpans(clip) {
738
+ const a = clip.animateIn ? clip.animateIn.duration ?? VIDEO_CLIP_ANIMATION_DURATION : 0;
739
+ const b = clip.animateOut ? clip.animateOut.duration ?? VIDEO_CLIP_ANIMATION_DURATION : 0;
740
+ const k = a + b > clip.duration ? clip.duration / (a + b) : 1;
741
+ return { in: a * k, out: b * k };
742
+ }
743
+ function videoClipAnimated(clip, project) {
744
+ if (!clip.animateIn && !clip.animateOut) return clip;
745
+ const spans = videoClipAnimationSpans(clip);
746
+ const out = { ...clip, transform: { ...clip.transform } };
747
+ for (const prop of PROPS) {
748
+ const current = prop === "opacity" ? clip.opacity : clip.transform?.[prop];
749
+ if (isVideoKeyframes(current)) continue;
750
+ const base = current ?? (prop === "opacity" || prop === "scale" ? 1 : 0);
751
+ const away = (spec) => {
752
+ if (prop === "opacity") return 0;
753
+ const v = spec[prop];
754
+ if (v === void 0) return void 0;
755
+ return prop === "scale" ? base * v : base + v * (prop === "x" ? project.width : project.height);
756
+ };
757
+ const keys = [];
758
+ if (clip.animateIn) {
759
+ const spec = EFFECTS[clip.animateIn.effect];
760
+ const from = away(spec);
761
+ if (from !== void 0) keys.push({ t: 0, v: from, ease: spec.easeIn }, { t: spans.in, v: base });
762
+ }
763
+ if (clip.animateOut) {
764
+ const spec = EFFECTS[clip.animateOut.effect];
765
+ const to = away(spec);
766
+ if (to !== void 0) {
767
+ const start = clip.duration - spans.out;
768
+ if (!keys.length || keys[keys.length - 1].t < start) keys.push({ t: start, v: base, ease: spec.easeOut });
769
+ else keys[keys.length - 1] = { ...keys[keys.length - 1], ease: spec.easeOut };
770
+ keys.push({ t: clip.duration, v: to });
771
+ }
772
+ }
773
+ if (!keys.length) continue;
774
+ if (prop === "opacity") out.opacity = { keys };
775
+ else out.transform[prop] = { keys };
776
+ }
777
+ return out;
778
+ }
779
+
566
780
  // src/video-editor/check.ts
567
781
  function videoClipKeyframes(clip) {
568
782
  const out = [
@@ -581,6 +795,8 @@ var VIDEO_ISSUE_CODES = [
581
795
  "transition-too-long",
582
796
  "transition-handles",
583
797
  "fades-too-long",
798
+ "animations-too-long",
799
+ "animation-keyed",
584
800
  "keyframes-outside-clip",
585
801
  "unknown-duration",
586
802
  "unused-asset",
@@ -617,6 +833,16 @@ function checkVideoProject(project) {
617
833
  issues.push({ code: "overlap", severity: "error", ...at, message: `Clips "${clip.id}" and "${next.id}" overlap on track "${track.id}" from ${formatVideoSeconds(next.start)}; move one of them.` });
618
834
  if ((clip.fadeIn ?? 0) + (clip.fadeOut ?? 0) > clip.duration + VIDEO_TIME_EPSILON)
619
835
  issues.push({ code: "fades-too-long", severity: "warning", ...at, message: `Clip "${clip.id}"'s fades (${clip.fadeIn ?? 0} + ${clip.fadeOut ?? 0} ms) are longer than the clip (${clip.duration} ms); shorten them.` });
836
+ const anim = (clip.animateIn ? clip.animateIn.duration ?? VIDEO_CLIP_ANIMATION_DURATION : 0) + (clip.animateOut ? clip.animateOut.duration ?? VIDEO_CLIP_ANIMATION_DURATION : 0);
837
+ if (anim > clip.duration + VIDEO_TIME_EPSILON)
838
+ issues.push({ code: "animations-too-long", severity: "warning", ...at, message: `Clip "${clip.id}"'s entrance and exit (${anim} ms together) are longer than the clip (${clip.duration} ms), so both are shortened; give them shorter durations.` });
839
+ for (const which of ["animateIn", "animateOut"]) {
840
+ const a = clip[which];
841
+ if (!a) continue;
842
+ const keyed = videoClipAnimationProperties(a).filter((p) => videoKeysOf(p === "opacity" ? clip.opacity : clip.transform?.[p]).length);
843
+ if (keyed.length)
844
+ issues.push({ code: "animation-keyed", severity: "note", ...at, message: `Clip "${clip.id}"'s ${a.effect} ${which === "animateIn" ? "entrance" : "exit"} leaves ${keyed.join(", ")} alone, because the clip keys ${keyed.length > 1 ? "them" : "it"} itself; remove those keyframes or the animation.` });
845
+ }
620
846
  for (const { property, keys } of videoClipKeyframes(clip)) {
621
847
  const outside = keys.filter((k) => k.t < -VIDEO_TIME_EPSILON || k.t > clip.duration + VIDEO_TIME_EPSILON);
622
848
  if (outside.length)
@@ -730,7 +956,7 @@ function retimeClipKeys(clip, fn) {
730
956
  if (isVideoKeyframes(v)) a.set(fn(v));
731
957
  }
732
958
  }
733
- var OPTIONAL_CLIP_FIELDS = ["name", "opacity", "transform", "fit", "fadeIn", "fadeOut", "fadeInCurve", "fadeOutCurve", "transition", "in", "speed", "volume", "muted", "color", "fontSize", "fontFamily", "fontWeight", "background"];
959
+ var OPTIONAL_CLIP_FIELDS = ["name", "opacity", "transform", "fit", "blend", "grade", "animateIn", "animateOut", "fadeIn", "fadeOut", "fadeInCurve", "fadeOutCurve", "transition", "in", "speed", "volume", "muted", "color", "fontSize", "fontFamily", "fontWeight", "background"];
734
960
  var TIME_FIELDS = { slip_clip: ["delta"], roll_edit: ["delta"], slide_clip: ["delta"], lift_range: ["from", "to"], extract_range: ["from", "to"], split_at: ["at"] };
735
961
  var Editor = class {
736
962
  constructor(p) {
@@ -827,6 +1053,7 @@ var Editor = class {
827
1053
  delete right.transition;
828
1054
  delete right.fadeIn;
829
1055
  delete right.fadeInCurve;
1056
+ delete right.animateIn;
830
1057
  const leftValues = clipAnimatables(clip);
831
1058
  clipAnimatables(right).forEach((r, i) => {
832
1059
  const [l, rr] = splitVideoAnimatable(leftValues[i].get(), at - clip.start);
@@ -836,6 +1063,7 @@ var Editor = class {
836
1063
  this.moveIn(right, at - clip.start);
837
1064
  clip.duration = at - clip.start;
838
1065
  delete clip.fadeOut;
1066
+ delete clip.animateOut;
839
1067
  if (clip.type === "media") delete clip.fadeOutCurve;
840
1068
  track.clips.push(right);
841
1069
  track.clips.sort((a, b) => a.start - b.start);
@@ -1387,6 +1615,7 @@ function videoTitleFrame(project, clip, clipTime = 0) {
1387
1615
  return { time: clip.start, width: project.width, height: project.height, background: "rgba(0,0,0,0)", layers: [layer], audio: [] };
1388
1616
  }
1389
1617
  var clamp01 = (v) => Math.min(1, Math.max(0, v));
1618
+ var videoTransitionEase = (progress) => (1 - Math.cos(Math.PI * clamp01(progress))) / 2;
1390
1619
  function videoFadeCurveGain(curve, x) {
1391
1620
  const t = clamp01(x);
1392
1621
  switch (curve) {
@@ -1440,8 +1669,8 @@ function videoFrameAt(project, time) {
1440
1669
  if (outgoing?.type === "dip" && outgoing.progress >= 0.5) continue;
1441
1670
  const inside = Math.min(Math.max(time, clip.start), videoClipEnd(clip));
1442
1671
  const fade = fadeGain(clip, inside);
1443
- const level = (t, into) => t.type === "dip" ? Math.abs(1 - 2 * t.progress) : into ? t.progress : 1 - t.progress;
1444
- const mix = (incoming ? level(incoming, true) : 1) * (outgoing ? level(outgoing, false) : 1);
1672
+ const level = (t, transition, into) => videoFadeCurveGain(videoTransitionAudioCurve(transition), t.type === "dip" ? Math.abs(1 - 2 * t.progress) : into ? t.progress : 1 - t.progress);
1673
+ const mix = (incoming ? level(incoming, incoming.transition, true) : 1) * (outgoing ? level(outgoing, outgoing.next.transition, false) : 1);
1445
1674
  const asset = clip.type === "media" ? assets.get(clip.asset) : void 0;
1446
1675
  const sourceTime = sourceTimeAt(clip, time);
1447
1676
  const clipTime = time - clip.start;
@@ -1452,20 +1681,39 @@ function videoFrameAt(project, time) {
1452
1681
  }
1453
1682
  if (track.kind !== "video" || track.hidden) continue;
1454
1683
  if (clip.type === "media" && (!asset || asset.kind === "audio")) continue;
1455
- let opacity = videoValueAt(clip.opacity, clipTime, 1) * fade;
1684
+ const shown = videoClipAnimated(clip, project);
1685
+ let opacity = videoValueAt(shown.opacity, clipTime, 1) * fade;
1456
1686
  let wipe;
1457
1687
  let dip = null;
1688
+ let iris;
1689
+ let shift = 0;
1690
+ let shiftDirection = "right";
1458
1691
  if (incoming) {
1459
1692
  if (incoming.type === "crossfade") opacity *= incoming.progress;
1460
1693
  else if (incoming.type === "wipe") wipe = { direction: incoming.transition.direction ?? "right", progress: incoming.progress };
1461
- else dip = { color: incoming.transition.color ?? "#000000", opacity: (1 - incoming.progress) * 2 };
1694
+ else if (incoming.type === "iris") iris = { progress: incoming.progress };
1695
+ else if (incoming.type === "push" || incoming.type === "slide") {
1696
+ shiftDirection = incoming.transition.direction ?? "right";
1697
+ shift = videoTransitionEase(incoming.progress) - 1;
1698
+ } else dip = { color: incoming.transition.color ?? "#000000", opacity: (1 - incoming.progress) * 2 };
1462
1699
  }
1463
1700
  if (outgoing?.type === "dip") dip = { color: outgoing.next.transition.color ?? "#000000", opacity: outgoing.progress * 2 };
1701
+ if (outgoing?.type === "push") {
1702
+ shiftDirection = outgoing.next.transition.direction ?? "right";
1703
+ shift = videoTransitionEase(outgoing.progress);
1704
+ }
1464
1705
  const size = clip.type === "media" ? { width: asset.width ?? project.width, height: asset.height ?? project.height } : { width: project.width, height: project.height };
1465
1706
  const fit = clip.fit ?? (clip.type === "media" ? "contain" : "fill");
1466
- const rect = placeVideoRect(project, size, clip.type === "text" ? "fill" : fit, clip.transform, clipTime);
1467
- const layer = { clip: clip.id, track: track.id, type: clip.type, opacity: clamp01(opacity), rect, rotation: videoValueAt(clip.transform?.rotation, clipTime, 0) };
1707
+ const placed = placeVideoRect(project, size, clip.type === "text" ? "fill" : fit, shown.transform, clipTime);
1708
+ const across = shiftDirection === "left" || shiftDirection === "right";
1709
+ const sign = shiftDirection === "left" || shiftDirection === "up" ? -1 : 1;
1710
+ const rect = shift ? { ...placed, ...across ? { x: placed.x + sign * shift * project.width } : { y: placed.y + sign * shift * project.height } } : placed;
1711
+ const layer = { clip: clip.id, track: track.id, type: clip.type, opacity: clamp01(opacity), rect, rotation: videoValueAt(shown.transform?.rotation, clipTime, 0) };
1468
1712
  if (wipe) layer.wipe = wipe;
1713
+ if (iris) layer.iris = iris;
1714
+ if (clip.blend && clip.blend !== "normal") layer.blend = clip.blend;
1715
+ const grade = normalizeVideoGrade(clip.grade);
1716
+ if (grade) layer.grade = grade;
1469
1717
  if (clip.type === "media") Object.assign(layer, { asset: asset.id, assetKind: asset.kind, src: asset.src, sourceTime });
1470
1718
  if (clip.type === "text") layer.text = videoTextStyle(clip);
1471
1719
  if (clip.type === "color") layer.color = clip.color;
@@ -1696,6 +1944,7 @@ var VideoAudioPeaksCache = class {
1696
1944
  // src/video-editor/render.ts
1697
1945
  var esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1698
1946
  var n = (v) => String(Math.round(v * 100) / 100);
1947
+ var videoIrisRadius = (frame, progress) => progress * Math.hypot(frame.width / 2, frame.height / 2);
1699
1948
  function videoWipeRect(rect, wipe) {
1700
1949
  const { direction, progress } = wipe;
1701
1950
  const w = rect.width * progress;
@@ -1713,6 +1962,11 @@ function videoFrameToSvg(frame, options = {}, assets = []) {
1713
1962
  const { rect } = layer;
1714
1963
  const attrs = [];
1715
1964
  if (layer.opacity < 1) attrs.push(`opacity="${n(layer.opacity)}"`);
1965
+ if (layer.blend) attrs.push(`style="mix-blend-mode:${layer.blend === "add" ? "plus-lighter" : layer.blend}"`);
1966
+ if (layer.grade) {
1967
+ defs.push(videoGradeSvgFilter(`g${i}`, layer.grade));
1968
+ attrs.push(`filter="url(#g${i})"`);
1969
+ }
1716
1970
  if (layer.rotation) attrs.push(`transform="rotate(${n(layer.rotation)} ${n(rect.x + rect.width / 2)} ${n(rect.y + rect.height / 2)})"`);
1717
1971
  if (layer.wipe) {
1718
1972
  const r = videoWipeRect(rect, layer.wipe);
@@ -1744,7 +1998,12 @@ function videoFrameToSvg(frame, options = {}, assets = []) {
1744
1998
  inner = `<rect ${box} fill="#334155" stroke="#94a3b8" stroke-width="2"/><text x="${n(rect.x + rect.width / 2)}" y="${n(rect.y + rect.height / 2)}" text-anchor="middle" dominant-baseline="central" font-family="sans-serif" font-size="${n(size)}" fill="#e2e8f0">${esc(label)}</text>`;
1745
1999
  }
1746
2000
  }
1747
- body.push(attrs.length ? `<g ${attrs.join(" ")}>${inner}</g>` : inner);
2001
+ let item = attrs.length ? `<g ${attrs.join(" ")}>${inner}</g>` : inner;
2002
+ if (layer.iris) {
2003
+ defs.push(`<clipPath id="i${i}"><circle cx="${n(frame.width / 2)}" cy="${n(frame.height / 2)}" r="${n(videoIrisRadius(frame, layer.iris.progress))}"/></clipPath>`);
2004
+ item = `<g clip-path="url(#i${i})">${item}</g>`;
2005
+ }
2006
+ body.push(item);
1748
2007
  });
1749
2008
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${n(frame.width)} ${n(frame.height)}" width="${n(frame.width)}" height="${n(frame.height)}">${defs.length ? `<defs>${defs.join("")}</defs>` : ""}<rect width="100%" height="100%" fill="${esc(frame.background)}"/>${body.join("")}</svg>`;
1750
2009
  }
@@ -1768,7 +2027,7 @@ function videoContactSheetSvg(project, times, options = {}) {
1768
2027
  });
1769
2028
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${n(W)} ${n(H)}" width="${n(W)}" height="${n(H)}"><rect width="100%" height="100%" fill="#f8fafc"/>${cells.join("")}</svg>`;
1770
2029
  }
1771
- function drawVideoFrame(ctx, frame, sourceFor, scale = 1) {
2030
+ function drawVideoFrame(ctx, frame, sourceFor, scale = 1, options = {}) {
1772
2031
  ctx.save();
1773
2032
  ctx.setTransform(scale, 0, 0, scale, 0, 0);
1774
2033
  ctx.globalAlpha = 1;
@@ -1778,6 +2037,18 @@ function drawVideoFrame(ctx, frame, sourceFor, scale = 1) {
1778
2037
  const { rect } = layer;
1779
2038
  ctx.save();
1780
2039
  ctx.globalAlpha = layer.opacity;
2040
+ if (layer.iris) {
2041
+ ctx.beginPath();
2042
+ ctx.arc(frame.width / 2, frame.height / 2, videoIrisRadius(frame, layer.iris.progress), 0, 2 * Math.PI);
2043
+ ctx.clip();
2044
+ }
2045
+ if (layer.blend && layer.blend !== "normal") ctx.globalCompositeOperation = layer.blend === "add" ? "lighter" : layer.blend;
2046
+ if (layer.grade) {
2047
+ const defs = videoGradeSvgDefs(layer.grade);
2048
+ if (options.defineSvgFilter) for (const d of defs) options.defineSvgFilter(d.id, d.markup);
2049
+ const css = videoGradeCssFilter(layer.grade);
2050
+ ctx.filter = options.defineSvgFilter ? css : css.replace(/\s*url\([^)]*\)/g, "").trim() || "none";
2051
+ }
1781
2052
  if (layer.rotation) {
1782
2053
  ctx.translate(rect.x + rect.width / 2, rect.y + rect.height / 2);
1783
2054
  ctx.rotate(layer.rotation * Math.PI / 180);
@@ -1858,7 +2129,7 @@ function toVideoEdl(project, options = {}) {
1858
2129
  const outReel = before.type === "media" ? reelName(assets.get(before.asset)) : "BL";
1859
2130
  const at = sourceAt(before, recIn);
1860
2131
  lines.push(row(n2, outReel, "C", at, at, recIn, recIn));
1861
- lines.push(row(n2, reel, `${t.type === "wipe" ? "W001" : "D"} ${frames}`, sourceAt(clip, recIn), sourceAt(clip, recOut), recIn, recOut));
2132
+ lines.push(row(n2, reel, `${t.type === "crossfade" || t.type === "dip" ? "D" : "W001"} ${frames}`, sourceAt(clip, recIn), sourceAt(clip, recOut), recIn, recOut));
1862
2133
  } else lines.push(row(n2, reel, "C", sourceAt(clip, recIn), sourceAt(clip, recOut), recIn, recOut));
1863
2134
  if (clip.type === "media" && videoClipSpeed(clip) !== 1) lines.push(`M2 ${reel.padEnd(8)} ${(videoClipSpeed(clip) * fps).toFixed(1).padStart(6)} ${tc(sourceAt(clip, recIn))}`);
1864
2135
  if (clip.type === "media") lines.push(`* FROM CLIP NAME: ${asset?.name ?? asset?.src ?? clip.asset}`);
@@ -1894,6 +2165,20 @@ function parseVideoLoudnessMeasurement(output) {
1894
2165
  return m;
1895
2166
  }
1896
2167
  var sec = (ms) => String(Math.round(ms) / 1e3);
2168
+ var FFMPEG_BLEND = {
2169
+ multiply: "multiply",
2170
+ screen: "screen",
2171
+ overlay: "overlay",
2172
+ darken: "darken",
2173
+ lighten: "lighten",
2174
+ "color-dodge": "dodge",
2175
+ "color-burn": "burn",
2176
+ "hard-light": "hardlight",
2177
+ "soft-light": "softlight",
2178
+ difference: "difference",
2179
+ exclusion: "exclusion",
2180
+ add: "addition"
2181
+ };
1897
2182
  var AFADE_CURVES = { linear: "tri", "equal-power": "qsin", "s-curve": "hsin", exponential: "cub", logarithmic: "ipar" };
1898
2183
  var afadeCurve = (curve) => curve && curve !== "linear" ? `:curve=${AFADE_CURVES[curve]}` : "";
1899
2184
  var even = (v) => Math.max(2, 2 * Math.round(v / 2));
@@ -1939,6 +2224,7 @@ function buildFfmpeg(project, options, measure) {
1939
2224
  let cur = "base";
1940
2225
  let usesDrawtext = false;
1941
2226
  const animated = /* @__PURE__ */ new Set();
2227
+ const blended = /* @__PURE__ */ new Set();
1942
2228
  let k = 0;
1943
2229
  const step = 2e3 / fps;
1944
2230
  const expr = (prop, clipTime, fallback, what) => {
@@ -1954,7 +2240,8 @@ function buildFfmpeg(project, options, measure) {
1954
2240
  return inputs.length - 1;
1955
2241
  };
1956
2242
  for (const track of [...project.tracks].reverse()) {
1957
- for (const clip of sortedVideoClips(track)) {
2243
+ for (const raw of sortedVideoClips(track)) {
2244
+ const clip = videoClipAnimated(raw, project);
1958
2245
  const { lead, tail } = videoClipOverhang(track, clip);
1959
2246
  const t0 = clip.start - lead;
1960
2247
  const span = clip.duration + lead + tail;
@@ -1973,11 +2260,13 @@ function buildFfmpeg(project, options, measure) {
1973
2260
  const volume = isVideoKeyframes(clip.volume) ? `volume='${trackGain}*(${expr(clip.volume, `(t*1000${lead ? `-${Math.round(lead)}` : ""})`, 1, "volume")})':eval=frame` : `volume=${(clip.volume ?? 1) * trackGain}`;
1974
2261
  const chain2 = ["asetpts=PTS-STARTPTS", ...atempo(videoClipSpeed(clip)), volume];
1975
2262
  const before2 = clip.transition ? videoClipBefore(track, clip) : null;
1976
- if (lead) chain2.push(`afade=t=in:st=0:d=${sec(2 * lead)}`);
1977
- if (tail) chain2.push(`afade=t=out:st=${sec(span - 2 * tail)}:d=${sec(2 * tail)}`);
1978
- if (before2 && clip.transition.type === "dip") chain2.push(`afade=t=in:st=0:d=${sec(clip.transition.duration / 2)}`);
1979
- const after = sortedVideoClips(track).find((c) => c !== clip && Math.abs(c.start - end) < 0.5 && c.transition?.type === "dip");
1980
- if (after) chain2.push(`afade=t=out:st=${sec(span - after.transition.duration / 2)}:d=${sec(after.transition.duration / 2)}`);
2263
+ const next2 = sortedVideoClips(track).find((c) => c !== clip && Math.abs(c.start - end) < 0.5 && c.transition);
2264
+ const inCurve = clip.transition ? afadeCurve(videoTransitionAudioCurve(clip.transition)) : "";
2265
+ const outCurve = next2 ? afadeCurve(videoTransitionAudioCurve(next2.transition)) : "";
2266
+ if (lead) chain2.push(`afade=t=in:st=0:d=${sec(2 * lead)}${inCurve}`);
2267
+ if (tail) chain2.push(`afade=t=out:st=${sec(span - 2 * tail)}:d=${sec(2 * tail)}${outCurve}`);
2268
+ if (before2 && clip.transition.type === "dip") chain2.push(`afade=t=in:st=0:d=${sec(clip.transition.duration / 2)}${inCurve}`);
2269
+ if (next2?.transition.type === "dip") chain2.push(`afade=t=out:st=${sec(span - next2.transition.duration / 2)}:d=${sec(next2.transition.duration / 2)}${outCurve}`);
1981
2270
  if (clip.fadeIn) chain2.push(`afade=t=in:st=${sec(lead)}:d=${sec(clip.fadeIn)}${afadeCurve(clip.fadeInCurve)}`);
1982
2271
  if (clip.fadeOut) chain2.push(`afade=t=out:st=${sec(lead + clip.duration - clip.fadeOut)}:d=${sec(clip.fadeOut)}${afadeCurve(clip.fadeOutCurve)}`);
1983
2272
  chain2.push(`adelay=${Math.round(t0)}:all=1`);
@@ -2016,6 +2305,8 @@ function buildFfmpeg(project, options, measure) {
2016
2305
  `enable='between(t,${sec(clip.start)},${sec(end)})'`
2017
2306
  ];
2018
2307
  if (tf?.rotation) notes.push(`Title "${clip.id}" is drawn without its rotation (drawtext cannot rotate).`);
2308
+ if (videoGradeFfmpegFilters(clip.grade).length) notes.push(`Title "${clip.id}" is drawn without its grade (drawtext draws straight onto the picture); pass titleImage to grade titles.`);
2309
+ if (clip.blend && clip.blend !== "normal") notes.push(`Title "${clip.id}" is drawn without its blend mode (drawtext draws straight onto the picture); pass titleImage to blend titles.`);
2019
2310
  graph.push(`[${cur}]drawtext=${opts.join(":")}[${next}]`);
2020
2311
  cur = next;
2021
2312
  continue;
@@ -2031,10 +2322,8 @@ function buildFfmpeg(project, options, measure) {
2031
2322
  source = `[${index}:v]`;
2032
2323
  chain.push(`setpts=${videoClipSpeed(clip) !== 1 ? `(PTS-STARTPTS)/${videoClipSpeed(clip)}` : "PTS-STARTPTS"}`, `fps=${fps}`, `scale=${even(rect.width)}:${even(rect.height)}`);
2033
2324
  }
2034
- if (isVideoKeyframes(placed?.scale)) {
2035
- const s = expr(placed.scale, onChain("t"), 1, "scale");
2036
- chain.push(`scale=w='max(2,trunc(${even(rect.width)}*(${s})/2)*2)':h='max(2,trunc(${even(rect.height)}*(${s})/2)*2)':eval=frame`);
2037
- }
2325
+ const grade = videoGradeFfmpegFilters(clip.grade);
2326
+ if (grade.length) chain.push("format=rgba", ...grade);
2038
2327
  chain.push("format=yuva420p");
2039
2328
  if (isVideoKeyframes(clip.opacity)) chain.push(`geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='alpha(X,Y)*clip(${expr(clip.opacity, onChain("T"), 1, "opacity")},0,1)'`);
2040
2329
  else if (clip.opacity !== void 0 && clip.opacity < 1) chain.push(`colorchannelmixer=aa=${clip.opacity}`);
@@ -2048,18 +2337,50 @@ function buildFfmpeg(project, options, measure) {
2048
2337
  const test = dir === "right" ? `lt(X,W*${p})` : dir === "left" ? `gt(X,W*(1-${p}))` : dir === "down" ? `lt(Y,H*${p})` : `gt(Y,H*(1-${p}))`;
2049
2338
  chain.push(`geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='if(${test},alpha(X,Y),0)'`);
2050
2339
  }
2340
+ if (lead && clip.transition.type === "iris") {
2341
+ const r = videoIrisRadius({ width: W, height: H }, 1);
2342
+ chain.push(`geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='if(lte(hypot(X+${Math.round(rect.x)}-${W / 2},Y+${Math.round(rect.y)}-${H / 2}),${Math.round(r)}*min(1,T/${sec(2 * lead)})),alpha(X,Y),0)'`);
2343
+ }
2051
2344
  const rotation = placed?.rotation;
2052
- if (isVideoKeyframes(rotation)) chain.push(`rotate=a='(${expr(rotation, onChain("t"), 0, "rotation")})*PI/180':c=none:ow='hypot(iw,ih)':oh=ow`);
2053
- else if (rotation) chain.push(`rotate=${rotation}*PI/180:c=none:ow=rotw(${rotation}*PI/180):oh=roth(${rotation}*PI/180)`);
2345
+ if (isVideoKeyframes(rotation)) chain.push(`rotate=a='(${expr(rotation, onChain("t"), 0, "rotation")})*PI/180':c=black@0:ow='hypot(iw,ih)':oh=ow`);
2346
+ else if (rotation) chain.push(`rotate=${rotation}*PI/180:c=black@0:ow=rotw(${rotation}*PI/180):oh=roth(${rotation}*PI/180)`);
2347
+ if (isVideoKeyframes(placed?.scale)) {
2348
+ const s = expr(placed.scale, onChain("t"), 1, "scale");
2349
+ chain.push(`scale=w='max(2,trunc(iw*(${s})/2)*2)':h='max(2,trunc(ih*(${s})/2)*2)':eval=frame`);
2350
+ }
2054
2351
  chain.push(`setpts=PTS+${sec(t0)}/TB`);
2055
2352
  const cx = rect.x + rect.width / 2;
2056
2353
  const cy = rect.y + rect.height / 2;
2354
+ const shifts = { x: [], y: [] };
2355
+ const shiftBy = (direction, from, d, entering) => {
2356
+ const dir = direction ?? "right";
2357
+ const axis = dir === "left" || dir === "right" ? "x" : "y";
2358
+ const size2 = axis === "x" ? W : H;
2359
+ const sign = dir === "left" || dir === "up" ? -1 : 1;
2360
+ const ease = `(1-cos(PI*clip((t-${sec(from)})/${sec(d)},0,1)))/2`;
2361
+ shifts[axis].push(`${sign * size2}*(${ease}${entering ? "-1" : ""})`);
2362
+ };
2363
+ const into = lead ? clip.transition.type : void 0;
2364
+ if (into === "push" || into === "slide") shiftBy(clip.transition.direction, clip.start - lead, 2 * lead, true);
2365
+ const after = tail ? sortedVideoClips(track).find((c) => c !== clip && Math.abs(c.start - end) < 0.5 && c.transition) : void 0;
2366
+ if (after?.transition.type === "push") shiftBy(after.transition.direction, end - tail, 2 * tail, false);
2057
2367
  const at = (axis, c) => {
2058
2368
  const v = placed?.[axis];
2059
- return isVideoKeyframes(v) ? `${axis}='${Math.round(axis === "x" ? project.width / 2 : project.height / 2)}+(${expr(v, onTimeline, 0, "position")})-${axis === "x" ? "w" : "h"}/2'` : `${axis}='${Math.round(c)}-${axis === "x" ? "w" : "h"}/2'`;
2369
+ const extra = shifts[axis].map((s) => `+${s}`).join("");
2370
+ return isVideoKeyframes(v) ? `${axis}='${Math.round(axis === "x" ? project.width / 2 : project.height / 2)}+(${expr(v, onTimeline, 0, "position")})-${axis === "x" ? "w" : "h"}/2${extra}'` : `${axis}='${Math.round(c)}-${axis === "x" ? "w" : "h"}/2${extra}'`;
2060
2371
  };
2061
2372
  graph.push(`${source.startsWith("[") ? source : `${source},`}${chain.join(",")}[l${next}]`);
2062
- graph.push(`[${cur}][l${next}]overlay=${at("x", cx)}:${at("y", cy)}:eof_action=pass:${enable}[${next}]`);
2373
+ if (clip.blend && clip.blend !== "normal") {
2374
+ graph.push(`color=c=black@0:s=${W}x${H}:r=${fps}:d=${sec(D)},format=yuva420p[t${next}]`);
2375
+ graph.push(`[t${next}][l${next}]overlay=${at("x", cx)}:${at("y", cy)}:eof_action=pass:${enable},format=gbrap,split[f${next}][g${next}]`);
2376
+ graph.push(`[${cur}]format=gbrap,split[c${next}][d${next}]`);
2377
+ const layerFirst = clip.blend === "color-dodge" || clip.blend === "color-burn";
2378
+ graph.push(`${layerFirst ? `[f${next}][c${next}]` : `[c${next}][f${next}]`}blend=all_mode=${FFMPEG_BLEND[clip.blend]}[b${next}]`);
2379
+ graph.push(`[g${next}]alphaextract[m${next}]`);
2380
+ graph.push(`[b${next}][m${next}]alphamerge[x${next}]`);
2381
+ graph.push(`[d${next}][x${next}]overlay=format=gbrp,format=yuva420p[${next}]`);
2382
+ blended.add(clip.blend);
2383
+ } else graph.push(`[${cur}][l${next}]overlay=${at("x", cx)}:${at("y", cy)}:eof_action=pass:${enable}[${next}]`);
2063
2384
  cur = next;
2064
2385
  const before = clip.transition ? videoClipBefore(track, clip) : null;
2065
2386
  if (before && clip.transition.type === "dip") {
@@ -2082,6 +2403,7 @@ function buildFfmpeg(project, options, measure) {
2082
2403
  notes.push('Video assets are assumed to have sound; set "hasAudio": false on any that do not, or ffmpeg will stop at a missing audio stream.');
2083
2404
  if (animated.size)
2084
2405
  notes.push(`Keyframed ${[...animated].sort().join(", ")} ${animated.size > 1 ? "are" : "is"} rendered as straight lines between keys, with eased segments sampled every two frames${animated.has("scale") ? "; animated scale needs ffmpeg 5 or later (scale with eval=frame)" : ""}.`);
2406
+ if (blended.has("soft-light")) notes.push("ffmpeg's soft-light blend uses a slightly different formula from the browser's, so the render can differ a little from the preview.");
2085
2407
  if (usesDrawtext) notes.push("Titles use drawtext, which needs an ffmpeg built with libfreetype (Homebrew's is not: check `ffmpeg -filters | grep drawtext`); pass titleImage to render them as images instead.");
2086
2408
  const filterComplex = graph.join(";");
2087
2409
  if (measure) {
@@ -2129,6 +2451,8 @@ var VideoPreviewEngine = class {
2129
2451
  unsubscribe;
2130
2452
  scheduled = false;
2131
2453
  destroyed = false;
2454
+ filterHost = null;
2455
+ filters = /* @__PURE__ */ new Set();
2132
2456
  createMedia(kind) {
2133
2457
  if (this.options.createMedia) return this.options.createMedia(kind);
2134
2458
  const el = document.createElement(kind);
@@ -2230,12 +2554,31 @@ var VideoPreviewEngine = class {
2230
2554
  const h = Math.max(1, Math.round(frame.height * resolution));
2231
2555
  if (this.canvas.width !== w) this.canvas.width = w;
2232
2556
  if (this.canvas.height !== h) this.canvas.height = h;
2233
- drawVideoFrame(this.ctx, frame, this.sourceFor, resolution);
2557
+ drawVideoFrame(this.ctx, frame, this.sourceFor, resolution, { defineSvgFilter: this.defineSvgFilter });
2234
2558
  this.options.onFrame?.(frame);
2235
2559
  }
2560
+ defineSvgFilter = (id, markup) => {
2561
+ if (this.options.defineSvgFilter === null) return;
2562
+ if (this.options.defineSvgFilter) return this.options.defineSvgFilter(id, markup);
2563
+ if (this.filters.has(id) || typeof document === "undefined") return;
2564
+ if (!this.filterHost) {
2565
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
2566
+ svg.setAttribute("width", "0");
2567
+ svg.setAttribute("height", "0");
2568
+ svg.setAttribute("aria-hidden", "true");
2569
+ svg.setAttribute("style", "position:absolute;width:0;height:0;overflow:hidden");
2570
+ document.body.appendChild(svg);
2571
+ this.filterHost = svg;
2572
+ }
2573
+ this.filterHost.insertAdjacentHTML("beforeend", markup);
2574
+ this.filters.add(id);
2575
+ };
2236
2576
  /** Stops following the editor and releases every media element. */
2237
2577
  destroy() {
2238
2578
  this.destroyed = true;
2579
+ this.filterHost?.remove();
2580
+ this.filterHost = null;
2581
+ this.filters.clear();
2239
2582
  this.unsubscribe();
2240
2583
  for (const entry of [...this.media.values()]) this.release(entry);
2241
2584
  this.images.clear();
@@ -2529,19 +2872,37 @@ var TRANSFORM = object(
2529
2872
  );
2530
2873
  var TRANSITION = object(
2531
2874
  {
2532
- 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) or wipe (an edge travels across revealing this clip)." },
2533
- duration: { type: "number", exclusiveMinimum: 0, description: "Length in ms, centred on the cut. A crossfade or wipe needs half of it in media past the cut on both sides (handles); a dip needs none." },
2875
+ 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)." },
2876
+ 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." },
2534
2877
  color: COLOR("For dip: the colour passed through (default black)"),
2535
- direction: { type: "string", enum: [...VIDEO_WIPE_DIRECTIONS], description: "For wipe: the way the edge travels (default right)." }
2878
+ 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)." },
2879
+ audioCurve: { type: "string", enum: [...VIDEO_FADE_CURVES], description: "Shape of the sound's fades through it: default equal-power for every type but dip (steady loudness while the two sounds overlap), linear for dip; or s-curve, exponential, logarithmic." }
2536
2880
  },
2537
2881
  ["type", "duration"],
2538
2882
  "A transition from the clip that ends where this one starts on the same track."
2539
2883
  );
2884
+ var GRADE = object(
2885
+ Object.fromEntries(VIDEO_GRADE_CONTROLS.map((c) => [c.key, { type: "number", minimum: c.min, maximum: c.max, description: c.description }])),
2886
+ [],
2887
+ "Colour correction of the picture, applied in order: exposure, contrast, saturation, hue, white balance (temperature, tint). Leave a setting out for no change; the object replaces the clip's whole grade."
2888
+ );
2889
+ var ANIMATION = (when) => object(
2890
+ {
2891
+ effect: { type: "string", enum: [...VIDEO_CLIP_ANIMATION_EFFECTS], description: "fade; rise (up into place) or drop (down into place); slide-left / slide-right (moving that way); zoom (grows into place); pop (grows past its size and settles). Each also fades; an exit plays the movement backwards." },
2892
+ duration: { type: "number", exclusiveMinimum: 0, description: "Length in ms (default 500)." }
2893
+ },
2894
+ ["effect"],
2895
+ when
2896
+ );
2540
2897
  var CLIP_COMMON = {
2541
2898
  name: { type: "string", description: "A label for the clip." },
2542
2899
  opacity: { type: "number", minimum: 0, maximum: 1, description: "Opacity 0\u20131 (default 1). A number replaces any keyframes; set_video_keyframes animates it." },
2543
2900
  transform: TRANSFORM,
2544
2901
  fit: { type: "string", enum: [...VIDEO_FITS], description: "How the picture fits the frame before the transform, as CSS object-fit (default contain for media, fill for colours)." },
2902
+ grade: GRADE,
2903
+ animateIn: ANIMATION("How the picture enters, from the clip's start (on top of any fade in). Titles usually rise, pop or fade in."),
2904
+ animateOut: ANIMATION("How the picture leaves, ending at the clip's end."),
2905
+ blend: { type: "string", enum: [...VIDEO_BLEND_MODES], description: "How the picture mixes with what is beneath it, as CSS mix-blend-mode (default normal): multiply darkens (shadows, textures), screen and add lighten (light leaks, flares, glows), overlay and soft-light add contrast, difference and exclusion invert." },
2545
2906
  fadeIn: MS("Fade up from nothing (picture and sound) over this long"),
2546
2907
  fadeOut: MS("Fade down to nothing over this long")
2547
2908
  };
@@ -2702,7 +3063,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
2702
3063
  const getFrame = {
2703
3064
  name: "get_video_frame",
2704
3065
  title: "What plays at a moment",
2705
- description: "Returns what plays at a time without changing anything: the pictures back to front (clip, track, asset, source time in ms, opacity, placement rect, rotation, any wipe) and the sounds (clip, source time, gain after volumes, fades and transitions).",
3066
+ description: "Returns what plays at a time without changing anything: the pictures back to front (clip, track, asset, source time in ms, opacity, placement rect, rotation, any wipe or iris) and the sounds (clip, source time, gain after volumes, fades and transitions).",
2706
3067
  inputSchema: object({ time: MS("Time from the start (default: the playhead)") }),
2707
3068
  annotations: { title: "What plays at a moment", ...READ },
2708
3069
  handler: ({ time }) => {
@@ -2724,7 +3085,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
2724
3085
  const checkProject = {
2725
3086
  name: "check_video_project",
2726
3087
  title: "Check the video project",
2727
- description: "Lists the project's problems, errors first, each with a code, the clip / track / asset it is about and how to fix it: missing assets, overlaps, clips past the end of their source, transitions without a cut or handles, fades longer than the clip, keyframes outside their clip, assets with no duration, unused assets.",
3088
+ description: "Lists the project's problems, errors first, each with a code, the clip / track / asset it is about and how to fix it: missing assets, overlaps, clips past the end of their source, transitions without a cut or handles, fades or animations longer than the clip, animations a clip's own keyframes override, keyframes outside their clip, assets with no duration, unused assets.",
2728
3089
  inputSchema: object({}),
2729
3090
  annotations: { title: "Check the video project", ...READ },
2730
3091
  handler: () => {
@@ -2956,12 +3317,12 @@ function createVideoEditorToolProvider(controller, options = {}) {
2956
3317
  const updateClip = {
2957
3318
  name: "update_video_clip",
2958
3319
  title: "Change a clip",
2959
- description: `Changes a clip's properties (one undo step): opacity, transform, fit, fades, name; for media volume, mute, the sound's fade curves, speed (keeps the stretch of source, so the length changes) or asset; for titles text and style; for colours the colour. Fields in "remove" go back to their defaults. Use move / trim for timing. Returns the clip.`,
3320
+ description: `Changes a clip's properties (one undo step): opacity, transform, fit, blend mode, colour grade, entrance and exit animations, fades, name; for media volume, mute, the sound's fade curves, speed (keeps the stretch of source, so the length changes) or asset; for titles text and style; for colours the colour. Fields in "remove" go back to their defaults. Use move / trim for timing. Returns the clip.`,
2960
3321
  inputSchema: object(
2961
3322
  {
2962
3323
  clip: CLIP_ID,
2963
3324
  set: object({ ...CLIP_COMMON, ...MEDIA_FIELDS, asset: { ...ASSET_ID, description: "For media: play another asset (same in point)." }, ...TEXT_FIELDS, color: COLOR("Text colour, or a colour clip's fill") }, [], "Fields to replace (only those that fit the clip's type)."),
2964
- remove: { type: "array", items: { type: "string", enum: ["name", "opacity", "transform", "fit", "fadeIn", "fadeOut", "fadeInCurve", "fadeOutCurve", "in", "speed", "volume", "muted", "fontSize", "fontFamily", "fontWeight", "background"] }, description: "Fields to reset to their defaults." }
3325
+ remove: { type: "array", items: { type: "string", enum: ["name", "opacity", "transform", "fit", "blend", "grade", "animateIn", "animateOut", "fadeIn", "fadeOut", "fadeInCurve", "fadeOutCurve", "in", "speed", "volume", "muted", "fontSize", "fontFamily", "fontWeight", "background"] }, description: "Fields to reset to their defaults." }
2965
3326
  },
2966
3327
  ["clip"]
2967
3328
  ),
@@ -2969,17 +3330,60 @@ function createVideoEditorToolProvider(controller, options = {}) {
2969
3330
  handler: ({ clip, set, remove }) => {
2970
3331
  const current = clipOut(project(), clip);
2971
3332
  if (current && set) {
2972
- const allowed = /* @__PURE__ */ new Set(["name", "opacity", "transform", "fit", "fadeIn", "fadeOut", ...current.type === "media" ? ["in", "speed", "volume", "muted", "asset", "fadeInCurve", "fadeOutCurve"] : current.type === "text" ? Object.keys(TEXT_FIELDS) : ["color"]]);
3333
+ const allowed = /* @__PURE__ */ new Set(["name", "opacity", "transform", "fit", "blend", "grade", "animateIn", "animateOut", "fadeIn", "fadeOut", ...current.type === "media" ? ["in", "speed", "volume", "muted", "asset", "fadeInCurve", "fadeOutCurve"] : current.type === "text" ? Object.keys(TEXT_FIELDS) : ["color"]]);
2973
3334
  const wrong = Object.keys(set).filter((k) => !allowed.has(k));
2974
3335
  if (wrong.length) throw new Error(`${wrong.map((k) => `"${k}"`).join(", ")} cannot be set on a ${current.type} clip; it takes ${[...allowed].join(", ")}.`);
2975
3336
  }
2976
3337
  return apply({ op: "update_clip", clip, set, remove }, "Change clip", (r) => ({ clip: clipOut(r.project, clip) }));
2977
3338
  }
2978
3339
  };
3340
+ const gradeTargets = (ids) => {
3341
+ const p = project();
3342
+ for (const id of ids) {
3343
+ const at = findVideoClip(p, id);
3344
+ if (!at) throw new Error(`There is no clip "${id}"; get_video_project lists the clips.`);
3345
+ const { clip } = at;
3346
+ if (at.track.kind !== "video" || clip.type === "media" && p.assets.find((a) => a.id === clip.asset)?.kind === "audio")
3347
+ throw new Error(`Clip "${id}" is sound only; grades change pictures. Pass clips on video tracks.`);
3348
+ }
3349
+ };
3350
+ const applyGradePreset = {
3351
+ name: "apply_video_grade_preset",
3352
+ title: "Apply a colour look",
3353
+ description: "Gives clips a named colour look at a strength (one undo step), replacing their grades: warm, cool, punchy (more contrast and colour), faded (lifted and muted), black-and-white, bleach-bypass (desaturated, contrasty), day-for-night (dark and blue). Returns each clip's resulting grade; tweak it with update_video_clip set.grade.",
3354
+ inputSchema: object(
3355
+ {
3356
+ clips: { type: "array", items: CLIP_ID, minItems: 1, description: "Clips on video tracks to grade." },
3357
+ preset: { type: "string", enum: [...VIDEO_GRADE_PRESET_NAMES], description: "The look." },
3358
+ amount: { type: "number", minimum: 0, maximum: 1, description: "Strength, 0 (none) to 1 (full, the default); 0.5 is a subtler version." }
3359
+ },
3360
+ ["clips", "preset"]
3361
+ ),
3362
+ annotations: { title: "Apply a colour look", ...WRITE_IDEMPOTENT },
3363
+ handler: ({ clips, preset, amount }) => {
3364
+ gradeTargets(clips);
3365
+ const grade = videoGradePreset(preset, amount ?? 1);
3366
+ const edits = clips.map((clip) => grade ? { op: "update_clip", clip, set: { grade } } : { op: "update_clip", clip, remove: ["grade"] });
3367
+ return apply(edits, `Apply ${preset} look`, (r) => ({ clips: clips.map((id) => ({ id, grade: findVideoClip(r.project, id)?.clip.grade ?? null })) }));
3368
+ }
3369
+ };
3370
+ const copyGrade = {
3371
+ name: "copy_video_grade",
3372
+ title: "Copy a colour grade",
3373
+ description: "Gives clips the same colour grade as another clip (one undo step), to match shots in a scene; a source with no grade clears theirs. Returns the grade copied and the clips changed.",
3374
+ inputSchema: object({ from: { ...CLIP_ID, description: "The clip whose grade to copy." }, to: { type: "array", items: CLIP_ID, minItems: 1, description: "Clips on video tracks to give it to." } }, ["from", "to"]),
3375
+ annotations: { title: "Copy a colour grade", ...WRITE_IDEMPOTENT },
3376
+ handler: ({ from, to }) => {
3377
+ gradeTargets([from, ...to]);
3378
+ const grade = findVideoClip(project(), from).clip.grade;
3379
+ const edits = to.map((clip) => grade ? { op: "update_clip", clip, set: { grade: { ...grade } } } : { op: "update_clip", clip, remove: ["grade"] });
3380
+ return apply(edits, "Copy grade", () => ({ grade: grade ?? null, clips: to }));
3381
+ }
3382
+ };
2979
3383
  const setTransition = {
2980
3384
  name: "set_video_transition",
2981
3385
  title: "Set a transition",
2982
- description: "Sets (or with null removes) the transition into a clip from the clip that ends where it starts on the same track (one undo step). A crossfade or wipe plays half before and half after the cut, so both clips need that much media past the cut; when they do not, the error says the longest that fits. Returns the clip.",
3386
+ description: "Sets (or with null removes) the transition into a clip from the clip that ends where it starts on the same track (one undo step). Every type but dip plays half before and half after the cut, so both clips need that much media past the cut; when they do not, the error says the longest that fits. Returns the clip.",
2983
3387
  inputSchema: object({ clip: { ...CLIP_ID, description: "The incoming clip (the one after the cut)." }, transition: { anyOf: [TRANSITION, { type: "null" }], description: "The transition, or null to remove it." } }, ["clip", "transition"]),
2984
3388
  annotations: { title: "Set a transition", ...WRITE_IDEMPOTENT },
2985
3389
  handler: ({ clip, transition }) => apply({ op: "set_transition", clip, transition }, transition ? "Set transition" : "Remove transition", (r) => ({ clip: clipOut(r.project, clip) }))
@@ -3194,7 +3598,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
3194
3598
  }
3195
3599
  };
3196
3600
  const reads = [getProject, getFrame, findGaps, checkProject, renderFrame, exportFfmpeg, exportLoudnessMeasure, exportEdl, audioLevels, findSilences, seek];
3197
- const writes = [setSettings, addAsset, updateAsset, removeAsset, addTrack, updateTrack, removeTrack, moveTrack, addClip, moveClip, trimClip, splitClip, deleteClips, updateClip, setTransition, setKeyframes, removeKeyframes, slipClip, rollEdit, slideClip, liftRange, extractRange, splitAt, closeGaps, addMarker, removeMarker, editVideo, undo, redo];
3601
+ const writes = [setSettings, addAsset, updateAsset, removeAsset, addTrack, updateTrack, removeTrack, moveTrack, addClip, moveClip, trimClip, splitClip, deleteClips, updateClip, setTransition, applyGradePreset, copyGrade, setKeyframes, removeKeyframes, slipClip, rollEdit, slideClip, liftRange, extractRange, splitAt, closeGaps, addMarker, removeMarker, editVideo, undo, redo];
3198
3602
  return {
3199
3603
  id: options.id ?? "video",
3200
3604
  namespace: options.namespace,
@@ -3230,6 +3634,39 @@ function videoClipShowsWaveform(clip, asset, mode = "audio") {
3230
3634
  if (clip.type !== "media" || mode === "none" || !videoAssetHasSound(asset)) return false;
3231
3635
  return mode === "all" || asset.kind === "audio";
3232
3636
  }
3637
+ var VIDEO_CLIP_ANIMATION_LABELS = {
3638
+ fade: "Fade",
3639
+ rise: "Rise",
3640
+ drop: "Drop",
3641
+ "slide-left": "Slide left",
3642
+ "slide-right": "Slide right",
3643
+ zoom: "Zoom",
3644
+ pop: "Pop"
3645
+ };
3646
+ var VIDEO_TRANSITION_LABELS = {
3647
+ crossfade: "Crossfade",
3648
+ dip: "Dip to colour",
3649
+ wipe: "Wipe",
3650
+ push: "Push",
3651
+ slide: "Slide",
3652
+ iris: "Iris"
3653
+ };
3654
+ var videoTransitionHasDirection = (type) => type === "wipe" || type === "push" || type === "slide";
3655
+ var VIDEO_BLEND_MODE_LABELS = {
3656
+ normal: "Normal",
3657
+ multiply: "Multiply",
3658
+ screen: "Screen",
3659
+ overlay: "Overlay",
3660
+ darken: "Darken",
3661
+ lighten: "Lighten",
3662
+ "color-dodge": "Colour dodge",
3663
+ "color-burn": "Colour burn",
3664
+ "hard-light": "Hard light",
3665
+ "soft-light": "Soft light",
3666
+ difference: "Difference",
3667
+ exclusion: "Exclusion",
3668
+ add: "Add"
3669
+ };
3233
3670
  var VIDEO_FADE_CURVE_LABELS = {
3234
3671
  linear: "Linear",
3235
3672
  "equal-power": "Equal power",
@@ -3400,6 +3837,11 @@ function videoKeyframeToggleEdit(project, clipId, property, time) {
3400
3837
  return { op: "set_keyframes", clip: clipId, property, keys: isVideoKeyframes(prop) ? setVideoKey(prop, { t, v }, fallback).keys : [{ t, v }] };
3401
3838
  }
3402
3839
  export {
3840
+ VIDEO_BLEND_MODES,
3841
+ VIDEO_BLEND_MODE_LABELS,
3842
+ VIDEO_CLIP_ANIMATION_DURATION,
3843
+ VIDEO_CLIP_ANIMATION_EFFECTS,
3844
+ VIDEO_CLIP_ANIMATION_LABELS,
3403
3845
  VIDEO_CLIP_TYPES,
3404
3846
  VIDEO_DEFAULT_STILL_DURATION,
3405
3847
  VIDEO_DEFAULT_TITLE_DURATION,
@@ -3408,6 +3850,9 @@ export {
3408
3850
  VIDEO_FADE_CURVES,
3409
3851
  VIDEO_FADE_CURVE_LABELS,
3410
3852
  VIDEO_FITS,
3853
+ VIDEO_GRADE_CONTROLS,
3854
+ VIDEO_GRADE_PRESETS,
3855
+ VIDEO_GRADE_PRESET_NAMES,
3411
3856
  VIDEO_ISSUE_CODES,
3412
3857
  VIDEO_KEYFRAME_LIMITS,
3413
3858
  VIDEO_KEYFRAME_PROPERTIES,
@@ -3415,6 +3860,7 @@ export {
3415
3860
  VIDEO_PLACEMENT_MODES,
3416
3861
  VIDEO_TIME_EPSILON,
3417
3862
  VIDEO_TRANSFORM_PROPERTIES,
3863
+ VIDEO_TRANSITION_LABELS,
3418
3864
  VIDEO_TRANSITION_TYPES,
3419
3865
  VIDEO_WIPE_DIRECTIONS,
3420
3866
  VideoAudioPeaksCache,
@@ -3422,6 +3868,7 @@ export {
3422
3868
  VideoPreviewEngine,
3423
3869
  allVideoClipIds,
3424
3870
  applyVideoEdits,
3871
+ applyVideoGrade,
3425
3872
  buildVideoAudioPeaks,
3426
3873
  checkVideoProject,
3427
3874
  createVideoEditorToolProvider,
@@ -3439,9 +3886,12 @@ export {
3439
3886
  isVideoDropFrameRate,
3440
3887
  isVideoKeyframes,
3441
3888
  nextFreeVideoSlot,
3889
+ normalizeVideoGrade,
3442
3890
  parseVideoAnimatable,
3443
3891
  parseVideoAsset,
3444
3892
  parseVideoClip,
3893
+ parseVideoClipAnimation,
3894
+ parseVideoGrade,
3445
3895
  parseVideoKeyframes,
3446
3896
  parseVideoLoudnessMeasurement,
3447
3897
  parseVideoMarker,
@@ -3472,6 +3922,9 @@ export {
3472
3922
  videoAudioPeakSpans,
3473
3923
  videoAudioPeaksDuration,
3474
3924
  videoClipAfter,
3925
+ videoClipAnimated,
3926
+ videoClipAnimationProperties,
3927
+ videoClipAnimationSpans,
3475
3928
  videoClipBefore,
3476
3929
  videoClipEnd,
3477
3930
  videoClipHandles,
@@ -3496,7 +3949,17 @@ export {
3496
3949
  videoFrameDuration,
3497
3950
  videoFrameToSvg,
3498
3951
  videoFrames,
3952
+ videoGradeCssFilter,
3953
+ videoGradeFfmpegFilters,
3954
+ videoGradeGainsFilterId,
3955
+ videoGradePreset,
3956
+ videoGradeStepMatrix,
3957
+ videoGradeStepSvgValues,
3958
+ videoGradeSteps,
3959
+ videoGradeSvgDefs,
3960
+ videoGradeSvgFilter,
3499
3961
  videoHourFrames,
3962
+ videoIrisRadius,
3500
3963
  videoIssueKey,
3501
3964
  videoKeyframeBreakpoints,
3502
3965
  videoKeyframeFfmpegExpr,
@@ -3515,6 +3978,9 @@ export {
3515
3978
  videoTitleFrame,
3516
3979
  videoTrackSyncLocked,
3517
3980
  videoTransformAt,
3981
+ videoTransitionAudioCurve,
3982
+ videoTransitionEase,
3983
+ videoTransitionHasDirection,
3518
3984
  videoValueAt,
3519
3985
  videoWaveformPath,
3520
3986
  videoWipeRect