@@ -154,3 +154,145 @@ fn atempo_guard_rejects_non_positive_and_non_finite_rates_without_hanging() {
154154 }
155155 }
156156}
157+
158+ // ─── A transition's declared easing must actually reshape its progress ────
159+ //
160+ // `transition_progress`/`view_transition_progress` hand a raw *linear*
161+ // fraction to `apply_transition`, which composites pixels at exactly the
162+ // fraction it's given — it has no notion of the scenario's declared
163+ // `transition.easing` on its own. Before this fix, that raw linear fraction
164+ // reached `apply_transition` unmodified for every transition type except
165+ // `camera_pan` (which threads easing through a different function,
166+ // `camera_pan_transition`, that already applies it internally), so
167+ // `"easing": "ease_in"`/`"ease_out"` on a `fade`, wipe, iris, or a
168+ // between-view transition was silently a no-op.
169+ //
170+ // A `fade` between a solid-black and a solid-white full-canvas frame turns
171+ // this into an exact, predictable pixel value: at the frame whose *linear*
172+ // position in the transition is precisely 0.5, an eased blend must land at
173+ // `255 * ease(0.5)`, not at the unequal, easing-blind `255 * 0.5 = 128`.
174+ // `ease_in_cubic(0.5) = 0.125` and `ease_out_cubic(0.5) = 0.875` are both far
175+ // enough from `0.5` that no rendering noise/antialiasing could produce a
176+ // false pass.
177+
178+ fn center_pixel_red ( rgba : & [ u8 ] , width : u32 , height : u32 ) -> u8 {
179+ let idx = ( ( height / 2 * width + width / 2 ) * 4 ) as usize ;
180+ rgba[ idx]
181+ }
182+
183+ #[ test]
184+ fn a_fade_transitions_easing_reshapes_its_progress_not_just_camera_pan ( ) {
185+ let width = 64u32 ;
186+ let height = 64u32 ;
187+ let fps = 11u32 ;
188+
189+ for ( easing, expected_u8) in [ ( "ease_in" , 32u8 ) , ( "ease_out" , 224u8 ) ] {
190+ let json = format ! (
191+ r##"{{"video": {{"width": {width}, "height": {height}, "fps": {fps}}},
192+ "scenes": [
193+ {{"duration": 1.0, "children": [
194+ {{"type": "shape", "shape": "rect", "fill": "#000000",
195+ "position": "absolute", "x": 0, "y": 0,
196+ "style": {{"width": {width}, "height": {height}}}}}
197+ ]}},
198+ {{"duration": 1.0,
199+ "transition": {{"type": "fade", "duration": 1.0, "easing": "{easing}"}},
200+ "children": [
201+ {{"type": "shape", "shape": "rect", "fill": "#ffffff",
202+ "position": "absolute", "x": 0, "y": 0,
203+ "style": {{"width": {width}, "height": {height}}}}}
204+ ]}}
205+ ]}}"##
206+ ) ;
207+ let scenario = rustmotion:: loader:: load_scenario_from_source ( None , Some ( & json) )
208+ . expect ( "load" ) ;
209+
210+ let tasks = rustmotion:: encode:: video:: build_frame_tasks ( & scenario) ;
211+ let target = tasks
212+ . iter ( )
213+ . find ( |t| {
214+ matches ! (
215+ t,
216+ rustmotion:: encode:: video:: FrameTask :: SlideTransition {
217+ frame_in_transition: 5 ,
218+ ..
219+ }
220+ )
221+ } )
222+ . unwrap_or_else ( || panic ! ( "{easing}: expected a mid-transition frame at index 5" ) ) ;
223+
224+ let rgba =
225+ rustmotion:: encode:: video:: render_frame_task ( & scenario. video , & scenario, target)
226+ . unwrap_or_else ( |e| panic ! ( "{easing}: render failed: {e}" ) ) ;
227+ let red = center_pixel_red ( & rgba, width, height) ;
228+
229+ assert ! (
230+ ( red as i32 - expected_u8 as i32 ) . abs( ) <= 4 ,
231+ "{easing}: at the transition's linear midpoint, the eased blend must land near \
232+ {expected_u8}, got {red}"
233+ ) ;
234+ assert ! (
235+ ( red as i32 - 128 ) . abs( ) > 20 ,
236+ "{easing}: {red} is too close to 128 — the raw, unequal linear-progress blend an \
237+ easing-blind composite would produce"
238+ ) ;
239+ }
240+ }
241+
242+ #[ test]
243+ fn a_between_view_fade_transitions_easing_reshapes_its_progress ( ) {
244+ let width = 64u32 ;
245+ let height = 64u32 ;
246+ let fps = 11u32 ;
247+
248+ let json = format ! (
249+ r##"{{"video": {{"width": {width}, "height": {height}, "fps": {fps}}},
250+ "composition": [
251+ {{"type": "slide", "scenes": [
252+ {{"duration": 1.0, "children": [
253+ {{"type": "shape", "shape": "rect", "fill": "#000000",
254+ "position": "absolute", "x": 0, "y": 0,
255+ "style": {{"width": {width}, "height": {height}}}}}
256+ ]}}
257+ ]}},
258+ {{"type": "slide",
259+ "transition": {{"type": "fade", "duration": 1.0, "easing": "ease_in"}},
260+ "scenes": [
261+ {{"duration": 1.0, "children": [
262+ {{"type": "shape", "shape": "rect", "fill": "#ffffff",
263+ "position": "absolute", "x": 0, "y": 0,
264+ "style": {{"width": {width}, "height": {height}}}}}
265+ ]}}
266+ ]}}
267+ ]}}"##
268+ ) ;
269+ let scenario = rustmotion:: loader:: load_scenario_from_source ( None , Some ( & json) ) . expect ( "load" ) ;
270+
271+ let tasks = rustmotion:: encode:: video:: build_frame_tasks ( & scenario) ;
272+ let target = tasks
273+ . iter ( )
274+ . find ( |t| {
275+ matches ! (
276+ t,
277+ rustmotion:: encode:: video:: FrameTask :: ViewTransition {
278+ frame_in_transition: 5 ,
279+ ..
280+ }
281+ )
282+ } )
283+ . expect ( "expected a mid-view-transition frame at index 5" ) ;
284+
285+ let rgba = rustmotion:: encode:: video:: render_frame_task ( & scenario. video , & scenario, target)
286+ . expect ( "render" ) ;
287+ let red = center_pixel_red ( & rgba, width, height) ;
288+
289+ assert ! (
290+ ( red as i32 - 32 ) . abs( ) <= 4 ,
291+ "ease_in at the view transition's linear midpoint must land near 32, got {red}"
292+ ) ;
293+ assert ! (
294+ ( red as i32 - 128 ) . abs( ) > 20 ,
295+ "{red} is too close to 128 — the raw, unequal linear-progress blend an easing-blind \
296+ composite would produce"
297+ ) ;
298+ }
0 commit comments