#![allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FrameCountMode {
DropFrame,
NonDropFrame,
}
impl FrameCountMode {
#[must_use]
pub fn as_fcm_str(self) -> &'static str {
match self {
Self::DropFrame => "DROP FRAME",
Self::NonDropFrame => "NON-DROP FRAME",
}
}
#[must_use]
pub fn from_fcm_str(s: &str) -> Option<Self> {
let upper = s.trim().to_uppercase();
if upper == "DROP FRAME" || upper == "DF" {
Some(Self::DropFrame)
} else if upper.contains("NON") || upper == "NDF" {
Some(Self::NonDropFrame)
} else {
None
}
}
}
impl std::fmt::Display for FrameCountMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_fcm_str())
}
}
#[derive(Debug, Clone, Default)]
pub struct EdlMetadata {
pub title: Option<String>,
pub frame_count_mode: Option<FrameCountMode>,
pub project_name: Option<String>,
pub client_name: Option<String>,
pub date: Option<String>,
pub extra_fields: Vec<(String, String)>,
}
impl EdlMetadata {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn set_title(&mut self, title: impl Into<String>) {
self.title = Some(title.into());
}
pub fn set_frame_count_mode(&mut self, mode: FrameCountMode) {
self.frame_count_mode = Some(mode);
}
pub fn add_extra_field(&mut self, key: impl Into<String>, value: impl Into<String>) {
self.extra_fields.push((key.into(), value.into()));
}
#[must_use]
pub fn get_extra_field(&self, key: &str) -> Option<&str> {
let upper = key.to_uppercase();
self.extra_fields
.iter()
.find(|(k, _)| k.to_uppercase() == upper)
.map(|(_, v)| v.as_str())
}
#[must_use]
pub fn to_header_lines(&self) -> Vec<String> {
let mut lines = Vec::new();
if let Some(ref t) = self.title {
lines.push(format!("TITLE: {t}"));
}
if let Some(fcm) = self.frame_count_mode {
lines.push(format!("FCM: {fcm}"));
}
if let Some(ref p) = self.project_name {
lines.push(format!("* PROJECT: {p}"));
}
if let Some(ref c) = self.client_name {
lines.push(format!("* CLIENT: {c}"));
}
if let Some(ref d) = self.date {
lines.push(format!("* DATE: {d}"));
}
for (k, v) in &self.extra_fields {
lines.push(format!("* {k}: {v}"));
}
lines
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.title.is_none()
&& self.frame_count_mode.is_none()
&& self.project_name.is_none()
&& self.client_name.is_none()
&& self.date.is_none()
&& self.extra_fields.is_empty()
}
}
#[derive(Debug, Default)]
pub struct MetadataParser;
impl MetadataParser {
#[must_use]
pub fn parse(&self, text: &str) -> EdlMetadata {
let mut meta = EdlMetadata::new();
for line in text.lines() {
let trimmed = line.trim();
if trimmed.starts_with(|c: char| c.is_ascii_digit()) {
break;
}
if let Some(rest) = trimmed.strip_prefix("TITLE:") {
meta.set_title(rest.trim());
} else if let Some(rest) = trimmed.strip_prefix("FCM:") {
if let Some(mode) = FrameCountMode::from_fcm_str(rest.trim()) {
meta.set_frame_count_mode(mode);
}
} else if let Some(rest) = trimmed.strip_prefix("* PROJECT:") {
meta.project_name = Some(rest.trim().to_string());
} else if let Some(rest) = trimmed.strip_prefix("* CLIENT:") {
meta.client_name = Some(rest.trim().to_string());
} else if let Some(rest) = trimmed.strip_prefix("* DATE:") {
meta.date = Some(rest.trim().to_string());
} else if let Some(rest) = trimmed.strip_prefix("* ") {
if let Some(colon) = rest.find(':') {
let key = rest[..colon].trim().to_string();
let val = rest[colon + 1..].trim().to_string();
meta.add_extra_field(key, val);
}
}
}
meta
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_frame_count_mode_as_fcm_str() {
assert_eq!(FrameCountMode::DropFrame.as_fcm_str(), "DROP FRAME");
assert_eq!(FrameCountMode::NonDropFrame.as_fcm_str(), "NON-DROP FRAME");
}
#[test]
fn test_frame_count_mode_from_fcm_str_drop_frame() {
assert_eq!(
FrameCountMode::from_fcm_str("DROP FRAME"),
Some(FrameCountMode::DropFrame)
);
assert_eq!(
FrameCountMode::from_fcm_str("DF"),
Some(FrameCountMode::DropFrame)
);
}
#[test]
fn test_frame_count_mode_from_fcm_str_non_drop() {
assert_eq!(
FrameCountMode::from_fcm_str("NON-DROP FRAME"),
Some(FrameCountMode::NonDropFrame)
);
assert_eq!(
FrameCountMode::from_fcm_str("NDF"),
Some(FrameCountMode::NonDropFrame)
);
}
#[test]
fn test_frame_count_mode_from_fcm_str_unknown() {
assert_eq!(FrameCountMode::from_fcm_str("UNKNOWN"), None);
}
#[test]
fn test_frame_count_mode_display() {
assert_eq!(FrameCountMode::DropFrame.to_string(), "DROP FRAME");
assert_eq!(FrameCountMode::NonDropFrame.to_string(), "NON-DROP FRAME");
}
#[test]
fn test_edl_metadata_new_is_empty() {
let meta = EdlMetadata::new();
assert!(meta.is_empty());
}
#[test]
fn test_edl_metadata_set_title() {
let mut meta = EdlMetadata::new();
meta.set_title("My Sequence");
assert_eq!(meta.title.as_deref(), Some("My Sequence"));
assert!(!meta.is_empty());
}
#[test]
fn test_edl_metadata_set_frame_count_mode() {
let mut meta = EdlMetadata::new();
meta.set_frame_count_mode(FrameCountMode::DropFrame);
assert_eq!(meta.frame_count_mode, Some(FrameCountMode::DropFrame));
}
#[test]
fn test_edl_metadata_extra_field_roundtrip() {
let mut meta = EdlMetadata::new();
meta.add_extra_field("EDITOR", "Jane Smith");
assert_eq!(meta.get_extra_field("EDITOR"), Some("Jane Smith"));
assert_eq!(meta.get_extra_field("editor"), Some("Jane Smith")); }
#[test]
fn test_edl_metadata_extra_field_not_found() {
let meta = EdlMetadata::new();
assert_eq!(meta.get_extra_field("MISSING"), None);
}
#[test]
fn test_to_header_lines_title_and_fcm() {
let mut meta = EdlMetadata::new();
meta.set_title("Test EDL");
meta.set_frame_count_mode(FrameCountMode::NonDropFrame);
let lines = meta.to_header_lines();
assert!(lines.iter().any(|l| l.contains("Test EDL")));
assert!(lines.iter().any(|l| l.contains("NON-DROP FRAME")));
}
#[test]
fn test_to_header_lines_empty_when_no_fields() {
let meta = EdlMetadata::new();
assert!(meta.to_header_lines().is_empty());
}
#[test]
fn test_metadata_parser_extracts_title() {
let text = "TITLE: Parsed Title\nFCM: NON-DROP FRAME\n\n001 AX V C 01:00:00:00 01:00:05:00 01:00:00:00 01:00:05:00\n";
let parser = MetadataParser::default();
let meta = parser.parse(text);
assert_eq!(meta.title.as_deref(), Some("Parsed Title"));
}
#[test]
fn test_metadata_parser_extracts_fcm() {
let text = "TITLE: T\nFCM: DROP FRAME\n";
let parser = MetadataParser::default();
let meta = parser.parse(text);
assert_eq!(meta.frame_count_mode, Some(FrameCountMode::DropFrame));
}
#[test]
fn test_metadata_parser_stops_at_event_line() {
let text = "TITLE: T\n001 AX V C 01:00:00:00 01:00:05:00 01:00:00:00 01:00:05:00\n";
let parser = MetadataParser::default();
let meta = parser.parse(text);
assert!(meta.extra_fields.is_empty());
}
#[test]
fn test_metadata_parser_project_and_client() {
let text = "TITLE: X\n* PROJECT: MyProject\n* CLIENT: Acme Corp\n";
let parser = MetadataParser::default();
let meta = parser.parse(text);
assert_eq!(meta.project_name.as_deref(), Some("MyProject"));
assert_eq!(meta.client_name.as_deref(), Some("Acme Corp"));
}
#[test]
fn test_metadata_parser_date_field() {
let text = "TITLE: X\n* DATE: 2026-03-02\n";
let parser = MetadataParser::default();
let meta = parser.parse(text);
assert_eq!(meta.date.as_deref(), Some("2026-03-02"));
}
}