@pixerate/editor 0.81.0 → 0.83.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/agent/index.cjs +36 -7
  2. package/dist/agent/index.cjs.map +1 -1
  3. package/dist/agent/index.d.cts +5 -3
  4. package/dist/agent/index.d.ts +5 -3
  5. package/dist/agent/index.js +43 -14
  6. package/dist/agent/index.js.map +1 -1
  7. package/dist/animation-editor/index.d.cts +1 -1
  8. package/dist/animation-editor/index.d.ts +1 -1
  9. package/dist/bridge/index.d.cts +3 -3
  10. package/dist/bridge/index.d.ts +3 -3
  11. package/dist/{bridge-client-C5dRjrz5.d.ts → bridge-client-CMGfnjTl.d.ts} +1 -1
  12. package/dist/{bridge-client-C5OziCRK.d.cts → bridge-client-VpnICVxP.d.cts} +1 -1
  13. package/dist/canvas/index.cjs +235 -104
  14. package/dist/canvas/index.cjs.map +1 -1
  15. package/dist/canvas/index.d.cts +59 -1
  16. package/dist/canvas/index.d.ts +59 -1
  17. package/dist/canvas/index.js +234 -103
  18. package/dist/canvas/index.js.map +1 -1
  19. package/dist/capture-DFEMFmVt.d.cts +133 -0
  20. package/dist/capture-wwNGaW05.d.ts +133 -0
  21. package/dist/chart/index.d.cts +1 -1
  22. package/dist/chart/index.d.ts +1 -1
  23. package/dist/{chunk-K73CKYUM.cjs → chunk-EI2MOVQ3.cjs} +198 -3
  24. package/dist/chunk-EI2MOVQ3.cjs.map +1 -0
  25. package/dist/chunk-FURT6ZFZ.cjs +1 -0
  26. package/dist/chunk-FURT6ZFZ.cjs.map +1 -0
  27. package/dist/{chunk-GPWNYQ7R.js → chunk-O2W2SP3C.js} +197 -2
  28. package/dist/chunk-O2W2SP3C.js.map +1 -0
  29. package/dist/chunk-REOJJIY3.js +201 -0
  30. package/dist/chunk-REOJJIY3.js.map +1 -0
  31. package/dist/chunk-RJ2BFKEN.js +1 -0
  32. package/dist/chunk-RJ2BFKEN.js.map +1 -0
  33. package/dist/chunk-VJUVNERT.cjs +201 -0
  34. package/dist/chunk-VJUVNERT.cjs.map +1 -0
  35. package/dist/commands/index.d.cts +1 -1
  36. package/dist/commands/index.d.ts +1 -1
  37. package/dist/diagram/index.d.cts +1 -1
  38. package/dist/diagram/index.d.ts +1 -1
  39. package/dist/dom-capture-7Wvf6Tyg.d.ts +59 -0
  40. package/dist/dom-capture-GyYuE-zq.d.cts +59 -0
  41. package/dist/generation/index.d.cts +2 -2
  42. package/dist/generation/index.d.ts +2 -2
  43. package/dist/image-editor/index.cjs +97 -1
  44. package/dist/image-editor/index.cjs.map +1 -1
  45. package/dist/image-editor/index.d.cts +24 -2
  46. package/dist/image-editor/index.d.ts +24 -2
  47. package/dist/image-editor/index.js +96 -0
  48. package/dist/image-editor/index.js.map +1 -1
  49. package/dist/{registry-DIpIaCkH.d.cts → registry-BqA1AZt0.d.cts} +1 -1
  50. package/dist/{registry-7AZFuand.d.ts → registry-D-4fit9C.d.ts} +1 -1
  51. package/dist/tools/index.cjs +33 -4
  52. package/dist/tools/index.cjs.map +1 -1
  53. package/dist/tools/index.d.cts +4 -2
  54. package/dist/tools/index.d.ts +4 -2
  55. package/dist/tools/index.js +43 -14
  56. package/dist/video-editor/index.cjs +557 -99
  57. package/dist/video-editor/index.cjs.map +1 -1
  58. package/dist/video-editor/index.d.cts +211 -6
  59. package/dist/video-editor/index.d.ts +211 -6
  60. package/dist/video-editor/index.js +493 -35
  61. package/dist/video-editor/index.js.map +1 -1
  62. package/package.json +1 -1
  63. package/skills/pixerate-canvas/SKILL.md +1 -0
  64. package/skills/pixerate-image-editor/SKILL.md +1 -0
  65. package/skills/pixerate-video-editor/SKILL.md +24 -6
  66. package/dist/chunk-GPWNYQ7R.js.map +0 -1
  67. package/dist/chunk-K73CKYUM.cjs.map +0 -1
@@ -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
 
@@ -15,6 +15,7 @@ require('../chunk-2FHLHZAL.cjs');
15
15
  var VIDEO_FITS = ["contain", "cover", "fill", "none"];
16
16
  var VIDEO_TRANSITION_TYPES = ["crossfade", "dip", "wipe"];
17
17
  var VIDEO_WIPE_DIRECTIONS = ["left", "right", "up", "down"];
18
+ var VIDEO_FADE_CURVES = ["linear", "equal-power", "s-curve", "exponential", "logarithmic"];
18
19
  var VIDEO_PLACEMENT_MODES = ["refuse", "overwrite", "insert"];
19
20
  var VIDEO_CLIP_TYPES = ["media", "text", "color"];
20
21
 
@@ -324,7 +325,9 @@ function parseVideoClip(v, path = "clip") {
324
325
  in: num(v.in, `${path}.in`, { optional: true, min: 0 }),
325
326
  speed: num(v.speed, `${path}.speed`, { optional: true, min: 0.05, max: 20 }),
326
327
  volume: parseVideoAnimatable(v.volume, `${path}.volume`, { optional: true, min: 0, max: 4 }),
327
- muted: bool(v.muted, `${path}.muted`)
328
+ muted: bool(v.muted, `${path}.muted`),
329
+ fadeInCurve: oneOf(v.fadeInCurve, VIDEO_FADE_CURVES, `${path}.fadeInCurve`),
330
+ fadeOutCurve: oneOf(v.fadeOutCurve, VIDEO_FADE_CURVES, `${path}.fadeOutCurve`)
328
331
  });
329
332
  if (type === "text")
330
333
  return compact({
@@ -426,15 +429,41 @@ function snapToVideoFrame(time, fps) {
426
429
  return Math.round(frame * 1e3 / fps * 1e3) / 1e3;
427
430
  }
428
431
  var videoFrames = (time, fps) => Math.round(time * fps / 1e3);
429
- function formatVideoTimecode(time, fps) {
432
+ function isVideoDropFrameRate(fps) {
433
+ const nominal = Math.round(fps);
434
+ return (nominal === 30 || nominal === 60) && Math.abs(fps - nominal * 1e3 / 1001) < 5e-3;
435
+ }
436
+ var exactVideoRate = (fps) => {
437
+ const nominal = Math.round(fps);
438
+ return nominal !== fps && Math.abs(fps - nominal * 1e3 / 1001) < 5e-3 ? nominal * 1e3 / 1001 : fps;
439
+ };
440
+ var videoTimecodeFrames = (time, fps) => Math.max(0, Math.round(time * exactVideoRate(fps) / 1e3));
441
+ function formatVideoTimecode(time, fps, dropFrame = isVideoDropFrameRate(fps)) {
442
+ return formatVideoTimecodeFrames(videoTimecodeFrames(time, fps), fps, dropFrame);
443
+ }
444
+ function formatVideoTimecodeFrames(frames, fps, dropFrame = isVideoDropFrameRate(fps)) {
430
445
  const base = Math.round(fps);
431
- const total = Math.max(0, videoFrames(time, fps));
446
+ let total = Math.max(0, Math.round(frames));
447
+ const drop = dropFrame && isVideoDropFrameRate(fps);
448
+ if (drop) {
449
+ const skip = base / 15;
450
+ const perMinute = base * 60 - skip;
451
+ const perTen = perMinute * 10 + skip;
452
+ const tens = Math.floor(total / perTen);
453
+ const rest = total % perTen;
454
+ total += skip * 9 * tens + (rest > skip ? skip * Math.floor((rest - skip) / perMinute) : 0);
455
+ }
432
456
  const ff = total % base;
433
457
  const secs = Math.floor(total / base);
434
458
  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)}`;
459
+ return `${pad(Math.floor(secs / 3600))}:${pad(Math.floor(secs / 60) % 60)}:${pad(secs % 60)}${drop ? ";" : ":"}${pad(ff)}`;
436
460
  }
461
+ var videoHourFrames = (fps, dropFrame = isVideoDropFrameRate(fps)) => {
462
+ const base = Math.round(fps);
463
+ return dropFrame && isVideoDropFrameRate(fps) ? base * 3600 - base / 15 * 54 : base * 3600;
464
+ };
437
465
  var formatVideoSeconds = (time) => `${(time / 1e3).toFixed(2)} s`;
466
+ var videoAssetHasSound = (asset) => !!asset && (asset.kind === "audio" || asset.kind === "video" && asset.hasAudio !== false);
438
467
  function videoAssetMap(project) {
439
468
  return new Map(project.assets.map((a) => [a.id, a]));
440
469
  }
@@ -472,13 +501,13 @@ function videoClipHandles(clip, assets) {
472
501
  const speed = videoClipSpeed(clip);
473
502
  const range = videoClipSourceRange(clip);
474
503
  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 };
504
+ if (!asset || asset.kind === "image" || asset.duration === void 0) return { before: _optionalChain([asset, 'optionalAccess', _2 => _2.kind]) === "image" ? Infinity : before, after: Infinity };
476
505
  return { before, after: Math.max(0, (asset.duration - range.out) / speed) };
477
506
  }
478
507
  function videoClipOverhang(track, clip) {
479
508
  const lead = clip.transition && clip.transition.type !== "dip" && videoClipBefore(track, clip) ? clip.transition.duration / 2 : 0;
480
509
  const next = videoClipAfter(track, clip);
481
- const tail = _optionalChain([next, 'optionalAccess', _2 => _2.transition]) && next.transition.type !== "dip" ? next.transition.duration / 2 : 0;
510
+ const tail = _optionalChain([next, 'optionalAccess', _3 => _3.transition]) && next.transition.type !== "dip" ? next.transition.duration / 2 : 0;
482
511
  return { lead, tail };
483
512
  }
484
513
  function videoSnapPoints(project, options = {}) {
@@ -539,7 +568,7 @@ function videoClipKeyframes(clip) {
539
568
  const out = [
540
569
  { property: "opacity", keys: videoKeysOf(clip.opacity) },
541
570
  { 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]])) }))
571
+ ...VIDEO_TRANSFORM_PROPERTIES.map((p) => ({ property: p, keys: videoKeysOf(_optionalChain([clip, 'access', _4 => _4.transform, 'optionalAccess', _5 => _5[p]])) }))
543
572
  ];
544
573
  return out.filter((o) => o.keys.length);
545
574
  }
@@ -692,7 +721,7 @@ function clipAnimatables(clip) {
692
721
  { get: () => c.opacity, set: (v) => c.opacity = v },
693
722
  { get: () => c.volume, set: (v) => c.volume = v }
694
723
  ];
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) });
724
+ for (const p of VIDEO_TRANSFORM_PROPERTIES) out.push({ get: () => _optionalChain([c, 'access', _6 => _6.transform, 'optionalAccess', _7 => _7[p]]), set: (v) => c.transform && (c.transform[p] = v) });
696
725
  return out;
697
726
  }
698
727
  function retimeClipKeys(clip, fn) {
@@ -701,7 +730,7 @@ function retimeClipKeys(clip, fn) {
701
730
  if (isVideoKeyframes(v)) a.set(fn(v));
702
731
  }
703
732
  }
704
- var OPTIONAL_CLIP_FIELDS = ["name", "opacity", "transform", "fit", "fadeIn", "fadeOut", "transition", "in", "speed", "volume", "muted", "color", "fontSize", "fontFamily", "fontWeight", "background"];
733
+ var OPTIONAL_CLIP_FIELDS = ["name", "opacity", "transform", "fit", "fadeIn", "fadeOut", "fadeInCurve", "fadeOutCurve", "transition", "in", "speed", "volume", "muted", "color", "fontSize", "fontFamily", "fontWeight", "background"];
705
734
  var TIME_FIELDS = { slip_clip: ["delta"], roll_edit: ["delta"], slide_clip: ["delta"], lift_range: ["from", "to"], extract_range: ["from", "to"], split_at: ["at"] };
706
735
  var Editor = (_class = class {
707
736
  constructor(p) {;_class.prototype.__init.call(this);
@@ -740,7 +769,7 @@ var Editor = (_class = class {
740
769
  kindFits(track, clip) {
741
770
  const asset = clip.type === "media" ? this.asset(clip.asset) : null;
742
771
  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.`);
772
+ if (track.kind === "video" && _optionalChain([asset, 'optionalAccess', _8 => _8.kind]) === "audio") refuse(`Track "${track.id}" is a video track; audio asset "${asset.id}" goes on an audio track.`);
744
773
  }
