@pixerate/editor 0.82.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.
@@ -15,6 +15,7 @@ import "../chunk-AGR4P4UZ.js";
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
  }
@@ -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 {
707
736
  constructor(p) {
@@ -797,6 +826,7 @@ var Editor = class {
797
826
  const right = { ...structuredClone(clip), id: 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 {
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;
@@ -1356,12 +1387,32 @@ 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(shape?.fadeInCurve, (time - clip.start) / clip.fadeIn);
1409
+ if (clip.fadeOut) g *= videoFadeCurveGain(shape?.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)) {
@@ -1387,15 +1438,16 @@ function videoFrameAt(project, time) {
1387
1438
  for (const { clip, incoming, outgoing } of activeOnTrack(track, time)) {
1388
1439
  if (incoming?.type === "dip" && incoming.progress < 0.5) continue;
1389
1440
  if (outgoing?.type === "dip" && outgoing.progress >= 0.5) continue;
1390
- const fade = fadeGain(clip, Math.min(Math.max(time, clip.start), videoClipEnd(clip)));
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) * (track.volume ?? 1) * fade * mix;
1450
+ const gain = videoValueAt(clip.volume, clipTime, 1) * (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;
@@ -1424,6 +1476,223 @@ function videoFrameAt(project, time) {
1424
1476
  return { time, width: project.width, height: project.height, background: 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 = clip.speed ?? 1;
1564
+ const start = 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 = options.thresholdDb ?? -40;
1599
+ const minDuration = options.minDuration ?? 300;
1600
+ const resolution = Math.max(1, 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 {
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 = class {
1631
+ constructor(loader = (asset) => decodeVideoAudioPeaks(asset.src)) {
1632
+ this.loader = loader;
1633
+ }
1634
+ loader;
1635
+ ready = /* @__PURE__ */ new Map();
1636
+ pending = /* @__PURE__ */ new Map();
1637
+ failed = /* @__PURE__ */ new Map();
1638
+ 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
+ };
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);
@@ -1562,15 +1831,17 @@ 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 = options.recordStart ?? 36e5;
1834
+ const dropFrame = (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: ${(options.title ?? project.name ?? "Untitled").toUpperCase()}`, "FCM: NON-DROP FRAME", ""];
1840
+ const lines = [`TITLE: ${(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" ? (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";
@@ -1597,7 +1868,34 @@ function toVideoEdl(project, options = {}) {
1597
1868
  }
1598
1869
  return lines.join("\n");
1599
1870
  }
1871
+ function loudnormFilter(target = {}, measure = false) {
1872
+ const I = target.integrated ?? -14;
1873
+ const TP = target.truePeak ?? -1;
1874
+ const LRA = 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(project.background ?? "#000000")}:s=${W}x${H}:r=${fps}:d=${sec(D)},format=yuva420p[base]`];
1937
+ const graph = measure ? [] : [`color=c=${color(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 = index ?? input(clip, asset, lead, tail);
1668
1972
  const trackGain = track.volume ?? 1;
@@ -1674,13 +1978,13 @@ function toVideoFfmpeg(project, options = {}) {
1674
1978
  if (before2 && clip.transition.type === "dip") chain2.push(`afade=t=in:st=0:d=${sec(clip.transition.duration / 2)}`);
1675
1979
  const after = sortedVideoClips(track).find((c) => c !== clip && Math.abs(c.start - end) < 0.5 && c.transition?.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)})`;
@@ -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]`);
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.");
1772
2081
  if (project.tracks.some((t) => t.clips.some((c) => c.type === "media" && assets.get(c.asset)?.kind === "video" && assets.get(c.asset)?.hasAudio === void 0)))
1773
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(),
@@ -1933,6 +2246,8 @@ var VideoPreviewEngine = class {
1933
2246
  var VideoEditorController = class {
1934
2247
  history;
1935
2248
  player;
2249
+ /** Sound levels per asset, loaded on demand for waveforms and agents. */
2250
+ audioPeaks;
1936
2251
  project;
1937
2252
  issues;
1938
2253
  selection = [];
@@ -1946,6 +2261,7 @@ var VideoEditorController = class {
1946
2261
  state;
1947
2262
  constructor(options = {}) {
1948
2263
  this.history = options.history ?? new 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 = options.readOnly ?? false;
@@ -2162,6 +2478,10 @@ var VideoEditorController = 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);
@@ -2169,6 +2489,7 @@ var VideoEditorController = class {
2169
2489
  };
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).` },
@@ -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 = c.speed ?? 1;
3144
+ toSource = (t) => (c.in ?? 0) + (t - c.start) * speed;
3145
+ toOut = (s) => c.start + (s - (c.in ?? 0)) / speed;
3146
+ from = Math.max(c.start, args.from ?? c.start);
3147
+ to = Math.min(videoClipEnd(c), 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 = args.from ?? 0;
3152
+ to = 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), 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: 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;
@@ -2915,7 +3348,7 @@ function videoKeyframeRows(project, clipId, time) {
2915
3348
  const { clip } = at;
2916
3349
  const asset = clip.type === "media" ? videoAssetMap(project).get(clip.asset) : void 0;
2917
3350
  const visual = !(asset?.kind === "audio" || at.track.kind === "audio");
2918
- const audible = clip.type === "media" && (asset?.kind === "audio" || asset?.kind === "video" && asset.hasAudio !== false);
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) => {
@@ -2972,35 +3405,45 @@ export {
2972
3405
  VIDEO_DEFAULT_TITLE_DURATION,
2973
3406
  VIDEO_EASE_NAMES,
2974
3407
  VIDEO_EDIT_OPS,
3408
+ VIDEO_FADE_CURVES,
3409
+ VIDEO_FADE_CURVE_LABELS,
2975
3410
  VIDEO_FITS,
2976
3411
  VIDEO_ISSUE_CODES,
2977
3412
  VIDEO_KEYFRAME_LIMITS,
2978
3413
  VIDEO_KEYFRAME_PROPERTIES,
3414
+ VIDEO_PEAK_BLOCK,
2979
3415
  VIDEO_PLACEMENT_MODES,
2980
3416
  VIDEO_TIME_EPSILON,
2981
3417
  VIDEO_TRANSFORM_PROPERTIES,
2982
3418
  VIDEO_TRANSITION_TYPES,
2983
3419
  VIDEO_WIPE_DIRECTIONS,
3420
+ VideoAudioPeaksCache,
2984
3421
  VideoEditorController,
2985
3422
  VideoPreviewEngine,
2986
3423
  allVideoClipIds,
2987
3424
  applyVideoEdits,
3425
+ buildVideoAudioPeaks,
2988
3426
  checkVideoProject,
2989
3427
  createVideoEditorToolProvider,
2990
3428
  createVideoProject,
3429
+ decodeVideoAudioPeaks,
2991
3430
  describeVideoProject,
2992
3431
  drawVideoFrame,
2993
3432
  ffmpegFilterValue,
3433
+ findVideoAudioSilences,
2994
3434
  findVideoClip,
2995
3435
  findVideoGaps,
2996
3436
  formatVideoSeconds,
2997
3437
  formatVideoTimecode,
3438
+ formatVideoTimecodeFrames,
3439
+ isVideoDropFrameRate,
2998
3440
  isVideoKeyframes,
2999
3441
  nextFreeVideoSlot,
3000
3442
  parseVideoAnimatable,
3001
3443
  parseVideoAsset,
3002
3444
  parseVideoClip,
3003
3445
  parseVideoKeyframes,
3446
+ parseVideoLoudnessMeasurement,
3004
3447
  parseVideoMarker,
3005
3448
  parseVideoProject,
3006
3449
  parseVideoTrack,
@@ -3019,9 +3462,15 @@ export {
3019
3462
  splitVideoAnimatable,
3020
3463
  toVideoEdl,
3021
3464
  toVideoFfmpeg,
3465
+ toVideoLoudnessMeasure,
3022
3466
  videoAssetDropEdit,
3467
+ videoAssetHasSound,
3023
3468
  videoAssetKindOf,
3024
3469
  videoAssetMap,
3470
+ videoAudioLevels,
3471
+ videoAudioPeakRange,
3472
+ videoAudioPeakSpans,
3473
+ videoAudioPeaksDuration,
3025
3474
  videoClipAfter,
3026
3475
  videoClipBefore,
3027
3476
  videoClipEnd,
@@ -3029,19 +3478,25 @@ export {
3029
3478
  videoClipKeyframes,
3030
3479
  videoClipLabel,
3031
3480
  videoClipOverhang,
3481
+ videoClipShowsWaveform,
3482
+ videoClipSoundGain,
3032
3483
  videoClipSourceRange,
3033
3484
  videoClipSpeed,
3034
3485
  videoClipTint,
3486
+ videoClipWaveform,
3487
+ videoClipWaveformPath,
3035
3488
  videoClipsAt,
3036
3489
  videoContactSheetSvg,
3037
3490
  videoDragLabel,
3038
3491
  videoDropTracks,
3039
3492
  videoEaseBezier,
3040
3493
  videoEaseProgress,
3494
+ videoFadeCurveGain,
3041
3495
  videoFrameAt,
3042
3496
  videoFrameDuration,
3043
3497
  videoFrameToSvg,
3044
3498
  videoFrames,
3499
+ videoHourFrames,
3045
3500
  videoIssueKey,
3046
3501
  videoKeyframeBreakpoints,
3047
3502
  videoKeyframeFfmpegExpr,
@@ -3050,15 +3505,18 @@ export {
3050
3505
  videoKeyframeValueEdit,
3051
3506
  videoKeysOf,
3052
3507
  videoOverlap,
3508
+ videoPeakDb,
3053
3509
  videoProjectDuration,
3054
3510
  videoProjectSvg,
3055
3511
  videoRulerTicks,
3056
3512
  videoSnapPoints,
3057
3513
  videoTextStyle,
3514
+ videoTimecodeFrames,
3058
3515
  videoTitleFrame,
3059
3516
  videoTrackSyncLocked,
3060
3517
  videoTransformAt,
3061
3518
  videoValueAt,
3519
+ videoWaveformPath,
3062
3520
  videoWipeRect
3063
3521
  };
3064
3522
  //# sourceMappingURL=index.js.map