@pixerate/editor 0.82.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,12 @@ 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"];
21
+ var VIDEO_FADE_CURVES = ["linear", "equal-power", "s-curve", "exponential", "logarithmic"];
18
22
  var VIDEO_PLACEMENT_MODES = ["refuse", "overwrite", "insert"];
19
23
  var VIDEO_CLIP_TYPES = ["media", "text", "color"];
20
24
 
@@ -206,6 +210,136 @@ function videoKeyframeFfmpegExpr(prop, time, fallback, step) {
206
210
  return `if(lt(${time},${fmt(points[0][0])}),${fmt(points[0][1])},${expr})`;
207
211
  }
208
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
+
209
343
  // src/video-editor/parse.ts
210
344
  var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
211
345
  var fail = (path, what) => {
@@ -274,6 +408,22 @@ function parseVideoTransform(v, path) {
274
408
  rotation: parseVideoAnimatable(v.rotation, `${path}.rotation`, { optional: true })
275
409
  });
276
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
+ }
277
427
  function parseVideoTransition(v, path) {
278
428
  if (v === void 0) return void 0;
279
429
  if (!isObject(v)) return fail(path, "must be an object { type, duration }");
@@ -281,7 +431,8 @@ function parseVideoTransition(v, path) {
281
431
  type: oneOf(v.type, VIDEO_TRANSITION_TYPES, `${path}.type`, false),
282
432
  duration: num(v.duration, `${path}.duration`, { positive: true }),
283
433
  color: str(v.color, `${path}.color`, true),
284
- 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`)
285
436
  });
286
437
  }
287
438
  function parseVideoAsset(v, path = "asset") {
@@ -312,6 +463,10 @@ function parseVideoClip(v, path = "clip") {
312
463
  opacity: parseVideoAnimatable(v.opacity, `${path}.opacity`, { optional: true, min: 0, max: 1 }),
313
464
  transform: parseVideoTransform(v.transform, `${path}.transform`),
314
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`),
315
470
  fadeIn: num(v.fadeIn, `${path}.fadeIn`, { optional: true, min: 0 }),
316
471
  fadeOut: num(v.fadeOut, `${path}.fadeOut`, { optional: true, min: 0 }),
317
472
  transition: parseVideoTransition(v.transition, `${path}.transition`)
@@ -324,7 +479,9 @@ function parseVideoClip(v, path = "clip") {
324
479
  in: num(v.in, `${path}.in`, { optional: true, min: 0 }),
325
480
  speed: num(v.speed, `${path}.speed`, { optional: true, min: 0.05, max: 20 }),
326
481
  volume: parseVideoAnimatable(v.volume, `${path}.volume`, { optional: true, min: 0, max: 4 }),
327
- muted: bool(v.muted, `${path}.muted`)
482
+ muted: bool(v.muted, `${path}.muted`),
483
+ fadeInCurve: oneOf(v.fadeInCurve, VIDEO_FADE_CURVES, `${path}.fadeInCurve`),
484
+ fadeOutCurve: oneOf(v.fadeOutCurve, VIDEO_FADE_CURVES, `${path}.fadeOutCurve`)
328
485
  });
329
486
  if (type === "text")
