use crate::error::ConformResult;
use crate::types::{ClipReference, FrameRate};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ProxyResolution {
Full,
Half,
Quarter,
Eighth,
Custom(u32, u32),
}
impl ProxyResolution {
#[must_use]
pub const fn axis_scale(self) -> (u32, u32) {
match self {
Self::Full => (1, 1),
Self::Half => (1, 2),
Self::Quarter => (1, 4),
Self::Eighth => (1, 8),
Self::Custom(n, d) => (n, d),
}
}
#[must_use]
pub fn scale_online_to_proxy(self, dim: u32) -> u32 {
let (n, d) = self.axis_scale();
if let Some(q) = (dim * n + d / 2).checked_div(d) {
q
} else {
dim
}
}
#[must_use]
pub fn scale_proxy_to_online(self, dim: u32) -> u32 {
let (n, d) = self.axis_scale();
if let Some(q) = (dim * d + n / 2).checked_div(n) {
q
} else {
dim
}
}
#[must_use]
pub fn label(self) -> String {
match self {
Self::Full => "1:1".to_string(),
Self::Half => "1:2".to_string(),
Self::Quarter => "1:4".to_string(),
Self::Eighth => "1:8".to_string(),
Self::Custom(n, d) => format!("{n}:{d}"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ProxyRelinkStrategy {
DirectorySwap {
proxy_dir: PathBuf,
online_dir: PathBuf,
},
SuffixReplace {
proxy_suffix: String,
online_suffix: String,
},
DirectoryComponent {
proxy_component: String,
online_component: String,
},
Identity,
}
impl ProxyRelinkStrategy {
#[must_use]
pub fn translate(&self, proxy_path: &Path) -> Option<PathBuf> {
match self {
Self::Identity => Some(proxy_path.to_path_buf()),
Self::DirectorySwap {
proxy_dir,
online_dir,
} => proxy_path
.strip_prefix(proxy_dir)
.ok()
.map(|rel| online_dir.join(rel)),
Self::SuffixReplace {
proxy_suffix,
online_suffix,
} => {
let stem = proxy_path.file_stem()?.to_str()?;
let ext = proxy_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
let online_stem = if stem.ends_with(proxy_suffix.as_str()) {
let new_stem = &stem[..stem.len() - proxy_suffix.len()];
format!("{new_stem}{online_suffix}")
} else {
return None; };
let online_filename = if ext.is_empty() {
online_stem
} else {
format!("{online_stem}.{ext}")
};
let parent = proxy_path.parent().unwrap_or(Path::new(""));
Some(parent.join(online_filename))
}
Self::DirectoryComponent {
proxy_component,
online_component,
} => {
let proxy_str = proxy_path.to_str()?;
let needle = format!("/{proxy_component}/");
let replacement = format!("/{online_component}/");
if let Some(pos) = proxy_str.find(&needle) {
let mut result = proxy_str.to_string();
result.replace_range(pos..pos + needle.len(), &replacement);
Some(PathBuf::from(result))
} else {
None
}
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProxyConformConfig {
pub proxy_resolution: ProxyResolution,
pub relink_strategy: ProxyRelinkStrategy,
pub proxy_fps: Option<FrameRate>,
pub online_fps: Option<FrameRate>,
pub strict: bool,
}
impl ProxyConformConfig {
#[must_use]
pub fn new(proxy_resolution: ProxyResolution) -> Self {
Self {
proxy_resolution,
relink_strategy: ProxyRelinkStrategy::Identity,
proxy_fps: None,
online_fps: None,
strict: true,
}
}
#[must_use]
pub fn with_relink(mut self, strategy: ProxyRelinkStrategy) -> Self {
self.relink_strategy = strategy;
self
}
#[must_use]
pub fn with_proxy_fps(mut self, fps: FrameRate) -> Self {
self.proxy_fps = Some(fps);
self
}
#[must_use]
pub fn with_online_fps(mut self, fps: FrameRate) -> Self {
self.online_fps = Some(fps);
self
}
#[must_use]
pub fn with_strict(mut self, strict: bool) -> Self {
self.strict = strict;
self
}
#[must_use]
pub fn effective_online_fps(&self, proxy_fps: FrameRate) -> FrameRate {
self.online_fps.unwrap_or(proxy_fps)
}
}
pub struct ProxyConformTranslator {
config: ProxyConformConfig,
}
impl ProxyConformTranslator {
#[must_use]
pub fn new(config: ProxyConformConfig) -> Self {
Self { config }
}
pub fn translate_clip(&self, clip: &ClipReference) -> ConformResult<ClipReference> {
let mut online_clip = clip.clone();
if let Some(ref proxy_path_str) = clip.source_file {
let proxy_path = Path::new(proxy_path_str);
match self.config.relink_strategy.translate(proxy_path) {
Some(online_path) => {
online_clip.source_file =
Some(online_path.to_str().unwrap_or(proxy_path_str).to_string());
}
None => {
return Err(crate::error::ConformError::Edl(format!(
"ProxyConformTranslator: cannot translate path '{proxy_path_str}' with current strategy"
)));
}
}
}
let proxy_fps = self.config.proxy_fps.unwrap_or(clip.fps);
let online_fps = self.config.effective_online_fps(proxy_fps);
if (proxy_fps.as_f64() - online_fps.as_f64()).abs() > 1e-4 {
let scale = online_fps.as_f64() / proxy_fps.as_f64();
online_clip.source_in = scale_timecode(clip.source_in, proxy_fps, online_fps, scale);
online_clip.source_out = scale_timecode(clip.source_out, proxy_fps, online_fps, scale);
online_clip.record_in = scale_timecode(clip.record_in, clip.fps, online_fps, scale);
online_clip.record_out = scale_timecode(clip.record_out, clip.fps, online_fps, scale);
}
online_clip.fps = online_fps;
online_clip.metadata.insert(
"proxy_resolution".to_string(),
self.config.proxy_resolution.label(),
);
online_clip
.metadata
.insert("conformed_from_proxy".to_string(), "true".to_string());
Ok(online_clip)
}
pub fn translate_clips(&self, clips: &[ClipReference]) -> ConformResult<ProxyConformReport> {
let mut translated = Vec::with_capacity(clips.len());
let mut failures: Vec<ProxyConformFailure> = Vec::new();
for clip in clips {
match self.translate_clip(clip) {
Ok(online) => translated.push(online),
Err(e) => {
if self.config.strict {
return Err(e);
}
failures.push(ProxyConformFailure {
clip_id: clip.id.clone(),
reason: e.to_string(),
});
translated.push(clip.clone()); }
}
}
Ok(ProxyConformReport {
translated_clips: translated,
failures,
proxy_resolution: self.config.proxy_resolution,
})
}
}
fn scale_timecode(
tc: crate::types::Timecode,
from_fps: FrameRate,
to_fps: FrameRate,
scale: f64,
) -> crate::types::Timecode {
let proxy_frames = tc.to_frames(from_fps);
let online_frames = (proxy_frames as f64 * scale).round() as u64;
crate::types::Timecode::from_frames(online_frames, to_fps)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProxyConformReport {
pub translated_clips: Vec<ClipReference>,
pub failures: Vec<ProxyConformFailure>,
pub proxy_resolution: ProxyResolution,
}
impl ProxyConformReport {
#[must_use]
pub fn is_clean(&self) -> bool {
self.failures.is_empty()
}
#[must_use]
pub fn success_count(&self) -> usize {
self.translated_clips.len() - self.failures.len()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProxyConformFailure {
pub clip_id: String,
pub reason: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{FrameRate, Timecode, TrackType};
use std::collections::HashMap;
fn make_clip(id: &str, source_file: Option<&str>) -> ClipReference {
let tc = Timecode::new(1, 0, 0, 0);
let tc_out = Timecode::new(1, 0, 5, 0);
let mut meta = HashMap::new();
meta.insert("reel".to_string(), "R1".to_string());
ClipReference {
id: id.to_string(),
source_file: source_file.map(str::to_string),
source_in: tc,
source_out: tc_out,
record_in: tc,
record_out: tc_out,
track: TrackType::Video,
fps: FrameRate::Fps25,
metadata: meta,
}
}
#[test]
fn test_resolution_axis_scale() {
assert_eq!(ProxyResolution::Full.axis_scale(), (1, 1));
assert_eq!(ProxyResolution::Half.axis_scale(), (1, 2));
assert_eq!(ProxyResolution::Quarter.axis_scale(), (1, 4));
assert_eq!(ProxyResolution::Eighth.axis_scale(), (1, 8));
}
#[test]
fn test_resolution_scale_round_trip() {
let res = ProxyResolution::Quarter;
let online_dim = 1920u32;
let proxy_dim = res.scale_online_to_proxy(online_dim);
assert_eq!(proxy_dim, 480);
let back = res.scale_proxy_to_online(proxy_dim);
assert_eq!(back, online_dim);
}
#[test]
fn test_resolution_label() {
assert_eq!(ProxyResolution::Quarter.label(), "1:4");
assert_eq!(ProxyResolution::Custom(1, 3).label(), "1:3");
}
#[test]
fn test_directory_swap() {
let strategy = ProxyRelinkStrategy::DirectorySwap {
proxy_dir: PathBuf::from("/media/proxies"),
online_dir: PathBuf::from("/media/online"),
};
let result = strategy
.translate(Path::new("/media/proxies/scene_01/shot_001.mov"))
.expect("should translate");
assert_eq!(result, PathBuf::from("/media/online/scene_01/shot_001.mov"));
}
#[test]
fn test_directory_swap_no_match() {
let strategy = ProxyRelinkStrategy::DirectorySwap {
proxy_dir: PathBuf::from("/media/proxies"),
online_dir: PathBuf::from("/media/online"),
};
let result = strategy.translate(Path::new("/media/other/shot.mov"));
assert!(result.is_none());
}
#[test]
fn test_suffix_replace() {
let strategy = ProxyRelinkStrategy::SuffixReplace {
proxy_suffix: "_proxy".to_string(),
online_suffix: String::new(),
};
let result = strategy
.translate(Path::new("/media/shot_001_proxy.mov"))
.expect("should translate");
assert_eq!(result, PathBuf::from("/media/shot_001.mov"));
}
#[test]
fn test_suffix_replace_no_match() {
let strategy = ProxyRelinkStrategy::SuffixReplace {
proxy_suffix: "_proxy".to_string(),
online_suffix: String::new(),
};
let result = strategy.translate(Path::new("/media/shot_001.mov"));
assert!(result.is_none());
}
#[test]
fn test_directory_component_swap() {
let strategy = ProxyRelinkStrategy::DirectoryComponent {
proxy_component: "Proxy".to_string(),
online_component: "Online".to_string(),
};
let result = strategy
.translate(Path::new("/media/Proxy/shot_001.mov"))
.expect("should translate");
assert_eq!(result, PathBuf::from("/media/Online/shot_001.mov"));
}
#[test]
fn test_identity_strategy() {
let strategy = ProxyRelinkStrategy::Identity;
let path = Path::new("/media/shot.mov");
let result = strategy.translate(path).expect("identity always succeeds");
assert_eq!(result, path);
}
#[test]
fn test_translate_clip_directory_swap() {
let config = ProxyConformConfig::new(ProxyResolution::Quarter).with_relink(
ProxyRelinkStrategy::DirectorySwap {
proxy_dir: PathBuf::from("/proxy"),
online_dir: PathBuf::from("/online"),
},
);
let translator = ProxyConformTranslator::new(config);
let clip = make_clip("c1", Some("/proxy/scene/shot.mov"));
let online = translator.translate_clip(&clip).expect("should succeed");
assert_eq!(
online.source_file.as_deref(),
Some("/online/scene/shot.mov")
);
assert_eq!(
online.metadata.get("proxy_resolution").map(|s| s.as_str()),
Some("1:4")
);
assert_eq!(
online
.metadata
.get("conformed_from_proxy")
.map(|s| s.as_str()),
Some("true")
);
}
#[test]
fn test_translate_clip_identity_no_path_change() {
let config = ProxyConformConfig::new(ProxyResolution::Half);
let translator = ProxyConformTranslator::new(config);
let clip = make_clip("c1", Some("/media/shot.mov"));
let online = translator.translate_clip(&clip).expect("should succeed");
assert_eq!(online.source_file.as_deref(), Some("/media/shot.mov"));
}
#[test]
fn test_translate_clips_batch() {
let config = ProxyConformConfig::new(ProxyResolution::Quarter).with_relink(
ProxyRelinkStrategy::DirectorySwap {
proxy_dir: PathBuf::from("/proxy"),
online_dir: PathBuf::from("/online"),
},
);
let translator = ProxyConformTranslator::new(config);
let clips = vec![
make_clip("c1", Some("/proxy/a.mov")),
make_clip("c2", Some("/proxy/b.mov")),
];
let report = translator.translate_clips(&clips).expect("should succeed");
assert!(report.is_clean());
assert_eq!(report.translated_clips.len(), 2);
assert_eq!(
report.translated_clips[0].source_file.as_deref(),
Some("/online/a.mov")
);
}
#[test]
fn test_non_strict_partial_failure() {
let config = ProxyConformConfig::new(ProxyResolution::Half)
.with_relink(ProxyRelinkStrategy::SuffixReplace {
proxy_suffix: "_proxy".to_string(),
online_suffix: String::new(),
})
.with_strict(false);
let translator = ProxyConformTranslator::new(config);
let clips = vec![
make_clip("c1", Some("/media/shot_001_proxy.mov")), make_clip("c2", Some("/media/shot_002.mov")), ];
let report = translator.translate_clips(&clips).expect("should succeed");
assert_eq!(report.translated_clips.len(), 2);
assert_eq!(report.failures.len(), 1);
assert_eq!(report.failures[0].clip_id, "c2");
assert!(!report.is_clean());
}
#[test]
fn test_proxy_resolution_full_is_identity_scale() {
let res = ProxyResolution::Full;
assert_eq!(res.scale_online_to_proxy(1920), 1920);
assert_eq!(res.scale_proxy_to_online(1080), 1080);
}
}