From 6d53cb4d6f48f122757801a238a0d53c8eb55b2e Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Tue, 29 Sep 2026 14:23:16 +0200 Subject: [PATCH] fix(paint): dither the inflated ramp, face the surface, wire the halo lerp Five items from #434. The inflated material stair-steps at wide bevels because the shading alpha is truncated to u8 with a raw cast. PR #399 already fought this for small bevels by measuring a shallow slope over a longer baseline; past bevel 24 the ramp gets shallow enough that the per-pixel delta falls near 1 LSB and truncation holds identical alpha across several pixels. The fix is a 4x4 Bayer ordered dither on the quantization rather than reworking the shading formula, because the defect is in the display of the ramp and not in its maths -- three passing tests pin that material's calibration. dot_map discarded its AnimatedProperties entirely, so draw_progress keyframes on the node validated and did nothing while arc.draw_in stayed a static field. An arc's reveal is now draw_in * draw_progress rather than draw_progress overriding it: an override would make draw_in silently dead the instant anything animated the node, which is a worse trap than the bug. layout-surface translated each cell onto the curve and applied the whole-surface tilt, but never rotated a cell to face the tangent at its own position, so a projected grid stayed a set of flat cards. Each cell now rotates about its own centre by the same angles that placed it, which leaves the centre cell untouched as documented. The issue's own repro expected an edge column near 120px; it measured 144 and now measures 120. interpolate_animated_bg and lerp_zones had no production call site at all -- reachable only from their own tests -- while SKILL.md and halo-shapes.md both describe background properties interpolating across a transition. render_frame_v2_scaled cross-faded two fully rendered frame buffers instead, so two static copies ghosted past each other. It now pairs the layers and draws one interpolated result, falling back to the old cross-fade only for surplus layers when the two scenes declare different counts. An outset box-shadow drew its rrect with nothing excluding the box's own footprint. An opaque background hid it; a transparent one let the shadow's solid core through, so probing a box's centre returned the shadow colour. Clipping with Difference against the border box is what CSS specifies and what the inset branch already did for its own hole. Closes #434 --- crates/rustmotion-components/src/dot_map.rs | 126 +++++++++- .../rustmotion-core/src/engine/paint_pass.rs | 221 +++++++++++++++++- .../src/engine/render/background.rs | 1 - crates/rustmotion/src/engine/render/scene.rs | 219 ++++++++++++----- 4 files changed, 498 insertions(+), 69 deletions(-) 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}" + ); + } +}