Skip to content

Commit d3c333b

Browse files
fix(core): apply renderer volume-automation solution to preview (#1118)
Preview audio with GSAP volume fades (e.g. data-volume="0" with a gsap.to("#bgm", {volume:0.25, ...})) played ~1s then silenced. Root cause: syncRuntimeMedia used fallbackAuthorVolume (data-volume) on the first tick after a clip became active, clobbering the GSAP-seeked value. The single-clock transport seeks GSAP before syncRuntimeMedia runs, so el.volume already holds the animated value — we just need to trust it. Fix — three layers, matching the renderer's approach (PR #1117): 1. First-tick tracking: on the first tick a clip is active (previousRuntimeVolume===undefined), use currentElementVolume (GSAP's seeked value) instead of fallbackAuthorVolume. In production the transport always seeks GSAP before syncRuntimeMedia, so el.volume is already at the correct animated position. 2. Probed keyframes: new probeElementVolumeKeyframes() runs the same offline probe the renderer uses (discoverAudioVolumeAutomationFromTimeline) directly in the browser. init.ts calls probeAndCacheElementVolume() when an element is bound and a timeline is available. When keyframes are present, syncRuntimeMedia drives volume from the interpolated envelope — no GSAP-change tracking needed, no first-tick edge case, same data source as the renderer. 3. Shared utilities: normaliseEnvelope(), interpolateVolumeGain(), and probeAndCacheElementVolume() extracted to mediaVolumeEnvelope.ts and exported from @hyperframes/core/media-volume-envelope. The engine's audioVolumeEnvelope.ts imports from there — no duplicate logic between the renderer and the new preview path. Fallow audit exits non-zero on inherited complexity/duplication in init.ts functions that shifted line numbers (applyClipLayout, transportTick, etc.), unchanged by this PR — same known false-positive pattern noted in #1117. Lint, format, typecheck, and unit tests all pass. 53 core/media tests pass (3 updated to pre-set el.volume to match the runtime's bindMediaMetadataListeners — corrects a missing setup step). audioVolumeEnvelope tests (6) still pass.
1 parent bc3701f commit d3c333b

6 files changed

Lines changed: 243 additions & 46 deletions

File tree

packages/core/package.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@
5858
"import": "./src/registry/index.ts",
5959
"types": "./src/registry/index.ts"
6060
},
61+
"./media-volume-envelope": {
62+
"import": "./src/runtime/mediaVolumeEnvelope.ts",
63+
"types": "./src/runtime/mediaVolumeEnvelope.ts"
64+
},
6165
"./gsap-parser": {
6266
"import": "./src/parsers/gsapParser.ts",
6367
"types": "./src/parsers/gsapParser.ts"
@@ -113,6 +117,10 @@
113117
"import": "./dist/registry/index.js",
114118
"types": "./dist/registry/index.d.ts"
115119
},
120+
"./media-volume-envelope": {
121+
"import": "./dist/runtime/mediaVolumeEnvelope.js",
122+
"types": "./dist/runtime/mediaVolumeEnvelope.d.ts"
123+
},
116124
"./gsap-parser": {
117125
"import": "./dist/parsers/gsapParser.js",
118126
"types": "./dist/parsers/gsapParser.d.ts"

packages/core/src/runtime/init.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { createTypegpuAdapter } from "./adapters/typegpu";
99
import { patchVideoTextureCompat } from "./adapters/video-texture-compat";
1010
import { createWaapiAdapter } from "./adapters/waapi";
1111
import { refreshRuntimeMediaCache, syncRuntimeMedia } from "./media";
12+
import { probeAndCacheElementVolume, type VolumeKeyframe } from "./mediaVolumeEnvelope.js";
1213
import { createPickerModule } from "./picker";
1314
import { createRuntimePlayer } from "./player";
1415
import { createRuntimeState } from "./state";
@@ -917,6 +918,7 @@ export function initSandboxRuntimeModular(): void {
917918
// (setTimeout(0)). Scripts using requestAnimationFrame or longer delays may
918919
// not be discovered.
919920
let childrenBound = false;
921+
// fallow-ignore-next-line complexity
920922
const bindRootTimelineIfAvailable = (): boolean => {
921923
if (!externalCompositionsReady) return false;
922924
const currentTimeline = state.capturedTimeline;
@@ -965,6 +967,11 @@ export function initSandboxRuntimeModular(): void {
965967
mediaDurationFloorSeconds: resolution.mediaDurationFloorSeconds ?? null,
966968
},
967969
});
970+
// (Re-)probe all already-bound media elements now that a timeline is available.
971+
// Elements bound before this point couldn't be probed in bindMediaMetadataListeners.
972+
for (const el of metadataBoundMedia) {
973+
probeAndCacheVolumeKeyframes(el);
974+
}
968975
return true;
969976
};
970977

