use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use super::subtitle::{SubtitleEntry, SubtitleStyle};
#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
pub enum OverlayPosition {
TopLeft,
TopCenter,
TopRight,
MiddleLeft,
#[default]
MiddleCenter,
MiddleRight,
BottomLeft,
BottomCenter,
BottomRight,
Custom(f32, f32),
}
impl OverlayPosition {
#[must_use]
pub fn to_ass_alignment(&self) -> u8 {
match self {
Self::BottomLeft => 1,
Self::BottomCenter => 2,
Self::BottomRight => 3,
Self::MiddleLeft => 4,
Self::MiddleCenter | Self::Custom(_, _) => 5,
Self::MiddleRight => 6,
Self::TopLeft => 7,
Self::TopCenter => 8,
Self::TopRight => 9,
}
}
#[must_use]
pub fn to_drawtext_position(&self, margin: u32) -> (String, String) {
let m = margin.to_string();
match self {
Self::TopLeft => (m.clone(), m),
Self::TopCenter => ("(w-text_w)/2".to_string(), m),
Self::TopRight => (format!("w-text_w-{m}"), m),
Self::MiddleLeft => (m, "(h-text_h)/2".to_string()),
Self::MiddleCenter => ("(w-text_w)/2".to_string(), "(h-text_h)/2".to_string()),
Self::MiddleRight => (format!("w-text_w-{m}"), "(h-text_h)/2".to_string()),
Self::BottomLeft => (m.clone(), format!("h-text_h-{m}")),
Self::BottomCenter => ("(w-text_w)/2".to_string(), format!("h-text_h-{m}")),
Self::BottomRight => (format!("w-text_w-{m}"), format!("h-text_h-{m}")),
Self::Custom(x, y) => {
if *x < 1.0 && *y < 1.0 {
(format!("w*{x}"), format!("h*{y}"))
} else {
(format!("{x}"), format!("{y}"))
}
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OverlayStyle {
pub font_name: String,
pub font_size: u32,
pub color: String,
pub background_color: String,
pub background_opacity: f32,
pub outline_color: String,
pub outline_width: f32,
pub shadow_offset: f32,
pub bold: bool,
pub italic: bool,
}
impl Default for OverlayStyle {
fn default() -> Self {
Self {
font_name: "Arial".to_string(),
font_size: 32,
color: "FFFFFF".to_string(),
background_color: "000000".to_string(),
background_opacity: 0.5,
outline_color: "000000".to_string(),
outline_width: 2.0,
shadow_offset: 1.0,
bold: false,
italic: false,
}
}
}
impl OverlayStyle {
#[must_use]
pub fn speaker_label() -> Self {
Self {
font_name: "Arial".to_string(),
font_size: 28,
color: "FFFF00".to_string(), background_color: "000000".to_string(),
background_opacity: 0.7,
outline_color: "000000".to_string(),
outline_width: 2.0,
shadow_offset: 1.0,
bold: true,
italic: false,
}
}
#[must_use]
pub fn analysis() -> Self {
Self {
font_name: "Consolas".to_string(),
font_size: 20,
color: "80FF80".to_string(), background_color: "000000".to_string(),
background_opacity: 0.8,
outline_color: "000000".to_string(),
outline_width: 1.0,
shadow_offset: 0.0,
bold: false,
italic: false,
}
}
#[must_use]
pub fn to_ass_style(&self, name: &str, position: OverlayPosition) -> SubtitleStyle {
let primary = format!(
"&H00{}{}{}",
&self.color[4..6],
&self.color[2..4],
&self.color[0..2]
);
let outline = format!(
"&H00{}{}{}",
&self.outline_color[4..6],
&self.outline_color[2..4],
&self.outline_color[0..2]
);
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let alpha = ((1.0 - self.background_opacity) * 255.0) as u8;
let back = format!(
"&H{:02X}{}{}{}",
alpha,
&self.background_color[4..6],
&self.background_color[2..4],
&self.background_color[0..2]
);
SubtitleStyle {
name: name.to_string(),
font_name: self.font_name.clone(),
font_size: self.font_size,
primary_color: primary,
outline_color: outline,
back_color: back,
bold: self.bold,
italic: self.italic,
outline: self.outline_width,
shadow: self.shadow_offset,
alignment: position.to_ass_alignment(),
margin_l: 20,
margin_r: 20,
margin_v: 20,
}
}
#[must_use]
pub fn to_drawtext_params(&self, position: OverlayPosition) -> String {
let (x, y) = position.to_drawtext_position(20);
let mut params = vec![
format!("fontfile=/System/Library/Fonts/{}.ttf", self.font_name),
format!("fontsize={}", self.font_size),
format!("fontcolor=0x{}", self.color),
format!("x={x}"),
format!("y={y}"),
];
if self.outline_width > 0.0 {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
params.push(format!("borderw={}", self.outline_width as u32));
params.push(format!("bordercolor=0x{}", self.outline_color));
}
if self.shadow_offset > 0.0 {
#[allow(clippy::cast_possible_truncation)]
params.push(format!("shadowx={}", self.shadow_offset as i32));
#[allow(clippy::cast_possible_truncation)]
params.push(format!("shadowy={}", self.shadow_offset as i32));
params.push("shadowcolor=0x000000@0.5".to_string());
}
if !self.background_color.is_empty() && self.background_opacity > 0.0 {
params.push(format!(
"box=1:boxcolor=0x{}@{}:boxborderw=5",
self.background_color, self.background_opacity
));
}
params.join(":")
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OverlayEntry {
pub start_ms: u64,
pub end_ms: u64,
pub text: String,
pub position: OverlayPosition,
pub style: Option<OverlayStyle>,
pub metadata: HashMap<String, String>,
}
impl OverlayEntry {
#[must_use]
pub fn new(start_ms: u64, end_ms: u64, text: impl Into<String>) -> Self {
Self {
start_ms,
end_ms,
text: text.into(),
position: OverlayPosition::default(),
style: None,
metadata: HashMap::new(),
}
}
#[must_use]
pub fn with_position(mut self, position: OverlayPosition) -> Self {
self.position = position;
self
}
#[must_use]
pub fn with_style(mut self, style: OverlayStyle) -> Self {
self.style = Some(style);
self
}
#[must_use]
pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.metadata.insert(key.into(), value.into());
self
}
#[must_use]
pub fn to_subtitle_entry(&self, style_name: &str) -> SubtitleEntry {
SubtitleEntry {
start_ms: self.start_ms,
end_ms: self.end_ms,
text: self.text.clone(),
speaker: None,
style: Some(style_name.to_string()),
}
}
}
#[derive(Debug, Clone)]
pub struct OverlayTrack {
pub name: String,
pub default_position: OverlayPosition,
pub default_style: OverlayStyle,
pub entries: Vec<OverlayEntry>,
}
impl OverlayTrack {
#[must_use]
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
default_position: OverlayPosition::default(),
default_style: OverlayStyle::default(),
entries: Vec::new(),
}
}
#[must_use]
pub fn with_position(mut self, position: OverlayPosition) -> Self {
self.default_position = position;
self
}
#[must_use]
pub fn with_style(mut self, style: OverlayStyle) -> Self {
self.default_style = style;
self
}
pub fn add_entry(&mut self, entry: OverlayEntry) {
self.entries.push(entry);
}
pub fn add_entries(&mut self, entries: impl IntoIterator<Item = OverlayEntry>) {
self.entries.extend(entries);
}
pub fn sort_by_time(&mut self) {
self.entries.sort_by_key(|e| e.start_ms);
}
#[must_use]
pub fn to_subtitle_entries(&self) -> Vec<SubtitleEntry> {
self.entries
.iter()
.map(|e| e.to_subtitle_entry(&self.name))
.collect()
}
#[must_use]
pub fn to_ass_style(&self) -> SubtitleStyle {
self.default_style
.to_ass_style(&self.name, self.default_position)
}
}
#[derive(Debug, Clone)]
pub struct SpeakerLabelOverlay {
pub style: OverlayStyle,
pub position: OverlayPosition,
pub format: String,
pub min_duration_ms: u64,
}
impl Default for SpeakerLabelOverlay {
fn default() -> Self {
Self {
style: OverlayStyle::speaker_label(),
position: OverlayPosition::TopLeft,
format: "{speaker}".to_string(),
min_duration_ms: 500,
}
}
}
impl SpeakerLabelOverlay {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_format(mut self, format: impl Into<String>) -> Self {
self.format = format.into();
self
}
#[must_use]
pub fn with_position(mut self, position: OverlayPosition) -> Self {
self.position = position;
self
}
#[must_use]
pub fn generate(&self, segments: &[(u64, u64, String)]) -> OverlayTrack {
let mut track = OverlayTrack::new("Speaker")
.with_position(self.position)
.with_style(self.style.clone());
let mut merged: Vec<(u64, u64, String)> = Vec::new();
for (start, end, speaker) in segments {
if let Some(last) = merged.last_mut() {
if last.2 == *speaker && *start <= last.1 + 500 {
last.1 = (*end).max(last.1);
continue;
}
}
merged.push((*start, *end, speaker.clone()));
}
for (start, end, speaker) in merged {
let duration = end.saturating_sub(start);
if duration < self.min_duration_ms {
continue;
}
let text = self.format.replace("{speaker}", &speaker);
track.add_entry(
OverlayEntry::new(start, end, text)
.with_position(self.position)
.with_metadata("speaker", speaker),
);
}
track.sort_by_time();
track
}
}
#[derive(Debug, Clone)]
pub struct AnalysisOverlay {
pub style: OverlayStyle,
pub position: OverlayPosition,
pub max_line_length: usize,
pub max_lines: usize,
}
impl Default for AnalysisOverlay {
fn default() -> Self {
Self {
style: OverlayStyle::analysis(),
position: OverlayPosition::TopRight,
max_line_length: 40,
max_lines: 4,
}
}
}
impl AnalysisOverlay {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_position(mut self, position: OverlayPosition) -> Self {
self.position = position;
self
}
#[must_use]
pub fn with_style(mut self, style: OverlayStyle) -> Self {
self.style = style;
self
}
fn wrap_text(&self, text: &str) -> String {
let mut lines = Vec::new();
let mut current_line = String::new();
for word in text.split_whitespace() {
if current_line.is_empty() {
current_line = word.to_string();
} else if current_line.len() + 1 + word.len() <= self.max_line_length {
current_line.push(' ');
current_line.push_str(word);
} else {
lines.push(current_line);
current_line = word.to_string();
if lines.len() >= self.max_lines {
break;
}
}
}
if !current_line.is_empty() && lines.len() < self.max_lines {
lines.push(current_line);
}
lines.join("\n")
}
#[must_use]
pub fn generate(
&self,
entries: &[(u64, u64, String, HashMap<String, String>)],
) -> OverlayTrack {
let mut track = OverlayTrack::new("Analysis")
.with_position(self.position)
.with_style(self.style.clone());
for (start, end, text, metadata) in entries {
let wrapped = self.wrap_text(text);
let mut entry = OverlayEntry::new(*start, *end, wrapped).with_position(self.position);
for (k, v) in metadata {
entry = entry.with_metadata(k.clone(), v.clone());
}
track.add_entry(entry);
}
track.sort_by_time();
track
}
#[must_use]
pub fn generate_emotion_overlay(&self, emotions: &[(u64, u64, String, f32)]) -> OverlayTrack {
let entries: Vec<_> = emotions
.iter()
.map(|(start, end, emotion, confidence)| {
let text = format!("{emotion}: {:.0}%", confidence * 100.0);
let mut meta = HashMap::new();
meta.insert("type".to_string(), "emotion".to_string());
meta.insert("confidence".to_string(), confidence.to_string());
(*start, *end, text, meta)
})
.collect();
self.generate(&entries)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_overlay_position_to_ass_alignment() {
assert_eq!(OverlayPosition::BottomLeft.to_ass_alignment(), 1);
assert_eq!(OverlayPosition::BottomCenter.to_ass_alignment(), 2);
assert_eq!(OverlayPosition::TopLeft.to_ass_alignment(), 7);
assert_eq!(OverlayPosition::TopRight.to_ass_alignment(), 9);
}
#[test]
fn test_speaker_label_generation() {
let overlay = SpeakerLabelOverlay::new().with_format("Speaker: {speaker}");
let segments = vec![
(0, 2000, "John".to_string()),
(2500, 4000, "Jane".to_string()),
(4000, 6000, "John".to_string()),
];
let track = overlay.generate(&segments);
assert_eq!(track.entries.len(), 3);
assert_eq!(track.entries[0].text, "Speaker: John");
assert_eq!(track.entries[1].text, "Speaker: Jane");
}
#[test]
fn test_speaker_label_merge_consecutive() {
let overlay = SpeakerLabelOverlay::new();
let segments = vec![
(0, 1000, "John".to_string()),
(1000, 2000, "John".to_string()), (2500, 4000, "Jane".to_string()),
];
let track = overlay.generate(&segments);
assert_eq!(track.entries.len(), 2);
assert_eq!(track.entries[0].end_ms, 2000);
}
#[test]
fn test_analysis_text_wrap() {
let overlay = AnalysisOverlay {
max_line_length: 20,
max_lines: 3,
..Default::default()
};
let wrapped = overlay.wrap_text("This is a very long text that needs to be wrapped");
let lines: Vec<&str> = wrapped.lines().collect();
assert!(lines.len() <= 3);
for line in &lines {
assert!(line.len() <= 25); }
}
#[test]
fn test_overlay_track_to_subtitle_entries() {
let mut track = OverlayTrack::new("Test");
track.add_entry(OverlayEntry::new(0, 2000, "Test entry"));
let entries = track.to_subtitle_entries();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].style, Some("Test".to_string()));
}
#[test]
fn test_drawtext_position() {
let (x, y) = OverlayPosition::TopLeft.to_drawtext_position(20);
assert_eq!(x, "20");
assert_eq!(y, "20");
let (x, y) = OverlayPosition::BottomCenter.to_drawtext_position(10);
assert_eq!(x, "(w-text_w)/2");
assert_eq!(y, "h-text_h-10");
}
}