745
774
  /** Moves every clip on `track` starting at or after `from` by `delta`. */
746
775
  shift(track, from, delta, except = /* @__PURE__ */ new Set()) {
@@ -782,7 +811,7 @@ var Editor = (_class = class {
782
811
  }
783
812
  /** Moves a media clip's in point by `delta` of timeline time (stills, titles and colours have none). */
784
813
  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;
814
+ if (clip.type !== "media" || _optionalChain([videoAssetMap, 'call', _9 => _9(this.p), 'access', _10 => _10.get, 'call', _11 => _11(clip.asset), 'optionalAccess', _12 => _12.kind]) === "image") return;
786
815
  clip.in = Math.max(0, (_nullishCoalesce(clip.in, () => ( 0))) + delta * videoClipSpeed(clip));
787
816
  if (clip.in < 1e-9) delete clip.in;
788
817
  }
@@ -797,6 +826,7 @@ var Editor = (_class = class {
797
826
  const right = { ...structuredClone(clip), id: _nullishCoalesce(id, () => ( freshId(`${clip.id}-`, this.ids()))), start: at, duration: end - at };
798
827
  delete right.transition;
799
828
  delete right.fadeIn;
829
+ delete right.fadeInCurve;
800
830
  const leftValues = clipAnimatables(clip);
801
831
  clipAnimatables(right).forEach((r, i) => {
802
832
  const [l, rr] = splitVideoAnimatable(leftValues[i].get(), at - clip.start);
@@ -806,6 +836,7 @@ var Editor = (_class = class {
806
836
  this.moveIn(right, at - clip.start);
807
837
  clip.duration = at - clip.start;
808
838
  delete clip.fadeOut;
839
+ if (clip.type === "media") delete clip.fadeOutCurve;
809
840
  track.clips.push(right);
810
841
  track.clips.sort((a, b) => a.start - b.start);
811
842
  return right;
@@ -1044,7 +1075,7 @@ var Editor = (_class = class {
1044
1075
  if (clip.type === "media") {
1045
1076
  const asset = assets.get(clip.asset);
1046
1077
  const out = (_nullishCoalesce(clip.in, () => ( 0))) + duration * speed;
1047
- if (_optionalChain([asset, 'optionalAccess', _12 => _12.duration]) !== void 0 && out > asset.duration + VIDEO_TIME_EPSILON)
1078
+ if (_optionalChain([asset, 'optionalAccess', _13 => _13.duration]) !== void 0 && out > asset.duration + VIDEO_TIME_EPSILON)
1048
1079
  refuse(`"${clip.id}" can end at most at ${formatVideoSeconds(clip.start + (asset.duration - (_nullishCoalesce(clip.in, () => ( 0)))) / speed)}, where "${asset.id}" runs out.`);
1049
1080
  }
1050
1081
  if (e.ripple) {
@@ -1100,7 +1131,7 @@ var Editor = (_class = class {
1100
1131
  let stretch = 1;
1101
1132
  if (at.clip.type === "media" && !("duration" in set)) {
1102
1133
  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;
1134
+ const now = "speed" in set ? set.speed : _optionalChain([e, 'access', _14 => _14.remove, 'optionalAccess', _15 => _15.includes, 'call', _16 => _16("speed")]) ? 1 : was;
1104
1135
  if (now !== was) {
1105
1136
  set.duration = at.clip.duration * was / now;
1106
1137
  stretch = was / now;
@@ -1196,7 +1227,7 @@ var Editor = (_class = class {
1196
1227
  this.trimHead(after, delta);
1197
1228
  }
1198
1229
  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 });
1230
+ return this.outcomes.push({ op: e.op, clip: clip.id, clips: [_optionalChain([before, 'optionalAccess', _17 => _17.id]), clip.id, _optionalChain([after, 'optionalAccess', _18 => _18.id])].filter((x) => !!x), track: track.id, delta });
1200
1231
  }
1201
1232
  case "lift_range":
1202
1233
  case "extract_range": {
@@ -1227,7 +1258,7 @@ var Editor = (_class = class {
1227
1258
  const limits = VIDEO_KEYFRAME_LIMITS[e.property];
1228
1259
  const transform = VIDEO_TRANSFORM_PROPERTIES.includes(e.property);
1229
1260
  const c = clip;
1230
- const current = transform ? _optionalChain([c, 'access', _18 => _18.transform, 'optionalAccess', _19 => _19[e.property]]) : c[e.property];
1261
+ const current = transform ? _optionalChain([c, 'access', _19 => _19.transform, 'optionalAccess', _20 => _20[e.property]]) : c[e.property];
1231
1262
  let value;
1232
1263
  if (e.keys === null) {
1233
1264
  const v = videoValueAt(current, 0, limits.default);
@@ -1309,7 +1340,7 @@ function applyVideoEdits(project, edits) {
1309
1340
  editor.run(edit);
1310
1341
  } catch (error) {
1311
1342
  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."}` };
1343
+ return { ok: false, error: `${list.length > 1 ? `op ${i + 1} (${_optionalChain([edit, 'optionalAccess', _21 => _21.op])}): ` : ""}${message}${/Nothing was changed\.$/.test(message) ? "" : " Nothing was changed."}` };
1313
1344
  }
1314
1345
  }
1315
1346
  let next;
@@ -1328,7 +1359,7 @@ function applyVideoEdits(project, edits) {
1328
1359
 
1329
1360
  // src/video-editor/evaluate.ts
1330
1361
  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) };
1362
+ return { x: videoValueAt(_optionalChain([transform, 'optionalAccess', _22 => _22.x]), clipTime, 0), y: videoValueAt(_optionalChain([transform, 'optionalAccess', _23 => _23.y]), clipTime, 0), scale: videoValueAt(_optionalChain([transform, 'optionalAccess', _24 => _24.scale]), clipTime, 1), rotation: videoValueAt(_optionalChain([transform, 'optionalAccess', _25 => _25.rotation]), clipTime, 0) };
1332
1363
  }
1333
1364
  function placeVideoRect(frame, size, fit = "contain", transform = {}, clipTime = 0) {
1334
1365
  let w = size.width;
@@ -1356,19 +1387,39 @@ function videoTitleFrame(project, clip, clipTime = 0) {
1356
1387
  return { time: clip.start, width: project.width, height: project.height, background: "rgba(0,0,0,0)", layers: [layer], audio: [] };
1357
1388
  }
1358
1389
  var clamp01 = (v) => Math.min(1, Math.max(0, v));
1359
- function fadeGain(clip, time) {
1390
+ function videoFadeCurveGain(curve, x) {
1391
+ const t = clamp01(x);
1392
+ switch (curve) {
1393
+ case "equal-power":
1394
+ return Math.sin(t * Math.PI / 2);
1395
+ case "s-curve":
1396
+ return (1 - Math.cos(t * Math.PI)) / 2;
1397
+ case "exponential":
1398
+ return t * t * t;
1399
+ case "logarithmic":
1400
+ return 1 - (1 - t) * (1 - t);
1401
+ default:
1402
+ return t;
1403
+ }
1404
+ }
1405
+ function fadeGain(clip, time, sound = false) {
1360
1406
  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);
1407
+ const shape = sound && clip.type === "media" ? clip : void 0;
1408
+ if (clip.fadeIn) g *= videoFadeCurveGain(_optionalChain([shape, 'optionalAccess', _26 => _26.fadeInCurve]), (time - clip.start) / clip.fadeIn);
1409
+ if (clip.fadeOut) g *= videoFadeCurveGain(_optionalChain([shape, 'optionalAccess', _27 => _27.fadeOutCurve]), (videoClipEnd(clip) - time) / clip.fadeOut);
1363
1410
  return g;
1364
1411
  }
1412
+ function videoClipSoundGain(clip, clipTime) {
1413
+ const volume = clip.type === "media" ? videoValueAt(clip.volume, clipTime, 1) : 1;
1414
+ return volume * fadeGain(clip, clip.start + Math.min(Math.max(clipTime, 0), clip.duration), true);
1415
+ }
1365
1416
  function activeOnTrack(track, time) {
1366
1417
  const out = [];
1367
1418
  for (const clip of sortedVideoClips(track)) {
1368
1419
  const before = clip.transition ? videoClipBefore(track, clip) : null;
1369
1420
  const lead = before ? clip.transition.duration / 2 : 0;
1370
1421
  const next = videoClipAfter(track, clip);
1371
- const tail = _optionalChain([next, 'optionalAccess', _25 => _25.transition]) ? next.transition.duration / 2 : 0;
1422
+ const tail = _optionalChain([next, 'optionalAccess', _28 => _28.transition]) ? next.transition.duration / 2 : 0;
1372
1423
  if (time < clip.start - lead || time >= videoClipEnd(clip) + tail) continue;
1373
1424
  const a = { clip };
1374
1425
  if (lead && time < clip.start + lead) a.incoming = { progress: clamp01((time - (clip.start - lead)) / (2 * lead)), type: clip.transition.type, transition: clip.transition };
@@ -1385,17 +1436,18 @@ function videoFrameAt(project, time) {
1385
1436
  for (let ti = project.tracks.length - 1; ti >= 0; ti--) {
1386
1437
  const track = project.tracks[ti];
1387
1438
  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)));
1439
+ if (_optionalChain([incoming, 'optionalAccess', _29 => _29.type]) === "dip" && incoming.progress < 0.5) continue;
1440
+ if (_optionalChain([outgoing, 'optionalAccess', _30 => _30.type]) === "dip" && outgoing.progress >= 0.5) continue;
1441
+ const inside = Math.min(Math.max(time, clip.start), videoClipEnd(clip));
1442
+ const fade = fadeGain(clip, inside);
1391
1443
  const level = (t, into) => t.type === "dip" ? Math.abs(1 - 2 * t.progress) : into ? t.progress : 1 - t.progress;
1392
1444
  const mix = (incoming ? level(incoming, true) : 1) * (outgoing ? level(outgoing, false) : 1);
1393
1445
  const asset = clip.type === "media" ? assets.get(clip.asset) : void 0;
1394
1446
  const sourceTime = sourceTimeAt(clip, time);
1395
1447
  const clipTime = time - clip.start;
1396
- const audible = asset && (asset.kind === "audio" || asset.kind === "video" && asset.hasAudio !== false);
1448
+ const audible = videoAssetHasSound(asset);
1397
1449
  if (audible && clip.type === "media" && !clip.muted && !track.muted) {
1398
- const gain = videoValueAt(clip.volume, clipTime, 1) * (_nullishCoalesce(track.volume, () => ( 1))) * fade * mix;
1450
+ const gain = videoValueAt(clip.volume, clipTime, 1) * (_nullishCoalesce(track.volume, () => ( 1))) * fadeGain(clip, inside, true) * mix;
1399
1451
  audio.push({ clip: clip.id, track: track.id, asset: asset.id, src: asset.src, sourceTime, gain, speed: videoClipSpeed(clip) });
1400
1452
  }
1401
1453
  if (track.kind !== "video" || track.hidden) continue;
@@ -1408,11 +1460,11 @@ function videoFrameAt(project, time) {
1408
1460
  else if (incoming.type === "wipe") wipe = { direction: _nullishCoalesce(incoming.transition.direction, () => ( "right")), progress: incoming.progress };
1409
1461
  else dip = { color: _nullishCoalesce(incoming.transition.color, () => ( "#000000")), opacity: (1 - incoming.progress) * 2 };
1410
1462
  }
1411
- if (_optionalChain([outgoing, 'optionalAccess', _28 => _28.type]) === "dip") dip = { color: _nullishCoalesce(outgoing.next.transition.color, () => ( "#000000")), opacity: outgoing.progress * 2 };
1463
+ if (_optionalChain([outgoing, 'optionalAccess', _31 => _31.type]) === "dip") dip = { color: _nullishCoalesce(outgoing.next.transition.color, () => ( "#000000")), opacity: outgoing.progress * 2 };
1412
1464
  const size = clip.type === "media" ? { width: _nullishCoalesce(asset.width, () => ( project.width)), height: _nullishCoalesce(asset.height, () => ( project.height)) } : { width: project.width, height: project.height };
1413
1465
  const fit = _nullishCoalesce(clip.fit, () => ( (clip.type === "media" ? "contain" : "fill")));
1414
1466
  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) };
1467
+ const layer = { clip: clip.id, track: track.id, type: clip.type, opacity: clamp01(opacity), rect, rotation: videoValueAt(_optionalChain([clip, 'access', _32 => _32.transform, 'optionalAccess', _33 => _33.rotation]), clipTime, 0) };
1416
1468
  if (wipe) layer.wipe = wipe;
1417
1469
  if (clip.type === "media") Object.assign(layer, { asset: asset.id, assetKind: asset.kind, src: asset.src, sourceTime });
1418
1470
  if (clip.type === "text") layer.text = videoTextStyle(clip);
@@ -1424,6 +1476,223 @@ function videoFrameAt(project, time) {
1424
1476
  return { time, width: project.width, height: project.height, background: _nullishCoalesce(project.background, () => ( "#000000")), layers, audio };
1425
1477
  }
1426
1478
 
1479
+ // src/video-editor/audio.ts
1480
+ var VIDEO_PEAK_BLOCK = 32;
1481
+ function buildVideoAudioPeaks(channels, sampleRate, blockSize = VIDEO_PEAK_BLOCK) {
1482
+ if (!(sampleRate > 0)) throw new Error("The sample rate must be above 0.");
1483
+ const length = channels.reduce((n2, c) => Math.max(n2, c.length), 0);
1484
+ const blocks = Math.max(1, Math.ceil(length / blockSize));
1485
+ const level0 = new Float32Array(blocks * 2);
1486
+ for (let b = 0; b < blocks; b++) {
1487
+ let lo = Infinity;
1488
+ let hi = -Infinity;
1489
+ const end = Math.min(length, (b + 1) * blockSize);
1490
+ for (const channel of channels) {
1491
+ for (let i = b * blockSize; i < Math.min(end, channel.length); i++) {
1492
+ const v = channel[i];
1493
+ if (!Number.isFinite(v)) continue;
1494
+ if (v < lo) lo = v;
1495
+ if (v > hi) hi = v;
1496
+ }
1497
+ }
1498
+ level0[2 * b] = lo === Infinity ? 0 : lo;
1499
+ level0[2 * b + 1] = hi === -Infinity ? 0 : hi;
1500
+ }
1501
+ const levels = [level0];
1502
+ while (levels[levels.length - 1].length > 2) {
1503
+ const below = levels[levels.length - 1];
1504
+ const n2 = below.length / 2;
1505
+ const up = new Float32Array(Math.ceil(n2 / 4) * 2);
1506
+ for (let j = 0; j < up.length / 2; j++) {
1507
+ let lo = Infinity;
1508
+ let hi = -Infinity;
1509
+ for (let i = 4 * j; i < Math.min(n2, 4 * j + 4); i++) {
1510
+ lo = Math.min(lo, below[2 * i]);
1511
+ hi = Math.max(hi, below[2 * i + 1]);
1512
+ }
1513
+ up[2 * j] = lo;
1514
+ up[2 * j + 1] = hi;
1515
+ }
1516
+ levels.push(up);
1517
+ }
1518
+ return { sampleRate, length, blockSize, levels };
1519
+ }
1520
+ var videoAudioPeaksDuration = (peaks) => peaks.length * 1e3 / peaks.sampleRate;
1521
+ function videoAudioPeakRange(peaks, from, to) {
1522
+ const blocks = peaks.levels[0].length / 2;
1523
+ let a = Math.max(0, Math.floor(from * peaks.sampleRate / 1e3 / peaks.blockSize));
1524
+ let b = Math.min(blocks, Math.ceil(to * peaks.sampleRate / 1e3 / peaks.blockSize));
1525
+ if (b <= a) {
1526
+ if (a >= blocks || to < 0) return { min: 0, max: 0 };
1527
+ b = a + 1;
1528
+ }
1529
+ let min = Infinity;
1530
+ let max = -Infinity;
1531
+ const take = (level, i) => {
1532
+ if (2 * i + 1 >= level.length) return;
1533
+ if (level[2 * i] < min) min = level[2 * i];
1534
+ if (level[2 * i + 1] > max) max = level[2 * i + 1];
1535
+ };
1536
+ for (let k = 0; a < b; k++) {
1537
+ const level = peaks.levels[k];
1538
+ if (k === peaks.levels.length - 1) {
1539
+ for (let i = a; i < b; i++) take(level, i);
1540
+ break;
1541
+ }
1542
+ while (a < b && a % 4) take(level, a++);
1543
+ while (a < b && b % 4) take(level, --b);
1544
+ a /= 4;
1545
+ b /= 4;
1546
+ }
1547
+ return min === Infinity ? { min: 0, max: 0 } : { min, max };
1548
+ }
1549
+ function videoAudioPeakSpans(peaks, from, to, columns) {
1550
+ const n2 = Math.max(0, Math.floor(columns));
1551
+ const out = new Float32Array(n2 * 2);
1552
+ const step = (to - from) / Math.max(1, n2);
1553
+ for (let c = 0; c < n2; c++) {
1554
+ const { min, max } = videoAudioPeakRange(peaks, from + c * step, from + (c + 1) * step);
1555
+ out[2 * c] = min;
1556
+ out[2 * c + 1] = max;
1557
+ }
1558
+ return out;
1559
+ }
1560
+ function videoClipWaveform(clip, peaks, columns) {
1561
+ const n2 = Math.max(0, Math.floor(columns));
1562
+ const out = new Float32Array(n2 * 2);
1563
+ const speed = _nullishCoalesce(clip.speed, () => ( 1));
1564
+ const start = _nullishCoalesce(clip.in, () => ( 0));
1565
+ const step = clip.duration / Math.max(1, n2);
1566
+ for (let c = 0; c < n2; c++) {
1567
+ const { min, max } = videoAudioPeakRange(peaks, start + c * step * speed, start + (c + 1) * step * speed);
1568
+ const gain = videoClipSoundGain(clip, (c + 0.5) * step);
1569
+ out[2 * c] = min * gain;
1570
+ out[2 * c + 1] = max * gain;
1571
+ }
1572
+ return out;
1573
+ }
1574
+ function videoWaveformPath(spans, width, height) {
1575
+ const n2 = spans.length / 2;
1576
+ if (!n2 || width <= 0 || height <= 0) return "";
1577
+ const mid = height / 2;
1578
+ const y = (v) => (mid - Math.max(-1, Math.min(1, v)) * mid).toFixed(1);
1579
+ const x = (i) => ((i + 0.5) * (width / n2)).toFixed(1);
1580
+ const floor = 0.5 / mid;
1581
+ const top = [];
1582
+ const bottom = [];
1583
+ for (let i = 0; i < n2; i++) top.push(`${i ? "L" : "M"}${x(i)} ${y(Math.max(spans[2 * i + 1], floor))}`);
1584
+ for (let i = n2 - 1; i >= 0; i--) bottom.push(`L${x(i)} ${y(Math.min(spans[2 * i], -floor))}`);
1585
+ return `${top.join("")}${bottom.join("")}Z`;
1586
+ }
1587
+ function videoClipWaveformPath(clip, peaks, width, height) {
1588
+ const columns = Math.max(1, Math.min(2e3, Math.ceil(width / 2)));
1589
+ return videoWaveformPath(videoClipWaveform(clip, peaks, columns), width, height);
1590
+ }
1591
+ var videoPeakDb = (value) => Math.abs(value) > 1e-5 ? Math.max(-100, Math.round(200 * Math.log10(Math.abs(value))) / 10) : -100;
1592
+ function videoAudioLevels(peaks, from, to, buckets) {
1593
+ const spans = videoAudioPeakSpans(peaks, from, to, buckets);
1594
+ const step = (to - from) / Math.max(1, buckets);
1595
+ 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]))) }));
1596
+ }
1597
+ function findVideoAudioSilences(peaks, from, to, options = {}) {
1598
+ const threshold = _nullishCoalesce(options.thresholdDb, () => ( -40));
1599
+ const minDuration = _nullishCoalesce(options.minDuration, () => ( 300));
1600
+ const resolution = Math.max(1, _nullishCoalesce(options.resolution, () => ( 10)));
1601
+ const buckets = Math.max(1, Math.ceil((to - from) / resolution));
1602
+ const out = [];
1603
+ let run = null;
1604
+ const levels = videoAudioLevels(peaks, from, to, buckets);
1605
+ levels.forEach((l, i) => {
1606
+ const quiet = l.peakDb < threshold;
1607
+ if (quiet && run === null) run = l.start;
1608
+ if ((!quiet || i === levels.length - 1) && run !== null) {
1609
+ const end = quiet ? l.end : l.start;
1610
+ if (end - run >= minDuration) out.push({ start: run, end });
1611
+ run = null;
1612
+ }
1613
+ });
1614
+ return out;
1615
+ }
1616
+ async function decodeVideoAudioPeaks(src, options = {}) {
1617
+ const Offline = globalThis.OfflineAudioContext;
1618
+ if (!Offline || typeof fetch === "undefined") throw new Error("Audio can only be decoded in a browser with Web Audio.");
1619
+ const response = await fetch(src, options.signal ? { signal: options.signal } : {});
1620
+ if (!response.ok) throw new Error(`"${src}" could not be loaded (${response.status}).`);
1621
+ const data = await response.arrayBuffer();
1622
+ let buffer;
1623
+ try {
1624
+ buffer = await new Offline(1, 1, 44100).decodeAudioData(data);
1625
+ } catch (e2) {
1626
+ throw new Error(`"${src}" has no sound this browser can decode.`);
1627
+ }
1628
+ return buildVideoAudioPeaks(Array.from({ length: buffer.numberOfChannels }, (_, i) => buffer.getChannelData(i)), buffer.sampleRate);
1629
+ }
1630
+ var VideoAudioPeaksCache = (_class2 = class {
1631
+ 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);
1632
+ this.loader = loader;
1633
+ }
1634
+
1635
+ __init2() {this.ready = /* @__PURE__ */ new Map()}
1636
+ __init3() {this.pending = /* @__PURE__ */ new Map()}
1637
+ __init4() {this.failed = /* @__PURE__ */ new Map()}
1638
+ __init5() {this.listeners = /* @__PURE__ */ new Set()}
1639
+ /** The peaks if ready, without starting to load them. */
1640
+ peek(asset) {
1641
+ return this.ready.get(asset.src);
1642
+ }
1643
+ /** The peaks if ready; otherwise `undefined`, and loading starts (once) unless it failed. */
1644
+ get(asset) {
1645
+ const peaks = this.ready.get(asset.src);
1646
+ if (!peaks && !this.failed.has(asset.src)) void this.load(asset).catch(() => {
1647
+ });
1648
+ return peaks;
1649
+ }
1650
+ /** Why loading an asset's peaks failed, if it did. */
1651
+ error(asset) {
1652
+ return this.failed.get(asset.src);
1653
+ }
1654
+ /** The peaks, loading them if needed. Rejects with a readable error. */
1655
+ load(asset) {
1656
+ const ready = this.ready.get(asset.src);
1657
+ if (ready) return Promise.resolve(ready);
1658
+ const failed = this.failed.get(asset.src);
1659
+ if (failed) return Promise.reject(new Error(failed));
1660
+ let pending = this.pending.get(asset.src);
1661
+ if (!pending) {
1662
+ pending = this.loader(asset).then(
1663
+ (peaks) => {
1664
+ this.pending.delete(asset.src);
1665
+ this.ready.set(asset.src, peaks);
1666
+ this.emit();
1667
+ return peaks;
1668
+ },
1669
+ (e) => {
1670
+ this.pending.delete(asset.src);
1671
+ const message = `The sound of "${asset.id}" could not be read: ${e instanceof Error ? e.message : String(e)}`;
1672
+ this.failed.set(asset.src, message);
1673
+ this.emit();
1674
+ throw new Error(message);
1675
+ }
1676
+ );
1677
+ this.pending.set(asset.src, pending);
1678
+ }
1679
+ return pending;
1680
+ }
1681
+ /** Uses peaks the host already has (computed earlier or stored). */
1682
+ set(src, peaks) {
1683
+ this.ready.set(src, peaks);
1684
+ this.failed.delete(src);
1685
+ this.emit();
1686
+ }
1687
+ subscribe(fn) {
1688
+ this.listeners.add(fn);
1689
+ return () => this.listeners.delete(fn);
1690
+ }
1691
+ emit() {
1692
+ for (const fn of this.listeners) fn();
1693
+ }
1694
+ }, _class2);
1695
+
1427
1696
  // src/video-editor/render.ts
1428
1697
  var esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1429
1698
  var n = (v) => String(Math.round(v * 100) / 100);
@@ -1557,20 +1826,22 @@ function drawVideoFrame(ctx, frame, sourceFor, scale = 1) {
1557
1826
  }
1558
1827
 
1559
1828
  // 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";
1829
+ var reelName = (asset) => (_nullishCoalesce(_nullishCoalesce(_optionalChain([asset, 'optionalAccess', _34 => _34.name]), () => ( _optionalChain([asset, 'optionalAccess', _35 => _35.id]))), () => ( "AX"))).replace(/\.[a-z0-9]+$/i, "").replace(/[^A-Za-z0-9]/g, "").toUpperCase().slice(0, 8) || "AX";
1561
1830
  function toVideoEdl(project, options = {}) {
1562
1831
  const track = options.track !== void 0 ? project.tracks.find((t) => t.id === options.track) : [...project.tracks].reverse().find((t) => t.kind === "video");
1563
1832
  if (!track) throw new Error(options.track !== void 0 ? `There is no track "${options.track}".` : "The project has no video track.");
1564
1833
  const fps = project.fps;
1565
- const tc = (ms) => formatVideoTimecode(ms, fps);
1566
- const rec0 = _nullishCoalesce(options.recordStart, () => ( 36e5));
1834
+ const dropFrame = (_nullishCoalesce(options.dropFrame, () => ( true))) && isVideoDropFrameRate(fps);
1835
+ const tc = (ms) => formatVideoTimecode(ms, fps, dropFrame);
1836
+ const rec0 = options.recordStart !== void 0 ? videoTimecodeFrames(options.recordStart, fps) : videoHourFrames(fps, dropFrame);
1837
+ const rtc = (ms) => formatVideoTimecodeFrames(rec0 + videoTimecodeFrames(ms, fps), fps, dropFrame);
1567
1838
  const assets = videoAssetMap(project);
1568
1839
  const channel = track.kind === "video" ? "V" : "A";
1569
- const lines = [`TITLE: ${(_nullishCoalesce(_nullishCoalesce(options.title, () => ( project.name)), () => ( "Untitled"))).toUpperCase()}`, "FCM: NON-DROP FRAME", ""];
1840
+ const lines = [`TITLE: ${(_nullishCoalesce(_nullishCoalesce(options.title, () => ( project.name)), () => ( "Untitled"))).toUpperCase()}`, `FCM: ${dropFrame ? "DROP FRAME" : "NON-DROP FRAME"}`, ""];
1570
1841
  let event = 0;
1571
1842
  const num2 = () => String(++event).padStart(3, "0");
1572
1843
  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)}`;
1844
+ 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
1845
  for (const clip of sortedVideoClips(track)) {
1575
1846
  const asset = clip.type === "media" ? assets.get(clip.asset) : void 0;
1576
1847
  const reel = clip.type === "media" ? reelName(asset) : "BL";
@@ -1590,14 +1861,41 @@ function toVideoEdl(project, options = {}) {
1590
1861
  lines.push(row(n2, reel, `${t.type === "wipe" ? "W001" : "D"} ${frames}`, sourceAt(clip, recIn), sourceAt(clip, recOut), recIn, recOut));
1591
1862
  } else lines.push(row(n2, reel, "C", sourceAt(clip, recIn), sourceAt(clip, recOut), recIn, recOut));
1592
1863
  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))}`);
1864
+ if (clip.type === "media") lines.push(`* FROM CLIP NAME: ${_nullishCoalesce(_nullishCoalesce(_optionalChain([asset, 'optionalAccess', _36 => _36.name]), () => ( _optionalChain([asset, 'optionalAccess', _37 => _37.src]))), () => ( clip.asset))}`);
1594
1865
  if (clip.type === "text") lines.push(`* TITLE: ${clip.text.replace(/\n/g, " ")}`);
1595
1866
  if (clip.type === "color") lines.push(`* COLOR: ${clip.color}`);
1596
1867
  lines.push("");
1597
1868
  }
1598
1869
  return lines.join("\n");
1599
1870
  }