330
487
  return compact({
@@ -426,15 +583,41 @@ function snapToVideoFrame(time, fps) {
426
583
  return Math.round(frame * 1e3 / fps * 1e3) / 1e3;
427
584
  }
428
585
  var videoFrames = (time, fps) => Math.round(time * fps / 1e3);
429
- function formatVideoTimecode(time, fps) {
586
+ function isVideoDropFrameRate(fps) {
587
+ const nominal = Math.round(fps);
588
+ return (nominal === 30 || nominal === 60) && Math.abs(fps - nominal * 1e3 / 1001) < 5e-3;
589
+ }
590
+ var exactVideoRate = (fps) => {
591
+ const nominal = Math.round(fps);
592
+ return nominal !== fps && Math.abs(fps - nominal * 1e3 / 1001) < 5e-3 ? nominal * 1e3 / 1001 : fps;
593
+ };
594
+ var videoTimecodeFrames = (time, fps) => Math.max(0, Math.round(time * exactVideoRate(fps) / 1e3));
595
+ function formatVideoTimecode(time, fps, dropFrame = isVideoDropFrameRate(fps)) {
596
+ return formatVideoTimecodeFrames(videoTimecodeFrames(time, fps), fps, dropFrame);
597
+ }
598
+ function formatVideoTimecodeFrames(frames, fps, dropFrame = isVideoDropFrameRate(fps)) {
430
599
  const base = Math.round(fps);
431
- const total = Math.max(0, videoFrames(time, fps));
600
+ let total = Math.max(0, Math.round(frames));
601
+ const drop = dropFrame && isVideoDropFrameRate(fps);
602
+ if (drop) {
603
+ const skip = base / 15;
604
+ const perMinute = base * 60 - skip;
605
+ const perTen = perMinute * 10 + skip;
606
+ const tens = Math.floor(total / perTen);
607
+ const rest = total % perTen;
608
+ total += skip * 9 * tens + (rest > skip ? skip * Math.floor((rest - skip) / perMinute) : 0);
609
+ }
432
610
  const ff = total % base;
433
611
  const secs = Math.floor(total / base);
434
612
  const pad = (n2) => String(n2).padStart(2, "0");
435
- return `${pad(Math.floor(secs / 3600))}:${pad(Math.floor(secs / 60) % 60)}:${pad(secs % 60)}:${pad(ff)}`;
613
+ return `${pad(Math.floor(secs / 3600))}:${pad(Math.floor(secs / 60) % 60)}:${pad(secs % 60)}${drop ? ";" : ":"}${pad(ff)}`;
436
614
  }
615
+ var videoHourFrames = (fps, dropFrame = isVideoDropFrameRate(fps)) => {
616
+ const base = Math.round(fps);
617
+ return dropFrame && isVideoDropFrameRate(fps) ? base * 3600 - base / 15 * 54 : base * 3600;
618
+ };
437
619
  var formatVideoSeconds = (time) => `${(time / 1e3).toFixed(2)} s`;
620
+ var videoAssetHasSound = (asset) => !!asset && (asset.kind === "audio" || asset.kind === "video" && asset.hasAudio !== false);
438
621
  function videoAssetMap(project) {
439
622
  return new Map(project.assets.map((a) => [a.id, a]));
440
623
  }
@@ -534,6 +717,66 @@ function videoClipsAt(project, time) {
534
717
  return out;
535
718
  }
536
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
+
537
780
  // src/video-editor/check.ts
538
781
  function videoClipKeyframes(clip) {
539
782
  const out = [
@@ -552,6 +795,8 @@ var VIDEO_ISSUE_CODES = [
552
795
  "transition-too-long",
553
796
  "transition-handles",
554
797
  "fades-too-long",
798
+ "animations-too-long",
799
+ "animation-keyed",
555
800
  "keyframes-outside-clip",
556
801
  "unknown-duration",
557
802
  "unused-asset",
@@ -588,6 +833,16 @@ function checkVideoProject(project) {
588
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.` });
589
834
  if ((clip.fadeIn ?? 0) + (clip.fadeOut ?? 0) > clip.duration + VIDEO_TIME_EPSILON)
590
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
+ }
591
846
  for (const { property, keys } of videoClipKeyframes(clip)) {
592
847
  const outside = keys.filter((k) => k.t < -VIDEO_TIME_EPSILON || k.t > clip.duration + VIDEO_TIME_EPSILON);
593
848
  if (outside.length)
@@ -701,7 +956,7 @@ function retimeClipKeys(clip, fn) {
701
956
  if (isVideoKeyframes(v)) a.set(fn(v));
702
957
  }
703
958
  }
704
- var OPTIONAL_CLIP_FIELDS = ["name", "opacity", "transform", "fit", "fadeIn", "fadeOut", "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"];
705
960
  var TIME_FIELDS = { slip_clip: ["delta"], roll_edit: ["delta"], slide_clip: ["delta"], lift_range: ["from", "to"], extract_range: ["from", "to"], split_at: ["at"] };
706
961
  var Editor = class {
707
962
  constructor(p) {
@@ -797,6 +1052,8 @@ var Editor = class {
797
1052
  const right = { ...structuredClone(clip), id: id ?? freshId(`${clip.id}-`, this.ids()), start: at, duration: end - at };
798
1053
  delete right.transition;
799
1054
  delete right.fadeIn;
1055
+ delete right.fadeInCurve;
1056
+ delete right.animateIn;
800
1057
  const leftValues = clipAnimatables(clip);
801
1058
  clipAnimatables(right).forEach((r, i) => {
802
1059
  const [l, rr] = splitVideoAnimatable(leftValues[i].get(), at - clip.start);
@@ -806,6 +1063,8 @@ var Editor = class {
806
1063
  this.moveIn(right, at - clip.start);
807
1064
  clip.duration = at - clip.start;
808
1065
  delete clip.fadeOut;
1066
+ delete clip.animateOut;
1067
+ if (clip.type === "media") delete clip.fadeOutCurve;
809
1068
  track.clips.push(right);
810
1069
  track.clips.sort((a, b) => a.start - b.start);
811
1070
  return right;
@@ -1356,12 +1615,33 @@ function videoTitleFrame(project, clip, clipTime = 0) {
1356
1615
  return { time: clip.start, width: project.width, height: project.height, background: "rgba(0,0,0,0)", layers: [layer], audio: [] };
1357
1616
  }
1358
1617
  var clamp01 = (v) => Math.min(1, Math.max(0, v));
1359
- function fadeGain(clip, time) {
1618
+ var videoTransitionEase = (progress) => (1 - Math.cos(Math.PI * clamp01(progress))) / 2;
1619
+ function videoFadeCurveGain(curve, x) {
1620
+ const t = clamp01(x);
1621
+ switch (curve) {
1622
+ case "equal-power":
1623
+ return Math.sin(t * Math.PI / 2);
1624
+ case "s-curve":
1625
+ return (1 - Math.cos(t * Math.PI)) / 2;
1626
+ case "exponential":
1627
+ return t * t * t;
1628
+ case "logarithmic":
1629
+ return 1 - (1 - t) * (1 - t);
1630
+ default:
1631
+ return t;
1632
+ }
1633
+ }
1634
+ function fadeGain(clip, time, sound = false) {
1360
1635
  let g = 1;
1361
- if (clip.fadeIn) g *= clamp01((time - clip.start) / clip.fadeIn);
1362
- if (clip.fadeOut) g *= clamp01((videoClipEnd(clip) - time) / clip.fadeOut);
1636
+ const shape = sound && clip.type === "media" ? clip : void 0;
1637
+ if (clip.fadeIn) g *= videoFadeCurveGain(shape?.fadeInCurve, (time - clip.start) / clip.fadeIn);
1638
+ if (clip.fadeOut) g *= videoFadeCurveGain(shape?.fadeOutCurve, (videoClipEnd(clip) - time) / clip.fadeOut);
1363
1639
  return g;
1364
1640
  }
1641
+ function videoClipSoundGain(clip, clipTime) {
1642
+ const volume = clip.type === "media" ? videoValueAt(clip.volume, clipTime, 1) : 1;
1643
+ return volume * fadeGain(clip, clip.start + Math.min(Math.max(clipTime, 0), clip.duration), true);
1644
+ }
1365
1645
  function activeOnTrack(track, time) {
1366
1646
  const out = [];
1367
1647
  for (const clip of sortedVideoClips(track)) {
@@ -1387,33 +1667,53 @@ function videoFrameAt(project, time) {
1387
1667
  for (const { clip, incoming, outgoing } of activeOnTrack(track, time)) {
1388
1668
  if (incoming?.type === "dip" && incoming.progress < 0.5) continue;
1389
1669
  if (outgoing?.type === "dip" && outgoing.progress >= 0.5) continue;
1390
- const fade = fadeGain(clip, Math.min(Math.max(time, clip.start), videoClipEnd(clip)));
1391
- const level = (t, into) => t.type === "dip" ? Math.abs(1 - 2 * t.progress) : into ? t.progress : 1 - t.progress;
1392
- const mix = (incoming ? level(incoming, true) : 1) * (outgoing ? level(outgoing, false) : 1);
1670
+ const inside = Math.min(Math.max(time, clip.start), videoClipEnd(clip));
1671
+ const fade = fadeGain(clip, inside);
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);
1393
1674
  const asset = clip.type === "media" ? assets.get(clip.asset) : void 0;
1394
1675
  const sourceTime = sourceTimeAt(clip, time);
1395
1676
  const clipTime = time - clip.start;
1396
- const audible = asset && (asset.kind === "audio" || asset.kind === "video" && asset.hasAudio !== false);
1677
+ const audible = videoAssetHasSound(asset);
1397
1678
  if (audible && clip.type === "media" && !clip.muted && !track.muted) {
1398
- const gain = videoValueAt(clip.volume, clipTime, 1) * (track.volume ?? 1) * fade * mix;
1679
+ const gain = videoValueAt(clip.volume, clipTime, 1) * (track.volume ?? 1) * fadeGain(clip, inside, true) * mix;
1399
1680
  audio.push({ clip: clip.id, track: track.id, asset: asset.id, src: asset.src, sourceTime, gain, speed: videoClipSpeed(clip) });
1400
1681
  }
1401
1682
  if (track.kind !== "video" || track.hidden) continue;
1402
1683
  if (clip.type === "media" && (!asset || asset.kind === "audio")) continue;
1403
- let opacity = videoValueAt(clip.opacity, clipTime, 1) * fade;
1684
+ const shown = videoClipAnimated(clip, project);
1685
+ let opacity = videoValueAt(shown.opacity, clipTime, 1) * fade;
1404
1686
  let wipe;
1405
1687
  let dip = null;
1688
+ let iris;
1689
+ let shift = 0;
1690
+ let shiftDirection = "right";
1406
1691
  if (incoming) {
1407
1692
  if (incoming.type === "crossfade") opacity *= incoming.progress;
1408
1693
  else if (incoming.type === "wipe") wipe = { direction: incoming.transition.direction ?? "right", progress: incoming.progress };
1409
- 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 };
1410
1699
  }
1411
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
+ }
1412
1705
  const size = clip.type === "media" ? { width: asset.width ?? project.width, height: asset.height ?? project.height } : { width: project.width, height: project.height };
1413
1706
  const fit = clip.fit ?? (clip.type === "media" ? "contain" : "fill");
1414
- const rect = placeVideoRect(project, size, clip.type === "text" ? "fill" : fit, clip.transform, clipTime);
1415
- 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) };
1416
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;
1417
1717
  if (clip.type === "media") Object.assign(layer, { asset: asset.id, assetKind: asset.kind, src: asset.src, sourceTime });
1418
1718
  if (clip.type === "text") layer.text = videoTextStyle(clip);
1419
1719
  if (clip.type === "color") layer.color = clip.color;
@@ -1424,9 +1724,227 @@ function videoFrameAt(project, time) {
1424
1724
  return { time, width: project.width, height: project.height, background: project.background ?? "#000000", layers, audio };
1425
1725
  }
1426
1726
 
1727
+ // src/video-editor/audio.ts
1728
+ var VIDEO_PEAK_BLOCK = 32;
1729
+ function buildVideoAudioPeaks(channels, sampleRate, blockSize = VIDEO_PEAK_BLOCK) {
1730
+ if (!(sampleRate > 0)) throw new Error("The sample rate must be above 0.");
1731
+ const length = channels.reduce((n2, c) => Math.max(n2, c.length), 0);
1732
+ const blocks = Math.max(1, Math.ceil(length / blockSize));
1733
+ const level0 = new Float32Array(blocks * 2);
1734
+ for (let b = 0; b < blocks; b++) {
1735
+ let lo = Infinity;
1736
+ let hi = -Infinity;
1737
+ const end = Math.min(length, (b + 1) * blockSize);
1738
+ for (const channel of channels) {
1739
+ for (let i = b * blockSize; i < Math.min(end, channel.length); i++) {
1740
+ const v = channel[i];
1741
+ if (!Number.isFinite(v)) continue;
1742
+ if (v < lo) lo = v;
1743
+ if (v > hi) hi = v;
1744
+ }
1745
+ }
1746
+ level0[2 * b] = lo === Infinity ? 0 : lo;
1747
+ level0[2 * b + 1] = hi === -Infinity ? 0 : hi;
1748
+ }
1749
+ const levels = [level0];
1750
+ while (levels[levels.length - 1].length > 2) {
1751
+ const below = levels[levels.length - 1];
1752
+ const n2 = below.length / 2;
1753
+ const up = new Float32Array(Math.ceil(n2 / 4) * 2);
1754
+ for (let j = 0; j < up.length / 2; j++) {
1755
+ let lo = Infinity;
1756
+ let hi = -Infinity;
1757
+ for (let i = 4 * j; i < Math.min(n2, 4 * j + 4); i++) {
1758
+ lo = Math.min(lo, below[2 * i]);
1759
+ hi = Math.max(hi, below[2 * i + 1]);
1760
+ }
1761
+ up[2 * j] = lo;
1762
+ up[2 * j + 1] = hi;
1763
+ }
1764
+ levels.push(up);
1765
+ }
1766
+ return { sampleRate, length, blockSize, levels };
1767
+ }
1768
+ var videoAudioPeaksDuration = (peaks) => peaks.length * 1e3 / peaks.sampleRate;
1769
+ function videoAudioPeakRange(peaks, from, to) {
1770
+ const blocks = peaks.levels[0].length / 2;
1771
+ let a = Math.max(0, Math.floor(from * peaks.sampleRate / 1e3 / peaks.blockSize));
1772
+ let b = Math.min(blocks, Math.ceil(to * peaks.sampleRate / 1e3 / peaks.blockSize));
1773
+ if (b <= a) {
1774
+ if (a >= blocks || to < 0) return { min: 0, max: 0 };
1775
+ b = a + 1;
1776
+ }
1777
+ let min = Infinity;
1778
+ let max = -Infinity;
1779
+ const take = (level, i) => {
1780
+ if (2 * i + 1 >= level.length) return;
1781
+ if (level[2 * i] < min) min = level[2 * i];
1782
+ if (level[2 * i + 1] > max) max = level[2 * i + 1];
1783
+ };
1784
+ for (let k = 0; a < b; k++) {
1785
+ const level = peaks.levels[k];
1786
+ if (k === peaks.levels.length - 1) {
1787
+ for (let i = a; i < b; i++) take(level, i);
1788
+ break;
1789
+ }
1790
+ while (a < b && a % 4) take(level, a++);
1791
+ while (a < b && b % 4) take(level, --b);
1792
+ a /= 4;
1793
+ b /= 4;
1794
+ }
1795
+ return min === Infinity ? { min: 0, max: 0 } : { min, max };
1796
+ }
1797
+ function videoAudioPeakSpans(peaks, from, to, columns) {
1798
+ const n2 = Math.max(0, Math.floor(columns));
1799
+ const out = new Float32Array(n2 * 2);
1800
+ const step = (to - from) / Math.max(1, n2);
1801
+ for (let c = 0; c < n2; c++) {
1802
+ const { min, max } = videoAudioPeakRange(peaks, from + c * step, from + (c + 1) * step);
1803
+ out[2 * c] = min;
1804
+ out[2 * c + 1] = max;
1805
+ }
1806
+ return out;
1807
+ }
1808
+ function videoClipWaveform(clip, peaks, columns) {
1809
+ const n2 = Math.max(0, Math.floor(columns));
1810
+ const out = new Float32Array(n2 * 2);
1811
+ const speed = clip.speed ?? 1;
1812
+ const start = clip.in ?? 0;
1813
+ const step = clip.duration / Math.max(1, n2);
1814
+ for (let c = 0; c < n2; c++) {
1815
+ const { min, max } = videoAudioPeakRange(peaks, start + c * step * speed, start + (c + 1) * step * speed);
1816
+ const gain = videoClipSoundGain(clip, (c + 0.5) * step);
1817
+ out[2 * c] = min * gain;
1818
+ out[2 * c + 1] = max * gain;
1819
+ }
1820
+ return out;
1821
+ }
1822
+ function videoWaveformPath(spans, width, height) {
1823
+ const n2 = spans.length / 2;
1824
+ if (!n2 || width <= 0 || height <= 0) return "";
1825
+ const mid = height / 2;
1826
+ const y = (v) => (mid - Math.max(-1, Math.min(1, v)) * mid).toFixed(1);
1827
+ const x = (i) => ((i + 0.5) * (width / n2)).toFixed(1);
1828
+ const floor = 0.5 / mid;
1829
+ const top = [];
1830
+ const bottom = [];
1831
+ for (let i = 0; i < n2; i++) top.push(`${i ? "L" : "M"}${x(i)} ${y(Math.max(spans[2 * i + 1], floor))}`);
1832
+ for (let i = n2 - 1; i >= 0; i--) bottom.push(`L${x(i)} ${y(Math.min(spans[2 * i], -floor))}`);
1833
+ return `${top.join("")}${bottom.join("")}Z`;
1834
+ }
1835
+ function videoClipWaveformPath(clip, peaks, width, height) {
1836
+ const columns = Math.max(1, Math.min(2e3, Math.ceil(width / 2)));
1837
+ return videoWaveformPath(videoClipWaveform(clip, peaks, columns), width, height);
1838
+ }
1839
+ var videoPeakDb = (value) => Math.abs(value) > 1e-5 ? Math.max(-100, Math.round(200 * Math.log10(Math.abs(value))) / 10) : -100;
1840
+ function videoAudioLevels(peaks, from, to, buckets) {
1841
+ const spans = videoAudioPeakSpans(peaks, from, to, buckets);
1842
+ const step = (to - from) / Math.max(1, buckets);
1843
+ return Array.from({ length: spans.length / 2 }, (_, i) => ({ start: from + i * step, end: from + (i + 1) * step, peakDb: videoPeakDb(Math.max(Math.abs(spans[2 * i]), Math.abs(spans[2 * i + 1]))) }));
1844
+ }
1845
+ function findVideoAudioSilences(peaks, from, to, options = {}) {
1846
+ const threshold = options.thresholdDb ?? -40;
1847
+ const minDuration = options.minDuration ?? 300;
1848
+ const resolution = Math.max(1, options.resolution ?? 10);
1849
+ const buckets = Math.max(1, Math.ceil((to - from) / resolution));
1850
+ const out = [];
1851
+ let run = null;
1852
+ const levels = videoAudioLevels(peaks, from, to, buckets);
1853
+ levels.forEach((l, i) => {
1854
+ const quiet = l.peakDb < threshold;
1855
+ if (quiet && run === null) run = l.start;
1856
+ if ((!quiet || i === levels.length - 1) && run !== null) {
1857
+ const end = quiet ? l.end : l.start;
1858
+ if (end - run >= minDuration) out.push({ start: run, end });
1859
+ run = null;
1860
+ }
1861
+ });
1862
+ return out;
1863
+ }
1864
+ async function decodeVideoAudioPeaks(src, options = {}) {
1865
+ const Offline = globalThis.OfflineAudioContext;
1866
+ if (!Offline || typeof fetch === "undefined") throw new Error("Audio can only be decoded in a browser with Web Audio.");
1867
+ const response = await fetch(src, options.signal ? { signal: options.signal } : {});
1868
+ if (!response.ok) throw new Error(`"${src}" could not be loaded (${response.status}).`);
1869
+ const data = await response.arrayBuffer();
1870
+ let buffer;
1871
+ try {
1872
+ buffer = await new Offline(1, 1, 44100).decodeAudioData(data);
1873
+ } catch {
1874
+ throw new Error(`"${src}" has no sound this browser can decode.`);
1875
+ }
1876
+ return buildVideoAudioPeaks(Array.from({ length: buffer.numberOfChannels }, (_, i) => buffer.getChannelData(i)), buffer.sampleRate);
1877
+ }
1878
+ var VideoAudioPeaksCache = class {
1879
+ constructor(loader = (asset) => decodeVideoAudioPeaks(asset.src)) {
1880
+ this.loader = loader;
1881
+ }
1882
+ loader;
1883
+ ready = /* @__PURE__ */ new Map();
1884
+ pending = /* @__PURE__ */ new Map();
1885
+ failed = /* @__PURE__ */ new Map();
1886
+ listeners = /* @__PURE__ */ new Set();
1887
+ /** The peaks if ready, without starting to load them. */
1888
+ peek(asset) {
1889
+ return this.ready.get(asset.src);
1890
+ }
1891
+ /** The peaks if ready; otherwise `undefined`, and loading starts (once) unless it failed. */
1892
+ get(asset) {
1893
+ const peaks = this.ready.get(asset.src);
1894
+ if (!peaks && !this.failed.has(asset.src)) void this.load(asset).catch(() => {
1895
+ });
1896
+ return peaks;
1897
+ }
1898
+ /** Why loading an asset's peaks failed, if it did. */
1899
+ error(asset) {
1900
+ return this.failed.get(asset.src);
1901
+ }
1902
+ /** The peaks, loading them if needed. Rejects with a readable error. */
1903
+ load(asset) {
1904
+ const ready = this.ready.get(asset.src);
1905
+ if (ready) return Promise.resolve(ready);
1906
+ const failed = this.failed.get(asset.src);
1907
+ if (failed) return Promise.reject(new Error(failed));
1908
+ let pending = this.pending.get(asset.src);
1909
+ if (!pending) {
1910
+ pending = this.loader(asset).then(
1911
+ (peaks) => {
1912
+ this.pending.delete(asset.src);
1913
+ this.ready.set(asset.src, peaks);
1914
+ this.emit();
1915
+ return peaks;
1916
+ },
1917
+ (e) => {
1918
+ this.pending.delete(asset.src);
1919
+ const message = `The sound of "${asset.id}" could not be read: ${e instanceof Error ? e.message : String(e)}`;
1920
+ this.failed.set(asset.src, message);
1921
+ this.emit();
1922
+ throw new Error(message);
1923
+ }
1924
+ );
1925
+ this.pending.set(asset.src, pending);
1926
+ }
1927
+ return pending;
1928
+ }
1929
+ /** Uses peaks the host already has (computed earlier or stored). */
1930
+ set(src, peaks) {
1931
+ this.ready.set(src, peaks);
1932
+ this.failed.delete(src);
1933
+ this.emit();
1934
+ }
1935
+ subscribe(fn) {
1936
+ this.listeners.add(fn);
1937
+ return () => this.listeners.delete(fn);
1938
+ }
1939
+ emit() {
1940
+ for (const fn of this.listeners) fn();
1941
+ }
1942
+ };
1943
+
1427
1944
  // src/video-editor/render.ts
1428
1945
  var esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1429
1946
  var n = (v) => String(Math.round(v * 100) / 100);
1947
+ var videoIrisRadius = (frame, progress) => progress * Math.hypot(frame.width / 2, frame.height / 2);
1430
1948
  function videoWipeRect(rect, wipe) {
1431
1949
  const { direction, progress } = wipe;
1432
1950
  const w = rect.width * progress;
@@ -1444,6 +1962,11 @@ function videoFrameToSvg(frame, options = {}, assets = []) {
1444
1962
  const { rect } = layer;
1445
1963
  const attrs = [];
1446
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
+ }
1447
1970
  if (layer.rotation) attrs.push(`transform="rotate(${n(layer.rotation)} ${n(rect.x + rect.width / 2)} ${n(rect.y + rect.height / 2)})"`);
1448
1971
  if (layer.wipe) {
1449
1972
  const r = videoWipeRect(rect, layer.wipe);
@@ -1475,7 +1998,12 @@ function videoFrameToSvg(frame, options = {}, assets = []) {
1475
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>`;
1476
1999
  }
1477
2000
  }
1478
- 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);
1479
2007
  });
1480
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>`;
1481
2009
  }
@@ -1499,7 +2027,7 @@ function videoContactSheetSvg(project, times, options = {}) {
1499
2027
  });
1500
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>`;
1501
2029
  }
1502
- function drawVideoFrame(ctx, frame, sourceFor, scale = 1) {
2030
+ function drawVideoFrame(ctx, frame, sourceFor, scale = 1, options = {}) {
1503
2031
  ctx.save();
1504
2032
  ctx.setTransform(scale, 0, 0, scale, 0, 0);
1505
2033
  ctx.globalAlpha = 1;
@@ -1509,6 +2037,18 @@ function drawVideoFrame(ctx, frame, sourceFor, scale = 1) {
1509
2037
  const { rect } = layer;
1510
2038
  ctx.save();
1511
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
+ }
1512
2052
  if (layer.rotation) {
1513
2053
  ctx.translate(rect.x + rect.width / 2, rect.y + rect.height / 2);
1514
2054
  ctx.rotate(layer.rotation * Math.PI / 180);
@@ -1562,15 +2102,17 @@ function toVideoEdl(project, options = {}) {
1562
2102
  const track = options.track !== void 0 ? project.tracks.find((t) => t.id === options.track) : [...project.tracks].reverse().find((t) => t.kind === "video");
1563
2103
  if (!track) throw new Error(options.track !== void 0 ? `There is no track "${options.track}".` : "The project has no video track.");
1564
2104
  const fps = project.fps;
1565
- const tc = (ms) => formatVideoTimecode(ms, fps);
1566
- const rec0 = options.recordStart ?? 36e5;
2105
+ const dropFrame = (options.dropFrame ?? true) && isVideoDropFrameRate(fps);
2106
+ const tc = (ms) => formatVideoTimecode(ms, fps, dropFrame);
2107
+ const rec0 = options.recordStart !== void 0 ? videoTimecodeFrames(options.recordStart, fps) : videoHourFrames(fps, dropFrame);
2108
+ const rtc = (ms) => formatVideoTimecodeFrames(rec0 + videoTimecodeFrames(ms, fps), fps, dropFrame);
1567
2109
  const assets = videoAssetMap(project);
1568
2110
  const channel = track.kind === "video" ? "V" : "A";
1569
- const lines = [`TITLE: ${(options.title ?? project.name ?? "Untitled").toUpperCase()}`, "FCM: NON-DROP FRAME", ""];
2111
+ const lines = [`TITLE: ${(options.title ?? project.name ?? "Untitled").toUpperCase()}`, `FCM: ${dropFrame ? "DROP FRAME" : "NON-DROP FRAME"}`, ""];
1570
2112
  let event = 0;
1571
2113
  const num2 = () => String(++event).padStart(3, "0");
1572
2114
  const sourceAt = (clip, time) => clip.type === "media" ? (clip.in ?? 0) + (time - clip.start) * videoClipSpeed(clip) : time - clip.start;
1573
- const row = (n2, reel, kind, srcIn, srcOut, recIn, recOut) => `${n2} ${reel.padEnd(8)} ${channel.padEnd(5)} ${kind.padEnd(8)} ${tc(srcIn)} ${tc(srcOut)} ${tc(rec0 + recIn)} ${tc(rec0 + recOut)}`;
2115
+ const row = (n2, reel, kind, srcIn, srcOut, recIn, recOut) => `${n2} ${reel.padEnd(8)} ${channel.padEnd(5)} ${kind.padEnd(8)} ${tc(srcIn)} ${tc(srcOut)} ${rtc(recIn)} ${rtc(recOut)}`;
1574
2116
  for (const clip of sortedVideoClips(track)) {
1575
2117
  const asset = clip.type === "media" ? assets.get(clip.asset) : void 0;
1576
2118
  const reel = clip.type === "media" ? reelName(asset) : "BL";
@@ -1587,7 +2129,7 @@ function toVideoEdl(project, options = {}) {
1587
2129
  const outReel = before.type === "media" ? reelName(assets.get(before.asset)) : "BL";
1588
2130
  const at = sourceAt(before, recIn);
1589
2131
  lines.push(row(n2, outReel, "C", at, at, recIn, recIn));
1590
- 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));
1591
2133
  } else lines.push(row(n2, reel, "C", sourceAt(clip, recIn), sourceAt(clip, recOut), recIn, recOut));
1592
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))}`);
1593
2135
  if (clip.type === "media") lines.push(`* FROM CLIP NAME: ${asset?.name ?? asset?.src ?? clip.asset}`);
@@ -1597,7 +2139,48 @@ function toVideoEdl(project, options = {}) {
1597
2139
  }
1598
2140
  return lines.join("\n");
1599
2141
  }
2142
+ function loudnormFilter(target = {}, measure = false) {
2143
+ const I = target.integrated ?? -14;
2144
+ const TP = target.truePeak ?? -1;
2145
+ const LRA = target.range ?? 11;
2146
+ if (!(I >= -70 && I <= -5)) throw new Error(`The loudness target must be between -70 and -5 LUFS (got ${I}); -14 suits streaming, -23 broadcast.`);
2147
+ if (!(TP >= -9 && TP <= 0)) throw new Error(`The true peak ceiling must be between -9 and 0 dBTP (got ${TP}); -1 is usual.`);
2148
+ if (!(LRA >= 1 && LRA <= 50)) throw new Error(`The loudness range must be between 1 and 50 LU (got ${LRA}).`);
2149
+ const parts = [`loudnorm=I=${I}:TP=${TP}:LRA=${LRA}`];
2150
+ const m = target.measured;
2151
+ if (m && !measure) {
2152
+ for (const [k, v] of Object.entries(m)) if (!Number.isFinite(v)) throw new Error(`The measured ${k} is not a number (${v}); measure again, or leave "measured" out for a one-pass normalisation.`);
2153
+ parts.push(`measured_I=${m.integrated}:measured_TP=${m.truePeak}:measured_LRA=${m.range}:measured_thresh=${m.threshold}:offset=${m.offset}:linear=true`);
2154
+ }
2155
+ if (measure) parts.push("print_format=json");
2156
+ return `${parts.join(":")},aresample=48000`;
2157
+ }
2158
+ function parseVideoLoudnessMeasurement(output) {
2159
+ const blocks = output.match(/\{[^{}]*"input_i"[^{}]*\}/g);
2160
+ if (!blocks) throw new Error("No loudness measurement found: pass the stderr of the command from toVideoLoudnessMeasure.");
2161
+ const json = JSON.parse(blocks[blocks.length - 1]);
2162
+ const n2 = (k) => Number(json[k]);
2163
+ const m = { integrated: n2("input_i"), truePeak: n2("input_tp"), range: n2("input_lra"), threshold: n2("input_thresh"), offset: n2("target_offset") };
2164
+ if (!Number.isFinite(m.integrated)) throw new Error("The mix is silent, so its loudness cannot be normalised.");
2165
+ return m;
2166
+ }
1600
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
+ };
2182
+ var AFADE_CURVES = { linear: "tri", "equal-power": "qsin", "s-curve": "hsin", exponential: "cub", logarithmic: "ipar" };
2183
+ var afadeCurve = (curve) => curve && curve !== "linear" ? `:curve=${AFADE_CURVES[curve]}` : "";
1601
2184
  var even = (v) => Math.max(2, 2 * Math.round(v / 2));
1602
2185
  var ffmpegFilterValue = (s) => s.replace(/[\\':%]/g, (c) => `\\${c}`).replace(/[\\'[\],;]/g, (c) => `\\${c}`);
1603
2186
  var color = (c) => {
@@ -1622,6 +2205,12 @@ function atempo(speed) {
1622
2205
  return out;
1623
2206
  }
1624
2207
  function toVideoFfmpeg(project, options = {}) {
2208
+ return buildFfmpeg(project, options, false);
2209
+ }
2210
+ function toVideoLoudnessMeasure(project, options = {}) {
2211
+ return buildFfmpeg(project, { ...options, loudness: { ...options.loudness, measured: void 0 } }, true);
2212
+ }
2213
+ function buildFfmpeg(project, options, measure) {
1625
2214
  const notes = [];
1626
2215
  const assets = videoAssetMap(project);
1627
2216
  const D = videoProjectDuration(project);
@@ -1630,11 +2219,12 @@ function toVideoFfmpeg(project, options = {}) {
1630
2219
  const H = even(project.height);
1631
2220
  const fps = project.fps;
1632
2221
  const inputs = [];
1633
- const graph = [`color=c=${color(project.background ?? "#000000")}:s=${W}x${H}:r=${fps}:d=${sec(D)},format=yuva420p[base]`];
2222
+ const graph = measure ? [] : [`color=c=${color(project.background ?? "#000000")}:s=${W}x${H}:r=${fps}:d=${sec(D)},format=yuva420p[base]`];
1634
2223
  const audio = [];
1635
2224
  let cur = "base";
1636
2225
  let usesDrawtext = false;
1637
2226
  const animated = /* @__PURE__ */ new Set();
2227
+ const blended = /* @__PURE__ */ new Set();
1638
2228
  let k = 0;
1639
2229
  const step = 2e3 / fps;
1640
2230
  const expr = (prop, clipTime, fallback, what) => {
@@ -1650,7 +2240,8 @@ function toVideoFfmpeg(project, options = {}) {
1650
2240
  return inputs.length - 1;
1651
2241
  };
1652
2242
  for (const track of [...project.tracks].reverse()) {
1653
- for (const clip of sortedVideoClips(track)) {
2243
+ for (const raw of sortedVideoClips(track)) {
2244
+ const clip = videoClipAnimated(raw, project);
1654
2245
  const { lead, tail } = videoClipOverhang(track, clip);
1655
2246
  const t0 = clip.start - lead;
1656
2247
  const span = clip.duration + lead + tail;
@@ -1661,26 +2252,28 @@ function toVideoFfmpeg(project, options = {}) {
1661
2252
  continue;
1662
2253
  }
1663
2254
  let index;
1664
- if (asset && asset.kind !== "audio" && track.kind === "video" && !track.hidden) index = input(clip, asset, lead, tail);
1665
- const audible = asset && (asset.kind === "audio" || asset.kind === "video" && asset.hasAudio !== false);
2255
+ if (!measure && asset && asset.kind !== "audio" && track.kind === "video" && !track.hidden) index = input(clip, asset, lead, tail);
2256
+ const audible = videoAssetHasSound(asset);
1666
2257
  if (audible && clip.type === "media" && !clip.muted && !track.muted) {
1667
2258
  const ai = index ?? input(clip, asset, lead, tail);
1668
2259
  const trackGain = track.volume ?? 1;
1669
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}`;
1670
2261
  const chain2 = ["asetpts=PTS-STARTPTS", ...atempo(videoClipSpeed(clip)), volume];
1671
2262
  const before2 = clip.transition ? videoClipBefore(track, clip) : null;
1672
- if (lead) chain2.push(`afade=t=in:st=0:d=${sec(2 * lead)}`);
1673
- if (tail) chain2.push(`afade=t=out:st=${sec(span - 2 * tail)}:d=${sec(2 * tail)}`);
1674
- if (before2 && clip.transition.type === "dip") chain2.push(`afade=t=in:st=0:d=${sec(clip.transition.duration / 2)}`);
1675
- const after = sortedVideoClips(track).find((c) => c !== clip && Math.abs(c.start - end) < 0.5 && c.transition?.type === "dip");
1676
- if (after) chain2.push(`afade=t=out:st=${sec(span - after.transition.duration / 2)}:d=${sec(after.transition.duration / 2)}`);
1677
- if (clip.fadeIn) chain2.push(`afade=t=in:st=${sec(lead)}:d=${sec(clip.fadeIn)}`);
1678
- if (clip.fadeOut) chain2.push(`afade=t=out:st=${sec(lead + clip.duration - clip.fadeOut)}:d=${sec(clip.fadeOut)}`);
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}`);
2270
+ if (clip.fadeIn) chain2.push(`afade=t=in:st=${sec(lead)}:d=${sec(clip.fadeIn)}${afadeCurve(clip.fadeInCurve)}`);
2271
+ if (clip.fadeOut) chain2.push(`afade=t=out:st=${sec(lead + clip.duration - clip.fadeOut)}:d=${sec(clip.fadeOut)}${afadeCurve(clip.fadeOutCurve)}`);
1679
2272
  chain2.push(`adelay=${Math.round(t0)}:all=1`);
1680
2273
  graph.push(`[${ai}:a]${chain2.join(",")}[a${audio.length}]`);
1681
2274
  audio.push(`[a${audio.length}]`);
1682
2275
  }
1683
- if (track.kind !== "video" || track.hidden) continue;
2276
+ if (measure || track.kind !== "video" || track.hidden) continue;
1684
2277
  const enable = `enable='between(t,${sec(t0)},${sec(t0 + span)})'`;
1685
2278
  const next = `v${k++}`;
1686
2279
  const onTimeline = `(t*1000-${Math.round(clip.start)})`;
@@ -1712,6 +2305,8 @@ function toVideoFfmpeg(project, options = {}) {
1712
2305
  `enable='between(t,${sec(clip.start)},${sec(end)})'`
1713
2306
  ];
1714
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.`);
1715
2310
  graph.push(`[${cur}]drawtext=${opts.join(":")}[${next}]`);
