@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.
@@ -1,4 +1,4 @@
1
- "use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; } var _class; var _class2; var _class3;
1
+ "use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; } var _class; var _class2; var _class3; var _class4;
2
2
 
3
3
  var _chunkWAJQKWQUcjs = require('../chunk-WAJQKWQU.cjs');
4
4
 
@@ -13,8 +13,12 @@ require('../chunk-2FHLHZAL.cjs');
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) => _nullishCoalesce(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 = _nullishCoalesce(g.temperature, () => ( 0));
258
+ steps.push({ kind: "gains", r: 1 + 0.25 * t, g: 1 - 0.25 * (_nullishCoalesce(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
  }
@@ -472,13 +655,13 @@ function videoClipHandles(clip, assets) {
472
655
  const speed = videoClipSpeed(clip);
473
656
  const range = videoClipSourceRange(clip);
474
657
  const before = range.in / speed;
475
- if (!asset || asset.kind === "image" || asset.duration === void 0) return { before: _optionalChain([asset, 'optionalAccess', _ => _.kind]) === "image" ? Infinity : before, after: Infinity };
658
+ if (!asset || asset.kind === "image" || asset.duration === void 0) return { before: _optionalChain([asset, 'optionalAccess', _2 => _2.kind]) === "image" ? Infinity : before, after: Infinity };
476
659
  return { before, after: Math.max(0, (asset.duration - range.out) / speed) };
477
660
  }
478
661
  function videoClipOverhang(track, clip) {
479
662
  const lead = clip.transition && clip.transition.type !== "dip" && videoClipBefore(track, clip) ? clip.transition.duration / 2 : 0;
480
663
  const next = videoClipAfter(track, clip);
481
- const tail = _optionalChain([next, 'optionalAccess', _2 => _2.transition]) && next.transition.type !== "dip" ? next.transition.duration / 2 : 0;
664
+ const tail = _optionalChain([next, 'optionalAccess', _3 => _3.transition]) && next.transition.type !== "dip" ? next.transition.duration / 2 : 0;
482
665
  return { lead, tail };
483
666
  }
484
667
  function videoSnapPoints(project, options = {}) {
@@ -534,12 +717,72 @@ 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 ? _nullishCoalesce(clip.animateIn.duration, () => ( VIDEO_CLIP_ANIMATION_DURATION)) : 0;
739
+ const b = clip.animateOut ? _nullishCoalesce(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 : _optionalChain([clip, 'access', _4 => _4.transform, 'optionalAccess', _5 => _5[prop]]);
749
+ if (isVideoKeyframes(current)) continue;
750
+ const base = _nullishCoalesce(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 = [
540
783
  { property: "opacity", keys: videoKeysOf(clip.opacity) },
541
784
  { property: "volume", keys: clip.type === "media" ? videoKeysOf(clip.volume) : [] },
542
- ...VIDEO_TRANSFORM_PROPERTIES.map((p) => ({ property: p, keys: videoKeysOf(_optionalChain([clip, 'access', _3 => _3.transform, 'optionalAccess', _4 => _4[p]])) }))
785
+ ...VIDEO_TRANSFORM_PROPERTIES.map((p) => ({ property: p, keys: videoKeysOf(_optionalChain([clip, 'access', _6 => _6.transform, 'optionalAccess', _7 => _7[p]])) }))
543
786
  ];
544
787
  return out.filter((o) => o.keys.length);
545
788
  }
@@ -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 ((_nullishCoalesce(clip.fadeIn, () => ( 0))) + (_nullishCoalesce(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 (${_nullishCoalesce(clip.fadeIn, () => ( 0))} + ${_nullishCoalesce(clip.fadeOut, () => ( 0))} ms) are longer than the clip (${clip.duration} ms); shorten them.` });
836
+ const anim = (clip.animateIn ? _nullishCoalesce(clip.animateIn.duration, () => ( VIDEO_CLIP_ANIMATION_DURATION)) : 0) + (clip.animateOut ? _nullishCoalesce(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 : _optionalChain([clip, 'access', _8 => _8.transform, 'optionalAccess', _9 => _9[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)
@@ -692,7 +947,7 @@ function clipAnimatables(clip) {
692
947
  { get: () => c.opacity, set: (v) => c.opacity = v },
693
948
  { get: () => c.volume, set: (v) => c.volume = v }
694
949
  ];
695
- for (const p of VIDEO_TRANSFORM_PROPERTIES) out.push({ get: () => _optionalChain([c, 'access', _5 => _5.transform, 'optionalAccess', _6 => _6[p]]), set: (v) => c.transform && (c.transform[p] = v) });
950
+ for (const p of VIDEO_TRANSFORM_PROPERTIES) out.push({ get: () => _optionalChain([c, 'access', _10 => _10.transform, 'optionalAccess', _11 => _11[p]]), set: (v) => c.transform && (c.transform[p] = v) });
696
951
  return out;
697
952
  }
698
953
  function retimeClipKeys(clip, fn) {
@@ -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 = class {
707
962
  constructor(p) {;_class.prototype.__init.call(this);
@@ -740,7 +995,7 @@ var Editor = (_class = class {
740
995
  kindFits(track, clip) {
741
996
  const asset = clip.type === "media" ? this.asset(clip.asset) : null;
742
997
  if (track.kind === "audio" && (clip.type !== "media" || asset.kind === "image")) refuse(`Track "${track.id}" is an audio track; ${clip.type === "media" ? "images" : `${clip.type} clips`} go on a video track.`);
743
- if (track.kind === "video" && _optionalChain([asset, 'optionalAccess', _7 => _7.kind]) === "audio") refuse(`Track "${track.id}" is a video track; audio asset "${asset.id}" goes on an audio track.`);
998
+ if (track.kind === "video" && _optionalChain([asset, 'optionalAccess', _12 => _12.kind]) === "audio") refuse(`Track "${track.id}" is a video track; audio asset "${asset.id}" goes on an audio track.`);
744
999
  }
745
1000
  /** Moves every clip on `track` starting at or after `from` by `delta`. */
746
1001
  shift(track, from, delta, except = /* @__PURE__ */ new Set()) {
@@ -782,7 +1037,7 @@ var Editor = (_class = class {
782
1037
  }
783
1038
  /** Moves a media clip's in point by `delta` of timeline time (stills, titles and colours have none). */
784
1039
  moveIn(clip, delta) {
785
- if (clip.type !== "media" || _optionalChain([videoAssetMap, 'call', _8 => _8(this.p), 'access', _9 => _9.get, 'call', _10 => _10(clip.asset), 'optionalAccess', _11 => _11.kind]) === "image") return;
1040
+ if (clip.type !== "media" || _optionalChain([videoAssetMap, 'call', _13 => _13(this.p), 'access', _14 => _14.get, 'call', _15 => _15(clip.asset), 'optionalAccess', _16 => _16.kind]) === "image") return;
786
1041
  clip.in = Math.max(0, (_nullishCoalesce(clip.in, () => ( 0))) + delta * videoClipSpeed(clip));
787
1042
  if (clip.in < 1e-9) delete clip.in;
788
1043
  }
@@ -797,6 +1052,8 @@ var Editor = (_class = class {
797
1052
  const right = { ...structuredClone(clip), id: _nullishCoalesce(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 = 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;
@@ -1044,7 +1303,7 @@ var Editor = (_class = class {
1044
1303
  if (clip.type === "media") {
1045
1304
  const asset = assets.get(clip.asset);
1046
1305
  const out = (_nullishCoalesce(clip.in, () => ( 0))) + duration * speed;
1047
- if (_optionalChain([asset, 'optionalAccess', _12 => _12.duration]) !== void 0 && out > asset.duration + VIDEO_TIME_EPSILON)
1306
+ if (_optionalChain([asset, 'optionalAccess', _17 => _17.duration]) !== void 0 && out > asset.duration + VIDEO_TIME_EPSILON)
1048
1307
  refuse(`"${clip.id}" can end at most at ${formatVideoSeconds(clip.start + (asset.duration - (_nullishCoalesce(clip.in, () => ( 0)))) / speed)}, where "${asset.id}" runs out.`);
1049
1308
  }
1050
1309
  if (e.ripple) {
@@ -1100,7 +1359,7 @@ var Editor = (_class = class {
1100
1359
  let stretch = 1;
1101
1360
  if (at.clip.type === "media" && !("duration" in set)) {
1102
1361
  const was = videoClipSpeed(at.clip);
1103
- const now = "speed" in set ? set.speed : _optionalChain([e, 'access', _13 => _13.remove, 'optionalAccess', _14 => _14.includes, 'call', _15 => _15("speed")]) ? 1 : was;
1362
+ const now = "speed" in set ? set.speed : _optionalChain([e, 'access', _18 => _18.remove, 'optionalAccess', _19 => _19.includes, 'call', _20 => _20("speed")]) ? 1 : was;
1104
1363
  if (now !== was) {
1105
1364
  set.duration = at.clip.duration * was / now;
1106
1365
  stretch = was / now;
@@ -1196,7 +1455,7 @@ var Editor = (_class = class {
1196
1455
  this.trimHead(after, delta);
1197
1456
  }
1198
1457
  track.clips.sort((a, b) => a.start - b.start);
1199
- return this.outcomes.push({ op: e.op, clip: clip.id, clips: [_optionalChain([before, 'optionalAccess', _16 => _16.id]), clip.id, _optionalChain([after, 'optionalAccess', _17 => _17.id])].filter((x) => !!x), track: track.id, delta });
1458
+ return this.outcomes.push({ op: e.op, clip: clip.id, clips: [_optionalChain([before, 'optionalAccess', _21 => _21.id]), clip.id, _optionalChain([after, 'optionalAccess', _22 => _22.id])].filter((x) => !!x), track: track.id, delta });
1200
1459
  }
1201
1460
  case "lift_range":
1202
1461
  case "extract_range": {
@@ -1227,7 +1486,7 @@ var Editor = (_class = class {
1227
1486
  const limits = VIDEO_KEYFRAME_LIMITS[e.property];
1228
1487
  const transform = VIDEO_TRANSFORM_PROPERTIES.includes(e.property);
1229
1488
  const c = clip;
1230
- const current = transform ? _optionalChain([c, 'access', _18 => _18.transform, 'optionalAccess', _19 => _19[e.property]]) : c[e.property];
1489
+ const current = transform ? _optionalChain([c, 'access', _23 => _23.transform, 'optionalAccess', _24 => _24[e.property]]) : c[e.property];
1231
1490
  let value;
1232
1491
  if (e.keys === null) {
1233
1492
  const v = videoValueAt(current, 0, limits.default);
@@ -1309,7 +1568,7 @@ function applyVideoEdits(project, edits) {
1309
1568
  editor.run(edit);
1310
1569
  } catch (error) {
1311
1570
  const message = error.message;
1312
- return { ok: false, error: `${list.length > 1 ? `op ${i + 1} (${_optionalChain([edit, 'optionalAccess', _20 => _20.op])}): ` : ""}${message}${/Nothing was changed\.$/.test(message) ? "" : " Nothing was changed."}` };
1571
+ return { ok: false, error: `${list.length > 1 ? `op ${i + 1} (${_optionalChain([edit, 'optionalAccess', _25 => _25.op])}): ` : ""}${message}${/Nothing was changed\.$/.test(message) ? "" : " Nothing was changed."}` };
1313
1572
  }
1314
1573
  }
1315
1574
  let next;
@@ -1328,7 +1587,7 @@ function applyVideoEdits(project, edits) {
1328
1587
 
1329
1588
  // src/video-editor/evaluate.ts
1330
1589
  function videoTransformAt(transform, clipTime = 0) {
1331
- return { x: videoValueAt(_optionalChain([transform, 'optionalAccess', _21 => _21.x]), clipTime, 0), y: videoValueAt(_optionalChain([transform, 'optionalAccess', _22 => _22.y]), clipTime, 0), scale: videoValueAt(_optionalChain([transform, 'optionalAccess', _23 => _23.scale]), clipTime, 1), rotation: videoValueAt(_optionalChain([transform, 'optionalAccess', _24 => _24.rotation]), clipTime, 0) };
1590
+ return { x: videoValueAt(_optionalChain([transform, 'optionalAccess', _26 => _26.x]), clipTime, 0), y: videoValueAt(_optionalChain([transform, 'optionalAccess', _27 => _27.y]), clipTime, 0), scale: videoValueAt(_optionalChain([transform, 'optionalAccess', _28 => _28.scale]), clipTime, 1), rotation: videoValueAt(_optionalChain([transform, 'optionalAccess', _29 => _29.rotation]), clipTime, 0) };
1332
1591
  }
1333
1592
  function placeVideoRect(frame, size, fit = "contain", transform = {}, clipTime = 0) {
1334
1593
  let w = size.width;
@@ -1356,19 +1615,40 @@ 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(_optionalChain([shape, 'optionalAccess', _30 => _30.fadeInCurve]), (time - clip.start) / clip.fadeIn);
1638
+ if (clip.fadeOut) g *= videoFadeCurveGain(_optionalChain([shape, 'optionalAccess', _31 => _31.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)) {
1368
1648
  const before = clip.transition ? videoClipBefore(track, clip) : null;
1369
1649
  const lead = before ? clip.transition.duration / 2 : 0;
1370
1650
  const next = videoClipAfter(track, clip);
1371
- const tail = _optionalChain([next, 'optionalAccess', _25 => _25.transition]) ? next.transition.duration / 2 : 0;
1651
+ const tail = _optionalChain([next, 'optionalAccess', _32 => _32.transition]) ? next.transition.duration / 2 : 0;
1372
1652
  if (time < clip.start - lead || time >= videoClipEnd(clip) + tail) continue;
1373
1653
  const a = { clip };
1374
1654
  if (lead && time < clip.start + lead) a.incoming = { progress: clamp01((time - (clip.start - lead)) / (2 * lead)), type: clip.transition.type, transition: clip.transition };
@@ -1385,35 +1665,55 @@ function videoFrameAt(project, time) {
1385
1665
  for (let ti = project.tracks.length - 1; ti >= 0; ti--) {
1386
1666
  const track = project.tracks[ti];
1387
1667
  for (const { clip, incoming, outgoing } of activeOnTrack(track, time)) {
1388
- if (_optionalChain([incoming, 'optionalAccess', _26 => _26.type]) === "dip" && incoming.progress < 0.5) continue;
1389
- if (_optionalChain([outgoing, 'optionalAccess', _27 => _27.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);
1668
+ if (_optionalChain([incoming, 'optionalAccess', _33 => _33.type]) === "dip" && incoming.progress < 0.5) continue;
1669
+ if (_optionalChain([outgoing, 'optionalAccess', _34 => _34.type]) === "dip" && outgoing.progress >= 0.5) continue;
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) * (_nullishCoalesce(track.volume, () => ( 1))) * fade * mix;
1679
+ const gain = videoValueAt(clip.volume, clipTime, 1) * (_nullishCoalesce(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: _nullishCoalesce(incoming.transition.direction, () => ( "right")), progress: incoming.progress };
1409
- else dip = { color: _nullishCoalesce(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 = _nullishCoalesce(incoming.transition.direction, () => ( "right"));
1697
+ shift = videoTransitionEase(incoming.progress) - 1;
1698
+ } else dip = { color: _nullishCoalesce(incoming.transition.color, () => ( "#000000")), opacity: (1 - incoming.progress) * 2 };
1699
+ }
1700
+ if (_optionalChain([outgoing, 'optionalAccess', _35 => _35.type]) === "dip") dip = { color: _nullishCoalesce(outgoing.next.transition.color, () => ( "#000000")), opacity: outgoing.progress * 2 };
1701
+ if (_optionalChain([outgoing, 'optionalAccess', _36 => _36.type]) === "push") {
1702
+ shiftDirection = _nullishCoalesce(outgoing.next.transition.direction, () => ( "right"));
1703
+ shift = videoTransitionEase(outgoing.progress);
1410
1704
  }
1411
- if (_optionalChain([outgoing, 'optionalAccess', _28 => _28.type]) === "dip") dip = { color: _nullishCoalesce(outgoing.next.transition.color, () => ( "#000000")), opacity: outgoing.progress * 2 };
1412
1705
  const size = clip.type === "media" ? { width: _nullishCoalesce(asset.width, () => ( project.width)), height: _nullishCoalesce(asset.height, () => ( project.height)) } : { width: project.width, height: project.height };
1413
1706
  const fit = _nullishCoalesce(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(_optionalChain([clip, 'access', _29 => _29.transform, 'optionalAccess', _30 => _30.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(_optionalChain([shown, 'access', _37 => _37.transform, 'optionalAccess', _38 => _38.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: _nullishCoalesce(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 = _nullishCoalesce(clip.speed, () => ( 1));
1812
+ const start = _nullishCoalesce(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 = _nullishCoalesce(options.thresholdDb, () => ( -40));
1847
+ const minDuration = _nullishCoalesce(options.minDuration, () => ( 300));
1848
+ const resolution = Math.max(1, _nullishCoalesce(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 (e2) {
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 = (_class2 = class {
1879
+ constructor(loader = (asset) => decodeVideoAudioPeaks(asset.src)) {;_class2.prototype.__init2.call(this);_class2.prototype.__init3.call(this);_class2.prototype.__init4.call(this);_class2.prototype.__init5.call(this);
1880
+ this.loader = loader;
1881
+ }
1882
+
1883
+ __init2() {this.ready = /* @__PURE__ */ new Map()}
1884
+ __init3() {this.pending = /* @__PURE__ */ new Map()}
1885
+ __init4() {this.failed = /* @__PURE__ */ new Map()}
1886
+ __init5() {this.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
+ }, _class2);
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);
@@ -1557,20 +2097,22 @@ function drawVideoFrame(ctx, frame, sourceFor, scale = 1) {
1557
2097
  }
1558
2098
 
1559
2099
  // src/video-editor/export.ts
1560
- var reelName = (asset) => (_nullishCoalesce(_nullishCoalesce(_optionalChain([asset, 'optionalAccess', _31 => _31.name]), () => ( _optionalChain([asset, 'optionalAccess', _32 => _32.id]))), () => ( "AX"))).replace(/\.[a-z0-9]+$/i, "").replace(/[^A-Za-z0-9]/g, "").toUpperCase().slice(0, 8) || "AX";
2100
+ var reelName = (asset) => (_nullishCoalesce(_nullishCoalesce(_optionalChain([asset, 'optionalAccess', _39 => _39.name]), () => ( _optionalChain([asset, 'optionalAccess', _40 => _40.id]))), () => ( "AX"))).replace(/\.[a-z0-9]+$/i, "").replace(/[^A-Za-z0-9]/g, "").toUpperCase().slice(0, 8) || "AX";
1561
2101
  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 = _nullishCoalesce(options.recordStart, () => ( 36e5));
2105
+ const dropFrame = (_nullishCoalesce(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: ${(_nullishCoalesce(_nullishCoalesce(options.title, () => ( project.name)), () => ( "Untitled"))).toUpperCase()}`, "FCM: NON-DROP FRAME", ""];
2111
+ const lines = [`TITLE: ${(_nullishCoalesce(_nullishCoalesce(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" ? (_nullishCoalesce(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,17 +2129,58 @@ 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
- if (clip.type === "media") lines.push(`* FROM CLIP NAME: ${_nullishCoalesce(_nullishCoalesce(_optionalChain([asset, 'optionalAccess', _33 => _33.name]), () => ( _optionalChain([asset, 'optionalAccess', _34 => _34.src]))), () => ( clip.asset))}`);
2135
+ if (clip.type === "media") lines.push(`* FROM CLIP NAME: ${_nullishCoalesce(_nullishCoalesce(_optionalChain([asset, 'optionalAccess', _41 => _41.name]), () => ( _optionalChain([asset, 'optionalAccess', _42 => _42.src]))), () => ( clip.asset))}`);
1594
2136
  if (clip.type === "text") lines.push(`* TITLE: ${clip.text.replace(/\n/g, " ")}`);
1595
2137
  if (clip.type === "color") lines.push(`* COLOR: ${clip.color}`);
1596
2138
  lines.push("");
1597
2139
  }
1598
2140
  return lines.join("\n");
1599
2141
  }
2142
+ function loudnormFilter(target = {}, measure = false) {
2143
+ const I = _nullishCoalesce(target.integrated, () => ( -14));
2144
+ const TP = _nullishCoalesce(target.truePeak, () => ( -1));
2145
+ const LRA = _nullishCoalesce(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(_nullishCoalesce(project.background, () => ( "#000000")))}:s=${W}x${H}:r=${fps}:d=${sec(D)},format=yuva420p[base]`];
2222
+ const graph = measure ? [] : [`color=c=${color(_nullishCoalesce(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,32 +2252,34 @@ 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 = _nullishCoalesce(index, () => ( input(clip, asset, lead, tail)));
1668
2259
  const trackGain = _nullishCoalesce(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=${(_nullishCoalesce(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 && _optionalChain([c, 'access', _35 => _35.transition, 'optionalAccess', _36 => _36.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 (_optionalChain([next2, 'optionalAccess', _43 => _43.transition, 'access', _44 => _44.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)})`;
1687
2280
  const onChain = (v) => `(${v}*1000${lead ? `-${Math.round(lead)}` : ""})`;
1688
2281
  const tf = clip.transform;
1689
- const moving = VIDEO_TRANSFORM_PROPERTIES.some((p) => isVideoKeyframes(_optionalChain([tf, 'optionalAccess', _37 => _37[p]])));
2282
+ const moving = VIDEO_TRANSFORM_PROPERTIES.some((p) => isVideoKeyframes(_optionalChain([tf, 'optionalAccess', _45 => _45[p]])));
1690
2283
  if (clip.type === "text" && options.titleImage) {
1691
2284
  inputs.push(["-loop", "1", "-framerate", String(fps), "-t", sec(span), "-i", options.titleImage(moving ? { ...clip, transform: void 0 } : clip, { width: project.width, height: project.height })]);
1692
2285
  index = inputs.length - 1;
@@ -1695,7 +2288,7 @@ function toVideoFfmpeg(project, options = {}) {
1695
2288
  const op = isVideoKeyframes(clip.opacity) ? `clip(${expr(clip.opacity, onTimeline, 1, "opacity")},0,1)` : String(_nullishCoalesce(clip.opacity, () => ( 1)));
1696
2289
  const alpha = [op, clip.fadeIn ? `min(1,(t-${sec(clip.start)})/${sec(clip.fadeIn)})` : "", clip.fadeOut ? `min(1,(${sec(end)}-t)/${sec(clip.fadeOut)})` : ""].filter(Boolean).join("*");
1697
2290
  const coord = (axis) => {
1698
- const v = _optionalChain([tf, 'optionalAccess', _38 => _38[axis]]);
2291
+ const v = _optionalChain([tf, 'optionalAccess', _46 => _46[axis]]);
1699
2292
  const base = `(${axis === "x" ? "w-text_w" : "h-text_h"})/2`;
1700
2293
  return isVideoKeyframes(v) ? `${axis}='${base}+(${expr(v, onTimeline, 0, "position")})'` : `${axis}=${base}+${_nullishCoalesce(v, () => ( 0))}`;
1701
2294
  };
@@ -1703,7 +2296,7 @@ function toVideoFfmpeg(project, options = {}) {
1703
2296
  const opts = [
1704
2297
  `text=${ffmpegFilterValue(clip.text)}`,
1705
2298
  options.fontFile ? `fontfile=${ffmpegFilterValue(options.fontFile)}` : `font=${ffmpegFilterValue(_nullishCoalesce(clip.fontFamily, () => ( "sans-serif")))}`,
1706
- isVideoKeyframes(_optionalChain([tf, 'optionalAccess', _39 => _39.scale])) ? `fontsize='${fontSize}*(${expr(tf.scale, onTimeline, 1, "scale")})'` : `fontsize=${Math.round(fontSize * (_nullishCoalesce(_optionalChain([tf, 'optionalAccess', _40 => _40.scale]), () => ( 1))))}`,
2299
+ isVideoKeyframes(_optionalChain([tf, 'optionalAccess', _47 => _47.scale])) ? `fontsize='${fontSize}*(${expr(tf.scale, onTimeline, 1, "scale")})'` : `fontsize=${Math.round(fontSize * (_nullishCoalesce(_optionalChain([tf, 'optionalAccess', _48 => _48.scale]), () => ( 1))))}`,
1707
2300
  `fontcolor=${color(_nullishCoalesce(clip.color, () => ( "#ffffff")))}`,
1708
2301
  coord("x"),
1709
2302
  coord("y"),
@@ -1711,7 +2304,9 @@ function toVideoFfmpeg(project, options = {}) {
1711
2304
  ...clip.background ? ["box=1", `boxcolor=${color(clip.background)}`, `boxborderw=${Math.round((_nullishCoalesce(clip.fontSize, () => ( 64))) / 2)}`] : [],
1712
2305
  `enable='between(t,${sec(clip.start)},${sec(end)})'`
1713
2306
  ];
1714
- if (_optionalChain([tf, 'optionalAccess', _41 => _41.rotation])) notes.push(`Title "${clip.id}" is drawn without its rotation (drawtext cannot rotate).`);
2307
+ if (_optionalChain([tf, 'optionalAccess', _49 => _49.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(_optionalChain([placed, 'optionalAccess', _42 => _42.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
  }
1747
- const rotation = _optionalChain([placed, 'optionalAccess', _43 => _43.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)`);
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
+ }
2344
+ const rotation = _optionalChain([placed, 'optionalAccess', _50 => _50.rotation]);
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(_optionalChain([placed, 'optionalAccess', _51 => _51.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 = _nullishCoalesce(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 (_optionalChain([after, 'optionalAccess', _52 => _52.transition, 'access', _53 => _53.type]) === "push") shiftBy(after.transition.direction, end - tail, 2 * tail, false);
1753
2367
  const at = (axis, c) => {
1754
- const v = _optionalChain([placed, 'optionalAccess', _44 => _44[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'`;
2368
+ const v = _optionalChain([placed, 'optionalAccess', _54 => _54[axis]]);
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]`);
1772
- if (project.tracks.some((t) => t.clips.some((c) => c.type === "media" && _optionalChain([assets, 'access', _45 => _45.get, 'call', _46 => _46(c.asset), 'optionalAccess', _47 => _47.kind]) === "video" && _optionalChain([assets, 'access', _48 => _48.get, 'call', _49 => _49(c.asset), 'optionalAccess', _50 => _50.hasAudio]) === void 0)))
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.");
2402
+ if (project.tracks.some((t) => t.clips.some((c) => c.type === "media" && _optionalChain([assets, 'access', _55 => _55.get, 'call', _56 => _56(c.asset), 'optionalAccess', _57 => _57.kind]) === "video" && _optionalChain([assets, 'access', _58 => _58.get, 'call', _59 => _59(c.asset), 'optionalAccess', _60 => _60.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(),
@@ -1798,12 +2433,12 @@ function toVideoFfmpeg(project, options = {}) {
1798
2433
  }
1799
2434
 
1800
2435
  // src/video-editor/preview.ts
1801
- var VideoPreviewEngine = (_class2 = class {
1802
- constructor(source, canvas, options = {}) {;_class2.prototype.__init2.call(this);_class2.prototype.__init3.call(this);_class2.prototype.__init4.call(this);_class2.prototype.__init5.call(this);_class2.prototype.__init6.call(this);
2436
+ var VideoPreviewEngine = (_class3 = class {
2437
+ constructor(source, canvas, options = {}) {;_class3.prototype.__init6.call(this);_class3.prototype.__init7.call(this);_class3.prototype.__init8.call(this);_class3.prototype.__init9.call(this);_class3.prototype.__init10.call(this);_class3.prototype.__init11.call(this);_class3.prototype.__init12.call(this);_class3.prototype.__init13.call(this);
1803
2438
  this.source = source;
1804
2439
  this.options = options;
1805
2440
  this.canvas = canvas;
1806
- this.ctx = options.context !== void 0 ? options.context : _nullishCoalesce(_optionalChain([canvas, 'access', _51 => _51.getContext, 'optionalCall', _52 => _52("2d")]), () => ( null));
2441
+ this.ctx = options.context !== void 0 ? options.context : _nullishCoalesce(_optionalChain([canvas, 'access', _61 => _61.getContext, 'optionalCall', _62 => _62("2d")]), () => ( null));
1807
2442
  this.unsubscribe = source.subscribe(() => this.update());
1808
2443
  this.update();
1809
2444
  }
@@ -1811,11 +2446,13 @@ var VideoPreviewEngine = (_class2 = class {
1811
2446
 
1812
2447
 
1813
2448
 
1814
- __init2() {this.media = /* @__PURE__ */ new Map()}
1815
- __init3() {this.images = /* @__PURE__ */ new Map()}
2449
+ __init6() {this.media = /* @__PURE__ */ new Map()}
2450
+ __init7() {this.images = /* @__PURE__ */ new Map()}
1816
2451
 
1817
- __init4() {this.scheduled = false}
1818
- __init5() {this.destroyed = false}
2452
+ __init8() {this.scheduled = false}
2453
+ __init9() {this.destroyed = false}
2454
+ __init10() {this.filterHost = null}
2455
+ __init11() {this.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);
@@ -1851,8 +2488,8 @@ var VideoPreviewEngine = (_class2 = class {
1851
2488
  entry.media.pause();
1852
2489
  entry.media.removeEventListener("seeked", entry.onReady);
1853
2490
  entry.media.removeEventListener("loadeddata", entry.onReady);
1854
- _optionalChain([entry, 'access', _53 => _53.media, 'access', _54 => _54.removeAttribute, 'optionalCall', _55 => _55("src")]);
1855
- _optionalChain([entry, 'access', _56 => _56.media, 'access', _57 => _57.load, 'optionalCall', _58 => _58()]);
2491
+ _optionalChain([entry, 'access', _63 => _63.media, 'access', _64 => _64.removeAttribute, 'optionalCall', _65 => _65("src")]);
2492
+ _optionalChain([entry, 'access', _66 => _66.media, 'access', _67 => _67.load, 'optionalCall', _68 => _68()]);
1856
2493
  this.media.delete(entry.clip);
1857
2494
  }
1858
2495
  /** Brings the media elements in step with the editor and draws the frame. */
@@ -1885,7 +2522,7 @@ var VideoPreviewEngine = (_class2 = class {
1885
2522
  for (const layer of frame.layers) {
1886
2523
  if (layer.type !== "media" || layer.assetKind !== "video" || !layer.src) continue;
1887
2524
  const sound = sounding.get(layer.clip);
1888
- sync(layer.clip, layer.src, "video", _nullishCoalesce(layer.sourceTime, () => ( 0)), _nullishCoalesce(_optionalChain([sound, 'optionalAccess', _59 => _59.speed]), () => ( 1)), sound ? sound.gain : null);
2525
+ sync(layer.clip, layer.src, "video", _nullishCoalesce(layer.sourceTime, () => ( 0)), _nullishCoalesce(_optionalChain([sound, 'optionalAccess', _69 => _69.speed]), () => ( 1)), sound ? sound.gain : null);
1889
2526
  sounding.delete(layer.clip);
1890
2527
  }
1891
2528
  for (const a of sounding.values()) sync(a.clip, a.src, "audio", a.sourceTime, a.speed, a.gain);
@@ -1900,7 +2537,7 @@ var VideoPreviewEngine = (_class2 = class {
1900
2537
  if (!this.destroyed) this.draw(videoFrameAt(this.source.getState().project, this.source.getState().time));
1901
2538
  });
1902
2539
  }
1903
- __init6() {this.sourceFor = (layer) => {
2540
+ __init12() {this.sourceFor = (layer) => {
1904
2541
  if (layer.assetKind === "image" && layer.src) {
1905
2542
  const img = this.image(layer.src);
1906
2543
  return img.complete ? img : null;
@@ -1917,35 +2554,57 @@ var VideoPreviewEngine = (_class2 = 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);
1921
- _optionalChain([this, 'access', _60 => _60.options, 'access', _61 => _61.onFrame, 'optionalCall', _62 => _62(frame)]);
1922
- }
2557
+ drawVideoFrame(this.ctx, frame, this.sourceFor, resolution, { defineSvgFilter: this.defineSvgFilter });
2558
+ _optionalChain([this, 'access', _70 => _70.options, 'access', _71 => _71.onFrame, 'optionalCall', _72 => _72(frame)]);
2559
+ }
2560
+ __init13() {this.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
+ _optionalChain([this, 'access', _73 => _73.filterHost, 'optionalAccess', _74 => _74.remove, 'call', _75 => _75()]);
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();
1929
2585
  }
1930
- }, _class2);
2586
+ }, _class3);
1931
2587
 
1932
2588
  // src/video-editor/controller.ts
1933
- var VideoEditorController = (_class3 = class {
2589
+ var VideoEditorController = (_class4 = class {
2590
+
1934
2591
 
2592
+ /** Sound levels per asset, loaded on demand for waveforms and agents. */
1935
2593
 
1936
2594
 
1937
2595
 
1938
- __init7() {this.selection = []}
2596
+ __init14() {this.selection = []}
1939
2597
 
1940
- __init8() {this.error = null}
1941
- __init9() {this.revision = 0}
1942
- __init10() {this.applying = false}
2598
+ __init15() {this.error = null}
2599
+ __init16() {this.revision = 0}
2600
+ __init17() {this.applying = false}
1943
2601
 
1944
- __init11() {this.listeners = /* @__PURE__ */ new Set()}
1945
- __init12() {this.unsubscribers = []}
2602
+ __init18() {this.listeners = /* @__PURE__ */ new Set()}
2603
+ __init19() {this.unsubscribers = []}
1946
2604
 
1947
- constructor(options = {}) {;_class3.prototype.__init7.call(this);_class3.prototype.__init8.call(this);_class3.prototype.__init9.call(this);_class3.prototype.__init10.call(this);_class3.prototype.__init11.call(this);_class3.prototype.__init12.call(this);_class3.prototype.__init13.call(this);_class3.prototype.__init14.call(this);
2605
+ constructor(options = {}) {;_class4.prototype.__init14.call(this);_class4.prototype.__init15.call(this);_class4.prototype.__init16.call(this);_class4.prototype.__init17.call(this);_class4.prototype.__init18.call(this);_class4.prototype.__init19.call(this);_class4.prototype.__init20.call(this);_class4.prototype.__init21.call(this);
1948
2606
  this.history = _nullishCoalesce(options.history, () => ( new (0, _chunkQKLRAA4Tcjs.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 = _nullishCoalesce(options.readOnly, () => ( false));
@@ -1968,8 +2627,8 @@ var VideoEditorController = (_class3 = class {
1968
2627
  this.player.pause();
1969
2628
  this.unsubscribers.splice(0).forEach((u) => u());
1970
2629
  }
1971
- __init13() {this.getState = () => this.state}
1972
- __init14() {this.subscribe = (listener) => {
2630
+ __init20() {this.getState = () => this.state}
2631
+ __init21() {this.subscribe = (listener) => {
1973
2632
  this.connect();
1974
2633
  this.listeners.add(listener);
1975
2634
  return () => {
@@ -2025,7 +2684,7 @@ var VideoEditorController = (_class3 = class {
2025
2684
  this.applying = false;
2026
2685
  }
2027
2686
  this.notify();
2028
- _optionalChain([this, 'access', _63 => _63.onChange, 'optionalCall', _64 => _64(project)]);
2687
+ _optionalChain([this, 'access', _76 => _76.onChange, 'optionalCall', _77 => _77(project)]);
2029
2688
  }
2030
2689
  commit(label, next) {
2031
2690
  const before = this.project;
@@ -2089,7 +2748,7 @@ var VideoEditorController = (_class3 = class {
2089
2748
  }
2090
2749
  /** Selects clips (unknown ids are dropped). `add` toggles them into the current selection. */
2091
2750
  select(clips, options = {}) {
2092
- const known = _nullishCoalesce(_optionalChain([clips, 'optionalAccess', _65 => _65.filter, 'call', _66 => _66((id) => findVideoClip(this.project, id))]), () => ( []));
2751
+ const known = _nullishCoalesce(_optionalChain([clips, 'optionalAccess', _78 => _78.filter, 'call', _79 => _79((id) => findVideoClip(this.project, id))]), () => ( []));
2093
2752
  let next;
2094
2753
  if (options.add) {
2095
2754
  next = [...this.selection];
@@ -2162,13 +2821,18 @@ var VideoEditorController = (_class3 = 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);
2168
2831
  }
2169
- }, _class3);
2832
+ }, _class4);
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).` },
@@ -2354,14 +3038,14 @@ function createVideoEditorToolProvider(controller, options = {}) {
2354
3038
  return null;
2355
3039
  };
2356
3040
  const placed = (o) => ({
2357
- ..._optionalChain([o, 'access', _67 => _67.created, 'optionalAccess', _68 => _68.length]) ? { created: o.created } : {},
2358
- ..._optionalChain([o, 'access', _69 => _69.trimmed, 'optionalAccess', _70 => _70.length]) ? { trimmed: o.trimmed } : {},
2359
- ..._optionalChain([o, 'access', _71 => _71.removed, 'optionalAccess', _72 => _72.length]) ? { removed: o.removed } : {},
3041
+ ..._optionalChain([o, 'access', _80 => _80.created, 'optionalAccess', _81 => _81.length]) ? { created: o.created } : {},
3042
+ ..._optionalChain([o, 'access', _82 => _82.trimmed, 'optionalAccess', _83 => _83.length]) ? { trimmed: o.trimmed } : {},
3043
+ ..._optionalChain([o, 'access', _84 => _84.removed, 'optionalAccess', _85 => _85.length]) ? { removed: o.removed } : {},
2360
3044
  ...o.rippled ? { rippled: o.rippled } : {}
2361
3045
  });
2362
3046
  const keysOut = (p, clip, property) => {
2363
3047
  const c = clipOut(p, clip);
2364
- const value = VIDEO_TRANSFORM_PROPERTIES.includes(property) ? _optionalChain([c, 'optionalAccess', _73 => _73.transform, 'optionalAccess', _74 => _74[property]]) : _optionalChain([c, 'optionalAccess', _75 => _75[property]]);
3048
+ const value = VIDEO_TRANSFORM_PROPERTIES.includes(property) ? _optionalChain([c, 'optionalAccess', _86 => _86.transform, 'optionalAccess', _87 => _87[property]]) : _optionalChain([c, 'optionalAccess', _88 => _88[property]]);
2365
3049
  return typeof value === "object" ? { keys: value.keys } : { keys: [], value: _nullishCoalesce(value, () => ( null)) };
2366
3050
  };
2367
3051
  const getProject = {
@@ -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" && _optionalChain([p, 'access', _89 => _89.assets, 'access', _90 => _90.find, 'call', _91 => _91((a) => a.id === clip.asset), 'optionalAccess', _92 => _92.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, _nullishCoalesce(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: _nullishCoalesce(_optionalChain([findVideoClip, 'call', _93 => _93(r.project, id), 'optionalAccess', _94 => _94.clip, 'access', _95 => _95.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: _nullishCoalesce(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 = _nullishCoalesce(c.speed, () => ( 1));
3548
+ toSource = (t) => (_nullishCoalesce(c.in, () => ( 0))) + (t - c.start) * speed;
3549
+ toOut = (s) => c.start + (s - (_nullishCoalesce(c.in, () => ( 0)))) / speed;
3550
+ from = Math.max(c.start, _nullishCoalesce(args.from, () => ( c.start)));
3551
+ to = Math.min(videoClipEnd(c), _nullishCoalesce(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 = _nullishCoalesce(args.from, () => ( 0));
3556
+ to = _nullishCoalesce(_nullishCoalesce(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), _nullishCoalesce(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: _nullishCoalesce(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;
@@ -2814,7 +3684,7 @@ function videoClipLabel(clip, asset) {
2814
3684
  if (clip.name) return clip.name;
2815
3685
  if (clip.type === "text") return clip.text.split("\n")[0];
2816
3686
  if (clip.type === "color") return clip.color;
2817
- const base = _nullishCoalesce(_nullishCoalesce(_optionalChain([asset, 'optionalAccess', _76 => _76.name]), () => ( _optionalChain([asset, 'optionalAccess', _77 => _77.src, 'access', _78 => _78.replace, 'call', _79 => _79(/^.*[\\/]/, "")]))), () => ( clip.asset));
3687
+ const base = _nullishCoalesce(_nullishCoalesce(_optionalChain([asset, 'optionalAccess', _96 => _96.name]), () => ( _optionalChain([asset, 'optionalAccess', _97 => _97.src, 'access', _98 => _98.replace, 'call', _99 => _99(/^.*[\\/]/, "")]))), () => ( clip.asset));
2818
3688
  const speed = videoClipSpeed(clip);
2819
3689
  return speed !== 1 ? `${base} (${Math.round(speed * 100)}%)` : base;
2820
3690
  }
@@ -2858,7 +3728,7 @@ function videoAssetDropEdit(project, assetId, options = {}) {
2858
3728
  const asset = videoAssetMap(project).get(assetId);
2859
3729
  if (!asset) return null;
2860
3730
  const kind = asset.kind === "audio" ? "audio" : "video";
2861
- const track = _nullishCoalesce(options.track, () => ( _optionalChain([project, 'access', _80 => _80.tracks, 'access', _81 => _81.find, 'call', _82 => _82((t) => t.kind === kind && !t.locked), 'optionalAccess', _83 => _83.id])));
3731
+ const track = _nullishCoalesce(options.track, () => ( _optionalChain([project, 'access', _100 => _100.tracks, 'access', _101 => _101.find, 'call', _102 => _102((t) => t.kind === kind && !t.locked), 'optionalAccess', _103 => _103.id])));
2862
3732
  if (!track) return null;
2863
3733
  return { op: "add_clip", track, clip: { type: "media", asset: asset.id, ...options.start !== void 0 ? { start: options.start } : {} }, ...options.ripple ? { ripple: true } : {}, ...options.mode && options.mode !== "refuse" ? { mode: options.mode } : {} };
2864
3734
  }
@@ -2907,15 +3777,15 @@ var KEY_LABELS = {
2907
3777
  scale: { label: "Scale", step: 0.05 },
2908
3778
  rotation: { label: "Rotation", step: 1 }
2909
3779
  };
2910
- var animatableOf = (clip, property) => VIDEO_TRANSFORM_PROPERTIES.includes(property) ? _optionalChain([clip, 'access', _84 => _84.transform, 'optionalAccess', _85 => _85[property]]) : property === "volume" ? clip.type === "media" ? clip.volume : void 0 : clip.opacity;
3780
+ var animatableOf = (clip, property) => VIDEO_TRANSFORM_PROPERTIES.includes(property) ? _optionalChain([clip, 'access', _104 => _104.transform, 'optionalAccess', _105 => _105[property]]) : property === "volume" ? clip.type === "media" ? clip.volume : void 0 : clip.opacity;
2911
3781
  var playheadInClip = (clip, time) => Math.round(Math.min(clip.duration, Math.max(0, time - clip.start)));
2912
3782
  function videoKeyframeRows(project, clipId, time) {
2913
3783
  const at = findVideoClip(project, clipId);
2914
3784
  if (!at) return [];
2915
3785
  const { clip } = at;
2916
3786
  const asset = clip.type === "media" ? videoAssetMap(project).get(clip.asset) : void 0;
2917
- const visual = !(_optionalChain([asset, 'optionalAccess', _86 => _86.kind]) === "audio" || at.track.kind === "audio");
2918
- const audible = clip.type === "media" && (_optionalChain([asset, 'optionalAccess', _87 => _87.kind]) === "audio" || _optionalChain([asset, 'optionalAccess', _88 => _88.kind]) === "video" && asset.hasAudio !== false);
3787
+ const visual = !(_optionalChain([asset, 'optionalAccess', _106 => _106.kind]) === "audio" || at.track.kind === "audio");
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) => {
@@ -3060,5 +3930,59 @@ function videoKeyframeToggleEdit(project, clipId, property, time) {
3060
3930
 
3061
3931
 
3062
3932
 
3063
- exports.VIDEO_CLIP_TYPES = VIDEO_CLIP_TYPES; exports.VIDEO_DEFAULT_STILL_DURATION = VIDEO_DEFAULT_STILL_DURATION; exports.VIDEO_DEFAULT_TITLE_DURATION = VIDEO_DEFAULT_TITLE_DURATION; exports.VIDEO_EASE_NAMES = VIDEO_EASE_NAMES; exports.VIDEO_EDIT_OPS = VIDEO_EDIT_OPS; exports.VIDEO_FITS = VIDEO_FITS; exports.VIDEO_ISSUE_CODES = VIDEO_ISSUE_CODES; exports.VIDEO_KEYFRAME_LIMITS = VIDEO_KEYFRAME_LIMITS; exports.VIDEO_KEYFRAME_PROPERTIES = VIDEO_KEYFRAME_PROPERTIES; exports.VIDEO_PLACEMENT_MODES = VIDEO_PLACEMENT_MODES; exports.VIDEO_TIME_EPSILON = VIDEO_TIME_EPSILON; exports.VIDEO_TRANSFORM_PROPERTIES = VIDEO_TRANSFORM_PROPERTIES; exports.VIDEO_TRANSITION_TYPES = VIDEO_TRANSITION_TYPES; exports.VIDEO_WIPE_DIRECTIONS = VIDEO_WIPE_DIRECTIONS; exports.VideoEditorController = VideoEditorController; exports.VideoPreviewEngine = VideoPreviewEngine; exports.allVideoClipIds = allVideoClipIds; exports.applyVideoEdits = applyVideoEdits; exports.checkVideoProject = checkVideoProject; exports.createVideoEditorToolProvider = createVideoEditorToolProvider; exports.createVideoProject = createVideoProject; exports.describeVideoProject = describeVideoProject; exports.drawVideoFrame = drawVideoFrame; exports.ffmpegFilterValue = ffmpegFilterValue; exports.findVideoClip = findVideoClip; exports.findVideoGaps = findVideoGaps; exports.formatVideoSeconds = formatVideoSeconds; exports.formatVideoTimecode = formatVideoTimecode; exports.isVideoKeyframes = isVideoKeyframes; exports.nextFreeVideoSlot = nextFreeVideoSlot; exports.parseVideoAnimatable = parseVideoAnimatable; exports.parseVideoAsset = parseVideoAsset; exports.parseVideoClip = parseVideoClip; exports.parseVideoKeyframes = parseVideoKeyframes; exports.parseVideoMarker = parseVideoMarker; exports.parseVideoProject = parseVideoProject; exports.parseVideoTrack = parseVideoTrack; exports.parseVideoTransform = parseVideoTransform; exports.parseVideoTransition = parseVideoTransition; exports.placeVideoRect = placeVideoRect; exports.planVideoClipDrag = planVideoClipDrag; exports.probeVideoMedia = probeVideoMedia; exports.removeVideoKey = removeVideoKey; exports.retimeVideoAnimatable = retimeVideoAnimatable; exports.setVideoKey = setVideoKey; exports.snapToVideoFrame = snapToVideoFrame; exports.snapVideoTime = snapVideoTime; exports.sortVideoKeys = sortVideoKeys; exports.sortedVideoClips = sortedVideoClips; exports.splitVideoAnimatable = splitVideoAnimatable; exports.toVideoEdl = toVideoEdl; exports.toVideoFfmpeg = toVideoFfmpeg; exports.videoAssetDropEdit = videoAssetDropEdit; exports.videoAssetKindOf = videoAssetKindOf; exports.videoAssetMap = videoAssetMap; exports.videoClipAfter = videoClipAfter; exports.videoClipBefore = videoClipBefore; exports.videoClipEnd = videoClipEnd; exports.videoClipHandles = videoClipHandles; exports.videoClipKeyframes = videoClipKeyframes; exports.videoClipLabel = videoClipLabel; exports.videoClipOverhang = videoClipOverhang; exports.videoClipSourceRange = videoClipSourceRange; exports.videoClipSpeed = videoClipSpeed; exports.videoClipTint = videoClipTint; exports.videoClipsAt = videoClipsAt; exports.videoContactSheetSvg = videoContactSheetSvg; exports.videoDragLabel = videoDragLabel; exports.videoDropTracks = videoDropTracks; exports.videoEaseBezier = videoEaseBezier; exports.videoEaseProgress = videoEaseProgress; exports.videoFrameAt = videoFrameAt; exports.videoFrameDuration = videoFrameDuration; exports.videoFrameToSvg = videoFrameToSvg; exports.videoFrames = videoFrames; exports.videoIssueKey = videoIssueKey; exports.videoKeyframeBreakpoints = videoKeyframeBreakpoints; exports.videoKeyframeFfmpegExpr = videoKeyframeFfmpegExpr; exports.videoKeyframeRows = videoKeyframeRows; exports.videoKeyframeToggleEdit = videoKeyframeToggleEdit; exports.videoKeyframeValueEdit = videoKeyframeValueEdit; exports.videoKeysOf = videoKeysOf; exports.videoOverlap = videoOverlap; exports.videoProjectDuration = videoProjectDuration; exports.videoProjectSvg = videoProjectSvg; exports.videoRulerTicks = videoRulerTicks; exports.videoSnapPoints = videoSnapPoints; exports.videoTextStyle = videoTextStyle; exports.videoTitleFrame = videoTitleFrame; exports.videoTrackSyncLocked = videoTrackSyncLocked; exports.videoTransformAt = videoTransformAt; exports.videoValueAt = videoValueAt; exports.videoWipeRect = videoWipeRect;
3933
+
3934
+
3935
+
3936
+
3937
+
3938
+
3939
+
3940
+
3941
+
3942
+
3943
+
3944
+
3945
+
3946
+
3947
+
3948
+
3949
+
3950
+
3951
+
3952
+
3953
+
3954
+
3955
+
3956
+
3957
+
3958
+
3959
+
3960
+
3961
+
3962
+
3963
+
3964
+
3965
+
3966
+
3967
+
3968
+
3969
+
3970
+
3971
+
3972
+
3973
+
3974
+
3975
+
3976
+
3977
+
3978
+
3979
+
3980
+
3981
+
3982
+
3983
+
3984
+
3985
+
3986
+
3987
+ exports.VIDEO_BLEND_MODES = VIDEO_BLEND_MODES; exports.VIDEO_BLEND_MODE_LABELS = VIDEO_BLEND_MODE_LABELS; exports.VIDEO_CLIP_ANIMATION_DURATION = VIDEO_CLIP_ANIMATION_DURATION; exports.VIDEO_CLIP_ANIMATION_EFFECTS = VIDEO_CLIP_ANIMATION_EFFECTS; exports.VIDEO_CLIP_ANIMATION_LABELS = VIDEO_CLIP_ANIMATION_LABELS; exports.VIDEO_CLIP_TYPES = VIDEO_CLIP_TYPES; exports.VIDEO_DEFAULT_STILL_DURATION = VIDEO_DEFAULT_STILL_DURATION; exports.VIDEO_DEFAULT_TITLE_DURATION = VIDEO_DEFAULT_TITLE_DURATION; exports.VIDEO_EASE_NAMES = VIDEO_EASE_NAMES; exports.VIDEO_EDIT_OPS = VIDEO_EDIT_OPS; exports.VIDEO_FADE_CURVES = VIDEO_FADE_CURVES; exports.VIDEO_FADE_CURVE_LABELS = VIDEO_FADE_CURVE_LABELS; exports.VIDEO_FITS = VIDEO_FITS; exports.VIDEO_GRADE_CONTROLS = VIDEO_GRADE_CONTROLS; exports.VIDEO_GRADE_PRESETS = VIDEO_GRADE_PRESETS; exports.VIDEO_GRADE_PRESET_NAMES = VIDEO_GRADE_PRESET_NAMES; exports.VIDEO_ISSUE_CODES = VIDEO_ISSUE_CODES; exports.VIDEO_KEYFRAME_LIMITS = VIDEO_KEYFRAME_LIMITS; exports.VIDEO_KEYFRAME_PROPERTIES = VIDEO_KEYFRAME_PROPERTIES; exports.VIDEO_PEAK_BLOCK = VIDEO_PEAK_BLOCK; exports.VIDEO_PLACEMENT_MODES = VIDEO_PLACEMENT_MODES; exports.VIDEO_TIME_EPSILON = VIDEO_TIME_EPSILON; exports.VIDEO_TRANSFORM_PROPERTIES = VIDEO_TRANSFORM_PROPERTIES; exports.VIDEO_TRANSITION_LABELS = VIDEO_TRANSITION_LABELS; exports.VIDEO_TRANSITION_TYPES = VIDEO_TRANSITION_TYPES; exports.VIDEO_WIPE_DIRECTIONS = VIDEO_WIPE_DIRECTIONS; exports.VideoAudioPeaksCache = VideoAudioPeaksCache; exports.VideoEditorController = VideoEditorController; exports.VideoPreviewEngine = VideoPreviewEngine; exports.allVideoClipIds = allVideoClipIds; exports.applyVideoEdits = applyVideoEdits; exports.applyVideoGrade = applyVideoGrade; exports.buildVideoAudioPeaks = buildVideoAudioPeaks; exports.checkVideoProject = checkVideoProject; exports.createVideoEditorToolProvider = createVideoEditorToolProvider; exports.createVideoProject = createVideoProject; exports.decodeVideoAudioPeaks = decodeVideoAudioPeaks; exports.describeVideoProject = describeVideoProject; exports.drawVideoFrame = drawVideoFrame; exports.ffmpegFilterValue = ffmpegFilterValue; exports.findVideoAudioSilences = findVideoAudioSilences; exports.findVideoClip = findVideoClip; exports.findVideoGaps = findVideoGaps; exports.formatVideoSeconds = formatVideoSeconds; exports.formatVideoTimecode = formatVideoTimecode; exports.formatVideoTimecodeFrames = formatVideoTimecodeFrames; exports.isVideoDropFrameRate = isVideoDropFrameRate; exports.isVideoKeyframes = isVideoKeyframes; exports.nextFreeVideoSlot = nextFreeVideoSlot; exports.normalizeVideoGrade = normalizeVideoGrade; exports.parseVideoAnimatable = parseVideoAnimatable; exports.parseVideoAsset = parseVideoAsset; exports.parseVideoClip = parseVideoClip; exports.parseVideoClipAnimation = parseVideoClipAnimation; exports.parseVideoGrade = parseVideoGrade; exports.parseVideoKeyframes = parseVideoKeyframes; exports.parseVideoLoudnessMeasurement = parseVideoLoudnessMeasurement; exports.parseVideoMarker = parseVideoMarker; exports.parseVideoProject = parseVideoProject; exports.parseVideoTrack = parseVideoTrack; exports.parseVideoTransform = parseVideoTransform; exports.parseVideoTransition = parseVideoTransition; exports.placeVideoRect = placeVideoRect; exports.planVideoClipDrag = planVideoClipDrag; exports.probeVideoMedia = probeVideoMedia; exports.removeVideoKey = removeVideoKey; exports.retimeVideoAnimatable = retimeVideoAnimatable; exports.setVideoKey = setVideoKey; exports.snapToVideoFrame = snapToVideoFrame; exports.snapVideoTime = snapVideoTime; exports.sortVideoKeys = sortVideoKeys; exports.sortedVideoClips = sortedVideoClips; exports.splitVideoAnimatable = splitVideoAnimatable; exports.toVideoEdl = toVideoEdl; exports.toVideoFfmpeg = toVideoFfmpeg; exports.toVideoLoudnessMeasure = toVideoLoudnessMeasure; exports.videoAssetDropEdit = videoAssetDropEdit; exports.videoAssetHasSound = videoAssetHasSound; exports.videoAssetKindOf = videoAssetKindOf; exports.videoAssetMap = videoAssetMap; exports.videoAudioLevels = videoAudioLevels; exports.videoAudioPeakRange = videoAudioPeakRange; exports.videoAudioPeakSpans = videoAudioPeakSpans; exports.videoAudioPeaksDuration = videoAudioPeaksDuration; exports.videoClipAfter = videoClipAfter; exports.videoClipAnimated = videoClipAnimated; exports.videoClipAnimationProperties = videoClipAnimationProperties; exports.videoClipAnimationSpans = videoClipAnimationSpans; exports.videoClipBefore = videoClipBefore; exports.videoClipEnd = videoClipEnd; exports.videoClipHandles = videoClipHandles; exports.videoClipKeyframes = videoClipKeyframes; exports.videoClipLabel = videoClipLabel; exports.videoClipOverhang = videoClipOverhang; exports.videoClipShowsWaveform = videoClipShowsWaveform; exports.videoClipSoundGain = videoClipSoundGain; exports.videoClipSourceRange = videoClipSourceRange; exports.videoClipSpeed = videoClipSpeed; exports.videoClipTint = videoClipTint; exports.videoClipWaveform = videoClipWaveform; exports.videoClipWaveformPath = videoClipWaveformPath; exports.videoClipsAt = videoClipsAt; exports.videoContactSheetSvg = videoContactSheetSvg; exports.videoDragLabel = videoDragLabel; exports.videoDropTracks = videoDropTracks; exports.videoEaseBezier = videoEaseBezier; exports.videoEaseProgress = videoEaseProgress; exports.videoFadeCurveGain = videoFadeCurveGain; exports.videoFrameAt = videoFrameAt; exports.videoFrameDuration = videoFrameDuration; exports.videoFrameToSvg = videoFrameToSvg; exports.videoFrames = videoFrames; exports.videoGradeCssFilter = videoGradeCssFilter; exports.videoGradeFfmpegFilters = videoGradeFfmpegFilters; exports.videoGradeGainsFilterId = videoGradeGainsFilterId; exports.videoGradePreset = videoGradePreset; exports.videoGradeStepMatrix = videoGradeStepMatrix; exports.videoGradeStepSvgValues = videoGradeStepSvgValues; exports.videoGradeSteps = videoGradeSteps; exports.videoGradeSvgDefs = videoGradeSvgDefs; exports.videoGradeSvgFilter = videoGradeSvgFilter; exports.videoHourFrames = videoHourFrames; exports.videoIrisRadius = videoIrisRadius; exports.videoIssueKey = videoIssueKey; exports.videoKeyframeBreakpoints = videoKeyframeBreakpoints; exports.videoKeyframeFfmpegExpr = videoKeyframeFfmpegExpr; exports.videoKeyframeRows = videoKeyframeRows; exports.videoKeyframeToggleEdit = videoKeyframeToggleEdit; exports.videoKeyframeValueEdit = videoKeyframeValueEdit; exports.videoKeysOf = videoKeysOf; exports.videoOverlap = videoOverlap; exports.videoPeakDb = videoPeakDb; exports.videoProjectDuration = videoProjectDuration; exports.videoProjectSvg = videoProjectSvg; exports.videoRulerTicks = videoRulerTicks; exports.videoSnapPoints = videoSnapPoints; exports.videoTextStyle = videoTextStyle; exports.videoTimecodeFrames = videoTimecodeFrames; exports.videoTitleFrame = videoTitleFrame; exports.videoTrackSyncLocked = videoTrackSyncLocked; exports.videoTransformAt = videoTransformAt; exports.videoTransitionAudioCurve = videoTransitionAudioCurve; exports.videoTransitionEase = videoTransitionEase; exports.videoTransitionHasDirection = videoTransitionHasDirection; exports.videoValueAt = videoValueAt; exports.videoWaveformPath = videoWaveformPath; exports.videoWipeRect = videoWipeRect;
3064
3988
  //# sourceMappingURL=index.cjs.map