use crate::cli::tui;
use crate::cli::OutputFormat;
use rustmotion::encode;
use rustmotion::engine;
use rustmotion::engine::renderer::invalidate_font_caches;
use rustmotion::error::{Result, RustmotionError};
use rustmotion::schema::ResolvedScenario;
use std::path::{Path, PathBuf};
use crate::cli::commands::validation::{self, ValidationSource};
fn clear_all_media_caches() {
engine::clear_asset_cache();
engine::gif_cache().clear();
engine::video_frame_cache().clear();
invalidate_font_caches();
}
#[allow(clippy::too_many_arguments)]
fn load_for_watch(
input: &Path,
no_validate: bool,
lenient: bool,
strict_anim: bool,
strict_attrs: bool,
) -> Result<ResolvedScenario> {
let loaded = validation::load(ValidationSource::File(input))?;
if !no_validate {
let mut report = validation::run_checks(&loaded, strict_anim);
if strict_attrs {
report.promote_attr_warnings();
}
if !report.is_clean() {
validation::print_report(&report, &input.display().to_string());
}
if report.is_blocking(lenient) {
return Err(report.to_error());
}
}
Ok(loaded.scenario)
}
#[allow(clippy::too_many_arguments)]
pub fn cmd_render(
scenario: ResolvedScenario,
output: &Path,
frame: Option<u32>,
output_format: Option<&OutputFormat>,
quiet: bool,
codec: Option<String>,
crf: Option<u8>,
format: Option<String>,
transparent: bool,
hardware_acceleration: bool,
) -> Result<()> {
if frame.is_none() {
let container = format
.as_deref()
.unwrap_or_else(|| output.extension().and_then(|e| e.to_str()).unwrap_or("mp4"));
encode::check_codec_container(codec.as_deref().unwrap_or("h264"), container)?;
}
let start = std::time::Instant::now();
if frame.is_some() {
engine::preload::preload_scenario_assets(&scenario)?;
} else if !scenario.fonts.is_empty() {
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)?;
}
}
if let Some(frame_num) = frame {
let png_path = if output.extension().map(|e| e == "mp4").unwrap_or(false) {
output.with_extension("png")
} else {
output.to_path_buf()
};
render_single_frame(&scenario, frame_num, &png_path)?;
if !quiet {
eprintln!("Frame {} saved to {}", frame_num, png_path.display());
}
} else {
let fmt = format
.as_deref()
.unwrap_or_else(|| output.extension().and_then(|e| e.to_str()).unwrap_or("mp4"));
let config = &scenario.video;
let output_str = output
.to_str()
.ok_or_else(|| RustmotionError::NonUtf8Path {
path: output.to_string_lossy().into_owned(),
})?;
let make_tui = |codec_label: &str| -> Option<tui::TuiProgress> {
if quiet {
return None;
}
let total = encode::build_frame_tasks(&scenario).len() as u32;
tui::TuiProgress::new(
total,
output_str,
config.width,
config.height,
config.fps,
codec_label,
)
.ok()
};
if hardware_acceleration && matches!(fmt, "png-seq" | "gif" | "raw") && !quiet {
eprintln!(
"Warning: --hardware-acceleration has no effect on `{fmt}` output — only the \
ffmpeg-driven video encode step (mp4/webm/mov) can use a hardware encoder."
);
}
match fmt {
"png-seq" => {
let mut tui = make_tui("png");
let mut cb = |p: encode::EncodeProgress| {
if let (Some(ref mut t), encode::EncodeProgress::Rendering(c, _)) =
(&mut tui, &p)
{
t.set_progress(*c);
}
};
encode::encode_png_sequence(
&scenario,
output_str,
quiet,
transparent,
Some(&mut cb),
)?;
if let Some(ref mut t) = tui {
t.finish("Done!");
}
}
"gif" => {
let mut tui = make_tui("gif");
let mut cb = |p: encode::EncodeProgress| {
if let (Some(ref mut t), encode::EncodeProgress::Rendering(c, _)) =
(&mut tui, &p)
{
t.set_progress(*c);
}
};
encode::encode_gif(&scenario, output_str, quiet, Some(&mut cb))?;
if let Some(ref mut t) = tui {
t.finish("Done!");
}
}
"raw" => {
encode::encode_raw_stdout(&scenario, false)?;
}
_ => {
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");
if ffmpeg_available {
let mut tui = make_tui(codec_str);
let mut cb = |p: encode::EncodeProgress| {
if let Some(ref mut t) = tui {
match &p {
encode::EncodeProgress::Rendering(c, _) => t.set_progress(*c),
encode::EncodeProgress::Muxing => t.set_status("Muxing"),
_ => {}
}
}
};
encode::video::encode_with_ffmpeg_hw(
&scenario,
output_str,
quiet,
codec_str,
crf,
transparent,
hardware_acceleration,
Some(&mut cb),
)?;
if let Some(ref mut t) = tui {
t.finish("Done!");
}
} 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."
);
}
let mut tui = make_tui("h264");
let mut cb = |p: encode::EncodeProgress| {
if let Some(ref mut t) = tui {
match &p {
encode::EncodeProgress::Rendering(c, _) => t.set_progress(*c),
encode::EncodeProgress::Encoding(c, _) => {
t.set_status("Encoding H.264");
t.set_progress(*c);
}
encode::EncodeProgress::Muxing => t.set_status("Muxing to MP4"),
}
}
};
encode::encode_video(&scenario, output_str, quiet, Some(&mut cb))?;
if let Some(ref mut t) = tui {
t.finish("Done!");
}
}
}
}
}
let elapsed = start.elapsed();
if let Some(OutputFormat::Json) = output_format {
let result = serde_json::json!({
"status": "success",
"output": output.to_string_lossy(),
"duration_ms": elapsed.as_millis(),
});
println!("{}", serde_json::to_string(&result)?);
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub fn cmd_watch(
input: &PathBuf,
output: &Path,
frame: Option<u32>,
output_format: Option<&OutputFormat>,
quiet: bool,
codec: Option<String>,
crf: Option<u8>,
format: Option<String>,
transparent: bool,
hardware_acceleration: bool,
no_validate: bool,
lenient: bool,
strict_anim: bool,
strict_attrs: bool,
) -> Result<()> {
use notify::{RecursiveMode, Watcher};
use std::sync::mpsc;
if frame.is_none() {
let container = format
.as_deref()
.unwrap_or_else(|| output.extension().and_then(|e| e.to_str()).unwrap_or("mp4"));
encode::check_codec_container(codec.as_deref().unwrap_or("h264"), container)?;
}
let fmt = format
.as_deref()
.unwrap_or_else(|| output.extension().and_then(|e| e.to_str()).unwrap_or("mp4"));
let use_ffmpeg = codec.as_deref().is_some_and(|c| c != "h264")
|| matches!(fmt, "webm" | "mov")
|| transparent
|| hardware_acceleration;
let can_incremental =
frame.is_none() && !matches!(fmt, "png-seq" | "gif" | "raw") && !use_ffmpeg;
let output_str = output
.to_str()
.ok_or_else(|| RustmotionError::NonUtf8Path {
path: output.to_string_lossy().into_owned(),
})?
.to_string();
let mut prev_segments: Option<Vec<encode::SceneSegment>> = None;
let mut prev_config_hash: Option<u64> = None;
let codec_label = codec.as_deref().unwrap_or("h264");
let mut tui_watch: Option<tui::TuiWatch> = None;
let mut initial_includes: Vec<PathBuf> = Vec::new();
match load_for_watch(input, no_validate, lenient, strict_anim, strict_attrs) {
Ok(scenario) => {
initial_includes = scenario.included_paths.clone();
if !scenario.fonts.is_empty() {
engine::renderer::load_custom_fonts(&scenario.fonts);
}
if !quiet {
tui_watch = tui::TuiWatch::new(
&input.display().to_string(),
&output.display().to_string(),
scenario.video.width,
scenario.video.height,
scenario.video.fps,
codec_label,
)
.ok();
}
if can_incremental {
let config_hash = encode::hash_video_config(&scenario.video);
if let Some(ref mut tui) = tui_watch {
tui.set_phase(tui::WatchPhase::InitialRender);
}
let mut encoding_started = false;
let mut cb = |progress: encode::EncodeProgress| {
if let Some(ref mut tui) = tui_watch {
match progress {
encode::EncodeProgress::Rendering(current, total) => {
tui.set_frame_progress(current, total)
}
encode::EncodeProgress::Encoding(current, total) => {
if !encoding_started {
tui.set_phase(tui::WatchPhase::Encoding);
encoding_started = true;
}
tui.set_frame_progress(current, total);
}
encode::EncodeProgress::Muxing => {
tui.set_phase(tui::WatchPhase::Muxing)
}
}
}
};
match encode::encode_video_incremental(
&scenario,
&output_str,
quiet,
None,
Some(&mut cb),
) {
Ok(segments) => {
prev_segments = Some(segments);
prev_config_hash = Some(config_hash);
if let Some(ref mut tui) = tui_watch {
tui.finish_render();
}
}
Err(e) => eprintln!("Render error: {}", e),
}
} else {
clear_all_media_caches();
if let Err(e) = cmd_render(
scenario,
output,
frame,
output_format,
quiet,
codec.clone(),
crf,
format.clone(),
transparent,
hardware_acceleration,
) {
eprintln!("Render error: {}", e);
}
if let Some(ref mut tui) = tui_watch {
tui.finish_render();
}
}
}
Err(e) => eprintln!("Load error: {}", e),
}
let (tx, rx) = mpsc::channel();
let mut watcher = notify::recommended_watcher(
move |res: std::result::Result<notify::Event, notify::Error>| {
if let Ok(event) = res {
if event.kind.is_modify() || event.kind.is_create() {
let _ = tx.send(());
}
}
},
)?;
watcher.watch(input.as_ref(), RecursiveMode::NonRecursive)?;
let mut watched_includes: Vec<PathBuf> = initial_includes;
for inc in &watched_includes {
let _ = watcher.watch(inc.as_ref(), RecursiveMode::NonRecursive);
}
let mut last_err_repr: Option<String> = None;
let mut consecutive_err_count: u32 = 0;
let mut suppressed = false;
loop {
rx.recv().map_err(|_| RustmotionError::WatcherClosed)?;
std::thread::sleep(std::time::Duration::from_millis(100));
while rx.try_recv().is_ok() {}
match load_for_watch(input, no_validate, lenient, strict_anim, strict_attrs) {
Ok(scenario) => {
clear_all_media_caches();
if consecutive_err_count > 0 && suppressed {
eprintln!("Recovered from previous errors.");
}
last_err_repr = None;
consecutive_err_count = 0;
suppressed = false;
for old in &watched_includes {
let _ = watcher.unwatch(old.as_ref());
}
watched_includes = scenario.included_paths.clone();
for inc in &watched_includes {
let _ = watcher.watch(inc.as_ref(), RecursiveMode::NonRecursive);
}
if can_incremental {
let config_hash = encode::hash_video_config(&scenario.video);
let use_prev = if prev_config_hash == Some(config_hash) {
prev_segments.as_deref()
} else {
None
};
let view0_scenes = scenario.views.first().map(|v| &v.scenes[..]).unwrap_or(&[]);
let num_scenes = view0_scenes.len();
let scene_hashes: Vec<u64> = view0_scenes
.iter()
.map(encode::hash_video_config_scene)
.collect();
let changed = if let Some(prev) = use_prev {
if prev.len() == num_scenes {
(0..num_scenes)
.filter(|&i| {
let hash_changed = scene_hashes[i] != prev[i].scene_hash;
let next_changed = if i + 1 < num_scenes {
scene_hashes[i + 1] != prev[i + 1].scene_hash
&& view0_scenes[i + 1].transition.is_some()
} else {
false
};
hash_changed || next_changed
})
.count()
} else {
num_scenes
}
} else {
num_scenes
};
if let Some(ref mut tui) = tui_watch {
tui.set_phase(tui::WatchPhase::Rerendering {
changed,
total: num_scenes,
});
}
let mut encoding_started = false;
let mut cb = |progress: encode::EncodeProgress| {
if let Some(ref mut tui) = tui_watch {
match progress {
encode::EncodeProgress::Rendering(current, total) => {
tui.set_frame_progress(current, total)
}
encode::EncodeProgress::Encoding(current, total) => {
if !encoding_started {
tui.set_phase(tui::WatchPhase::Encoding);
encoding_started = true;
}
tui.set_frame_progress(current, total);
}
encode::EncodeProgress::Muxing => {
tui.set_phase(tui::WatchPhase::Muxing)
}
}
}
};
match encode::encode_video_incremental(
&scenario,
&output_str,
quiet,
use_prev,
Some(&mut cb),
) {
Ok(segments) => {
prev_segments = Some(segments);
prev_config_hash = Some(config_hash);
if let Some(ref mut tui) = tui_watch {
tui.finish_render();
}
}
Err(e) => eprintln!("Render error: {}", e),
}
} else {
if let Err(e) = cmd_render(
scenario,
output,
frame,
output_format,
quiet,
codec.clone(),
crf,
format.clone(),
transparent,
hardware_acceleration,
) {
eprintln!("Render error: {}", e);
}
if let Some(ref mut tui) = tui_watch {
tui.finish_render();
}
}
}
Err(e) => {
let repr = e.to_string();
if last_err_repr.as_deref() == Some(&repr) {
consecutive_err_count += 1;
if consecutive_err_count > 3 {
if !suppressed {
eprintln!("Load error: {} (further identical errors suppressed)", repr);
suppressed = true;
}
} else {
eprintln!("Load error: {}", repr);
}
} else {
eprintln!("Load error: {}", repr);
last_err_repr = Some(repr);
consecutive_err_count = 1;
suppressed = false;
}
}
}
}
}
fn render_single_frame(
scenario: &ResolvedScenario,
frame_num: u32,
output: &PathBuf,
) -> Result<()> {
let config = &scenario.video;
let tasks = encode::build_frame_tasks(scenario);
let total = tasks.len() as u32;
let task = tasks
.get(frame_num as usize)
.ok_or(RustmotionError::FrameOutOfRange {
frame: frame_num,
total,
})?;
let rgba = encode::render_frame_task_scaled(config, scenario, task, 1.0)?;
let img = image::RgbaImage::from_raw(config.width, config.height, rgba)
.ok_or(RustmotionError::PixelImage)?;
img.save(output)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use rustmotion_core::engine::renderer::{asset_cache, gif_cache, video_frame_cache};
fn unique_marker(label: &str) -> String {
format!(
"rustmotion-audit-ws-d-rm25-{label}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock before UNIX epoch")
.as_nanos()
)
}
#[test]
fn clear_all_media_caches_clears_gif_and_video_caches_not_just_images() {
let marker = unique_marker("clear-all");
let mut surface = skia_safe::surfaces::raster_n32_premul((1, 1)).expect("raster surface");
asset_cache().insert(marker.clone(), surface.image_snapshot());
gif_cache().insert(
marker.clone(),
std::sync::Arc::new((Vec::new(), Vec::new(), 0.0)),
);
video_frame_cache().insert(marker.clone(), std::sync::Arc::new(Vec::new()));
assert!(asset_cache().contains_key(&marker));
assert!(gif_cache().contains_key(&marker));
assert!(video_frame_cache().contains_key(&marker));
clear_all_media_caches();
assert!(!asset_cache().contains_key(&marker));
assert!(
!gif_cache().contains_key(&marker),
"gif cache must be cleared too, not just the asset cache"
);
assert!(
!video_frame_cache().contains_key(&marker),
"video frame cache must be cleared too, not just the asset cache"
);
}
}