fix: preserve plateaus in sampled audio automation (#2863)

* fix: preserve audio automation plateaus

* fix(audio): align automation probe windows
This commit is contained in:
Miguel Ángel
2026-07-29 20:51:33 +02:00
committed by GitHub
parent 85f0c9d354
commit 04e0ccce42
4 changed files with 227 additions and 35 deletions
@@ -4,6 +4,7 @@ import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { parseHTML } from "linkedom";
import { interpolateVolumeGain } from "@hyperframes/core/media-volume-envelope";
import { defaultLogger } from "../logger.js";
import {
collectExternalAssets,
@@ -1763,6 +1764,58 @@ h1 { font-size: 2rem; }`;
});
describe("discoverAudioVolumeAutomationFromTimeline", () => {
it("emits plateau boundaries around a sampled volume change", async () => {
class TestAudioElement {
id = "music";
dataset = { start: "0", duration: "3", volume: "0.8" };
volume = 0.8;
}
class TestVideoElement {}
const audio = new TestAudioElement();
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
const previousAudioElement = globalThis.HTMLAudioElement;
const previousVideoElement = globalThis.HTMLVideoElement;
globalThis.window = {
__timelines: {
root: {
totalTime: (time: number) => {
audio.volume = time < 1.05 ? 0.8 : 0.2;
},
},
},
} as any;
globalThis.document = {
querySelector: (selector: string) =>
selector === "[data-composition-id]"
? { getAttribute: (name: string) => (name === "data-composition-id" ? "root" : null) }
: null,
getElementById: (id: string) => (id === "music" ? audio : null),
} as any;
globalThis.HTMLAudioElement = TestAudioElement as any;
globalThis.HTMLVideoElement = TestVideoElement as any;
try {
const page = {
evaluate: async (fn: (arg: unknown) => unknown, arg: unknown) => fn(arg),
} as any;
const [automation] = await discoverAudioVolumeAutomationFromTimeline(page, ["music"], 3, 10);
expect(automation?.keyframes).toContainEqual({ time: 1, volume: 0.8 });
expect(automation?.keyframes).toContainEqual({ time: 1.1, volume: 0.2 });
expect(interpolateVolumeGain(automation?.keyframes ?? [], 0.5)).toBeCloseTo(0.8, 6);
expect(interpolateVolumeGain(automation?.keyframes ?? [], 1.5)).toBeCloseTo(0.2, 6);
} finally {
globalThis.window = previousWindow;
globalThis.document = previousDocument;
globalThis.HTMLAudioElement = previousAudioElement;
globalThis.HTMLVideoElement = previousVideoElement;
}
});
it("prefers runtime duration over stale data-end while sampling video-derived audio", async () => {
class TestAudioElement {}
class TestVideoElement {
+25 -12
View File
@@ -2150,20 +2150,33 @@ export async function discoverAudioVolumeAutomationFromTimeline(
}
const keyframes: { time: number; volume: number }[] = [];
for (let t = sampleStart; t <= sampleEnd + 0.000001; t += step) {
const boundedTime = Math.min(sampleEnd, t);
seekTl(boundedTime);
let previousSample: { time: number; volume: number } | undefined;
for (let t = sampleStart; t <= sampleEnd + 0.000001; t = Math.min(sampleEnd, t + step)) {
seekTl(t);
const rawVolume = Number(el.volume);
if (!Number.isFinite(rawVolume)) continue;
const volume = Math.max(0, Math.min(1, rawVolume));
const last = keyframes.at(-1);
if (!last || Math.abs(last.volume - volume) > 0.0001 || boundedTime === sampleEnd) {
keyframes.push({
time: Number(boundedTime.toFixed(6)),
volume: Number(volume.toFixed(6)),
});
if (!Number.isFinite(rawVolume)) {
if (t === sampleEnd) break;
continue;
}
if (boundedTime === sampleEnd) break;
const volume = Math.max(0, Math.min(1, rawVolume));
const sample = {
time: Number(t.toFixed(6)),
volume: Number(volume.toFixed(6)),
};
const last = keyframes.at(-1);
if (!last || Math.abs(last.volume - volume) > 0.0001) {
// Retain the preceding real sample when compression omitted a flat
// run. Continuous ramps already have that sample as their last
// keyframe, so their interpolation remains unchanged.
if (last && previousSample && previousSample.time > last.time) {
keyframes.push(previousSample);
}
keyframes.push(sample);
} else if (t === sampleEnd && sample.time > last.time) {
keyframes.push(sample);
}
previousSample = sample;
if (t === sampleEnd) break;
}
const staticAttr = Number.parseFloat(el.dataset.volume ?? "");