use crate::bridge::export_thread::filename_template::{self, TemplateContext};
use std::{
path::{Path, PathBuf},
time::SystemTime,
};
pub const DEFAULT_VIDEO_TEMPLATE: &str =
"tilt_video_{design}_{material}_axis{axis}_{width}x{height}_{timestamp}";
#[derive(Debug, Clone)]
pub struct VideoTemplateExtras {
pub axis_label: String,
pub fps: u32,
pub step_deg: f64,
pub start_deg: f64,
pub end_deg: f64,
pub total_frames: usize,
}
fn substitute_video_only(template: &str, extras: &VideoTemplateExtras) -> String {
template
.replace("{axis}", &extras.axis_label)
.replace("{fps}", &extras.fps.to_string())
.replace("{step}", &format!("{:.2}", extras.step_deg))
.replace("{start}", &format!("{:.1}", extras.start_deg))
.replace("{end}", &format!("{:.1}", extras.end_deg))
.replace("{frames}", &extras.total_frames.to_string())
}
const fn is_forbidden_char(c: char) -> bool {
matches!(c, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') || (c as u32) < 0x20
}
fn sanitize_folder_name(raw: &str) -> String {
let mut cleaned: String = raw
.chars()
.map(|c| if is_forbidden_char(c) { '_' } else { c })
.collect();
while matches!(cleaned.chars().last(), Some('.' | ' ')) {
cleaned.pop();
}
if cleaned.is_empty() {
cleaned = "tilt_video".to_string();
}
cleaned
}
#[must_use]
pub fn resolve_folder_name(
template: &str,
ctx: &TemplateContext,
extras: &VideoTemplateExtras,
) -> String {
let pre_substituted = substitute_video_only(template, extras);
let rendered = filename_template::render(&pre_substituted, ctx, SystemTime::now());
sanitize_folder_name(&rendered)
}
#[must_use]
pub fn unique_folder_path(export_dir: &Path, name: &str) -> PathBuf {
let mut candidate = export_dir.join(name);
let mut suffix = 2u32;
while candidate.exists() {
candidate = export_dir.join(format!("{name} ({suffix})"));
suffix += 1;
}
candidate
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_ctx() -> TemplateContext {
TemplateContext {
design: "Round Brilliant".to_string(),
designer: String::new(),
shape: "Round".to_string(),
material: "Diamond".to_string(),
ri: "2.42".to_string(),
width: 512,
height: 512,
spp: 64,
bounces: 12,
colorspace: "sRGB".to_string(),
preset: String::new(),
lighting: "Gem Studio Ring Lights".to_string(),
yaw_deg: 0.0,
pitch_deg: 90.0,
distance: 2.4,
exposure: 1.0,
}
}
fn sample_extras() -> VideoTemplateExtras {
VideoTemplateExtras {
axis_label: "45".to_string(),
fps: 30,
step_deg: 1.0,
start_deg: -90.0,
end_deg: 90.0,
total_frames: 181,
}
}
#[test]
fn resolve_folder_name_substitutes_both_shared_and_video_only_variables() {
let name = resolve_folder_name(
"{design}_{material}_axis{axis}_{width}x{height}_{fps}fps",
&sample_ctx(),
&sample_extras(),
);
assert_eq!(name, "Round Brilliant_Diamond_axis45_512x512_30fps");
}
#[test]
fn resolve_folder_name_strips_forbidden_characters() {
let mut ctx = sample_ctx();
ctx.design = "A/B:C".to_string();
let name = resolve_folder_name("{design}", &ctx, &sample_extras());
assert_eq!(name, "A_B_C");
}
#[test]
fn resolve_folder_name_never_returns_empty() {
let mut ctx = sample_ctx();
ctx.design.clear();
let name = resolve_folder_name("", &ctx, &sample_extras());
assert_eq!(name, "tilt_video");
}
#[test]
fn resolve_folder_name_leaves_an_unrecognised_placeholder_literal() {
let name = resolve_folder_name("{nonsense}", &sample_ctx(), &sample_extras());
assert_eq!(name, "{nonsense}");
}
#[test]
fn unique_folder_path_appends_a_counter_on_collision() {
let dir =
std::env::temp_dir().join(format!("tilt_video_template_test_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::create_dir(dir.join("clip")).unwrap();
let resolved = unique_folder_path(&dir, "clip");
assert_eq!(resolved, dir.join("clip (2)"));
let _ = std::fs::remove_dir_all(&dir);
}
}