#![allow(clippy::cast_precision_loss)]
use std::fmt;
use crate::{Result, TranscodeError};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SafeArea {
pub left: f32,
pub right: f32,
pub top: f32,
pub bottom: f32,
}
impl SafeArea {
#[must_use]
pub fn uniform(fraction: f32) -> Self {
Self {
left: fraction,
right: fraction,
top: fraction,
bottom: fraction,
}
}
#[must_use]
pub fn title_safe() -> Self {
Self::uniform(0.10)
}
#[must_use]
pub fn action_safe() -> Self {
Self::uniform(0.05)
}
#[must_use]
pub fn usable_rect(&self, frame_width: u32, frame_height: u32) -> (u32, u32, u32, u32) {
let fw = frame_width as f32;
let fh = frame_height as f32;
let x = (fw * self.left) as u32;
let y = (fh * self.top) as u32;
let w = (fw * (1.0 - self.left - self.right)) as u32;
let h = (fh * (1.0 - self.top - self.bottom)) as u32;
(x, y, w, h)
}
#[must_use]
pub fn is_valid(&self) -> bool {
let ok = |v: f32| (0.0..0.5).contains(&v);
ok(self.left) && ok(self.right) && ok(self.top) && ok(self.bottom)
}
}
impl Default for SafeArea {
fn default() -> Self {
Self::uniform(0.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SubtitleAlignment {
Left,
Center,
Right,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SubtitleVertical {
Bottom,
Top,
Middle,
}
#[derive(Debug, Clone)]
pub struct BurnInStyle {
pub font_family: String,
pub font_size_pt: f32,
pub bold: bool,
pub italic: bool,
pub color: (u8, u8, u8, u8),
pub outline_color: (u8, u8, u8, u8),
pub outline_px: u32,
pub background_color: Option<(u8, u8, u8, u8)>,
pub safe_area: SafeArea,
pub alignment: SubtitleAlignment,
pub vertical: SubtitleVertical,
pub line_spacing: f32,
pub max_width_fraction: f32,
}
impl BurnInStyle {
#[must_use]
pub fn broadcast_default() -> Self {
Self {
font_family: "Arial".to_string(),
font_size_pt: 38.0,
bold: false,
italic: false,
color: (255, 255, 255, 255),
outline_color: (0, 0, 0, 210),
outline_px: 2,
background_color: None,
safe_area: SafeArea::title_safe(),
alignment: SubtitleAlignment::Center,
vertical: SubtitleVertical::Bottom,
line_spacing: 1.2,
max_width_fraction: 0.85,
}
}
#[must_use]
pub fn sdh_default() -> Self {
Self {
font_family: "Courier New".to_string(),
font_size_pt: 32.0,
bold: false,
italic: false,
color: (255, 255, 255, 255),
outline_color: (0, 0, 0, 0),
outline_px: 0,
background_color: Some((0, 0, 0, 180)),
safe_area: SafeArea::title_safe(),
alignment: SubtitleAlignment::Center,
vertical: SubtitleVertical::Bottom,
line_spacing: 1.3,
max_width_fraction: 0.80,
}
}
pub fn validate(&self) -> Result<()> {
if self.font_family.trim().is_empty() {
return Err(TranscodeError::InvalidInput(
"font_family must not be empty".to_string(),
));
}
if self.font_size_pt <= 0.0 {
return Err(TranscodeError::InvalidInput(
"font_size_pt must be positive".to_string(),
));
}
if !(0.0..=1.0).contains(&self.max_width_fraction) {
return Err(TranscodeError::InvalidInput(format!(
"max_width_fraction {} is out of range [0, 1]",
self.max_width_fraction
)));
}
if !self.safe_area.is_valid() {
return Err(TranscodeError::InvalidInput(
"safe_area margins must each be in [0, 0.5)".to_string(),
));
}
if self.line_spacing <= 0.0 {
return Err(TranscodeError::InvalidInput(
"line_spacing must be positive".to_string(),
));
}
Ok(())
}
}
impl Default for BurnInStyle {
fn default() -> Self {
Self::broadcast_default()
}
}
#[derive(Debug, Clone, Default)]
pub struct CueStyleOverride {
pub font_size_pt: Option<f32>,
pub color: Option<(u8, u8, u8, u8)>,
pub italic: Option<bool>,
pub bold: Option<bool>,
pub vertical: Option<SubtitleVertical>,
pub alignment: Option<SubtitleAlignment>,
}
#[derive(Debug, Clone)]
pub struct SubtitleRenderCue {
pub text: String,
pub start_ms: u64,
pub end_ms: u64,
pub style_override: Option<CueStyleOverride>,
pub speaker: Option<String>,
}
impl SubtitleRenderCue {
#[must_use]
pub fn new(text: impl Into<String>, start_ms: u64, end_ms: u64) -> Self {
Self {
text: text.into(),
start_ms,
end_ms,
style_override: None,
speaker: None,
}
}
#[must_use]
pub fn with_style(mut self, ovr: CueStyleOverride) -> Self {
self.style_override = Some(ovr);
self
}
#[must_use]
pub fn with_speaker(mut self, speaker: impl Into<String>) -> Self {
self.speaker = Some(speaker.into());
self
}
#[must_use]
pub fn duration_ms(&self) -> u64 {
self.end_ms.saturating_sub(self.start_ms)
}
#[must_use]
pub fn is_active_at(&self, pts_ms: u64) -> bool {
pts_ms >= self.start_ms && pts_ms < self.end_ms
}
#[must_use]
pub fn is_valid(&self) -> bool {
self.end_ms > self.start_ms && !self.text.is_empty()
}
}
impl fmt::Display for SubtitleRenderCue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"[{:>8}–{:>8} ms] {}",
self.start_ms,
self.end_ms,
self.text.replace('\n', " / ")
)
}
}
#[derive(Debug, Clone)]
pub struct BurnInSpec {
pub style: BurnInStyle,
pub cues: Vec<SubtitleRenderCue>,
pub frame_width: u32,
pub frame_height: u32,
pub show_language_tag: bool,
pub max_lines: u8,
}
impl BurnInSpec {
#[must_use]
pub fn new(frame_width: u32, frame_height: u32) -> Self {
Self {
style: BurnInStyle::broadcast_default(),
cues: Vec::new(),
frame_width,
frame_height,
show_language_tag: false,
max_lines: 2,
}
}
#[must_use]
pub fn with_style(mut self, style: BurnInStyle) -> Self {
self.style = style;
self
}
#[must_use]
pub fn add_cue(mut self, cue: SubtitleRenderCue) -> Self {
self.cues.push(cue);
self
}
#[must_use]
pub fn add_cues(mut self, cues: impl IntoIterator<Item = SubtitleRenderCue>) -> Self {
self.cues.extend(cues);
self
}
#[must_use]
pub fn show_language_tag(mut self) -> Self {
self.show_language_tag = true;
self
}
#[must_use]
pub fn max_lines(mut self, n: u8) -> Self {
self.max_lines = n;
self
}
pub fn into_plan(self) -> Result<BurnInPlan> {
if self.frame_width == 0 || self.frame_height == 0 {
return Err(TranscodeError::InvalidInput(
"frame dimensions must be non-zero".to_string(),
));
}
self.style.validate()?;
for (idx, cue) in self.cues.iter().enumerate() {
if !cue.is_valid() {
return Err(TranscodeError::InvalidInput(format!(
"cue {idx} is invalid (empty text or zero duration)"
)));
}
}
let mut sorted_cues = self.cues;
sorted_cues.sort_by_key(|c| c.start_ms);
Ok(BurnInPlan {
style: self.style,
cues: sorted_cues,
frame_width: self.frame_width,
frame_height: self.frame_height,
show_language_tag: self.show_language_tag,
max_lines: self.max_lines,
})
}
}
#[derive(Debug, Clone)]
pub struct BurnInPlan {
pub style: BurnInStyle,
pub cues: Vec<SubtitleRenderCue>,
pub frame_width: u32,
pub frame_height: u32,
pub show_language_tag: bool,
pub max_lines: u8,
}
impl BurnInPlan {
#[must_use]
pub fn active_cues_at(&self, pts_ms: u64) -> Vec<&SubtitleRenderCue> {
self.cues
.iter()
.filter(|c| c.is_active_at(pts_ms))
.collect()
}
#[must_use]
pub fn usable_rect(&self) -> (u32, u32, u32, u32) {
self.style
.safe_area
.usable_rect(self.frame_width, self.frame_height)
}
#[must_use]
pub fn cue_count(&self) -> usize {
self.cues.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.cues.is_empty()
}
pub fn iter_cues(&self) -> impl Iterator<Item = &SubtitleRenderCue> {
self.cues.iter()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_plan() -> BurnInPlan {
BurnInSpec::new(1920, 1080)
.add_cue(SubtitleRenderCue::new("Hello world", 1_000, 4_000))
.add_cue(SubtitleRenderCue::new("Second cue", 5_000, 9_000))
.into_plan()
.unwrap()
}
#[test]
fn test_safe_area_uniform() {
let sa = SafeArea::uniform(0.10);
assert_eq!(sa.left, 0.10);
assert_eq!(sa.right, 0.10);
assert_eq!(sa.top, 0.10);
assert_eq!(sa.bottom, 0.10);
}
#[test]
fn test_safe_area_usable_rect() {
let sa = SafeArea::uniform(0.10);
let (x, y, w, h) = sa.usable_rect(1920, 1080);
assert_eq!(x, 192);
assert_eq!(y, 108);
assert!(w == 1535 || w == 1536, "expected w≈1536, got {w}");
assert!(h == 863 || h == 864, "expected h≈864, got {h}");
}
#[test]
fn test_safe_area_is_valid() {
assert!(SafeArea::uniform(0.05).is_valid());
assert!(!SafeArea::uniform(0.5).is_valid()); assert!(!SafeArea::uniform(-0.01).is_valid());
}
#[test]
fn test_burn_in_style_validate_ok() {
assert!(BurnInStyle::broadcast_default().validate().is_ok());
}
#[test]
fn test_burn_in_style_validate_empty_font() {
let mut s = BurnInStyle::broadcast_default();
s.font_family = " ".to_string();
assert!(s.validate().is_err());
}
#[test]
fn test_burn_in_style_validate_zero_size() {
let mut s = BurnInStyle::broadcast_default();
s.font_size_pt = 0.0;
assert!(s.validate().is_err());
}
#[test]
fn test_subtitle_render_cue_basics() {
let cue = SubtitleRenderCue::new("Hello", 2_000, 5_000);
assert_eq!(cue.duration_ms(), 3_000);
assert!(cue.is_active_at(2_000));
assert!(cue.is_active_at(4_999));
assert!(!cue.is_active_at(5_000));
assert!(!cue.is_active_at(1_999));
}
#[test]
fn test_cue_invalid_zero_duration() {
let cue = SubtitleRenderCue::new("X", 5_000, 5_000);
assert!(!cue.is_valid());
}
#[test]
fn test_cue_invalid_empty_text() {
let cue = SubtitleRenderCue::new("", 1_000, 2_000);
assert!(!cue.is_valid());
}
#[test]
fn test_burn_in_plan_active_cues() {
let plan = sample_plan();
let active = plan.active_cues_at(2_000);
assert_eq!(active.len(), 1);
assert_eq!(active[0].text, "Hello world");
let active = plan.active_cues_at(6_000);
assert_eq!(active.len(), 1);
assert_eq!(active[0].text, "Second cue");
let active = plan.active_cues_at(4_500);
assert!(active.is_empty());
}
#[test]
fn test_burn_in_spec_zero_frame_dimensions() {
let err = BurnInSpec::new(0, 1080).into_plan();
assert!(err.is_err());
}
#[test]
fn test_burn_in_spec_invalid_cue_rejected() {
let err = BurnInSpec::new(1920, 1080)
.add_cue(SubtitleRenderCue::new("", 0, 1_000))
.into_plan();
assert!(err.is_err());
}
#[test]
fn test_burn_in_plan_usable_rect() {
let plan = sample_plan();
let (x, y, w, h) = plan.usable_rect();
assert_eq!(x, 192); assert_eq!(y, 108); assert!(w == 1535 || w == 1536, "expected w≈1536, got {w}");
assert!(h == 863 || h == 864, "expected h≈864, got {h}");
}
#[test]
fn test_burn_in_plan_cue_count() {
let plan = sample_plan();
assert_eq!(plan.cue_count(), 2);
assert!(!plan.is_empty());
}
#[test]
fn test_cues_sorted_by_start_time() {
let plan = BurnInSpec::new(1280, 720)
.add_cue(SubtitleRenderCue::new("Late", 10_000, 12_000))
.add_cue(SubtitleRenderCue::new("Early", 1_000, 3_000))
.into_plan()
.unwrap();
assert_eq!(plan.cues[0].text, "Early");
assert_eq!(plan.cues[1].text, "Late");
}
#[test]
fn test_per_cue_style_override() {
let ovr = CueStyleOverride {
italic: Some(true),
color: Some((255, 0, 0, 255)),
..Default::default()
};
let cue = SubtitleRenderCue::new("Red italic", 0, 2_000).with_style(ovr.clone());
assert!(cue.style_override.is_some());
let s = cue.style_override.as_ref().unwrap();
assert_eq!(s.italic, Some(true));
assert_eq!(s.color, Some((255, 0, 0, 255)));
}
#[test]
fn test_sdh_style_has_background() {
let style = BurnInStyle::sdh_default();
assert!(style.background_color.is_some());
assert!(style.validate().is_ok());
}
#[test]
fn test_cue_display() {
let cue = SubtitleRenderCue::new("Hello\nworld", 1_000, 4_000);
let s = cue.to_string();
assert!(s.contains("Hello"));
assert!(s.contains('/'));
}
#[test]
fn test_plan_iter_cues() {
let plan = sample_plan();
let texts: Vec<&str> = plan.iter_cues().map(|c| c.text.as_str()).collect();
assert_eq!(texts, ["Hello world", "Second cue"]);
}
}