From 6e70ff791c03581b9a00bb20a087a5f07405000d Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sun, 27 Sep 2026 00:20:11 +0200 Subject: [PATCH] fix(audio): a world-only scenario lasts the sum of its scenes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolved_scenario_duration` read "last scene's offset + its duration" from `scene_start_offsets`. For a `World` view that function deliberately gives every scene the same offset — the view's own window start — because world scenes share one window for embedded-video audio. So the total collapsed to `0 + last_scene.duration`, and a synthesised score sized from it went silent after the last scene's duration and stayed silent to the end. The video rendered at the right length and the muxed track was padded, so only listening or measuring showed it. The end cursor was already being computed for both view types; it just was not returned. `timeline_offsets` now exposes `view_ends` beside `scene_starts` from one walk, so the two cannot drift, and `scene_start_offsets` stays as a wrapper with its existing signature and semantics. World scenes keep their shared window start, which is what embedded-video extraction wants — only the total changes. Measured on the reproduction from the issue, three world scenes of 3 s + 3 s + 2 s with a kick on every beat: before after t=0.5s -8.0 dB -8.0 dB t=1.5s -8.0 dB -8.0 dB t=2.5s -91.0 dB -8.0 dB t=4.0s -91.0 dB -8.0 dB t=6.0s -91.0 dB -8.0 dB t=7.5s -91.0 dB -8.0 dB Four tests. The first fails against the old computation with `got 2` for a scenario that lasts 8 s. One pins that world scenes still share their window start; one covers a world view followed by a slide view, which passed before the fix too and is exactly why the bug survived — appending a slide view was the known workaround; and one covers a trailing view with no scenes, which must neither add to the total nor shorten it. Closes #341 --- crates/rustmotion/src/encode/video_audio.rs | 127 +++++++++++++++++--- 1 file changed, 110 insertions(+), 17 deletions(-) diff --git a/crates/rustmotion/src/encode/video_audio.rs b/crates/rustmotion/src/encode/video_audio.rs index 5f3b6ee..c865d90 100644 --- a/crates/rustmotion/src/encode/video_audio.rs +++ b/crates/rustmotion/src/encode/video_audio.rs @@ -5,9 +5,19 @@ use std::path::PathBuf; use crate::components::{ChildComponent, Component}; use crate::schema::{AudioTrack, ResolvedScenario, ViewType}; +pub struct TimelineOffsets { + pub scene_starts: Vec>, + pub view_ends: Vec, +} + pub fn scene_start_offsets(scenario: &ResolvedScenario) -> Vec> { + timeline_offsets(scenario).scene_starts +} + +pub fn timeline_offsets(scenario: &ResolvedScenario) -> TimelineOffsets { let fps = scenario.video.fps as f64; let mut result: Vec> = Vec::with_capacity(scenario.views.len()); + let mut view_ends: Vec = Vec::with_capacity(scenario.views.len()); let mut cursor = 0.0_f64; for (view_idx, view) in scenario.views.iter().enumerate() { @@ -34,6 +44,7 @@ pub fn scene_start_offsets(scenario: &ResolvedScenario) -> Vec> { } cursor = scene_cursor; + view_ends.push(cursor); result.push(scene_offsets); } @@ -54,31 +65,27 @@ pub fn scene_start_offsets(scenario: &ResolvedScenario) -> Vec> { .sum(); cursor += world_duration; + view_ends.push(cursor); result.push(scene_offsets); } } } - result + TimelineOffsets { + scene_starts: result, + view_ends, + } } pub fn resolved_scenario_duration(scenario: &ResolvedScenario) -> f64 { - let offsets = scene_start_offsets(scenario); - let fps = scenario.video.fps as f64; - let mut total = 0.0f64; - for (view_idx, view) in scenario.views.iter().enumerate() { - let Some(last_scene) = view.scenes.last() else { - continue; - }; - let last_offset = offsets - .get(view_idx) - .and_then(|o| o.last()) - .copied() - .unwrap_or(0.0); - let scene_frames = (last_scene.duration * fps).round() / fps; - total = total.max(last_offset + scene_frames); - } - total + let ends = timeline_offsets(scenario).view_ends; + scenario + .views + .iter() + .enumerate() + .filter(|(_, view)| !view.scenes.is_empty()) + .filter_map(|(view_idx, _)| ends.get(view_idx).copied()) + .fold(0.0f64, f64::max) } #[derive(Debug)] @@ -448,6 +455,92 @@ mod tests { load_scenario_from_source(None, Some(json)).expect("load") } + #[test] + fn a_world_only_scenario_lasts_the_sum_of_its_scenes() { + let s = load( + r#"{ + "video": {"width": 32, "height": 32, "fps": 10}, + "composition": [{ + "type": "world", + "scenes": [ + {"duration": 3.0, "children": []}, + {"duration": 3.0, "children": []}, + {"duration": 2.0, "children": []} + ] + }] + }"#, + ); + + let total = resolved_scenario_duration(&s); + assert!( + (total - 8.0).abs() < 1e-9, + "a world view lasts the sum of its scenes, not its last scene's 2.0s: got {total}" + ); + } + + #[test] + fn world_scenes_keep_their_shared_window_start() { + let s = load( + r#"{ + "video": {"width": 32, "height": 32, "fps": 10}, + "composition": [{ + "type": "world", + "scenes": [ + {"duration": 3.0, "children": []}, + {"duration": 2.0, "children": []} + ] + }] + }"#, + ); + + let offsets = scene_start_offsets(&s); + assert_eq!(offsets[0], vec![0.0, 0.0]); + } + + #[test] + fn a_world_view_followed_by_a_slide_view_still_totals_correctly() { + let s = load( + r#"{ + "video": {"width": 32, "height": 32, "fps": 10}, + "composition": [ + {"type": "world", "scenes": [ + {"duration": 3.0, "children": []}, + {"duration": 2.0, "children": []} + ]}, + {"type": "slide", "scenes": [{"duration": 1.0, "children": []}]} + ] + }"#, + ); + + let total = resolved_scenario_duration(&s); + assert!( + (total - 6.0).abs() < 1e-9, + "world 5.0s then a 1.0s slide totals 6.0s: got {total}" + ); + } + + #[test] + fn a_trailing_empty_view_does_not_shorten_the_total() { + let s = load( + r#"{ + "video": {"width": 32, "height": 32, "fps": 10}, + "composition": [ + {"type": "world", "scenes": [ + {"duration": 3.0, "children": []}, + {"duration": 2.0, "children": []} + ]}, + {"type": "slide", "scenes": []} + ] + }"#, + ); + + let total = resolved_scenario_duration(&s); + assert!( + (total - 5.0).abs() < 1e-9, + "an empty view contributes nothing and takes nothing away: got {total}" + ); + } + #[test] fn offsets_single_view_no_transitions() { let s = load(