Two perf fixes caught in #1118 review:
1. Cache guard: probeAndCacheVolumeKeyframes now short-circuits when
the element is already in volumeKeyframeCache. Without the guard
every bindMediaMetadataListeners call (every 30 RAF ticks) re-probed
all bound elements — N elements × full-composition timeline seeks at
60 Hz regardless of whether keyframes were already known.
bindRootTimelineIfAvailable still clears the cache on a new timeline
capture so keyframes stay fresh when the composition is rebound.
2. PCM cursor: audioVolumeEnvelope.ts had the incremental segment
cursor (O(N+M) overall) before #1118 extracted the interpolation into
interpolateVolumeGain. The shared function restarts from segment=0 on
each call — fine for the preview path (one call per RAF tick) but
O(N×M) for the PCM path (one call per sample: 48 kHz × duration).
Napkin math: a 10-min render went from ~30M to ~460M ops. Restored
the inline incremental scan in the engine bake loop; engine now only
imports normaliseEnvelope from core.
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.
Animated media volume (GSAP/JS fades) dropped the audio track entirely for dense
fades. The 60 Hz timeline probe emits 100-300 keyframes for a multi-second fade,
which were folded into an FFmpeg `volume` expression nesting one `if(lt(t,...))`
per keyframe. Past ~95 nested levels (build-dependent, lower on some Linux ffmpeg
builds) the expression overflows FFmpeg's evaluator, fails filter-graph init,
fails the whole mix, and the muxer omits audio — so a `data-volume="0"` fade-in
rendered with no audio at all (follow-up to #1066; this is why #1064's own
scenario regressed once the fade was dense enough).
Apply volume automation as sample-accurate gain, layered so audio is never lost:
1. Primary: bake the envelope into the prepared PCM samples in-process
(audioVolumeEnvelope.ts). The track WAV is always pcm_s16le/48k/stereo;
multiply its samples by the interpolated envelope and atomically rename the
result into place, then mix at unity. No expression, no keyframe ceiling,
exact at every sample, and the downstream ffmpeg amix/AAC encode is untouched
so golden baselines only change where a fade is applied. The RIFF parser
scans chunks order-independently and accepts only 16-bit PCM, falling back
otherwise. The output is written to a random-named sibling and renamed, so a
crash can't leave a truncated WAV and there's no predictable-path write.
2. Fallback: RDP-bounded ffmpeg `volume` expression (0.5% tolerance, capped at
32 segments) for the rare case a WAV is not 16-bit PCM. 0.5% keeps the
rendered envelope within ~0.2 dB of the source curve.
3. Backstop: if an automated mix still fails, retry once at base volume and
surface the degradation rather than dropping the track.
This mirrors how OSS NLEs render automation (sample-level gain): MoviePy,
Kdenlive/Shotcut (MLT), Remotion.
Verified end-to-end: a 297-keyframe fade that rendered with no audio now bakes
all 297 keyframes sample-accurately. Adds unit tests for sample-accurate gain,
track-start offset, base/tail holds, thousands of keyframes, order-independent
chunk parsing, and format rejection, plus mixer regression tests for bounded
nesting and the base-volume backstop.