1871
+ function loudnormFilter(target = {}, measure = false) {
1872
+ const I = _nullishCoalesce(target.integrated, () => ( -14));
1873
+ const TP = _nullishCoalesce(target.truePeak, () => ( -1));
1874
+ const LRA = _nullishCoalesce(target.range, () => ( 11));
1875
+ 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.`);
1876
+ if (!(TP >= -9 && TP <= 0)) throw new Error(`The true peak ceiling must be between -9 and 0 dBTP (got ${TP}); -1 is usual.`);
1877
+ if (!(LRA >= 1 && LRA <= 50)) throw new Error(`The loudness range must be between 1 and 50 LU (got ${LRA}).`);
1878
+ const parts = [`loudnorm=I=${I}:TP=${TP}:LRA=${LRA}`];
1879
+ const m = target.measured;
1880
+ if (m && !measure) {
1881
+ 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.`);
1882
+ parts.push(`measured_I=${m.integrated}:measured_TP=${m.truePeak}:measured_LRA=${m.range}:measured_thresh=${m.threshold}:offset=${m.offset}:linear=true`);
1883
+ }
1884
+ if (measure) parts.push("print_format=json");
1885
+ return `${parts.join(":")},aresample=48000`;
1886
+ }
1887
+ function parseVideoLoudnessMeasurement(output) {
1888
+ const blocks = output.match(/\{[^{}]*"input_i"[^{}]*\}/g);
1889
+ if (!blocks) throw new Error("No loudness measurement found: pass the stderr of the command from toVideoLoudnessMeasure.");
1890
+ const json = JSON.parse(blocks[blocks.length - 1]);
1891
+ const n2 = (k) => Number(json[k]);
1892
+ const m = { integrated: n2("input_i"), truePeak: n2("input_tp"), range: n2("input_lra"), threshold: n2("input_thresh"), offset: n2("target_offset") };
1893
+ if (!Number.isFinite(m.integrated)) throw new Error("The mix is silent, so its loudness cannot be normalised.");
1894
+ return m;
1895
+ }
1600
1896
  var sec = (ms) => String(Math.round(ms) / 1e3);
