Skip to content

Commit eee9b26

Browse files
fix(core): key the preview volume envelope to the clip, not the timeline (#3198)
A GSAP volume fade on an audio clip that starts after t=0 left the preview silent for the clip's whole length while the encoded render was correct. `mediaVolumeEnvelope` is meant to keep preview and render on one envelope, and its contract is "normalise, then read with track-relative seconds". The preview skipped both halves. `probeElementVolumeKeyframes` stamps each keyframe with the TIMELINE seek time it sampled at, and `normaliseEnvelope` — the function that rebases those onto the track — had exactly one caller, the renderer's PCM baker. The preview handed the raw keyframes to `interpolateVolumeGain` along with `relTime`, so for a clip at t=2 every lookup fell two seconds before the first keyframe and clamped to its volume: 0 for a fade-in. Rebase once, where the cache is filled, so the cached envelope has a single documented time base. Read it with elapsed-time-in-clip rather than `relTime`, which is a position inside the media SOURCE — it carries `mediaStart` and the playback rate, and only coincides with the envelope's time base for an untrimmed clip playing at 1x from zero. That second half also fixes a latent sibling: a trimmed clip read the wrong envelope point even when it started at 0.
1 parent 91f1495 commit eee9b26

3 files changed

Lines changed: 60 additions & 5 deletions

File tree

packages/core/src/runtime/media.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,13 @@ export function syncRuntimeMedia(params: {
273273
if (clip.volumeKeyframes && clip.volumeKeyframes.length > 0) {
274274
// Keyframes probed from the GSAP timeline — same source as the renderer.
275275
// Use the interpolated envelope value directly; no need to track GSAP changes.
276-
authorVolume = clampVolume(interpolateVolumeGain(clip.volumeKeyframes, relTime));
276+
// Index by elapsed time on the TIMELINE since the clip began, which is what
277+
// a normalised envelope is keyed by (and what the renderer's PCM baker uses).
278+
// `relTime` is a position inside the media SOURCE — it carries `mediaStart`
279+
// and the playback rate — so it only coincides with the envelope's time base
280+
// for an untrimmed clip playing at 1x from t=0.
281+
const elapsedInClip = params.timeSeconds - clip.start;
282+
authorVolume = clampVolume(interpolateVolumeGain(clip.volumeKeyframes, elapsedInClip));
277283
} else if (previousRuntimeVolume === undefined) {
278284
// First tick this clip is active. The transport has already seeked GSAP
279285
// to the current time (seekTimelineAndAdapters runs before syncRuntimeMedia),

packages/core/src/runtime/mediaVolumeEnvelope.test.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
/** @vitest-environment jsdom */
22
import { describe, expect, it } from "vitest";
3-
import { probeAndCacheElementVolume, probeElementVolumeKeyframes } from "./mediaVolumeEnvelope";
3+
import {
4+
interpolateVolumeGain,
5+
probeAndCacheElementVolume,
6+
probeElementVolumeKeyframes,
7+
} from "./mediaVolumeEnvelope";
48

59
describe("probeElementVolumeKeyframes", () => {
610
it("retains the last plateau sample before a short volume change", () => {
@@ -143,4 +147,40 @@ describe("probeAndCacheElementVolume", () => {
143147
expect.arrayContaining([expect.objectContaining({ volume: 0 })]),
144148
);
145149
});
150+
151+
it("caches a track-relative envelope for a clip that starts after t=0", () => {
152+
// The probe stamps timeline seek times. A clip starting at 2s therefore
153+
// yields keyframes at 2.0+, and reading them with track-relative time landed
154+
// before the first keyframe and clamped to its volume — 0 for a fade-in, so
155+
// the preview stayed silent for the whole clip while the render was correct.
156+
const audio = document.createElement("audio");
157+
audio.dataset.start = "2";
158+
audio.dataset.duration = "1";
159+
audio.dataset.volume = "1";
160+
document.body.append(audio);
161+
162+
const timeline = {
163+
totalTime(next?: number) {
164+
if (next !== undefined) {
165+
// 0.05s linear fade-in at the clip's start (timeline t=2).
166+
audio.volume = Math.max(0, Math.min(1, (next - 2) / 0.05));
167+
}
168+
return 0;
169+
},
170+
};
171+
const cache = new WeakMap<HTMLMediaElement, { time: number; volume: number }[]>();
172+
173+
probeAndCacheElementVolume(audio, timeline, 3, cache);
174+
175+
const envelope = cache.get(audio);
176+
if (!envelope) throw new Error("Expected a cached envelope");
177+
expect(envelope[0]).toEqual({ time: 0, volume: 0 });
178+
expect(envelope.at(-1)?.time).toBeCloseTo(1, 5);
179+
180+
// Silent at the clip's start, full once the fade is done, and it stays there.
181+
expect(interpolateVolumeGain(envelope, 0)).toBeCloseTo(0, 5);
182+
expect(interpolateVolumeGain(envelope, 0.05)).toBeCloseTo(1, 5);
183+
expect(interpolateVolumeGain(envelope, 0.5)).toBeCloseTo(1, 5);
184+
expect(interpolateVolumeGain(envelope, 1)).toBeCloseTo(1, 5);
185+
});
146186
});

packages/core/src/runtime/mediaVolumeEnvelope.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -179,8 +179,15 @@ export interface VolumeProbeOptions {
179179
}
180180

181181
/**
182-
* Probe a media element and, if volume automation is detected, store the
183-
* keyframes in `cache`. Safe to call with a null timeline — returns early.
182+
* Probe a media element and, if volume automation is detected, store a
183+
* NORMALISED envelope in `cache`. Safe to call with a null timeline — returns
184+
* early.
185+
*
186+
* `probeElementVolumeKeyframes` stamps each keyframe with the timeline seek
187+
* time it was sampled at. Everything downstream of this cache — like the
188+
* renderer's PCM baker — indexes an envelope by track-relative seconds, so the
189+
* rebase belongs here, at the one point that fills the cache, rather than at
190+
* each read.
184191
*/
185192
export function probeAndCacheElementVolume(
186193
mediaEl: HTMLMediaElement,
@@ -217,6 +224,8 @@ export function probeAndCacheElementVolume(
217224
const keyframes = probeElementVolumeKeyframes(mediaEl, seekFn, compositionDuration, 60);
218225
if (Number.isFinite(originalTime)) seekFn(originalTime);
219226
if (keyframes) {
220-
cache.set(mediaEl, keyframes);
227+
const { start, staticVolume } = resolveVolumeProbeWindow(mediaEl, compositionDuration);
228+
const envelope = normaliseEnvelope(keyframes, start, staticVolume);
229+
if (envelope.length > 0) cache.set(mediaEl, envelope);
221230
}
222231
}

0 commit comments

Comments
 (0)