From fe262691dfafddfecb4283dce543722bead48552 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sun, 9 Aug 2026 01:01:51 +0200 Subject: [PATCH] fix(world): stop the camera teleporting and the frame budget losing frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight confirmed audit findings on scene transitions and the `world` view — the one mechanism meant to read as a single continuous shot, and the one where every defect below is plainly visible on screen. - A transition longer than the scene it leaves silently deleted frames from the *entering* scene. The outgoing scene cannot spend more frames than it has, but the budget was computed as if it could, and the overrun was taken out of the next scene. Now clamped to the outgoing scene's own frame count, with the accounting asserted frame by frame. - `camera_pan_duration` longer than a scene teleported the camera: measured jump of 245.33px between two consecutive frames, now 21.33px. - The outgoing scene's background held at full strength through the whole pan, then vanished in one frame — an avg-luma step of 109.0 between two frames, now at most 4.82. - The scene cross-fade drove the *whole* frame to 50% opacity at every pan midpoint (both scenes at 0.5, nothing behind them). Mid-pan opacity is now 0.670 instead of sitting on that floor. - `persist: true` snapped opacity back to 1.0 while the scene was still fading out — a 0.326 discontinuity, now 0.034. - A view-to-view transition duplicated the junction frame on both sides. `ViewTransition` progress is now an open interval, matching what `SlideTransition` already guaranteed. - `scene.freeze_at` was read by no world code path at all. - `scene.effects` (post-effects) were dropped on world frames and on view transition frames. Every figure above is a before/after measurement from the tests added here, not an estimate. Tests: 144 pass on this branch alone. --- crates/rustmotion/src/encode/video/tasks.rs | 407 ++++++++++++++++-- crates/rustmotion/src/engine/render/scene.rs | 190 ++++++-- crates/rustmotion/src/engine/world.rs | 429 +++++++++++++++++-- crates/rustmotion/src/tests.rs | 203 +++++++++ 4 files changed, 1130 insertions(+), 99 deletions(-) diff --git a/crates/rustmotion/src/encode/video/tasks.rs b/crates/rustmotion/src/encode/video/tasks.rs index 9610db7..3a0b03c 100644 --- a/crates/rustmotion/src/encode/video/tasks.rs +++ b/crates/rustmotion/src/encode/video/tasks.rs @@ -269,13 +269,32 @@ pub fn render_frame_task_scaled( use crate::engine::world::WorldTimeline; let view = &scenario.views[*view_idx]; let timeline = WorldTimeline::build(view, config.fps, config.width, config.height); - crate::engine::render::render_world_frame_scaled( + let mut pixels = crate::engine::render::render_world_frame_scaled( config, view, &timeline, *frame_in_view, scale_factor, - ) + )?; + // `apply_post_effects` runs for every other frame kind (Normal, + // the camera-pan and slide-transition composites) but was never + // called on a `WorldFrame` — a scene's `effects` (e.g. vignette) + // silently never rendered inside a `world` view. Apply the + // active scene's effects, by symmetry with how `Normal` applies + // that scene's own effects. + let scaled_w = (config.width as f32 * scale_factor) as u32; + let scaled_h = (config.height as f32 * scale_factor) as u32; + let time = *frame_in_view as f64 / config.fps as f64; + if let Some(active_idx) = timeline.active_scene_idx(time, &view.scenes, config.fps) { + apply_post_effects( + &mut pixels, + scaled_w, + scaled_h, + &view.scenes[active_idx].effects, + *frame_in_view, + ); + } + Ok(pixels) } FrameTask::ViewTransition { view_a_idx, @@ -288,7 +307,12 @@ pub fn render_frame_task_scaled( let scaled_w = (config.width as f32 * scale_factor) as u32; let scaled_h = (config.height as f32 * scale_factor) as u32; let fps = config.fps; - let progress = transition_progress(*frame_in_transition, *transition_duration, fps); + // Open-interval progress (never exactly 0.0 or 1.0) — see + // `view_transition_progress`'s doc for why a `ViewTransition` + // needs this and a `SlideTransition` (via `transition_progress`) + // does not. + let progress = + view_transition_progress(*frame_in_transition, *transition_duration, fps); let view_a = &scenario.views[*view_a_idx]; let view_b = &scenario.views[*view_b_idx]; @@ -296,14 +320,29 @@ pub fn render_frame_task_scaled( let frame_a = render_last_frame_of_view(config, view_a, fps, scale_factor)?; let frame_b = render_first_frame_of_view(config, view_b, fps, scale_factor)?; - Ok(apply_transition( + let mut composited = apply_transition( &frame_a, &frame_b, scaled_w, scaled_h, progress, transition_type, - )) + ); + // By symmetry with `SlideTransition` (which applies scene_b's + // effects to the blended result, "the transition is the entry + // of scene_b"): apply the incoming view's first scene's effects, + // so an effect present on both sides' Normal frames doesn't + // disappear for the transition's duration and pop back. + if let Some(first_scene) = view_b.scenes.first() { + apply_post_effects( + &mut composited, + scaled_w, + scaled_h, + &first_scene.effects, + *frame_in_transition, + ); + } + Ok(composited) } } } @@ -326,6 +365,32 @@ 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 } +/// Frame index within a *view* transition → progress in the OPEN interval +/// `(0.0, 1.0)`, excluding both endpoints. +/// +/// A `SlideTransition` (via `transition_progress` above) is deliberately +/// allowed to hit exactly 0.0/1.0 at its ends, because at those points it +/// renders each side at scene-frame indices that were never emitted as a +/// `Normal` frame — `frame_a_idx`/`frame_in_transition` continue exactly +/// where `normal_start`/`normal_end` left off, so a 0.0-progress transition +/// frame is new content, not a repeat. +/// +/// A `ViewTransition` is different: `frame_a`/`frame_b` are +/// `render_last_frame_of_view`/`render_first_frame_of_view` — the outgoing +/// view's own last `Normal` frame and the incoming view's own first +/// `Normal` frame, at the exact same time point, re-rendered byte-for-byte. +/// A blend weight of exactly 0.0 or 1.0 there reproduces one of those +/// frames identically, placed directly next to the frame it duplicates in +/// the output stream — a still frame sitting inside otherwise continuous +/// motion. Mapping onto the open interval instead — `(f + 1) / (N + 1)` +/// instead of `f / (N - 1)` — keeps every `ViewTransition` frame a genuine +/// blend of both sides, so no frame in the stream is ever byte-identical to +/// its neighbour. +fn view_transition_progress(frame_in_transition: u32, transition_duration: f64, fps: u32) -> f64 { + let transition_frames = (transition_duration * fps as f64).round().max(1.0); + (frame_in_transition as f64 + 1.0) / (transition_frames + 1.0) +} + fn render_last_frame_of_view( config: &VideoConfig, view: &ResolvedView, @@ -440,6 +505,36 @@ pub fn build_frame_tasks(scenario: &Scenario) -> Vec { tasks } +/// Frames actually spent on the transition from `scenes[i]` into +/// `scenes[i + 1]` — defined by `scenes[i + 1].transition` — clamped to the +/// *outgoing* scene's own frame budget. `(frames, effective_duration)` +/// where `effective_duration` is that frame count expressed back in +/// seconds, exactly the value `transition_progress` must be given so its +/// internal `(duration * fps).round()` reproduces `frames` instead of the +/// raw, unclamped declared duration. +/// +/// A transition longer than the scene it leaves cannot consume more frames +/// than that scene has: `scenes[i]` only has `scene_frames` frames to spend, +/// full stop. Before this clamp existed, the *entering* scene's +/// `normal_start` (see `build_slide_view_tasks`) was computed from the raw +/// declared duration instead of from this same number — so when a +/// transition declared e.g. 1.0s but the outgoing scene was only 0.3s long, +/// only 9 frames of `SlideTransition` were ever emitted, yet the entering +/// scene still skipped its first 30 frames (`normal_start = 30`) waiting for +/// a transition that had already finished after 9 — silently dropping 21 +/// frames (0.7s) of the entering scene's own animation. Passing the raw +/// duration through to `transition_progress` compounded this: progress +/// maxed out around 0.28 instead of reaching 1.0 on the last emitted frame. +fn actual_outgoing_transition(scenes: &[Scene], i: usize, fps: u32) -> (u32, f64) { + let Some(transition) = scenes.get(i + 1).and_then(|s| s.transition.as_ref()) else { + return (0, 0.0); + }; + let raw_frames = (transition.duration * fps as f64).round() as u32; + let scene_frames = (scenes[i].duration * fps as f64).round() as u32; + let frames = raw_frames.min(scene_frames); + (frames, frames as f64 / fps as f64) +} + fn build_slide_view_tasks( tasks: &mut Vec, view_idx: usize, @@ -451,16 +546,15 @@ fn build_slide_view_tasks( for (i, scene) in scenes.iter().enumerate() { let scene_frames = (scene.duration * fps as f64).round() as u32; let next_transition = scenes.get(i + 1).and_then(|s| s.transition.as_ref()); - let outgoing_transition_frames = next_transition - .map(|t| (t.duration * fps as f64).round() as u32) - .unwrap_or(0); + let (outgoing_transition_frames, outgoing_effective_duration) = + actual_outgoing_transition(scenes, i, fps); + // Symmetric with `outgoing_transition_frames` above: the frames this + // scene skips at its own start must equal what the *previous* + // scene's iteration actually emitted for the transition into this + // one, not a value recomputed independently from the raw duration. let incoming_transition_frames = if i > 0 { - scene - .transition - .as_ref() - .map(|t| (t.duration * fps as f64).round() as u32) - .unwrap_or(0) + actual_outgoing_transition(scenes, i - 1, fps).0 } else { 0 }; @@ -478,20 +572,19 @@ fn build_slide_view_tasks( } if let Some(transition) = next_transition { - let actual_transition_frames = outgoing_transition_frames.min(scene_frames); let scene_b_frames = (scenes[i + 1].duration * fps as f64).round() as u32; let easing = transition.easing.clone(); - for f in 0..actual_transition_frames { + for f in 0..outgoing_transition_frames { tasks.push(FrameTask::SlideTransition { view_idx, scene_a_idx: i, scene_b_idx: i + 1, frame_in_transition: f, - scene_a_frame_offset: scene_frames - actual_transition_frames, + scene_a_frame_offset: scene_frames - outgoing_transition_frames, scene_a_total_frames: scene_frames, scene_b_total_frames: scene_b_frames, transition_type: transition.transition_type.clone(), - transition_duration: transition.duration, + transition_duration: outgoing_effective_duration, easing: easing.clone(), }); } @@ -677,16 +770,14 @@ pub(super) fn build_scene_frame_tasks_in_view( let next_transition = scenes .get(scene_idx + 1) .and_then(|s| s.transition.as_ref()); - let outgoing_transition_frames = next_transition - .map(|t| (t.duration * fps as f64).round() as u32) - .unwrap_or(0); + let (outgoing_transition_frames, outgoing_effective_duration) = + actual_outgoing_transition(scenes, scene_idx, fps); + // Mirrors `build_slide_view_tasks`: must agree exactly with what the + // previous scene's own slot emitted, or the two builders diverge and + // `slot_tasks_reproduce_the_full_builder_exactly` catches it. let incoming_transition_frames = if scene_idx > 0 { - scene - .transition - .as_ref() - .map(|t| (t.duration * fps as f64).round() as u32) - .unwrap_or(0) + actual_outgoing_transition(scenes, scene_idx - 1, fps).0 } else { 0 }; @@ -704,20 +795,19 @@ pub(super) fn build_scene_frame_tasks_in_view( } if let Some(transition) = next_transition { - let actual_transition_frames = outgoing_transition_frames.min(scene_frames); let scene_b_frames = (scenes[scene_idx + 1].duration * fps as f64).round() as u32; let easing = transition.easing.clone(); - for f in 0..actual_transition_frames { + for f in 0..outgoing_transition_frames { tasks.push(FrameTask::SlideTransition { view_idx, scene_a_idx: scene_idx, scene_b_idx: scene_idx + 1, frame_in_transition: f, - scene_a_frame_offset: scene_frames - actual_transition_frames, + scene_a_frame_offset: scene_frames - outgoing_transition_frames, scene_a_total_frames: scene_frames, scene_b_total_frames: scene_b_frames, transition_type: transition.transition_type.clone(), - transition_duration: transition.duration, + transition_duration: outgoing_effective_duration, easing: easing.clone(), }); } @@ -813,6 +903,265 @@ mod transition_progress_tests { } } +// Constat 1 (audit lot "transitions"): a transition longer than the scene it +// leaves must not silently drop frames from the scene it enters. +#[cfg(test)] +mod outgoing_transition_clamp_tests { + use super::*; + use crate::schema::Scene; + + fn scene(duration: f64) -> Scene { + serde_json::from_value(serde_json::json!({ + "duration": duration, + "children": [] + })) + .unwrap() + } + + fn scene_with_transition(duration: f64, transition_duration: f64) -> Scene { + serde_json::from_value(serde_json::json!({ + "duration": duration, + "children": [], + "transition": { "type": "fade", "duration": transition_duration } + })) + .unwrap() + } + + // The audit's own repro: scene A is far shorter than the declared + // transition, so the transition can only ever spend A's own 9 frames + // (0.3s @ 30fps), not the raw 30 (1.0s @ 30fps) it asked for. + #[test] + fn clamps_to_the_outgoing_scenes_own_frame_budget() { + let scenes = vec![scene(0.3), scene_with_transition(2.0, 1.0)]; + let (frames, effective_duration) = actual_outgoing_transition(&scenes, 0, 30); + assert_eq!( + frames, 9, + "9 frames is all scene A (0.3s @ 30fps) has to spend" + ); + assert!( + (effective_duration - 0.3).abs() < 1e-9, + "effective duration must reflect the clamped frame count, not the raw 1.0s: {effective_duration}" + ); + } + + #[test] + fn no_clamp_needed_when_transition_fits() { + // 0.5s transition entering scene B, 2.0s outgoing scene A @ 30fps: + // 15 frames <= 60 available, no clamp — effective duration is the + // declared one, byte-for-byte. + let pair = vec![scene(2.0), scene_with_transition(2.0, 0.5)]; + let (frames, effective_duration) = actual_outgoing_transition(&pair, 0, 30); + assert_eq!(frames, 15); + assert!((effective_duration - 0.5).abs() < 1e-9); + } + + // The core regression: every one of scene B's own local-frame indices + // must be rendered exactly once, somewhere in the output — either as + // part of the SlideTransition (indices 0..outgoing_frames) or as a + // Normal frame (indices normal_start..scene_b_frames). Before the fix, + // indices `[9, 30)` were rendered nowhere: the transition only ever + // advanced scene B through frame 8, and Normal frames for B started at + // the unclamped 30. + #[test] + fn every_local_frame_of_the_entering_scene_is_rendered_exactly_once() { + let json = r#"{ + "video": { "width": 320, "height": 180, "fps": 30 }, + "scenes": [ + { "duration": 0.3, "children": [] }, + { "duration": 2.0, "children": [], + "transition": { "type": "fade", "duration": 1.0 } } + ] + }"#; + let scenario = crate::loader::load_scenario_from_source(None, Some(json)).unwrap(); + let tasks = build_frame_tasks(&scenario); + + let scene_b_frames = (2.0_f64 * 30.0).round() as u32; // 60 + let mut covered = vec![0u32; scene_b_frames as usize]; + for t in &tasks { + match t { + FrameTask::SlideTransition { + scene_b_idx: 1, + frame_in_transition, + .. + } => covered[*frame_in_transition as usize] += 1, + FrameTask::Normal { + scene_idx: 1, + frame_in_scene, + .. + } => covered[*frame_in_scene as usize] += 1, + _ => {} + } + } + + let missing: Vec = covered + .iter() + .enumerate() + .filter(|(_, &c)| c == 0) + .map(|(i, _)| i) + .collect(); + assert!( + missing.is_empty(), + "scene B local frames never rendered: {missing:?} (covered={covered:?})" + ); + let duplicated: Vec = covered + .iter() + .enumerate() + .filter(|(_, &c)| c > 1) + .map(|(i, _)| i) + .collect(); + assert!( + duplicated.is_empty(), + "scene B local frames rendered more than once: {duplicated:?}" + ); + + // Exact frame-accounting check: 9 SlideTransition frames (scene A's + // entire 9-frame budget) + 51 Normal frames for scene B + // (60 - 9 = 51) + 0 Normal frames for scene A (fully absorbed by + // the clamped transition) = 60 tasks total. + assert_eq!(tasks.len(), 60, "tasks: {tasks:?}"); + } +} + +// Constat 6 (audit lot "transitions"): a ViewTransition frame must never be +// byte-identical to the Normal frame it sits next to in the output stream. +#[cfg(test)] +mod view_transition_progress_tests { + use super::*; + + #[test] + fn never_reaches_either_endpoint() { + let frames = (0.2_f64 * 30.0).round() as u32; // 6 + for f in 0..frames { + let p = view_transition_progress(f, 0.2, 30); + assert!( + p > 0.0 && p < 1.0, + "frame {f}: progress {p} must be strictly inside (0.0, 1.0)" + ); + } + } + + #[test] + fn monotonic_and_symmetric_about_the_midpoint() { + let frames = (0.2_f64 * 30.0).round() as u32; // 6 + let mut prev = 0.0; + let mut values = Vec::new(); + for f in 0..frames { + let p = view_transition_progress(f, 0.2, 30); + assert!(p > prev, "must be strictly increasing: {prev} -> {p}"); + prev = p; + values.push(p); + } + // (f+1)/(N+1) is symmetric: values[i] + values[N-1-i] == 1.0. + for i in 0..values.len() { + let j = values.len() - 1 - i; + assert!( + (values[i] + values[j] - 1.0).abs() < 1e-9, + "not symmetric about the midpoint: values[{i}]={} values[{j}]={}", + values[i], + values[j] + ); + } + } + + #[test] + fn single_frame_transition_does_not_panic_and_stays_open() { + let p = view_transition_progress(0, 1.0 / 60.0, 30); + assert!(p.is_finite()); + assert!(p > 0.0 && p < 1.0); + } + + // Contrast with `transition_progress`, which the SlideTransition path + // deliberately pins to exactly 0.0/1.0 at its ends (new content there, + // not a repeat — see `transition_progress`'s doc). A `ViewTransition` + // must not share that behaviour. + #[test] + fn differs_from_the_closed_interval_slide_transition_formula() { + let frames = (0.2_f64 * 30.0).round() as u32; + assert_eq!(transition_progress(0, 0.2, 30), 0.0); + assert!(view_transition_progress(0, 0.2, 30) > 0.0); + assert_eq!(transition_progress(frames - 1, 0.2, 30), 1.0); + assert!(view_transition_progress(frames - 1, 0.2, 30) < 1.0); + } +} + +// Constat 6, black-box: an actual ViewTransition composite must not be +// byte-identical to the Normal frame sitting next to it in the output +// stream (the last frame of view A, or the first frame of view B). +#[cfg(test)] +mod view_transition_no_duplicate_frame_tests { + use super::*; + use crate::loader::load_scenario_from_source; + + fn two_view_scenario() -> String { + r##"{ + "video": { "width": 64, "height": 64, "fps": 30, "background": "#000000" }, + "composition": [ + { "type": "slide", "scenes": [ + { "duration": 0.5, "children": [ + { "type": "shape", "shape": "rect", "position": "absolute", + "x": 0, "y": 0, "style": { "width": 64, "height": 64, "background": "#ffffff" }, + "animation": [{ "name": "fade_in", "duration": 0.5, "easing": "linear" }] } + ] } + ] }, + { "type": "slide", + "transition": { "type": "fade", "duration": 0.2 }, + "scenes": [ + { "duration": 0.5, "children": [ + { "type": "shape", "shape": "rect", "position": "absolute", + "x": 0, "y": 0, "style": { "width": 64, "height": 64, "background": "#000000" } } + ] } + ] } + ] + }"## + .to_string() + } + + #[test] + fn transition_frames_are_never_byte_identical_to_their_adjacent_normal_frame() { + let json = two_view_scenario(); + let scenario = load_scenario_from_source(None, Some(&json)).expect("load"); + let tasks = build_frame_tasks(&scenario); + + // Locate: last Normal frame of view 0, first/last ViewTransition + // frame, first Normal frame of view 1 — in output order, exactly as + // they sit in the encoded stream. + let last_normal_a = tasks + .iter() + .rposition(|t| matches!(t, FrameTask::Normal { view_idx: 0, .. })) + .expect("view 0 has normal frames"); + let vt_frames: Vec = tasks + .iter() + .enumerate() + .filter(|(_, t)| matches!(t, FrameTask::ViewTransition { .. })) + .map(|(i, _)| i) + .collect(); + assert!(!vt_frames.is_empty(), "expected ViewTransition frames"); + let first_vt = vt_frames[0]; + let last_vt = *vt_frames.last().unwrap(); + let first_normal_b = tasks + .iter() + .position(|t| matches!(t, FrameTask::Normal { view_idx: 1, .. })) + .expect("view 1 has normal frames"); + assert_eq!(last_normal_a + 1, first_vt, "no gap before the transition"); + assert_eq!(last_vt + 1, first_normal_b, "no gap after the transition"); + + let render = |i: usize| render_frame_task(&scenario.video, &scenario, &tasks[i]).unwrap(); + let buf_last_normal_a = render(last_normal_a); + let buf_first_vt = render(first_vt); + let buf_last_vt = render(last_vt); + let buf_first_normal_b = render(first_normal_b); + + assert_ne!( + buf_last_normal_a, buf_first_vt, + "first ViewTransition frame duplicates the last Normal frame of view 0" + ); + assert_ne!( + buf_last_vt, buf_first_normal_b, + "last ViewTransition frame duplicates the first Normal frame of view 1" + ); + } +} + #[cfg(test)] mod hit_tests { use super::*; diff --git a/crates/rustmotion/src/engine/render/scene.rs b/crates/rustmotion/src/engine/render/scene.rs index dcef5b7..3ff5839 100644 --- a/crates/rustmotion/src/engine/render/scene.rs +++ b/crates/rustmotion/src/engine/render/scene.rs @@ -762,57 +762,90 @@ pub fn render_world_frame_scaled( &scene_b.resolved_background.animated }; - // Calculate crossfade progress based on camera pan position - let pan_half = timeline.camera_pan_duration / 2.0; - let (_, _scene_b_start) = timeline.scene_windows[scene_b_idx]; + // Calculate crossfade progress based on camera pan position. + // This boundary's pan sits between `scene_a_idx` and + // `scene_b_idx`, i.e. `boundary_pan_duration[scene_b_idx - 1]` + // (see `WorldTimeline::boundary_pan_duration`) — using the + // single view-level `camera_pan_duration` here instead would + // desync this background crossfade window from the foreground + // opacity/camera windows whenever the per-boundary clamp kicks + // in (pan longer than a neighbouring scene's duration). + let pan_half = timeline + .boundary_pan_duration + .get(scene_b_idx.saturating_sub(1)) + .copied() + .unwrap_or(timeline.camera_pan_duration) + / 2.0; let pan_start = timeline.scene_windows[scene_b_idx].0 - pan_half; let pan_end = timeline.scene_windows[scene_b_idx].0 + pan_half; let crossfade = safe_div(time - pan_start, pan_end - pan_start, 1.0).clamp(0.0, 1.0) as f32; - // Draw scene A backgrounds with fading alpha - if crossfade < 1.0 { + if std::ptr::eq(bgs_a, bgs_b) { + // Same backgrounds on both sides (the documented recipe: a + // shared `view.background` and no per-scene override) — a + // crossfade of a layer with itself is that layer, so just + // paint it once instead of doing the work twice. for bg in bgs_a { draw_world_bg_with_parallax(canvas, bg, time as f32, vw, vh, cam_x, cam_y); } - } - // Draw scene B backgrounds with growing alpha - if crossfade > 0.0 && !std::ptr::eq(bgs_a, bgs_b) { - // If backgrounds are different, create a temporary surface for B and blend - let bg_info = ImageInfo::new( - (scaled_w, scaled_h), - ColorType::RGBA8888, - skia_safe::AlphaType::Premul, - None, + } else { + // Distinct per-scene backgrounds: render each side into its + // own transparent, full-frame layer and crossfade the RAW + // pixel buffers in f32 — the same technique + // `camera_pan_transition`'s `Static` mode uses for the + // slide-view side of a scene-to-scene cut (see + // `crates/rustmotion-core/src/engine/transition.rs`). + // + // The previous version painted `bgs_a` straight onto the + // canvas at full alpha unconditionally, then composited + // `bgs_b` over it through Skia's 8-bit Paint alpha: scene + // A's background never faded at all, and scene B's alpha + // byte discharged its whole accumulated per-frame + // truncation as a single jump the instant `non_persisted` + // dropped back below 2 — measured as a step at the pan's + // end instead of a spread-out fade. + let layer_a = render_world_bg_layer_pixels( + bgs_a, + time as f32, + vw, + vh, + cam_x, + cam_y, + scaled_w, + scaled_h, + scale_factor, ); - if let Some(mut bg_surface) = surfaces::raster(&bg_info, None, None) { - let bg_canvas = bg_surface.canvas(); - if scale_factor != 1.0 { - bg_canvas.scale((scale_factor, scale_factor)); - } - bg_canvas.clear(skia_safe::Color4f::new(0.0, 0.0, 0.0, 0.0)); - for bg in bgs_b { - draw_world_bg_with_parallax( - bg_canvas, - bg, - time as f32, - vw, - vh, - cam_x, - cam_y, - ); - } - let snapshot = bg_surface.image_snapshot(); - let mut paint = skia_safe::Paint::default(); - paint.set_alpha_f(crossfade); - // save()/restore() already bracket the matrix change; an - // extra canvas.scale() after restore would compound it. - canvas.save(); - if scale_factor != 1.0 { - canvas.reset_matrix(); + let layer_b = render_world_bg_layer_pixels( + bgs_b, + time as f32, + vw, + vh, + cam_x, + cam_y, + scaled_w, + scaled_h, + scale_factor, + ); + if let (Some(la), Some(lb)) = (layer_a, layer_b) { + let blended = blend_world_bg_layers(&la, &lb, crossfade); + let bg_info = ImageInfo::new( + (scaled_w, scaled_h), + ColorType::RGBA8888, + skia_safe::AlphaType::Premul, + None, + ); + let data = skia_safe::Data::new_copy(&blended); + if let Some(img) = + skia_safe::images::raster_from_data(&bg_info, data, scaled_w as usize * 4) + { + canvas.save(); + if scale_factor != 1.0 { + canvas.reset_matrix(); + } + canvas.draw_image(&img, (0.0, 0.0), None); + canvas.restore(); } - canvas.draw_image(&snapshot, (0.0, 0.0), Some(&paint)); - canvas.restore(); } } } else { @@ -855,7 +888,18 @@ pub fn render_world_frame_scaled( canvas.translate((wx - viewport_cx, wy - viewport_cy)); // Use local_time for animations (clamped to 0 if pan hasn't finished) - let anim_time = vis.local_time.max(0.0); + let mut anim_time = vis.local_time.max(0.0); + // Apply freeze_at (parity with the other four render paths — + // render_frame_v2_scaled, render_scene_hits, render_scene_bg_scaled, + // render_scene_fg_scaled — all of which clamp `time` the same way). + // Only the animation clock is clamped, not `frame_index` + // (`vis.local_frame` below): the other paths keep advancing + // `frame_index` past the freeze point too, and diverging here would + // desync any effect keyed on frame index (e.g. grain) from a scene + // that also appears in a slide view. + if let Some(freeze_at) = scene.freeze_at { + anim_time = anim_time.min(freeze_at); + } // World views keep the global per-scene camera (depth planes are a // slide-view feature; the world pan is a separate transform). let ctx = RenderContext { @@ -935,6 +979,68 @@ pub fn render_world_frame_scaled( Ok(pixels) } +/// Render a world-view scene's set of animated backgrounds into their own +/// transparent, full-frame surface and read back the raw RGBA8888 pixels. +/// Used to crossfade the outgoing/incoming scene's background layers in f32 +/// (see the call site in `render_world_frame_scaled`) instead of Skia's +/// 8-bit Paint alpha. `None` only on surface-allocation failure. +#[allow(clippy::too_many_arguments)] +fn render_world_bg_layer_pixels( + bgs: &[crate::schema::AnimatedBackground], + time: f32, + vw: f32, + vh: f32, + cam_x: f32, + cam_y: f32, + scaled_w: i32, + scaled_h: i32, + scale_factor: f32, +) -> Option> { + let info = ImageInfo::new( + (scaled_w, scaled_h), + ColorType::RGBA8888, + skia_safe::AlphaType::Premul, + None, + ); + let mut surface = surfaces::raster(&info, None, None)?; + let canvas = surface.canvas(); + if scale_factor != 1.0 { + canvas.scale((scale_factor, scale_factor)); + } + canvas.clear(skia_safe::Color4f::new(0.0, 0.0, 0.0, 0.0)); + for bg in bgs { + draw_world_bg_with_parallax(canvas, bg, time, vw, vh, cam_x, cam_y); + } + let row_bytes = scaled_w as usize * 4; + let mut pixels = vec![0u8; row_bytes * scaled_h as usize]; + let dst_info = ImageInfo::new( + (scaled_w, scaled_h), + ColorType::RGBA8888, + skia_safe::AlphaType::Premul, + None, + ); + surface + .read_pixels(&dst_info, &mut pixels, row_bytes, (0, 0)) + .then_some(pixels) +} + +/// Blend two equally-sized RGBA8888 buffers in f32, byte-for-byte — the same +/// formula `blend_fade` in `crates/rustmotion-core/src/engine/transition.rs` +/// uses for slide-view crossfades. Kept as a local copy because that helper +/// is private to its own crate; see the call site's doc for why an f32 +/// blend matters here instead of Skia's Paint alpha. +fn blend_world_bg_layers(a: &[u8], b: &[u8], progress: f32) -> Vec { + let inv = 1.0 - progress; + a.iter() + .zip(b.iter()) + .map(|(&av, &bv)| { + let va = av as f32 * inv; + let vb = bv as f32 * progress; + (va + vb + 0.5) as u8 + }) + .collect() +} + /// Render only the background (solid color + animated-background) of a scene. pub fn render_scene_bg_scaled( config: &VideoConfig, diff --git a/crates/rustmotion/src/engine/world.rs b/crates/rustmotion/src/engine/world.rs index e597569..698b915 100644 --- a/crates/rustmotion/src/engine/world.rs +++ b/crates/rustmotion/src/engine/world.rs @@ -10,8 +10,26 @@ pub struct WorldTimeline { pub camera_waypoints: Vec, /// Total duration of the world view in seconds pub total_duration: f64, - /// Camera pan duration between scenes + /// Declared view-level camera pan duration (seconds), before the + /// per-boundary clamp below. Kept for callers that have no boundary + /// index at hand; prefer `boundary_pan_duration` wherever one is known. pub camera_pan_duration: f64, + /// Actual pan duration (seconds) used at each scene boundary — + /// `boundary_pan_duration[i]` governs the pan between `scenes[i]` and + /// `scenes[i + 1]`. `len() == scene_windows.len().saturating_sub(1)`. + /// + /// Clamped to `min(scenes[i].duration, scenes[i + 1].duration)`: a pan + /// window is centered on the boundary and reaches `pan_half` into each + /// side, so capping `pan_half` at half of *both* neighbouring scenes' + /// durations guarantees two consecutive pan windows never overlap. + /// Before this clamp existed, a `camera_pan_duration` longer than a + /// scene's own duration made boundary `i`'s window reach past boundary + /// `i + 1`'s start; `camera_at` returns the first matching window it + /// finds, so time entering that overlap jumped from boundary `i`'s + /// (already near-complete) interpolation straight to boundary `i + + /// 1`'s — a same-frame camera teleport measured at 245px (77% of the + /// frame width) in the audit's repro. + pub boundary_pan_duration: Vec, } #[derive(Debug, Clone)] @@ -56,6 +74,7 @@ impl WorldTimeline { camera_waypoints: Vec::new(), total_duration: 0.0, camera_pan_duration: pan_dur, + boundary_pan_duration: Vec::new(), }; } @@ -90,11 +109,19 @@ impl WorldTimeline { let total_duration = t; + // Per-boundary clamp — see the field doc on `boundary_pan_duration` + // for why `min` of both neighbouring scene durations is what + // guarantees non-overlapping pan windows. + let boundary_pan_duration: Vec = (0..scenes.len().saturating_sub(1)) + .map(|i| pan_dur.min(scenes[i].duration).min(scenes[i + 1].duration)) + .collect(); + WorldTimeline { scene_windows: windows, camera_waypoints: waypoints, total_duration, camera_pan_duration: pan_dur, + boundary_pan_duration, } } @@ -113,8 +140,6 @@ impl WorldTimeline { return (wp.x, wp.y); } - let pan_half = self.camera_pan_duration / 2.0; - // Before the first waypoint if time < self.camera_waypoints[0].time { let wp = &self.camera_waypoints[0]; @@ -126,6 +151,16 @@ impl WorldTimeline { let wp_a = &self.camera_waypoints[i]; let wp_b = &self.camera_waypoints[i + 1]; + // Each boundary uses its own clamped pan duration (see + // `boundary_pan_duration`'s doc) so consecutive windows never + // overlap and this loop's first match is always the right one. + let pan_half = self + .boundary_pan_duration + .get(i) + .copied() + .unwrap_or(self.camera_pan_duration) + / 2.0; + // Pan starts pan_half before wp_b.time and ends pan_half after wp_b.time let pan_start = wp_b.time - pan_half; let pan_end = wp_b.time + pan_half; @@ -151,28 +186,50 @@ impl WorldTimeline { (last.x, last.y) } + /// Pan duration (seconds) into `scenes[i]` (from `i - 1`) and out of it + /// (to `i + 1`). `0.0` at the timeline's own edges, where there is no + /// neighbour to pan from/to. + fn boundary_pans_for(&self, i: usize) -> (f64, f64) { + let in_pan_dur = if i > 0 { + self.boundary_pan_duration + .get(i - 1) + .copied() + .unwrap_or(0.0) + } else { + 0.0 + }; + let out_pan_dur = self.boundary_pan_duration.get(i).copied().unwrap_or(0.0); + (in_pan_dur, out_pan_dur) + } + /// Return all scenes that should be visible at the given time. /// /// A scene is visible if: /// - We're within its time window, OR - /// - We're within camera_pan_duration/2 of its boundary (it's being panned to/from), OR + /// - We're within its own boundary's pan duration / 2 of its start or + /// end (it's being panned to/from — see `boundary_pan_duration`), OR /// - It has `persist: true` and its window has ended pub fn visible_scenes_at(&self, time: f64, scenes: &[Scene], fps: u32) -> Vec { let mut result = Vec::new(); - let pan_half = self.camera_pan_duration / 2.0; for (i, (start, end)) in self.scene_windows.iter().enumerate() { let scene = &scenes[i]; let scene_total_frames = (scene.duration * fps as f64).round() as u32; - // The pan to this scene starts at `start - pan_half` and finishes at `start + pan_half` - // Animations begin after the pan finishes arriving, so anim_start = start + pan_half + // Pan durations either side of this scene, each independently + // clamped at build time — see `boundary_pan_duration`. + let (in_pan_dur, out_pan_dur) = self.boundary_pans_for(i); + let in_pan_half = in_pan_dur / 2.0; + let out_pan_half = out_pan_dur / 2.0; + + // The pan to this scene starts at `start - in_pan_half` and finishes at `start + in_pan_half` + // Animations begin after the pan finishes arriving, so anim_start = start + in_pan_half // (For the first scene, there's no incoming pan, so anim_start = start) - let anim_start = if i == 0 { *start } else { start + pan_half }; + let anim_start = if i == 0 { *start } else { start + in_pan_half }; // Is this scene currently in its active window (including pan margins)? - let visible_start = start - pan_half; - let visible_end = *end + pan_half; + let visible_start = start - in_pan_half; + let visible_end = *end + out_pan_half; let is_in_window = time >= visible_start.max(0.0) && time < visible_end; let is_persisted = scene.persist && time >= *end; @@ -186,34 +243,73 @@ impl WorldTimeline { .min(scene_total_frames.saturating_sub(1)) }; - // Calculate opacity for crossfade during camera pans + // The outgoing pan window, centred on `end`: starts at + // `end - out_pan_half`, ends at `end + out_pan_half`. Shared + // by the non-persisted fade-out branch and the persisted + // recovery ramp below, so both agree on where it sits. + let out_pan_start = *end - out_pan_half; + let out_pan_end = *end + out_pan_half; + let has_outgoing_pan = i < self.scene_windows.len() - 1; + + // Calculate opacity for crossfade during camera pans. + // + // Both branches use mirrored power-curve exponents rather + // than a plain linear ramp — the same shape + // `camera_pan_transition`'s `FG_DISSOLVE` uses for the + // slide-view side of a scene-to-scene cut (see + // `crates/rustmotion-core/src/engine/transition.rs`). A + // linear 1-t / t ramp on two scenes that occupy roughly + // equal, non-overlapping screen slices at the pan's + // midpoint (the camera sits between their world-positions) + // multiplies through to a p²+(1-p)² luminance curve that + // dips to 50% exactly mid-pan — a wash-out the `world` view + // exists to avoid. Pinned at both ends (`0` and `1`) so a + // transition frame's opacity is always exactly 1.0 at its + // own scene's t=0/t=1 junction against a non-pan frame. + const CROSSFADE_DISSOLVE: f32 = 1.6; + let fade_in_curve = |p: f32| 1.0 - (1.0 - p).powf(CROSSFADE_DISSOLVE); + let fade_out_curve = |p: f32| 1.0 - p.powf(CROSSFADE_DISSOLVE); + let opacity = if is_persisted { - 1.0_f32 + // `persist` keeps this scene's content around after its + // own window ends instead of disappearing — but until + // `has_outgoing_pan` is checked, `is_persisted` alone + // says nothing about *how far* past `end` we are. If + // we're still inside the same outgoing pan window that + // a non-persisted scene would be fading out through, + // continue that exact curve from the value it already + // reached at `time == end` and ramp it back up to 1.0 by + // `out_pan_end`, instead of snapping straight to 1.0. + // The snap was the bug: the fade-out curve is still + // mid-descent at `end` (progress 0.5 into the outgoing + // window), so forcing opacity to 1.0 right there produced + // a same-frame pop from a partial value — on a feature + // whose entire point is a callback with no rupture. + if has_outgoing_pan && time < out_pan_end { + let value_at_end = fade_out_curve(0.5); + let recovery = + safe_div(time - *end, out_pan_end - *end, 1.0).clamp(0.0, 1.0) as f32; + value_at_end + (1.0 - value_at_end) * fade_in_curve(recovery) + } else { + 1.0_f32 + } } else { - // Check if scene is fading OUT (pan away from this scene) - // The outgoing pan starts at `end - pan_half` and ends at `end + pan_half` - let out_pan_start = *end - pan_half; - let out_pan_end = *end + pan_half; - // Check if scene is fading IN (pan arriving at this scene) - let in_pan_start = *start - pan_half; - let in_pan_end = *start + pan_half; + let in_pan_start = *start - in_pan_half; + let in_pan_end = *start + in_pan_half; if i > 0 && time >= in_pan_start.max(0.0) && time < in_pan_end { // Fading in: opacity goes 0 → 1 during incoming pan let denom = in_pan_end - in_pan_start.max(0.0); - let progress = - safe_div(time - in_pan_start.max(0.0), denom, 1.0).clamp(0.0, 1.0); - progress as f32 - } else if i < self.scene_windows.len() - 1 - && time >= out_pan_start - && time <= out_pan_end - { + let progress = safe_div(time - in_pan_start.max(0.0), denom, 1.0) + .clamp(0.0, 1.0) as f32; + fade_in_curve(progress) + } else if has_outgoing_pan && time >= out_pan_start && time <= out_pan_end { // Fading out: opacity goes 1 → 0 during outgoing pan let progress = safe_div(time - out_pan_start, out_pan_end - out_pan_start, 1.0) - .clamp(0.0, 1.0); - 1.0 - progress as f32 + .clamp(0.0, 1.0) as f32; + fade_out_curve(progress) } else { 1.0 } @@ -232,4 +328,281 @@ impl WorldTimeline { result } + + /// The scene actively "in front" at `time` — the highest-indexed + /// non-persisted visible scene, mirroring the selection + /// `render_world_frame_scaled` uses to pick which scene's background + /// dominates a frame. `None` when nothing is visible (e.g. an empty + /// view). Shared with the frame-task post-effects pass so both agree on + /// which scene's `effects` apply to a given `WorldFrame`. + pub fn active_scene_idx(&self, time: f64, scenes: &[Scene], fps: u32) -> Option { + self.visible_scenes_at(time, scenes, fps) + .iter() + .filter(|v| !v.is_persisted) + .map(|v| v.scene_idx) + .max() + } +} + +#[cfg(test)] +mod world_timeline_tests { + use super::*; + + fn view_from_json(json: &str) -> crate::schema::ResolvedView { + let scenario = + crate::loader::load_scenario_from_source(None, Some(json)).expect("scenario must load"); + scenario + .views + .into_iter() + .next() + .expect("scenario must have at least one view") + } + + // Constat 5: `camera_pan_duration` longer than a scene's own duration + // must not let two consecutive pan windows overlap. + mod camera_teleport { + use super::*; + + const REPRO: &str = r#"{ + "video": { "width": 320, "height": 180, "fps": 30 }, + "composition": [ + { "type": "world", "camera_pan_duration": 2.0, "camera_easing": "linear", + "scenes": [ + { "duration": 0.5, "children": [] }, + { "duration": 0.5, "children": [] }, + { "duration": 0.5, "children": [] }, + { "duration": 0.5, "children": [] } + ] } + ] + }"#; + + #[test] + fn boundary_pan_duration_is_clamped_per_junction() { + let view = view_from_json(REPRO); + let timeline = WorldTimeline::build(&view, 30, 320, 180); + assert_eq!(timeline.boundary_pan_duration.len(), 3); + for (i, d) in timeline.boundary_pan_duration.iter().enumerate() { + assert!( + (*d - 0.5).abs() < 1e-9, + "boundary {i}: expected clamp to 0.5s (both neighbouring scenes are 0.5s), got {d}" + ); + } + } + + #[test] + fn first_frame_is_not_already_decadre() { + let view = view_from_json(REPRO); + let timeline = WorldTimeline::build(&view, 30, 320, 180); + let (x, y) = timeline.camera_at(0.0, &view.camera_easing); + assert_eq!( + (x, y), + (160.0, 90.0), + "camera_at(0) must sit exactly on scene 0's default waypoint, not already \ + mid-pan toward scene 1 (the secondary effect the audit measured as x=240)" + ); + } + + // The decisive test: sample the camera's x position at every + // rendered frame across the whole timeline and measure the largest + // frame-to-frame jump. Before the per-boundary clamp, two + // overlapping pan windows produced a same-frame jump of 245px (the + // audit's repro measured x: 480 -> 725.3 between t=1.5 and + // t=1.5333, one frame apart). With the clamp, the theoretical worst + // case is the full 320px waypoint spacing spread over one 15-frame + // (0.5s) pan window: 320/15 ≈ 21.3px/frame. + #[test] + fn camera_x_never_jumps_more_than_one_pans_worth_of_travel_per_frame() { + let view = view_from_json(REPRO); + let timeline = WorldTimeline::build(&view, 30, 320, 180); + let fps = 30u32; + let total_frames = timeline.total_frames(fps); + + let mut max_jump = 0.0_f32; + let mut worst_at = 0u32; + let mut prev_x = timeline.camera_at(0.0, &view.camera_easing).0; + for f in 1..total_frames { + let t = f as f64 / fps as f64; + let (x, _y) = timeline.camera_at(t, &view.camera_easing); + let jump = (x - prev_x).abs(); + if jump > max_jump { + max_jump = jump; + worst_at = f; + } + prev_x = x; + } + + // Generous headroom (40px) over the ~21.3px theoretical worst + // case — still an order of magnitude under the 245px the bug + // produced. + assert!( + max_jump < 40.0, + "max per-frame camera jump {max_jump}px at frame {worst_at} — expected < 40px \ + (bug produced 245px in one frame)" + ); + } + } + + // Constat 3: the scene-to-scene crossfade opacity must not wash the + // whole frame out to 50% at the midpoint of a pan. + mod crossfade_dissolve { + use super::*; + + const REPRO: &str = r#"{ + "video": { "width": 320, "height": 180, "fps": 30 }, + "composition": [ + { "type": "world", "camera_pan_duration": 0.8, "camera_easing": "linear", + "scenes": [ + { "duration": 2.0, "children": [] }, + { "duration": 2.0, "children": [] } + ] } + ] + }"#; + + #[test] + fn opacity_is_exactly_pinned_at_the_fade_in_windows_own_junctions() { + let view = view_from_json(REPRO); + let timeline = WorldTimeline::build(&view, 30, 320, 180); + // Boundary pan: 0.8s clamped to min(2.0, 2.0) = 0.8s, half = 0.4s, + // centred on t=2.0 (end of scene 0 / start of scene 1). + let scene1_at = |t: f64| { + timeline + .visible_scenes_at(t, &view.scenes, 30) + .into_iter() + .find(|v| v.scene_idx == 1) + .map(|v| v.opacity) + }; + assert_eq!( + scene1_at(1.6), + Some(0.0), + "t=0 of scene 1's fade-in window must be exactly 0.0" + ); + assert_eq!( + scene1_at(2.4), + Some(1.0), + "t=1 of scene 1's fade-in window must be exactly 1.0" + ); + } + + #[test] + fn mid_pan_opacity_stays_well_above_the_old_50_percent_floor() { + let view = view_from_json(REPRO); + let timeline = WorldTimeline::build(&view, 30, 320, 180); + let visible = timeline.visible_scenes_at(2.0, &view.scenes, 30); + + let scene0 = visible + .iter() + .find(|v| v.scene_idx == 0) + .expect("scene 0 must still be visible mid-pan"); + let scene1 = visible + .iter() + .find(|v| v.scene_idx == 1) + .expect("scene 1 must be visible mid-pan"); + + // Mirrored power-curve exponent (k=1.6) at progress=0.5: + // 1 - 0.5^1.6 ≈ 0.670. The old linear ramp gave exactly 0.5. + for (label, opacity) in [ + ("scene0 (fading out)", scene0.opacity), + ("scene1 (fading in)", scene1.opacity), + ] { + assert!( + opacity > 0.6, + "{label} mid-pan opacity {opacity} must be well above the old 0.5 floor" + ); + assert!( + (opacity - 0.670).abs() < 0.01, + "{label} mid-pan opacity {opacity} should match the mirrored-exponent curve (~0.670)" + ); + } + } + } + + // Constat 4: `persist: true` must not pop back to 1.0 opacity while a + // scene is still mid-fade-out; it must rise continuously. + mod persist_recovery { + use super::*; + + const REPRO: &str = r#"{ + "video": { "width": 320, "height": 180, "fps": 30 }, + "composition": [ + { "type": "world", "camera_pan_duration": 1.0, "camera_easing": "linear", + "scenes": [ + { "duration": 1.0, "persist": true, "world-position": { "x": 160, "y": 90 }, + "children": [] }, + { "duration": 1.0, "world-position": { "x": 160, "y": 90 }, "children": [] } + ] } + ] + }"#; + + fn scene0_opacity_at(timeline: &WorldTimeline, scenes: &[Scene], t: f64) -> f32 { + timeline + .visible_scenes_at(t, scenes, 30) + .into_iter() + .find(|v| v.scene_idx == 0) + .expect("scene 0 must be visible/persisted throughout [0.5, 1.5]") + .opacity + } + + // The exact junction the bug hit: `is_persisted` flips true at + // `time >= end` (1.0), but the fade-out curve is only half-descended + // there (progress 0.5 into the [0.5, 1.5] outgoing window) — the old + // code forced opacity to 1.0 at that exact instant regardless. + #[test] + fn no_pop_at_the_instant_persist_takes_over() { + let view = view_from_json(REPRO); + let timeline = WorldTimeline::build(&view, 30, 320, 180); + let just_before = scene0_opacity_at(&timeline, &view.scenes, 1.0 - 1.0 / 300.0); + let at_end = scene0_opacity_at(&timeline, &view.scenes, 1.0); + let delta = (at_end - just_before).abs(); + assert!( + delta < 0.05, + "opacity must be continuous across the is_persisted switch: \ + just_before={just_before} at_end={at_end} delta={delta} (bug produced ~0.40-0.48)" + ); + } + + #[test] + fn reaches_exactly_one_by_the_end_of_the_outgoing_pan_window() { + let view = view_from_json(REPRO); + let timeline = WorldTimeline::build(&view, 30, 320, 180); + assert_eq!(scene0_opacity_at(&timeline, &view.scenes, 1.5), 1.0); + assert_eq!(scene0_opacity_at(&timeline, &view.scenes, 2.0), 1.0); + } + + // The decisive test: sample every rendered frame across the outgoing + // pan window and measure the largest frame-to-frame opacity jump. + #[test] + fn opacity_never_jumps_more_than_one_frames_worth_across_the_whole_window() { + let view = view_from_json(REPRO); + let timeline = WorldTimeline::build(&view, 30, 320, 180); + let fps = 30u32; + + let start_frame = (0.5 * fps as f64).round() as u32; + let end_frame = (1.5 * fps as f64).round() as u32; + + let mut max_jump = 0.0_f32; + let mut worst_at = start_frame; + let mut prev = + scene0_opacity_at(&timeline, &view.scenes, start_frame as f64 / fps as f64); + for f in (start_frame + 1)..=end_frame { + let t = f as f64 / fps as f64; + let cur = scene0_opacity_at(&timeline, &view.scenes, t); + let jump = (cur - prev).abs(); + if jump > max_jump { + max_jump = jump; + worst_at = f; + } + prev = cur; + } + + // The bug produced a single-frame jump of ~0.40-0.48 (measured + // 409/1024 ≈ 0.40 in the audit's YAVG repro). A continuous curve + // sampled at 30fps over a 1.0s window should never move more + // than a small fraction per frame. + assert!( + max_jump < 0.15, + "max per-frame opacity jump {max_jump} at frame {worst_at} — expected < 0.15 \ + (bug produced ~0.40-0.48 in one frame)" + ); + } + } } diff --git a/crates/rustmotion/src/tests.rs b/crates/rustmotion/src/tests.rs index a1ca7a3..55e93c1 100644 --- a/crates/rustmotion/src/tests.rs +++ b/crates/rustmotion/src/tests.rs @@ -2878,3 +2878,206 @@ mod parallax_hitmap_tests { ); } } + +// ─── World-view regressions (audit lot "transitions", constats 2/7/8) ──────── + +#[cfg(test)] +mod world_view_regressions { + use crate::encode::video::{build_frame_tasks, render_frame_task, FrameTask}; + use crate::loader::load_scenario_from_source; + use crate::schema::ResolvedScenario; + + fn scenario(json: &str) -> ResolvedScenario { + load_scenario_from_source(None, Some(json)).expect("load") + } + + fn avg_luma(buf: &[u8]) -> f64 { + let mut sum = 0u64; + let mut n = 0u64; + for px in buf.chunks_exact(4) { + sum += px[0] as u64 + px[1] as u64 + px[2] as u64; + n += 3; + } + sum as f64 / n as f64 + } + + // Constat 2: the world-view background crossfade must genuinely fade the + // outgoing scene's background during the pan, and must not jump when the + // pan ends and the renderer switches from the two-scene crossfade branch + // to the single-active-scene branch. + #[test] + fn outgoing_world_background_fades_gradually_instead_of_holding_then_jumping() { + let json = r##"{ + "video": { "width": 320, "height": 180, "fps": 30, "background": "#000000" }, + "composition": [ + { "type": "world", "camera_pan_duration": 0.8, "camera_easing": "linear", + "scenes": [ + { "duration": 2.0, "children": [], + "background": { "preset": "halo", "zones": [ + { "color": "#FFFFFF80", "x": 0.5, "y": 0.5, "radius": 3.0 } + ] } }, + { "duration": 2.0, "children": [], + "background": { "preset": "halo", "zones": [ + { "color": "#00000000", "x": 0.5, "y": 0.5, "radius": 3.0 } + ] } } + ] } + ] + }"##; + let scenario = scenario(json); + let tasks = build_frame_tasks(&scenario); + let fps = scenario.video.fps; + + // Pan window: boundary at t=2.0, half=0.4s -> [1.6, 2.4]. Sample a + // margin either side too, at frame granularity (the actual render + // grain), from t=1.5 to t=2.5. + let render_at = |t: f64| { + let f = (t * fps as f64).round() as usize; + let task = tasks + .iter() + .find(|task| matches!(task, FrameTask::WorldFrame { frame_in_view, .. } if *frame_in_view as usize == f)) + .unwrap_or_else(|| panic!("no WorldFrame task for frame {f} (t={t})")); + render_frame_task(&scenario.video, &scenario, task).unwrap() + }; + + let mut samples = Vec::new(); + let start_f = (1.5 * fps as f64).round() as i32; + let end_f = (2.5 * fps as f64).round() as i32; + for f in start_f..=end_f { + let t = f as f64 / fps as f64; + samples.push((f, avg_luma(&render_at(t)))); + } + + // No single-frame jump: the old bug held scene A's halo at full + // alpha for the entire pan, then a hard cut when the "single active + // scene" branch took over at the pan's end. + let mut max_jump = 0.0_f64; + let mut worst = (0, 0); + for w in samples.windows(2) { + let jump = (w[1].1 - w[0].1).abs(); + if jump > max_jump { + max_jump = jump; + worst = (w[0].0, w[1].0); + } + } + assert!( + max_jump < 15.0, + "avg-luma jump of {max_jump:.1} between frames {worst:?} — background must fade \ + gradually, not hold then cut. Samples: {samples:?}" + ); + + // Genuine fade, not a flat hold: the value partway through the pan + // must sit strictly between the pre-pan and post-pan levels, not + // equal either endpoint (the "never fades" half of the bug). + let pre = samples.first().unwrap().1; + let post = samples.last().unwrap().1; + let mid = samples[samples.len() / 2].1; + assert!( + (mid - pre).abs() > 1.0 && (mid - post).abs() > 1.0, + "mid-pan luma {mid:.1} must differ meaningfully from both pre-pan {pre:.1} and \ + post-pan {post:.1} — background never faded if it matches either endpoint" + ); + } + + // Constat 7: `freeze_at` on a world-view scene must stop its animated + // content exactly like it does in a slide view, not keep advancing. + #[test] + fn freeze_at_stops_animation_inside_a_world_view() { + let json = r##"{ + "video": { "width": 200, "height": 200, "fps": 30, "background": "#000000" }, + "composition": [ + { "type": "world", "scenes": [ + { "duration": 2.0, "freeze_at": 0.5, "children": [ + { "type": "counter", "from": 0, "to": 200, + "style": { "font-size": 48, "color": "#ffffff" } } + ] } + ] } + ] + }"##; + let scenario = scenario(json); + let tasks = build_frame_tasks(&scenario); + // 2.0s @ 30fps = 60 WorldFrame tasks; freeze_at=0.5s = frame 15. + assert_eq!(tasks.len(), 60); + + let render = |i: usize| render_frame_task(&scenario.video, &scenario, &tasks[i]).unwrap(); + let before_freeze = render(5); // t ~= 0.167s, counter still climbing + let after_freeze_a = render(45); // t = 1.5s, well past freeze_at + let after_freeze_b = render(55); // t ~= 1.833s, also well past freeze_at + + assert_ne!( + before_freeze, after_freeze_a, + "counter must have visibly changed before the freeze point" + ); + assert_eq!( + after_freeze_a, after_freeze_b, + "frames 45 and 55 are both past freeze_at=0.5s and must be pixel-identical \ + (the counter must have stopped, not kept incrementing)" + ); + } + + // Constat 8a: `scene.effects` (post-effects) must apply on WorldFrame + // tasks, not just Normal/SlideTransition ones. + #[test] + fn post_effects_apply_on_world_frames() { + let json = r##"{ + "video": { "width": 100, "height": 100, "background": "#ffffff" }, + "composition": [ + { "type": "world", "scenes": [ + { "duration": 1.0, "children": [], + "effects": [ { "type": "vignette", "intensity": 0.9, "radius": 0.3 } ] } + ] } + ] + }"##; + let scenario = scenario(json); + let tasks = build_frame_tasks(&scenario); + let task = tasks + .iter() + .find(|t| matches!(t, FrameTask::WorldFrame { .. })) + .expect("world frame task"); + let buf = render_frame_task(&scenario.video, &scenario, task).unwrap(); + + let corner_r = buf[0] as u16; + let center_base = (50 * 100 + 50) * 4; + let center_r = buf[center_base] as u16; + assert!( + corner_r < center_r, + "vignette must darken the corner of a WorldFrame: corner={corner_r} center={center_r}" + ); + } + + // Constat 8b: a ViewTransition composite must carry the incoming view's + // effects too, by symmetry with SlideTransition (so an effect present on + // both sides' Normal frames doesn't disappear for the transition and pop + // back). + #[test] + fn post_effects_apply_on_view_transition_frames() { + let json = r##"{ + "video": { "width": 100, "height": 100, "fps": 10, "background": "#ffffff" }, + "composition": [ + { "type": "slide", "scenes": [ + { "duration": 0.5, "children": [], + "effects": [ { "type": "vignette", "intensity": 0.9, "radius": 0.3 } ] } + ] }, + { "type": "slide", "transition": { "type": "fade", "duration": 0.3 }, + "scenes": [ + { "duration": 0.5, "children": [], + "effects": [ { "type": "vignette", "intensity": 0.9, "radius": 0.3 } ] } + ] } + ] + }"##; + let scenario = scenario(json); + let tasks = build_frame_tasks(&scenario); + let task = tasks + .iter() + .find(|t| matches!(t, FrameTask::ViewTransition { .. })) + .expect("view transition task"); + let buf = render_frame_task(&scenario.video, &scenario, task).unwrap(); + + let corner_r = buf[0] as u16; + let center_base = (50 * 100 + 50) * 4; + let center_r = buf[center_base] as u16; + assert!( + corner_r < center_r, + "vignette must darken the corner of a ViewTransition frame: corner={corner_r} center={center_r}" + ); + } +}