1897
+ var AFADE_CURVES = { linear: "tri", "equal-power": "qsin", "s-curve": "hsin", exponential: "cub", logarithmic: "ipar" };
1898
+ var afadeCurve = (curve) => curve && curve !== "linear" ? `:curve=${AFADE_CURVES[curve]}` : "";
1601
1899
  var even = (v) => Math.max(2, 2 * Math.round(v / 2));
1602
1900
  var ffmpegFilterValue = (s) => s.replace(/[\\':%]/g, (c) => `\\${c}`).replace(/[\\'[\],;]/g, (c) => `\\${c}`);
1603
1901
  var color = (c) => {
@@ -1622,6 +1920,12 @@ function atempo(speed) {
1622
1920
  return out;
1623
1921
  }
1624
1922
  function toVideoFfmpeg(project, options = {}) {
1923
+ return buildFfmpeg(project, options, false);
1924
+ }
1925
+ function toVideoLoudnessMeasure(project, options = {}) {
1926
+ return buildFfmpeg(project, { ...options, loudness: { ...options.loudness, measured: void 0 } }, true);
1927
+ }
1928
+ function buildFfmpeg(project, options, measure) {
1625
1929
  const notes = [];
1626
1930
  const assets = videoAssetMap(project);
1627
1931
  const D = videoProjectDuration(project);
@@ -1630,7 +1934,7 @@ function toVideoFfmpeg(project, options = {}) {
1630
1934
  const H = even(project.height);
1631
1935
  const fps = project.fps;
1632
1936
  const inputs = [];
1633
- const graph = [`color=c=${color(_nullishCoalesce(project.background, () => ( "#000000")))}:s=${W}x${H}:r=${fps}:d=${sec(D)},format=yuva420p[base]`];
1937
+ const graph = measure ? [] : [`color=c=${color(_nullishCoalesce(project.background, () => ( "#000000")))}:s=${W}x${H}:r=${fps}:d=${sec(D)},format=yuva420p[base]`];
1634
1938
  const audio = [];
1635
1939
  let cur = "base";
1636
1940
  let usesDrawtext = false;
@@ -1661,8 +1965,8 @@ function toVideoFfmpeg(project, options = {}) {
1661
1965
  continue;
1662
1966
  }
1663
1967
  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);
1968
+ if (!measure && asset && asset.kind !== "audio" && track.kind === "video" && !track.hidden) index = input(clip, asset, lead, tail);
1969
+ const audible = videoAssetHasSound(asset);
1666
1970
  if (audible && clip.type === "media" && !clip.muted && !track.muted) {
1667
1971
  const ai = _nullishCoalesce(index, () => ( input(clip, asset, lead, tail)));
1668
1972
  const trackGain = _nullishCoalesce(track.volume, () => ( 1));
@@ -1672,21 +1976,21 @@ function toVideoFfmpeg(project, options = {}) {
1672
1976
  if (lead) chain2.push(`afade=t=in:st=0:d=${sec(2 * lead)}`);
1673
1977
  if (tail) chain2.push(`afade=t=out:st=${sec(span - 2 * tail)}:d=${sec(2 * tail)}`);
1674
1978
  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");
1979
+ const after = sortedVideoClips(track).find((c) => c !== clip && Math.abs(c.start - end) < 0.5 && _optionalChain([c, 'access', _38 => _38.transition, 'optionalAccess', _39 => _39.type]) === "dip");
1676
1980
  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)}`);
1981
+ if (clip.fadeIn) chain2.push(`afade=t=in:st=${sec(lead)}:d=${sec(clip.fadeIn)}${afadeCurve(clip.fadeInCurve)}`);
1982
+ if (clip.fadeOut) chain2.push(`afade=t=out:st=${sec(lead + clip.duration - clip.fadeOut)}:d=${sec(clip.fadeOut)}${afadeCurve(clip.fadeOutCurve)}`);
1679
1983
  chain2.push(`adelay=${Math.round(t0)}:all=1`);
1680
1984
  graph.push(`[${ai}:a]${chain2.join(",")}[a${audio.length}]`);
1681
1985
  audio.push(`[a${audio.length}]`);
1682
1986
  }
1683
- if (track.kind !== "video" || track.hidden) continue;
1987
+ if (measure || track.kind !== "video" || track.hidden) continue;
1684
1988
  const enable = `enable='between(t,${sec(t0)},${sec(t0 + span)})'`;
1685
1989
  const next = `v${k++}`;
1686
1990
  const onTimeline = `(t*1000-${Math.round(clip.start)})`;
1687
1991
  const onChain = (v) => `(${v}*1000${lead ? `-${Math.round(lead)}` : ""})`;
1688
1992
  const tf = clip.transform;
1689
- const moving = VIDEO_TRANSFORM_PROPERTIES.some((p) => isVideoKeyframes(_optionalChain([tf, 'optionalAccess', _37 => _37[p]])));
1993
+ const moving = VIDEO_TRANSFORM_PROPERTIES.some((p) => isVideoKeyframes(_optionalChain([tf, 'optionalAccess', _40 => _40[p]])));
1690
1994
  if (clip.type === "text" && options.titleImage) {
1691
1995
  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
1996
  index = inputs.length - 1;
@@ -1695,7 +1999,7 @@ function toVideoFfmpeg(project, options = {}) {
1695
1999
  const op = isVideoKeyframes(clip.opacity) ? `clip(${expr(clip.opacity, onTimeline, 1, "opacity")},0,1)` : String(_nullishCoalesce(clip.opacity, () => ( 1)));
1696
2000
  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
2001
  const coord = (axis) => {
1698
- const v = _optionalChain([tf, 'optionalAccess', _38 => _38[axis]]);
2002
+ const v = _optionalChain([tf, 'optionalAccess', _41 => _41[axis]]);
1699
2003
  const base = `(${axis === "x" ? "w-text_w" : "h-text_h"})/2`;
1700
2004
  return isVideoKeyframes(v) ? `${axis}='${base}+(${expr(v, onTimeline, 0, "position")})'` : `${axis}=${base}+${_nullishCoalesce(v, () => ( 0))}`;
1701
2005
  };
@@ -1703,7 +2007,7 @@ function toVideoFfmpeg(project, options = {}) {
1703
2007
  const opts = [
1704
2008
  `text=${ffmpegFilterValue(clip.text)}`,
1705
2009
  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))))}`,
2010
+ isVideoKeyframes(_optionalChain([tf, 'optionalAccess', _42 => _42.scale])) ? `fontsize='${fontSize}*(${expr(tf.scale, onTimeline, 1, "scale")})'` : `fontsize=${Math.round(fontSize * (_nullishCoalesce(_optionalChain([tf, 'optionalAccess', _43 => _43.scale]), () => ( 1))))}`,
1707
2011
  `fontcolor=${color(_nullishCoalesce(clip.color, () => ( "#ffffff")))}`,
1708
2012
  coord("x"),
1709
2013
  coord("y"),
@@ -1711,7 +2015,7 @@ function toVideoFfmpeg(project, options = {}) {
1711
2015
  ...clip.background ? ["box=1", `boxcolor=${color(clip.background)}`, `boxborderw=${Math.round((_nullishCoalesce(clip.fontSize, () => ( 64))) / 2)}`] : [],
1712
2016
  `enable='between(t,${sec(clip.start)},${sec(end)})'`
1713
2017
  ];
1714
- if (_optionalChain([tf, 'optionalAccess', _41 => _41.rotation])) notes.push(`Title "${clip.id}" is drawn without its rotation (drawtext cannot rotate).`);
2018
+ if (_optionalChain([tf, 'optionalAccess', _44 => _44.rotation])) notes.push(`Title "${clip.id}" is drawn without its rotation (drawtext cannot rotate).`);
1715
2019
  graph.push(`[${cur}]drawtext=${opts.join(":")}[${next}]`);
1716
2020
  cur = next;
1717
2021
  continue;
@@ -1727,7 +2031,7 @@ function toVideoFfmpeg(project, options = {}) {
1727
2031
  source = `[${index}:v]`;
1728
2032
  chain.push(`setpts=${videoClipSpeed(clip) !== 1 ? `(PTS-STARTPTS)/${videoClipSpeed(clip)}` : "PTS-STARTPTS"}`, `fps=${fps}`, `scale=${even(rect.width)}:${even(rect.height)}`);
1729
2033
  }
1730
- if (isVideoKeyframes(_optionalChain([placed, 'optionalAccess', _42 => _42.scale]))) {
2034
+ if (isVideoKeyframes(_optionalChain([placed, 'optionalAccess', _45 => _45.scale]))) {
1731
2035
  const s = expr(placed.scale, onChain("t"), 1, "scale");
1732
2036
  chain.push(`scale=w='max(2,trunc(${even(rect.width)}*(${s})/2)*2)':h='max(2,trunc(${even(rect.height)}*(${s})/2)*2)':eval=frame`);
1733
2037
  }
@@ -1744,14 +2048,14 @@ function toVideoFfmpeg(project, options = {}) {
1744
2048
  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
2049
  chain.push(`geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='if(${test},alpha(X,Y),0)'`);
1746
2050
  }
1747
- const rotation = _optionalChain([placed, 'optionalAccess', _43 => _43.rotation]);
2051
+ const rotation = _optionalChain([placed, 'optionalAccess', _46 => _46.rotation]);
1748
2052
  if (isVideoKeyframes(rotation)) chain.push(`rotate=a='(${expr(rotation, onChain("t"), 0, "rotation")})*PI/180':c=none:ow='hypot(iw,ih)':oh=ow`);
1749
2053
  else if (rotation) chain.push(`rotate=${rotation}*PI/180:c=none:ow=rotw(${rotation}*PI/180):oh=roth(${rotation}*PI/180)`);
1750
2054
  chain.push(`setpts=PTS+${sec(t0)}/TB`);
1751
2055
  const cx = rect.x + rect.width / 2;
1752
2056
  const cy = rect.y + rect.height / 2;
1753
2057
  const at = (axis, c) => {
1754
- const v = _optionalChain([placed, 'optionalAccess', _44 => _44[axis]]);
2058
+ const v = _optionalChain([placed, 'optionalAccess', _47 => _47[axis]]);
1755
2059
  return isVideoKeyframes(v) ? `${axis}='${Math.round(axis === "x" ? project.width / 2 : project.height / 2)}+(${expr(v, onTimeline, 0, "position")})-${axis === "x" ? "w" : "h"}/2'` : `${axis}='${Math.round(c)}-${axis === "x" ? "w" : "h"}/2'`;
1756
2060
  };
1757
2061
  graph.push(`${source.startsWith("[") ? source : `${source},`}${chain.join(",")}[l${next}]`);
@@ -1767,14 +2071,23 @@ function toVideoFfmpeg(project, options = {}) {
1767
2071
  }
1768
2072
  }
1769
2073
  }
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)))
2074
+ if (!measure) graph.push(`[${cur}]format=yuv420p[vout]`);
2075
+ if (measure && !audio.length) throw new Error("Nothing on the timeline makes a sound, so there is no loudness to measure.");
2076
+ const loudness = options.loudness || measure ? `,${loudnormFilter(options.loudness, measure)}` : "";
2077
+ if (audio.length) graph.push(`${audio.join("")}amix=inputs=${audio.length}:normalize=0:duration=longest,apad,atrim=0:${sec(D)}${loudness}[aout]`);
2078
+ if (options.loudness && !audio.length) notes.push("Nothing makes a sound, so there was no loudness to normalise.");
2079
+ else if (options.loudness && !options.loudness.measured)
2080
+ 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.");
2081
+ if (project.tracks.some((t) => t.clips.some((c) => c.type === "media" && _optionalChain([assets, 'access', _48 => _48.get, 'call', _49 => _49(c.asset), 'optionalAccess', _50 => _50.kind]) === "video" && _optionalChain([assets, 'access', _51 => _51.get, 'call', _52 => _52(c.asset), 'optionalAccess', _53 => _53.hasAudio]) === void 0)))
1773
2082
  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
