#[derive(Debug, Clone)]
pub struct BurnInConfig {
pub font_size: u32,
pub margin_px: u32,
pub background_box: bool,
pub background_opacity: u8,
pub safe_area_pct: f32,
}
impl BurnInConfig {
#[must_use]
pub fn broadcast() -> Self {
Self {
font_size: 72,
margin_px: 30,
background_box: true,
background_opacity: 180,
safe_area_pct: 0.10,
}
}
#[must_use]
pub fn web() -> Self {
Self {
font_size: 48,
margin_px: 20,
background_box: false,
background_opacity: 0,
safe_area_pct: 0.05,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BurnInAlignment {
TopLeft,
TopCenter,
TopRight,
BottomLeft,
BottomCenter,
BottomRight,
}
impl BurnInAlignment {
#[must_use]
pub const fn is_top(&self) -> bool {
matches!(self, Self::TopLeft | Self::TopCenter | Self::TopRight)
}
#[must_use]
pub const fn is_left(&self) -> bool {
matches!(self, Self::TopLeft | Self::BottomLeft)
}
}
#[derive(Debug, Clone)]
pub struct BurnInRenderer {
pub config: BurnInConfig,
}
impl BurnInRenderer {
#[must_use]
pub fn new(config: BurnInConfig) -> Self {
Self { config }
}
#[must_use]
pub fn compute_position(
&self,
text_w: u32,
text_h: u32,
frame_w: u32,
frame_h: u32,
align: &BurnInAlignment,
) -> (u32, u32) {
#[allow(clippy::cast_possible_truncation)]
#[allow(clippy::cast_sign_loss)]
let safe_x = (frame_w as f32 * self.config.safe_area_pct) as u32;
#[allow(clippy::cast_possible_truncation)]
#[allow(clippy::cast_sign_loss)]
let safe_y = (frame_h as f32 * self.config.safe_area_pct) as u32;
let margin = self.config.margin_px;
let x = match align {
BurnInAlignment::TopLeft | BurnInAlignment::BottomLeft => safe_x + margin,
BurnInAlignment::TopCenter | BurnInAlignment::BottomCenter => {
let center = frame_w / 2;
center.saturating_sub(text_w / 2)
}
BurnInAlignment::TopRight | BurnInAlignment::BottomRight => {
frame_w.saturating_sub(text_w + safe_x + margin)
}
};
let y = if align.is_top() {
safe_y + margin
} else {
frame_h.saturating_sub(text_h + safe_y + margin)
};
(x, y)
}
#[must_use]
pub fn validate_safe_area(
&self,
x: u32,
y: u32,
w: u32,
h: u32,
frame_w: u32,
frame_h: u32,
) -> bool {
#[allow(clippy::cast_possible_truncation)]
#[allow(clippy::cast_sign_loss)]
let safe_x = (frame_w as f32 * self.config.safe_area_pct) as u32;
#[allow(clippy::cast_possible_truncation)]
#[allow(clippy::cast_sign_loss)]
let safe_y = (frame_h as f32 * self.config.safe_area_pct) as u32;
x >= safe_x
&& y >= safe_y
&& (x + w) <= frame_w.saturating_sub(safe_x)
&& (y + h) <= frame_h.saturating_sub(safe_y)
}
}
#[derive(Debug, Clone)]
pub struct BurnInJob {
pub subtitle_path: String,
pub video_path: String,
pub output_path: String,
pub config: BurnInConfig,
}
impl BurnInJob {
#[must_use]
pub fn new(
subtitle_path: impl Into<String>,
video_path: impl Into<String>,
output_path: impl Into<String>,
config: BurnInConfig,
) -> Self {
Self {
subtitle_path: subtitle_path.into(),
video_path: video_path.into(),
output_path: output_path.into(),
config,
}
}
#[must_use]
pub fn estimated_processing_ms(&self, duration_ms: u64) -> u64 {
if self.config.background_box {
duration_ms + duration_ms / 2
} else {
duration_ms
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_broadcast_config_font_size() {
let cfg = BurnInConfig::broadcast();
assert_eq!(cfg.font_size, 72);
assert!(cfg.background_box);
}
#[test]
fn test_web_config_no_background() {
let cfg = BurnInConfig::web();
assert!(!cfg.background_box);
assert_eq!(cfg.font_size, 48);
}
#[test]
fn test_alignment_is_top() {
assert!(BurnInAlignment::TopLeft.is_top());
assert!(BurnInAlignment::TopCenter.is_top());
assert!(!BurnInAlignment::BottomRight.is_top());
}
#[test]
fn test_alignment_is_left() {
assert!(BurnInAlignment::TopLeft.is_left());
assert!(BurnInAlignment::BottomLeft.is_left());
assert!(!BurnInAlignment::TopCenter.is_left());
assert!(!BurnInAlignment::TopRight.is_left());
}
#[test]
fn test_compute_position_bottom_center() {
let renderer = BurnInRenderer::new(BurnInConfig::web());
let (x, y) = renderer.compute_position(200, 50, 1920, 1080, &BurnInAlignment::BottomCenter);
let expected_x = 1920 / 2 - 200 / 2;
assert_eq!(x, expected_x);
assert!(y > 1080 / 2, "y={y} should be in the lower half");
}
#[test]
fn test_compute_position_top_left() {
let renderer = BurnInRenderer::new(BurnInConfig::web());
let (x, y) = renderer.compute_position(100, 50, 1920, 1080, &BurnInAlignment::TopLeft);
assert!(x < 200, "x={x}");
assert!(y < 200, "y={y}");
}
#[test]
fn test_compute_position_bottom_right() {
let renderer = BurnInRenderer::new(BurnInConfig::web());
let (x, _y) = renderer.compute_position(200, 50, 1920, 1080, &BurnInAlignment::BottomRight);
assert!(x > 1920 / 2, "x={x}");
}
#[test]
fn test_validate_safe_area_inside() {
let renderer = BurnInRenderer::new(BurnInConfig::web());
let ok = renderer.validate_safe_area(100, 60, 200, 50, 1920, 1080);
assert!(ok, "Should be inside safe area");
}
#[test]
fn test_validate_safe_area_outside_left() {
let renderer = BurnInRenderer::new(BurnInConfig::web());
let ok = renderer.validate_safe_area(0, 60, 200, 50, 1920, 1080);
assert!(!ok, "x=0 should be outside safe area");
}
#[test]
fn test_validate_safe_area_outside_right() {
let renderer = BurnInRenderer::new(BurnInConfig::web());
let ok = renderer.validate_safe_area(1800, 60, 200, 50, 1920, 1080);
assert!(!ok, "Right edge outside safe area");
}
#[test]
fn test_burn_in_job_estimated_broadcast() {
let job = BurnInJob::new("a.srt", "v.mp4", "out.mp4", BurnInConfig::broadcast());
assert_eq!(job.estimated_processing_ms(10_000), 15_000);
}
#[test]
fn test_burn_in_job_estimated_web() {
let job = BurnInJob::new("a.srt", "v.mp4", "out.mp4", BurnInConfig::web());
assert_eq!(job.estimated_processing_ms(10_000), 10_000);
}
#[test]
fn test_burn_in_job_fields() {
let job = BurnInJob::new("sub.srt", "video.mp4", "output.mp4", BurnInConfig::web());
assert_eq!(job.subtitle_path, "sub.srt");
assert_eq!(job.video_path, "video.mp4");
assert_eq!(job.output_path, "output.mp4");
}
}