#![allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExportTarget {
Mp4,
ProRes,
DnxHd,
ImageSequence,
Aaf,
Xml,
}
impl ExportTarget {
pub fn format_name(&self) -> &'static str {
match self {
ExportTarget::Mp4 => "MP4 (H.264/HEVC)",
ExportTarget::ProRes => "Apple ProRes MOV",
ExportTarget::DnxHd => "Avid DNxHD MXF",
ExportTarget::ImageSequence => "PNG Image Sequence",
ExportTarget::Aaf => "AAF Interchange",
ExportTarget::Xml => "XML Interchange",
}
}
pub fn extension(&self) -> &'static str {
match self {
ExportTarget::Mp4 => "mp4",
ExportTarget::ProRes => "mov",
ExportTarget::DnxHd => "mxf",
ExportTarget::ImageSequence => "png",
ExportTarget::Aaf => "aaf",
ExportTarget::Xml => "xml",
}
}
pub fn is_professional_format(&self) -> bool {
matches!(
self,
ExportTarget::ProRes | ExportTarget::DnxHd | ExportTarget::Aaf
)
}
}
#[derive(Debug, Clone)]
pub struct ClipExportConfig {
pub clip_id: String,
pub output_dir: String,
pub target: ExportTarget,
pub width: u32,
pub height: u32,
pub bitrate_kbps: u32,
pub include_metadata: bool,
}
impl ClipExportConfig {
pub fn new(
clip_id: impl Into<String>,
output_dir: impl Into<String>,
target: ExportTarget,
) -> Self {
Self {
clip_id: clip_id.into(),
output_dir: output_dir.into(),
target,
width: 0,
height: 0,
bitrate_kbps: 0,
include_metadata: true,
}
}
pub fn with_resolution(mut self, width: u32, height: u32) -> Self {
self.width = width;
self.height = height;
self
}
pub fn with_bitrate(mut self, kbps: u32) -> Self {
self.bitrate_kbps = kbps;
self
}
pub fn is_valid(&self) -> bool {
!self.clip_id.is_empty() && !self.output_dir.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExportJobStatus {
Pending,
Running,
Completed,
Failed(String),
}
impl ExportJobStatus {
pub fn is_done(&self) -> bool {
matches!(
self,
ExportJobStatus::Completed | ExportJobStatus::Failed(_)
)
}
}
#[derive(Debug, Clone)]
pub struct ClipExportJob {
pub job_id: String,
pub config: ClipExportConfig,
pub status: ExportJobStatus,
pub duration_secs: f64,
}
impl ClipExportJob {
pub fn new(job_id: impl Into<String>, config: ClipExportConfig, duration_secs: f64) -> Self {
Self {
job_id: job_id.into(),
config,
status: ExportJobStatus::Pending,
duration_secs,
}
}
#[allow(clippy::cast_precision_loss)]
pub fn estimated_size_mb(&self) -> f64 {
let effective_kbps = if self.config.bitrate_kbps > 0 {
self.config.bitrate_kbps as f64
} else {
match &self.config.target {
ExportTarget::Mp4 => 8_000.0,
ExportTarget::ProRes => 145_000.0,
ExportTarget::DnxHd => 120_000.0,
ExportTarget::ImageSequence => 4_000.0,
ExportTarget::Aaf | ExportTarget::Xml => 500.0,
}
};
effective_kbps * self.duration_secs / 8.0 / 1024.0
}
pub fn start(&mut self) {
self.status = ExportJobStatus::Running;
}
pub fn complete(&mut self) {
self.status = ExportJobStatus::Completed;
}
pub fn fail(&mut self, reason: impl Into<String>) {
self.status = ExportJobStatus::Failed(reason.into());
}
}
#[derive(Debug, Default)]
pub struct ClipExporter {
jobs: Vec<ClipExportJob>,
next_id: u64,
}
impl ClipExporter {
pub fn new() -> Self {
Self::default()
}
pub fn queue(&mut self, config: ClipExportConfig, duration_secs: f64) -> String {
let job_id = format!("job-{}", self.next_id);
self.next_id += 1;
self.jobs
.push(ClipExportJob::new(job_id.clone(), config, duration_secs));
job_id
}
pub fn pending_count(&self) -> usize {
self.jobs
.iter()
.filter(|j| j.status == ExportJobStatus::Pending)
.count()
}
pub fn total_count(&self) -> usize {
self.jobs.len()
}
pub fn get_job(&self, job_id: &str) -> Option<&ClipExportJob> {
self.jobs.iter().find(|j| j.job_id == job_id)
}
pub fn get_job_mut(&mut self, job_id: &str) -> Option<&mut ClipExportJob> {
self.jobs.iter_mut().find(|j| j.job_id == job_id)
}
pub fn pending_jobs(&self) -> Vec<&ClipExportJob> {
self.jobs
.iter()
.filter(|j| j.status == ExportJobStatus::Pending)
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_str(name: &str) -> String {
std::env::temp_dir()
.join(format!("oximedia-clips-export-{name}"))
.to_string_lossy()
.into_owned()
}
fn mp4_config(clip_id: &str) -> ClipExportConfig {
ClipExportConfig::new(clip_id, tmp_str("export"), ExportTarget::Mp4)
}
#[test]
fn export_target_format_name_mp4() {
assert_eq!(ExportTarget::Mp4.format_name(), "MP4 (H.264/HEVC)");
}
#[test]
fn export_target_extension() {
assert_eq!(ExportTarget::ProRes.extension(), "mov");
assert_eq!(ExportTarget::DnxHd.extension(), "mxf");
}
#[test]
fn export_target_is_professional() {
assert!(ExportTarget::ProRes.is_professional_format());
assert!(!ExportTarget::Mp4.is_professional_format());
}
#[test]
fn export_config_is_valid_with_fields() {
let cfg = mp4_config("clip-1");
assert!(cfg.is_valid());
}
#[test]
fn export_config_invalid_when_empty_clip_id() {
let cfg = ClipExportConfig::new("", "/tmp", ExportTarget::Mp4);
assert!(!cfg.is_valid());
}
#[test]
fn export_config_invalid_when_empty_output_dir() {
let cfg = ClipExportConfig::new("c1", "", ExportTarget::Mp4);
assert!(!cfg.is_valid());
}
#[test]
fn export_job_initial_status_pending() {
let job = ClipExportJob::new("j1", mp4_config("c1"), 60.0);
assert_eq!(job.status, ExportJobStatus::Pending);
}
#[test]
fn export_job_estimated_size_uses_bitrate() {
let cfg = mp4_config("c1").with_bitrate(10_000); let job = ClipExportJob::new("j1", cfg, 10.0); let size = job.estimated_size_mb();
assert!(size > 10.0 && size < 15.0);
}
#[test]
fn export_job_estimated_size_fallback_prores() {
let cfg = ClipExportConfig::new("c1", "/tmp", ExportTarget::ProRes);
let job = ClipExportJob::new("j1", cfg, 60.0);
assert!(job.estimated_size_mb() > 100.0);
}
#[test]
fn export_job_status_transitions() {
let mut job = ClipExportJob::new("j1", mp4_config("c1"), 30.0);
job.start();
assert_eq!(job.status, ExportJobStatus::Running);
job.complete();
assert_eq!(job.status, ExportJobStatus::Completed);
assert!(job.status.is_done());
}
#[test]
fn export_job_fail_status() {
let mut job = ClipExportJob::new("j1", mp4_config("c1"), 30.0);
job.fail("codec not found");
assert!(matches!(job.status, ExportJobStatus::Failed(_)));
assert!(job.status.is_done());
}
#[test]
fn exporter_queue_increments_pending() {
let mut exporter = ClipExporter::new();
assert_eq!(exporter.pending_count(), 0);
exporter.queue(mp4_config("c1"), 60.0);
exporter.queue(mp4_config("c2"), 30.0);
assert_eq!(exporter.pending_count(), 2);
}
#[test]
fn exporter_queue_returns_job_id() {
let mut exporter = ClipExporter::new();
let id = exporter.queue(mp4_config("c1"), 10.0);
assert_eq!(id, "job-0");
let id2 = exporter.queue(mp4_config("c2"), 10.0);
assert_eq!(id2, "job-1");
}
#[test]
fn exporter_get_job_found() {
let mut exporter = ClipExporter::new();
let id = exporter.queue(mp4_config("c1"), 10.0);
assert!(exporter.get_job(&id).is_some());
}
#[test]
fn exporter_pending_count_decreases_after_completion() {
let mut exporter = ClipExporter::new();
let id = exporter.queue(mp4_config("c1"), 10.0);
if let Some(job) = exporter.get_job_mut(&id) {
job.complete();
}
assert_eq!(exporter.pending_count(), 0);
}
}