use crate::font::Font;
use crate::overlay::overlay_subtitle;
use crate::style::{Position, SubtitleStyle};
use crate::text::TextLayoutEngine;
use crate::{Subtitle, SubtitleError, SubtitleResult};
use oximedia_codec::VideoFrame;
use oximedia_core::Timestamp;
pub struct SubtitleRenderer {
layout_engine: TextLayoutEngine,
default_style: SubtitleStyle,
}
impl SubtitleRenderer {
#[must_use]
pub fn new(font: Font, style: SubtitleStyle) -> Self {
Self {
layout_engine: TextLayoutEngine::new(font),
default_style: style,
}
}
pub fn render_subtitle(
&mut self,
subtitle: &Subtitle,
frame: &mut VideoFrame,
timestamp: Timestamp,
) -> SubtitleResult<()> {
let timestamp_ms = (timestamp.to_seconds() * 1000.0) as i64;
if !subtitle.is_active(timestamp_ms) {
return Ok(());
}
let style = subtitle.style.as_ref().unwrap_or(&self.default_style);
let max_width = if style.max_width > 0 {
style.max_width
} else {
frame
.width
.saturating_sub(style.margin_left + style.margin_right)
};
let layout = self
.layout_engine
.layout(&subtitle.text, style, max_width)?;
if layout.is_empty() {
return Ok(());
}
let position = subtitle.position.as_ref().unwrap_or(&style.position);
let (x, y) = self.calculate_position(frame, &layout, position, style);
let (color, outline_color) = self.apply_animations(
subtitle,
timestamp_ms,
style.primary_color,
style.outline.as_ref().map(|o| o.color),
);
if let Some(bg_color) = style.background_color {
self.render_background(frame, &layout, x, y, bg_color, style.background_padding)?;
}
let outline_width = style.outline.as_ref().map(|o| o.width).unwrap_or(0.0);
overlay_subtitle(frame, &layout, x, y, color, outline_color, outline_width)?;
Ok(())
}
pub fn render_subtitles(
&mut self,
subtitles: &[Subtitle],
frame: &mut VideoFrame,
timestamp: Timestamp,
) -> SubtitleResult<()> {
let timestamp_ms = (timestamp.to_seconds() * 1000.0) as i64;
let active: Vec<_> = subtitles
.iter()
.filter(|s| s.is_active(timestamp_ms))
.collect();
for subtitle in active {
self.render_subtitle(subtitle, frame, timestamp)?;
}
Ok(())
}
#[allow(clippy::cast_possible_truncation)]
#[allow(clippy::cast_precision_loss)]
fn calculate_position(
&self,
frame: &VideoFrame,
layout: &crate::text::TextLayout,
position: &Position,
style: &SubtitleStyle,
) -> (i32, i32) {
let frame_width = frame.width as f32;
let frame_height = frame.height as f32;
let mut x = frame_width * position.x;
let mut y = frame_height * position.y;
match position.alignment {
crate::style::Alignment::Left => {
x += style.margin_left as f32;
}
crate::style::Alignment::Center => {
x -= layout.width / 2.0;
}
crate::style::Alignment::Right => {
x -= layout.width + style.margin_right as f32;
}
}
match position.vertical_alignment {
crate::style::VerticalAlignment::Top => {
y += style.margin_top as f32;
}
crate::style::VerticalAlignment::Middle => {
y -= layout.height / 2.0;
}
crate::style::VerticalAlignment::Bottom => {
y -= layout.height + style.margin_bottom as f32;
}
}
(x as i32, y as i32)
}
#[allow(clippy::cast_precision_loss)]
fn apply_animations(
&self,
subtitle: &Subtitle,
timestamp_ms: i64,
mut primary_color: crate::style::Color,
outline_color: Option<crate::style::Color>,
) -> (crate::style::Color, Option<crate::style::Color>) {
use crate::style::Animation;
let elapsed = timestamp_ms - subtitle.start_time;
let duration = subtitle.duration();
for animation in &subtitle.animations {
match animation {
Animation::FadeIn(fade_duration) => {
if elapsed < *fade_duration {
let progress = elapsed as f32 / *fade_duration as f32;
let alpha = (f32::from(primary_color.a) * progress) as u8;
primary_color = primary_color.with_alpha(alpha);
}
}
Animation::FadeOut(fade_duration) => {
let fade_start = duration - fade_duration;
if elapsed > fade_start {
let fade_elapsed = elapsed - fade_start;
let progress = fade_elapsed as f32 / *fade_duration as f32;
let alpha = (f32::from(primary_color.a) * (1.0 - progress)) as u8;
primary_color = primary_color.with_alpha(alpha);
}
}
_ => {
}
}
}
(primary_color, outline_color)
}
#[allow(clippy::cast_possible_truncation)]
#[allow(clippy::cast_sign_loss)]
fn render_background(
&self,
frame: &mut VideoFrame,
layout: &crate::text::TextLayout,
x: i32,
y: i32,
color: crate::style::Color,
padding: f32,
) -> SubtitleResult<()> {
if frame.planes.is_empty() {
return Err(SubtitleError::InvalidFrameFormat(
"Frame has no planes".to_string(),
));
}
let x1 = (x as f32 - padding).max(0.0) as usize;
let y1 = (y as f32 - padding).max(0.0) as usize;
let x2 = ((x as f32 + layout.width + padding) as usize).min(frame.width as usize);
let y2 = ((y as f32 + layout.height + padding) as usize).min(frame.height as usize);
let mut plane_data = frame.planes[0].data.to_vec();
let stride = frame.planes[0].stride;
let bytes_per_pixel = match frame.format {
oximedia_core::PixelFormat::Rgb24 => 3,
oximedia_core::PixelFormat::Rgba32 => 4,
_ => return Ok(()), };
for py in y1..y2 {
for px in x1..x2 {
let idx = py * stride + px * bytes_per_pixel;
if idx + bytes_per_pixel <= plane_data.len() {
let alpha = f32::from(color.a) / 255.0;
let inv_alpha = 1.0 - alpha;
plane_data[idx] =
(f32::from(color.r) * alpha + f32::from(plane_data[idx]) * inv_alpha) as u8;
plane_data[idx + 1] = (f32::from(color.g) * alpha
+ f32::from(plane_data[idx + 1]) * inv_alpha)
as u8;
plane_data[idx + 2] = (f32::from(color.b) * alpha
+ f32::from(plane_data[idx + 2]) * inv_alpha)
as u8;
}
}
}
frame.planes[0].data = plane_data;
Ok(())
}
#[must_use]
pub fn style(&self) -> &SubtitleStyle {
&self.default_style
}
pub fn set_style(&mut self, style: SubtitleStyle) {
self.default_style = style;
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DirtyRect {
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
}
impl DirtyRect {
#[must_use]
pub const fn new(x: u32, y: u32, width: u32, height: u32) -> Self {
Self {
x,
y,
width,
height,
}
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.width == 0 || self.height == 0
}
#[must_use]
pub fn union(&self, other: &Self) -> Self {
if self.is_empty() {
return *other;
}
if other.is_empty() {
return *self;
}
let x1 = self.x.min(other.x);
let y1 = self.y.min(other.y);
let x2 = (self.x + self.width).max(other.x + other.width);
let y2 = (self.y + self.height).max(other.y + other.height);
Self::new(x1, y1, x2 - x1, y2 - y1)
}
}
#[derive(Clone, Debug, PartialEq)]
struct DisplayedState {
cues: Vec<(i64, i64, String)>,
}
pub struct IncrementalSubtitleRenderer {
inner: SubtitleRenderer,
last_state: Option<DisplayedState>,
last_dirty: Option<DirtyRect>,
}
impl IncrementalSubtitleRenderer {
#[must_use]
pub fn new(font: Font, style: SubtitleStyle) -> Self {
Self {
inner: SubtitleRenderer::new(font, style),
last_state: None,
last_dirty: None,
}
}
#[must_use]
pub fn style(&self) -> &SubtitleStyle {
self.inner.style()
}
pub fn set_style(&mut self, style: SubtitleStyle) {
self.inner.set_style(style);
}
pub fn render_incremental(
&mut self,
subtitles: &[Subtitle],
frame: &mut VideoFrame,
timestamp: Timestamp,
) -> SubtitleResult<Option<DirtyRect>> {
let timestamp_ms = (timestamp.to_seconds() * 1000.0) as i64;
let active: Vec<&Subtitle> = subtitles
.iter()
.filter(|s| s.is_active(timestamp_ms))
.collect();
let new_state = DisplayedState {
cues: active
.iter()
.map(|s| (s.start_time, s.end_time, s.text.clone()))
.collect(),
};
if self.last_state.as_ref() == Some(&new_state) {
return Ok(None);
}
let mut dirty = DirtyRect::new(0, 0, 0, 0);
for subtitle in &active {
self.inner.render_subtitle(subtitle, frame, timestamp)?;
let cue_dirty = DirtyRect::new(0, frame.height / 2, frame.width, frame.height / 2);
dirty = dirty.union(&cue_dirty);
}
if let Some(prev_dirty) = self.last_dirty {
dirty = dirty.union(&prev_dirty);
}
self.last_state = Some(new_state);
self.last_dirty = if dirty.is_empty() { None } else { Some(dirty) };
if dirty.is_empty() && active.is_empty() {
Ok(Some(DirtyRect::new(0, 0, 0, 0)))
} else {
Ok(Some(dirty))
}
}
pub fn invalidate(&mut self) {
self.last_state = None;
self.last_dirty = None;
}
#[must_use]
pub fn last_dirty_rect(&self) -> Option<DirtyRect> {
self.last_dirty
}
}
#[cfg(test)]
mod incremental_tests {
use super::*;
#[test]
fn test_dirty_rect_new() {
let r = DirtyRect::new(10, 20, 100, 50);
assert_eq!(r.x, 10);
assert_eq!(r.y, 20);
assert_eq!(r.width, 100);
assert_eq!(r.height, 50);
}
#[test]
fn test_dirty_rect_is_empty_zero_width() {
let r = DirtyRect::new(0, 0, 0, 100);
assert!(r.is_empty());
}
#[test]
fn test_dirty_rect_is_empty_zero_height() {
let r = DirtyRect::new(0, 0, 100, 0);
assert!(r.is_empty());
}
#[test]
fn test_dirty_rect_not_empty() {
let r = DirtyRect::new(5, 5, 10, 10);
assert!(!r.is_empty());
}
#[test]
fn test_dirty_rect_union_basic() {
let a = DirtyRect::new(0, 0, 10, 10);
let b = DirtyRect::new(5, 5, 10, 10);
let u = a.union(&b);
assert_eq!(u.x, 0);
assert_eq!(u.y, 0);
assert_eq!(u.width, 15);
assert_eq!(u.height, 15);
}
#[test]
fn test_dirty_rect_union_with_empty() {
let a = DirtyRect::new(10, 20, 100, 50);
let empty = DirtyRect::new(0, 0, 0, 0);
assert_eq!(a.union(&empty), a);
assert_eq!(empty.union(&a), a);
}
#[test]
fn test_dirty_rect_union_both_empty() {
let a = DirtyRect::new(0, 0, 0, 0);
let b = DirtyRect::new(0, 0, 0, 0);
let u = a.union(&b);
assert!(u.is_empty());
}
#[test]
fn test_dirty_rect_union_adjacent() {
let a = DirtyRect::new(0, 0, 50, 10);
let b = DirtyRect::new(50, 0, 50, 10);
let u = a.union(&b);
assert_eq!(u.x, 0);
assert_eq!(u.width, 100);
}
#[test]
fn test_dirty_rect_equality() {
let a = DirtyRect::new(1, 2, 3, 4);
let b = DirtyRect::new(1, 2, 3, 4);
assert_eq!(a, b);
}
#[test]
fn test_incremental_renderer_invalidate_resets_state() {
let s1 = DisplayedState {
cues: vec![(0, 1000, "Hello".to_string())],
};
let s2 = DisplayedState {
cues: vec![(0, 1000, "Hello".to_string())],
};
let s3 = DisplayedState {
cues: vec![(0, 1000, "Different".to_string())],
};
assert_eq!(s1, s2);
assert_ne!(s1, s3);
}
}