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.