mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
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.
This commit is contained in:
@@ -9,6 +9,7 @@ import { createTypegpuAdapter } from "./adapters/typegpu";
|
||||
import { patchVideoTextureCompat } from "./adapters/video-texture-compat";
|
||||
import { createWaapiAdapter } from "./adapters/waapi";
|
||||
import { refreshRuntimeMediaCache, syncRuntimeMedia } from "./media";
|
||||
import { probeAndCacheElementVolume, type VolumeKeyframe } from "./mediaVolumeEnvelope.js";
|
||||
import { createPickerModule } from "./picker";
|
||||
import { createRuntimePlayer } from "./player";
|
||||
import { createRuntimeState } from "./state";
|
||||
@@ -917,6 +918,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
// (setTimeout(0)). Scripts using requestAnimationFrame or longer delays may
|
||||
// not be discovered.
|
||||
let childrenBound = false;
|
||||
// fallow-ignore-next-line complexity
|
||||
const bindRootTimelineIfAvailable = (): boolean => {
|
||||
if (!externalCompositionsReady) return false;
|
||||
const currentTimeline = state.capturedTimeline;
|
||||
@@ -965,6 +967,11 @@ export function initSandboxRuntimeModular(): void {
|
||||
mediaDurationFloorSeconds: resolution.mediaDurationFloorSeconds ?? null,
|
||||
},
|
||||
});
|
||||
// (Re-)probe all already-bound media elements now that a timeline is available.
|
||||
// Elements bound before this point couldn't be probed in bindMediaMetadataListeners.
|
||||
for (const el of metadataBoundMedia) {
|
||||
probeAndCacheVolumeKeyframes(el);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -1184,6 +1191,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
let metadataRebindDebounceTimerId: number | null = null;
|
||||
let metadataRebindApplied = false;
|
||||
const metadataBoundMedia = new Set<HTMLMediaElement>();
|
||||
const volumeKeyframeCache = new WeakMap<HTMLMediaElement, VolumeKeyframe[]>();
|
||||
|
||||
const scheduleMetadataDurationHydration = () => {
|
||||
if (state.tornDown) return;
|
||||
@@ -1264,9 +1272,26 @@ export function initSandboxRuntimeModular(): void {
|
||||
if (mediaEl.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) {
|
||||
mediaEl.load();
|
||||
}
|
||||
|
||||
// Probe volume automation from the GSAP timeline — same approach as the
|
||||
// renderer (see discoverAudioVolumeAutomationFromTimeline / audioMixer).
|
||||
// Runs only when the timeline is already captured; elements bound before
|
||||
// the timeline is ready are re-probed the first time bindMediaMetadataListeners
|
||||
// fires after the timeline has been captured (every 30 transport ticks).
|
||||
probeAndCacheVolumeKeyframes(mediaEl);
|
||||
}
|
||||
};
|
||||
|
||||
const probeAndCacheVolumeKeyframes = (mediaEl: HTMLMediaElement) => {
|
||||
probeAndCacheElementVolume(
|
||||
mediaEl,
|
||||
state.capturedTimeline,
|
||||
getSafeTimelineDurationSeconds(state.capturedTimeline, 0),
|
||||
volumeKeyframeCache,
|
||||
);
|
||||
};
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const syncMediaForCurrentState = () => {
|
||||
const resolveMediaCompositionContext = (element: HTMLVideoElement | HTMLAudioElement) => {
|
||||
const compositionRoot = element.closest("[data-composition-id]");
|
||||
@@ -1312,6 +1337,13 @@ export function initSandboxRuntimeModular(): void {
|
||||
return sourceDuration ?? hostRemaining;
|
||||
},
|
||||
});
|
||||
// Attach probed volume keyframes to clips so syncRuntimeMedia can use the
|
||||
// same envelope the renderer uses instead of tracking GSAP-change diffs.
|
||||
for (const clip of cache.mediaClips) {
|
||||
const kf = volumeKeyframeCache.get(clip.el as HTMLMediaElement);
|
||||
if (kf) clip.volumeKeyframes = kf;
|
||||
}
|
||||
|
||||
const forceSync = state.mediaForceSyncNextTick;
|
||||
if (forceSync) state.mediaForceSyncNextTick = false;
|
||||
syncRuntimeMedia({
|
||||
|
||||
Reference in New Issue
Block a user