1716
2311
  cur = next;
1717
2312
  continue;
@@ -1727,10 +2322,8 @@ function toVideoFfmpeg(project, options = {}) {
1727
2322
  source = `[${index}:v]`;
1728
2323
  chain.push(`setpts=${videoClipSpeed(clip) !== 1 ? `(PTS-STARTPTS)/${videoClipSpeed(clip)}` : "PTS-STARTPTS"}`, `fps=${fps}`, `scale=${even(rect.width)}:${even(rect.height)}`);
1729
2324
  }
1730
- if (isVideoKeyframes(placed?.scale)) {
1731
- const s = expr(placed.scale, onChain("t"), 1, "scale");
1732
- 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`);
1733
- }
2325
+ const grade = videoGradeFfmpegFilters(clip.grade);
2326
+ if (grade.length) chain.push("format=rgba", ...grade);
1734
2327
  chain.push("format=yuva420p");
1735
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)'`);
1736
2329
  else if (clip.opacity !== void 0 && clip.opacity < 1) chain.push(`colorchannelmixer=aa=${clip.opacity}`);
@@ -1744,18 +2337,50 @@ function toVideoFfmpeg(project, options = {}) {
1744
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}))`;
1745
2338
  chain.push(`geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='if(${test},alpha(X,Y),0)'`);
1746
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
+ }
1747
2344
  const rotation = placed?.rotation;
1748
- if (isVideoKeyframes(rotation)) chain.push(`rotate=a='(${expr(rotation, onChain("t"), 0, "rotation")})*PI/180':c=none:ow='hypot(iw,ih)':oh=ow`);
1749
- 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
+ }
1750
2351
  chain.push(`setpts=PTS+${sec(t0)}/TB`);
1751
2352
  const cx = rect.x + rect.width / 2;
1752
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);
1753
2367
  const at = (axis, c) => {
1754
2368
  const v = placed?.[axis];
1755
- 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}'`;
1756
2371
  };
