mod encode;
mod metrics;
mod overlay;
mod params;
mod render;
mod run;
mod template;
use crate::{
ActivityModel, ExportModel, LibraryModel, MainWindow, TiltModel, TiltVideoExportModel,
bridge::{
export_thread::{SceneSnapshot, filename_template::TemplateContext},
render_thread::RenderContext,
},
};
use indicatrix::color::{ColorSpace, metrics::PROFILE_AZIMUTHS_DEG};
use slint::{ComponentHandle, Model};
use std::{
path::PathBuf,
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
};
type CurrentExportRun = Arc<Mutex<Option<(i32, Arc<AtomicBool>)>>>;
pub(in crate::gui) fn setup_video_export_callback(
ui: &MainWindow,
render_ctx: &Arc<Mutex<RenderContext>>,
) {
let current_run: CurrentExportRun = Arc::new(Mutex::new(None));
ui.global::<TiltVideoExportModel>()
.on_frame_count(|start, end, step| {
params::frame_count(f64::from(start), f64::from(end), f64::from(step)) as i32
});
ui.global::<TiltVideoExportModel>()
.on_parse_step_deg(|raw| {
raw.trim()
.parse::<f64>()
.map_or(1.0, params::clamp_step_deg) as f32
});
ui.global::<TiltVideoExportModel>()
.on_custom_resolution_index(|| params::CUSTOM_RESOLUTION_INDEX);
let ui_weak_preview = ui.as_weak();
let render_ctx_preview = render_ctx.clone();
ui.global::<TiltVideoExportModel>()
.on_preview_video_folder_name(move |raw_template, axis_index| {
let Some(ui) = ui_weak_preview.upgrade() else {
return template::resolve_folder_name(
&raw_template,
&sample_template_context(),
&sample_template_extras(),
)
.into();
};
let (ctx, extras) = live_template_context(&ui, &render_ctx_preview, axis_index);
template::resolve_folder_name(&raw_template, &ctx, &extras).into()
});
let ui_weak_start = ui.as_weak();
let render_ctx_start = render_ctx.clone();
let current_run_start = current_run.clone();
ui.global::<TiltVideoExportModel>()
.on_start_video_export(move |axis_index: i32| {
let Some(ui) = ui_weak_start.upgrade() else {
return;
};
handle_start_video_export(&ui, &render_ctx_start, ¤t_run_start, axis_index);
});
let current_run_cancel = current_run.clone();
ui.global::<TiltVideoExportModel>()
.on_cancel_video_export(move || {
if let Some((_, cancel)) = current_run
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_ref()
{
cancel.store(true, Ordering::Relaxed);
}
});
ui.global::<ActivityModel>().on_external_cancel(move |id| {
let run = current_run_cancel
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some((running_id, cancel)) = run.as_ref()
&& *running_id == id
{
cancel.store(true, Ordering::Relaxed);
}
});
}
fn handle_start_video_export(
ui: &MainWindow,
render_ctx: &Arc<Mutex<RenderContext>>,
current_run: &CurrentExportRun,
axis_index: i32,
) {
let model = ui.global::<TiltVideoExportModel>();
if model.get_is_exporting() {
return;
}
let render_ctx = Arc::clone(render_ctx);
let current_run = Arc::clone(current_run);
resolve_export_directory_then(ui, move |ui, export_dir| {
let model = ui.global::<TiltVideoExportModel>();
let Some(export_dir) = export_dir else {
return;
};
let request = match build_request(ui, &render_ctx, axis_index, &export_dir) {
Ok(request) => request,
Err(message) => {
model.set_has_error(true);
model.set_status_message(message.into());
return;
}
};
let cancel = Arc::new(AtomicBool::new(false));
let activity_id = ui.global::<ActivityModel>().invoke_start_external(
"tilt_video".into(),
"Tilt video export".into(),
true,
);
*current_run
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) =
Some((activity_id, cancel.clone()));
model.set_is_exporting(true);
model.set_has_error(false);
model.set_status_message("Rendering...".into());
model.set_current_frame(0);
model.set_total_frames(request.total_frames as i32);
model.set_progress(0.0);
run::spawn(ui.as_weak(), request, cancel, activity_id);
});
}
fn build_request(
ui: &MainWindow,
render_ctx: &Arc<Mutex<RenderContext>>,
axis_index: i32,
export_dir: &std::path::Path,
) -> Result<run::VideoExportRequest, String> {
let model = ui.global::<TiltVideoExportModel>();
let start_deg = params::clamp_angle_deg(f64::from(model.get_start_angle_deg()));
let end_deg = params::clamp_angle_deg(f64::from(model.get_end_angle_deg()));
let step_deg = params::clamp_step_deg(f64::from(model.get_step_deg()));
let total_frames = params::frame_count(start_deg, end_deg, step_deg);
if total_frames == 0 {
return Err("The angle range/step must produce at least one frame.".to_string());
}
if total_frames > params::CONFIRM_FRAME_THRESHOLD {
tracing::info!("Starting a large tilt video export: {total_frames} frames");
}
let curves = read_axis_curves(ui, axis_index)
.ok_or_else(|| "The tilt sweep hasn't finished computing yet.".to_string())?;
let fps = params::resolve_fps(model.get_fps_index());
let (width, height) = params::resolve_resolution(
model.get_resolution_preset(),
model.get_custom_width(),
model.get_custom_height(),
);
let samples_per_pixel = model.get_sample_exponent_raw().round().exp2().round() as u32;
let max_bounces = params::resolve_max_bounces(model.get_selected_bounce_index());
let color_space = crate::gui::color_space_from_index(model.get_selected_color_space_index());
let mut scene = SceneSnapshot::capture(render_ctx);
scene.max_bounces = max_bounces;
let selection = metrics::MetricSelection {
brilliance: model.get_show_values() && model.get_metric_brilliance(),
windowing: model.get_show_values() && model.get_metric_windowing(),
extinction: model.get_show_values() && model.get_metric_extinction(),
tilt_brilliance: model.get_show_values() && model.get_metric_tilt_brilliance(),
angle: model.get_show_values() && model.get_metric_angle(),
};
let detail = ui.global::<LibraryModel>().get_current_detail();
let (template_ctx, extras) = build_video_template_context(
detail.title.as_str(),
detail.designer.as_str(),
detail.shape.as_str(),
detail.ri.as_str(),
scene.material.name.as_str(),
&scene,
VideoTemplateSettings {
axis_index,
fps,
width,
height,
spp: samples_per_pixel,
bounces: max_bounces,
color_space,
step_deg,
start_deg,
end_deg,
total_frames,
},
);
let raw_template = model.get_filename_template();
let raw_template = if raw_template.is_empty() {
template::DEFAULT_VIDEO_TEMPLATE
} else {
raw_template.as_str()
};
let folder_name = template::resolve_folder_name(raw_template, &template_ctx, &extras);
let out_dir = template::unique_folder_path(export_dir, &folder_name);
std::fs::create_dir_all(&out_dir)
.map_err(|e| format!("Could not create {}: {e}", out_dir.display()))?;
Ok(run::VideoExportRequest {
scene,
axis_index: axis_index.max(0) as usize,
start_deg,
end_deg,
step_deg,
total_frames,
fps,
width,
height,
samples_per_pixel,
color_space,
out_dir,
out_name: folder_name,
selection,
curves,
keep_frames: model.get_keep_frames(),
})
}
fn read_axis_curves(ui: &MainWindow, axis_index: i32) -> Option<metrics::MetricCurves> {
if axis_index < 0 {
return None;
}
let idx = axis_index as usize;
let tilt = ui.global::<TiltModel>();
let to_vec = |row: slint::ModelRc<f32>| row.iter().collect::<Vec<f32>>();
let brilliance = to_vec(tilt.get_graph_brilliance_extra_axes().row_data(idx)?);
let windowing = to_vec(tilt.get_graph_windowing_extra_axes().row_data(idx)?);
let extinction = to_vec(tilt.get_graph_extinction_extra_axes().row_data(idx)?);
if brilliance.len() < 181 || windowing.len() < 181 || extinction.len() < 181 {
return None;
}
Some(metrics::MetricCurves {
brilliance,
windowing,
extinction,
})
}
fn resolve_export_directory_then(
ui: &MainWindow,
on_done: impl FnOnce(&MainWindow, Option<PathBuf>) + 'static,
) {
let export_model = ui.global::<ExportModel>();
let current = export_model.get_directory();
if !current.is_empty() {
on_done(ui, Some(PathBuf::from(current.as_str())));
return;
}
crate::gui::pickers::pick(
ui,
crate::gui::pickers::PickerRequest {
kind: crate::gui::pickers::PickerKind::PickFolder,
title: Some("Choose an export folder".to_string()),
filters: Vec::new(),
default_file_name: None,
starting_dir: None,
},
move |ui, dir| {
if let Some(dir) = &dir {
ui.global::<ExportModel>()
.set_directory(dir.to_string_lossy().into_owned().into());
}
on_done(ui, dir);
},
);
}
fn axis_label_for_index(axis_index: i32) -> String {
if axis_index < 0 {
return "0".to_string();
}
PROFILE_AZIMUTHS_DEG
.get(axis_index as usize)
.map_or_else(|| "0".to_string(), |deg| format!("{deg:.0}"))
}
const fn colorspace_label(cs: ColorSpace) -> &'static str {
match cs {
ColorSpace::Srgb => "sRGB",
ColorSpace::DisplayP3 => "Display P3",
ColorSpace::Rec2020 => "Rec.2020",
ColorSpace::AcesCg => "ACEScg",
}
}
#[derive(Debug, Clone, Copy)]
struct VideoTemplateSettings {
axis_index: i32,
fps: u32,
width: u32,
height: u32,
spp: u32,
bounces: u32,
color_space: ColorSpace,
step_deg: f64,
start_deg: f64,
end_deg: f64,
total_frames: usize,
}
fn build_video_template_context(
design: &str,
designer: &str,
shape: &str,
ri: &str,
material: &str,
scene: &SceneSnapshot,
settings: VideoTemplateSettings,
) -> (TemplateContext, template::VideoTemplateExtras) {
let ctx = TemplateContext {
design: design.to_string(),
designer: designer.to_string(),
shape: shape.to_string(),
material: material.to_string(),
ri: ri.to_string(),
width: settings.width,
height: settings.height,
spp: settings.spp,
bounces: settings.bounces,
colorspace: colorspace_label(settings.color_space).to_string(),
preset: String::new(),
lighting: scene.lighting_preset.label().to_string(),
yaw_deg: scene.yaw.to_degrees(),
pitch_deg: scene.pitch.to_degrees(),
distance: scene.distance,
exposure: scene.exposure,
};
let extras = template::VideoTemplateExtras {
axis_label: axis_label_for_index(settings.axis_index),
fps: settings.fps,
step_deg: settings.step_deg,
start_deg: settings.start_deg,
end_deg: settings.end_deg,
total_frames: settings.total_frames,
};
(ctx, extras)
}
fn live_template_context(
ui: &MainWindow,
render_ctx: &Arc<Mutex<RenderContext>>,
axis_index: i32,
) -> (TemplateContext, template::VideoTemplateExtras) {
let model = ui.global::<TiltVideoExportModel>();
let detail = ui.global::<LibraryModel>().get_current_detail();
let scene = SceneSnapshot::capture(render_ctx);
let fps = params::resolve_fps(model.get_fps_index());
let (width, height) = params::resolve_resolution(
model.get_resolution_preset(),
model.get_custom_width(),
model.get_custom_height(),
);
let spp = model.get_sample_exponent_raw().round().exp2().round() as u32;
let bounces = params::resolve_max_bounces(model.get_selected_bounce_index());
let color_space = crate::gui::color_space_from_index(model.get_selected_color_space_index());
let step_deg = params::clamp_step_deg(f64::from(model.get_step_deg()));
let start_deg = params::clamp_angle_deg(f64::from(model.get_start_angle_deg()));
let end_deg = params::clamp_angle_deg(f64::from(model.get_end_angle_deg()));
let total_frames = params::frame_count(start_deg, end_deg, step_deg);
build_video_template_context(
detail.title.as_str(),
detail.designer.as_str(),
detail.shape.as_str(),
detail.ri.as_str(),
scene.material.name.as_str(),
&scene,
VideoTemplateSettings {
axis_index,
fps,
width,
height,
spp,
bounces,
color_space,
step_deg,
start_deg,
end_deg,
total_frames,
},
)
}
fn sample_template_context() -> TemplateContext {
TemplateContext {
design: "Sample Design".to_string(),
designer: "Sample Designer".to_string(),
shape: "Round".to_string(),
material: "Diamond".to_string(),
ri: "2.42".to_string(),
width: 1920,
height: 1080,
spp: 64,
bounces: 12,
colorspace: "sRGB".to_string(),
preset: String::new(),
lighting: "Gem Studio Ring Lights".to_string(),
yaw_deg: 45.0,
pitch_deg: 30.0,
distance: 2.4,
exposure: 1.0,
}
}
fn sample_template_extras() -> template::VideoTemplateExtras {
template::VideoTemplateExtras {
axis_label: "0".to_string(),
fps: 30,
step_deg: 1.0,
start_deg: -90.0,
end_deg: 90.0,
total_frames: 181,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn axis_label_for_index_reads_the_profile_azimuth_table() {
assert_eq!(axis_label_for_index(0), "0");
assert_eq!(axis_label_for_index(1), "45");
assert_eq!(axis_label_for_index(-1), "0");
assert_eq!(axis_label_for_index(999), "0");
}
#[test]
fn colorspace_label_covers_every_variant() {
assert_eq!(colorspace_label(ColorSpace::Srgb), "sRGB");
assert_eq!(colorspace_label(ColorSpace::DisplayP3), "Display P3");
assert_eq!(colorspace_label(ColorSpace::Rec2020), "Rec.2020");
assert_eq!(colorspace_label(ColorSpace::AcesCg), "ACEScg");
}
#[test]
fn preview_reflects_the_real_open_design_and_material_not_the_hard_coded_sample() {
let scene = SceneSnapshot::capture(&Mutex::new(RenderContext::default()));
let settings = VideoTemplateSettings {
axis_index: 0,
fps: 30,
width: 1920,
height: 1080,
spp: 64,
bounces: 12,
color_space: ColorSpace::Srgb,
step_deg: 1.0,
start_deg: -90.0,
end_deg: 90.0,
total_frames: 181,
};
let (ctx, extras) = build_video_template_context(
"40.014 Eleets",
"Sample Designer",
"Round",
"1.71",
"Spinel",
&scene,
settings,
);
assert_eq!(ctx.design, "40.014 Eleets");
assert_eq!(ctx.material, "Spinel");
let name = template::resolve_folder_name(template::DEFAULT_VIDEO_TEMPLATE, &ctx, &extras);
assert!(
name.contains("40.014 Eleets"),
"expected the real design name in {name:?}"
);
assert!(
name.contains("Spinel"),
"expected the real material in {name:?}"
);
assert!(
!name.contains("Sample Design"),
"must not fall back to the hard-coded sample in {name:?}"
);
}
#[test]
fn end_to_end_cpu_render_at_128x128_with_a_handful_of_frames_and_overlay() {
use crate::bridge::render_thread::RenderContext;
use std::sync::{Mutex, atomic::AtomicBool};
let scene = SceneSnapshot::capture(&Mutex::new(RenderContext::default()));
let start_deg = -2.0;
let end_deg = 2.0;
let step_deg = 2.0; let total_frames = params::frame_count(start_deg, end_deg, step_deg);
assert_eq!(total_frames, 3);
let dir = std::env::temp_dir().join(format!("tilt_video_e2e_test_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let curves = metrics::MetricCurves {
brilliance: vec![50.0; 181],
windowing: vec![5.0; 181],
extinction: vec![5.0; 181],
};
let selection = metrics::MetricSelection {
brilliance: true,
angle: true,
..Default::default()
};
let cancel = AtomicBool::new(false);
for index in 0..total_frames {
let tilt_deg =
params::frame_angle_deg(start_deg, end_deg, step_deg, index, total_frames);
let (cam_yaw, cam_pitch) =
crate::gui::tilt::tilt_hover_preview::camera_pose_for_axis_tilt(0, tilt_deg);
let mut rgba = render::render_frame_rgba(
&scene,
128,
128,
1,
cam_yaw,
cam_pitch,
ColorSpace::Srgb,
);
assert_eq!(rgba.len(), 128 * 128 * 4);
let readings = metrics::readings_for_frame(selection, &curves, tilt_deg);
overlay::draw_overlay(&mut rgba, 128, 128, &readings);
let path = dir.join(params::frame_file_name(index + 1, total_frames));
let image =
image::RgbaImage::from_raw(128, 128, rgba).expect("buffer matches dimensions");
image.save(&path).expect("frame must save");
assert!(path.exists());
let saved = image::open(&path).expect("saved frame must be readable");
assert_eq!((saved.width(), saved.height()), (128, 128));
}
assert!(!cancel.load(std::sync::atomic::Ordering::Relaxed));
let _ = std::fs::remove_dir_all(&dir);
}
}