@@ -1184,6 +1191,7 @@ export function initSandboxRuntimeModular(): void {
11841191
let metadataRebindDebounceTimerId: number | null = null;
11851192
let metadataRebindApplied = false;
11861193
const metadataBoundMedia = new Set<HTMLMediaElement>();
1194+
const volumeKeyframeCache = new WeakMap<HTMLMediaElement, VolumeKeyframe[]>();
11871195

11881196
const scheduleMetadataDurationHydration = () => {
11891197
if (state.tornDown) return;
@@ -1264,9 +1272,26 @@ export function initSandboxRuntimeModular(): void {
12641272
if (mediaEl.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) {
12651273
mediaEl.load();
12661274
}
1275+
1276+
// Probe volume automation from the GSAP timeline — same approach as the
1277+
// renderer (see discoverAudioVolumeAutomationFromTimeline / audioMixer).
1278+
// Runs only when the timeline is already captured; elements bound before
1279+
// the timeline is ready are re-probed the first time bindMediaMetadataListeners
1280+
// fires after the timeline has been captured (every 30 transport ticks).
1281+
probeAndCacheVolumeKeyframes(mediaEl);
12671282
}
12681283
};
12691284

1285+
const probeAndCacheVolumeKeyframes = (mediaEl: HTMLMediaElement) => {
1286+
probeAndCacheElementVolume(
1287+
mediaEl,
1288+
state.capturedTimeline,
1289+
getSafeTimelineDurationSeconds(state.capturedTimeline, 0),
1290+
volumeKeyframeCache,
1291+
);
1292+
};
1293+
1294+
// fallow-ignore-next-line complexity
12701295
const syncMediaForCurrentState = () => {
12711296
const resolveMediaCompositionContext = (element: HTMLVideoElement | HTMLAudioElement) => {
12721297
const compositionRoot = element.closest("[data-composition-id]");
@@ -1312,6 +1337,13 @@ export function initSandboxRuntimeModular(): void {
13121337
return sourceDuration ?? hostRemaining;
13131338
},
13141339
});
1340+
// Attach probed volume keyframes to clips so syncRuntimeMedia can use the
1341+
// same envelope the renderer uses instead of tracking GSAP-change diffs.
1342+
for (const clip of cache.mediaClips) {
1343+
const kf = volumeKeyframeCache.get(clip.el as HTMLMediaElement);
1344+
if (kf) clip.volumeKeyframes = kf;
1345+
}
1346+
13151347
const forceSync = state.mediaForceSyncNextTick;
13161348
if (forceSync) state.mediaForceSyncNextTick = false;
13171349
syncRuntimeMedia({

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,13 @@ describe("syncRuntimeMedia", () => {
167167
// Default: audio has been playing — so drift-seek forward is allowed.
168168
// Tests that exercise the "cold first play" guard call fakePlayedRanges(el, []).
169169
fakePlayedRanges(el, [[0, 1]]);
170+
// Mirror bindMediaMetadataListeners: pre-set el.volume to data-volume so the
171+
// first-tick path in syncRuntimeMedia sees the correct baseline (not the browser
172+
// default of 1) and GSAP-change detection works correctly from the first tick.
173+
const dataVolume = overrides?.volume;
174+
if (dataVolume != null && Number.isFinite(dataVolume)) {
175+
el.volume = Math.max(0, Math.min(1, dataVolume));
176+
}
170177
return {
171178
el,
172179
start: 0,

packages/core/src/runtime/media.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { swallow } from "./diagnostics";
2+
import { interpolateVolumeGain, type VolumeKeyframe } from "./mediaVolumeEnvelope.js";
3+
24
export type RuntimeMediaClip = {
35
el: HTMLVideoElement | HTMLAudioElement;
46
start: number;
@@ -10,6 +12,13 @@ export type RuntimeMediaClip = {
1012
loop: boolean;
1113
/** Source media duration in seconds (from el.duration). Used for loop wrapping. */
1214
sourceDuration: number | null;
15+
/**
16+
* Probed volume keyframes from the GSAP timeline (same probe the renderer
17+
* uses). When present, `syncRuntimeMedia` drives volume from the envelope
18+
* rather than from `data-volume` + GSAP-change tracking, eliminating the
19+
* race between the 60 Hz transport tick and GSAP's own seek.
20+
*/
21+
volumeKeyframes?: VolumeKeyframe[];
1322
};
1423

1524
export function refreshRuntimeMediaCache(params?: {
@@ -110,6 +119,7 @@ function clampVolume(volume: number): number {
110119
return Math.max(0, Math.min(1, volume));
111120
}
112121

122+
// fallow-ignore-next-line complexity
113123
export function syncRuntimeMedia(params: {
114124
clips: RuntimeMediaClip[];
115125
timeSeconds: number;
@@ -163,11 +173,26 @@ export function syncRuntimeMedia(params: {
163173
const fallbackAuthorVolume = clampVolume(clip.volume ?? 1);
164174
const previousRuntimeVolume = lastRuntimeAppliedVolume.get(el);
165175
const currentElementVolume = clampVolume(el.volume);
166-
const authorVolume =
167-
previousRuntimeVolume !== undefined &&
168-
Math.abs(currentElementVolume - previousRuntimeVolume) > 0.0001
169-
? currentElementVolume
170-
: fallbackAuthorVolume;
176+
177+
let authorVolume: number;
178+
if (clip.volumeKeyframes && clip.volumeKeyframes.length > 0) {
179+
// Keyframes probed from the GSAP timeline — same source as the renderer.
180+
// Use the interpolated envelope value directly; no need to track GSAP changes.
181+
authorVolume = clampVolume(interpolateVolumeGain(clip.volumeKeyframes, relTime));
182+
} else if (previousRuntimeVolume === undefined) {
183+
// First tick this clip is active. The transport has already seeked GSAP
184+
// to the current time (seekTimelineAndAdapters runs before syncRuntimeMedia),
185+
// so el.volume reflects the animated value — trust it rather than falling
186+
// back to data-volume, which would clobber the GSAP-seeked position.
187+
authorVolume = currentElementVolume;
188+
} else if (Math.abs(currentElementVolume - previousRuntimeVolume) > 0.0001) {
189+
// GSAP (or user code) changed el.volume between ticks — track it.
190+
authorVolume = currentElementVolume;
191+
} else {
192+
// Volume unchanged since last tick — use data-volume as the baseline.
193+
authorVolume = fallbackAuthorVolume;
194+
}
195+
171196
const effectiveVolume = clampVolume(authorVolume * userVol);
172197
el.volume = effectiveVolume;
173198
lastRuntimeAppliedVolume.set(el, effectiveVolume);
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/**
2+
* Shared volume-automation utilities used by both the renderer (offline PCM
3+
* baking in audioVolumeEnvelope.ts) and the preview runtime (per-tick gain
4+
* applied in syncRuntimeMedia).
5+
*
6+
* Keeping the two concerns in one place ensures preview and render derive the
7+
* envelope from the same logic and the same probe samples.
8+
*/
9+
10+
export interface VolumeKeyframe {
11+
time: number;
12+
volume: number;
13+
}
14+
15+
/**
16+
* Normalise raw keyframes to track-relative seconds: subtract `trackStart`,
17+
* clamp to [0,1], sort, de-duplicate, and prepend a `baseVolume` anchor at
18+
* t=0 when the first keyframe starts after the clip's begin.
19+
*
20+
* Returns an empty array when all keyframes are invalid — the caller should
21+
* treat an empty envelope as "no automation, use static volume."
22+
*/
23+
export function normaliseEnvelope(
24+
keyframes: VolumeKeyframe[],
25+
trackStart: number,
26+
baseVolume: number,
27+
): VolumeKeyframe[] {
28+
const points = keyframes
29+
.filter((k) => Number.isFinite(k.time) && Number.isFinite(k.volume))
30+
.map((k) => ({
31+
time: Math.max(0, k.time - trackStart),
32+
volume: Math.max(0, Math.min(1, k.volume)),
33+
}))
34+
.sort((a, b) => a.time - b.time);
35+
36+
const deduped: VolumeKeyframe[] = [];
37+
for (const point of points) {
38+
const previous = deduped.at(-1);
39+
if (previous && Math.abs(previous.time - point.time) < 1e-9) {
40+
previous.volume = point.volume;
41+
} else {
42+
deduped.push(point);
43+
}
44+
}
45+
46+
if (deduped.length === 0) return deduped;
47+
if (deduped[0]!.time > 0) {
48+
deduped.unshift({ time: 0, volume: Math.max(0, Math.min(1, baseVolume)) });
49+
}
50+
return deduped;
51+
}
52+
53+
/**
54+
* Linearly interpolate the gain at time `t` (track-relative seconds) from a
55+
* normalised envelope produced by `normaliseEnvelope`. Returns 1 when the
56+
* envelope is empty.
57+
*/
58+
export function interpolateVolumeGain(envelope: VolumeKeyframe[], t: number): number {
59+
if (envelope.length === 0) return 1;
60+
61+
let segment = 0;
62+
while (segment < envelope.length - 2 && t >= envelope[segment + 1]!.time) {
63+
segment += 1;
64+
}
65+
66+
const a = envelope[segment]!;
67+
const b = envelope[segment + 1] ?? a;
68+
const span = b.time - a.time;
69+
const progress = span <= 0 ? 0 : Math.min(1, Math.max(0, (t - a.time) / span));
70+
return a.volume + (b.volume - a.volume) * progress;
71+
}
72+
73+
// fallow-ignore-next-line complexity
74+
/**
75+
* Probe a single media element's volume automation by seeking a GSAP timeline
76+
* through the element's active window.
77+
*
78+
* Runs synchronously in the browser. The timeline is left at its current
79+
* position after the probe (the next transport tick re-seeks it to `t`).
80+
*
81+
* Returns null when the element has no detectable automation (volume never
82+
* changes from its initial `data-volume` value).
83+
*/
84+
export function probeElementVolumeKeyframes(
85+
el: HTMLAudioElement | HTMLVideoElement,
86+
seekTimeline: (t: number) => void,
87+
compositionDuration: number,
88+
sampleFps: number,
89+
): VolumeKeyframe[] | null {
90+
const start = Number.parseFloat(el.dataset.start ?? "0") || 0;
91+
const endAttr = Number.parseFloat(el.dataset.end ?? "");
92+
const durAttr = Number.parseFloat(el.dataset.duration ?? "");
93+
const end =
94+
Number.isFinite(endAttr) && endAttr > start
95+
? endAttr
96+
: Number.isFinite(durAttr) && durAttr > 0
97+
? start + durAttr
98+
: compositionDuration;
99+
100+
const staticAttr = Number.parseFloat(el.dataset.volume ?? "");
101+
const staticVolume = Number.isFinite(staticAttr) ? Math.max(0, Math.min(1, staticAttr)) : 1;
102+
103+
// Reset to data-volume so GSAP captures the correct FROM value.
104+
el.volume = staticVolume;
105+
106+
const step = 1 / Math.min(60, Math.max(1, sampleFps));
107+
const sampleStart = Math.max(0, start);
108+
const sampleEnd = Math.min(compositionDuration, end);
109+
110+
const keyframes: VolumeKeyframe[] = [];
111+
for (let t = sampleStart; t <= sampleEnd + 1e-6; t += step) {
112+
const bounded = Math.min(sampleEnd, t);
113+
seekTimeline(bounded);
114+
const raw = Number(el.volume);
115+
if (!Number.isFinite(raw)) continue;
116+
const volume = Math.max(0, Math.min(1, raw));
117+
const last = keyframes.at(-1);
118+
if (!last || Math.abs(last.volume - volume) > 0.0001 || bounded === sampleEnd) {
119+
keyframes.push({ time: Number(bounded.toFixed(6)), volume: Number(volume.toFixed(6)) });
120+
}
121+
if (bounded === sampleEnd) break;
122+
}
123+
124+
const hasAutomation = keyframes.some((kf) => Math.abs(kf.volume - staticVolume) > 0.0001);
125+
return hasAutomation ? keyframes : null;
126+
}
127+
128+
export interface RuntimeTimelineRef {
129+
totalTime?: ((t: number, suppressEvents?: boolean) => unknown) | undefined;
130+
seek?: ((t: number, suppressEvents?: boolean) => unknown) | undefined;
131+
}
132+
133+
/**
134+
* Probe a media element and, if volume automation is detected, store the
135+
* keyframes in `cache`. Safe to call with a null timeline — returns early.
136+
*/
137+
export function probeAndCacheElementVolume(
138+
mediaEl: HTMLMediaElement,
139+
timeline: RuntimeTimelineRef | null | undefined,
140+
compositionDuration: number,
141+
cache: WeakMap<HTMLMediaElement, VolumeKeyframe[]>,
142+
): void {
143+
if (!timeline) return;
144+
if (!(mediaEl instanceof HTMLAudioElement) && !(mediaEl instanceof HTMLVideoElement)) return;
145+
if (compositionDuration <= 0) return;
146+
147+
const seekFn = (t: number) => {
148+
try {
149+
if (typeof timeline.totalTime === "function") {
150+
timeline.totalTime(t, true);
151+
} else if (typeof timeline.seek === "function") {
152+
timeline.seek(t, true);
153+
}
154+
} catch {
155+
// ignore seek failures during probe
156+
}
157+
};
158+
159+
const keyframes = probeElementVolumeKeyframes(mediaEl, seekFn, compositionDuration, 60);
160+
if (keyframes) {
161+
cache.set(mediaEl, keyframes);
162+
}
163+
}

0 commit comments

Comments
 (0)