use super::{encode, metrics, overlay, params, render};
use crate::{
ActivityModel, MainWindow, TiltVideoExportModel,
bridge::{
export_thread::{AccumulationCarry, RemoteSelection, SceneSnapshot},
render_thread::RenderContext,
},
gui::progress_eta::{EtaEstimator, format_eta},
settings::LocalComputeTarget,
};
use indicatrix::{color::ColorSpace, renderer::gpu_backend::GpuBackend};
use slint::ComponentHandle;
use std::{
path::{Path, PathBuf},
sync::{
Arc, Mutex,
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) remote: RemoteSelection,
pub(super) local_compute: LocalComputeTarget,
}
pub(super) fn spawn(
ui_weak: slint::Weak<MainWindow>,
render_ctx: Arc<Mutex<RenderContext>>,
request: VideoExportRequest,
cancel: Arc<AtomicBool>,
activity_id: i32,
) {
std::thread::spawn(move || {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
run(&ui_weak, &render_ctx, &request, &cancel, activity_id);
}));
if let Err(payload) = result {
let message = panic_message(&*payload);
report_failure(
&ui_weak,
&render_ctx,
&format!("Video export failed unexpectedly: {message}"),
activity_id,
);
}
});
}
fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
payload
.downcast_ref::<&str>()
.map(|s| (*s).to_string())
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "unknown panic".to_string())
}
fn finish_video_export(render_ctx: &Arc<Mutex<RenderContext>>) {
RenderContext::lock(render_ctx).export_active_count -= 1;
}
enum FramesOutcome {
Done(Vec<PathBuf>),
Cancelled,
Failed(String),
}
fn run(
ui_weak: &slint::Weak<MainWindow>,
render_ctx: &Arc<Mutex<RenderContext>>,
request: &VideoExportRequest,
cancel: &AtomicBool,
activity_id: i32,
) {
let digits = params::frame_number_digits(request.total_frames);
let frame_paths = match render_all_frames(ui_weak, request, cancel, activity_id) {
FramesOutcome::Done(paths) => paths,
FramesOutcome::Cancelled => {
report_cancelled(ui_weak, render_ctx, activity_id);
return;
}
FramesOutcome::Failed(message) => {
report_failure(ui_weak, render_ctx, &message, 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, render_ctx, &request.out_dir, &outcome, activity_id);
}
fn render_all_frames(
ui_weak: &slint::Weak<MainWindow>,
request: &VideoExportRequest,
cancel: &AtomicBool,
activity_id: i32,
) -> FramesOutcome {
let angles = params::frame_angles(request.start_deg, request.end_deg, request.step_deg);
let mut frame_paths = Vec::with_capacity(request.total_frames);
let gpu = match request.local_compute {
LocalComputeTarget::Cpu => GpuBackend::disabled(),
LocalComputeTarget::CpuGpu | LocalComputeTarget::Gpu => GpuBackend::acquire(),
};
let mut carry = AccumulationCarry::default();
let mut eta = EtaEstimator::default();
let config = render::VideoComputeConfig {
remote: request.remote.clone(),
local_compute: request.local_compute,
};
for (index, &tilt_deg) in angles.iter().enumerate() {
if cancel.load(Ordering::Relaxed) {
return FramesOutcome::Cancelled;
}
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 ui_weak_progress = ui_weak.clone();
let total_frames = request.total_frames;
let outcome = render::render_frame_rgba(
&request.scene,
request.width,
request.height,
request.samples_per_pixel,
cam_yaw,
cam_pitch,
request.color_space,
&config,
&gpu,
&mut carry,
cancel,
move |progress| {
report_frame_progress(
&ui_weak_progress,
index,
total_frames,
progress.fraction,
progress.note,
activity_id,
);
},
);
let mut rgba = match outcome {
render::FrameOutcome::Rendered(rgba) => rgba,
render::FrameOutcome::Cancelled => return FramesOutcome::Cancelled,
render::FrameOutcome::Failed(message) => {
return FramesOutcome::Failed(format!(
"Frame {} of {}: {message}",
index + 1,
request.total_frames
));
}
};
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) {
return FramesOutcome::Failed(format!("Failed to write {}: {e}", path.display()));
}
frame_paths.push(path);
let now = Instant::now();
eta.observe(now, (index + 1) as f64 / request.total_frames.max(1) as f64);
report_progress(
ui_weak,
index + 1,
request.total_frames,
started.elapsed().as_secs_f32(),
format_eta(eta.eta(now)),
activity_id,
);
}
FramesOutcome::Done(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,
eta_text: String,
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);
model.set_eta_text(eta_text.into());
ui.global::<ActivityModel>()
.invoke_progress_external(activity_id, fraction);
});
}
fn report_frame_progress(
ui_weak: &slint::Weak<MainWindow>,
frame_index: usize,
total_frames: usize,
frame_fraction: f32,
note: Option<String>,
activity_id: i32,
) {
let overall_fraction = (frame_index as f32 + frame_fraction) / (total_frames.max(1) as f32);
let ui_weak = ui_weak.clone();
let _ = ui_weak.upgrade_in_event_loop(move |ui| {
let model = ui.global::<TiltVideoExportModel>();
model.set_progress(overall_fraction);
if let Some(note) = note {
model.set_status_message(note.into());
}
ui.global::<ActivityModel>()
.invoke_progress_external(activity_id, overall_fraction);
});
}
fn report_cancelled(
ui_weak: &slint::Weak<MainWindow>,
render_ctx: &Arc<Mutex<RenderContext>>,
activity_id: i32,
) {
finish_video_export(render_ctx);
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());
model.set_eta_text(String::new().into());
ui.global::<ActivityModel>()
.invoke_finish_external(activity_id);
});
}
fn report_failure(
ui_weak: &slint::Weak<MainWindow>,
render_ctx: &Arc<Mutex<RenderContext>>,
message: &str,
activity_id: i32,
) {
finish_video_export(render_ctx);
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());
model.set_eta_text(String::new().into());
ui.global::<ActivityModel>()
.invoke_finish_external(activity_id);
});
}
fn report_done(
ui_weak: &slint::Weak<MainWindow>,
render_ctx: &Arc<Mutex<RenderContext>>,
out_dir: &Path,
outcome: &encode::EncodeOutcome,
activity_id: i32,
) {
finish_video_export(render_ctx);
let message = match outcome {
encode::EncodeOutcome::Mp4(path) => format!("Exported {}", path.display()),
encode::EncodeOutcome::Gif {
path,
mp4_failure_reason,
} => mp4_failure_reason.as_ref().map_or_else(
|| {
format!(
"ffmpeg not found on PATH -- exported an animated GIF instead: {}",
path.display()
)
},
|reason| {
format!(
"ffmpeg could not produce an MP4 ({reason}) -- 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());
model.set_eta_text(String::new().into());
ui.global::<ActivityModel>()
.invoke_finish_external(activity_id);
});
}
#[cfg(test)]
mod tests {
use super::*;
fn test_config() -> render::VideoComputeConfig {
render::VideoComputeConfig {
remote: RemoteSelection::local_only(),
local_compute: LocalComputeTarget::Cpu,
}
}
#[test]
fn a_run_stopped_partway_through_leaves_only_the_frames_already_written_and_no_video() {
let scene = SceneSnapshot::capture(&Mutex::new(RenderContext::default()))
.expect("Diamond resolves");
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 config = test_config();
let gpu = GpuBackend::disabled();
let mut carry = AccumulationCarry::default();
let cancel = AtomicBool::new(false);
let outcome = render::render_frame_rgba(
&scene,
8,
8,
1,
0.0,
1.5,
ColorSpace::Srgb,
&config,
&gpu,
&mut carry,
&cancel,
|_| {},
);
let render::FrameOutcome::Rendered(rgba) = outcome else {
panic!("expected a rendered frame");
};
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);
}
}