diff --git a/crates/rustmotion-components/src/badge.rs b/crates/rustmotion-components/src/badge.rs index 5e19ea9f..f4f2cdbb 100644 --- a/crates/rustmotion-components/src/badge.rs +++ b/crates/rustmotion-components/src/badge.rs @@ -441,7 +441,9 @@ mod tests { // Solid variant text is always white — probe for white ink // specifically, since the pill background paints regardless. let text_ink = buf - .chunks_exact(4) + .as_chunks::<4>() + .0 + .iter() .filter(|p| p[3] > 0 && p[0] > 200 && p[1] > 200 && p[2] > 200) .count(); assert!( diff --git a/crates/rustmotion-components/src/callout.rs b/crates/rustmotion-components/src/callout.rs index 22e8bf97..8092d4aa 100644 --- a/crates/rustmotion-components/src/callout.rs +++ b/crates/rustmotion-components/src/callout.rs @@ -267,7 +267,9 @@ mod tests { // for near-white ink specifically, since the bubble background // paints regardless of font-size. let text_ink = buf - .chunks_exact(4) + .as_chunks::<4>() + .0 + .iter() .filter(|p| p[3] > 0 && p[0] > 200 && p[1] > 200 && p[2] > 200) .count(); assert!( diff --git a/crates/rustmotion-components/src/counter.rs b/crates/rustmotion-components/src/counter.rs index ebe1108e..e67af735 100644 --- a/crates/rustmotion-components/src/counter.rs +++ b/crates/rustmotion-components/src/counter.rs @@ -403,7 +403,7 @@ mod tests { skia_safe::image::CachingHint::Disallow, ); assert!(ok, "pixel read should succeed"); - let lit = buf.chunks_exact(4).filter(|p| p[3] > 0).count(); + let lit = buf.as_chunks::<4>().0.iter().filter(|p| p[3] > 0).count(); assert!( lit > 20, "counter at font-size: 2rem must paint visible ink, got {lit} lit pixels" diff --git a/crates/rustmotion-components/src/kbd.rs b/crates/rustmotion-components/src/kbd.rs index ecc065b8..6874cbf3 100644 --- a/crates/rustmotion-components/src/kbd.rs +++ b/crates/rustmotion-components/src/kbd.rs @@ -339,7 +339,9 @@ mod tests { // face — probe for near-white ink specifically, since the face/ // border/shadow paint regardless of font-size. let text_ink = buf - .chunks_exact(4) + .as_chunks::<4>() + .0 + .iter() .filter(|p| p[3] > 0 && p[0] > 180 && p[1] > 180 && p[2] > 180) .count(); assert!( diff --git a/crates/rustmotion-components/src/list.rs b/crates/rustmotion-components/src/list.rs index d58723cd..4e7fd71e 100644 --- a/crates/rustmotion-components/src/list.rs +++ b/crates/rustmotion-components/src/list.rs @@ -333,7 +333,7 @@ mod tests { skia_safe::image::CachingHint::Disallow, ); assert!(ok, "pixel read should succeed"); - let lit = buf.chunks_exact(4).filter(|p| p[3] > 0).count(); + let lit = buf.as_chunks::<4>().0.iter().filter(|p| p[3] > 0).count(); assert!( lit > 20, "list at font-size: 2rem must paint visible ink, got {lit} lit pixels" diff --git a/crates/rustmotion-components/src/lottie.rs b/crates/rustmotion-components/src/lottie.rs index e2b08b5c..6fee0988 100644 --- a/crates/rustmotion-components/src/lottie.rs +++ b/crates/rustmotion-components/src/lottie.rs @@ -525,7 +525,7 @@ mod tests { let mut red_sum: u64 = 0; let mut blue_sum: u64 = 0; - for px in buf.chunks_exact(4) { + for px in buf.as_chunks::<4>().0.iter() { let r = px[0] as u64; let _g = px[1] as u64; let b = px[2] as u64; diff --git a/crates/rustmotion-components/src/marquee.rs b/crates/rustmotion-components/src/marquee.rs index 08e0379c..401baaa1 100644 --- a/crates/rustmotion-components/src/marquee.rs +++ b/crates/rustmotion-components/src/marquee.rs @@ -224,7 +224,7 @@ mod tests { skia_safe::image::CachingHint::Disallow, ); assert!(ok, "pixel read should succeed"); - let lit = buf.chunks_exact(4).filter(|p| p[3] > 0).count(); + let lit = buf.as_chunks::<4>().0.iter().filter(|p| p[3] > 0).count(); assert!( lit > 20, "marquee at font-size: 2rem must paint visible ink, got {lit} lit pixels" diff --git a/crates/rustmotion-components/src/notification.rs b/crates/rustmotion-components/src/notification.rs index 436f2d35..4d63a057 100644 --- a/crates/rustmotion-components/src/notification.rs +++ b/crates/rustmotion-components/src/notification.rs @@ -451,7 +451,9 @@ mod tests { // Title text is white (#FFFFFF default) on a dark #1E293B card — // probe for near-white ink specifically. let text_ink = buf - .chunks_exact(4) + .as_chunks::<4>() + .0 + .iter() .filter(|p| p[3] > 0 && p[0] > 200 && p[1] > 200 && p[2] > 200) .count(); assert!( diff --git a/crates/rustmotion-components/src/pill_nav.rs b/crates/rustmotion-components/src/pill_nav.rs index 3b3d038d..3039de35 100644 --- a/crates/rustmotion-components/src/pill_nav.rs +++ b/crates/rustmotion-components/src/pill_nav.rs @@ -328,7 +328,7 @@ mod tests { skia_safe::image::CachingHint::Disallow, ); assert!(ok, "pixel read should succeed"); - let lit = buf.chunks_exact(4).filter(|p| p[3] > 0).count(); + let lit = buf.as_chunks::<4>().0.iter().filter(|p| p[3] > 0).count(); assert!( lit > 20, "pill_nav at font-size: 2rem must paint visible ink, got {lit} lit pixels" diff --git a/crates/rustmotion-components/src/table.rs b/crates/rustmotion-components/src/table.rs index 05fbc996..527a532c 100644 --- a/crates/rustmotion-components/src/table.rs +++ b/crates/rustmotion-components/src/table.rs @@ -371,7 +371,9 @@ mod tests { // probe specifically for near-white text ink rather than any lit // pixel (the header/row backgrounds paint regardless of font-size). let text_ink = buf - .chunks_exact(4) + .as_chunks::<4>() + .0 + .iter() .filter(|p| p[3] > 0 && p[0] > 200 && p[1] > 200 && p[2] > 200) .count(); assert!( diff --git a/crates/rustmotion-components/src/terminal.rs b/crates/rustmotion-components/src/terminal.rs index 0e22037b..be6224ec 100644 --- a/crates/rustmotion-components/src/terminal.rs +++ b/crates/rustmotion-components/src/terminal.rs @@ -536,7 +536,9 @@ mod tests { // specifically: pixels that are not the dark theme background color // (#1E1E1E) and not fully transparent. let text_ink = buf - .chunks_exact(4) + .as_chunks::<4>() + .0 + .iter() .filter(|p| p[3] > 0 && !(p[0] < 40 && p[1] < 40 && p[2] < 40)) .count(); assert!( diff --git a/crates/rustmotion-components/src/tooltip.rs b/crates/rustmotion-components/src/tooltip.rs index 44cd1a54..432c1690 100644 --- a/crates/rustmotion-components/src/tooltip.rs +++ b/crates/rustmotion-components/src/tooltip.rs @@ -280,7 +280,9 @@ mod tests { // Text is near-white (#E2E8F0 default) on a dark #1E293B body — // probe for near-white ink specifically. let text_ink = buf - .chunks_exact(4) + .as_chunks::<4>() + .0 + .iter() .filter(|p| p[3] > 0 && p[0] > 180 && p[1] > 180 && p[2] > 180) .count(); assert!( diff --git a/crates/rustmotion-components/tests/caption_presets.rs b/crates/rustmotion-components/tests/caption_presets.rs index 2f074ae8..59ecf7b9 100644 --- a/crates/rustmotion-components/tests/caption_presets.rs +++ b/crates/rustmotion-components/tests/caption_presets.rs @@ -82,7 +82,11 @@ fn render_caption_at(json: serde_json::Value, time: f64, y: f32) -> Vec { } fn count_pixels(buf: &[u8], pred: impl Fn(&[u8]) -> bool) -> usize { - buf.chunks_exact(4).filter(|p| pred(p)).count() + buf.as_chunks::<4>() + .0 + .iter() + .filter(|p| pred(p.as_slice())) + .count() } fn lit_pixels(buf: &[u8]) -> usize { diff --git a/crates/rustmotion-components/tests/codeblock_auto_scroll.rs b/crates/rustmotion-components/tests/codeblock_auto_scroll.rs index 40a4f2eb..e1668a31 100644 --- a/crates/rustmotion-components/tests/codeblock_auto_scroll.rs +++ b/crates/rustmotion-components/tests/codeblock_auto_scroll.rs @@ -42,7 +42,9 @@ fn text_ink_pixels(buf: &[u8]) -> usize { // Background is #2b303b ~ (43, 48, 59). Count pixels that deviate from // that by a wide margin in any channel — syntect's theme colors are all // much brighter than the near-black background. - buf.chunks_exact(4) + buf.as_chunks::<4>() + .0 + .iter() .filter(|p| { let (r, g, b, a) = (p[0] as i32, p[1] as i32, p[2] as i32, p[3] as i32); a > 200 && ((r - 43).abs() > 40 || (g - 48).abs() > 40 || (b - 59).abs() > 40) diff --git a/crates/rustmotion-components/tests/relative_font_size.rs b/crates/rustmotion-components/tests/relative_font_size.rs index 21965697..c40e8fe0 100644 --- a/crates/rustmotion-components/tests/relative_font_size.rs +++ b/crates/rustmotion-components/tests/relative_font_size.rs @@ -77,7 +77,7 @@ fn render(json: serde_json::Value) -> Vec { } fn lit_pixels(buf: &[u8]) -> usize { - buf.chunks_exact(4).filter(|p| p[3] > 0).count() + buf.as_chunks::<4>().0.iter().filter(|p| p[3] > 0).count() } #[test] diff --git a/crates/rustmotion-core/src/engine/renderer/text.rs b/crates/rustmotion-core/src/engine/renderer/text.rs index d607dadf..6d578977 100644 --- a/crates/rustmotion-core/src/engine/renderer/text.rs +++ b/crates/rustmotion-core/src/engine/renderer/text.rs @@ -975,7 +975,7 @@ mod emoji_presentation_tests { surface.read_pixels(&info, &mut buf, (W * 4) as usize, (0, 0)); let (mut n, mut sr, mut sg, mut sb) = (0usize, 0f64, 0f64, 0f64); - for px in buf.chunks_exact(4) { + for px in buf.as_chunks::<4>().0.iter() { if px[3] > 40 { n += 1; sr += px[0] as f64; diff --git a/crates/rustmotion-core/src/engine/transition.rs b/crates/rustmotion-core/src/engine/transition.rs index b256a080..22e5923f 100644 --- a/crates/rustmotion-core/src/engine/transition.rs +++ b/crates/rustmotion-core/src/engine/transition.rs @@ -970,9 +970,9 @@ mod camera_pan_tests { ); // blend_fade(10, 200, 0.5) = (10*0.5 + 200*0.5 + 0.5) as u8 = 105. - for px in out.chunks_exact(4) { + for px in out.as_chunks::<4>().0.iter() { assert_eq!( - px, + *px, [105, 105, 105, 255], "mid-pan Static frame must be a blend of bg_a and bg_b, not a copy of either" ); diff --git a/crates/rustmotion-studio/src/editor/audio.rs b/crates/rustmotion-studio/src/editor/audio.rs index 5b015479..6a232169 100644 --- a/crates/rustmotion-studio/src/editor/audio.rs +++ b/crates/rustmotion-studio/src/editor/audio.rs @@ -165,7 +165,9 @@ pub fn prepare(scenario: Arc, total_duration: f64) { match mixed { Ok(Some(pcm_bytes)) => { let pcm: Vec = pcm_bytes - .chunks_exact(2) + .as_chunks::<2>() + .0 + .iter() .map(|b| i16::from_le_bytes([b[0], b[1]]) as f32 / 32768.0) .collect(); let _ = tx.send(Cmd::Load(Arc::new(pcm), SAMPLE_RATE)); diff --git a/crates/rustmotion-studio/src/scenario/optimistic.rs b/crates/rustmotion-studio/src/scenario/optimistic.rs index adf3ae3f..5cca75c0 100644 --- a/crates/rustmotion-studio/src/scenario/optimistic.rs +++ b/crates/rustmotion-studio/src/scenario/optimistic.rs @@ -312,7 +312,9 @@ mod tests { .expect("render") }; let count_red = |rgba: &[u8]| { - rgba.chunks_exact(4) + rgba.as_chunks::<4>() + .0 + .iter() .filter(|p| p[0] > 180 && p[1] < 90 && p[2] < 90) .count() }; diff --git a/crates/rustmotion/src/encode/video/tasks.rs b/crates/rustmotion/src/encode/video/tasks.rs index ca6f8e6f..378174f5 100644 --- a/crates/rustmotion/src/encode/video/tasks.rs +++ b/crates/rustmotion/src/encode/video/tasks.rs @@ -1,3 +1,4 @@ +use crate::engine::animator::ease; use crate::engine::transition::{apply_transition, camera_pan_transition, TransitionOptions}; use crate::error::{Result, RustmotionError}; use crate::schema::{ @@ -296,6 +297,7 @@ pub fn render_frame_task_scaled( // Rationale: the transition is the "entry" of scene_b; its post-effects // (e.g. vignette) should appear on the blended frames to avoid a // jarring pop when the transition ends and Normal frames begin. + let progress = eased_transition_progress(progress, easing); let mut composited = apply_transition( &frame_a, &frame_b, @@ -360,7 +362,7 @@ pub fn render_frame_task_scaled( transition_type, options, transition_duration, - easing: _, + easing, } => { let scenario_time = *global_frame as f64 / config.fps as f64; let scaled_w = (config.width as f32 * scale_factor) as u32; @@ -372,6 +374,7 @@ pub fn render_frame_task_scaled( // does not. let progress = view_transition_progress(*frame_in_transition, *transition_duration, fps); + let progress = eased_transition_progress(progress, easing); let view_a = &scenario.views[*view_a_idx]; let view_b = &scenario.views[*view_b_idx]; @@ -427,6 +430,18 @@ fn transition_progress(frame_in_transition: u32, transition_duration: f64, fps: frame_in_transition as f64 / transition_frames.saturating_sub(1).max(1) as f64 } +/// Reshapes a transition's raw linear `progress` by its declared +/// `transition.easing` before it reaches `apply_transition`, which composites +/// pixels at exactly the fraction it's handed and has no notion of easing on +/// its own. Every transition arm that calls `apply_transition` needs this — +/// `SlideTransition`'s `CameraPan` case is the one exception, since it never +/// reaches `apply_transition` at all: it hands its own `easing` straight into +/// `camera_pan_transition`, which applies it internally, so routing it +/// through here first would ease that progress twice. +fn eased_transition_progress(progress: f64, easing: &EasingType) -> f64 { + ease(progress, easing) +} + /// Frame index within a *view* transition → progress in the OPEN interval /// `(0.0, 1.0)`, excluding both endpoints. /// diff --git a/crates/rustmotion/src/tests.rs b/crates/rustmotion/src/tests.rs index 784e3d12..661687b4 100644 --- a/crates/rustmotion/src/tests.rs +++ b/crates/rustmotion/src/tests.rs @@ -539,7 +539,7 @@ mod component_smoke { /// Counts non-transparent pixels in an RGBA buffer (alpha > 0). fn nonzero_pixels(buf: &[u8]) -> usize { - buf.chunks_exact(4).filter(|p| p[3] > 0).count() + buf.as_chunks::<4>().0.iter().filter(|p| p[3] > 0).count() } #[test] @@ -605,7 +605,8 @@ mod component_smoke { let late = render_new_at(&scene, 400, 300, 0.95, 1.0); // Total red intensity (sum of red channel) — alpha-attenuated pixels // contribute less to this sum even when premul keeps the count up. - let red_sum: fn(&[u8]) -> u64 = |buf| buf.chunks_exact(4).map(|p| p[0] as u64).sum(); + let red_sum: fn(&[u8]) -> u64 = + |buf| buf.as_chunks::<4>().0.iter().map(|p| p[0] as u64).sum(); let early_red = red_sum(&early); let late_red = red_sum(&late); assert!( @@ -638,7 +639,7 @@ mod component_smoke { } fn red_sum(buf: &[u8]) -> u64 { - buf.chunks_exact(4).map(|p| p[0] as u64).sum() + buf.as_chunks::<4>().0.iter().map(|p| p[0] as u64).sum() } #[test] @@ -718,7 +719,9 @@ mod component_smoke { } })); let count_mid = |buf: &[u8]| { - buf.chunks_exact(4) + buf.as_chunks::<4>() + .0 + .iter() .filter(|p| p[0] > 20 && p[0] < 220) .count() }; @@ -744,7 +747,9 @@ mod component_smoke { })); let buf = render_new_at(&inverted, 400, 300, 0.5, 1.0); let flipped = buf - .chunks_exact(4) + .as_chunks::<4>() + .0 + .iter() .filter(|p| p[0] < 50 && p[1] > 150) .count(); assert!( @@ -873,7 +878,8 @@ mod component_smoke { bleed: false, }; let scene = vec![child]; - let blue_sum = |buf: &[u8]| -> u64 { buf.chunks_exact(4).map(|p| p[2] as u64).sum() }; + let blue_sum = + |buf: &[u8]| -> u64 { buf.as_chunks::<4>().0.iter().map(|p| p[2] as u64).sum() }; let before = render_new_at(&scene, 400, 300, 0.5, 4.0); let mid = render_new_at(&scene, 400, 300, 1.5, 4.0); let after = render_new_at(&scene, 400, 300, 2.5, 4.0); @@ -936,7 +942,7 @@ mod component_smoke { fn red_centroid_x(buf: &[u8], width: u32) -> Option { let mut sum_wx = 0.0_f64; let mut sum_w = 0.0_f64; - for (i, p) in buf.chunks_exact(4).enumerate() { + for (i, p) in buf.as_chunks::<4>().0.iter().enumerate() { let x = (i as u32 % width) as f64; let w = p[0] as f64; sum_wx += w * x; @@ -1665,7 +1671,7 @@ mod svg_draw_on_tests { /// Count non-transparent pixels in RGBA buffer. fn lit(buf: &[u8]) -> usize { - buf.chunks_exact(4).filter(|p| p[3] > 0).count() + buf.as_chunks::<4>().0.iter().filter(|p| p[3] > 0).count() } /// Count non-transparent pixels in horizontal band [y0, y1) of a 100-wide canvas. @@ -1831,7 +1837,9 @@ mod svg_draw_on_tests { ); // resvg fills the rect red. Count red-dominant pixels. let red_pixels = buf - .chunks_exact(4) + .as_chunks::<4>() + .0 + .iter() .filter(|p| p[3] > 0 && p[0] > p[2] && p[0] > p[1]) .count(); assert!( @@ -2232,7 +2240,9 @@ mod audio_tests { let lit_at = |frame: usize| { crate::encode::video::render_frame_task(&scenario.video, &scenario, &tasks[frame]) .expect("render") - .chunks_exact(4) + .as_chunks::<4>() + .0 + .iter() .filter(|p| p[0] > 40) .count() }; @@ -2556,7 +2566,14 @@ mod audio_tests { } })) }; - let red_sum = |pixels: &[u8]| pixels.chunks_exact(4).map(|p| p[0] as u64).sum::(); + let red_sum = |pixels: &[u8]| { + pixels + .as_chunks::<4>() + .0 + .iter() + .map(|p| p[0] as u64) + .sum::() + }; let loud = paint_scene(make_child(), 200, 200, 0.0, 30); let quiet = paint_scene(make_child(), 200, 200, 0.5, 30); @@ -2658,7 +2675,13 @@ mod motion_blur_trail { /// Find the maximum red channel value across all pixels. fn max_red(pixels: &[u8]) -> u8 { - pixels.chunks_exact(4).map(|p| p[0]).max().unwrap_or(0) + pixels + .as_chunks::<4>() + .0 + .iter() + .map(|p| p[0]) + .max() + .unwrap_or(0) } /// Make a red Shape with slide_in_left + motion_blur, positioned at the center. @@ -3385,7 +3408,7 @@ mod world_view_regressions { fn avg_luma(buf: &[u8]) -> f64 { let mut sum = 0u64; let mut n = 0u64; - for px in buf.chunks_exact(4) { + for px in buf.as_chunks::<4>().0.iter() { sum += px[0] as u64 + px[1] as u64 + px[2] as u64; n += 3; } diff --git a/crates/rustmotion/tests/audit_ws_c.rs b/crates/rustmotion/tests/audit_ws_c.rs index 0b18c5b3..e792d49c 100644 --- a/crates/rustmotion/tests/audit_ws_c.rs +++ b/crates/rustmotion/tests/audit_ws_c.rs @@ -154,3 +154,145 @@ fn atempo_guard_rejects_non_positive_and_non_finite_rates_without_hanging() { } } } + +// ─── A transition's declared easing must actually reshape its progress ──── +// +// `transition_progress`/`view_transition_progress` hand a raw *linear* +// fraction to `apply_transition`, which composites pixels at exactly the +// fraction it's given — it has no notion of the scenario's declared +// `transition.easing` on its own. Before this fix, that raw linear fraction +// reached `apply_transition` unmodified for every transition type except +// `camera_pan` (which threads easing through a different function, +// `camera_pan_transition`, that already applies it internally), so +// `"easing": "ease_in"`/`"ease_out"` on a `fade`, wipe, iris, or a +// between-view transition was silently a no-op. +// +// A `fade` between a solid-black and a solid-white full-canvas frame turns +// this into an exact, predictable pixel value: at the frame whose *linear* +// position in the transition is precisely 0.5, an eased blend must land at +// `255 * ease(0.5)`, not at the unequal, easing-blind `255 * 0.5 = 128`. +// `ease_in_cubic(0.5) = 0.125` and `ease_out_cubic(0.5) = 0.875` are both far +// enough from `0.5` that no rendering noise/antialiasing could produce a +// false pass. + +fn center_pixel_red(rgba: &[u8], width: u32, height: u32) -> u8 { + let idx = ((height / 2 * width + width / 2) * 4) as usize; + rgba[idx] +} + +#[test] +fn a_fade_transitions_easing_reshapes_its_progress_not_just_camera_pan() { + let width = 64u32; + let height = 64u32; + let fps = 11u32; + + for (easing, expected_u8) in [("ease_in", 32u8), ("ease_out", 224u8)] { + let json = format!( + r##"{{"video": {{"width": {width}, "height": {height}, "fps": {fps}}}, + "scenes": [ + {{"duration": 1.0, "children": [ + {{"type": "shape", "shape": "rect", "fill": "#000000", + "position": "absolute", "x": 0, "y": 0, + "style": {{"width": {width}, "height": {height}}}}} + ]}}, + {{"duration": 1.0, + "transition": {{"type": "fade", "duration": 1.0, "easing": "{easing}"}}, + "children": [ + {{"type": "shape", "shape": "rect", "fill": "#ffffff", + "position": "absolute", "x": 0, "y": 0, + "style": {{"width": {width}, "height": {height}}}}} + ]}} + ]}}"## + ); + let scenario = rustmotion::loader::load_scenario_from_source(None, Some(&json)) + .expect("load"); + + let tasks = rustmotion::encode::video::build_frame_tasks(&scenario); + let target = tasks + .iter() + .find(|t| { + matches!( + t, + rustmotion::encode::video::FrameTask::SlideTransition { + frame_in_transition: 5, + .. + } + ) + }) + .unwrap_or_else(|| panic!("{easing}: expected a mid-transition frame at index 5")); + + let rgba = + rustmotion::encode::video::render_frame_task(&scenario.video, &scenario, target) + .unwrap_or_else(|e| panic!("{easing}: render failed: {e}")); + let red = center_pixel_red(&rgba, width, height); + + assert!( + (red as i32 - expected_u8 as i32).abs() <= 4, + "{easing}: at the transition's linear midpoint, the eased blend must land near \ + {expected_u8}, got {red}" + ); + assert!( + (red as i32 - 128).abs() > 20, + "{easing}: {red} is too close to 128 — the raw, unequal linear-progress blend an \ + easing-blind composite would produce" + ); + } +} + +#[test] +fn a_between_view_fade_transitions_easing_reshapes_its_progress() { + let width = 64u32; + let height = 64u32; + let fps = 11u32; + + let json = format!( + r##"{{"video": {{"width": {width}, "height": {height}, "fps": {fps}}}, + "composition": [ + {{"type": "slide", "scenes": [ + {{"duration": 1.0, "children": [ + {{"type": "shape", "shape": "rect", "fill": "#000000", + "position": "absolute", "x": 0, "y": 0, + "style": {{"width": {width}, "height": {height}}}}} + ]}} + ]}}, + {{"type": "slide", + "transition": {{"type": "fade", "duration": 1.0, "easing": "ease_in"}}, + "scenes": [ + {{"duration": 1.0, "children": [ + {{"type": "shape", "shape": "rect", "fill": "#ffffff", + "position": "absolute", "x": 0, "y": 0, + "style": {{"width": {width}, "height": {height}}}}} + ]}} + ]}} + ]}}"## + ); + let scenario = rustmotion::loader::load_scenario_from_source(None, Some(&json)).expect("load"); + + let tasks = rustmotion::encode::video::build_frame_tasks(&scenario); + let target = tasks + .iter() + .find(|t| { + matches!( + t, + rustmotion::encode::video::FrameTask::ViewTransition { + frame_in_transition: 5, + .. + } + ) + }) + .expect("expected a mid-view-transition frame at index 5"); + + let rgba = rustmotion::encode::video::render_frame_task(&scenario.video, &scenario, target) + .expect("render"); + let red = center_pixel_red(&rgba, width, height); + + assert!( + (red as i32 - 32).abs() <= 4, + "ease_in at the view transition's linear midpoint must land near 32, got {red}" + ); + assert!( + (red as i32 - 128).abs() > 20, + "{red} is too close to 128 — the raw, unequal linear-progress blend an easing-blind \ + composite would produce" + ); +}