Skip to content

Commit 90ec48a

Browse files
committed
fix(encode): apply the transition easing to progress
Refs #220
1 parent eff63b6 commit 90ec48a

2 files changed

Lines changed: 158 additions & 1 deletion

File tree

‎crates/rustmotion/src/encode/video/tasks.rs‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use crate::engine::animator::ease;
12
use crate::engine::transition::{apply_transition, camera_pan_transition, TransitionOptions};
23
use crate::error::{Result, RustmotionError};
34
use crate::schema::{
@@ -296,6 +297,7 @@ pub fn render_frame_task_scaled(
296297
// Rationale: the transition is the "entry" of scene_b; its post-effects
297298
// (e.g. vignette) should appear on the blended frames to avoid a
298299
// jarring pop when the transition ends and Normal frames begin.
300+
let progress = eased_transition_progress(progress, easing);
299301
let mut composited = apply_transition(
300302
&frame_a,
301303
&frame_b,
@@ -360,7 +362,7 @@ pub fn render_frame_task_scaled(
360362
transition_type,
361363
options,
362364
transition_duration,
363-
easing: _,
365+
easing,
364366
} => {
365367
let scenario_time = *global_frame as f64 / config.fps as f64;
366368
let scaled_w = (config.width as f32 * scale_factor) as u32;
@@ -372,6 +374,7 @@ pub fn render_frame_task_scaled(
372374
// does not.
373375
let progress =
374376
view_transition_progress(*frame_in_transition, *transition_duration, fps);
377+
let progress = eased_transition_progress(progress, easing);
375378

376379
let view_a = &scenario.views[*view_a_idx];
377380
let view_b = &scenario.views[*view_b_idx];
@@ -427,6 +430,18 @@ fn transition_progress(frame_in_transition: u32, transition_duration: f64, fps:
427430
frame_in_transition as f64 / transition_frames.saturating_sub(1).max(1) as f64
428431
}
429432

433+
/// Reshapes a transition's raw linear `progress` by its declared
434+
/// `transition.easing` before it reaches `apply_transition`, which composites
435+
/// pixels at exactly the fraction it's handed and has no notion of easing on
436+
/// its own. Every transition arm that calls `apply_transition` needs this —
437+
/// `SlideTransition`'s `CameraPan` case is the one exception, since it never
438+
/// reaches `apply_transition` at all: it hands its own `easing` straight into
439+
/// `camera_pan_transition`, which applies it internally, so routing it
440+
/// through here first would ease that progress twice.
441+
fn eased_transition_progress(progress: f64, easing: &EasingType) -> f64 {
442+
ease(progress, easing)
443+
}
444+
430445
/// Frame index within a *view* transition → progress in the OPEN interval
431446
/// `(0.0, 1.0)`, excluding both endpoints.
432447
///

‎crates/rustmotion/tests/audit_ws_c.rs‎

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)