1757
2372
  graph.push(`${source.startsWith("[") ? source : `${source},`}${chain.join(",")}[l${next}]`);
1758
- 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}]`);
1759
2384
  cur = next;
1760
2385
  const before = clip.transition ? videoClipBefore(track, clip) : null;
1761
2386
  if (before && clip.transition.type === "dip") {
@@ -1767,14 +2392,24 @@ function toVideoFfmpeg(project, options = {}) {
1767
2392
  }
1768
2393
  }
1769
2394
  }
1770
- graph.push(`[${cur}]format=yuv420p[vout]`);
1771
- if (audio.length) graph.push(`${audio.join("")}amix=inputs=${audio.length}:normalize=0:duration=longest,apad,atrim=0:${sec(D)}[aout]`);
2395
+ if (!measure) graph.push(`[${cur}]format=yuv420p[vout]`);
2396
+ if (measure && !audio.length) throw new Error("Nothing on the timeline makes a sound, so there is no loudness to measure.");
2397
+ const loudness = options.loudness || measure ? `,${loudnormFilter(options.loudness, measure)}` : "";
2398
+ if (audio.length) graph.push(`${audio.join("")}amix=inputs=${audio.length}:normalize=0:duration=longest,apad,atrim=0:${sec(D)}${loudness}[aout]`);
2399
+ if (options.loudness && !audio.length) notes.push("Nothing makes a sound, so there was no loudness to normalise.");
2400
+ else if (options.loudness && !options.loudness.measured)
2401
+ notes.push("Loudness is normalised in one pass, which adjusts the level as it goes; for a fixed gain that keeps the dynamics, run toVideoLoudnessMeasure first and pass its result as loudness.measured.");
1772
2402
  if (project.tracks.some((t) => t.clips.some((c) => c.type === "media" && assets.get(c.asset)?.kind === "video" && assets.get(c.asset)?.hasAudio === void 0)))
1773
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.');
1774
2404
  if (animated.size)
1775
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.");
1776
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.");
1777
2408
  const filterComplex = graph.join(";");
2409
+ if (measure) {
2410
+ const args2 = ["-hide_banner", "-nostats", ...inputs.flat(), "-filter_complex", filterComplex, "-map", "[aout]", "-f", "null", "-"];
2411
+ return { args: args2, command: ["ffmpeg", ...args2].map(shellQuote).join(" "), filterComplex, duration: D, notes };
2412
+ }
1778
2413
  const args = [
1779
2414
  "-y",
1780
2415
  ...inputs.flat(),
@@ -1816,6 +2451,8 @@ var VideoPreviewEngine = class {
1816
2451
  unsubscribe;
1817
2452
  scheduled = false;
1818
2453
  destroyed = false;
2454
+ filterHost = null;
2455
+ filters = /* @__PURE__ */ new Set();
1819
2456
  createMedia(kind) {
1820
2457
  if (this.options.createMedia) return this.options.createMedia(kind);
1821
2458
  const el = document.createElement(kind);
@@ -1917,12 +2554,31 @@ var VideoPreviewEngine = class {
1917
2554
  const h = Math.max(1, Math.round(frame.height * resolution));
1918
2555
  if (this.canvas.width !== w) this.canvas.width = w;
1919
2556
  if (this.canvas.height !== h) this.canvas.height = h;
1920
- drawVideoFrame(this.ctx, frame, this.sourceFor, resolution);
2557
+ drawVideoFrame(this.ctx, frame, this.sourceFor, resolution, { defineSvgFilter: this.defineSvgFilter });
1921
2558
  this.options.onFrame?.(frame);
1922
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
+ };
1923
2576
  /** Stops following the editor and releases every media element. */
1924
2577
  destroy() {
1925
2578
  this.destroyed = true;
2579
+ this.filterHost?.remove();
2580
+ this.filterHost = null;
2581
+ this.filters.clear();
1926
2582
  this.unsubscribe();
1927
2583
  for (const entry of [...this.media.values()]) this.release(entry);
1928
2584
  this.images.clear();
@@ -1933,6 +2589,8 @@ var VideoPreviewEngine = class {
1933
2589
  var VideoEditorController = class {
1934
2590
  history;
1935
2591
  player;
2592
+ /** Sound levels per asset, loaded on demand for waveforms and agents. */
2593
+ audioPeaks;
1936
2594
  project;
1937
2595
  issues;
1938
2596
  selection = [];
@@ -1946,6 +2604,7 @@ var VideoEditorController = class {
1946
2604
  state;
1947
2605
  constructor(options = {}) {
1948
2606
  this.history = options.history ?? new HistoryManager();
2607
+ this.audioPeaks = options.audioPeaks instanceof VideoAudioPeaksCache ? options.audioPeaks : new VideoAudioPeaksCache(options.audioPeaks);
1949
2608
  this.project = options.project ? parseVideoProject(options.project) : createVideoProject();
1950
2609
  this.issues = checkVideoProject(this.project);
1951
2610
  this.readOnly = options.readOnly ?? false;
@@ -2162,6 +2821,10 @@ var VideoEditorController = class {
2162
2821
  toFfmpeg(options = {}) {
2163
2822
  return toVideoFfmpeg(this.project, options);
2164
2823
  }
2824
+ /** The ffmpeg command that measures the mix's loudness (see `toVideoLoudnessMeasure`). */
2825
+ toLoudnessMeasure(options = {}) {
2826
+ return toVideoLoudnessMeasure(this.project, options);
2827
+ }
2165
2828
  /** One track as a CMX 3600 EDL. */
2166
2829
  toEdl(options = {}) {
2167
2830
  return toVideoEdl(this.project, options);
@@ -2169,6 +2832,7 @@ var VideoEditorController = class {
2169
2832
  };
2170
2833
 
2171
2834
  // src/video-editor/tools.ts
2835
+ var round = (ms) => Math.round(ms * 10) / 10;
2172
2836
  var READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
2173
2837
  var WRITE = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
2174
2838
  var WRITE_IDEMPOTENT = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false };
@@ -2208,19 +2872,37 @@ var TRANSFORM = object(
2208
2872
  );
2209
2873
  var TRANSITION = object(
2210
2874
  {
2211
- 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)." },
2212
- 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." },
2213
2877
  color: COLOR("For dip: the colour passed through (default black)"),
2214
- 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." }
2215
2880
  },
2216
2881
  ["type", "duration"],
2217
2882
  "A transition from the clip that ends where this one starts on the same track."
2218
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
+ );
2219
2897
  var CLIP_COMMON = {
2220
2898
  name: { type: "string", description: "A label for the clip." },
2221
2899
  opacity: { type: "number", minimum: 0, maximum: 1, description: "Opacity 0\u20131 (default 1). A number replaces any keyframes; set_video_keyframes animates it." },
2222
2900
  transform: TRANSFORM,
2223
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." },
2224
2906
  fadeIn: MS("Fade up from nothing (picture and sound) over this long"),
2225
2907
  fadeOut: MS("Fade down to nothing over this long")
2226
2908
  };
@@ -2228,7 +2910,9 @@ var MEDIA_FIELDS = {
2228
2910
  in: MS("Where in the source it starts (default 0)"),
2229
2911
  speed: { type: "number", minimum: 0.05, maximum: 20, description: "Playback speed (default 1). Changing it keeps the stretch of source, so the clip gets shorter or longer." },
2230
2912
  volume: { type: "number", minimum: 0, maximum: 4, description: "Gain (1 = unchanged, default 1). A number replaces any keyframes; set_video_keyframes animates it." },
2231
- muted: { type: "boolean", description: "Silence this clip (default false)." }
2913
+ muted: { type: "boolean", description: "Silence this clip (default false)." },
2914
+ fadeInCurve: { type: "string", enum: [...VIDEO_FADE_CURVES], description: "Shape of the sound's fade in (default linear; the picture always fades linearly): linear, equal-power (steady loudness, best for crossfades), s-curve (gentle at both ends), exponential (slow start, natural from silence) or logarithmic (fast start)." },
2915
+ fadeOutCurve: { type: "string", enum: [...VIDEO_FADE_CURVES], description: "Shape of the sound's fade out (default linear), the same choices as fadeInCurve; exponential sounds natural fading to silence." }
2232
2916
  };
2233
2917
  var TEXT_FIELDS = {
2234
2918
  text: { type: "string", minLength: 1, description: `The title's text ("\\n" breaks lines).` },
