From d7049e5dd0f673a8106ab69132bfef2a8851f81e Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Tue, 29 Sep 2026 11:18:15 +0200 Subject: [PATCH] fix(still, sheet): analyse the audio track before exporting a frame analyze_scenario_audio ran from the two encoders and from the studio, and from nowhere else. still and sheet went straight to painting, so every audio-reactive component in an exported frame sat at its min. waveform and audio_spectrum rendered a flat line, with no diagnostic. The issue read this as the synthesised score (#331) being incompatible with the audio-reactive components, since that is the combination it was found on. It is not: a still of the same scene with an explicit WAV src is flat in exactly the same way, and a full render of the synthesised score has always animated correctly. The score was never the variable -- the export path was. still and sheet shared a three-step preamble with the encoders, and it had drifted: load the custom fonts, prefetch the icons, and now analyse the audio. preload_scenario_assets is that preamble, so the next thing a frame needs before it can be painted has one place to be added rather than three. Verified on the issue's own repro, a 120 BPM kick on every beat: the bars now move between a beat (5172 lit pixels, top at y=479) and the kick (10788, top at y=420), where all three sampled instants were previously identical. --- crates/rustmotion/src/cli/commands/sheet.rs | 8 +-- crates/rustmotion/src/cli/commands/still.rs | 8 +-- crates/rustmotion/src/engine/preload.rs | 19 ++++++- crates/rustmotion/src/tests.rs | 56 ++++++++++++++++++++- 4 files changed, 74 insertions(+), 17 deletions(-) diff --git a/crates/rustmotion/src/cli/commands/sheet.rs b/crates/rustmotion/src/cli/commands/sheet.rs index 2fc7184..1fd4b6a 100644 --- a/crates/rustmotion/src/cli/commands/sheet.rs +++ b/crates/rustmotion/src/cli/commands/sheet.rs @@ -224,13 +224,7 @@ pub fn cmd_sheet( )); } - if !scenario.fonts.is_empty() { - engine::renderer::load_custom_fonts(&scenario.fonts); - } - - for view in &scenario.views { - engine::prefetch_icons(&view.scenes)?; - } + engine::preload::preload_scenario_assets(&scenario)?; let start_time = std::time::Instant::now(); let config = &scenario.video; diff --git a/crates/rustmotion/src/cli/commands/still.rs b/crates/rustmotion/src/cli/commands/still.rs index 7c9da76..5698c34 100644 --- a/crates/rustmotion/src/cli/commands/still.rs +++ b/crates/rustmotion/src/cli/commands/still.rs @@ -39,13 +39,7 @@ pub fn cmd_still( format: Option, quality: u8, ) -> Result<()> { - if !scenario.fonts.is_empty() { - engine::renderer::load_custom_fonts(&scenario.fonts); - } - - for view in &scenario.views { - engine::prefetch_icons(&view.scenes)?; - } + engine::preload::preload_scenario_assets(&scenario)?; let config = &scenario.video; let fps = config.fps; diff --git a/crates/rustmotion/src/engine/preload.rs b/crates/rustmotion/src/engine/preload.rs index 9ee24fc..bf87334 100644 --- a/crates/rustmotion/src/engine/preload.rs +++ b/crates/rustmotion/src/engine/preload.rs @@ -2,7 +2,8 @@ use std::io::Read; use std::sync::Arc; use crate::components::{ChildComponent, Component}; -use crate::schema::Scene; +use crate::encode::audio_analysis::analyze_scenario_audio; +use crate::schema::{ResolvedScenario, Scene}; use rustmotion_core::engine::renderer::{ asset_cache, fetch_icon_svg, ffmpeg_available, icon_cache_dir, icon_cache_key, video_frame_cache, @@ -34,6 +35,22 @@ fn video_frame_cache_bytes() -> u64 { .sum() } +pub fn preload_scenario_assets(scenario: &ResolvedScenario) -> crate::error::Result<()> { + if !scenario.fonts.is_empty() { + rustmotion_core::engine::renderer::load_custom_fonts(&scenario.fonts); + } + for view in &scenario.views { + prefetch_icons(&view.scenes)?; + } + for failure in analyze_scenario_audio(scenario) { + eprintln!( + "rustmotion: audio-reactive: {failure} — waveform/audio_spectrum will render flat \ + for this track." + ); + } + Ok(()) +} + pub fn prefetch_icons(scenes: &[Scene]) -> crate::error::Result<()> { use std::collections::HashSet; diff --git a/crates/rustmotion/src/tests.rs b/crates/rustmotion/src/tests.rs index b404ffb..1cdf000 100644 --- a/crates/rustmotion/src/tests.rs +++ b/crates/rustmotion/src/tests.rs @@ -1596,7 +1596,7 @@ mod audio_tests { use rustmotion_core::engine::renderer::audio_analysis::{audio_analysis_cache, AudioAnalysis}; - fn make_sine_wav( + pub(super) fn make_sine_wav( total_samples: u32, sine_samples: u32, freq: f32, @@ -1629,7 +1629,7 @@ mod audio_tests { } wav } - fn nanos() -> u128 { + pub(super) fn nanos() -> u128 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() @@ -3770,3 +3770,55 @@ mod self_painting_background_animation_tests { ); } } + +#[cfg(test)] +mod still_runs_the_audio_analysis_tests { + use super::audio_tests::{make_sine_wav, nanos}; + use rustmotion_core::engine::renderer::audio_analysis_cache; + + fn scenario_json(wav: &str) -> String { + serde_json::json!({ + "video": { "width": 64, "height": 64, "fps": 30 }, + "audio": [{ "src": wav }], + "scenes": [{ + "duration": 1.0, + "children": [{ + "type": "audio_spectrum", "bars": 8, "mode": "bars", "color": "#ffffff", + "style": { "position": "absolute", "left": 0, "top": 0, + "width": 64, "height": 64 } + }] + }] + }) + .to_string() + } + + #[test] + fn the_shared_preload_analyses_the_track_the_way_an_encode_does() { + let sample_rate = 44100u32; + let wav = make_sine_wav(sample_rate, sample_rate / 2, 440.0, sample_rate); + let wav_path = std::env::temp_dir().join(format!("rm_still_audio_{}.wav", nanos())); + std::fs::write(&wav_path, &wav).expect("write wav fixture"); + let src = wav_path.to_str().unwrap().to_string(); + + let scenario = crate::loader::load_scenario_from_source(None, Some(&scenario_json(&src))) + .expect("load scenario"); + + audio_analysis_cache().remove(&src); + assert!( + audio_analysis_cache().get(&src).is_none(), + "test setup: the track must not already be analysed" + ); + + crate::engine::preload::preload_scenario_assets(&scenario).expect("preload"); + + let analysed = audio_analysis_cache().get(&src).is_some(); + std::fs::remove_file(&wav_path).ok(); + + assert!( + analysed, + "still and sheet went through this preamble without analysing the track, so every \ + audio-reactive component in an exported frame sat at its min — the flat bars \ + reported in issue #349" + ); + } +}