pub mod encode;
pub mod frames;
pub mod metrics;
mod overlay;
pub mod params;
mod render;
pub mod run;
pub mod template;
use crate::{
ActivityModel, ExportModel, ExportRunModel, LibraryModel, MainWindow, TiltModel,
TiltVideoExportModel,
bridge::{
export_thread::{
ComputeTarget, RemoteSelection, SceneSnapshot, filename_template::TemplateContext,
},
render_thread::RenderContext,
},
settings::{ExportTransfer, SettingsPersister, TiltVideoCompute},
};
use indicatrix::color::{ColorSpace, metrics::PROFILE_AZIMUTHS_DEG};
use indicatrix_solid::preview::StoneGeometryBuf;
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>>,
settings_store: &Arc<SettingsPersister>,
) {
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 settings_store_start = settings_store.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,
&settings_store_start,
¤t_run_start,
axis_index,
);
});
let settings_store_compute = Arc::clone(settings_store);
ui.global::<TiltVideoExportModel>()
.on_compute_target_picked(move |index| {
let choice = TiltVideoCompute::from_index(index);
settings_store_compute.update(|s| s.settings.tilt_video_compute_target = choice);
});
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>>,
settings_store: &Arc<SettingsPersister>,
current_run: &CurrentExportRun,
axis_index: i32,
) {
let model = ui.global::<TiltVideoExportModel>();
if model.get_is_exporting() {
return;
}
if refuse_while_busy(ui) {
return;
}
let render_ctx = Arc::clone(render_ctx);
let settings_store = settings_store.clone();
let current_run = Arc::clone(current_run);
resolve_export_directory_then(ui, move |ui, export_dir| {
let Some(export_dir) = export_dir else {
return;
};
let render_ctx_later = Arc::clone(&render_ctx);
crate::gui::editor::finished_stone_then(
ui,
&render_ctx,
|ui| {
let model = ui.global::<TiltVideoExportModel>();
model.set_has_error(false);
model.set_status_message("Preparing the finished stone...".into());
},
move |ui, finished| match finished {
Ok(finished) => start_video_run(
ui,
render_ctx_later,
&settings_store,
¤t_run,
axis_index,
&export_dir,
finished.as_ref(),
),
Err(withheld) => {
let model = ui.global::<TiltVideoExportModel>();
model.set_has_error(true);
model.set_status_message(withheld.export_message().into());
}
},
);
});
}
fn refuse_while_busy(ui: &MainWindow) -> bool {
let Some(reason) = crate::gui::export_run::busy_refusal(ui) else {
return false;
};
let model = ui.global::<TiltVideoExportModel>();
model.set_has_error(true);
model.set_status_message(reason.into());
crate::gui::show_toast(ui, reason, "info");
true
}
fn start_video_run(
ui: &MainWindow,
render_ctx: Arc<Mutex<RenderContext>>,
settings_store: &Arc<SettingsPersister>,
current_run: &CurrentExportRun,
axis_index: i32,
export_dir: &std::path::Path,
finished: Option<&StoneGeometryBuf>,
) {
let model = ui.global::<TiltVideoExportModel>();
if model.get_is_exporting() {
return;
}
if refuse_while_busy(ui) {
return;
}
let mut request = match build_request(
ui,
&render_ctx,
settings_store,
axis_index,
export_dir,
finished,
) {
Ok(request) => request,
Err(message) => {
model.set_has_error(true);
model.set_status_message(message.into());
return;
}
};
request.out_dir = template::unique_folder_path(export_dir, &request.out_name);
if let Err(e) = std::fs::create_dir_all(&request.out_dir) {
model.set_has_error(true);
model.set_status_message(
format!("Could not create {}: {e}", request.out_dir.display()).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);
model.set_eta_text(String::new().into());
let pausing = run::pauses_viewport(&request);
if pausing {
RenderContext::lock(&render_ctx).export_active_count += 1;
}
ui.global::<ExportRunModel>().set_live_view_paused(pausing);
run::spawn(ui.as_weak(), render_ctx, request, cancel, activity_id);
}
pub(in crate::gui) fn prepare_queued_video(
ui: &MainWindow,
render_ctx: &Arc<Mutex<RenderContext>>,
settings_store: &Arc<SettingsPersister>,
axis_index: i32,
on_ready: impl FnOnce(&MainWindow, Result<Option<run::VideoExportRequest>, String>) + 'static,
) {
let render_ctx = Arc::clone(render_ctx);
let settings_store = Arc::clone(settings_store);
resolve_export_directory_then(ui, move |ui, export_dir| {
let Some(export_dir) = export_dir else {
on_ready(ui, Ok(None));
return;
};
let render_ctx_later = Arc::clone(&render_ctx);
crate::gui::editor::finished_stone_then(
ui,
&render_ctx,
|ui| {
let model = ui.global::<TiltVideoExportModel>();
model.set_has_error(false);
model.set_status_message("Preparing the finished stone...".into());
},
move |ui, finished| match finished {
Ok(finished) => {
let request = build_request(
ui,
&render_ctx_later,
&settings_store,
axis_index,
&export_dir,
finished.as_ref(),
);
on_ready(ui, request.map(Some));
}
Err(withheld) => on_ready(ui, Err(withheld.export_message())),
},
);
});
}
fn build_request(
ui: &MainWindow,
render_ctx: &Arc<Mutex<RenderContext>>,
settings_store: &Arc<SettingsPersister>,
axis_index: i32,
export_dir: &std::path::Path,
finished: Option<&StoneGeometryBuf>,
) -> 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_finished(render_ctx, finished)?;
scene.max_bounces = max_bounces;
let local_compute = RenderContext::lock(render_ctx).local_compute_target;
let worker = settings_store.snapshot().settings.remote_worker();
let remote = RemoteSelection {
compute_target: compute_target_of(model.get_compute_target_index(), worker.is_some()),
worker,
transfer: ExportTransfer::from_index(model.get_transfer_index()),
contribute_local: settings_store
.snapshot()
.settings
.contribute_to_final_picture,
};
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 = export_dir.join(&folder_name);
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(),
remote,
local_compute,
})
}
const fn compute_target_of(index: i32, remote_configured: bool) -> ComputeTarget {
if !remote_configured {
return ComputeTarget::LocalOnly;
}
match index {
0 => ComputeTarget::LocalOnly,
1 => ComputeTarget::RemoteOnly,
_ => ComputeTarget::Both,
}
}
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 Ok(scene) = SceneSnapshot::capture(render_ctx) else {
return (sample_template_context(), sample_template_extras());
};
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;