Skip to content

Commit a0fd570

Browse files
authored
feat(render): render an arbitrary frame range, with the segment's own audio (#169)
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.
1 parent 327e571 commit a0fd570

7 files changed

Lines changed: 1088 additions & 45 deletions

File tree

‎crates/rustmotion-cli/src/lib.rs‎

Lines changed: 247 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ pub mod tui;
66
use clap::{CommandFactory, Parser, Subcommand};
77
use rustmotion::error::{Result, RustmotionError};
88
use rustmotion::loader::load_input;
9+
use rustmotion::schema::ResolvedScenario;
910
use std::collections::HashMap;
1011
use std::io::Write;
11-
use std::path::PathBuf;
12+
use std::path::{Path, PathBuf};
1213

1314
#[derive(Parser)]
1415
#[command(
@@ -56,6 +57,19 @@ enum Commands {
5657
#[arg(long)]
5758
frame: Option<u32>,
5859

60+
/// Render only frames START..=END (0-indexed, inclusive) as a
61+
/// standalone segment instead of the full video — e.g. `0-149` for
62+
/// this scenario's first 150 frames. Bounds are validated against
63+
/// the scenario's actual total frame count once it is loaded.
64+
/// Segments carry their own windowed slice of the scenario's audio
65+
/// (they do not restart every track from t=0), so segments from the
66+
/// same scenario can be joined with `rustmotion concat` afterwards.
67+
/// Mutually exclusive with --frame and --watch. Only mp4/webm/mov
68+
/// output is implemented for a range today — png-seq/gif/raw are
69+
/// not (see --format).
70+
#[arg(long, value_name = "START-END", conflicts_with_all = ["frame", "watch"])]
71+
frames: Option<FrameRangeArg>,
72+
5973
/// Output format for machine consumption
6074
#[arg(long, value_enum)]
6175
output_format: Option<OutputFormat>,
@@ -124,6 +138,22 @@ enum Commands {
124138
var: Vec<String>,
125139
},
126140

141+
/// Join MP4 segments — e.g. ones produced by several `render --frames
142+
/// a-b` calls against the same scenario — into one file. Remuxes via
143+
/// ffmpeg's concat demuxer (`-c copy`, no re-encoding); every input must
144+
/// share codec, resolution, and pixel format, which segments of the
145+
/// same scenario rendered with the same `render` flags always do.
146+
/// Requires ffmpeg on PATH.
147+
Concat {
148+
/// Segment files to join, in order.
149+
#[arg(required = true, num_args = 1..)]
150+
inputs: Vec<PathBuf>,
151+
152+
/// Output file path
153+
#[arg(short, long, default_value = "concat.mp4")]
154+
output: PathBuf,
155+
},
156+
127157
/// Export a single frame as a still image (PNG, JPEG, WebP)
128158
Still {
129159
/// Path to the JSON scenario file
@@ -355,6 +385,41 @@ pub(crate) enum OutputFormat {
355385
Json,
356386
}
357387

388+
/// `--frames START-END`: an inclusive, 0-indexed frame range. Parsed eagerly
389+
/// (format + `start <= end`) by clap via `FromStr`; whether `end` actually
390+
/// fits the scenario's total frame count can only be checked once the
391+
/// scenario is loaded, so that half lives in
392+
/// `RustmotionError::FrameRangeOutOfRange` instead.
393+
#[derive(Clone, Copy, Debug)]
394+
pub(crate) struct FrameRangeArg {
395+
start: u32,
396+
end: u32,
397+
}
398+
399+
impl std::str::FromStr for FrameRangeArg {
400+
type Err = String;
401+
402+
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
403+
let (a, b) = s.split_once('-').ok_or_else(|| {
404+
format!("--frames '{s}' must look like START-END (e.g. 0-149), got no '-'")
405+
})?;
406+
let start: u32 = a
407+
.trim()
408+
.parse()
409+
.map_err(|_| format!("--frames '{s}': '{a}' is not a valid frame number"))?;
410+
let end: u32 = b
411+
.trim()
412+
.parse()
413+
.map_err(|_| format!("--frames '{s}': '{b}' is not a valid frame number"))?;
414+
if start > end {
415+
return Err(format!(
416+
"--frames '{s}': start ({start}) must be <= end ({end})"
417+
));
418+
}
419+
Ok(FrameRangeArg { start, end })
420+
}
421+
}
422+
358423
/// Parse `--var key=value` flags into a map. Values that parse as valid JSON
359424
/// scalars or objects are stored as their JSON type; bare strings that are not
360425
/// valid JSON are stored as JSON strings.
@@ -437,6 +502,143 @@ fn build_overrides(
437502
Ok(Some(map))
438503
}
439504

505+
/// Render frames `[frame_range.0, frame_range.1]` (inclusive, 0-indexed) of
506+
/// `scenario` as a standalone segment file, instead of the full video.
507+
///
508+
/// Deliberately separate from `commands::cmd_render` rather than an added
509+
/// parameter on it: `cmd_render` is also called from `commands::batch`,
510+
/// outside this change's file scope, so its signature stays untouched.
511+
/// Only the two output kinds `render --frames` actually supports
512+
/// (mp4/webm/mov, native or ffmpeg-driven) are implemented here —
513+
/// png-seq/gif/raw frame-range support does not exist yet (see the
514+
/// `--frames` help text) and this function says so instead of silently
515+
/// ignoring the range for those formats.
516+
#[allow(clippy::too_many_arguments)]
517+
fn render_frame_range(
518+
scenario: ResolvedScenario,
519+
output: &Path,
520+
frame_range: (u32, u32),
521+
output_format: Option<&OutputFormat>,
522+
quiet: bool,
523+
codec: Option<String>,
524+
crf: Option<u8>,
525+
format: Option<String>,
526+
transparent: bool,
527+
hardware_acceleration: bool,
528+
) -> Result<()> {
529+
let start_time = std::time::Instant::now();
530+
531+
if !scenario.fonts.is_empty() {
532+
rustmotion::engine::renderer::load_custom_fonts(&scenario.fonts);
533+
}
534+
535+
if let Some(parent) = output.parent() {
536+
if !parent.as_os_str().is_empty() {
537+
std::fs::create_dir_all(parent)?;
538+
}
539+
}
540+
541+
let fmt = format
542+
.as_deref()
543+
.unwrap_or_else(|| output.extension().and_then(|e| e.to_str()).unwrap_or("mp4"));
544+
545+
if matches!(fmt, "png-seq" | "gif" | "raw") {
546+
return Err(RustmotionError::Generic(format!(
547+
"--frames does not support --format {fmt} yet; only mp4/webm/mov segment output is \
548+
implemented. Render the full video in that format instead, or drop --format for the \
549+
default mp4 container."
550+
)));
551+
}
552+
553+
let output_str = output
554+
.to_str()
555+
.ok_or_else(|| RustmotionError::NonUtf8Path {
556+
path: output.to_string_lossy().into_owned(),
557+
})?;
558+
559+
let ffmpeg_available = std::process::Command::new("ffmpeg")
560+
.arg("-version")
561+
.stdout(std::process::Stdio::null())
562+
.stderr(std::process::Stdio::null())
563+
.status()
564+
.map(|s| s.success())
565+
.unwrap_or(false);
566+
567+
let codec_str = codec.as_deref().unwrap_or("h264");
568+
569+
let mut cb = |p: rustmotion::encode::EncodeProgress| {
570+
if quiet {
571+
return;
572+
}
573+
match p {
574+
rustmotion::encode::EncodeProgress::Rendering(c, t) => {
575+
eprint!(
576+
"\rRendering frames {}-{}: {}/{}",
577+
frame_range.0, frame_range.1, c, t
578+
);
579+
}
580+
rustmotion::encode::EncodeProgress::Encoding(c, t) => {
581+
eprint!("\rEncoding: {}/{} ", c, t);
582+
}
583+
rustmotion::encode::EncodeProgress::Muxing => {
584+
eprint!("\rMuxing... ");
585+
}
586+
}
587+
};
588+
589+
if ffmpeg_available {
590+
rustmotion::encode::video::encode_with_ffmpeg_hw_range(
591+
&scenario,
592+
output_str,
593+
quiet,
594+
codec_str,
595+
crf,
596+
transparent,
597+
hardware_acceleration,
598+
frame_range,
599+
Some(&mut cb),
600+
)?;
601+
} else {
602+
if hardware_acceleration && !quiet {
603+
eprintln!(
604+
"Hardware acceleration requested but ffmpeg was not found on PATH (only ffmpeg \
605+
can drive a hardware encoder); continuing with the bundled software encoder."
606+
);
607+
}
608+
rustmotion::encode::video::encode_video_range(
609+
&scenario,
610+
output_str,
611+
quiet,
612+
frame_range,
613+
Some(&mut cb),
614+
)?;
615+
}
616+
617+
if !quiet {
618+
eprintln!();
619+
eprintln!(
620+
"Frames {}-{} saved to {}",
621+
frame_range.0,
622+
frame_range.1,
623+
output.display()
624+
);
625+
}
626+
627+
let elapsed = start_time.elapsed();
628+
if let Some(OutputFormat::Json) = output_format {
629+
let result = serde_json::json!({
630+
"status": "success",
631+
"output": output.to_string_lossy(),
632+
"frame_start": frame_range.0,
633+
"frame_end": frame_range.1,
634+
"duration_ms": elapsed.as_millis(),
635+
});
636+
println!("{}", serde_json::to_string(&result)?);
637+
}
638+
639+
Ok(())
640+
}
641+
440642
pub fn run() -> Result<()> {
441643
let cli = Cli::parse();
442644

@@ -458,6 +660,7 @@ pub fn run() -> Result<()> {
458660
json,
459661
output,
460662
frame,
663+
frames,
461664
output_format,
462665
codec,
463666
crf,
@@ -538,19 +741,50 @@ pub fn run() -> Result<()> {
538741
}
539742
}
540743

541-
commands::cmd_render(
542-
loaded.scenario,
543-
&output,
544-
frame,
545-
output_format.as_ref(),
546-
cli.quiet,
547-
codec,
548-
crf,
549-
format,
550-
transparent,
551-
hardware_acceleration,
552-
)
744+
if let Some(range) = frames {
745+
render_frame_range(
746+
loaded.scenario,
747+
&output,
748+
(range.start, range.end),
749+
output_format.as_ref(),
750+
cli.quiet,
751+
codec,
752+
crf,
753+
format,
754+
transparent,
755+
hardware_acceleration,
756+
)
757+
} else {
758+
commands::cmd_render(
759+
loaded.scenario,
760+
&output,
761+
frame,
762+
output_format.as_ref(),
763+
cli.quiet,
764+
codec,
765+
crf,
766+
format,
767+
transparent,
768+
hardware_acceleration,
769+
)
770+
}
771+
}
772+
}
773+
Commands::Concat { inputs, output } => {
774+
let output_str = output
775+
.to_str()
776+
.ok_or_else(|| RustmotionError::NonUtf8Path {
777+
path: output.to_string_lossy().into_owned(),
778+
})?;
779+
rustmotion::encode::video::concat_mp4_segments(&inputs, output_str)?;
780+
if !cli.quiet {
781+
eprintln!(
782+
"Joined {} segment(s) into {}",
783+
inputs.len(),
784+
output.display()
785+
);
553786
}
787+
Ok(())
554788
}
555789
Commands::Still {
556790
file,

‎crates/rustmotion-core/src/error.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,9 @@ pub enum RustmotionError {
224224
#[error("Frame {frame} is out of range (total frames: {total})")]
225225
FrameOutOfRange { frame: u32, total: u32 },
226226

227+
#[error("Frame range {start}-{end} is out of range (total frames: {total})")]
228+
FrameRangeOutOfRange { start: u32, end: u32, total: u32 },
229+
227230
#[error("Time {time:.2}s is beyond video duration")]
228231
TimeOutOfRange { time: f64 },
229232

0 commit comments

Comments
 (0)