use super::{
encode,
frames::{self, FrameReporter, FramesOutcome},
metrics, params,
};
use crate::{
ActivityModel, MainWindow, TiltModel, TiltVideoExportModel,
bridge::{
export_thread::{RemoteSelection, SceneSnapshot},
render_thread::RenderContext,
},
gui::progress_eta::{EtaEstimator, format_eta},
settings::LocalComputeTarget,
};
use indicatrix::color::ColorSpace;
use slint::ComponentHandle;
use std::{
path::{Path, PathBuf},
sync::{Arc, Mutex, atomic::AtomicBool},
time::Instant,
};
pub struct VideoExportRequest {
pub scene: SceneSnapshot,
pub axis_index: usize,
pub start_deg: f64,
pub end_deg: f64,
pub step_deg: f64,
pub total_frames: usize,
pub fps: u32,
pub width: u32,
pub height: u32,
pub samples_per_pixel: u32,
pub color_space: ColorSpace,
pub out_dir: PathBuf,
pub out_name: String,
pub selection: metrics::MetricSelection,
pub curves: metrics::MetricCurves,
pub keep_frames: bool,
pub remote: RemoteSelection,
pub 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,
) {
let pausing = pauses_viewport(&request);
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,
pausing,
&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>>, pausing: bool) {
if pausing {
RenderContext::lock(render_ctx).export_active_count -= 1;
}
}
pub(super) const fn pauses_viewport(request: &VideoExportRequest) -> bool {
request.remote.compute_target.pauses_live_viewport()
}
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 pausing = pauses_viewport(request);
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, pausing, activity_id);
return;
}
FramesOutcome::Failed { message, .. } => {
report_failure(ui_weak, render_ctx, pausing, &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,
pausing,
&request.out_dir,
&outcome,
activity_id,
);
}
fn render_all_frames(
ui_weak: &slint::Weak<MainWindow>,
request: &VideoExportRequest,
cancel: &AtomicBool,
activity_id: i32,
) -> FramesOutcome {
let mut reporter = SlintFrameReporter {
ui_weak: ui_weak.clone(),
activity_id,
eta: EtaEstimator::default(),
};
frames::render_frames(request, false, cancel, &mut reporter)
}
struct SlintFrameReporter {
ui_weak: slint::Weak<MainWindow>,
activity_id: i32,
eta: EtaEstimator,
}
impl FrameReporter for SlintFrameReporter {
fn frame_progress(&mut self, index: usize, total: usize, fraction: f32, note: Option<String>) {
report_frame_progress(
&self.ui_weak,
index,
total,
fraction,
note,
self.activity_id,
);
}
fn frame_saved(&mut self, _index: usize, frames_done: usize, total: usize, frame_secs: f32) {
let now = Instant::now();
self.eta
.observe(now, frames_done as f64 / total.max(1) as f64);
report_progress(
&self.ui_weak,
frames_done,
total,
frame_secs,
format_eta(self.eta.eta(now)),
self.activity_id,
);
}
}
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>>,
pausing: bool,
activity_id: i32,
) {
finish_video_export(render_ctx, pausing);
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());
if !ui.global::<TiltModel>().get_dialog_open() {
crate::gui::show_toast(&ui, "Video export cancelled.", "info");
}
ui.global::<ActivityModel>()
.invoke_finish_external(activity_id);
});
}
fn report_failure(
ui_weak: &slint::Weak<MainWindow>,
render_ctx: &Arc<Mutex<RenderContext>>,
pausing: bool,
message: &str,
activity_id: i32,
) {
finish_video_export(render_ctx, pausing);
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);
crate::gui::show_toast(&ui, &message, "error");
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>>,
pausing: bool,
out_dir: &Path,
outcome: &encode::EncodeOutcome,
activity_id: i32,
) {
finish_video_export(render_ctx, pausing);
let (_, message) = frames::encode_outcome_message(outcome, out_dir);
let toast_kind = if matches!(outcome, encode::EncodeOutcome::Mp4(_)) {
"success"
} else {
"info"
};
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);
crate::gui::show_toast(&ui, &message, toast_kind);
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::{super::render, *};
use crate::bridge::export_thread::AccumulationCarry;
use indicatrix::renderer::gpu_backend::GpuBackend;
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");
};
frames::save_png_atomic(&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];
frames::save_png_atomic(&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);
}
}