@@ -2379,7 +3063,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
2379
3063
  const getFrame = {
2380
3064
  name: "get_video_frame",
2381
3065
  title: "What plays at a moment",
2382
- 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).",
2383
3067
  inputSchema: object({ time: MS("Time from the start (default: the playhead)") }),
2384
3068
  annotations: { title: "What plays at a moment", ...READ },
2385
3069
  handler: ({ time }) => {
@@ -2401,7 +3085,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
2401
3085
  const checkProject = {
2402
3086
  name: "check_video_project",
2403
3087
  title: "Check the video project",
2404
- 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.",
2405
3089
  inputSchema: object({}),
2406
3090
  annotations: { title: "Check the video project", ...READ },
2407
3091
  handler: () => {
@@ -2432,27 +3116,59 @@ function createVideoEditorToolProvider(controller, options = {}) {
2432
3116
  return { svg };
2433
3117
  }
2434
3118
  };
3119
+ const LOUDNESS_TARGET = {
3120
+ integrated: { type: "number", minimum: -70, maximum: -5, description: "Integrated loudness to reach, in LUFS (default -14, what streaming services play at; -16 for podcasts, -23 for EBU R128 broadcast)." },
3121
+ truePeak: { type: "number", minimum: -9, maximum: 0, description: "Highest true peak, in dBTP (default -1)." },
3122
+ range: { type: "number", minimum: 1, maximum: 50, description: "Loudness range, in LU (default 11)." }
3123
+ };
2435
3124
  const exportFfmpeg = {
2436
3125
  name: "export_video_ffmpeg",
2437
3126
  title: "Export an ffmpeg command",
2438
- description: "Returns the ffmpeg command that renders the project to a file: { args (for spawn), command (one shell line), filterComplex, duration, notes on anything approximated }. Fails when the project has no clips.",
3127
+ description: "Returns the ffmpeg command that renders the project to a file: { args (for spawn), command (one shell line), filterComplex, duration, notes on anything approximated }. With loudness, the mix is normalised to a target loudness (EBU R128); add loudness.measured from a measuring pass (export_video_loudness_measure) for an exact fixed gain. Fails when the project has no clips.",
2439
3128
  inputSchema: object({
2440
3129
  output: { type: "string", minLength: 1, description: "Output file name (default output.mp4)." },
2441
- crf: { type: "integer", minimum: 0, maximum: 51, description: "x264 quality, lower is better (default 18)." }
3130
+ crf: { type: "integer", minimum: 0, maximum: 51, description: "x264 quality, lower is better (default 18)." },
3131
+ loudness: object(
3132
+ {
3133
+ ...LOUDNESS_TARGET,
3134
+ measured: object(
3135
+ {
3136
+ integrated: { type: "number", description: `Measured integrated loudness, LUFS (loudnorm's "input_i").` },
3137
+ truePeak: { type: "number", description: 'Measured true peak, dBTP ("input_tp").' },
3138
+ range: { type: "number", description: 'Measured loudness range, LU ("input_lra").' },
3139
+ threshold: { type: "number", description: 'Measured gating threshold, LUFS ("input_thresh").' },
3140
+ offset: { type: "number", description: 'Offset for the second pass, LU ("target_offset").' }
3141
+ },
3142
+ ["integrated", "truePeak", "range", "threshold", "offset"],
3143
+ "What the measuring pass printed (the JSON at the end of its stderr), for a linear second pass that keeps the dynamics. Leave out for one-pass normalisation."
3144
+ )
3145
+ },
3146
+ [],
3147
+ "Normalise the mix's loudness (default: not normalised). An empty object uses -14 LUFS, -1 dBTP."
3148
+ )
2442
3149
  }),
2443
3150
  annotations: { title: "Export an ffmpeg command", ...READ },
2444
- handler: ({ output, crf }) => controller.toFfmpeg({ ...output ? { output } : {}, ...crf !== void 0 ? { crf } : {} })
3151
+ handler: ({ output, crf, loudness }) => controller.toFfmpeg({ ...output ? { output } : {}, ...crf !== void 0 ? { crf } : {}, ...loudness ? { loudness } : {} })
3152
+ };
3153
+ const exportLoudnessMeasure = {
3154
+ name: "export_video_loudness_measure",
3155
+ title: "Export a loudness measuring command",
3156
+ description: "Returns the ffmpeg command for the first pass of a loudness normalisation: it mixes only the sound and prints its loudness as JSON at the end of stderr (input_i, input_tp, input_lra, input_thresh, target_offset) instead of writing a file. Pass those numbers as loudness.measured (integrated, truePeak, range, threshold, offset) to export_video_ffmpeg, with the same target. Fails when nothing makes a sound.",
3157
+ inputSchema: object(LOUDNESS_TARGET),
3158
+ annotations: { title: "Export a loudness measuring command", ...READ },
3159
+ handler: (target) => controller.toLoudnessMeasure({ loudness: target })
2445
3160
  };
2446
3161
  const exportEdl = {
2447
3162
  name: "export_video_edl",
2448
3163
  title: "Export an EDL",
2449
- description: "Returns one track as a CMX 3600 edit decision list (text) for conforming in another editor: events with source and record timecode, dissolves, speed changes and clip names.",
3164
+ description: "Returns one track as a CMX 3600 edit decision list (text) for conforming in another editor: events with source and record timecode (drop-frame at 29.97 and 59.94 fps unless dropFrame is false), dissolves, speed changes and clip names.",
2450
3165
  inputSchema: object({
2451
3166
  track: { ...TRACK_ID, description: "Track to export (default: the bottom video track)." },
2452
- title: { type: "string", description: "TITLE line (default: the project name)." }
3167
+ title: { type: "string", description: "TITLE line (default: the project name)." },
3168
+ dropFrame: { type: "boolean", description: "Drop-frame timecode (HH:MM:SS;FF). Default true at 29.97 and 59.94 fps, where it is the norm; ignored at other rates, which have no drop-frame form." }
2453
3169
  }),
2454
3170
  annotations: { title: "Export an EDL", ...READ },
2455
- handler: ({ track, title }) => ({ edl: controller.toEdl({ ...track ? { track } : {}, ...title ? { title } : {} }) })
3171
+ handler: ({ track, title, dropFrame }) => ({ edl: controller.toEdl({ ...track ? { track } : {}, ...title ? { title } : {}, ...dropFrame !== void 0 ? { dropFrame } : {} }) })
2456
3172
  };
2457
3173
  const seek = {
2458
3174
  name: "seek_video",
@@ -2601,12 +3317,12 @@ function createVideoEditorToolProvider(controller, options = {}) {
2601
3317
  const updateClip = {
2602
3318
  name: "update_video_clip",
2603
3319
  title: "Change a clip",
2604
- description: `Changes a clip's properties (one undo step): opacity, transform, fit, fades, name; for media volume, mute, 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.`,
2605
3321
  inputSchema: object(
2606
3322
  {
2607
3323
  clip: CLIP_ID,
2608
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)."),
2609
- remove: { type: "array", items: { type: "string", enum: ["name", "opacity", "transform", "fit", "fadeIn", "fadeOut", "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." }
2610
3326
  },
2611
3327
  ["clip"]
2612
3328
  ),
@@ -2614,17 +3330,60 @@ function createVideoEditorToolProvider(controller, options = {}) {
2614
3330
  handler: ({ clip, set, remove }) => {
2615
3331
  const current = clipOut(project(), clip);
2616
3332
  if (current && set) {
2617
- const allowed = /* @__PURE__ */ new Set(["name", "opacity", "transform", "fit", "fadeIn", "fadeOut", ...current.type === "media" ? ["in", "speed", "volume", "muted", "asset"] : 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"]]);
2618
3334
  const wrong = Object.keys(set).filter((k) => !allowed.has(k));
2619
3335
  if (wrong.length) throw new Error(`${wrong.map((k) => `"${k}"`).join(", ")} cannot be set on a ${current.type} clip; it takes ${[...allowed].join(", ")}.`);
2620
3336
  }
2621
3337
  return apply({ op: "update_clip", clip, set, remove }, "Change clip", (r) => ({ clip: clipOut(r.project, clip) }));
2622
3338
  }
2623
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
+ };
2624
3383
  const setTransition = {
2625
3384
  name: "set_video_transition",
2626
3385
  title: "Set a transition",
2627
- 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.",
2628
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"]),
2629
3388
  annotations: { title: "Set a transition", ...WRITE_IDEMPOTENT },
2630
3389
  handler: ({ clip, transition }) => apply({ op: "set_transition", clip, transition }, transition ? "Set transition" : "Remove transition", (r) => ({ clip: clipOut(r.project, clip) }))
@@ -2771,8 +3530,75 @@ function createVideoEditorToolProvider(controller, options = {}) {
2771
3530
  return { redone, ...redone ? { label } : {} };
2772
3531
  }
2773
3532
  };
2774
- const reads = [getProject, getFrame, findGaps, checkProject, renderFrame, exportFfmpeg, exportEdl, seek];
2775
- 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];
3533
+ const soundRange = (args) => {
3534
+ const p = project();
3535
+ if (args.clip === void 0 === (args.asset === void 0)) throw new Error('Pass either "clip" (a media clip, times on the timeline) or "asset" (times in the source), not both.');
3536
+ let asset;
3537
+ let toSource = (t) => t;
3538
+ let toOut = (t) => t;
3539
+ let from;
3540
+ let to;
3541
+ if (args.clip !== void 0) {
3542
+ const found = findVideoClip(p, args.clip);
3543
+ if (!found) throw new Error(`There is no clip "${args.clip}"; get_video_project lists the clips.`);
3544
+ const c = found.clip;
3545
+ if (c.type !== "media") throw new Error(`Clip "${c.id}" is a ${c.type} clip and has no sound; pass a media clip.`);
3546
+ asset = p.assets.find((a) => a.id === c.asset);
3547
+ const speed = c.speed ?? 1;
3548
+ toSource = (t) => (c.in ?? 0) + (t - c.start) * speed;
3549
+ toOut = (s) => c.start + (s - (c.in ?? 0)) / speed;
3550
+ from = Math.max(c.start, args.from ?? c.start);
3551
+ to = Math.min(videoClipEnd(c), args.to ?? videoClipEnd(c));
3552
+ } else {
3553
+ asset = p.assets.find((a) => a.id === args.asset);
3554
+ if (!asset) throw new Error(`There is no asset "${args.asset}"; the assets are ${p.assets.map((a) => `"${a.id}"`).join(", ") || "none"}.`);
3555
+ from = args.from ?? 0;
3556
+ to = args.to ?? asset.duration ?? 0;
3557
+ }
3558
+ if (!asset) throw new Error(`Clip "${args.clip}" uses a missing asset.`);
3559
+ if (asset.kind === "image" || asset.hasAudio === false) throw new Error(`Asset "${asset.id}" has no sound.`);
3560
+ if (!(to > from)) throw new Error(`The stretch to read is empty (${from}\u2013${to} ms); "to" must be after "from"${args.clip !== void 0 ? " and both inside the clip" : asset.duration === void 0 ? ', and the asset has no duration, so pass "to"' : ""}.`);
3561
+ return { asset, from, to, toSource, toOut };
3562
+ };
3563
+ const SOUND_TARGET = {
3564
+ clip: { ...CLIP_ID, description: "A media clip: read the stretch of source it plays, with times on the timeline. Pass this or asset." },
3565
+ asset: { ...ASSET_ID, description: "A video or audio asset: read its source, with times in the source. Pass this or clip." },
3566
+ from: MS("Start of the stretch (default: the clip's start, or 0 for an asset)"),
3567
+ to: MS("End of the stretch (default: the clip's end, or the asset's duration)")
3568
+ };
3569
+ const SOUND_ANNOTATIONS = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
3570
+ const audioLevels = {
3571
+ name: "get_video_audio_levels",
3572
+ title: "Read sound levels",
3573
+ description: 'Measures how loud the source sound is over a clip or an asset, without changing anything: the peak level in dBFS (0 = full scale, -100 = silence) for each of "buckets" equal stretches, plus the loudest peak. Levels are the recording as it is, before clip volume and fades. Use it to find where speech or a beat starts, or to judge whether a clip is too loud. The first call for an asset decodes its file, which can take a moment.',
3574
+ inputSchema: object({ ...SOUND_TARGET, buckets: { type: "integer", minimum: 1, maximum: 500, description: "How many equal stretches to measure (default 50)." } }),
3575
+ annotations: { title: "Read sound levels", ...SOUND_ANNOTATIONS },
3576
+ handler: async (args) => {
3577
+ const r = soundRange(args);
3578
+ const peaks = await controller.audioPeaks.load(r.asset);
3579
+ const levels = videoAudioLevels(peaks, r.toSource(r.from), r.toSource(r.to), args.buckets ?? 50).map((l) => ({ start: round(r.toOut(l.start)), end: round(r.toOut(l.end)), peakDb: l.peakDb }));
3580
+ return { asset: r.asset.id, from: r.from, to: r.to, peakDb: Math.max(...levels.map((l) => l.peakDb)), levels };
3581
+ }
3582
+ };
3583
+ const findSilences = {
3584
+ name: "find_video_silences",
3585
+ title: "Find silences",
3586
+ description: "Lists the stretches of a clip or an asset whose source sound stays below a level for at least a minimum length, as { start, end } ms (timeline times for a clip, so they can go straight to extract_video_range or split_video_at; source times for an asset). Use it to cut pauses, dead air or the gaps between takes. The first call for an asset decodes its file.",
3587
+ inputSchema: object({
3588
+ ...SOUND_TARGET,
3589
+ thresholdDb: { type: "number", minimum: -100, maximum: 0, description: "Quieter than this peak level counts as silence, in dBFS (default -40; -50 for a quiet room, -30 over background noise)." },
3590
+ minDuration: { type: "number", exclusiveMinimum: 0, description: "Shortest silence to report, in ms (default 300)." }
3591
+ }),
3592
+ annotations: { title: "Find silences", ...SOUND_ANNOTATIONS },
3593
+ handler: async (args) => {
3594
+ const r = soundRange(args);
3595
+ const peaks = await controller.audioPeaks.load(r.asset);
3596
+ const silences = findVideoAudioSilences(peaks, r.toSource(r.from), r.toSource(r.to), { thresholdDb: args.thresholdDb, minDuration: args.minDuration }).map((x) => ({ start: round(r.toOut(x.start)), end: round(r.toOut(x.end)) }));
3597
+ return { asset: r.asset.id, from: r.from, to: r.to, silences, total: round(silences.reduce((n2, x) => n2 + x.end - x.start, 0)) };
3598
+ }
3599
+ };
3600
+ const reads = [getProject, getFrame, findGaps, checkProject, renderFrame, exportFfmpeg, exportLoudnessMeasure, exportEdl, audioLevels, findSilences, seek];
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];
2776
3602
  return {
2777
3603
  id: options.id ?? "video",
2778
3604
  namespace: options.namespace,
@@ -2804,6 +3630,50 @@ function videoRulerTicks(duration, pxPerSecond, fps) {
2804
3630
  }
2805
3631
  return ticks;
2806
3632
  }
3633
+ function videoClipShowsWaveform(clip, asset, mode = "audio") {
3634
+ if (clip.type !== "media" || mode === "none" || !videoAssetHasSound(asset)) return false;
3635
+ return mode === "all" || asset.kind === "audio";
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
+ };
3670
+ var VIDEO_FADE_CURVE_LABELS = {
3671
+ linear: "Linear",
3672
+ "equal-power": "Equal power",
3673
+ "s-curve": "S-curve",
3674
+ exponential: "Exponential",
3675
+ logarithmic: "Logarithmic"
3676
+ };
2807
3677
  function videoClipTint(clip, asset) {
2808
3678
  if (clip.type === "text") return "#a855f7";
2809
3679
  if (clip.type === "color") return clip.color;
@@ -2915,7 +3785,7 @@ function videoKeyframeRows(project, clipId, time) {
2915
3785
  const { clip } = at;
2916
3786
  const asset = clip.type === "media" ? videoAssetMap(project).get(clip.asset) : void 0;
2917
3787
  const visual = !(asset?.kind === "audio" || at.track.kind === "audio");
2918
- const audible = clip.type === "media" && (asset?.kind === "audio" || asset?.kind === "video" && asset.hasAudio !== false);
3788
+ const audible = clip.type === "media" && videoAssetHasSound(asset);
2919
3789
  const properties = [...visual ? ["opacity"] : [], ...audible ? ["volume"] : [], ...visual ? VIDEO_TRANSFORM_PROPERTIES : []];
2920
3790
  const clipTime = playheadInClip(clip, time);
2921
3791
  return properties.map((property) => {
@@ -2967,40 +3837,63 @@ function videoKeyframeToggleEdit(project, clipId, property, time) {
2967
3837
  return { op: "set_keyframes", clip: clipId, property, keys: isVideoKeyframes(prop) ? setVideoKey(prop, { t, v }, fallback).keys : [{ t, v }] };
2968
3838
  }
2969
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,
2970
3845
  VIDEO_CLIP_TYPES,
2971
3846
  VIDEO_DEFAULT_STILL_DURATION,
2972
3847
  VIDEO_DEFAULT_TITLE_DURATION,
2973
3848
  VIDEO_EASE_NAMES,
2974
3849
  VIDEO_EDIT_OPS,
3850
+ VIDEO_FADE_CURVES,
3851
+ VIDEO_FADE_CURVE_LABELS,
2975
3852
  VIDEO_FITS,
3853
+ VIDEO_GRADE_CONTROLS,
3854
+ VIDEO_GRADE_PRESETS,
3855
+ VIDEO_GRADE_PRESET_NAMES,
2976
3856
  VIDEO_ISSUE_CODES,
2977
3857
  VIDEO_KEYFRAME_LIMITS,
2978
3858
  VIDEO_KEYFRAME_PROPERTIES,
3859
+ VIDEO_PEAK_BLOCK,
2979
3860
  VIDEO_PLACEMENT_MODES,
2980
3861
  VIDEO_TIME_EPSILON,
2981
3862
  VIDEO_TRANSFORM_PROPERTIES,
3863
+ VIDEO_TRANSITION_LABELS,
2982
3864
  VIDEO_TRANSITION_TYPES,
2983
3865
  VIDEO_WIPE_DIRECTIONS,
3866
+ VideoAudioPeaksCache,
2984
3867
  VideoEditorController,
2985
3868
  VideoPreviewEngine,
2986
3869
  allVideoClipIds,
2987
3870
  applyVideoEdits,
3871
+ applyVideoGrade,
3872
+ buildVideoAudioPeaks,
2988
3873
  checkVideoProject,
2989
3874
  createVideoEditorToolProvider,
2990
3875
  createVideoProject,
3876
+ decodeVideoAudioPeaks,
2991
3877
  describeVideoProject,
2992
3878
  drawVideoFrame,
2993
3879
  ffmpegFilterValue,
3880
+ findVideoAudioSilences,
2994
3881
  findVideoClip,
2995
3882
  findVideoGaps,
2996
3883
  formatVideoSeconds,
2997
3884
  formatVideoTimecode,
3885
+ formatVideoTimecodeFrames,
3886
+ isVideoDropFrameRate,
2998
3887
  isVideoKeyframes,
2999
3888
  nextFreeVideoSlot,
3889
+ normalizeVideoGrade,
3000
3890
  parseVideoAnimatable,
3001
3891
  parseVideoAsset,
3002
3892
  parseVideoClip,
3893
+ parseVideoClipAnimation,
3894
+ parseVideoGrade,
3003
3895
  parseVideoKeyframes,
3896
+ parseVideoLoudnessMeasurement,
3004
3897
  parseVideoMarker,
3005
3898
  parseVideoProject,
3006
3899
  parseVideoTrack,
@@ -3019,29 +3912,54 @@ export {
3019
3912
  splitVideoAnimatable,
3020
3913
  toVideoEdl,
3021
3914
  toVideoFfmpeg,
3915
+ toVideoLoudnessMeasure,
3022
3916
  videoAssetDropEdit,
3917
+ videoAssetHasSound,
3023
3918
  videoAssetKindOf,
3024
3919
  videoAssetMap,
3920
+ videoAudioLevels,
3921
+ videoAudioPeakRange,
3922
+ videoAudioPeakSpans,
3923
+ videoAudioPeaksDuration,
3025
3924
  videoClipAfter,
3925
+ videoClipAnimated,
3926
+ videoClipAnimationProperties,
3927
+ videoClipAnimationSpans,
3026
3928
  videoClipBefore,
3027
3929
  videoClipEnd,
3028
3930
  videoClipHandles,
3029
3931
  videoClipKeyframes,
3030
3932
  videoClipLabel,
3031
3933
  videoClipOverhang,
3934
+ videoClipShowsWaveform,
3935
+ videoClipSoundGain,
3032
3936
  videoClipSourceRange,
3033
3937
  videoClipSpeed,
3034
3938
  videoClipTint,
3939
+ videoClipWaveform,
3940
+ videoClipWaveformPath,
3035
3941
  videoClipsAt,
3036
3942
  videoContactSheetSvg,
3037
3943
  videoDragLabel,
3038
3944
  videoDropTracks,
3039
3945
  videoEaseBezier,
3040
3946
  videoEaseProgress,
3947
+ videoFadeCurveGain,
3041
3948
  videoFrameAt,
3042
3949
  videoFrameDuration,
3043
3950
  videoFrameToSvg,
3044
3951
  videoFrames,
3952
+ videoGradeCssFilter,
3953
+ videoGradeFfmpegFilters,
3954
+ videoGradeGainsFilterId,
3955
+ videoGradePreset,
3956
+ videoGradeStepMatrix,
3957
+ videoGradeStepSvgValues,
3958
+ videoGradeSteps,
3959
+ videoGradeSvgDefs,
3960
+ videoGradeSvgFilter,
3961
+ videoHourFrames,
3962
+ videoIrisRadius,
3045
3963
  videoIssueKey,
3046
3964
  videoKeyframeBreakpoints,
3047
3965
  videoKeyframeFfmpegExpr,
@@ -3050,15 +3968,21 @@ export {
3050
3968
  videoKeyframeValueEdit,
3051
3969
  videoKeysOf,
3052
3970
  videoOverlap,
3971
+ videoPeakDb,
3053
3972
  videoProjectDuration,
3054
3973
  videoProjectSvg,
3055
3974
  videoRulerTicks,
3056
3975
  videoSnapPoints,
3057
3976
  videoTextStyle,
3977
+ videoTimecodeFrames,
3058
3978
  videoTitleFrame,
3059
3979
  videoTrackSyncLocked,
3060
3980
  videoTransformAt,
3981
+ videoTransitionAudioCurve,
3982
+ videoTransitionEase,
3983
+ videoTransitionHasDirection,
3061
3984
  videoValueAt,
3985
+ videoWaveformPath,
3062
3986
  videoWipeRect
3063
3987
  };
3064
3988
  //# sourceMappingURL=index.js.map