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}" + ); + } +}