@@ -61,6 +61,9 @@ export function interpolateVolumeGain(envelope: VolumeKeyframe[], t: number): nu
6161 if ( envelope . length === 0 ) return 1 ;
6262
6363 let segment = 0 ;
64+ // The PCM baker intentionally inlines this lookup with a monotonic cursor
65+ // because calling this preview-oriented helper per sample would be O(N×M).
66+ // fallow-ignore-next-line code-duplication
6467 while ( segment < envelope . length - 2 && t >= envelope [ segment + 1 ] ! . time ) {
6568 segment += 1 ;
6669 }
@@ -72,7 +75,49 @@ export function interpolateVolumeGain(envelope: VolumeKeyframe[], t: number): nu
7275 return a . volume + ( b . volume - a . volume ) * progress ;
7376}
7477
75- // fallow-ignore-next-line complexity
78+ function recordVolumeSample (
79+ keyframes : VolumeKeyframe [ ] ,
80+ previousSample : VolumeKeyframe | undefined ,
81+ sample : VolumeKeyframe ,
82+ isFinalSample : boolean ,
83+ ) : void {
84+ const last = keyframes . at ( - 1 ) ;
85+ if ( ! last || Math . abs ( last . volume - sample . volume ) > 0.0001 ) {
86+ // Change-only compression must retain the preceding real sample so a
87+ // flat run stays flat instead of being interpolated into the next value.
88+ // During a continuous ramp, that sample is already the last keyframe.
89+ if ( last && previousSample && previousSample . time > last . time ) {
90+ keyframes . push ( previousSample ) ;
91+ }
92+ keyframes . push ( sample ) ;
93+ } else if ( isFinalSample && sample . time > last . time ) {
94+ keyframes . push ( sample ) ;
95+ }
96+ }
97+
98+ function parseFiniteDatasetNumber ( value : string | undefined ) : number | undefined {
99+ const parsed = Number . parseFloat ( value ?? "" ) ;
100+ return Number . isFinite ( parsed ) ? parsed : undefined ;
101+ }
102+
103+ function resolveVolumeProbeWindow (
104+ el : HTMLAudioElement | HTMLVideoElement ,
105+ compositionDuration : number ,
106+ ) : { start : number ; end : number ; staticVolume : number } {
107+ const start = parseFiniteDatasetNumber ( el . dataset . start ) ?? 0 ;
108+ const endAttr = parseFiniteDatasetNumber ( el . dataset . end ) ;
109+ const durAttr = parseFiniteDatasetNumber ( el . dataset . duration ) ;
110+ let end = compositionDuration ;
111+ if ( endAttr !== undefined && endAttr > start ) {
112+ end = endAttr ;
113+ } else if ( durAttr !== undefined && durAttr > 0 ) {
114+ end = start + durAttr ;
115+ }
116+ const staticAttr = parseFiniteDatasetNumber ( el . dataset . volume ) ?? 1 ;
117+ const staticVolume = Math . max ( 0 , Math . min ( 1 , staticAttr ) ) ;
118+ return { start, end, staticVolume } ;
119+ }
120+
76121/**
77122 * Probe a single media element's volume automation by seeking a GSAP timeline
78123 * through the element's active window.
@@ -89,18 +134,7 @@ export function probeElementVolumeKeyframes(
89134 compositionDuration : number ,
90135 sampleFps : number ,
91136) : VolumeKeyframe [ ] | null {
92- const start = Number . parseFloat ( el . dataset . start ?? "0" ) || 0 ;
93- const endAttr = Number . parseFloat ( el . dataset . end ?? "" ) ;
94- const durAttr = Number . parseFloat ( el . dataset . duration ?? "" ) ;
95- const end =
96- Number . isFinite ( endAttr ) && endAttr > start
97- ? endAttr
98- : Number . isFinite ( durAttr ) && durAttr > 0
99- ? start + durAttr
100- : compositionDuration ;
101-
102- const staticAttr = Number . parseFloat ( el . dataset . volume ?? "" ) ;
103- const staticVolume = Number . isFinite ( staticAttr ) ? Math . max ( 0 , Math . min ( 1 , staticAttr ) ) : 1 ;
137+ const { start, end, staticVolume } = resolveVolumeProbeWindow ( el , compositionDuration ) ;
104138
105139 // Reset to data-volume so GSAP captures the correct FROM value.
106140 el . volume = staticVolume ;
@@ -110,15 +144,19 @@ export function probeElementVolumeKeyframes(
110144 const sampleEnd = Math . min ( compositionDuration , end ) ;
111145
112146 const keyframes : VolumeKeyframe [ ] = [ ] ;
113- for ( let t = sampleStart ; t <= sampleEnd + 1e-6 ; t += step ) {
147+ let previousSample : VolumeKeyframe | undefined ;
148+ for ( let t = sampleStart ; t <= sampleEnd + 1e-6 ; t = Math . min ( sampleEnd , t + step ) ) {
114149 const bounded = Math . min ( sampleEnd , t ) ;
115150 seekTimeline ( bounded ) ;
116151 const raw = Number ( el . volume ) ;
117- if ( ! Number . isFinite ( raw ) ) continue ;
118- const volume = Math . max ( 0 , Math . min ( 1 , raw ) ) ;
119- const last = keyframes . at ( - 1 ) ;
120- if ( ! last || Math . abs ( last . volume - volume ) > 0.0001 || bounded === sampleEnd ) {
121- keyframes . push ( { time : Number ( bounded . toFixed ( 6 ) ) , volume : Number ( volume . toFixed ( 6 ) ) } ) ;
152+ if ( Number . isFinite ( raw ) ) {
153+ const volume = Math . max ( 0 , Math . min ( 1 , raw ) ) ;
154+ const sample = {
155+ time : Number ( bounded . toFixed ( 6 ) ) ,
156+ volume : Number ( volume . toFixed ( 6 ) ) ,
157+ } ;
158+ recordVolumeSample ( keyframes , previousSample , sample , bounded === sampleEnd ) ;
159+ previousSample = sample ;
122160 }
123161 if ( bounded === sampleEnd ) break ;
124162 }
0 commit comments