@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.
- package/dist/video-editor/index.cjs +557 -99
- package/dist/video-editor/index.cjs.map +1 -1
- package/dist/video-editor/index.d.cts +210 -5
- package/dist/video-editor/index.d.ts +210 -5
- package/dist/video-editor/index.js +493 -35
- package/dist/video-editor/index.js.map +1 -1
- package/package.json +1 -1
- package/skills/pixerate-video-editor/SKILL.md +24 -6
|
@@ -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
|
|
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
|
-
|
|
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)}
|
|
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
|
|
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
|
-
|
|
1362
|
-
if (clip.
|
|
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
|
|
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 =
|
|
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) *
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
|
1566
|
-
const
|
|
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()}`,
|
|
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)} ${
|
|
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 =
|
|
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)
|
|
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
|
|
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
|
|
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
|