#![forbid(unsafe_code)]
use oximedia_core::{OxiError, OxiResult};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TimecodeFormat {
Fps24,
Fps25,
Fps30NonDrop,
Fps30Drop,
Fps60NonDrop,
Fps60Drop,
}
impl TimecodeFormat {
#[must_use]
pub const fn fps(&self) -> f64 {
match self {
Self::Fps24 => 24.0,
Self::Fps25 => 25.0,
Self::Fps30NonDrop => 30.0,
Self::Fps30Drop => 29.97,
Self::Fps60NonDrop => 60.0,
Self::Fps60Drop => 59.94,
}
}
#[must_use]
pub const fn is_drop_frame(&self) -> bool {
matches!(self, Self::Fps30Drop | Self::Fps60Drop)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Timecode {
pub hours: u8,
pub minutes: u8,
pub seconds: u8,
pub frames: u8,
pub format: TimecodeFormat,
}
impl Timecode {
pub fn new(
hours: u8,
minutes: u8,
seconds: u8,
frames: u8,
format: TimecodeFormat,
) -> OxiResult<Self> {
if hours >= 24 {
return Err(OxiError::InvalidData("Hours must be 0-23".into()));
}
if minutes >= 60 {
return Err(OxiError::InvalidData("Minutes must be 0-59".into()));
}
if seconds >= 60 {
return Err(OxiError::InvalidData("Seconds must be 0-59".into()));
}
#[allow(clippy::cast_possible_truncation)]
#[allow(clippy::cast_sign_loss)]
let max_frames = format.fps() as u8;
if frames >= max_frames {
return Err(OxiError::InvalidData(format!(
"Frames must be 0-{}",
max_frames - 1
)));
}
Ok(Self {
hours,
minutes,
seconds,
frames,
format,
})
}
#[must_use]
#[allow(clippy::cast_possible_truncation)]
#[allow(clippy::cast_sign_loss)]
pub fn from_frame_count(frame_count: u64, format: TimecodeFormat) -> Self {
let fps = format.fps() as u64;
let total_seconds = frame_count / fps;
let frames = (frame_count % fps) as u8;
let hours = (total_seconds / 3600) as u8;
let minutes = ((total_seconds % 3600) / 60) as u8;
let seconds = (total_seconds % 60) as u8;
Self {
hours,
minutes,
seconds,
frames,
format,
}
}
#[must_use]
#[allow(clippy::cast_precision_loss)]
#[allow(clippy::cast_possible_truncation)]
#[allow(clippy::cast_sign_loss)]
pub fn to_frame_count(&self) -> u64 {
let fps = self.format.fps();
let total_seconds =
u64::from(self.hours) * 3600 + u64::from(self.minutes) * 60 + u64::from(self.seconds);
(total_seconds as f64 * fps) as u64 + u64::from(self.frames)
}
#[must_use]
pub fn format_string(&self) -> String {
let separator = if self.format.is_drop_frame() {
';'
} else {
':'
};
format!(
"{:02}:{:02}:{:02}{}{:02}",
self.hours, self.minutes, self.seconds, separator, self.frames
)
}
pub fn from_string(s: &str, format: TimecodeFormat) -> OxiResult<Self> {
let parts: Vec<&str> = s.split([':', ';']).collect();
if parts.len() != 4 {
return Err(OxiError::InvalidData(
"Timecode must be in format HH:MM:SS:FF".into(),
));
}
let hours = parts[0]
.parse()
.map_err(|_| OxiError::InvalidData("Invalid hours".into()))?;
let minutes = parts[1]
.parse()
.map_err(|_| OxiError::InvalidData("Invalid minutes".into()))?;
let seconds = parts[2]
.parse()
.map_err(|_| OxiError::InvalidData("Invalid seconds".into()))?;
let frames = parts[3]
.parse()
.map_err(|_| OxiError::InvalidData("Invalid frames".into()))?;
Self::new(hours, minutes, seconds, frames, format)
}
}
#[derive(Debug, Clone)]
pub struct TimecodeTrack {
format: TimecodeFormat,
start_timecode: Timecode,
timecodes: Vec<(u64, Timecode)>, }
impl TimecodeTrack {
#[must_use]
pub const fn new(format: TimecodeFormat, start_timecode: Timecode) -> Self {
Self {
format,
start_timecode,
timecodes: Vec::new(),
}
}
pub fn add_timecode(&mut self, sample_number: u64, timecode: Timecode) {
self.timecodes.push((sample_number, timecode));
}
#[must_use]
pub fn get_timecode(&self, sample_number: u64) -> Option<&Timecode> {
self.timecodes
.iter()
.rev()
.find(|(s, _)| *s <= sample_number)
.map(|(_, tc)| tc)
}
#[must_use]
pub const fn start_timecode(&self) -> &Timecode {
&self.start_timecode
}
#[must_use]
pub const fn format(&self) -> TimecodeFormat {
self.format
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_timecode_format() {
assert_eq!(TimecodeFormat::Fps24.fps(), 24.0);
assert_eq!(TimecodeFormat::Fps30Drop.fps(), 29.97);
assert!(TimecodeFormat::Fps30Drop.is_drop_frame());
assert!(!TimecodeFormat::Fps24.is_drop_frame());
}
#[test]
fn test_timecode_creation() {
let tc =
Timecode::new(1, 30, 45, 12, TimecodeFormat::Fps24).expect("operation should succeed");
assert_eq!(tc.hours, 1);
assert_eq!(tc.minutes, 30);
assert_eq!(tc.seconds, 45);
assert_eq!(tc.frames, 12);
assert!(Timecode::new(24, 0, 0, 0, TimecodeFormat::Fps24).is_err());
assert!(Timecode::new(0, 60, 0, 0, TimecodeFormat::Fps24).is_err());
}
#[test]
fn test_timecode_frame_count() {
let tc = Timecode::from_frame_count(100, TimecodeFormat::Fps24);
assert_eq!(tc.seconds, 4);
assert_eq!(tc.frames, 4);
let frame_count = tc.to_frame_count();
assert_eq!(frame_count, 100);
}
#[test]
fn test_timecode_string() {
let tc =
Timecode::new(1, 30, 45, 12, TimecodeFormat::Fps24).expect("operation should succeed");
assert_eq!(tc.format_string(), "01:30:45:12");
let tc_drop = Timecode::new(1, 30, 45, 12, TimecodeFormat::Fps30Drop)
.expect("operation should succeed");
assert_eq!(tc_drop.format_string(), "01:30:45;12");
}
#[test]
fn test_timecode_parse() {
let tc = Timecode::from_string("01:30:45:12", TimecodeFormat::Fps24)
.expect("operation should succeed");
assert_eq!(tc.hours, 1);
assert_eq!(tc.minutes, 30);
assert_eq!(tc.seconds, 45);
assert_eq!(tc.frames, 12);
assert!(Timecode::from_string("invalid", TimecodeFormat::Fps24).is_err());
}
#[test]
fn test_timecode_track() {
let start_tc =
Timecode::new(0, 0, 0, 0, TimecodeFormat::Fps24).expect("operation should succeed");
let mut track = TimecodeTrack::new(TimecodeFormat::Fps24, start_tc);
let tc1 =
Timecode::new(0, 0, 1, 0, TimecodeFormat::Fps24).expect("operation should succeed");
track.add_timecode(24, tc1);
let retrieved = track.get_timecode(24);
assert!(retrieved.is_some());
assert_eq!(retrieved.expect("operation should succeed").seconds, 1);
}
}