use super::{encode, metrics, overlay, params, render};
use crate::{
ActivityModel, MainWindow, TiltVideoExportModel, bridge::export_thread::SceneSnapshot,
};
use indicatrix::color::ColorSpace;
use slint::ComponentHandle;
use std::{
path::{Path, PathBuf},
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
time::Instant,
};
pub(super) struct VideoExportRequest {
pub(super) scene: SceneSnapshot,
pub(super) axis_index: usize,
pub(super) start_deg: f64,
pub(super) end_deg: f64,
pub(super) step_deg: f64,
pub(super) total_frames: usize,
pub(super) fps: u32,
pub(super) width: u32,
pub(super) height: u32,
pub(super) samples_per_pixel: u32,
pub(super) color_space: ColorSpace,
pub(super) out_dir: PathBuf,
pub(super) out_name: String,
pub(super) selection: metrics::MetricSelection,
pub(super) curves: metrics::MetricCurves,
pub(super) keep_frames: bool,
}
pub(super) fn spawn(
ui_weak: slint::Weak<MainWindow>,
request: VideoExportRequest,
cancel: Arc<AtomicBool>,
activity_id: i32,
) {
std::thread::spawn(move || run(&ui_weak, &request, &cancel, activity_id));
}
fn run(
ui_weak: &slint::Weak<MainWindow>,
request: &VideoExportRequest,
cancel: &AtomicBool,
activity_id: i32,
) {
let digits = params::frame_number_digits(request.total_frames);
let Some(frame_paths) = render_all_frames(ui_weak, request, cancel, activity_id) else {
report_cancelled(ui_weak, activity_id);
return;
};
let outcome = encode::encode(
&request.out_dir,
&frame_paths,
digits,
request.fps,
&request.out_name,
);
if !request.keep_frames
&& matches!(
outcome,
encode::EncodeOutcome::Mp4(_) | encode::EncodeOutcome::Gif(_)
)
{
for path in &frame_paths {
let _ = std::fs::remove_file(path);
}
}
report_done(ui_weak, &request.out_dir, &outcome, activity_id);
}
fn render_all_frames(
ui_weak: &slint::Weak<MainWindow>,
request: &VideoExportRequest,
cancel: &AtomicBool,
activity_id: i32,
) -> Option<Vec<PathBuf>> {
let angles = params::frame_angles(request.start_deg, request.end_deg, request.step_deg);
let mut frame_paths = Vec::with_capacity(request.total_frames);
for (index, &tilt_deg) in angles.iter().enumerate() {
if cancel.load(Ordering::Relaxed) {
return None;
}
let started = Instant::now();
let (cam_yaw, cam_pitch) = crate::gui::tilt::tilt_hover_preview::camera_pose_for_axis_tilt(
request.axis_index,
tilt_deg,
);
let mut rgba = render::render_frame_rgba(
&request.scene,
request.width,
request.height,
request.samples_per_pixel,
cam_yaw,
cam_pitch,
request.color_space,
);
if request.selection.count() > 0 {
let readings =
metrics::readings_for_frame(request.selection, &request.curves, tilt_deg);
overlay::draw_overlay(&mut rgba, request.width, request.height, &readings);
}
let path = request
.out_dir
.join(params::frame_file_name(index + 1, request.total_frames));
if let Err(e) = save_png(&path, request.width, request.height, &rgba) {
report_failure(
ui_weak,
&format!("Failed to write {}: {e}", path.display()),
activity_id,
);
return None;
}
frame_paths.push(path);
report_progress(
ui_weak,
index + 1,
request.total_frames,
started.elapsed().as_secs_f32(),
activity_id,
);
}
Some(frame_paths)
}
fn save_png(path: &Path, width: u32, height: u32, rgba: &[u8]) -> Result<(), String> {
let image = image::RgbaImage::from_raw(width, height, rgba.to_vec())
.ok_or_else(|| "pixel buffer size did not match dimensions".to_string())?;
image.save(path).map_err(|e| e.to_string())
}
fn report_progress(
ui_weak: &slint::Weak<MainWindow>,
done: usize,
total: usize,
last_frame_secs: f32,
activity_id: i32,
) {
let ui_weak = ui_weak.clone();
let _ = ui_weak.upgrade_in_event_loop(move |ui| {
let model = ui.global::<TiltVideoExportModel>();
model.set_current_frame(done as i32);
let fraction = params::export_progress_fraction(done, total);
model.set_progress(fraction);
model.set_last_frame_seconds(last_frame_secs);
ui.global::<ActivityModel>()
.invoke_progress_external(activity_id, fraction);
});
}
fn report_cancelled(ui_weak: &slint::Weak<MainWindow>, activity_id: i32) {
let ui_weak = ui_weak.clone();
let _ = ui_weak.upgrade_in_event_loop(move |ui| {
let model = ui.global::<TiltVideoExportModel>();
model.set_is_exporting(false);
model.set_has_error(false);
model.set_status_message("Video export cancelled.".into());
ui.global::<ActivityModel>()
.invoke_finish_external(activity_id);
});
}
fn report_failure(ui_weak: &slint::Weak<MainWindow>, message: &str, activity_id: i32) {
let message = message.to_string();
let ui_weak = ui_weak.clone();
let _ = ui_weak.upgrade_in_event_loop(move |ui| {
let model = ui.global::<TiltVideoExportModel>();
model.set_is_exporting(false);
model.set_has_error(true);
model.set_status_message(message.into());
ui.global::<ActivityModel>()
.invoke_finish_external(activity_id);
});
}
fn report_done(
ui_weak: &slint::Weak<MainWindow>,
out_dir: &Path,
outcome: &encode::EncodeOutcome,
activity_id: i32,
) {
let message = match outcome {
encode::EncodeOutcome::Mp4(path) => format!("Exported {}", path.display()),
encode::EncodeOutcome::Gif(path) => {
format!(
"ffmpeg not found on PATH -- exported an animated GIF instead: {}",
path.display()
)
}
encode::EncodeOutcome::FramesOnly { readme } => format!(
"No video muxer available -- left the frame sequence in {} (see {})",
out_dir.display(),
readme.display()
),
};
let ui_weak = ui_weak.clone();
let _ = ui_weak.upgrade_in_event_loop(move |ui| {
let model = ui.global::<TiltVideoExportModel>();
model.set_is_exporting(false);
model.set_has_error(false);
model.set_progress(1.0);
model.set_status_message(message.into());
ui.global::<ActivityModel>()
.invoke_finish_external(activity_id);
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bridge::render_thread::RenderContext;
use indicatrix::color::ColorSpace;
use std::sync::Mutex;
#[test]
fn a_run_stopped_partway_through_leaves_only_the_frames_already_written_and_no_video() {
let scene = SceneSnapshot::capture(&Mutex::new(RenderContext::default()));
let total_frames = 3;
let dir =
std::env::temp_dir().join(format!("tilt_video_cancel_test_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(params::frame_file_name(1, total_frames));
let rgba = render::render_frame_rgba(&scene, 8, 8, 1, 0.0, 1.5, ColorSpace::Srgb);
save_png(&path, 8, 8, &rgba).unwrap();
let entries: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(Result::ok)
.collect();
assert_eq!(
entries.len(),
1,
"only the one frame written before the simulated cancel should exist"
);
assert!(
entries
.iter()
.all(|e| e.path().extension().and_then(|ext| ext.to_str()) != Some("mp4")),
"no .mp4 must exist for a run that stopped before encoding was ever reached"
);
assert!(
entries
.iter()
.all(|e| e.path().extension().and_then(|ext| ext.to_str()) != Some("gif")),
"no .gif must exist for a run that stopped before encoding was ever reached"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn save_png_round_trips_a_frame_to_disk() {
let dir =
std::env::temp_dir().join(format!("tilt_video_save_png_test_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("frame_001.png");
let rgba = vec![255u8; 4 * 4 * 4];
save_png(&path, 4, 4, &rgba).unwrap();
let decoded = image::open(&path).unwrap();
assert_eq!((decoded.width(), decoded.height()), (4, 4));
let _ = std::fs::remove_dir_all(&dir);
}
}