#[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, Copy, PartialEq, Eq)]
pub struct BurnInColor {
pub r: u8,
pub g: u8,
pub b: u8,
pub a: u8,
}
impl BurnInColor {
#[must_use]
pub const fn new(r: u8, g: u8, b: u8, a: u8) -> Self {
Self { r, g, b, a }
}
#[must_use]
pub const fn white() -> Self {
Self::new(255, 255, 255, 255)
}
#[must_use]
pub const fn black_with_alpha(a: u8) -> Self {
Self::new(0, 0, 0, a)
}
#[must_use]
#[allow(clippy::cast_possible_truncation)]
#[allow(clippy::cast_sign_loss)]
pub fn to_yuv(&self) -> (u8, u8, u8) {
let r = self.r as f32;
let g = self.g as f32;
let b = self.b as f32;
let y = 0.2126 * r + 0.7152 * g + 0.0722 * b;
let u = (b - y) / 1.8556 + 128.0;
let v = (r - y) / 1.5748 + 128.0;
(
y.clamp(0.0, 255.0) as u8,
u.clamp(0.0, 255.0) as u8,
v.clamp(0.0, 255.0) as u8,
)
}
}
#[derive(Debug, Clone)]
pub struct BurnInGlyph {
pub bitmap: Vec<u8>,
pub width: u32,
pub height: u32,
}
impl BurnInGlyph {
#[must_use]
#[allow(clippy::cast_possible_truncation)]
#[allow(clippy::cast_sign_loss)]
#[allow(clippy::cast_precision_loss)]
pub fn render_char(ch: char, font_size: u32) -> Self {
let cell_w = ((font_size as f32) * 0.6).ceil() as u32;
let cell_h = font_size;
let mut bitmap = vec![0u8; (cell_w * cell_h) as usize];
if ch == ' ' {
return Self {
bitmap,
width: cell_w,
height: cell_h,
};
}
let template = char_to_5x7_template(ch);
let scale_x = cell_w as f32 / 5.0;
let scale_y = cell_h as f32 / 7.0;
for py in 0..cell_h {
for px in 0..cell_w {
let tx = (px as f32 / scale_x).min(4.0) as usize;
let ty = (py as f32 / scale_y).min(6.0) as usize;
if tx < 5 && ty < 7 {
let template_idx = ty * 5 + tx;
if template_idx < template.len() && template[template_idx] > 0 {
let alpha = template[template_idx];
bitmap[(py * cell_w + px) as usize] = alpha;
}
}
}
}
Self {
bitmap,
width: cell_w,
height: cell_h,
}
}
}
fn char_to_5x7_template(ch: char) -> Vec<u8> {
let pattern: [u8; 35] = match ch {
'A' | 'a' => [
0, 1, 1, 1, 0, 1, 0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0,
1, 1, 0, 0, 0, 1,
],
'B' | 'b' => [
1, 1, 1, 1, 0, 1, 0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 0, 1, 0, 0, 0, 1, 1, 0, 0, 0,
1, 1, 1, 1, 1, 0,
],
'C' | 'c' => [
0, 1, 1, 1, 0, 1, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0,
1, 0, 1, 1, 1, 0,
],
'D' | 'd' => [
1, 1, 1, 1, 0, 1, 0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0,
1, 1, 1, 1, 1, 0,
],
'E' | 'e' => [
1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0,
0, 1, 1, 1, 1, 1,
],
'H' | 'h' => [
1, 0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0,
1, 1, 0, 0, 0, 1,
],
'I' | 'i' => [
0, 1, 1, 1, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0,
0, 0, 1, 1, 1, 0,
],
'L' | 'l' => [
1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0,
0, 1, 1, 1, 1, 1,
],
'O' | 'o' | '0' => [
0, 1, 1, 1, 0, 1, 0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0,
1, 0, 1, 1, 1, 0,
],
'T' | 't' => [
1, 1, 1, 1, 1, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0,
0, 0, 0, 1, 0, 0,
],
'1' => [
0, 0, 1, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0,
0, 0, 1, 1, 1, 0,
],
':' => [
0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0,
0, 0, 0, 0, 0, 0,
],
_ => [
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1,
],
};
pattern
.iter()
.map(|&p| if p > 0 { 255u8 } else { 0u8 })
.collect()
}
#[must_use]
#[allow(clippy::cast_possible_truncation)]
#[allow(clippy::cast_sign_loss)]
pub fn render_text_bitmap(text: &str, font_size: u32) -> BurnInGlyph {
let glyphs: Vec<BurnInGlyph> = text
.chars()
.map(|ch| BurnInGlyph::render_char(ch, font_size))
.collect();
if glyphs.is_empty() {
return BurnInGlyph {
bitmap: Vec::new(),
width: 0,
height: 0,
};
}
let total_width: u32 = glyphs.iter().map(|g| g.width).sum();
let max_height = glyphs.iter().map(|g| g.height).max().unwrap_or(0);
let mut composite = vec![0u8; (total_width * max_height) as usize];
let mut cursor_x: u32 = 0;
for glyph in &glyphs {
for gy in 0..glyph.height.min(max_height) {
for gx in 0..glyph.width {
let src_idx = (gy * glyph.width + gx) as usize;
let dst_x = cursor_x + gx;
let dst_idx = (gy * total_width + dst_x) as usize;
if src_idx < glyph.bitmap.len() && dst_idx < composite.len() {
composite[dst_idx] = glyph.bitmap[src_idx];
}
}
}
cursor_x += glyph.width;
}
BurnInGlyph {
bitmap: composite,
width: total_width,
height: max_height,
}
}
impl BurnInRenderer {
#[allow(clippy::cast_possible_truncation)]
#[allow(clippy::cast_sign_loss)]
pub fn render_to_rgba(
&self,
buffer: &mut [u8],
frame_w: u32,
frame_h: u32,
text: &str,
color: BurnInColor,
align: &BurnInAlignment,
) -> Result<(), String> {
let expected_size = (frame_w as usize) * (frame_h as usize) * 4;
if buffer.len() < expected_size {
return Err(format!(
"Buffer too small: {} < {expected_size}",
buffer.len()
));
}
let text_bitmap = render_text_bitmap(text, self.config.font_size);
if text_bitmap.width == 0 || text_bitmap.height == 0 {
return Ok(());
}
let (pos_x, pos_y) = self.compute_position(
text_bitmap.width,
text_bitmap.height,
frame_w,
frame_h,
align,
);
if self.config.background_box {
let bg_color = BurnInColor::black_with_alpha(self.config.background_opacity);
let padding = self.config.margin_px / 2;
let bg_x1 = pos_x.saturating_sub(padding);
let bg_y1 = pos_y.saturating_sub(padding);
let bg_x2 = (pos_x + text_bitmap.width + padding).min(frame_w);
let bg_y2 = (pos_y + text_bitmap.height + padding).min(frame_h);
for py in bg_y1..bg_y2 {
for px in bg_x1..bg_x2 {
let idx = ((py * frame_w + px) * 4) as usize;
if idx + 3 < buffer.len() {
let alpha_f = bg_color.a as f32 / 255.0;
let inv_alpha = 1.0 - alpha_f;
buffer[idx] =
(bg_color.r as f32 * alpha_f + buffer[idx] as f32 * inv_alpha) as u8;
buffer[idx + 1] = (bg_color.g as f32 * alpha_f
+ buffer[idx + 1] as f32 * inv_alpha)
as u8;
buffer[idx + 2] = (bg_color.b as f32 * alpha_f
+ buffer[idx + 2] as f32 * inv_alpha)
as u8;
buffer[idx + 3] = buffer[idx + 3]
.saturating_add(((255.0 - buffer[idx + 3] as f32) * alpha_f) as u8);
}
}
}
}
for gy in 0..text_bitmap.height {
for gx in 0..text_bitmap.width {
let px = pos_x + gx;
let py = pos_y + gy;
if px >= frame_w || py >= frame_h {
continue;
}
let glyph_idx = (gy * text_bitmap.width + gx) as usize;
let glyph_alpha = if glyph_idx < text_bitmap.bitmap.len() {
text_bitmap.bitmap[glyph_idx]
} else {
0
};
if glyph_alpha == 0 {
continue;
}
let idx = ((py * frame_w + px) * 4) as usize;
if idx + 3 < buffer.len() {
let alpha_f = glyph_alpha as f32 / 255.0 * color.a as f32 / 255.0;
let inv_alpha = 1.0 - alpha_f;
buffer[idx] = (color.r as f32 * alpha_f + buffer[idx] as f32 * inv_alpha) as u8;
buffer[idx + 1] =
(color.g as f32 * alpha_f + buffer[idx + 1] as f32 * inv_alpha) as u8;
buffer[idx + 2] =
(color.b as f32 * alpha_f + buffer[idx + 2] as f32 * inv_alpha) as u8;
buffer[idx + 3] = buffer[idx + 3]
.saturating_add(((255.0 - buffer[idx + 3] as f32) * alpha_f) as u8);
}
}
}
Ok(())
}
#[allow(clippy::cast_possible_truncation)]
#[allow(clippy::cast_sign_loss)]
pub fn render_to_yuv420p(
&self,
y_plane: &mut [u8],
u_plane: &mut [u8],
v_plane: &mut [u8],
frame_w: u32,
frame_h: u32,
text: &str,
color: BurnInColor,
align: &BurnInAlignment,
) -> Result<(), String> {
let y_size = (frame_w * frame_h) as usize;
let uv_size = ((frame_w / 2) * (frame_h / 2)) as usize;
if y_plane.len() < y_size {
return Err("Y plane too small".to_string());
}
if u_plane.len() < uv_size || v_plane.len() < uv_size {
return Err("UV planes too small".to_string());
}
let text_bitmap = render_text_bitmap(text, self.config.font_size);
if text_bitmap.width == 0 || text_bitmap.height == 0 {
return Ok(());
}
let (pos_x, pos_y) = self.compute_position(
text_bitmap.width,
text_bitmap.height,
frame_w,
frame_h,
align,
);
let (y_val, u_val, v_val) = color.to_yuv();
let uv_w = frame_w / 2;
if self.config.background_box {
let bg_yuv = BurnInColor::black_with_alpha(self.config.background_opacity).to_yuv();
let bg_alpha = self.config.background_opacity as f32 / 255.0;
let padding = self.config.margin_px / 2;
let bg_x1 = pos_x.saturating_sub(padding);
let bg_y1 = pos_y.saturating_sub(padding);
let bg_x2 = (pos_x + text_bitmap.width + padding).min(frame_w);
let bg_y2 = (pos_y + text_bitmap.height + padding).min(frame_h);
for py in bg_y1..bg_y2 {
for px in bg_x1..bg_x2 {
let y_idx = (py * frame_w + px) as usize;
if y_idx < y_plane.len() {
let inv = 1.0 - bg_alpha;
y_plane[y_idx] =
(bg_yuv.0 as f32 * bg_alpha + y_plane[y_idx] as f32 * inv) as u8;
}
let uv_x = px / 2;
let uv_y = py / 2;
let uv_idx = (uv_y * uv_w + uv_x) as usize;
if uv_idx < u_plane.len() {
let inv = 1.0 - bg_alpha * 0.25;
u_plane[uv_idx] = (bg_yuv.1 as f32 * bg_alpha * 0.25
+ u_plane[uv_idx] as f32 * inv)
as u8;
v_plane[uv_idx] = (bg_yuv.2 as f32 * bg_alpha * 0.25
+ v_plane[uv_idx] as f32 * inv)
as u8;
}
}
}
}
for gy in 0..text_bitmap.height {
for gx in 0..text_bitmap.width {
let px = pos_x + gx;
let py = pos_y + gy;
if px >= frame_w || py >= frame_h {
continue;
}
let glyph_idx = (gy * text_bitmap.width + gx) as usize;
let glyph_alpha = if glyph_idx < text_bitmap.bitmap.len() {
text_bitmap.bitmap[glyph_idx]
} else {
0
};
if glyph_alpha == 0 {
continue;
}
let alpha_f = glyph_alpha as f32 / 255.0 * color.a as f32 / 255.0;
let inv = 1.0 - alpha_f;
let y_idx = (py * frame_w + px) as usize;
if y_idx < y_plane.len() {
y_plane[y_idx] = (y_val as f32 * alpha_f + y_plane[y_idx] as f32 * inv) as u8;
}
let uv_x = px / 2;
let uv_y = py / 2;
let uv_idx = (uv_y * uv_w + uv_x) as usize;
if uv_idx < u_plane.len() {
let uv_alpha = alpha_f * 0.25;
let uv_inv = 1.0 - uv_alpha;
u_plane[uv_idx] =
(u_val as f32 * uv_alpha + u_plane[uv_idx] as f32 * uv_inv) as u8;
v_plane[uv_idx] =
(v_val as f32 * uv_alpha + v_plane[uv_idx] as f32 * uv_inv) as u8;
}
}
}
Ok(())
}
}
#[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");
}
#[test]
fn test_burn_in_color_white() {
let c = BurnInColor::white();
assert_eq!(c.r, 255);
assert_eq!(c.g, 255);
assert_eq!(c.b, 255);
assert_eq!(c.a, 255);
}
#[test]
fn test_burn_in_color_to_yuv() {
let white = BurnInColor::white();
let (y, u, v) = white.to_yuv();
assert!(y > 200, "Y should be bright: {y}");
assert!(
(u as i16 - 128).unsigned_abs() < 10,
"U should be near 128: {u}"
);
assert!(
(v as i16 - 128).unsigned_abs() < 10,
"V should be near 128: {v}"
);
}
#[test]
fn test_render_text_bitmap_not_empty() {
let bm = render_text_bitmap("Hello", 24);
assert!(bm.width > 0);
assert!(bm.height > 0);
assert!(!bm.bitmap.is_empty());
}
#[test]
fn test_render_text_bitmap_space() {
let bm = render_text_bitmap(" ", 24);
assert!(bm.width > 0);
assert!(bm.bitmap.iter().all(|&b| b == 0));
}
#[test]
fn test_render_to_rgba_basic() {
let renderer = BurnInRenderer::new(BurnInConfig::web());
let w = 320u32;
let h = 240u32;
let mut buffer = vec![0u8; (w * h * 4) as usize];
let result = renderer.render_to_rgba(
&mut buffer,
w,
h,
"Hi",
BurnInColor::white(),
&BurnInAlignment::BottomCenter,
);
assert!(result.is_ok());
let modified = buffer.iter().any(|&b| b > 0);
assert!(modified, "Some pixels should be modified");
}
#[test]
fn test_render_to_rgba_buffer_too_small() {
let renderer = BurnInRenderer::new(BurnInConfig::web());
let mut buffer = vec![0u8; 10];
let result = renderer.render_to_rgba(
&mut buffer,
320,
240,
"Hi",
BurnInColor::white(),
&BurnInAlignment::BottomCenter,
);
assert!(result.is_err());
}
#[test]
fn test_render_to_yuv420p_basic() {
let renderer = BurnInRenderer::new(BurnInConfig::web());
let w = 320u32;
let h = 240u32;
let mut y_plane = vec![16u8; (w * h) as usize];
let mut u_plane = vec![128u8; ((w / 2) * (h / 2)) as usize];
let mut v_plane = vec![128u8; ((w / 2) * (h / 2)) as usize];
let result = renderer.render_to_yuv420p(
&mut y_plane,
&mut u_plane,
&mut v_plane,
w,
h,
"TC",
BurnInColor::white(),
&BurnInAlignment::TopLeft,
);
assert!(result.is_ok());
}
#[test]
fn test_render_to_rgba_with_background() {
let renderer = BurnInRenderer::new(BurnInConfig::broadcast());
let w = 640u32;
let h = 480u32;
let mut buffer = vec![0u8; (w * h * 4) as usize];
let result = renderer.render_to_rgba(
&mut buffer,
w,
h,
"TEST",
BurnInColor::white(),
&BurnInAlignment::BottomCenter,
);
assert!(result.is_ok());
}
#[test]
fn test_glyph_render_char_dimensions() {
let glyph = BurnInGlyph::render_char('A', 48);
assert_eq!(glyph.height, 48);
assert!(glyph.width > 0);
assert_eq!(glyph.bitmap.len(), (glyph.width * glyph.height) as usize);
}
}
use crate::format_converter::SubtitleEntry;
pub struct BitmapFont {
pub glyph_width: u32,
pub glyph_height: u32,
pub glyphs: std::collections::HashMap<char, Vec<bool>>,
}
impl BitmapFont {
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn basic_ascii() -> Self {
let mut glyphs = std::collections::HashMap::new();
fn from_rows(rows: &[u8; 12]) -> Vec<bool> {
let mut out = Vec::with_capacity(96);
for &row in rows {
for bit in (0..8).rev() {
out.push((row >> bit) & 1 != 0);
}
}
out
}
glyphs.insert(' ', vec![false; 96]);
glyphs.insert(
'0',
from_rows(&[
0b00111100, 0b01100110, 0b01100110, 0b01101110, 0b01110110, 0b01100110, 0b01100110,
0b01100110, 0b01100110, 0b00111100, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'1',
from_rows(&[
0b00011000, 0b00111000, 0b01111000, 0b00011000, 0b00011000, 0b00011000, 0b00011000,
0b00011000, 0b00011000, 0b01111110, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'2',
from_rows(&[
0b00111100, 0b01100110, 0b01100110, 0b00000110, 0b00001100, 0b00011000, 0b00110000,
0b01100000, 0b01100110, 0b01111110, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'3',
from_rows(&[
0b00111100, 0b01100110, 0b00000110, 0b00000110, 0b00011100, 0b00000110, 0b00000110,
0b00000110, 0b01100110, 0b00111100, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'4',
from_rows(&[
0b00001100, 0b00011100, 0b00111100, 0b01101100, 0b01101100, 0b01111110, 0b00001100,
0b00001100, 0b00001100, 0b00011110, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'5',
from_rows(&[
0b01111110, 0b01100000, 0b01100000, 0b01100000, 0b01111100, 0b00000110, 0b00000110,
0b00000110, 0b01100110, 0b00111100, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'6',
from_rows(&[
0b00111100, 0b01100110, 0b01100000, 0b01100000, 0b01111100, 0b01100110, 0b01100110,
0b01100110, 0b01100110, 0b00111100, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'7',
from_rows(&[
0b01111110, 0b01100110, 0b00000110, 0b00001100, 0b00011000, 0b00011000, 0b00011000,
0b00011000, 0b00011000, 0b00011000, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'8',
from_rows(&[
0b00111100, 0b01100110, 0b01100110, 0b01100110, 0b00111100, 0b01100110, 0b01100110,
0b01100110, 0b01100110, 0b00111100, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'9',
from_rows(&[
0b00111100, 0b01100110, 0b01100110, 0b01100110, 0b00111110, 0b00000110, 0b00000110,
0b00000110, 0b01100110, 0b00111100, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'A',
from_rows(&[
0b00011000, 0b00111100, 0b01100110, 0b01100110, 0b01100110, 0b01111110, 0b01100110,
0b01100110, 0b01100110, 0b01100110, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'B',
from_rows(&[
0b01111100, 0b01100110, 0b01100110, 0b01100110, 0b01111100, 0b01100110, 0b01100110,
0b01100110, 0b01100110, 0b01111100, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'C',
from_rows(&[
0b00111100, 0b01100110, 0b01100000, 0b01100000, 0b01100000, 0b01100000, 0b01100000,
0b01100000, 0b01100110, 0b00111100, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'D',
from_rows(&[
0b01111000, 0b01101100, 0b01100110, 0b01100110, 0b01100110, 0b01100110, 0b01100110,
0b01100110, 0b01101100, 0b01111000, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'E',
from_rows(&[
0b01111110, 0b01100000, 0b01100000, 0b01100000, 0b01111100, 0b01100000, 0b01100000,
0b01100000, 0b01100000, 0b01111110, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'F',
from_rows(&[
0b01111110, 0b01100000, 0b01100000, 0b01100000, 0b01111100, 0b01100000, 0b01100000,
0b01100000, 0b01100000, 0b01100000, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'G',
from_rows(&[
0b00111100, 0b01100110, 0b01100000, 0b01100000, 0b01101110, 0b01100110, 0b01100110,
0b01100110, 0b01100110, 0b00111100, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'H',
from_rows(&[
0b01100110, 0b01100110, 0b01100110, 0b01100110, 0b01111110, 0b01100110, 0b01100110,
0b01100110, 0b01100110, 0b01100110, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'I',
from_rows(&[
0b00111100, 0b00011000, 0b00011000, 0b00011000, 0b00011000, 0b00011000, 0b00011000,
0b00011000, 0b00011000, 0b00111100, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'J',
from_rows(&[
0b00011110, 0b00001100, 0b00001100, 0b00001100, 0b00001100, 0b00001100, 0b00001100,
0b01101100, 0b01101100, 0b00111000, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'K',
from_rows(&[
0b01100110, 0b01101100, 0b01111000, 0b01110000, 0b01100000, 0b01100000, 0b01110000,
0b01111000, 0b01101100, 0b01100110, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'L',
from_rows(&[
0b01100000, 0b01100000, 0b01100000, 0b01100000, 0b01100000, 0b01100000, 0b01100000,
0b01100000, 0b01100000, 0b01111110, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'M',
from_rows(&[
0b01000010, 0b01100110, 0b01111110, 0b01111110, 0b01011010, 0b01000010, 0b01000010,
0b01000010, 0b01000010, 0b01000010, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'N',
from_rows(&[
0b01100010, 0b01110010, 0b01111010, 0b01111110, 0b01101110, 0b01100110, 0b01100110,
0b01100110, 0b01100110, 0b01100110, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'O',
from_rows(&[
0b00111100, 0b01100110, 0b01100110, 0b01100110, 0b01100110, 0b01100110, 0b01100110,
0b01100110, 0b01100110, 0b00111100, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'P',
from_rows(&[
0b01111100, 0b01100110, 0b01100110, 0b01100110, 0b01111100, 0b01100000, 0b01100000,
0b01100000, 0b01100000, 0b01100000, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'Q',
from_rows(&[
0b00111100, 0b01100110, 0b01100110, 0b01100110, 0b01100110, 0b01100110, 0b01110110,
0b00111110, 0b00000110, 0b00000111, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'R',
from_rows(&[
0b01111100, 0b01100110, 0b01100110, 0b01100110, 0b01111100, 0b01111000, 0b01101100,
0b01100110, 0b01100110, 0b01100110, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'S',
from_rows(&[
0b00111100, 0b01100110, 0b01100000, 0b01100000, 0b00111100, 0b00000110, 0b00000110,
0b00000110, 0b01100110, 0b00111100, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'T',
from_rows(&[
0b01111110, 0b01011010, 0b00011000, 0b00011000, 0b00011000, 0b00011000, 0b00011000,
0b00011000, 0b00011000, 0b00111100, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'U',
from_rows(&[
0b01100110, 0b01100110, 0b01100110, 0b01100110, 0b01100110, 0b01100110, 0b01100110,
0b01100110, 0b01100110, 0b00111100, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'V',
from_rows(&[
0b01100110, 0b01100110, 0b01100110, 0b01100110, 0b01100110, 0b01100110, 0b01100110,
0b00111100, 0b00111100, 0b00011000, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'W',
from_rows(&[
0b01000010, 0b01000010, 0b01000010, 0b01000010, 0b01011010, 0b01011010, 0b01111110,
0b01100110, 0b01100110, 0b01000010, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'X',
from_rows(&[
0b01100110, 0b01100110, 0b00111100, 0b00011000, 0b00011000, 0b00011000, 0b00011000,
0b00111100, 0b01100110, 0b01100110, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'Y',
from_rows(&[
0b01100110, 0b01100110, 0b01100110, 0b00111100, 0b00011000, 0b00011000, 0b00011000,
0b00011000, 0b00011000, 0b00111100, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'Z',
from_rows(&[
0b01111110, 0b00000110, 0b00001100, 0b00011000, 0b00110000, 0b00110000, 0b00110000,
0b01100000, 0b01100000, 0b01111110, 0b00000000, 0b00000000,
]),
);
for ch in 'a'..='z' {
let upper = ch.to_ascii_uppercase();
if let Some(g) = glyphs.get(&upper).cloned() {
glyphs.entry(ch).or_insert(g);
}
}
glyphs.insert(
'.',
from_rows(&[
0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000,
0b00000000, 0b00011000, 0b00011000, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
',',
from_rows(&[
0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000,
0b00011000, 0b00011000, 0b00110000, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'!',
from_rows(&[
0b00011000, 0b00011000, 0b00011000, 0b00011000, 0b00011000, 0b00011000, 0b00011000,
0b00000000, 0b00011000, 0b00011000, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'?',
from_rows(&[
0b00111100, 0b01100110, 0b00000110, 0b00001100, 0b00011000, 0b00011000, 0b00011000,
0b00000000, 0b00011000, 0b00011000, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'-',
from_rows(&[
0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b01111110, 0b00000000, 0b00000000,
0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
':',
from_rows(&[
0b00000000, 0b00000000, 0b00011000, 0b00011000, 0b00000000, 0b00000000, 0b00011000,
0b00011000, 0b00000000, 0b00000000, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'\'',
from_rows(&[
0b00011000, 0b00011000, 0b00011000, 0b00000000, 0b00000000, 0b00000000, 0b00000000,
0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'"',
from_rows(&[
0b01100110, 0b01100110, 0b01000100, 0b00000000, 0b00000000, 0b00000000, 0b00000000,
0b00000000, 0b00000000, 0b00000000, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
'(',
from_rows(&[
0b00001100, 0b00011000, 0b00110000, 0b01100000, 0b01100000, 0b01100000, 0b01100000,
0b00110000, 0b00011000, 0b00001100, 0b00000000, 0b00000000,
]),
);
glyphs.insert(
')',
from_rows(&[
0b00110000, 0b00011000, 0b00001100, 0b00000110, 0b00000110, 0b00000110, 0b00000110,
0b00001100, 0b00011000, 0b00110000, 0b00000000, 0b00000000,
]),
);
Self {
glyph_width: 8,
glyph_height: 12,
glyphs,
}
}
#[must_use]
pub fn render_text(&self, text: &str, scale: u32) -> Vec<Vec<bool>> {
if scale == 0 || text.is_empty() {
return Vec::new();
}
let scale = scale as usize;
let gw = self.glyph_width as usize * scale;
let gh = self.glyph_height as usize * scale;
let total_width = text.chars().count() * gw;
let mut grid = vec![vec![false; total_width]; gh];
let fallback = vec![true; (self.glyph_width * self.glyph_height) as usize];
for (char_idx, ch) in text.chars().enumerate() {
let bitmap = self.glyphs.get(&ch).unwrap_or(&fallback);
let x_offset = char_idx * gw;
for src_row in 0..self.glyph_height as usize {
for src_col in 0..self.glyph_width as usize {
let src_idx = src_row * self.glyph_width as usize + src_col;
let ink = bitmap.get(src_idx).copied().unwrap_or(false);
if ink {
for dy in 0..scale {
let dst_row = src_row * scale + dy;
for dx in 0..scale {
let dst_col = x_offset + src_col * scale + dx;
if dst_row < gh && dst_col < total_width {
grid[dst_row][dst_col] = true;
}
}
}
}
}
}
}
grid
}
}
#[derive(Debug, Clone)]
pub struct SubtitleBurnInConfig {
pub font_size: u32,
pub margin_bottom: u32,
pub margin_horizontal: u32,
pub text_color: (u8, u8, u8),
pub outline_color: (u8, u8, u8),
pub outline_width: u32,
pub background: Option<(u8, u8, u8, u8)>,
pub box_padding: u32,
}
impl Default for SubtitleBurnInConfig {
fn default() -> Self {
Self {
font_size: 32,
margin_bottom: 24,
margin_horizontal: 16,
text_color: (255, 255, 255),
outline_color: (0, 0, 0),
outline_width: 2,
background: None,
box_padding: 4,
}
}
}
pub struct SubtitleBurnIn {
pub config: SubtitleBurnInConfig,
font: BitmapFont,
}
impl SubtitleBurnIn {
#[must_use]
pub fn new(config: SubtitleBurnInConfig) -> Self {
Self {
font: BitmapFont::basic_ascii(),
config,
}
}
#[must_use]
pub fn get_active<'a>(entries: &'a [SubtitleEntry], pts_ms: u64) -> Vec<&'a SubtitleEntry> {
entries.iter().filter(|e| e.is_active_at(pts_ms)).collect()
}
#[allow(clippy::cast_possible_truncation)]
#[allow(clippy::cast_sign_loss)]
pub fn burn_frame(
&self,
frame: &mut [u8],
width: u32,
height: u32,
entries: &[SubtitleEntry],
pts_ms: u64,
) {
let active = Self::get_active(entries, pts_ms);
if active.is_empty() {
return;
}
let expected = (width * height * 4) as usize;
if frame.len() < expected {
return;
}
let text: String = active
.iter()
.map(|e| e.text.as_str())
.collect::<Vec<_>>()
.join(" | ");
let scale = (self.config.font_size / self.font.glyph_height).max(1);
let grid = self.font.render_text(&text, scale);
if grid.is_empty() {
return;
}
let render_h = grid.len() as u32;
let render_w = grid[0].len() as u32;
let x_start = if render_w >= width {
0u32
} else {
(width - render_w) / 2
};
let y_start = if render_h + self.config.margin_bottom >= height {
0u32
} else {
height - render_h - self.config.margin_bottom
};
if let Some((br, bg_c, bb, ba)) = self.config.background {
let pad = self.config.box_padding;
let bx0 = x_start.saturating_sub(pad);
let by0 = y_start.saturating_sub(pad);
let bx1 = (x_start + render_w + pad).min(width);
let by1 = (y_start + render_h + pad).min(height);
let alpha_f = ba as f32 / 255.0;
for py in by0..by1 {
for px in bx0..bx1 {
let idx = ((py * width + px) * 4) as usize;
if idx + 3 < frame.len() {
let inv = 1.0 - alpha_f;
frame[idx] = (br as f32 * alpha_f + frame[idx] as f32 * inv) as u8;
frame[idx + 1] =
(bg_c as f32 * alpha_f + frame[idx + 1] as f32 * inv) as u8;
frame[idx + 2] = (bb as f32 * alpha_f + frame[idx + 2] as f32 * inv) as u8;
frame[idx + 3] = frame[idx + 3]
.saturating_add(((255.0 - frame[idx + 3] as f32) * alpha_f) as u8);
}
}
}
}
let blend_pixel = |frame: &mut [u8], px: u32, py: u32, r: u8, g: u8, b: u8| {
if px >= width || py >= height {
return;
}
let idx = ((py * width + px) * 4) as usize;
if idx + 3 < frame.len() {
frame[idx] = r;
frame[idx + 1] = g;
frame[idx + 2] = b;
frame[idx + 3] = 255;
}
};
let ow = self.config.outline_width;
let (or_c, og, ob) = self.config.outline_color;
if ow > 0 {
for row in 0..render_h {
for col in 0..render_w {
let ink = grid
.get(row as usize)
.and_then(|r| r.get(col as usize))
.copied()
.unwrap_or(false);
if !ink {
continue;
}
for dy in 0..=(ow * 2) {
for dx in 0..=(ow * 2) {
let ox = (x_start + col + dx).saturating_sub(ow);
let oy = (y_start + row + dy).saturating_sub(ow);
blend_pixel(frame, ox, oy, or_c, og, ob);
}
}
}
}
}
let (tr, tg, tb) = self.config.text_color;
for row in 0..render_h {
for col in 0..render_w {
let ink = grid
.get(row as usize)
.and_then(|r| r.get(col as usize))
.copied()
.unwrap_or(false);
if ink {
blend_pixel(frame, x_start + col, y_start + row, tr, tg, tb);
}
}
}
}
}
#[cfg(test)]
mod bitmap_tests {
use super::*;
use crate::format_converter::SubtitleEntry;
#[test]
fn test_bitmap_font_glyph_size() {
let font = BitmapFont::basic_ascii();
assert_eq!(font.glyph_width, 8);
assert_eq!(font.glyph_height, 12);
}
#[test]
fn test_bitmap_font_has_ascii_digits() {
let font = BitmapFont::basic_ascii();
for ch in '0'..='9' {
assert!(font.glyphs.contains_key(&ch), "Missing glyph for {ch}");
}
}
#[test]
fn test_bitmap_font_has_uppercase() {
let font = BitmapFont::basic_ascii();
for ch in 'A'..='Z' {
assert!(font.glyphs.contains_key(&ch), "Missing glyph for {ch}");
}
}
#[test]
fn test_bitmap_font_has_lowercase() {
let font = BitmapFont::basic_ascii();
for ch in 'a'..='z' {
assert!(font.glyphs.contains_key(&ch), "Missing glyph for {ch}");
}
}
#[test]
fn test_bitmap_font_glyph_correct_length() {
let font = BitmapFont::basic_ascii();
let expected = (font.glyph_width * font.glyph_height) as usize;
for (ch, glyph) in &font.glyphs {
assert_eq!(
glyph.len(),
expected,
"Glyph for '{ch}' has wrong length: {}",
glyph.len()
);
}
}
#[test]
fn test_render_text_dimensions() {
let font = BitmapFont::basic_ascii();
let grid = font.render_text("AB", 2);
assert_eq!(grid.len(), 24);
assert_eq!(grid[0].len(), 32);
}
#[test]
fn test_render_text_empty_string() {
let font = BitmapFont::basic_ascii();
let grid = font.render_text("", 2);
assert!(grid.is_empty());
}
#[test]
fn test_render_text_scale_zero_returns_empty() {
let font = BitmapFont::basic_ascii();
let grid = font.render_text("A", 0);
assert!(grid.is_empty());
}
#[test]
fn test_burn_in_new() {
let cfg = SubtitleBurnInConfig::default();
let burn = SubtitleBurnIn::new(cfg);
assert_eq!(burn.config.font_size, 32);
}
#[test]
fn test_get_active_returns_matching_entries() {
let entries = vec![
SubtitleEntry::new(1, 1_000, 4_000, "hello"),
SubtitleEntry::new(2, 5_000, 8_000, "world"),
];
let active = SubtitleBurnIn::get_active(&entries, 2_000);
assert_eq!(active.len(), 1);
assert_eq!(active[0].text, "hello");
}
#[test]
fn test_get_active_no_match() {
let entries = vec![SubtitleEntry::new(1, 1_000, 4_000, "hello")];
let active = SubtitleBurnIn::get_active(&entries, 9_000);
assert!(active.is_empty());
}
#[test]
fn test_burn_frame_modifies_pixels() {
let burn = SubtitleBurnIn::new(SubtitleBurnInConfig::default());
let w = 320u32;
let h = 240u32;
let mut frame = vec![0u8; (w * h * 4) as usize];
let entries = vec![SubtitleEntry::new(1, 0, 5_000, "Hi")];
burn.burn_frame(&mut frame, w, h, &entries, 1_000);
let any_nonzero = frame.iter().any(|&b| b > 0);
assert!(any_nonzero, "burn_frame should paint some pixels");
}
#[test]
fn test_burn_frame_no_active_entries_noop() {
let burn = SubtitleBurnIn::new(SubtitleBurnInConfig::default());
let w = 320u32;
let h = 240u32;
let mut frame = vec![0u8; (w * h * 4) as usize];
let entries = vec![SubtitleEntry::new(1, 10_000, 15_000, "Not visible")];
burn.burn_frame(&mut frame, w, h, &entries, 1_000);
assert!(frame.iter().all(|&b| b == 0));
}
#[test]
fn test_burn_frame_with_background_box() {
let mut cfg = SubtitleBurnInConfig::default();
cfg.background = Some((0, 0, 0, 128));
let burn = SubtitleBurnIn::new(cfg);
let w = 320u32;
let h = 240u32;
let mut frame = vec![0u8; (w * h * 4) as usize];
let entries = vec![SubtitleEntry::new(1, 0, 5_000, "Box")];
burn.burn_frame(&mut frame, w, h, &entries, 1_000);
let any_nonzero = frame.iter().any(|&b| b > 0);
assert!(any_nonzero, "background box should paint pixels");
}
}
pub use crate::soft_shadow::{
gaussian_blur_alpha, render_soft_shadow_rgba, SoftShadowConfig, TextBitmap,
};