2083
  if (animated.size)
1775
2084
  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)" : ""}.`);
1776
2085
  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
2086
  const filterComplex = graph.join(";");
2087
+ if (measure) {
2088
+ const args2 = ["-hide_banner", "-nostats", ...inputs.flat(), "-filter_complex", filterComplex, "-map", "[aout]", "-f", "null", "-"];
2089
+ return { args: args2, command: ["ffmpeg", ...args2].map(shellQuote).join(" "), filterComplex, duration: D, notes };
2090
+ }
1778
2091
  const args = [
1779
2092
  "-y",
1780
2093
  ...inputs.flat(),
@@ -1798,12 +2111,12 @@ function toVideoFfmpeg(project, options = {}) {
1798
2111
  }
1799
2112
 
1800
2113
  // 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);
2114
+ var VideoPreviewEngine = (_class3 = class {
2115
+ 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);
1803
2116
  this.source = source;
1804
2117
  this.options = options;
1805
2118
  this.canvas = canvas;
1806
- this.ctx = options.context !== void 0 ? options.context : _nullishCoalesce(_optionalChain([canvas, 'access', _51 => _51.getContext, 'optionalCall', _52 => _52("2d")]), () => ( null));
2119
+ this.ctx = options.context !== void 0 ? options.context : _nullishCoalesce(_optionalChain([canvas, 'access', _54 => _54.getContext, 'optionalCall', _55 => _55("2d")]), () => ( null));
1807
2120
  this.unsubscribe = source.subscribe(() => this.update());
1808
2121
  this.update();
1809
2122
  }
@@ -1811,11 +2124,11 @@ var VideoPreviewEngine = (_class2 = class {
1811
2124
 
1812
2125
 
1813
2126
 
1814
- __init2() {this.media = /* @__PURE__ */ new Map()}
1815
- __init3() {this.images = /* @__PURE__ */ new Map()}
2127
+ __init6() {this.media = /* @__PURE__ */ new Map()}
2128
+ __init7() {this.images = /* @__PURE__ */ new Map()}
1816
2129
 
1817
- __init4() {this.scheduled = false}
1818
- __init5() {this.destroyed = false}
2130
+ __init8() {this.scheduled = false}
2131
+ __init9() {this.destroyed = false}
1819
2132
  createMedia(kind) {
1820
2133
  if (this.options.createMedia) return this.options.createMedia(kind);
1821
2134
  const el = document.createElement(kind);
@@ -1851,8 +2164,8 @@ var VideoPreviewEngine = (_class2 = class {
1851
2164
  entry.media.pause();
1852
2165
  entry.media.removeEventListener("seeked", entry.onReady);
1853
2166
  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()]);
2167
+ _optionalChain([entry, 'access', _56 => _56.media, 'access', _57 => _57.removeAttribute, 'optionalCall', _58 => _58("src")]);
2168
+ _optionalChain([entry, 'access', _59 => _59.media, 'access', _60 => _60.load, 'optionalCall', _61 => _61()]);
1856
2169
  this.media.delete(entry.clip);
1857
2170
  }
1858
2171
  /** Brings the media elements in step with the editor and draws the frame. */
@@ -1885,7 +2198,7 @@ var VideoPreviewEngine = (_class2 = class {
1885
2198
  for (const layer of frame.layers) {
1886
2199
  if (layer.type !== "media" || layer.assetKind !== "video" || !layer.src) continue;
1887
2200
  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);
2201
+ sync(layer.clip, layer.src, "video", _nullishCoalesce(layer.sourceTime, () => ( 0)), _nullishCoalesce(_optionalChain([sound, 'optionalAccess', _62 => _62.speed]), () => ( 1)), sound ? sound.gain : null);
1889
2202
  sounding.delete(layer.clip);
1890
2203
  }
1891
2204
  for (const a of sounding.values()) sync(a.clip, a.src, "audio", a.sourceTime, a.speed, a.gain);
@@ -1900,7 +2213,7 @@ var VideoPreviewEngine = (_class2 = class {
1900
2213
  if (!this.destroyed) this.draw(videoFrameAt(this.source.getState().project, this.source.getState().time));
1901
2214
  });
1902
2215
  }
1903
- __init6() {this.sourceFor = (layer) => {
2216
+ __init10() {this.sourceFor = (layer) => {
1904
2217
  if (layer.assetKind === "image" && layer.src) {
1905
2218
  const img = this.image(layer.src);
1906
2219
  return img.complete ? img : null;
@@ -1918,7 +2231,7 @@ var VideoPreviewEngine = (_class2 = class {
1918
2231
  if (this.canvas.width !== w) this.canvas.width = w;
1919
2232
  if (this.canvas.height !== h) this.canvas.height = h;
1920
2233
  drawVideoFrame(this.ctx, frame, this.sourceFor, resolution);
1921
- _optionalChain([this, 'access', _60 => _60.options, 'access', _61 => _61.onFrame, 'optionalCall', _62 => _62(frame)]);
2234
+ _optionalChain([this, 'access', _63 => _63.options, 'access', _64 => _64.onFrame, 'optionalCall', _65 => _65(frame)]);
1922
2235
  }
1923
2236
  /** Stops following the editor and releases every media element. */
1924
2237
  destroy() {
@@ -1927,25 +2240,28 @@ var VideoPreviewEngine = (_class2 = class {
1927
2240
  for (const entry of [...this.media.values()]) this.release(entry);
1928
2241
  this.images.clear();
1929
2242
  }
1930
- }, _class2);
2243
+ }, _class3);
1931
2244
 
1932
2245
  // src/video-editor/controller.ts
1933
- var VideoEditorController = (_class3 = class {
2246
+ var VideoEditorController = (_class4 = class {
2247
+
1934
2248
 
2249
+ /** Sound levels per asset, loaded on demand for waveforms and agents. */
1935
2250
 
1936
2251
 
1937
2252
 
1938
- __init7() {this.selection = []}
2253
+ __init11() {this.selection = []}
1939
2254
 
1940
- __init8() {this.error = null}
1941
- __init9() {this.revision = 0}
1942
- __init10() {this.applying = false}
2255
+ __init12() {this.error = null}
2256
+ __init13() {this.revision = 0}
2257
+ __init14() {this.applying = false}
1943
2258
 
1944
- __init11() {this.listeners = /* @__PURE__ */ new Set()}
1945
- __init12() {this.unsubscribers = []}
2259
+ __init15() {this.listeners = /* @__PURE__ */ new Set()}
2260
+ __init16() {this.unsubscribers = []}
1946
2261
 
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);
2262
+ constructor(options = {}) {;_class4.prototype.__init11.call(this);_class4.prototype.__init12.call(this);_class4.prototype.__init13.call(this);_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);
1948
2263
  this.history = _nullishCoalesce(options.history, () => ( new (0, _chunkQKLRAA4Tcjs.HistoryManager)()));
2264
+ this.audioPeaks = options.audioPeaks instanceof VideoAudioPeaksCache ? options.audioPeaks : new VideoAudioPeaksCache(options.audioPeaks);
1949
2265
  this.project = options.project ? parseVideoProject(options.project) : createVideoProject();
1950
2266
  this.issues = checkVideoProject(this.project);
1951
2267
  this.readOnly = _nullishCoalesce(options.readOnly, () => ( false));
@@ -1968,8 +2284,8 @@ var VideoEditorController = (_class3 = class {
1968
2284
  this.player.pause();
1969
2285
  this.unsubscribers.splice(0).forEach((u) => u());
1970
2286
  }
1971
- __init13() {this.getState = () => this.state}
1972
- __init14() {this.subscribe = (listener) => {
2287
+ __init17() {this.getState = () => this.state}
2288
+ __init18() {this.subscribe = (listener) => {
1973
2289
  this.connect();
1974
2290
  this.listeners.add(listener);
1975
2291
  return () => {
@@ -2025,7 +2341,7 @@ var VideoEditorController = (_class3 = class {
2025
2341
  this.applying = false;
2026
2342
  }
2027
2343
  this.notify();
2028
- _optionalChain([this, 'access', _63 => _63.onChange, 'optionalCall', _64 => _64(project)]);
2344
+ _optionalChain([this, 'access', _66 => _66.onChange, 'optionalCall', _67 => _67(project)]);
2029
2345
  }
2030
2346
  commit(label, next) {
2031
2347
  const before = this.project;
@@ -2089,7 +2405,7 @@ var VideoEditorController = (_class3 = class {
2089
2405
  }
2090
2406
  /** Selects clips (unknown ids are dropped). `add` toggles them into the current selection. */
2091
2407
  select(clips, options = {}) {
2092
- const known = _nullishCoalesce(_optionalChain([clips, 'optionalAccess', _65 => _65.filter, 'call', _66 => _66((id) => findVideoClip(this.project, id))]), () => ( []));
2408
+ const known = _nullishCoalesce(_optionalChain([clips, 'optionalAccess', _68 => _68.filter, 'call', _69 => _69((id) => findVideoClip(this.project, id))]), () => ( []));
2093
2409
  let next;
2094
2410
  if (options.add) {
2095
2411
  next = [...this.selection];
@@ -2162,13 +2478,18 @@ var VideoEditorController = (_class3 = class {
2162
2478
  toFfmpeg(options = {}) {
2163
2479
  return toVideoFfmpeg(this.project, options);
2164
2480
  }
2481
+ /** The ffmpeg command that measures the mix's loudness (see `toVideoLoudnessMeasure`). */
2482
+ toLoudnessMeasure(options = {}) {
2483
+ return toVideoLoudnessMeasure(this.project, options);
2484
+ }
2165
2485
  /** One track as a CMX 3600 EDL. */
2166
2486
  toEdl(options = {}) {
2167
2487
  return toVideoEdl(this.project, options);
2168
2488
  }
2169
- }, _class3);
2489
+ }, _class4);
2170
2490
 
2171
2491
  // src/video-editor/tools.ts
2492
+ var round = (ms) => Math.round(ms * 10) / 10;
2172
2493
  var READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
2173
2494
  var WRITE = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
2174
2495
  var WRITE_IDEMPOTENT = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false };
@@ -2228,7 +2549,9 @@ var MEDIA_FIELDS = {
2228
2549
  in: MS("Where in the source it starts (default 0)"),
2229
2550
  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
2551
  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)." }
2552
+ muted: { type: "boolean", description: "Silence this clip (default false)." },
2553
+ 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)." },
2554
+ 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
2555
  };
2233
2556
  var TEXT_FIELDS = {
2234
2557
  text: { type: "string", minLength: 1, description: `The title's text ("\\n" breaks lines).` },
@@ -2354,14 +2677,14 @@ function createVideoEditorToolProvider(controller, options = {}) {
2354
2677
  return null;
2355
2678
  };
2356
2679
  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 } : {},
2680
+ ..._optionalChain([o, 'access', _70 => _70.created, 'optionalAccess', _71 => _71.length]) ? { created: o.created } : {},
2681
+ ..._optionalChain([o, 'access', _72 => _72.trimmed, 'optionalAccess', _73 => _73.length]) ? { trimmed: o.trimmed } : {},
2682
+ ..._optionalChain([o, 'access', _74 => _74.removed, 'optionalAccess', _75 => _75.length]) ? { removed: o.removed } : {},
2360
2683
  ...o.rippled ? { rippled: o.rippled } : {}
2361
2684
  });
2362
2685
  const keysOut = (p, clip, property) => {
2363
2686
  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]]);
2687
+ const value = VIDEO_TRANSFORM_PROPERTIES.includes(property) ? _optionalChain([c, 'optionalAccess', _76 => _76.transform, 'optionalAccess', _77 => _77[property]]) : _optionalChain([c, 'optionalAccess', _78 => _78[property]]);
2365
2688
  return typeof value === "object" ? { keys: value.keys } : { keys: [], value: _nullishCoalesce(value, () => ( null)) };
2366
2689
  };
2367
2690
  const getProject = {
@@ -2432,27 +2755,59 @@ function createVideoEditorToolProvider(controller, options = {}) {
2432
2755
  return { svg };
2433
2756
  }
2434
2757
  };
2758
+ const LOUDNESS_TARGET = {
2759
+ 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)." },
2760
+ truePeak: { type: "number", minimum: -9, maximum: 0, description: "Highest true peak, in dBTP (default -1)." },
2761
+ range: { type: "number", minimum: 1, maximum: 50, description: "Loudness range, in LU (default 11)." }
2762
+ };
2435
2763
  const exportFfmpeg = {
2436
2764
  name: "export_video_ffmpeg",
2437
2765
  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.",
2766
+ 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
2767
  inputSchema: object({
2440
2768
  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)." }
2769
+ crf: { type: "integer", minimum: 0, maximum: 51, description: "x264 quality, lower is better (default 18)." },
2770
+ loudness: object(
2771
+ {
2772
+ ...LOUDNESS_TARGET,
2773
+ measured: object(
2774
+ {
2775
+ integrated: { type: "number", description: `Measured integrated loudness, LUFS (loudnorm's "input_i").` },
2776
+ truePeak: { type: "number", description: 'Measured true peak, dBTP ("input_tp").' },
2777
+ range: { type: "number", description: 'Measured loudness range, LU ("input_lra").' },
2778
+ threshold: { type: "number", description: 'Measured gating threshold, LUFS ("input_thresh").' },
2779
+ offset: { type: "number", description: 'Offset for the second pass, LU ("target_offset").' }
2780
+ },
2781
+ ["integrated", "truePeak", "range", "threshold", "offset"],
2782
+ "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."
2783
+ )
2784
+ },
2785
+ [],
2786
+ "Normalise the mix's loudness (default: not normalised). An empty object uses -14 LUFS, -1 dBTP."
2787
+ )
2442
2788
  }),
2443
2789
  annotations: { title: "Export an ffmpeg command", ...READ },
2444
- handler: ({ output, crf }) => controller.toFfmpeg({ ...output ? { output } : {}, ...crf !== void 0 ? { crf } : {} })
2790
+ handler: ({ output, crf, loudness }) => controller.toFfmpeg({ ...output ? { output } : {}, ...crf !== void 0 ? { crf } : {}, ...loudness ? { loudness } : {} })
2791
+ };
2792
+ const exportLoudnessMeasure = {
2793
+ name: "export_video_loudness_measure",
2794
+ title: "Export a loudness measuring command",
2795
+ 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.",
2796
+ inputSchema: object(LOUDNESS_TARGET),
2797
+ annotations: { title: "Export a loudness measuring command", ...READ },
2798
+ handler: (target) => controller.toLoudnessMeasure({ loudness: target })
2445
2799
  };
2446
2800
  const exportEdl = {
2447
2801
  name: "export_video_edl",
2448
2802
  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.",
2803
+ 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
2804
  inputSchema: object({
2451
2805
  track: { ...TRACK_ID, description: "Track to export (default: the bottom video track)." },
2452
- title: { type: "string", description: "TITLE line (default: the project name)." }
2806
+ title: { type: "string", description: "TITLE line (default: the project name)." },
2807
+ 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
2808
  }),
2454
2809
  annotations: { title: "Export an EDL", ...READ },
2455
- handler: ({ track, title }) => ({ edl: controller.toEdl({ ...track ? { track } : {}, ...title ? { title } : {} }) })
2810
+ handler: ({ track, title, dropFrame }) => ({ edl: controller.toEdl({ ...track ? { track } : {}, ...title ? { title } : {}, ...dropFrame !== void 0 ? { dropFrame } : {} }) })
2456
2811
  };
2457
2812
  const seek = {
2458
2813
  name: "seek_video",
@@ -2601,12 +2956,12 @@ function createVideoEditorToolProvider(controller, options = {}) {
2601
2956
  const updateClip = {
2602
2957
  name: "update_video_clip",
2603
2958
  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.`,
2959
+ description: `Changes a clip's properties (one undo step): opacity, transform, fit, fades, name; for media volume, mute, the sound's fade curves, speed (keeps the stretch of source, so the length changes) or asset; for titles text and style; for colours the colour. Fields in "remove" go back to their defaults. Use move / trim for timing. Returns the clip.`,
2605
2960
  inputSchema: object(
2606
2961
  {
2607
2962
  clip: CLIP_ID,
2608
2963
  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." }
2964
+ remove: { type: "array", items: { type: "string", enum: ["name", "opacity", "transform", "fit", "fadeIn", "fadeOut", "fadeInCurve", "fadeOutCurve", "in", "speed", "volume", "muted", "fontSize", "fontFamily", "fontWeight", "background"] }, description: "Fields to reset to their defaults." }
2610
2965
  },
2611
2966
  ["clip"]
2612
2967
  ),
@@ -2614,7 +2969,7 @@ function createVideoEditorToolProvider(controller, options = {}) {
2614
2969
  handler: ({ clip, set, remove }) => {
2615
2970
  const current = clipOut(project(), clip);
2616
2971
  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"]]);
2972
+ const allowed = /* @__PURE__ */ new Set(["name", "opacity", "transform", "fit", "fadeIn", "fadeOut", ...current.type === "media" ? ["in", "speed", "volume", "muted", "asset", "fadeInCurve", "fadeOutCurve"] : current.type === "text" ? Object.keys(TEXT_FIELDS) : ["color"]]);
2618
2973
  const wrong = Object.keys(set).filter((k) => !allowed.has(k));
2619
2974
  if (wrong.length) throw new Error(`${wrong.map((k) => `"${k}"`).join(", ")} cannot be set on a ${current.type} clip; it takes ${[...allowed].join(", ")}.`);
2620
2975
  }
@@ -2771,7 +3126,74 @@ function createVideoEditorToolProvider(controller, options = {}) {
2771
3126
  return { redone, ...redone ? { label } : {} };
2772
3127
  }
2773
3128
  };
2774
- const reads = [getProject, getFrame, findGaps, checkProject, renderFrame, exportFfmpeg, exportEdl, seek];
3129
+ const soundRange = (args) => {
3130
+ const p = project();
3131
+ 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.');
3132
+ let asset;
3133
+ let toSource = (t) => t;
3134
+ let toOut = (t) => t;
3135
+ let from;
3136
+ let to;
3137
+ if (args.clip !== void 0) {
3138
+ const found = findVideoClip(p, args.clip);
3139
+ if (!found) throw new Error(`There is no clip "${args.clip}"; get_video_project lists the clips.`);
3140
+ const c = found.clip;
3141
+ if (c.type !== "media") throw new Error(`Clip "${c.id}" is a ${c.type} clip and has no sound; pass a media clip.`);
3142
+ asset = p.assets.find((a) => a.id === c.asset);
3143
+ const speed = _nullishCoalesce(c.speed, () => ( 1));
3144
+ toSource = (t) => (_nullishCoalesce(c.in, () => ( 0))) + (t - c.start) * speed;
3145
+ toOut = (s) => c.start + (s - (_nullishCoalesce(c.in, () => ( 0)))) / speed;
3146
+ from = Math.max(c.start, _nullishCoalesce(args.from, () => ( c.start)));
3147
+ to = Math.min(videoClipEnd(c), _nullishCoalesce(args.to, () => ( videoClipEnd(c))));
3148
+ } else {
3149
+ asset = p.assets.find((a) => a.id === args.asset);
3150
+ if (!asset) throw new Error(`There is no asset "${args.asset}"; the assets are ${p.assets.map((a) => `"${a.id}"`).join(", ") || "none"}.`);
3151
+ from = _nullishCoalesce(args.from, () => ( 0));
3152
+ to = _nullishCoalesce(_nullishCoalesce(args.to, () => ( asset.duration)), () => ( 0));
3153
+ }
3154
+ if (!asset) throw new Error(`Clip "${args.clip}" uses a missing asset.`);
3155
+ if (asset.kind === "image" || asset.hasAudio === false) throw new Error(`Asset "${asset.id}" has no sound.`);
3156
+ 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"' : ""}.`);
3157
+ return { asset, from, to, toSource, toOut };
3158
+ };
3159
+ const SOUND_TARGET = {
3160
+ clip: { ...CLIP_ID, description: "A media clip: read the stretch of source it plays, with times on the timeline. Pass this or asset." },
3161
+ asset: { ...ASSET_ID, description: "A video or audio asset: read its source, with times in the source. Pass this or clip." },
3162
+ from: MS("Start of the stretch (default: the clip's start, or 0 for an asset)"),
3163
+ to: MS("End of the stretch (default: the clip's end, or the asset's duration)")
3164
+ };
3165
+ const SOUND_ANNOTATIONS = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
3166
+ const audioLevels = {
3167
+ name: "get_video_audio_levels",
3168
+ title: "Read sound levels",
3169
+ 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.',
3170
+ inputSchema: object({ ...SOUND_TARGET, buckets: { type: "integer", minimum: 1, maximum: 500, description: "How many equal stretches to measure (default 50)." } }),
3171
+ annotations: { title: "Read sound levels", ...SOUND_ANNOTATIONS },
3172
+ handler: async (args) => {
3173
+ const r = soundRange(args);
3174
+ const peaks = await controller.audioPeaks.load(r.asset);
3175
+ 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 }));
3176
+ return { asset: r.asset.id, from: r.from, to: r.to, peakDb: Math.max(...levels.map((l) => l.peakDb)), levels };
3177
+ }
3178
+ };
3179
+ const findSilences = {
3180
+ name: "find_video_silences",
3181
+ title: "Find silences",
3182
+ 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.",
3183
+ inputSchema: object({
3184
+ ...SOUND_TARGET,
3185
+ 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)." },
3186
+ minDuration: { type: "number", exclusiveMinimum: 0, description: "Shortest silence to report, in ms (default 300)." }
3187
+ }),
3188
+ annotations: { title: "Find silences", ...SOUND_ANNOTATIONS },
3189
+ handler: async (args) => {
3190
+ const r = soundRange(args);
3191
+ const peaks = await controller.audioPeaks.load(r.asset);
3192
+ 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)) }));
3193
+ return { asset: r.asset.id, from: r.from, to: r.to, silences, total: round(silences.reduce((n2, x) => n2 + x.end - x.start, 0)) };
3194
+ }
3195
+ };
3196
+ const reads = [getProject, getFrame, findGaps, checkProject, renderFrame, exportFfmpeg, exportLoudnessMeasure, exportEdl, audioLevels, findSilences, seek];
2775
3197
  const writes = [setSettings, addAsset, updateAsset, removeAsset, addTrack, updateTrack, removeTrack, moveTrack, addClip, moveClip, trimClip, splitClip, deleteClips, updateClip, setTransition, setKeyframes, removeKeyframes, slipClip, rollEdit, slideClip, liftRange, extractRange, splitAt, closeGaps, addMarker, removeMarker, editVideo, undo, redo];
