diff --git a/crates/rustmotion-components/src/dot_map.rs b/crates/rustmotion-components/src/dot_map.rs index 9dd525d..5d2a1ad 100644 --- a/crates/rustmotion-components/src/dot_map.rs +++ b/crates/rustmotion-components/src/dot_map.rs @@ -78,9 +78,13 @@ pub struct GlobeRotation { /// A great-circle arc lifted off the globe's surface, drawn between two /// `[lat, lng]` endpoints. `draw_in` reveals the arc from `from` towards -/// `to` (1.0 = fully drawn), the counterpart of `draw_progress` on `line`/ -/// `arrow` — a plain static fraction here, since an arc's shape (which -/// hemisphere it crosses) already changes as the globe itself rotates. +/// `to` (1.0 = fully drawn) as a per-arc ceiling: the fraction actually +/// painted this frame is `draw_in` scaled by the node's own animated +/// `draw_progress` when one is active (the same property `line`/`arrow` +/// read), so a `draw_progress` keyframe on the `dot_map` component now +/// sweeps every arc in over time instead of being silently inert. With no +/// `draw_progress` animation, `draw_in` alone still applies as a fixed +/// fraction. #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct GreatCircleArc { pub from: [f64; 2], @@ -293,13 +297,20 @@ impl DotMap { 1.0 - (1.0 - p).powi(3) } - fn paint(&self, canvas: &Canvas, layout_w: f32, layout_h: f32, ctx: &PaintCtx) { + fn paint( + &self, + canvas: &Canvas, + layout_w: f32, + layout_h: f32, + ctx: &PaintCtx, + arc_draw_progress: f32, + ) { match self.projection { DotMapProjection::Equirectangular => { self.paint_equirectangular(canvas, layout_w, layout_h, ctx.time) } DotMapProjection::Orthographic => { - self.paint_orthographic(canvas, layout_w, layout_h, ctx.time) + self.paint_orthographic(canvas, layout_w, layout_h, ctx.time, arc_draw_progress) } } } @@ -321,7 +332,14 @@ impl DotMap { (center_lat, center_lng) } - fn paint_orthographic(&self, canvas: &Canvas, layout_w: f32, layout_h: f32, time: f64) { + fn paint_orthographic( + &self, + canvas: &Canvas, + layout_w: f32, + layout_h: f32, + time: f64, + arc_draw_progress: f32, + ) { let w = layout_w; let h = layout_h; let progress = self.progress_at(time); @@ -342,7 +360,14 @@ impl DotMap { self.paint_points_orthographic( canvas, center, radius, center_lat, center_lng, progress, time, ); - self.paint_arcs_orthographic(canvas, center, radius, center_lat, center_lng); + self.paint_arcs_orthographic( + canvas, + center, + radius, + center_lat, + center_lng, + arc_draw_progress, + ); } fn paint_world_dots_orthographic( @@ -495,6 +520,7 @@ impl DotMap { } } + #[allow(clippy::too_many_arguments)] fn paint_arcs_orthographic( &self, canvas: &Canvas, @@ -502,9 +528,15 @@ impl DotMap { radius: f32, center_lat: f64, center_lng: f64, + arc_draw_progress: f32, ) { + let animated_reveal = if arc_draw_progress >= 0.0 { + arc_draw_progress.clamp(0.0, 1.0) + } else { + 1.0 + }; for arc in &self.arcs { - let max_t = arc.draw_in.clamp(0.0, 1.0) as f64; + let max_t = (arc.draw_in.clamp(0.0, 1.0) * animated_reveal) as f64; if max_t <= 0.0 { continue; } @@ -680,10 +712,16 @@ impl Painter for DotMap { &self, canvas: &Canvas, layout: &BoxLayout, - _props: &AnimatedProperties, + props: &AnimatedProperties, ctx: &PaintCtx, ) { - self.paint(canvas, layout.width, layout.height, ctx); + self.paint( + canvas, + layout.width, + layout.height, + ctx, + props.draw_progress, + ); } } @@ -705,8 +743,29 @@ mod tests { } fn render(map: &DotMap, w: u32, h: u32, time: f64) -> Vec { + render_via_paint_content(map, w, h, time, -1.0) + } + + fn render_via_paint_content( + map: &DotMap, + w: u32, + h: u32, + time: f64, + draw_progress: f32, + ) -> Vec { let mut surface = skia_safe::surfaces::raster_n32_premul((w as i32, h as i32)).unwrap(); - map.paint(surface.canvas(), w as f32, h as f32, &test_ctx(time)); + let layout = BoxLayout { + x: 0.0, + y: 0.0, + width: w as f32, + height: h as f32, + ..Default::default() + }; + let props = AnimatedProperties { + draw_progress, + ..Default::default() + }; + map.paint_content(surface.canvas(), &layout, &props, &test_ctx(time)); let info = skia_safe::ImageInfo::new( (w as i32, h as i32), skia_safe::ColorType::RGBA8888, @@ -893,4 +952,49 @@ mod tests { the globe" ); } + + #[test] + fn animating_the_nodes_draw_progress_sweeps_an_arc_in_instead_of_being_ignored() { + let map = dot_map_from(serde_json::json!({ + "projection": "orthographic", + "points": [], + "show_world": false, + "arcs": [{"from": [10.0, -30.0], "to": [10.0, 30.0], "draw_in": 1.0}], + })); + let at_zero = render_via_paint_content(&map, 300, 300, 1.0, 0.0); + let at_full = render_via_paint_content(&map, 300, 300, 1.0, 1.0); + assert!( + !has_orange_pixel(&at_zero), + "an animated draw_progress of 0.0 on the dot_map node must paint no arc stroke \ + at all, not the fully-drawn arc" + ); + assert!( + has_orange_pixel(&at_full), + "an animated draw_progress of 1.0 on the dot_map node must paint the arc's \ + (default orange) stroke" + ); + } + + #[test] + fn a_static_draw_in_still_caps_the_arc_once_draw_progress_is_fully_animated_in() { + let capped = dot_map_from(serde_json::json!({ + "projection": "orthographic", + "points": [], + "show_world": false, + "arcs": [{"from": [10.0, -60.0], "to": [10.0, 60.0], "draw_in": 0.2}], + })); + let full = dot_map_from(serde_json::json!({ + "projection": "orthographic", + "points": [], + "show_world": false, + "arcs": [{"from": [10.0, -60.0], "to": [10.0, 60.0], "draw_in": 1.0}], + })); + let out_capped = render_via_paint_content(&capped, 300, 300, 1.0, 1.0); + let out_full = render_via_paint_content(&full, 300, 300, 1.0, 1.0); + assert_ne!( + out_capped, out_full, + "draw_in must still cap how much of the arc paints even once the node's own \ + animated draw_progress has reached 1.0" + ); + } } diff --git a/crates/rustmotion-core/src/engine/paint_pass.rs b/crates/rustmotion-core/src/engine/paint_pass.rs index 921cb49..879a6af 100644 --- a/crates/rustmotion-core/src/engine/paint_pass.rs +++ b/crates/rustmotion-core/src/engine/paint_pass.rs @@ -141,10 +141,20 @@ impl LayoutSurfaceFrame { ) } + fn child_facing(&self, nu: f32, nv: f32) -> (f32, f32) { + layout_surface_child_angles(self.kind, self.arc_x_deg, self.arc_y_deg, nu, nv) + } + fn apply(&self, canvas: &Canvas, child: &BoxLayout) { let nu = ((child.cx() - self.pivot.0) / self.half_w).clamp(-1.0, 1.0); let nv = ((child.cy() - self.pivot.1) / self.half_h).clamp(-1.0, 1.0); let (dx, dy, dz) = self.child_delta(nu, nv); + let (theta_x, theta_y) = self.child_facing(nu, nv); + let flat_x = nu * self.half_w; + let flat_y = nv * self.half_h; + let target_x = flat_x + dx; + let target_y = flat_y + dy; + let target_z = dz; let mut m = M44::new_identity(); m.pre_concat(&M44::translate(self.pivot.0, self.pivot.1, 0.0)); @@ -163,12 +173,36 @@ impl LayoutSurfaceFrame { self.rotate_x_deg.to_radians(), )); } - m.pre_concat(&M44::translate(dx, dy, dz)); + m.pre_concat(&M44::translate(target_x, target_y, target_z)); + if theta_x.abs() > 0.0001 { + m.pre_concat(&M44::rotate(V3::new(0.0, 1.0, 0.0), theta_x)); + } + if theta_y.abs() > 0.0001 { + m.pre_concat(&M44::rotate(V3::new(1.0, 0.0, 0.0), theta_y)); + } + m.pre_concat(&M44::translate(-flat_x, -flat_y, 0.0)); m.pre_concat(&M44::translate(-self.pivot.0, -self.pivot.1, 0.0)); canvas.concat_44(&m); } } +fn layout_surface_child_angles( + kind: SurfaceKind, + arc_x_deg: f32, + arc_y_deg: f32, + nu: f32, + nv: f32, +) -> (f32, f32) { + let nu = nu.clamp(-1.0, 1.0); + let nv = nv.clamp(-1.0, 1.0); + let theta_x = nu * arc_x_deg.to_radians() / 2.0; + let theta_y = match kind { + SurfaceKind::Sphere => nv * arc_y_deg.to_radians() / 2.0, + SurfaceKind::Cylinder => 0.0, + }; + (theta_x, theta_y) +} + #[allow(clippy::too_many_arguments)] fn layout_surface_child_delta( kind: SurfaceKind, @@ -182,11 +216,10 @@ fn layout_surface_child_delta( ) -> (f32, f32, f32) { let nu = nu.clamp(-1.0, 1.0); let nv = nv.clamp(-1.0, 1.0); - let theta_x = nu * arc_x_deg.to_radians() / 2.0; + let (theta_x, theta_y) = layout_surface_child_angles(kind, arc_x_deg, arc_y_deg, nu, nv); let flat_x = nu * half_w; match kind { SurfaceKind::Sphere => { - let theta_y = nv * arc_y_deg.to_radians() / 2.0; let flat_y = nv * half_h; let target_x = radius * theta_x.sin() * theta_y.cos(); let target_y = radius * theta_y.sin(); @@ -2135,6 +2168,22 @@ fn silhouette_alpha_field( Some((field, width as usize, height as usize)) } +const ORDERED_DITHER_4X4: [[f32; 4]; 4] = [ + [0.0, 8.0, 2.0, 10.0], + [12.0, 4.0, 14.0, 6.0], + [3.0, 11.0, 1.0, 9.0], + [15.0, 7.0, 13.0, 5.0], +]; + +fn quantize_with_ordered_dither(value: f32, x: usize, y: usize) -> u8 { + let clamped = value.clamp(0.0, 255.0); + let base = clamped.floor(); + let frac = clamped - base; + let threshold = (ORDERED_DITHER_4X4[y % 4][x % 4] + 0.5) / 16.0; + let rounded = if frac > threshold { base + 1.0 } else { base }; + rounded.clamp(0.0, 255.0) as u8 +} + fn paint_inflated_material( canvas: &Canvas, layout: &BoxLayout, @@ -2170,7 +2219,8 @@ fn paint_inflated_material( let index = (y * width + x) * 4; let coverage = field[y * width + x]; - let alpha = (amount.abs() * coverage * 255.0) as u8; + let raw_alpha = amount.abs() * coverage * 255.0; + let alpha = quantize_with_ordered_dither(raw_alpha, x, y); let tone = if amount >= 0.0 { (light.color.r(), light.color.g(), light.color.b()) } else { @@ -2781,7 +2831,14 @@ fn paint_box_shadow( layout.height + spread * 2.0, ); let rrect = rrect_from_corners(rect, radius); + let own_rrect = rrect_from_corners( + Rect::from_xywh(layout.x, layout.y, layout.width, layout.height), + radius, + ); + canvas.save(); + canvas.clip_rrect(own_rrect, ClipOp::Difference, true); canvas.draw_rrect(rrect, &paint); + canvas.restore(); } else { let (px, py, pw, ph) = layout.padding_box(); let outer = rrect_from_corners(Rect::from_xywh(px, py, pw, ph), radius); @@ -4614,6 +4671,49 @@ mod paint_order_tests { ); } + fn without_trailing_plateau(values: &[i32]) -> &[i32] { + let mut end = values.len(); + if end == 0 { + return values; + } + let last = values[end - 1]; + while end > 0 && values[end - 1] == last { + end -= 1; + } + &values[..end] + } + + fn flat_pair_count(values: &[i32]) -> usize { + values.windows(2).filter(|w| w[0] == w[1]).count() + } + + #[test] + fn a_wide_inflated_bevel_does_not_posterize_its_ramp_into_solid_flat_plateaus() { + let bevel = 48.0f32; + let mut node = material_tile(Some(Material::Tuned { + preset: MaterialPreset::Inflated, + intensity: 1.0, + bevel, + softness: 0.6, + })); + node.css.border_radius = Some(BorderRadius::Uniform(CLP::Px(100.0))); + let buf = render_lit(node, None); + let y = 200usize; + let x_max = 100 + (bevel * 2.0) as usize; + let lumas: Vec = (100..x_max).map(|x| luma_at(&buf, x, y) as i32).collect(); + let ramp = without_trailing_plateau(&lumas); + let flat_pairs = flat_pair_count(ramp); + assert!( + flat_pairs <= 28, + "a bevel of {bevel} across a 100px radius must not hold {flat_pairs} \ + adjacent same-luminance pixel pairs inside its shading ramp (out of {} \ + pairs sampled) — quantizing the shading alpha to 8 bits without dithering \ + posterizes a shallow gradient into wide flat plateaus, visible as \ + stair-stepped bands along the inner bevel", + ramp.len().saturating_sub(1) + ); + } + #[test] fn a_node_without_a_material_is_untouched() { let plain = render_lit(material_tile(None), None); @@ -5051,6 +5151,57 @@ mod paint_order_tests { ); } + #[test] + fn an_outset_box_shadow_does_not_paint_under_a_transparent_box() { + let transparent_card = BoxNode { + id: 0, + kind: BoxKind::Container, + css: CssStyle { + position: Some(Position::Absolute), + left: Some(CLP::Px(50.0)), + top: Some(CLP::Px(50.0)), + width: Some(CSize::Length(CLP::Px(100.0))), + height: Some(CSize::Length(CLP::Px(100.0))), + box_shadow: Some(vec![BoxShadow { + offset_x: Length::Px(0.0), + offset_y: Length::Px(0.0), + blur: None, + spread: Some(Length::Px(20.0)), + color: Some(CssColor::String("#ff0000".into())), + inset: None, + }]), + ..Default::default() + }, + children: vec![], + intrinsic: None, + source_path: None, + window: None, + }; + let mut root = root_node(200.0, 200.0, "#000000", vec![transparent_card]); + let buf = render_pixels(&mut root, 200, 200); + + let probe = |x: usize, y: usize| -> (u8, u8, u8) { + let i = (y * 200 + x) * 4; + (buf[i], buf[i + 1], buf[i + 2]) + }; + + let centre = probe(100, 100); + assert!( + centre.0 < 50 && centre.1 < 50 && centre.2 < 50, + "a box with no background of its own must leave its border-box area showing the \ + root's black background, not the shadow bleeding through in red — the shadow is \ + clipped to outside the border edge in CSS regardless of what (if anything) the \ + box itself paints — got {centre:?}" + ); + + let halo = probe(100, 45); + assert!( + halo.0 > 200 && halo.1 < 50, + "the shadow halo outside the box's own footprint must still be painted, got \ + {halo:?}" + ); + } + #[test] fn filter_layer_bounds_do_not_clip_blur_bleed() { let n = BoxNode { @@ -6040,6 +6191,35 @@ mod layout_surface_tests { } } + fn n_column_grid( + w: f32, + h: f32, + n: usize, + first_color: &str, + layout_surface: Option, + ) -> BoxNode { + let mut children = vec![colored_cell(first_color)]; + for _ in 1..n { + children.push(colored_cell("#333333")); + } + BoxNode { + id: 0, + kind: BoxKind::Container, + css: CssStyle { + display: Some(Display::Grid), + width: Some(CSize::Length(CLP::Px(w))), + height: Some(CSize::Length(CLP::Px(h))), + grid_template_columns: Some(vec![GridTrack::Fr(1.0); n]), + layout_surface, + ..Default::default() + }, + children, + intrinsic: None, + source_path: None, + window: None, + } + } + fn root_with(child: BoxNode, w: f32, h: f32) -> BoxNode { BoxNode { id: 0, @@ -6264,6 +6444,39 @@ mod layout_surface_tests { ); } + #[test] + fn a_cylinder_edge_column_foreshortens_towards_the_expected_width_with_no_perspective_at_all() { + let mut root = root_with( + n_column_grid( + 1800.0, + 300.0, + 9, + "#ff0000", + Some(LayoutSurface::Cylinder { + radius: 900.0, + arc_x: 120.0, + perspective: None, + rotate_x: None, + rotate_y: None, + }), + ), + 1800.0, + 300.0, + ); + let out = render_pixels(&mut root, 1800, 300, 0.0); + let (lo, hi) = + channel_extent(&out, 1800, 300, is_red).expect("the edge column must be visible"); + let width = hi - lo + 1; + assert!( + (100..=140).contains(&width), + "a 200px-wide edge column ~53 degrees off-axis on a layout-surface cylinder must \ + rotate to face the surface and foreshorten to roughly 120px even with no \ + `perspective` set at all — measured {width}px, which is what an unrotated cell \ + that is only translated and depth-shifted would still measure (no narrower than \ + its flat 200px)" + ); + } + #[test] fn rotate_y_animates_linearly_from_scene_start_to_the_scene_duration() { let angle = SurfaceAngle::Animated { diff --git a/crates/rustmotion/src/engine/render/background.rs b/crates/rustmotion/src/engine/render/background.rs index d35079b..4aa7b77 100644 --- a/crates/rustmotion/src/engine/render/background.rs +++ b/crates/rustmotion/src/engine/render/background.rs @@ -592,7 +592,6 @@ fn heropattern_raster_size(width: f32, height: f32, scale: f32) -> (u32, u32) { (pw, ph) } -#[allow(dead_code)] pub(super) fn interpolate_animated_bg( a: &AnimatedBackground, b: &AnimatedBackground, diff --git a/crates/rustmotion/src/engine/render/scene.rs b/crates/rustmotion/src/engine/render/scene.rs index 607a8f8..c4fb3fe 100644 --- a/crates/rustmotion/src/engine/render/scene.rs +++ b/crates/rustmotion/src/engine/render/scene.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use super::background::draw_animated_background; use super::background::draw_world_bg_with_parallax; +use super::background::interpolate_animated_bg; use crate::components::ChildComponent; use crate::error::RustmotionError; use crate::schema::{Camera, Scene, SceneLayout, VideoConfig, ViewType}; @@ -163,6 +164,49 @@ fn per_plane_camera( } } +#[allow(clippy::too_many_arguments)] +fn draw_unpaired_layers_faded( + canvas: &Canvas, + layers: &[crate::schema::AnimatedBackground], + continuous_time: f32, + w: f32, + h: f32, + alpha: f32, + scaled_w: i32, + scaled_h: i32, + scale_factor: f32, +) { + if layers.is_empty() || alpha <= 0.0 { + return; + } + let bg_info = ImageInfo::new( + (scaled_w, scaled_h), + ColorType::RGBA8888, + skia_safe::AlphaType::Premul, + None, + ); + let Some(mut layer_surface) = surfaces::raster(&bg_info, None, None) else { + return; + }; + let layer_canvas = layer_surface.canvas(); + if scale_factor != 1.0 { + layer_canvas.scale((scale_factor, scale_factor)); + } + layer_canvas.clear(skia_safe::Color4f::new(0.0, 0.0, 0.0, 0.0)); + for anim_bg in layers { + draw_animated_background(layer_canvas, anim_bg, continuous_time, w, h); + } + let snapshot = layer_surface.image_snapshot(); + let mut paint = Paint::default(); + paint.set_alpha_f(alpha); + canvas.save(); + if scale_factor != 1.0 { + canvas.reset_matrix(); + } + canvas.draw_image(&snapshot, (0.0, 0.0), Some(&paint)); + canvas.restore(); +} + pub fn render_frame_v2( config: &VideoConfig, scene: &Scene, @@ -233,60 +277,35 @@ pub fn render_frame_v2_scaled( let w = config.width as f32; let h = config.height as f32; - if progress < 1.0 { - let bg_info = ImageInfo::new( - (scaled_w, scaled_h), - ColorType::RGBA8888, - skia_safe::AlphaType::Premul, - None, - ); - if let Some(mut prev_surface) = surfaces::raster(&bg_info, None, None) { - let prev_canvas = prev_surface.canvas(); - if scale_factor != 1.0 { - prev_canvas.scale((scale_factor, scale_factor)); - } - prev_canvas.clear(skia_safe::Color4f::new(0.0, 0.0, 0.0, 0.0)); - for anim_bg in &prev.animated { - draw_animated_background(prev_canvas, anim_bg, continuous_time, w, h); - } - let snapshot = prev_surface.image_snapshot(); - let mut paint = Paint::default(); - paint.set_alpha_f(1.0 - progress); - canvas.save(); - if scale_factor != 1.0 { - canvas.reset_matrix(); - } - canvas.draw_image(&snapshot, (0.0, 0.0), Some(&paint)); - canvas.restore(); - } - } - if progress > 0.0 { - let bg_info = ImageInfo::new( - (scaled_w, scaled_h), - ColorType::RGBA8888, - skia_safe::AlphaType::Premul, - None, - ); - if let Some(mut cur_surface) = surfaces::raster(&bg_info, None, None) { - let cur_canvas = cur_surface.canvas(); - if scale_factor != 1.0 { - cur_canvas.scale((scale_factor, scale_factor)); - } - cur_canvas.clear(skia_safe::Color4f::new(0.0, 0.0, 0.0, 0.0)); - for anim_bg in &cur_bg.animated { - draw_animated_background(cur_canvas, anim_bg, continuous_time, w, h); - } - let snapshot = cur_surface.image_snapshot(); - let mut paint = Paint::default(); - paint.set_alpha_f(progress); - canvas.save(); - if scale_factor != 1.0 { - canvas.reset_matrix(); - } - canvas.draw_image(&snapshot, (0.0, 0.0), Some(&paint)); - canvas.restore(); - } + let paired = prev.animated.len().min(cur_bg.animated.len()); + for i in 0..paired { + let blended = + interpolate_animated_bg(&prev.animated[i], &cur_bg.animated[i], progress); + draw_animated_background(canvas, &blended, continuous_time, w, h); } + + draw_unpaired_layers_faded( + canvas, + &prev.animated[paired..], + continuous_time, + w, + h, + 1.0 - progress, + scaled_w, + scaled_h, + scale_factor, + ); + draw_unpaired_layers_faded( + canvas, + &cur_bg.animated[paired..], + continuous_time, + w, + h, + progress, + scaled_w, + scaled_h, + scale_factor, + ); } else { for anim_bg in &cur_bg.animated { draw_animated_background( @@ -1540,3 +1559,97 @@ mod prepared_children_tests { ); } } + +#[cfg(test)] +mod halo_transition_wiring_tests { + use crate::encode::video::{build_frame_tasks, render_frame_task, FrameTask}; + + const W: usize = 400; + const H: usize = 300; + + fn two_scene_halo_scenario( + from_x: f64, + to_x: f64, + transition_duration: f64, + ) -> crate::schema::ResolvedScenario { + let json = format!( + r##"{{ + "version": "1.0", + "video": {{ "width": {W}, "height": {H}, "fps": 30, "background": "#000000" }}, + "scenes": [ + {{ + "duration": 1.0, + "background": {{ + "preset": "halo", + "halo": {{ "zones": [ + {{ "color": "#FFFFFF", "x": {from_x}, "y": 0.5, "radius": 0.15, + "opacity": 1.0 }} + ] }} + }}, + "children": [] + }}, + {{ + "duration": 2.0, + "background": {{ + "preset": "halo", + "halo": {{ "zones": [ + {{ "color": "#FFFFFF", "x": {to_x}, "y": 0.5, "radius": 0.15, + "opacity": 1.0 }} + ] }}, + "transition": {{ "duration": {transition_duration}, "easing": "linear" }} + }}, + "children": [] + }} + ] + }}"## + ); + crate::loader::load_scenario_from_source(None, Some(&json)).expect("load") + } + + fn frame_at_scene_1_time( + scenario: &crate::schema::ResolvedScenario, + fps: u32, + t: f64, + ) -> Vec { + let target_frame_in_scene = (t * fps as f64).round() as u32; + let tasks = build_frame_tasks(scenario); + let task = tasks + .iter() + .find(|task| { + matches!( + task, + FrameTask::Normal { + scene_idx: 1, + frame_in_scene, + .. + } if *frame_in_scene == target_frame_in_scene + ) + }) + .expect("a frame task for scene 1 at the requested time must exist"); + render_frame_task(&scenario.video, scenario, task).expect("render") + } + + fn luma_at(buf: &[u8], w: usize, x: usize, y: usize) -> f32 { + let i = (y * w + x) * 4; + 0.299 * buf[i] as f32 + 0.587 * buf[i + 1] as f32 + 0.114 * buf[i + 2] as f32 + } + + #[test] + fn a_halo_transition_moves_the_zone_instead_of_cross_fading_two_static_copies() { + let scenario = two_scene_halo_scenario(0.1, 0.9, 1.0); + let mid = frame_at_scene_1_time(&scenario, 30, 0.5); + + let from_scene_position = (0.1 * W as f64) as usize; + let luma_at_from_position = luma_at(&mid, W, from_scene_position, H / 2); + + assert!( + luma_at_from_position < 40.0, + "halfway through the transition, the outgoing scene's own halo position \ + (x={from_scene_position}) must show background again — a single interpolated \ + zone has already moved on towards the frame's midpoint by t=0.5; a leftover \ + 50%-alpha copy still sitting at its original spot would mean the transition \ + cross-fades two static renders instead of interpolating one shape. measured \ + luma={luma_at_from_position}" + ); + } +}