From 2a1705292345a4b116747d2e82c3fd2d79896039 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Tue, 11 Aug 2026 01:21:29 +0200 Subject: [PATCH] feat(render): render an arbitrary frame range, with the segment's own audio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the Critical gap that gates the whole distributed/serverless axis: until now a scenario could only be rendered whole, or one frame at a time. The internals were already close. `build_frame_tasks` produces the complete ordered task list, `render_frame_task` renders one in isolation, and `--frame` already indexed into it. What was missing sat downstream. - `--frames a-b` (inclusive, 0-indexed). Malformed input fails at the clap layer; out-of-range fails against the scenario's real total, naming both the range and that total. Mutually exclusive with `--frame` and `--watch`. `png-seq`/`gif`/`raw` refuse explicitly rather than silently ignoring the range — those encoders live outside this change's file scope. - Audio was the real hazard, and the reason a naive `--frames` would have been worse than none. `mix_audio_tracks` had no offset parameter: every segment would have received the audio from the top of the scenario, so the video would have cut cleanly while the sound was wrong, with nothing to signal it. `mix_audio_tracks_segment` translates each sample into absolute scenario time and reprojects it into the segment's buffer. The scenario's total duration stays a separate parameter from the segment's, so fades and unbounded track ends remain anchored to the whole scenario rather than to a segment edge. Measured on the red phase: without the offset, 99.9% of a second segment's samples were wrong. - `rustmotion concat` joins segments through ffmpeg's concat demuxer with `-c copy`. Raw Annex-B bitstream joining was rejected deliberately: it requires every segment boundary to land on an independently decodable keyframe, which holds on the native openh264 path but *not* on the default ffmpeg path, where libx264 manages its own GOP structure. Joining bitstreams there would produce a silently corrupt stream at some cuts. Verified end to end, not just in unit tests: the same scenario rendered whole and as two concatenated segments gives 90 frames and 3.000000s either way. The segment mixer is byte-for-byte identical to the whole-scenario mix when its segments are concatenated. Also fixes a pre-existing race found while testing this: the ffmpeg audio scratch directory was named by PID alone, so concurrent encodes in one process shared it and one call's cleanup deleted a directory another was still writing to. It now carries an atomic counter as well. --- crates/rustmotion-cli/src/lib.rs | 260 ++++++++++- crates/rustmotion-core/src/error.rs | 3 + crates/rustmotion/src/encode/audio.rs | 318 +++++++++++++- crates/rustmotion/src/encode/video/ffmpeg.rs | 440 ++++++++++++++++++- crates/rustmotion/src/encode/video/h264.rs | 62 ++- crates/rustmotion/src/encode/video/mux.rs | 24 +- crates/rustmotion/src/encode/video/tasks.rs | 26 +- 7 files changed, 1088 insertions(+), 45 deletions(-) diff --git a/crates/rustmotion-cli/src/lib.rs b/crates/rustmotion-cli/src/lib.rs index 860ff06..ea95cf4 100644 --- a/crates/rustmotion-cli/src/lib.rs +++ b/crates/rustmotion-cli/src/lib.rs @@ -6,9 +6,10 @@ pub mod tui; use clap::{CommandFactory, Parser, Subcommand}; use rustmotion::error::{Result, RustmotionError}; use rustmotion::loader::load_input; +use rustmotion::schema::ResolvedScenario; use std::collections::HashMap; use std::io::Write; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; #[derive(Parser)] #[command( @@ -56,6 +57,19 @@ enum Commands { #[arg(long)] frame: Option, + /// Render only frames START..=END (0-indexed, inclusive) as a + /// standalone segment instead of the full video — e.g. `0-149` for + /// this scenario's first 150 frames. Bounds are validated against + /// the scenario's actual total frame count once it is loaded. + /// Segments carry their own windowed slice of the scenario's audio + /// (they do not restart every track from t=0), so segments from the + /// same scenario can be joined with `rustmotion concat` afterwards. + /// Mutually exclusive with --frame and --watch. Only mp4/webm/mov + /// output is implemented for a range today — png-seq/gif/raw are + /// not (see --format). + #[arg(long, value_name = "START-END", conflicts_with_all = ["frame", "watch"])] + frames: Option, + /// Output format for machine consumption #[arg(long, value_enum)] output_format: Option, @@ -124,6 +138,22 @@ enum Commands { var: Vec, }, + /// Join MP4 segments — e.g. ones produced by several `render --frames + /// a-b` calls against the same scenario — into one file. Remuxes via + /// ffmpeg's concat demuxer (`-c copy`, no re-encoding); every input must + /// share codec, resolution, and pixel format, which segments of the + /// same scenario rendered with the same `render` flags always do. + /// Requires ffmpeg on PATH. + Concat { + /// Segment files to join, in order. + #[arg(required = true, num_args = 1..)] + inputs: Vec, + + /// Output file path + #[arg(short, long, default_value = "concat.mp4")] + output: PathBuf, + }, + /// Export a single frame as a still image (PNG, JPEG, WebP) Still { /// Path to the JSON scenario file @@ -355,6 +385,41 @@ pub(crate) enum OutputFormat { Json, } +/// `--frames START-END`: an inclusive, 0-indexed frame range. Parsed eagerly +/// (format + `start <= end`) by clap via `FromStr`; whether `end` actually +/// fits the scenario's total frame count can only be checked once the +/// scenario is loaded, so that half lives in +/// `RustmotionError::FrameRangeOutOfRange` instead. +#[derive(Clone, Copy, Debug)] +pub(crate) struct FrameRangeArg { + start: u32, + end: u32, +} + +impl std::str::FromStr for FrameRangeArg { + type Err = String; + + fn from_str(s: &str) -> std::result::Result { + let (a, b) = s.split_once('-').ok_or_else(|| { + format!("--frames '{s}' must look like START-END (e.g. 0-149), got no '-'") + })?; + let start: u32 = a + .trim() + .parse() + .map_err(|_| format!("--frames '{s}': '{a}' is not a valid frame number"))?; + let end: u32 = b + .trim() + .parse() + .map_err(|_| format!("--frames '{s}': '{b}' is not a valid frame number"))?; + if start > end { + return Err(format!( + "--frames '{s}': start ({start}) must be <= end ({end})" + )); + } + Ok(FrameRangeArg { start, end }) + } +} + /// Parse `--var key=value` flags into a map. Values that parse as valid JSON /// scalars or objects are stored as their JSON type; bare strings that are not /// valid JSON are stored as JSON strings. @@ -437,6 +502,143 @@ fn build_overrides( Ok(Some(map)) } +/// Render frames `[frame_range.0, frame_range.1]` (inclusive, 0-indexed) of +/// `scenario` as a standalone segment file, instead of the full video. +/// +/// Deliberately separate from `commands::cmd_render` rather than an added +/// parameter on it: `cmd_render` is also called from `commands::batch`, +/// outside this change's file scope, so its signature stays untouched. +/// Only the two output kinds `render --frames` actually supports +/// (mp4/webm/mov, native or ffmpeg-driven) are implemented here — +/// png-seq/gif/raw frame-range support does not exist yet (see the +/// `--frames` help text) and this function says so instead of silently +/// ignoring the range for those formats. +#[allow(clippy::too_many_arguments)] +fn render_frame_range( + scenario: ResolvedScenario, + output: &Path, + frame_range: (u32, u32), + output_format: Option<&OutputFormat>, + quiet: bool, + codec: Option, + crf: Option, + format: Option, + transparent: bool, + hardware_acceleration: bool, +) -> Result<()> { + let start_time = std::time::Instant::now(); + + if !scenario.fonts.is_empty() { + rustmotion::engine::renderer::load_custom_fonts(&scenario.fonts); + } + + if let Some(parent) = output.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent)?; + } + } + + let fmt = format + .as_deref() + .unwrap_or_else(|| output.extension().and_then(|e| e.to_str()).unwrap_or("mp4")); + + if matches!(fmt, "png-seq" | "gif" | "raw") { + return Err(RustmotionError::Generic(format!( + "--frames does not support --format {fmt} yet; only mp4/webm/mov segment output is \ + implemented. Render the full video in that format instead, or drop --format for the \ + default mp4 container." + ))); + } + + let output_str = output + .to_str() + .ok_or_else(|| RustmotionError::NonUtf8Path { + path: output.to_string_lossy().into_owned(), + })?; + + let ffmpeg_available = std::process::Command::new("ffmpeg") + .arg("-version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + + let codec_str = codec.as_deref().unwrap_or("h264"); + + let mut cb = |p: rustmotion::encode::EncodeProgress| { + if quiet { + return; + } + match p { + rustmotion::encode::EncodeProgress::Rendering(c, t) => { + eprint!( + "\rRendering frames {}-{}: {}/{}", + frame_range.0, frame_range.1, c, t + ); + } + rustmotion::encode::EncodeProgress::Encoding(c, t) => { + eprint!("\rEncoding: {}/{} ", c, t); + } + rustmotion::encode::EncodeProgress::Muxing => { + eprint!("\rMuxing... "); + } + } + }; + + if ffmpeg_available { + rustmotion::encode::video::encode_with_ffmpeg_hw_range( + &scenario, + output_str, + quiet, + codec_str, + crf, + transparent, + hardware_acceleration, + frame_range, + Some(&mut cb), + )?; + } else { + if hardware_acceleration && !quiet { + eprintln!( + "Hardware acceleration requested but ffmpeg was not found on PATH (only ffmpeg \ + can drive a hardware encoder); continuing with the bundled software encoder." + ); + } + rustmotion::encode::video::encode_video_range( + &scenario, + output_str, + quiet, + frame_range, + Some(&mut cb), + )?; + } + + if !quiet { + eprintln!(); + eprintln!( + "Frames {}-{} saved to {}", + frame_range.0, + frame_range.1, + output.display() + ); + } + + let elapsed = start_time.elapsed(); + if let Some(OutputFormat::Json) = output_format { + let result = serde_json::json!({ + "status": "success", + "output": output.to_string_lossy(), + "frame_start": frame_range.0, + "frame_end": frame_range.1, + "duration_ms": elapsed.as_millis(), + }); + println!("{}", serde_json::to_string(&result)?); + } + + Ok(()) +} + pub fn run() -> Result<()> { let cli = Cli::parse(); @@ -458,6 +660,7 @@ pub fn run() -> Result<()> { json, output, frame, + frames, output_format, codec, crf, @@ -538,19 +741,50 @@ pub fn run() -> Result<()> { } } - commands::cmd_render( - loaded.scenario, - &output, - frame, - output_format.as_ref(), - cli.quiet, - codec, - crf, - format, - transparent, - hardware_acceleration, - ) + if let Some(range) = frames { + render_frame_range( + loaded.scenario, + &output, + (range.start, range.end), + output_format.as_ref(), + cli.quiet, + codec, + crf, + format, + transparent, + hardware_acceleration, + ) + } else { + commands::cmd_render( + loaded.scenario, + &output, + frame, + output_format.as_ref(), + cli.quiet, + codec, + crf, + format, + transparent, + hardware_acceleration, + ) + } + } + } + Commands::Concat { inputs, output } => { + let output_str = output + .to_str() + .ok_or_else(|| RustmotionError::NonUtf8Path { + path: output.to_string_lossy().into_owned(), + })?; + rustmotion::encode::video::concat_mp4_segments(&inputs, output_str)?; + if !cli.quiet { + eprintln!( + "Joined {} segment(s) into {}", + inputs.len(), + output.display() + ); } + Ok(()) } Commands::Still { file, diff --git a/crates/rustmotion-core/src/error.rs b/crates/rustmotion-core/src/error.rs index 02da951..f6dc0bd 100644 --- a/crates/rustmotion-core/src/error.rs +++ b/crates/rustmotion-core/src/error.rs @@ -224,6 +224,9 @@ pub enum RustmotionError { #[error("Frame {frame} is out of range (total frames: {total})")] FrameOutOfRange { frame: u32, total: u32 }, + #[error("Frame range {start}-{end} is out of range (total frames: {total})")] + FrameRangeOutOfRange { start: u32, end: u32, total: u32 }, + #[error("Time {time:.2}s is beyond video duration")] TimeOutOfRange { time: f64 }, diff --git a/crates/rustmotion/src/encode/audio.rs b/crates/rustmotion/src/encode/audio.rs index 98a9731..024aa43 100644 --- a/crates/rustmotion/src/encode/audio.rs +++ b/crates/rustmotion/src/encode/audio.rs @@ -114,13 +114,57 @@ pub(crate) fn decode_audio_file(path: &str) -> Result<(Vec, u32, u32)> { /// Mix multiple audio tracks into a single PCM i16 buffer for minimp4. /// Output: interleaved i16, stereo, 44100Hz. +/// +/// Equivalent to [`mix_audio_tracks_segment`] with `segment_start = 0.0` and +/// `segment_duration = total_duration` — i.e. "the whole scenario is the +/// segment". Kept as its own entry point for API stability (existing +/// callers, this module's own unit test). pub fn mix_audio_tracks(tracks: &[AudioTrack], total_duration: f64) -> Result>> { + mix_audio_tracks_segment(tracks, total_duration, 0.0, total_duration) +} + +/// Mix multiple audio tracks, but only materialize the samples that fall +/// inside `[segment_start, segment_start + segment_duration)` of the +/// *scenario's* own timeline — i.e. sample 0 of the returned buffer is +/// `segment_start` seconds into the scenario, not into each track. +/// +/// This is what makes a frame-range render (`rustmotion render --frames +/// a-b`) carry the audio that actually plays at that point in the full +/// scenario instead of the audio from t=0: without it, every segment's mux +/// step called the same `mix_audio_tracks(tracks, segment_duration)` that +/// the whole-video path uses, which always places sample 0 of every track +/// at sample 0 of the output — correct for a full render, silently wrong +/// for a segment starting anywhere past frame 0. +/// +/// `scenario_total_duration` is deliberately a separate parameter from +/// `segment_duration`: a track with no explicit `end` plays until the end +/// of the *scenario*, not the end of this segment. Bounding it by +/// `segment_duration` instead would make every segment boundary look like +/// the track's own natural end and trigger its `fade_out` early, once per +/// segment, instead of once at the point it actually ends. +pub fn mix_audio_tracks_segment( + tracks: &[AudioTrack], + scenario_total_duration: f64, + segment_start: f64, + segment_duration: f64, +) -> Result>> { if tracks.is_empty() { return Ok(None); } - let total_samples = (total_duration * TARGET_SAMPLE_RATE as f64).ceil() as usize; - let mut mix_buffer = vec![0.0f32; total_samples * TARGET_CHANNELS as usize]; + let segment_samples = (segment_duration * TARGET_SAMPLE_RATE as f64).ceil() as usize; + let mut mix_buffer = vec![0.0f32; segment_samples * TARGET_CHANNELS as usize]; + + // Absolute sample index (interleaved) of the segment's first sample + // within the scenario's own timeline — the anchor every track's + // scenario-relative `start`/`end` is translated against below. + let segment_offset_samples = + (segment_start * TARGET_SAMPLE_RATE as f64).round() as i64 * TARGET_CHANNELS as i64; + + // Bound used when a track has no explicit `end`, and a clamp on an + // explicit one: the scenario's own length, never this segment's. + let scenario_samples = (scenario_total_duration * TARGET_SAMPLE_RATE as f64).ceil() as usize + * TARGET_CHANNELS as usize; for track in tracks { eprintln!(" Loading audio: {}", track.src); @@ -137,25 +181,42 @@ pub fn mix_audio_tracks(tracks: &[AudioTrack], total_duration: f64) -> Result= mix_buffer.len() { + // Past this segment's window. `abs_idx` only increases as + // `i` does, so nothing later in this track falls inside + // this segment either. break; } @@ -173,8 +234,8 @@ pub fn mix_audio_tracks(tracks: &[AudioTrack], total_duration: f64) -> Result 0.0 && (frames_from_end as f64) < fade_out_samples { sample *= frames_from_end as f32 / fade_out_samples as f32; @@ -461,4 +522,239 @@ mod tests { let _ = std::fs::remove_file(&wav_path); } + + /// Write a mono PCM WAV with deterministic, non-silent content: + /// `sample[i] = ((i % 2000) - 1000) * 30`. Unlike `write_minimal_wav`'s + /// silence, an offset applied to this content is detectable — silence + /// shifted by any amount is still silence, which would make a + /// byte-equality check pass trivially even with a broken offset. + fn write_tone_wav(path: &std::path::Path, sample_rate: u32, num_samples: u32) { + let bits_per_sample: u16 = 16; + let num_channels: u16 = 1; + let byte_rate = sample_rate * num_channels as u32 * bits_per_sample as u32 / 8; + let block_align = num_channels * bits_per_sample / 8; + let data_size = num_samples * block_align as u32; + + let mut buf = Vec::with_capacity(44 + data_size as usize); + buf.extend_from_slice(b"RIFF"); + buf.extend_from_slice(&(36 + data_size).to_le_bytes()); + buf.extend_from_slice(b"WAVE"); + buf.extend_from_slice(b"fmt "); + buf.extend_from_slice(&16u32.to_le_bytes()); + buf.extend_from_slice(&1u16.to_le_bytes()); // PCM + buf.extend_from_slice(&num_channels.to_le_bytes()); + buf.extend_from_slice(&sample_rate.to_le_bytes()); + buf.extend_from_slice(&byte_rate.to_le_bytes()); + buf.extend_from_slice(&block_align.to_le_bytes()); + buf.extend_from_slice(&bits_per_sample.to_le_bytes()); + buf.extend_from_slice(b"data"); + buf.extend_from_slice(&data_size.to_le_bytes()); + for i in 0..num_samples { + let v: i16 = (((i % 2000) as i32 - 1000) * 30) as i16; + buf.extend_from_slice(&v.to_le_bytes()); + } + + std::fs::write(path, &buf).expect("write fixture wav"); + } + + /// The core proof of the frame-range audio fix (brief's constat #2): a + /// segment starting partway through the scenario must carry the audio + /// that actually plays at that point, not audio restarted from t=0. + /// + /// Mixing one 2.0s track for the whole scenario in a single call must + /// produce byte-identical PCM to mixing the *same* track in three + /// independent segment calls (0.7s + 0.7s + 0.6s) and concatenating the + /// results — each boundary lands on a whole sample count at 44100Hz + /// (30870 / 30870 / 26460, summing exactly to 88200), so nothing here + /// can hide behind rounding. `fade_in`/`fade_out` are set on the track + /// specifically to also prove fades key off the *scenario*'s bound, not + /// each segment's own edges (a segment boundary must never look like + /// the track's natural end and trigger an early fade-out). + #[test] + fn segment_mixing_concatenates_to_exactly_the_whole_scenario_mix() { + let sample_rate = TARGET_SAMPLE_RATE; + let scenario_duration = 2.0_f64; + let num_samples = (scenario_duration * sample_rate as f64) as u32; + + let wav_path = std::env::temp_dir().join(format!( + "rm_audio_segment_concat_test_{}_{}.wav", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + write_tone_wav(&wav_path, sample_rate, num_samples); + + let track = AudioTrack { + src: wav_path.to_str().unwrap().to_string(), + start: 0.3, + end: None, + volume: 0.8, + fade_in: Some(0.1), + fade_out: Some(0.1), + volume_keyframes: Vec::new(), + }; + let tracks = [track]; + + let whole = mix_audio_tracks_segment(&tracks, scenario_duration, 0.0, scenario_duration) + .expect("whole mix must succeed") + .expect("must return Some(pcm)"); + + let bounds = [(0.0, 0.7), (0.7, 0.7), (1.4, 0.6)]; + let mut concatenated = Vec::new(); + for (start, duration) in bounds { + let seg = mix_audio_tracks_segment(&tracks, scenario_duration, start, duration) + .expect("segment mix must succeed") + .expect("must return Some(pcm)"); + concatenated.extend_from_slice(&seg); + } + + assert_eq!( + concatenated.len(), + whole.len(), + "segment PCM lengths must sum to the whole mix's length" + ); + assert_eq!( + concatenated, whole, + "concatenating three independently-mixed segments must reproduce the whole-scenario \ + mix byte-for-byte — a mismatch means a segment is carrying audio from the wrong \ + offset (the frame-range bug this function exists to close)" + ); + + // The equality above is only meaningful if the fixture is not + // silent — otherwise it would hold trivially regardless of offsets. + assert!( + whole.iter().any(|&b| b != 0), + "fixture must contain non-silent audio or the byte-equality check above proves nothing" + ); + + let _ = std::fs::remove_file(&wav_path); + } + + /// A track's explicit `end` is scenario-relative. A segment sitting + /// entirely after that `end` must be silent — proving `track_end_abs` + /// clamps to the *scenario* bound (needed so a track with no `end` at + /// all keeps playing across segment boundaries) without also letting a + /// track that DOES have an end ignore it past its own segment. + #[test] + fn segment_mix_silences_a_track_after_its_own_explicit_end() { + let sample_rate = TARGET_SAMPLE_RATE; + let scenario_duration = 2.0_f64; + let num_samples = (scenario_duration * sample_rate as f64) as u32; + + let wav_path = std::env::temp_dir().join(format!( + "rm_audio_segment_end_test_{}_{}.wav", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + write_tone_wav(&wav_path, sample_rate, num_samples); + + let track = AudioTrack { + src: wav_path.to_str().unwrap().to_string(), + start: 0.0, + end: Some(1.0), + volume: 1.0, + fade_in: None, + fade_out: None, + volume_keyframes: Vec::new(), + }; + let tracks = [track]; + + // Segment [1.0, 2.0) sits entirely after the track's own end. + let seg = mix_audio_tracks_segment(&tracks, scenario_duration, 1.0, 1.0) + .expect("mix must succeed") + .expect("must return Some(pcm)"); + assert!( + seg.iter().all(|&b| b == 0), + "a segment entirely after the track's own `end` must be silent" + ); + + let _ = std::fs::remove_file(&wav_path); + } + + /// Reproduces the brief's exact bug shape, quantified. Before + /// `mix_audio_tracks_segment` existed, `mux_h264_to_mp4` / + /// `encode_with_ffmpeg_hw` had no offset to give the mixer at all — + /// every segment's mux step could only call `mix_audio_tracks(tracks, + /// segment_duration)`, which is exactly `mix_audio_tracks_segment` + /// with an implicit `segment_start = 0.0`. For a second segment that + /// actually starts at t=0.7s in the scenario, that call mixes the + /// track as if the *segment itself* were the whole timeline starting + /// at 0 — i.e. "a segment starting at frame 300 would receive the + /// audio from the start of the scenario" (the brief's own framing). + /// This test calls the old, still-present, offset-less + /// `mix_audio_tracks` the way that old mux code path would have, and + /// shows — with an actual byte-difference count, not just "it's + /// different" — how far that is from the correct windowed segment. + #[test] + fn without_the_offset_a_second_segment_would_wrongly_replay_the_track_from_the_start() { + let sample_rate = TARGET_SAMPLE_RATE; + let scenario_duration = 2.0_f64; + let num_samples = (scenario_duration * sample_rate as f64) as u32; + + let wav_path = std::env::temp_dir().join(format!( + "rm_audio_naive_bug_repro_{}_{}.wav", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + write_tone_wav(&wav_path, sample_rate, num_samples); + + let track = AudioTrack { + src: wav_path.to_str().unwrap().to_string(), + start: 0.3, + end: None, + volume: 0.8, + fade_in: None, + fade_out: None, + volume_keyframes: Vec::new(), + }; + let tracks = [track]; + + // Segment 2, correctly windowed: [0.7s, 2.0s) of the scenario. + let segment_start = 0.7_f64; + let segment_duration = 1.3_f64; + let correct = + mix_audio_tracks_segment(&tracks, scenario_duration, segment_start, segment_duration) + .expect("mix must succeed") + .expect("must return Some(pcm)"); + + // The bug: mixing the same segment's own duration with no offset — + // exactly the call shape available before this fix existed. + let buggy = mix_audio_tracks(&tracks, segment_duration) + .expect("mix must succeed") + .expect("must return Some(pcm)"); + + assert_eq!( + correct.len(), + buggy.len(), + "same segment duration, so same buffer size — the bug is about content, not length" + ); + + let differing_bytes = correct + .iter() + .zip(buggy.iter()) + .filter(|(a, b)| a != b) + .count(); + let total_bytes = correct.len(); + eprintln!( + "without the offset, {differing_bytes}/{total_bytes} bytes \ + ({:.1}%) of segment 2 would have been wrong", + 100.0 * differing_bytes as f64 / total_bytes as f64 + ); + assert!( + differing_bytes * 4 > total_bytes, + "expected the offset-less (buggy) mix to differ substantially from the correctly \ + windowed segment — only {differing_bytes}/{total_bytes} bytes differed, which would \ + mean the offset barely matters (it should matter for nearly the whole buffer here)" + ); + + let _ = std::fs::remove_file(&wav_path); + } } diff --git a/crates/rustmotion/src/encode/video/ffmpeg.rs b/crates/rustmotion/src/encode/video/ffmpeg.rs index f3e211e..567f1aa 100644 --- a/crates/rustmotion/src/encode/video/ffmpeg.rs +++ b/crates/rustmotion/src/encode/video/ffmpeg.rs @@ -8,7 +8,7 @@ use crate::engine::prefetch_icons; use crate::error::{Result, RustmotionError}; use crate::schema::ResolvedScenario as Scenario; -use super::tasks::{build_frame_tasks, render_frame_task}; +use super::tasks::{build_frame_tasks, build_frame_tasks_range, render_frame_task}; use super::EncodeProgress; /// A hardware encoder family ffmpeg can drive, in probe priority order. @@ -332,6 +332,65 @@ pub fn encode_with_ffmpeg_hw( crf: Option, transparent: bool, hardware_acceleration: bool, + on_progress: Option<&mut dyn FnMut(EncodeProgress)>, +) -> Result<()> { + encode_with_ffmpeg_hw_impl( + scenario, + output_path, + quiet, + codec, + crf, + transparent, + hardware_acceleration, + None, + on_progress, + ) +} + +/// Same as [`encode_with_ffmpeg_hw`], restricted to the inclusive frame +/// index range `[frame_range.0, frame_range.1]` — the same index space +/// `--frame N` already addresses via `build_frame_tasks(...).get(N)`. This +/// is the default (ffmpeg-driven) render path — the one actually used +/// unless ffmpeg is absent from `PATH` — so it, not just the native +/// `encode_video_range`, has to window its audio the same way: see +/// `mix_audio_tracks_segment`'s doc for why a segment carries the audio +/// that plays at that point in the *full* scenario instead of audio +/// restarted from t=0. +#[allow(clippy::too_many_arguments)] +pub fn encode_with_ffmpeg_hw_range( + scenario: &Scenario, + output_path: &str, + quiet: bool, + codec: &str, + crf: Option, + transparent: bool, + hardware_acceleration: bool, + frame_range: (u32, u32), + on_progress: Option<&mut dyn FnMut(EncodeProgress)>, +) -> Result<()> { + encode_with_ffmpeg_hw_impl( + scenario, + output_path, + quiet, + codec, + crf, + transparent, + hardware_acceleration, + Some(frame_range), + on_progress, + ) +} + +#[allow(clippy::too_many_arguments)] +fn encode_with_ffmpeg_hw_impl( + scenario: &Scenario, + output_path: &str, + quiet: bool, + codec: &str, + crf: Option, + transparent: bool, + hardware_acceleration: bool, + frame_range: Option<(u32, u32)>, mut on_progress: Option<&mut dyn FnMut(EncodeProgress)>, ) -> Result<()> { let config = &scenario.video; @@ -344,16 +403,30 @@ pub fn encode_with_ffmpeg_hw( } analyze_scenario_audio(scenario); - let tasks = build_frame_tasks(scenario); + let (tasks, full_total_frames, segment_start_frame) = match frame_range { + Some((start, end)) => { + let (tasks, total) = build_frame_tasks_range(scenario, start, end)?; + (tasks, total, start) + } + None => { + let tasks = build_frame_tasks(scenario); + let total = tasks.len() as u32; + if total == 0 { + return Err(RustmotionError::NoFrames); + } + (tasks, total, 0) + } + }; let total_frames = tasks.len() as u32; - if total_frames == 0 { - return Err(RustmotionError::NoFrames); - } - // Process audio — merge scenario.audio with tracks extracted from embedded - // video components. - let total_duration = total_frames as f64 / fps as f64; + // video components. `scenario_total_duration` stays separate from + // `segment_duration`: a track with no explicit `end` plays until the end + // of the *scenario*, and fades key off that same bound (see + // `mix_audio_tracks_segment`'s doc) — not this segment's own edges. + let scenario_total_duration = full_total_frames as f64 / fps as f64; + let segment_duration = total_frames as f64 / fps as f64; + let segment_start = segment_start_frame as f64 / fps as f64; let video_tracks = super::super::video_audio::collect_video_audio_tracks(scenario); let merged_audio: Vec = { let mut all = scenario.audio.clone(); @@ -361,8 +434,20 @@ pub fn encode_with_ffmpeg_hw( all }; + // PID alone is not a unique directory name: several audio-bearing + // encodes can run concurrently *within* one process (parallel test + // threads today; `--frames` segments rendered concurrently by a future + // distributed worker tomorrow — the exact shape this feature exists to + // enable). Two calls sharing a PID-only path would each `create_dir_all` + // the same directory, then whichever finishes first would + // `remove_dir_all` it out from under the other mid-write, surfacing as + // a bare `NotFound` on `std::fs::write` below. A monotonic counter on + // top of PID makes every call's directory distinct regardless of + // timing. + static AUDIO_TMP_DIR_SEQ: AtomicU32 = AtomicU32::new(0); let audio_tmp_dir = if !merged_audio.is_empty() { - Some(std::env::temp_dir().join(format!("rustmotion_audio_{}", std::process::id()))) + let seq = AUDIO_TMP_DIR_SEQ.fetch_add(1, Ordering::Relaxed); + Some(std::env::temp_dir().join(format!("rustmotion_audio_{}_{seq}", std::process::id()))) } else { None }; @@ -370,7 +455,12 @@ pub fn encode_with_ffmpeg_hw( if let Some(ref tmp_dir) = audio_tmp_dir { std::fs::create_dir_all(tmp_dir)?; } - super::super::audio::mix_audio_tracks(&merged_audio, total_duration)? + super::super::audio::mix_audio_tracks_segment( + &merged_audio, + scenario_total_duration, + segment_start, + segment_duration, + )? } else { None }; @@ -588,6 +678,113 @@ pub fn encode_with_ffmpeg_hw( Ok(()) } +/// Join MP4 segments — such as ones produced by consecutive `render +/// --frames a-b` calls against the same scenario — into one file via +/// ffmpeg's concat demuxer, remuxing (`-c copy`) instead of re-encoding. +/// +/// ## Why the demuxer, not a raw H.264 bitstream join +/// +/// The other way to concatenate video segments is to concatenate their raw +/// Annex-B H.264 bitstreams directly and mux the result once. That only +/// works when every segment's bitstream is independently decodable at its +/// boundary — in practice, every frame at every segment boundary has to be +/// a keyframe, and the segments' encoder settings (profile, resolution, +/// pixel format) have to match exactly. `encode_video_range` (the native +/// openh264 path) happens to force an intra frame on *every* output frame +/// already (`encoder.force_intra_frame()`, unrelated to frame ranges — it +/// predates this feature), so its segments would trivially qualify. But +/// this function's actual callers go through the ffmpeg path +/// (`encode_with_ffmpeg_hw_range`), the one `render` actually uses whenever +/// ffmpeg is on `PATH` (the CLI's default): that path hands GOP structure +/// to libx264/libx265 with no per-frame intra control at all, so segment +/// boundaries are not guaranteed keyframes and a raw bitstream join would +/// silently produce an undecodable or corrupted joint at some cuts. Making +/// bitstream concatenation reliable needs an encoding-side change (forcing +/// a keyframe at every segment boundary, or exposing a GOP-alignment knob) +/// that does not exist yet. +/// +/// The concat demuxer sidesteps all of that: it trusts each segment's own +/// container-level framing and restitches the streams, so it works +/// regardless of GOP layout. The cost is an extra remux pass — cheap +/// (`-c copy` touches no pixels, so no re-encode and no quality loss) — and +/// the requirement that every segment share codec, resolution, and pixel +/// format, which segments of the *same* scenario rendered with the *same* +/// `render` flags always do. +pub fn concat_mp4_segments(inputs: &[std::path::PathBuf], output_path: &str) -> Result<()> { + if inputs.is_empty() { + return Err(RustmotionError::Generic( + "concat requires at least one input segment".to_string(), + )); + } + + // The concat demuxer reads a text list of `file ''` lines. Paths + // are canonicalized so the list works regardless of the process's + // current directory, and single quotes are escaped the way ffmpeg's own + // docs prescribe for its concat protocol. + let mut list_contents = String::new(); + for input in inputs { + let abs = input + .canonicalize() + .map_err(|e| RustmotionError::FileRead { + path: input.display().to_string(), + source: e, + })?; + let escaped = abs.to_string_lossy().replace('\'', r"'\''"); + list_contents.push_str(&format!("file '{escaped}'\n")); + } + + let list_path = std::env::temp_dir().join(format!( + "rustmotion_concat_{}_{}.txt", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + std::fs::write(&list_path, &list_contents)?; + + let output = std::process::Command::new("ffmpeg") + .args([ + "-y", + "-loglevel", + "error", + "-f", + "concat", + "-safe", + "0", + "-i", + ]) + .arg(&list_path) + .args(["-c", "copy"]) + .arg(output_path) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()) + .output() + .map_err(|e| RustmotionError::FfmpegSpawn { + reason: e.to_string(), + })?; + + let _ = std::fs::remove_file(&list_path); + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let summary = stderr + .lines() + .rev() + .take(8) + .collect::>() + .into_iter() + .rev() + .collect::>() + .join("\n"); + return Err(RustmotionError::FfmpegFailed { + stderr: (!summary.trim().is_empty()).then_some(summary), + }); + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::{ffmpeg_args, parse_encoder_names, select_hardware_encoder, HardwareSelection}; @@ -1088,4 +1285,227 @@ Encoders: let _ = std::fs::remove_file(&out); } + + // ── Frame-range render + concat: the brief's "test that matters most" ── + // + // "rendre un scénario en un seul morceau, puis le même en N segments + // concaténés, et comparer. Les deux doivent avoir le même nombre de + // frames et la même durée audio." A scenario with an audio track is + // part of this test on purpose — it is the only way to exercise the + // segment-audio-offset fix (`mix_audio_tracks_segment`) through the + // actual default (ffmpeg) render path, not just the pure mixer unit + // tests in `encode::audio`. + + fn ffprobe_frame_count(path: &str) -> Option { + let out = std::process::Command::new("ffprobe") + .args([ + "-v", + "error", + "-count_frames", + "-select_streams", + "v:0", + "-show_entries", + "stream=nb_read_frames", + "-of", + "default=noprint_wrappers=1:nokey=1", + path, + ]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + String::from_utf8_lossy(&out.stdout) + .trim() + .parse::() + .ok() + } + + /// Extract the frame at `time_s` into `path` as a PNG and return its + /// center pixel's RGB. Accurate (post-`-i`) seeking, not the fast + /// keyframe-snapping `-ss`-before`-i` form — this scenario's scenes are + /// each a full second of one flat color, so any frame within a scene's + /// window has the same color regardless of exactly which one lands, but + /// accurate seeking keeps the test honest about which scene it read. + fn extract_center_pixel(path: &str, time_s: f64) -> Option<(u8, u8, u8)> { + let png_path = std::env::temp_dir().join(format!( + "rm_frame_range_pixel_{}_{}.png", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok()? + .as_nanos() + )); + let status = std::process::Command::new("ffmpeg") + .args(["-y", "-loglevel", "error", "-i", path, "-ss"]) + .arg(time_s.to_string()) + .args(["-frames:v", "1"]) + .arg(&png_path) + .status() + .ok()?; + if !status.success() { + return None; + } + let img = image::open(&png_path).ok()?.to_rgba8(); + let (w, h) = img.dimensions(); + let px = img.get_pixel(w / 2, h / 2); + let _ = std::fs::remove_file(&png_path); + Some((px[0], px[1], px[2])) + } + + #[test] + fn full_render_and_three_segments_concatenated_match_frame_count_and_audio_duration() { + if !ffmpeg_on_path() || !ffprobe_on_path() { + eprintln!( + "full_render_and_three_segments_concatenated_match_frame_count_and_audio_duration: \ + ffmpeg/ffprobe not found — skipping" + ); + return; + } + + // 3 scenes x 1.0s x 10fps = 30 frames, no transitions, so segment + // boundaries land exactly on scene boundaries: (0,9)=red, (10,19)=green, + // (20,29)=blue. Frame-range indices are the same index space + // `build_frame_tasks` (and `--frame N`) already use. + let fps = 10u32; + let width = 64u32; + let height = 64u32; + + let wav_path = std::env::temp_dir().join(format!( + "rm_frame_range_audio_{}_{}.wav", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + // 3.0s of audio at the video's own duration, so a whole-track (no + // explicit `end`) plays across all three segments — exactly the + // shape that needs the segment-audio-offset fix to sound right. + write_minimal_wav(&wav_path, 22_050, 22_050 * 3); + + let json = format!( + r##"{{"video": {{"width": {width}, "height": {height}, "fps": {fps}}}, + "audio": [{{"src": "{}"}}], + "scenes": [ + {{"duration": 1.0, "children": [ + {{"type": "shape", "shape": "rect", "fill": "#ff0000", + "position": "absolute", "x": 0, "y": 0, + "style": {{"width": {width}, "height": {height}}}}} + ]}}, + {{"duration": 1.0, "children": [ + {{"type": "shape", "shape": "rect", "fill": "#00ff00", + "position": "absolute", "x": 0, "y": 0, + "style": {{"width": {width}, "height": {height}}}}} + ]}}, + {{"duration": 1.0, "children": [ + {{"type": "shape", "shape": "rect", "fill": "#0000ff", + "position": "absolute", "x": 0, "y": 0, + "style": {{"width": {width}, "height": {height}}}}} + ]}} + ]}}"##, + wav_path.to_str().unwrap().replace('\\', "\\\\") + ); + let scenario = crate::loader::load_scenario_from_source(None, Some(&json)).expect("load"); + + let expected_total_frames = super::build_frame_tasks(&scenario).len() as u32; + assert_eq!(expected_total_frames, 30, "3 scenes x 1.0s x 10fps"); + + let pid = std::process::id(); + let full_out = std::env::temp_dir().join(format!("rm_frame_range_full_{pid}.mp4")); + let seg_outs: Vec = (0..3) + .map(|i| std::env::temp_dir().join(format!("rm_frame_range_seg{i}_{pid}.mp4"))) + .collect(); + let concat_out = std::env::temp_dir().join(format!("rm_frame_range_concat_{pid}.mp4")); + for p in [&full_out, &concat_out].into_iter().chain(seg_outs.iter()) { + let _ = std::fs::remove_file(p); + } + + // 1. Render the whole scenario in one piece. + super::encode_with_ffmpeg_hw( + &scenario, + full_out.to_str().unwrap(), + true, + "h264", + None, + false, + false, + None, + ) + .expect("full render must succeed"); + + // 2. Render the same scenario as three independent frame-range segments. + for (i, (start, end)) in [(0u32, 9u32), (10, 19), (20, 29)].into_iter().enumerate() { + super::encode_with_ffmpeg_hw_range( + &scenario, + seg_outs[i].to_str().unwrap(), + true, + "h264", + None, + false, + false, + (start, end), + None, + ) + .unwrap_or_else(|e| panic!("segment {i} ({start}-{end}) render must succeed: {e}")); + } + + // 3. Concatenate the three segments. + super::concat_mp4_segments(&seg_outs, concat_out.to_str().unwrap()) + .expect("concat must succeed"); + + // 4. Same frame count. + let full_frames = ffprobe_frame_count(full_out.to_str().unwrap()) + .expect("ffprobe must report the full render's frame count"); + let concat_frames = ffprobe_frame_count(concat_out.to_str().unwrap()) + .expect("ffprobe must report the concatenated render's frame count"); + assert_eq!( + full_frames, expected_total_frames, + "full render must have exactly the frames build_frame_tasks predicts" + ); + assert_eq!( + concat_frames, full_frames, + "concatenated segments must have the exact same frame count as the full render" + ); + + // 5. Same audio duration. + let full_audio_dur = ffprobe_stream_duration(full_out.to_str().unwrap(), "a:0") + .expect("full render must have an audio stream"); + let concat_audio_dur = ffprobe_stream_duration(concat_out.to_str().unwrap(), "a:0") + .expect("concatenated render must have an audio stream"); + assert!( + (full_audio_dur - concat_audio_dur).abs() < 0.05, + "audio duration must match within 50ms: full={full_audio_dur:.3}s \ + concat={concat_audio_dur:.3}s" + ); + + // 6. Pixel check at the middle of segment 2 (t=1.5s, inside the solid- + // green scene): the concatenated output's content there must match + // the full render's, proving the split didn't shift which frames + // land where. + let full_px = extract_center_pixel(full_out.to_str().unwrap(), 1.5) + .expect("must extract a frame from the full render"); + let concat_px = extract_center_pixel(concat_out.to_str().unwrap(), 1.5) + .expect("must extract a frame from the concatenated render"); + let close = |a: (u8, u8, u8), b: (u8, u8, u8)| { + (a.0 as i32 - b.0 as i32).abs() <= 20 + && (a.1 as i32 - b.1 as i32).abs() <= 20 + && (a.2 as i32 - b.2 as i32).abs() <= 20 + }; + assert!( + close(full_px, concat_px), + "pixel at t=1.5s must match between full and concatenated renders: \ + full={full_px:?} concat={concat_px:?}" + ); + assert!( + close(full_px, (0, 255, 0)), + "t=1.5s sits inside the solid-green second scene: expected ~green, \ + got full={full_px:?}" + ); + + for p in [&full_out, &concat_out].into_iter().chain(seg_outs.iter()) { + let _ = std::fs::remove_file(p); + } + let _ = std::fs::remove_file(&wav_path); + } } diff --git a/crates/rustmotion/src/encode/video/h264.rs b/crates/rustmotion/src/encode/video/h264.rs index 7abf84c..0a2c0ea 100644 --- a/crates/rustmotion/src/encode/video/h264.rs +++ b/crates/rustmotion/src/encode/video/h264.rs @@ -10,7 +10,7 @@ use crate::error::{Result, RustmotionError}; use crate::schema::ResolvedScenario as Scenario; use super::mux::mux_h264_to_mp4; -use super::tasks::{build_frame_tasks, render_frame_task, SceneSegment}; +use super::tasks::{build_frame_tasks, build_frame_tasks_range, render_frame_task, SceneSegment}; use super::EncodeProgress; /// Create an OpenH264 encoder with standard settings for the given video dimensions. @@ -25,9 +25,37 @@ fn create_encoder(width: u32, height: u32, fps: u32) -> Result { } pub fn encode_video( + scenario: &Scenario, + output_path: &str, + quiet: bool, + on_progress: Option<&mut dyn FnMut(EncodeProgress)>, +) -> Result<()> { + encode_video_impl(scenario, output_path, quiet, None, on_progress) +} + +/// Same as [`encode_video`], restricted to the inclusive frame index range +/// `[frame_range.0, frame_range.1]` — the same index space `--frame N` +/// already addresses via `build_frame_tasks(...).get(N)`. Produces a +/// standalone MP4 covering only that range, independently muxed; its +/// embedded audio is windowed to match via `mix_audio_tracks_segment` (see +/// that function's doc), so a segment starting at frame 300 carries the +/// audio that plays at that point in the *full* scenario rather than audio +/// restarted from t=0. +pub fn encode_video_range( + scenario: &Scenario, + output_path: &str, + quiet: bool, + frame_range: (u32, u32), + on_progress: Option<&mut dyn FnMut(EncodeProgress)>, +) -> Result<()> { + encode_video_impl(scenario, output_path, quiet, Some(frame_range), on_progress) +} + +fn encode_video_impl( scenario: &Scenario, output_path: &str, _quiet: bool, + frame_range: Option<(u32, u32)>, mut on_progress: Option<&mut dyn FnMut(EncodeProgress)>, ) -> Result<()> { let config = &scenario.video; @@ -41,13 +69,22 @@ pub fn encode_video( } analyze_scenario_audio(scenario); - let tasks = build_frame_tasks(scenario); + let (tasks, full_total_frames, segment_start_frame) = match frame_range { + Some((start, end)) => { + let (tasks, total) = build_frame_tasks_range(scenario, start, end)?; + (tasks, total, start) + } + None => { + let tasks = build_frame_tasks(scenario); + let total = tasks.len() as u32; + if total == 0 { + return Err(RustmotionError::NoFrames); + } + (tasks, total, 0) + } + }; let total_frames = tasks.len() as u32; - if total_frames == 0 { - return Err(RustmotionError::NoFrames); - } - let batch_size = (rayon::current_num_threads() * 2).max(4); let counter = AtomicU32::new(0); @@ -85,7 +122,9 @@ pub fn encode_video( cb(EncodeProgress::Muxing); } - let total_duration = total_frames as f64 / fps as f64; + let scenario_total_duration = full_total_frames as f64 / fps as f64; + let segment_duration = total_frames as f64 / fps as f64; + let segment_start = segment_start_frame as f64 / fps as f64; mux_h264_to_mp4( &h264_data, output_path, @@ -93,7 +132,9 @@ pub fn encode_video( height, fps, scenario, - total_duration, + segment_duration, + scenario_total_duration, + segment_start, )?; Ok(()) @@ -272,6 +313,9 @@ pub fn encode_video_incremental( cb(EncodeProgress::Muxing); } + // Incremental encoding always covers the full scenario — there is no + // sub-range concept here, so the segment IS the whole scenario, same as + // every pre-frame-range caller of `mux_h264_to_mp4`. let total_duration = total_frames as f64 / fps as f64; mux_h264_to_mp4( &h264_data, @@ -281,6 +325,8 @@ pub fn encode_video_incremental( fps, scenario, total_duration, + total_duration, + 0.0, )?; if !quiet && on_progress.is_none() { diff --git a/crates/rustmotion/src/encode/video/mux.rs b/crates/rustmotion/src/encode/video/mux.rs index 4f4fdef..8a770d4 100644 --- a/crates/rustmotion/src/encode/video/mux.rs +++ b/crates/rustmotion/src/encode/video/mux.rs @@ -6,6 +6,19 @@ use crate::error::Result; use crate::schema::ResolvedScenario as Scenario; /// Mux H.264 data (with optional audio) into an MP4 file. +/// +/// `h264_data` covers `segment_duration` seconds starting at `segment_start` +/// seconds into the scenario's own timeline (`segment_start = 0.0` and +/// `segment_duration == scenario_total_duration` for a full, non-ranged +/// render — the two call sites in `h264.rs` that mux the entire scenario +/// pass exactly that). Audio is windowed to match via +/// `mix_audio_tracks_segment`, so a segment that starts partway through the +/// scenario carries the audio that plays at that point instead of audio +/// restarted from t=0 — see that function's doc for why +/// `scenario_total_duration` has to stay a separate parameter from +/// `segment_duration` (fades key off the scenario's own bound, not this +/// segment's edges). +#[allow(clippy::too_many_arguments)] pub(super) fn mux_h264_to_mp4( h264_data: &[u8], output_path: &str, @@ -13,7 +26,9 @@ pub(super) fn mux_h264_to_mp4( height: u32, fps: u32, scenario: &Scenario, - total_duration: f64, + segment_duration: f64, + scenario_total_duration: f64, + segment_start: f64, ) -> Result<()> { // Collect audio from embedded video components and merge with scenario.audio. let video_tracks = super::super::video_audio::collect_video_audio_tracks(scenario); @@ -24,7 +39,12 @@ pub(super) fn mux_h264_to_mp4( }; let pcm_data = if !merged_audio.is_empty() { - super::super::audio::mix_audio_tracks(&merged_audio, total_duration)? + super::super::audio::mix_audio_tracks_segment( + &merged_audio, + scenario_total_duration, + segment_start, + segment_duration, + )? } else { None }; diff --git a/crates/rustmotion/src/encode/video/tasks.rs b/crates/rustmotion/src/encode/video/tasks.rs index 3a0b03c..a55efcf 100644 --- a/crates/rustmotion/src/encode/video/tasks.rs +++ b/crates/rustmotion/src/encode/video/tasks.rs @@ -1,5 +1,5 @@ use crate::engine::transition::{apply_transition, camera_pan_transition}; -use crate::error::Result; +use crate::error::{Result, RustmotionError}; use crate::schema::{ EasingType, ResolvedScenario as Scenario, ResolvedView, Scene, TransitionType, VideoConfig, ViewType, @@ -505,6 +505,30 @@ pub fn build_frame_tasks(scenario: &Scenario) -> Vec { tasks } +/// `build_frame_tasks`, restricted to the inclusive index range `[start, +/// end]` — the same index space `render --frame N` already addresses via +/// `build_frame_tasks(scenario).get(N)`. Returns the sliced tasks alongside +/// the *full* scenario's total frame count, since callers (the encoders' +/// mux step in particular) need it to compute the segment's time offset +/// into the scenario, not just the segment's own length. +/// +/// Errors with `FrameRangeOutOfRange` when `start > end` or `end` falls +/// outside `0..total`, naming both the requested range and the actual +/// total — the same contract `render_single_frame`'s `--frame` error gives +/// for a single out-of-range index. +pub fn build_frame_tasks_range( + scenario: &Scenario, + start: u32, + end: u32, +) -> Result<(Vec, u32)> { + let tasks = build_frame_tasks(scenario); + let total = tasks.len() as u32; + if start > end || end >= total { + return Err(RustmotionError::FrameRangeOutOfRange { start, end, total }); + } + Ok((tasks[start as usize..=end as usize].to_vec(), total)) +} + /// Frames actually spent on the transition from `scenes[i]` into /// `scenes[i + 1]` — defined by `scenes[i + 1].transition` — clamped to the /// *outgoing* scene's own frame budget. `(frames, effective_duration)`