2776
3198
  return {
2777
3199
  id: _nullishCoalesce(options.id, () => ( "video")),
@@ -2804,6 +3226,17 @@ function videoRulerTicks(duration, pxPerSecond, fps) {
2804
3226
  }
2805
3227
  return ticks;
2806
3228
  }
3229
+ function videoClipShowsWaveform(clip, asset, mode = "audio") {
3230
+ if (clip.type !== "media" || mode === "none" || !videoAssetHasSound(asset)) return false;
3231
+ return mode === "all" || asset.kind === "audio";
3232
+ }
3233
+ var VIDEO_FADE_CURVE_LABELS = {
3234
+ linear: "Linear",
3235
+ "equal-power": "Equal power",
3236
+ "s-curve": "S-curve",
3237
+ exponential: "Exponential",
3238
+ logarithmic: "Logarithmic"
3239
+ };
2807
3240
  function videoClipTint(clip, asset) {
2808
3241
  if (clip.type === "text") return "#a855f7";
2809
3242
  if (clip.type === "color") return clip.color;
@@ -2814,7 +3247,7 @@ function videoClipLabel(clip, asset) {
2814
3247
  if (clip.name) return clip.name;
2815
3248
  if (clip.type === "text") return clip.text.split("\n")[0];
2816
3249
  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));
