From f7f268155dfec83bd04b2a79cf20f3db821babe0 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 12 Aug 2026 19:36:32 +0200 Subject: [PATCH] fix(paint): give the audio-reactive painters the scenario's clock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A waveform in a scene starting at t=73 s drew the analysis at 73 s *into that scene*. With a uniform track nobody notices; with any track whose level varies over the timeline the visualisation is simply wrong, and it goes flat when the scene's own window falls in a silent region. Measured on the same scene, same track, same build — only its position in the scenario differs: 42 px of trace as the first scene, 0 px starting at t~73 s. PaintCtx carried no scenario-global time: `time` restarts at every scene and frame_index is documented as "frames elapsed since this scene started". So waveform, audio_spectrum and the audio-reactive binding had nothing else to read, while AudioAnalysis is indexed on the scenario's own timeline. Carry the position on the FrameTask — it is the index in the schedule, known at build time — and thread it through PaintFrame/PaintCtx and BuildAnimationCtx as `scenario_time`. Everything else stays on `time`, which is what a delay, a stagger or a preset is written against. A container's time_scale/time_offset remaps the animation clock of its subtree; the audio is not part of that subtree and passes through unremapped. The incremental encoder builds slots in isolation, so it renumbers each slot from its own start rather than taking the local Vec's length — the existing slot_tasks_reproduce_the_full_builder_exactly caught that immediately. --- .../rustmotion-cli/src/commands/geometry.rs | 1 + .../src/audio_spectrum.rs | 2 +- crates/rustmotion-components/src/badge.rs | 1 + .../rustmotion-components/src/box_builder.rs | 20 ++- crates/rustmotion-components/src/callout.rs | 1 + crates/rustmotion-components/src/caption.rs | 1 + crates/rustmotion-components/src/counter.rs | 1 + .../src/gradient_text.rs | 1 + crates/rustmotion-components/src/icon.rs | 1 + crates/rustmotion-components/src/kbd.rs | 1 + .../src/legacy_dispatch.rs | 7 + crates/rustmotion-components/src/list.rs | 1 + crates/rustmotion-components/src/marquee.rs | 1 + .../rustmotion-components/src/notification.rs | 1 + crates/rustmotion-components/src/pill_nav.rs | 1 + crates/rustmotion-components/src/progress.rs | 1 + crates/rustmotion-components/src/table.rs | 1 + crates/rustmotion-components/src/terminal.rs | 1 + crates/rustmotion-components/src/text.rs | 1 + crates/rustmotion-components/src/tooltip.rs | 1 + crates/rustmotion-components/src/video.rs | 1 + crates/rustmotion-components/src/waveform.rs | 6 +- .../tests/caption_presets.rs | 2 + .../tests/codeblock_auto_scroll.rs | 2 + .../tests/degenerate_inputs.rs | 2 + .../tests/relative_font_size.rs | 2 + .../tests/text_autofit.rs | 2 + .../tests/transition_interpolation.rs | 1 + .../rustmotion-core/src/engine/paint_pass.rs | 13 ++ crates/rustmotion-core/src/traits/painter.rs | 6 + crates/rustmotion/src/encode/video/h264.rs | 10 +- crates/rustmotion/src/encode/video/tasks.rs | 122 +++++++++++++++++- crates/rustmotion/src/engine/render/scene.rs | 33 ++++- crates/rustmotion/src/tests.rs | 86 +++++++++++- crates/rustmotion/tests/node_effects_cost.rs | 3 +- .../tests/node_effects_leak_proof.rs | 2 +- 36 files changed, 320 insertions(+), 19 deletions(-) diff --git a/crates/rustmotion-cli/src/commands/geometry.rs b/crates/rustmotion-cli/src/commands/geometry.rs index ac78b6e2..7a7ad0fa 100644 --- a/crates/rustmotion-cli/src/commands/geometry.rs +++ b/crates/rustmotion-cli/src/commands/geometry.rs @@ -1329,6 +1329,7 @@ pub fn validate_geometry_animated(scenario: &ResolvedScenario) -> Vec PaintCtx { PaintCtx { time: 0.0, + scenario_time: 0.0, scene_duration: 1.0, frame_index: 0, fps: 30, diff --git a/crates/rustmotion-components/src/box_builder.rs b/crates/rustmotion-components/src/box_builder.rs index d0ffe02c..e766e126 100644 --- a/crates/rustmotion-components/src/box_builder.rs +++ b/crates/rustmotion-components/src/box_builder.rs @@ -37,6 +37,13 @@ use crate::{ChildComponent, Component}; #[derive(Debug, Clone, Copy)] pub struct BuildAnimationCtx { pub time: f64, + /// Seconds since the scenario started, as opposed to `time`, which + /// restarts at every scene. Only the `audio-reactive` binding reads it: + /// the audio analysis is indexed on the scenario's timeline, so using + /// `time` gave a scene starting at t=73 s the analysis at 73 s *into that + /// scene*. Every animation stays on `time`, which is what a delay, a + /// stagger or a preset is written against. + pub scenario_time: f64, pub scene_duration: f64, /// Frames per second of the output video. Required to convert the /// `shutter` fraction (in `MotionBlurConfig`) into an absolute temporal @@ -305,6 +312,7 @@ fn build_ghosts<'a>( // Resolve animation props at the *ghost* time. let ghost_actx = BuildAnimationCtx { time: ghost_time, + scenario_time: actx.scenario_time, scene_duration: actx.scene_duration, fps: actx.fps, }; @@ -428,6 +436,11 @@ fn build_child<'a>( let (scale, shift) = time_remap; BuildAnimationCtx { time: a.time * scale + shift, + // A container's `time_scale`/`time_offset` remaps the *animation* + // clock of its subtree. The audio is not part of that subtree — + // it plays at wall-clock speed regardless — so the scenario clock + // passes through unremapped. + scenario_time: a.scenario_time, scene_duration: a.scene_duration, fps: a.fps, } @@ -545,10 +558,10 @@ fn build_child<'a>( let raw = if let Some(analysis) = analysis_opt { match &ar.source { AudioSource::Amplitude(AudioSourceTag::Amplitude) => { - analysis.amplitude_smoothed(actx.time, ar.smoothing_frames) + analysis.amplitude_smoothed(actx.scenario_time, ar.smoothing_frames) } AudioSource::Band { band } => { - analysis.band_smoothed(actx.time, *band, ar.smoothing_frames) + analysis.band_smoothed(actx.scenario_time, *band, ar.smoothing_frames) } } } else { @@ -2657,6 +2670,7 @@ mod tests { } let anim = BuildAnimationCtx { time: 0.0, + scenario_time: 0.0, scene_duration: 1.0, fps: 30, }; @@ -2709,6 +2723,7 @@ mod tests { } let anim = BuildAnimationCtx { time: 0.0, + scenario_time: 0.0, scene_duration: 1.0, fps: 30, }; @@ -2737,6 +2752,7 @@ mod tests { let shape = make_shape(100.0, 100.0); let anim = BuildAnimationCtx { time: 0.0, + scenario_time: 0.0, scene_duration: 1.0, fps: 30, }; diff --git a/crates/rustmotion-components/src/callout.rs b/crates/rustmotion-components/src/callout.rs index bcf9d07e..74cefae2 100644 --- a/crates/rustmotion-components/src/callout.rs +++ b/crates/rustmotion-components/src/callout.rs @@ -210,6 +210,7 @@ mod tests { fn test_ctx() -> PaintCtx { PaintCtx { time: 0.0, + scenario_time: 0.0, scene_duration: 1.0, frame_index: 0, fps: 30, diff --git a/crates/rustmotion-components/src/caption.rs b/crates/rustmotion-components/src/caption.rs index 4e6cb108..b234bc99 100644 --- a/crates/rustmotion-components/src/caption.rs +++ b/crates/rustmotion-components/src/caption.rs @@ -416,6 +416,7 @@ mod tests { fn test_ctx(time: f64) -> PaintCtx { PaintCtx { time, + scenario_time: time, scene_duration: 2.0, frame_index: (time * 30.0) as u32, fps: 30, diff --git a/crates/rustmotion-components/src/counter.rs b/crates/rustmotion-components/src/counter.rs index dd8935bb..ebe1108e 100644 --- a/crates/rustmotion-components/src/counter.rs +++ b/crates/rustmotion-components/src/counter.rs @@ -373,6 +373,7 @@ mod tests { let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); let ctx = PaintCtx { time: 1.0, + scenario_time: 1.0, scene_duration: 2.0, frame_index: 30, fps: 30, diff --git a/crates/rustmotion-components/src/gradient_text.rs b/crates/rustmotion-components/src/gradient_text.rs index 47aa2d4a..ac11cc5c 100644 --- a/crates/rustmotion-components/src/gradient_text.rs +++ b/crates/rustmotion-components/src/gradient_text.rs @@ -324,6 +324,7 @@ mod tests { fn test_ctx() -> PaintCtx { PaintCtx { time: 0.0, + scenario_time: 0.0, scene_duration: 1.0, frame_index: 0, fps: 30, diff --git a/crates/rustmotion-components/src/icon.rs b/crates/rustmotion-components/src/icon.rs index 473d97f4..812f53c1 100644 --- a/crates/rustmotion-components/src/icon.rs +++ b/crates/rustmotion-components/src/icon.rs @@ -122,6 +122,7 @@ mod tests { fn base_ctx() -> PaintCtx { PaintCtx { time: 0.0, + scenario_time: 0.0, scene_duration: 1.0, frame_index: 0, fps: 30, diff --git a/crates/rustmotion-components/src/kbd.rs b/crates/rustmotion-components/src/kbd.rs index c383e45d..ecc065b8 100644 --- a/crates/rustmotion-components/src/kbd.rs +++ b/crates/rustmotion-components/src/kbd.rs @@ -200,6 +200,7 @@ mod tests { fn test_ctx() -> PaintCtx { PaintCtx { time: 0.0, + scenario_time: 0.0, scene_duration: 1.0, frame_index: 0, fps: 30, diff --git a/crates/rustmotion-components/src/legacy_dispatch.rs b/crates/rustmotion-components/src/legacy_dispatch.rs index 773bbb87..da4fdc58 100644 --- a/crates/rustmotion-components/src/legacy_dispatch.rs +++ b/crates/rustmotion-components/src/legacy_dispatch.rs @@ -157,6 +157,7 @@ impl<'a> PaintDispatcher for LegacyPaintDispatcher<'a> { let paint_ctx = PaintCtx { time: local_time, + scenario_time: frame.scenario_time, scene_duration: frame.scene_duration, frame_index: frame.frame_index, fps: frame.fps, @@ -246,6 +247,7 @@ mod tests { let dispatcher = LegacyPaintDispatcher::new(&built.components); let frame = PaintFrame { time: 0.0, + scenario_time: 0.0, frame_index: 0, fps: 30, video_width: 200, @@ -314,6 +316,7 @@ mod tests { let dispatcher = LegacyPaintDispatcher::new(&built.components); let frame = PaintFrame { time: 0.0, + scenario_time: 0.0, frame_index: 0, fps: 30, video_width: 200, @@ -415,6 +418,7 @@ mod tests { let dispatcher = LegacyPaintDispatcher::new(&built.components); let frame = PaintFrame { time: 0.0, + scenario_time: 0.0, frame_index: 0, fps: 30, video_width: 200, @@ -511,6 +515,7 @@ mod tests { (200.0, 200.0), crate::box_builder::BuildAnimationCtx { time, + scenario_time: time, scene_duration: 1.0, fps: 30, }, @@ -523,6 +528,7 @@ mod tests { let dispatcher = LegacyPaintDispatcher::new(&built.components); let frame = PaintFrame { time, + scenario_time: time, frame_index: 0, fps: 30, video_width: 200, @@ -591,6 +597,7 @@ mod tests { }; let frame = PaintFrame { time: 0.0, + scenario_time: 0.0, frame_index: 0, fps: 30, video_width: 10, diff --git a/crates/rustmotion-components/src/list.rs b/crates/rustmotion-components/src/list.rs index 80e677ae..d58723cd 100644 --- a/crates/rustmotion-components/src/list.rs +++ b/crates/rustmotion-components/src/list.rs @@ -273,6 +273,7 @@ mod tests { fn test_ctx() -> PaintCtx { PaintCtx { time: 0.0, + scenario_time: 0.0, scene_duration: 1.0, frame_index: 0, fps: 30, diff --git a/crates/rustmotion-components/src/marquee.rs b/crates/rustmotion-components/src/marquee.rs index a512f284..08e0379c 100644 --- a/crates/rustmotion-components/src/marquee.rs +++ b/crates/rustmotion-components/src/marquee.rs @@ -168,6 +168,7 @@ mod tests { fn test_ctx() -> PaintCtx { PaintCtx { time: 0.0, + scenario_time: 0.0, scene_duration: 1.0, frame_index: 0, fps: 30, diff --git a/crates/rustmotion-components/src/notification.rs b/crates/rustmotion-components/src/notification.rs index 1f26d9ff..436f2d35 100644 --- a/crates/rustmotion-components/src/notification.rs +++ b/crates/rustmotion-components/src/notification.rs @@ -386,6 +386,7 @@ mod tests { fn test_ctx() -> PaintCtx { PaintCtx { time: 1.0, + scenario_time: 1.0, scene_duration: 2.0, frame_index: 30, fps: 30, diff --git a/crates/rustmotion-components/src/pill_nav.rs b/crates/rustmotion-components/src/pill_nav.rs index 1d4ef9d7..3b3d038d 100644 --- a/crates/rustmotion-components/src/pill_nav.rs +++ b/crates/rustmotion-components/src/pill_nav.rs @@ -269,6 +269,7 @@ mod tests { fn test_ctx() -> PaintCtx { PaintCtx { time: 0.0, + scenario_time: 0.0, scene_duration: 1.0, frame_index: 0, fps: 30, diff --git a/crates/rustmotion-components/src/progress.rs b/crates/rustmotion-components/src/progress.rs index 0c0b3580..6cef46b9 100644 --- a/crates/rustmotion-components/src/progress.rs +++ b/crates/rustmotion-components/src/progress.rs @@ -222,6 +222,7 @@ mod tests { fn base_ctx() -> PaintCtx { PaintCtx { time: 0.0, + scenario_time: 0.0, scene_duration: 2.0, frame_index: 0, fps: 30, diff --git a/crates/rustmotion-components/src/table.rs b/crates/rustmotion-components/src/table.rs index 666dae65..05fbc996 100644 --- a/crates/rustmotion-components/src/table.rs +++ b/crates/rustmotion-components/src/table.rs @@ -339,6 +339,7 @@ mod tests { let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); let ctx = PaintCtx { time: 0.0, + scenario_time: 0.0, scene_duration: 1.0, frame_index: 0, fps: 30, diff --git a/crates/rustmotion-components/src/terminal.rs b/crates/rustmotion-components/src/terminal.rs index 8d08d7ea..0e22037b 100644 --- a/crates/rustmotion-components/src/terminal.rs +++ b/crates/rustmotion-components/src/terminal.rs @@ -504,6 +504,7 @@ mod tests { let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); let ctx = PaintCtx { time: 0.0, + scenario_time: 0.0, scene_duration: 1.0, frame_index: 0, fps: 30, diff --git a/crates/rustmotion-components/src/text.rs b/crates/rustmotion-components/src/text.rs index e1b47631..215d4b0a 100644 --- a/crates/rustmotion-components/src/text.rs +++ b/crates/rustmotion-components/src/text.rs @@ -761,6 +761,7 @@ mod tests { fn test_ctx() -> PaintCtx { PaintCtx { time: 0.0, + scenario_time: 0.0, scene_duration: 1.0, frame_index: 0, fps: 30, diff --git a/crates/rustmotion-components/src/tooltip.rs b/crates/rustmotion-components/src/tooltip.rs index d60c325e..61299b53 100644 --- a/crates/rustmotion-components/src/tooltip.rs +++ b/crates/rustmotion-components/src/tooltip.rs @@ -222,6 +222,7 @@ mod tests { fn test_ctx() -> PaintCtx { PaintCtx { time: 0.0, + scenario_time: 0.0, scene_duration: 1.0, frame_index: 0, fps: 30, diff --git a/crates/rustmotion-components/src/video.rs b/crates/rustmotion-components/src/video.rs index 5e1f4a8d..0d64739f 100644 --- a/crates/rustmotion-components/src/video.rs +++ b/crates/rustmotion-components/src/video.rs @@ -122,6 +122,7 @@ mod tests { fn base_ctx() -> PaintCtx { PaintCtx { time: 0.0, + scenario_time: 0.0, scene_duration: 1.0, frame_index: 0, fps: 30, diff --git a/crates/rustmotion-components/src/waveform.rs b/crates/rustmotion-components/src/waveform.rs index e216b722..8f7e7df8 100644 --- a/crates/rustmotion-components/src/waveform.rs +++ b/crates/rustmotion-components/src/waveform.rs @@ -36,7 +36,7 @@ pub struct Waveform { /// Visual style: line or filled. #[serde(default)] pub draw_style: DrawStyle, - /// Time window in seconds, centered on ctx.time. + /// Time window in seconds, centered on ctx.scenario_time. #[serde(default = "default_window")] pub window: f32, #[serde(flatten)] @@ -91,8 +91,8 @@ impl Painter for Waveform { }; let half_window = self.window as f64 / 2.0; - let t_start = (ctx.time - half_window).max(0.0); - let t_end = ctx.time + half_window; + let t_start = (ctx.scenario_time - half_window).max(0.0); + let t_end = ctx.scenario_time + half_window; // Sample N points along the window let n_points = w as usize; diff --git a/crates/rustmotion-components/tests/caption_presets.rs b/crates/rustmotion-components/tests/caption_presets.rs index 44183f4b..2f074ae8 100644 --- a/crates/rustmotion-components/tests/caption_presets.rs +++ b/crates/rustmotion-components/tests/caption_presets.rs @@ -46,6 +46,7 @@ fn render_caption_at(json: serde_json::Value, time: f64, y: f32) -> Vec { (W as f32, H as f32), BuildAnimationCtx { time, + scenario_time: time, scene_duration: 2.0, fps: 30, }, @@ -58,6 +59,7 @@ fn render_caption_at(json: serde_json::Value, time: f64, y: f32) -> Vec { let dispatcher = LegacyPaintDispatcher::for_scene(&built); let frame = PaintFrame { time, + scenario_time: time, frame_index: (time * 30.0) as u32, fps: 30, video_width: W, diff --git a/crates/rustmotion-components/tests/codeblock_auto_scroll.rs b/crates/rustmotion-components/tests/codeblock_auto_scroll.rs index 80bcabec..40a4f2eb 100644 --- a/crates/rustmotion-components/tests/codeblock_auto_scroll.rs +++ b/crates/rustmotion-components/tests/codeblock_auto_scroll.rs @@ -72,6 +72,7 @@ fn render_codeblock_at(time: f64) -> Vec { (W as f32, H as f32), BuildAnimationCtx { time, + scenario_time: time, scene_duration: SCENE_DURATION, fps: 30, }, @@ -84,6 +85,7 @@ fn render_codeblock_at(time: f64) -> Vec { let dispatcher = LegacyPaintDispatcher::for_scene(&built); let frame = PaintFrame { time, + scenario_time: time, frame_index: (time * 30.0) as u32, fps: 30, video_width: W, diff --git a/crates/rustmotion-components/tests/degenerate_inputs.rs b/crates/rustmotion-components/tests/degenerate_inputs.rs index 8b609f3d..fb950e56 100644 --- a/crates/rustmotion-components/tests/degenerate_inputs.rs +++ b/crates/rustmotion-components/tests/degenerate_inputs.rs @@ -45,6 +45,7 @@ fn paint(json: serde_json::Value, time: f64) { (W as f32, H as f32), BuildAnimationCtx { time, + scenario_time: time, scene_duration: 2.0, fps: 30, }, @@ -57,6 +58,7 @@ fn paint(json: serde_json::Value, time: f64) { let dispatcher = LegacyPaintDispatcher::for_scene(&built); let frame = PaintFrame { time, + scenario_time: time, frame_index: (time * 30.0) as u32, fps: 30, video_width: W, diff --git a/crates/rustmotion-components/tests/relative_font_size.rs b/crates/rustmotion-components/tests/relative_font_size.rs index 06127b28..21965697 100644 --- a/crates/rustmotion-components/tests/relative_font_size.rs +++ b/crates/rustmotion-components/tests/relative_font_size.rs @@ -41,6 +41,7 @@ fn render(json: serde_json::Value) -> Vec { (W as f32, H as f32), BuildAnimationCtx { time: 0.5, + scenario_time: 0.5, scene_duration: 2.0, fps: 30, }, @@ -53,6 +54,7 @@ fn render(json: serde_json::Value) -> Vec { let dispatcher = LegacyPaintDispatcher::for_scene(&built); let frame = PaintFrame { time: 0.5, + scenario_time: 0.5, frame_index: 15, fps: 30, video_width: W, diff --git a/crates/rustmotion-components/tests/text_autofit.rs b/crates/rustmotion-components/tests/text_autofit.rs index 3cd457ea..358ee843 100644 --- a/crates/rustmotion-components/tests/text_autofit.rs +++ b/crates/rustmotion-components/tests/text_autofit.rs @@ -64,6 +64,7 @@ fn render_at(content: &str, autofit: bool, time: f64) -> Vec { (W as f32, H as f32), BuildAnimationCtx { time, + scenario_time: time, scene_duration: SCENE_DURATION, fps: 30, }, @@ -76,6 +77,7 @@ fn render_at(content: &str, autofit: bool, time: f64) -> Vec { let dispatcher = LegacyPaintDispatcher::for_scene(&built); let frame = PaintFrame { time, + scenario_time: time, frame_index: (time * 30.0) as u32, fps: 30, video_width: W, diff --git a/crates/rustmotion-components/tests/transition_interpolation.rs b/crates/rustmotion-components/tests/transition_interpolation.rs index 2d9d0846..4e02d9d2 100644 --- a/crates/rustmotion-components/tests/transition_interpolation.rs +++ b/crates/rustmotion-components/tests/transition_interpolation.rs @@ -59,6 +59,7 @@ fn css_at(json: serde_json::Value, time: f64) -> CssStyle { CssStyle::default(), BuildAnimationCtx { time, + scenario_time: time, scene_duration: SCENE_DURATION, fps: 30, }, diff --git a/crates/rustmotion-core/src/engine/paint_pass.rs b/crates/rustmotion-core/src/engine/paint_pass.rs index f9d454e5..2b363645 100644 --- a/crates/rustmotion-core/src/engine/paint_pass.rs +++ b/crates/rustmotion-core/src/engine/paint_pass.rs @@ -34,6 +34,15 @@ use crate::engine::layout_pass::{BoxLayout, LayoutResult}; #[derive(Debug, Clone, Copy)] pub struct PaintFrame { pub time: f64, + /// Seconds since the start of the *scenario* (of the view, for a `world` + /// view), as opposed to `time`, which restarts at every scene. + /// + /// Only the audio-reactive painters want this: an audio analysis is + /// indexed on the scenario's own timeline, so reading it with `time` gave + /// a scene starting at t=73 s the analysis at 73 s *into that scene*. + /// Everything else — animation progress, reveals, transitions — is + /// correctly scene-local and must stay on `time`. + pub scenario_time: f64, pub frame_index: u32, pub fps: u32, pub video_width: u32, @@ -1477,6 +1486,7 @@ mod hit_tests { fn test_frame(w: u32, h: u32) -> PaintFrame { PaintFrame { time: 0.0, + scenario_time: 0.0, frame_index: 0, fps: 30, video_width: w, @@ -1828,6 +1838,7 @@ mod transform_origin_tests { fn test_frame(w: u32, h: u32) -> PaintFrame { PaintFrame { time: 0.0, + scenario_time: 0.0, frame_index: 0, fps: 30, video_width: w, @@ -2268,6 +2279,7 @@ mod glassmorphism_tests { fn test_frame(w: u32, h: u32) -> PaintFrame { PaintFrame { time: 0.0, + scenario_time: 0.0, frame_index: 0, fps: 30, video_width: w, @@ -2694,6 +2706,7 @@ mod paint_order_tests { fn test_frame(w: u32, h: u32) -> PaintFrame { PaintFrame { time: 0.0, + scenario_time: 0.0, frame_index: 0, fps: 30, video_width: w, diff --git a/crates/rustmotion-core/src/traits/painter.rs b/crates/rustmotion-core/src/traits/painter.rs index f1d5f8d8..a9fc2ab0 100644 --- a/crates/rustmotion-core/src/traits/painter.rs +++ b/crates/rustmotion-core/src/traits/painter.rs @@ -14,7 +14,13 @@ use crate::engine::layout_pass::BoxLayout; /// Frame-level info passed into every paint call. #[derive(Debug, Clone, Copy)] pub struct PaintCtx { + /// Seconds since this *scene* started. What animation progress, reveals + /// and every timed effect are expressed in. pub time: f64, + /// Seconds since the start of the scenario (of the view, in a `world` + /// view). Only the audio-reactive painters need it — the audio analysis is + /// indexed on that timeline, not on each scene's own. + pub scenario_time: f64, pub scene_duration: f64, pub frame_index: u32, pub fps: u32, diff --git a/crates/rustmotion/src/encode/video/h264.rs b/crates/rustmotion/src/encode/video/h264.rs index 12a70569..269dc0c3 100644 --- a/crates/rustmotion/src/encode/video/h264.rs +++ b/crates/rustmotion/src/encode/video/h264.rs @@ -194,9 +194,17 @@ pub fn encode_video_incremental( } // Build per-slot tasks + // Slots are built independently but their tasks form one stream, so each + // starts where the previous ended — a task's `global_frame` must mean the + // same thing here as in a full encode. + let mut next_frame = 0u32; let scene_tasks: Vec> = slots .iter() - .map(|s| super::tasks::build_slot_frame_tasks(scenario, s)) + .map(|s| { + let tasks = super::tasks::build_slot_frame_tasks(scenario, s, next_frame); + next_frame += tasks.len() as u32; + tasks + }) .collect(); let num_scenes = num_slots; diff --git a/crates/rustmotion/src/encode/video/tasks.rs b/crates/rustmotion/src/encode/video/tasks.rs index a55efcfc..ac25f567 100644 --- a/crates/rustmotion/src/encode/video/tasks.rs +++ b/crates/rustmotion/src/encode/video/tasks.rs @@ -10,12 +10,28 @@ use crate::schema::{ #[allow(dead_code)] pub enum FrameTask { Normal { + /// This frame's index in the *whole* schedule. + /// + /// The audio track is muxed on the scenario's timeline, so anything + /// reading the audio analysis needs this rather than the scene-local + /// counter beside it. Carried on the task rather than re-derived from + /// the scenario, which would be a second copy of the scheduler's + /// arithmetic that can drift from it. + global_frame: u32, view_idx: usize, scene_idx: usize, frame_in_scene: u32, scene_total_frames: u32, }, SlideTransition { + /// This frame's index in the *whole* schedule. + /// + /// The audio track is muxed on the scenario's timeline, so anything + /// reading the audio analysis needs this rather than the scene-local + /// counter beside it. Carried on the task rather than re-derived from + /// the scenario, which would be a second copy of the scheduler's + /// arithmetic that can drift from it. + global_frame: u32, view_idx: usize, scene_a_idx: usize, scene_b_idx: usize, @@ -28,11 +44,27 @@ pub enum FrameTask { easing: EasingType, }, WorldFrame { + /// This frame's index in the *whole* schedule. + /// + /// The audio track is muxed on the scenario's timeline, so anything + /// reading the audio analysis needs this rather than the scene-local + /// counter beside it. Carried on the task rather than re-derived from + /// the scenario, which would be a second copy of the scheduler's + /// arithmetic that can drift from it. + global_frame: u32, view_idx: usize, frame_in_view: u32, view_total_frames: u32, }, ViewTransition { + /// This frame's index in the *whole* schedule. + /// + /// The audio track is muxed on the scenario's timeline, so anything + /// reading the audio analysis needs this rather than the scene-local + /// counter beside it. Carried on the task rather than re-derived from + /// the scenario, which would be a second copy of the scheduler's + /// arithmetic that can drift from it. + global_frame: u32, view_a_idx: usize, view_b_idx: usize, frame_in_transition: u32, @@ -84,11 +116,13 @@ pub fn render_frame_task_scaled( match task { FrameTask::Normal { + global_frame, view_idx, scene_idx, frame_in_scene, scene_total_frames, } => { + let scenario_time = *global_frame as f64 / config.fps as f64; let view = &scenario.views[*view_idx]; let scene = &view.scenes[*scene_idx]; let prev_bg = if *scene_idx > 0 { @@ -101,6 +135,7 @@ pub fn render_frame_task_scaled( config, scene, *frame_in_scene, + scenario_time, *scene_total_frames, scale_factor, prev_bg, @@ -117,6 +152,7 @@ pub fn render_frame_task_scaled( Ok(pixels) } FrameTask::SlideTransition { + global_frame, view_idx, scene_a_idx, scene_b_idx, @@ -128,6 +164,7 @@ pub fn render_frame_task_scaled( transition_duration, easing, } => { + let scenario_time = *global_frame as f64 / config.fps as f64; let scenes = &scenario.views[*view_idx].scenes; let scaled_w = (config.width as f32 * scale_factor) as u32; let scaled_h = (config.height as f32 * scale_factor) as u32; @@ -174,6 +211,7 @@ pub fn render_frame_task_scaled( config, &scenes[*scene_a_idx], frame_a_idx, + scenario_time, *scene_a_total_frames, scale_factor, )?; @@ -181,6 +219,7 @@ pub fn render_frame_task_scaled( config, &scenes[*scene_b_idx], *frame_in_transition, + scenario_time, *scene_b_total_frames, scale_factor, )?; @@ -213,12 +252,14 @@ pub fn render_frame_task_scaled( config, &scenes[*scene_a_idx], frame_a_idx, + scenario_time, *scene_a_total_frames, )?; let b = render_scene_frame( config, &scenes[*scene_b_idx], *frame_in_transition, + scenario_time, *scene_b_total_frames, )?; (a, b) @@ -227,6 +268,7 @@ pub fn render_frame_task_scaled( config, &scenes[*scene_a_idx], frame_a_idx, + scenario_time, *scene_a_total_frames, scale_factor, )?; @@ -234,6 +276,7 @@ pub fn render_frame_task_scaled( config, &scenes[*scene_b_idx], *frame_in_transition, + scenario_time, *scene_b_total_frames, scale_factor, )?; @@ -262,10 +305,12 @@ pub fn render_frame_task_scaled( Ok(composited) } FrameTask::WorldFrame { + global_frame, view_idx, frame_in_view, view_total_frames: _, } => { + let scenario_time = *global_frame as f64 / config.fps as f64; use crate::engine::world::WorldTimeline; let view = &scenario.views[*view_idx]; let timeline = WorldTimeline::build(view, config.fps, config.width, config.height); @@ -274,6 +319,7 @@ pub fn render_frame_task_scaled( view, &timeline, *frame_in_view, + scenario_time, scale_factor, )?; // `apply_post_effects` runs for every other frame kind (Normal, @@ -297,6 +343,7 @@ pub fn render_frame_task_scaled( Ok(pixels) } FrameTask::ViewTransition { + global_frame, view_a_idx, view_b_idx, frame_in_transition, @@ -304,6 +351,7 @@ pub fn render_frame_task_scaled( transition_duration, easing: _, } => { + let scenario_time = *global_frame as f64 / config.fps as f64; 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; @@ -317,8 +365,10 @@ pub fn render_frame_task_scaled( let view_a = &scenario.views[*view_a_idx]; let view_b = &scenario.views[*view_b_idx]; - 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)?; + let frame_a = + render_last_frame_of_view(config, view_a, fps, scenario_time, scale_factor)?; + let frame_b = + render_first_frame_of_view(config, view_b, fps, scenario_time, scale_factor)?; let mut composited = apply_transition( &frame_a, @@ -395,6 +445,9 @@ fn render_last_frame_of_view( config: &VideoConfig, view: &ResolvedView, fps: u32, + // Where that last frame sits on the scenario's own timeline — the view + // transition that needs it is itself somewhere in the middle of a render. + scenario_time: f64, scale_factor: f32, ) -> Result> { use crate::engine::render::render_scene_frame_scaled; @@ -406,6 +459,7 @@ fn render_last_frame_of_view( config, last_scene, scene_frames.saturating_sub(1), + scenario_time, scene_frames, scale_factor, ) @@ -427,6 +481,7 @@ fn render_last_frame_of_view( view, &timeline, total_frames.saturating_sub(1), + scenario_time, scale_factor, ) } @@ -437,6 +492,8 @@ fn render_first_frame_of_view( config: &VideoConfig, view: &ResolvedView, fps: u32, + // See `render_last_frame_of_view`. + scenario_time: f64, scale_factor: f32, ) -> Result> { use crate::engine::render::render_scene_frame_scaled; @@ -444,7 +501,14 @@ fn render_first_frame_of_view( ViewType::Slide => { if let Some(first_scene) = view.scenes.first() { let scene_frames = (first_scene.duration * fps as f64).round() as u32; - render_scene_frame_scaled(config, first_scene, 0, scene_frames, scale_factor) + render_scene_frame_scaled( + config, + first_scene, + 0, + scenario_time, + scene_frames, + scale_factor, + ) } else { Ok(vec![ 0u8; @@ -462,6 +526,7 @@ fn render_first_frame_of_view( view, &timeline, 0, + scenario_time, scale_factor, ) } @@ -478,6 +543,7 @@ pub fn build_frame_tasks(scenario: &Scenario) -> Vec { let transition_frames = (transition.duration * fps as f64).round() as u32; for f in 0..transition_frames { tasks.push(FrameTask::ViewTransition { + global_frame: tasks.len() as u32, view_a_idx: view_idx - 1, view_b_idx: view_idx, frame_in_transition: f, @@ -588,6 +654,7 @@ fn build_slide_view_tasks( for f in normal_start..normal_end { tasks.push(FrameTask::Normal { + global_frame: tasks.len() as u32, view_idx, scene_idx: i, frame_in_scene: f, @@ -600,6 +667,7 @@ fn build_slide_view_tasks( let easing = transition.easing.clone(); for f in 0..outgoing_transition_frames { tasks.push(FrameTask::SlideTransition { + global_frame: tasks.len() as u32, view_idx, scene_a_idx: i, scene_b_idx: i + 1, @@ -628,6 +696,7 @@ fn build_world_view_tasks( let total_frames = timeline.total_frames(fps); for f in 0..total_frames { tasks.push(FrameTask::WorldFrame { + global_frame: tasks.len() as u32, view_idx, frame_in_view: f, view_total_frames: total_frames, @@ -751,8 +820,36 @@ pub fn plan_dirty( } /// Frame tasks for one slot, mirroring the full builder's output. -pub(super) fn build_slot_frame_tasks(scenario: &Scenario, slot: &SegmentSlot) -> Vec { - match slot { +/// `start_frame` is where this slot begins in the full schedule. +/// +/// The incremental encoder builds slots independently, so the position cannot +/// come from the local `Vec`'s length the way it does in `build_frame_tasks` — +/// each slot would restart at 0 and every task past the first scene would carry +/// the wrong `global_frame`. Threaded in rather than re-derived here: summing +/// the preceding slots would be a second copy of the scheduler's arithmetic, +/// free to drift from it (`slot_tasks_reproduce_the_full_builder_exactly` +/// exists precisely because that has happened before). +impl FrameTask { + /// Overwrite this task's position in the full schedule. + /// + /// Used by the incremental path, which builds each slot in isolation and + /// only then knows where it starts. + fn set_global_frame(&mut self, frame: u32) { + match self { + FrameTask::Normal { global_frame, .. } + | FrameTask::SlideTransition { global_frame, .. } + | FrameTask::WorldFrame { global_frame, .. } + | FrameTask::ViewTransition { global_frame, .. } => *global_frame = frame, + } + } +} + +pub(super) fn build_slot_frame_tasks( + scenario: &Scenario, + slot: &SegmentSlot, + start_frame: u32, +) -> Vec { + let mut tasks = match slot { SegmentSlot::Scene { view_idx, scene_idx, @@ -765,6 +862,7 @@ pub(super) fn build_slot_frame_tasks(scenario: &Scenario, slot: &SegmentSlot) -> let transition_frames = (transition.duration * fps as f64).round() as u32; for f in 0..transition_frames { tasks.push(FrameTask::ViewTransition { + global_frame: tasks.len() as u32, view_a_idx: view_idx - 1, view_b_idx: *view_idx, frame_in_transition: f, @@ -776,7 +874,12 @@ pub(super) fn build_slot_frame_tasks(scenario: &Scenario, slot: &SegmentSlot) -> } tasks } + }; + // The slot was numbered from its own zero; place it in the full schedule. + for (i, task) in tasks.iter_mut().enumerate() { + task.set_global_frame(start_frame + i as u32); } + tasks } /// Build frame tasks for a single scene (by index) within a slide view. @@ -811,6 +914,7 @@ pub(super) fn build_scene_frame_tasks_in_view( for f in normal_start..normal_end { tasks.push(FrameTask::Normal { + global_frame: tasks.len() as u32, view_idx, scene_idx, frame_in_scene: f, @@ -823,6 +927,7 @@ pub(super) fn build_scene_frame_tasks_in_view( let easing = transition.easing.clone(); for f in 0..outgoing_transition_frames { tasks.push(FrameTask::SlideTransition { + global_frame: tasks.len() as u32, view_idx, scene_a_idx: scene_idx, scene_b_idx: scene_idx + 1, @@ -1274,9 +1379,14 @@ mod segment_tests { for with_vt in [false, true] { let s = scenario(&two_view_json("two", with_vt)); let slots = segment_slots(&s).unwrap(); + let mut next_frame = 0u32; let concatenated: Vec = slots .iter() - .flat_map(|slot| build_slot_frame_tasks(&s, slot)) + .flat_map(|slot| { + let tasks = build_slot_frame_tasks(&s, slot, next_frame); + next_frame += tasks.len() as u32; + tasks + }) .map(|t| format!("{t:?}")) .collect(); let full: Vec = build_frame_tasks(&s) diff --git a/crates/rustmotion/src/engine/render/scene.rs b/crates/rustmotion/src/engine/render/scene.rs index 08c91ee2..c6932b66 100644 --- a/crates/rustmotion/src/engine/render/scene.rs +++ b/crates/rustmotion/src/engine/render/scene.rs @@ -109,6 +109,8 @@ use scene_time::SceneTime; #[derive(Debug, Clone)] struct RenderContext { time: SceneTime, + /// Seconds since the scenario started — see `PaintFrame::scenario_time`. + scenario_time: f64, scene_duration: f64, frame_index: u32, fps: u32, @@ -168,6 +170,7 @@ pub fn render_frame_v2( config: &VideoConfig, scene: &Scene, frame_index: u32, + scenario_time: f64, _total_frames: u32, root_children: &[ChildComponent], ) -> Result> { @@ -175,6 +178,7 @@ pub fn render_frame_v2( config, scene, frame_index, + scenario_time, _total_frames, root_children, 1.0, @@ -192,6 +196,7 @@ pub fn render_frame_v2_scaled( config: &VideoConfig, scene: &Scene, frame_index: u32, + scenario_time: f64, _total_frames: u32, root_children: &[ChildComponent], scale_factor: f32, @@ -339,6 +344,7 @@ pub fn render_frame_v2_scaled( // Build render context let ctx = RenderContext { time: scene_time, + scenario_time, scene_duration: scene.duration, frame_index, fps: config.fps, @@ -540,6 +546,7 @@ fn render_with_new_pipeline_iter<'a, I>( let anim = Some(BuildAnimationCtx { time: ctx.time.seconds(), + scenario_time: ctx.scenario_time, scene_duration: ctx.scene_duration, fps: ctx.fps, }); @@ -552,6 +559,7 @@ fn render_with_new_pipeline_iter<'a, I>( let dispatcher = LegacyPaintDispatcher::for_scene(&built); let frame = PaintFrame { time: ctx.time.seconds(), + scenario_time: ctx.scenario_time, frame_index: ctx.frame_index, fps: ctx.fps, video_width: ctx.video_width, @@ -619,6 +627,7 @@ fn paint_decorative_fullscreen( }; let paint_ctx = PaintCtx { time, + scenario_time: ctx.scenario_time, scene_duration: ctx.scene_duration, frame_index: ctx.frame_index, fps: ctx.fps, @@ -662,16 +671,25 @@ pub fn render_scene_frame( config: &VideoConfig, scene: &Scene, frame_in_scene: u32, + scenario_time: f64, scene_total_frames: u32, ) -> Result> { let children = prepare_scene(scene, config); - render_frame_v2(config, scene, frame_in_scene, scene_total_frames, &children) + render_frame_v2( + config, + scene, + frame_in_scene, + scenario_time, + scene_total_frames, + &children, + ) } pub fn render_scene_frame_scaled( config: &VideoConfig, scene: &Scene, frame_in_scene: u32, + scenario_time: f64, scene_total_frames: u32, scale_factor: f32, ) -> Result> { @@ -680,6 +698,7 @@ pub fn render_scene_frame_scaled( config, scene, frame_in_scene, + scenario_time, scene_total_frames, &children, scale_factor, @@ -693,6 +712,7 @@ pub fn render_scene_frame_scaled_with_prev_bg( config: &VideoConfig, scene: &Scene, frame_in_scene: u32, + scenario_time: f64, scene_total_frames: u32, scale_factor: f32, prev_bg: Option<(&crate::schema::ResolvedBackground, f64)>, @@ -702,6 +722,7 @@ pub fn render_scene_frame_scaled_with_prev_bg( config, scene, frame_in_scene, + scenario_time, scene_total_frames, &children, scale_factor, @@ -763,6 +784,7 @@ pub fn render_scene_hits( let root_css = root_style(scene.layout.as_ref(), ViewType::Slide); let anim = Some(BuildAnimationCtx { time, + scenario_time: time, scene_duration: scene.duration, fps: config.fps, }); @@ -771,6 +793,9 @@ pub fn render_scene_hits( let dispatcher = LegacyPaintDispatcher::for_scene(&built); let frame = PaintFrame { time, + // Geometry probe for the studio overlay, never an encoded frame: + // no painter reads the audio clock here. + scenario_time: time, frame_index: frame_in_scene, fps: config.fps, video_width: config.width, @@ -807,6 +832,7 @@ pub fn render_world_frame_scaled( view: &crate::schema::ResolvedView, timeline: &crate::engine::world::WorldTimeline, frame_in_view: u32, + scenario_time: f64, scale_factor: f32, ) -> Result> { let scaled_w = (config.width as f32 * scale_factor) as i32; @@ -1070,6 +1096,7 @@ pub fn render_world_frame_scaled( // slide-view feature; the world pan is a separate transform). let ctx = RenderContext { time: scene_time, + scenario_time, scene_duration: scene.duration, frame_index: vis.local_frame, fps, @@ -1266,6 +1293,7 @@ pub fn render_scene_fg_scaled( config: &VideoConfig, scene: &Scene, frame_in_scene: u32, + scenario_time: f64, _scene_total_frames: u32, scale_factor: f32, ) -> Result> { @@ -1299,6 +1327,9 @@ pub fn render_scene_fg_scaled( ); let ctx = RenderContext { time: scene_time, + // The hit-map is a geometry probe for the studio overlay, never an + // encoded frame: no painter reads the audio clock on this path. + scenario_time, scene_duration: scene.duration, frame_index: frame_in_scene, fps: config.fps, diff --git a/crates/rustmotion/src/tests.rs b/crates/rustmotion/src/tests.rs index f2a9c025..fa496376 100644 --- a/crates/rustmotion/src/tests.rs +++ b/crates/rustmotion/src/tests.rs @@ -333,6 +333,7 @@ mod component_smoke { let canvas = surface.canvas(); let frame = PaintFrame { time: 0.5, + scenario_time: 0.5, frame_index: 15, fps: 30, video_width: 400, @@ -397,6 +398,7 @@ mod component_smoke { let canvas = surface.canvas(); let frame = PaintFrame { time: 0.0, + scenario_time: 0.0, frame_index: 0, fps: 30, video_width: 400, @@ -494,6 +496,7 @@ mod component_smoke { (w as f32, h as f32), BuildAnimationCtx { time, + scenario_time: time, scene_duration, fps: 30, }, @@ -506,6 +509,7 @@ mod component_smoke { let dispatcher = LegacyPaintDispatcher::for_scene(&built); let frame = PaintFrame { time, + scenario_time: time, frame_index: (time * 30.0) as u32, fps: 30, video_width: w, @@ -1618,6 +1622,7 @@ mod svg_draw_on_tests { (w as f32, h as f32), BuildAnimationCtx { time: progress, + scenario_time: progress, scene_duration, fps: 30, }, @@ -1630,6 +1635,7 @@ mod svg_draw_on_tests { let dispatcher = LegacyPaintDispatcher::for_scene(&built); let frame = PaintFrame { time: progress, + scenario_time: progress, frame_index: (progress * 30.0) as u32, fps: 30, video_width: w, @@ -1933,6 +1939,7 @@ mod audio_tests { (w as f32, h as f32), BuildAnimationCtx { time, + scenario_time: time, scene_duration: 10.0, fps, }, @@ -1949,6 +1956,7 @@ mod audio_tests { &layout, &PaintFrame { time, + scenario_time: time, frame_index: (time * fps as f64) as u32, fps, video_width: w as u32, @@ -2084,6 +2092,73 @@ mod audio_tests { assert_eq!(analysis.band_smoothed(4.9, 4, 3), 0.0); } + /// A `waveform` must read the scenario's clock, not its own scene's. + /// + /// The analysis is indexed on the scenario timeline. A scene starting at + /// t=2 s used to be handed the analysis at that many seconds *into itself*, + /// so it drew a moment of the track that had already played. + /// + /// The fixture separates the two clocks: 3 s of sine then 1 s of silence, + /// behind a 2 s filler scene. At frame 105 the scenario is at 3.5 s — in + /// the silence — while the scene is only 1.5 s old — still in the sine. + /// Reading the wrong clock draws a waveform where there is no sound. + #[test] + fn a_waveform_reads_the_scenario_clock_not_the_scene_clock() { + let sample_rate = 44100u32; + let wav_path = std::env::temp_dir().join(format!("rustmotion_test_clock_{}.wav", nanos())); + std::fs::write( + &wav_path, + make_sine_wav(sample_rate * 4, sample_rate * 3, 440.0, sample_rate), + ) + .expect("write fixture"); + let wav_str = wav_path.to_str().unwrap().to_string(); + + let json = serde_json::json!({ + "video": {"width": 200, "height": 80, "fps": 30, "background": "#000000"}, + "audio": [{"src": wav_str}], + "scenes": [ + {"duration": 2.0, "children": []}, + {"duration": 2.0, "children": [ + {"type": "waveform", "track": wav_str, "color": "#ffffff", + "draw_style": "filled", "window": 0.5, + "style": {"width": 200, "height": 80}} + ]} + ] + }) + .to_string(); + let scenario = + crate::loader::load_scenario_from_source(None, Some(&json)).expect("load scenario"); + crate::encode::audio_analysis::analyze_scenario_audio(&scenario); + + let tasks = crate::encode::build_frame_tasks(&scenario); + let lit_at = |frame: usize| { + crate::encode::video::render_frame_task(&scenario.video, &scenario, &tasks[frame]) + .expect("render") + .chunks_exact(4) + .filter(|p| p[0] > 40) + .count() + }; + + // t = 2.5 s: both clocks land in the sine, so this only proves the + // analysis is loaded and the component draws at all. + let audible = lit_at(75); + // t = 3.5 s: the scenario is in the silence, the scene is not. This is + // the frame that separates the two. + let silent = lit_at(105); + std::fs::remove_file(&wav_path).ok(); + + assert!( + audible > 300, + "sanity: with sound at that moment the waveform must be drawn, got {audible} lit pixels" + ); + assert!( + silent * 3 < audible, + "the track is silent at 3.5 s of the scenario — a scene-local clock \ + would read 1.5 s, still inside the sine, and draw a waveform for \ + sound nobody hears. audible={audible} silent={silent}" + ); + } + /// A track that cannot be decoded must be *reported*, not swallowed: /// silence here leaves `waveform`/`audio_spectrum` on their flat fallback /// with nothing anywhere saying why. @@ -2435,6 +2510,7 @@ mod motion_blur_trail { (w as f32, h as f32), BuildAnimationCtx { time, + scenario_time: time, scene_duration, fps: FPS, }, @@ -2447,6 +2523,7 @@ mod motion_blur_trail { let dispatcher = LegacyPaintDispatcher::for_scene(&built); let frame = PaintFrame { time, + scenario_time: time, frame_index: (time * FPS as f64) as u32, fps: FPS, video_width: w, @@ -2855,7 +2932,8 @@ mod camera_focal_tests { ) -> Vec { let scene: Scene = serde_json::from_value(scene_json).expect("scene json"); let children = crate::engine::render::deserialize_children(&scene); - render_frame_v2(&config(w, h), &scene, frame, 120, &children).expect("render") + let t = frame as f64 / 30.0; + render_frame_v2(&config(w, h), &scene, frame, t, 120, &children).expect("render") } /// Centroid (x, y) of pixels dominated by the given channel (0=r, 2=b). @@ -3366,9 +3444,11 @@ mod world_view_regressions { // Frames 45 (t=1.5s) and 55 (t~1.833s) are both well past it. let (pre, post_a, post_b) = (5u32, 45u32, 55u32); - let render_full = |f: u32| render_scene_frame_scaled(config, scene, f, 60, 1.0).unwrap(); + let render_full = + |f: u32| render_scene_frame_scaled(config, scene, f, f as f64 / 30.0, 60, 1.0).unwrap(); let render_bg = |f: u32| render_scene_bg_scaled(config, scene, f, 1.0).unwrap(); - let render_fg = |f: u32| render_scene_fg_scaled(config, scene, f, 60, 1.0).unwrap(); + let render_fg = + |f: u32| render_scene_fg_scaled(config, scene, f, f as f64 / 30.0, 60, 1.0).unwrap(); let pixel_paths: [(&str, &dyn Fn(u32) -> Vec); 3] = [ ("render_scene_frame_scaled", &render_full), diff --git a/crates/rustmotion/tests/node_effects_cost.rs b/crates/rustmotion/tests/node_effects_cost.rs index 4804dcf7..eaa97aaf 100644 --- a/crates/rustmotion/tests/node_effects_cost.rs +++ b/crates/rustmotion/tests/node_effects_cost.rs @@ -160,7 +160,8 @@ fn bench_duplicate_paint_via_render_scene_hits() { let t0 = Instant::now(); for f in 0..FRAMES { - let _ = render_scene_frame_scaled(config, scene, f, FRAMES, 1.0).expect("render"); + let _ = render_scene_frame_scaled(config, scene, f, f as f64 / 30.0, FRAMES, 1.0) + .expect("render"); } let normal_elapsed = t0.elapsed(); diff --git a/crates/rustmotion/tests/node_effects_leak_proof.rs b/crates/rustmotion/tests/node_effects_leak_proof.rs index 53b0caa0..1026a3ea 100644 --- a/crates/rustmotion/tests/node_effects_leak_proof.rs +++ b/crates/rustmotion/tests/node_effects_leak_proof.rs @@ -52,7 +52,7 @@ fn buffer_crop_strategy_leaks_effect_onto_overlapping_sibling() { let (config, scene) = background_and_overlapping_foreground(); // The real, already-rendered frame: red background, blue square on top. - let rendered = render_scene_frame_scaled(&config, &scene, 0, 30, 1.0).expect("render"); + let rendered = render_scene_frame_scaled(&config, &scene, 0, 0.0, 30, 1.0).expect("render"); // Sanity: the foreground square really is visible (opaque blue), not // occluded or blended away — otherwise the "leak" below would be trivial.