3250
+ const base = _nullishCoalesce(_nullishCoalesce(_optionalChain([asset, 'optionalAccess', _79 => _79.name]), () => ( _optionalChain([asset, 'optionalAccess', _80 => _80.src, 'access', _81 => _81.replace, 'call', _82 => _82(/^.*[\\/]/, "")]))), () => ( clip.asset));
2818
3251
  const speed = videoClipSpeed(clip);
2819
3252
  return speed !== 1 ? `${base} (${Math.round(speed * 100)}%)` : base;
2820
3253
  }
@@ -2858,7 +3291,7 @@ function videoAssetDropEdit(project, assetId, options = {}) {
2858
3291
  const asset = videoAssetMap(project).get(assetId);
2859
3292
  if (!asset) return null;
2860
3293
  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])));
3294
+ const track = _nullishCoalesce(options.track, () => ( _optionalChain([project, 'access', _83 => _83.tracks, 'access', _84 => _84.find, 'call', _85 => _85((t) => t.kind === kind && !t.locked), 'optionalAccess', _86 => _86.id])));
2862
3295
  if (!track) return null;
2863
3296
  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
3297
  }
@@ -2907,15 +3340,15 @@ var KEY_LABELS = {
2907
3340
  scale: { label: "Scale", step: 0.05 },
2908
3341
  rotation: { label: "Rotation", step: 1 }
2909
3342
  };
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;
3343
+ var animatableOf = (clip, property) => VIDEO_TRANSFORM_PROPERTIES.includes(property) ? _optionalChain([clip, 'access', _87 => _87.transform, 'optionalAccess', _88 => _88[property]]) : property === "volume" ? clip.type === "media" ? clip.volume : void 0 : clip.opacity;
2911
3344
  var playheadInClip = (clip, time) => Math.round(Math.min(clip.duration, Math.max(0, time - clip.start)));
2912
3345
  function videoKeyframeRows(project, clipId, time) {
2913
3346
  const at = findVideoClip(project, clipId);
2914
3347
  if (!at) return [];
2915
3348
  const { clip } = at;
2916
3349
  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);
3350
+ const visual = !(_optionalChain([asset, 'optionalAccess', _89 => _89.kind]) === "audio" || at.track.kind === "audio");
3351
+ const audible = clip.type === "media" && videoAssetHasSound(asset);
2919
3352
  const properties = [...visual ? ["opacity"] : [], ...audible ? ["volume"] : [], ...visual ? VIDEO_TRANSFORM_PROPERTIES : []];
2920
3353
  const clipTime = playheadInClip(clip, time);
2921
3354
  return properties.map((property) => {
@@ -3060,5 +3493,30 @@ function videoKeyframeToggleEdit(project, clipId, property, time) {
3060
3493
 
3061
3494
 
3062
3495
 
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;
3496
+
3497
+
3498
+
3499
+
3500
+
3501
+
3502
+
3503
+
3504
+
3505
+
3506
+
3507
+
3508
+
3509
+
3510
+
3511
+
3512
+
3513
+
3514
+
3515
+
3516
+
3517
+
3518
+
3519
+
3520
+
3521
+ 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_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_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.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.parseVideoAnimatable = parseVideoAnimatable; exports.parseVideoAsset = parseVideoAsset; exports.parseVideoClip = parseVideoClip; 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.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.videoHourFrames = videoHourFrames; 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.videoValueAt = videoValueAt; exports.videoWaveformPath = videoWaveformPath; exports.videoWipeRect = videoWipeRect;
3064
3522
  //# sourceMappingURL=index.cjs.map