1#![allow(dead_code)]
29#![allow(clippy::cast_possible_truncation)]
30
31use crate::Timecode;
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
35pub enum OverlayPosition {
36 TopLeft,
38 TopCenter,
40 TopRight,
42 BottomLeft,
44 BottomCenter,
46 BottomRight,
48 Center,
50 Custom { x: u32, y: u32 },
52}
53
54impl Default for OverlayPosition {
55 fn default() -> Self {
56 Self::BottomLeft
57 }
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
62pub enum FontSize {
63 Small,
65 Medium,
67 Large,
69 Custom(u32),
71}
72
73impl FontSize {
74 #[must_use]
76 pub fn pixel_height(&self, frame_height: u32) -> u32 {
77 match self {
78 FontSize::Small => frame_height / 40,
79 FontSize::Medium => frame_height / 25,
80 FontSize::Large => frame_height / 15,
81 FontSize::Custom(h) => *h,
82 }
83 }
84}
85
86impl Default for FontSize {
87 fn default() -> Self {
88 Self::Medium
89 }
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
94pub struct Rgba {
95 pub r: u8,
96 pub g: u8,
97 pub b: u8,
98 pub a: u8,
99}
100
101impl Rgba {
102 pub const WHITE: Self = Self {
104 r: 255,
105 g: 255,
106 b: 255,
107 a: 255,
108 };
109 pub const BLACK: Self = Self {
111 r: 0,
112 g: 0,
113 b: 0,
114 a: 255,
115 };
116 pub const SEMI_BLACK: Self = Self {
118 r: 0,
119 g: 0,
120 b: 0,
121 a: 180,
122 };
123 pub const RED: Self = Self {
125 r: 255,
126 g: 0,
127 b: 0,
128 a: 255,
129 };
130
131 #[must_use]
133 pub const fn new(r: u8, g: u8, b: u8, a: u8) -> Self {
134 Self { r, g, b, a }
135 }
136
137 #[must_use]
139 pub fn blend_over(&self, bg: &Rgba) -> Rgba {
140 let sa = self.a as u32;
141 let da = bg.a as u32;
142 let inv_sa = 255 - sa;
143
144 let out_a = sa + da * inv_sa / 255;
145 if out_a == 0 {
146 return Rgba::new(0, 0, 0, 0);
147 }
148
149 let blend = |fg: u8, bg_ch: u8| -> u8 {
150 let v = (fg as u32 * sa + bg_ch as u32 * da * inv_sa / 255) / out_a;
151 v.min(255) as u8
152 };
153
154 Rgba {
155 r: blend(self.r, bg.r),
156 g: blend(self.g, bg.g),
157 b: blend(self.b, bg.b),
158 a: out_a.min(255) as u8,
159 }
160 }
161}
162
163#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
165pub struct OverlayConfig {
166 pub position: OverlayPosition,
168 pub font_size: FontSize,
170 pub fg_color: Rgba,
172 pub bg_color: Rgba,
174 pub margin: u32,
176 pub show_df_indicator: bool,
178 pub show_background: bool,
180 pub prefix: Option<String>,
182 pub suffix: Option<String>,
184 pub show_field_indicator: bool,
186 pub current_field: u8,
188}
189
190impl Default for OverlayConfig {
191 fn default() -> Self {
192 Self {
193 position: OverlayPosition::default(),
194 font_size: FontSize::default(),
195 fg_color: Rgba::WHITE,
196 bg_color: Rgba::SEMI_BLACK,
197 margin: 16,
198 show_df_indicator: true,
199 show_background: true,
200 prefix: None,
201 suffix: None,
202 show_field_indicator: false,
203 current_field: 1,
204 }
205 }
206}
207
208impl OverlayConfig {
209 #[must_use]
211 pub fn monitoring() -> Self {
212 Self {
213 font_size: FontSize::Small,
214 position: OverlayPosition::BottomLeft,
215 ..Self::default()
216 }
217 }
218
219 #[must_use]
221 pub fn burn_in() -> Self {
222 Self {
223 font_size: FontSize::Large,
224 position: OverlayPosition::TopCenter,
225 bg_color: Rgba::BLACK,
226 ..Self::default()
227 }
228 }
229
230 #[must_use]
232 pub fn no_background() -> Self {
233 Self {
234 show_background: false,
235 bg_color: Rgba::new(0, 0, 0, 0),
236 ..Self::default()
237 }
238 }
239
240 #[must_use]
242 pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
243 self.prefix = Some(prefix.into());
244 self
245 }
246
247 #[must_use]
249 pub fn with_suffix(mut self, suffix: impl Into<String>) -> Self {
250 self.suffix = Some(suffix.into());
251 self
252 }
253
254 #[must_use]
256 pub fn with_field(mut self, field: u8) -> Self {
257 self.show_field_indicator = true;
258 self.current_field = field.clamp(1, 2);
259 self
260 }
261}
262
263#[derive(Debug, Clone)]
265pub struct RenderedOverlay {
266 pub text: String,
268 pub position: OverlayPosition,
270 pub fg_color: Rgba,
272 pub bg_color: Rgba,
274 pub font_height: u32,
276 pub margin: u32,
278 pub show_background: bool,
280 pub text_char_width: usize,
282}
283
284#[must_use]
288pub fn compute_position(
289 position: &OverlayPosition,
290 frame_width: u32,
291 frame_height: u32,
292 text_width_px: u32,
293 text_height_px: u32,
294 margin: u32,
295) -> (u32, u32) {
296 match position {
297 OverlayPosition::TopLeft => (margin, margin),
298 OverlayPosition::TopCenter => {
299 let x = frame_width.saturating_sub(text_width_px) / 2;
300 (x, margin)
301 }
302 OverlayPosition::TopRight => {
303 let x = frame_width.saturating_sub(text_width_px + margin);
304 (x, margin)
305 }
306 OverlayPosition::BottomLeft => {
307 let y = frame_height.saturating_sub(text_height_px + margin);
308 (margin, y)
309 }
310 OverlayPosition::BottomCenter => {
311 let x = frame_width.saturating_sub(text_width_px) / 2;
312 let y = frame_height.saturating_sub(text_height_px + margin);
313 (x, y)
314 }
315 OverlayPosition::BottomRight => {
316 let x = frame_width.saturating_sub(text_width_px + margin);
317 let y = frame_height.saturating_sub(text_height_px + margin);
318 (x, y)
319 }
320 OverlayPosition::Center => {
321 let x = frame_width.saturating_sub(text_width_px) / 2;
322 let y = frame_height.saturating_sub(text_height_px) / 2;
323 (x, y)
324 }
325 OverlayPosition::Custom { x, y } => (*x, *y),
326 }
327}
328
329#[must_use]
334pub fn render_overlay(tc: &Timecode, config: &OverlayConfig) -> RenderedOverlay {
335 let separator = if tc.frame_rate.drop_frame { ';' } else { ':' };
336
337 let mut text = String::new();
338
339 if let Some(ref prefix) = config.prefix {
341 text.push_str(prefix);
342 text.push(' ');
343 }
344
345 text.push_str(&format!(
347 "{:02}:{:02}:{:02}{}{:02}",
348 tc.hours, tc.minutes, tc.seconds, separator, tc.frames
349 ));
350
351 if config.show_df_indicator && tc.frame_rate.drop_frame {
353 text.push_str(" DF");
354 }
355
356 if config.show_field_indicator {
358 text.push_str(&format!(" F{}", config.current_field));
359 }
360
361 if let Some(ref suffix) = config.suffix {
363 text.push(' ');
364 text.push_str(suffix);
365 }
366
367 let font_height = config.font_size.pixel_height(1080);
368 let text_char_width = text.len();
369
370 RenderedOverlay {
371 text,
372 position: config.position,
373 fg_color: config.fg_color,
374 bg_color: config.bg_color,
375 font_height,
376 margin: config.margin,
377 show_background: config.show_background,
378 text_char_width,
379 }
380}
381
382#[must_use]
386pub fn estimate_text_width(text_len: usize, font_height: u32) -> u32 {
387 let char_width = (font_height as f64 * 0.6).ceil() as u32;
388 text_len as u32 * char_width
389}
390
391#[must_use]
395pub fn background_rect(
396 text_x: u32,
397 text_y: u32,
398 text_width_px: u32,
399 font_height: u32,
400 padding: u32,
401) -> (u32, u32, u32, u32) {
402 let x = text_x.saturating_sub(padding);
403 let y = text_y.saturating_sub(padding);
404 let w = text_width_px + padding * 2;
405 let h = font_height + padding * 2;
406 (x, y, w, h)
407}
408
409#[must_use]
413pub fn render_batch(timecodes: &[Timecode], config: &OverlayConfig) -> Vec<RenderedOverlay> {
414 timecodes
415 .iter()
416 .map(|tc| render_overlay(tc, config))
417 .collect()
418}
419
420#[derive(Debug, Clone)]
425pub struct OverlayStamper {
426 config: OverlayConfig,
427 frame_width: u32,
428 frame_height: u32,
429}
430
431impl OverlayStamper {
432 #[must_use]
434 pub fn new(config: OverlayConfig, frame_width: u32, frame_height: u32) -> Self {
435 Self {
436 config,
437 frame_width,
438 frame_height,
439 }
440 }
441
442 #[must_use]
444 pub fn stamp(&self, tc: &Timecode) -> (u32, u32, RenderedOverlay) {
445 let overlay = render_overlay(tc, &self.config);
446 let text_width = estimate_text_width(overlay.text_char_width, overlay.font_height);
447 let (x, y) = compute_position(
448 &overlay.position,
449 self.frame_width,
450 self.frame_height,
451 text_width,
452 overlay.font_height,
453 overlay.margin,
454 );
455 (x, y, overlay)
456 }
457
458 #[must_use]
460 pub fn frame_size(&self) -> (u32, u32) {
461 (self.frame_width, self.frame_height)
462 }
463
464 #[must_use]
466 pub fn config(&self) -> &OverlayConfig {
467 &self.config
468 }
469}
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474 use crate::FrameRate;
475
476 fn tc25(h: u8, m: u8, s: u8, f: u8) -> Timecode {
477 Timecode::new(h, m, s, f, FrameRate::Fps25).expect("valid tc")
478 }
479
480 fn tc_df(h: u8, m: u8, s: u8, f: u8) -> Timecode {
481 Timecode::new(h, m, s, f, FrameRate::Fps2997DF).expect("valid tc")
482 }
483
484 #[test]
485 fn test_render_overlay_basic() {
486 let tc = tc25(1, 30, 0, 12);
487 let config = OverlayConfig::default();
488 let overlay = render_overlay(&tc, &config);
489 assert_eq!(overlay.text, "01:30:00:12");
490 assert_eq!(overlay.position, OverlayPosition::BottomLeft);
491 }
492
493 #[test]
494 fn test_render_overlay_drop_frame_indicator() {
495 let tc = tc_df(0, 1, 0, 2);
496 let config = OverlayConfig::default();
497 let overlay = render_overlay(&tc, &config);
498 assert!(overlay.text.contains(';'));
499 assert!(overlay.text.contains("DF"));
500 }
501
502 #[test]
503 fn test_render_overlay_no_df_indicator() {
504 let tc = tc_df(0, 1, 0, 2);
505 let mut config = OverlayConfig::default();
506 config.show_df_indicator = false;
507 let overlay = render_overlay(&tc, &config);
508 assert!(!overlay.text.contains("DF"));
509 }
510
511 #[test]
512 fn test_render_overlay_with_prefix_and_suffix() {
513 let tc = tc25(0, 0, 0, 0);
514 let config = OverlayConfig::default()
515 .with_prefix("REC")
516 .with_suffix("SC1/TK3");
517 let overlay = render_overlay(&tc, &config);
518 assert!(overlay.text.starts_with("REC "));
519 assert!(overlay.text.ends_with("SC1/TK3"));
520 }
521
522 #[test]
523 fn test_render_overlay_with_field_indicator() {
524 let tc = tc25(0, 0, 0, 0);
525 let config = OverlayConfig::default().with_field(2);
526 let overlay = render_overlay(&tc, &config);
527 assert!(overlay.text.contains("F2"));
528 }
529
530 #[test]
531 fn test_compute_position_corners() {
532 let (x, y) = compute_position(&OverlayPosition::TopLeft, 1920, 1080, 200, 40, 16);
533 assert_eq!(x, 16);
534 assert_eq!(y, 16);
535
536 let (x, y) = compute_position(&OverlayPosition::BottomRight, 1920, 1080, 200, 40, 16);
537 assert_eq!(x, 1920 - 200 - 16);
538 assert_eq!(y, 1080 - 40 - 16);
539 }
540
541 #[test]
542 fn test_compute_position_center() {
543 let (x, y) = compute_position(&OverlayPosition::Center, 1920, 1080, 200, 40, 16);
544 assert_eq!(x, (1920 - 200) / 2);
545 assert_eq!(y, (1080 - 40) / 2);
546 }
547
548 #[test]
549 fn test_compute_position_custom() {
550 let (x, y) = compute_position(
551 &OverlayPosition::Custom { x: 100, y: 200 },
552 1920,
553 1080,
554 200,
555 40,
556 16,
557 );
558 assert_eq!(x, 100);
559 assert_eq!(y, 200);
560 }
561
562 #[test]
563 fn test_font_size_pixel_height() {
564 assert_eq!(FontSize::Small.pixel_height(1080), 27);
565 assert_eq!(FontSize::Medium.pixel_height(1080), 43);
566 assert_eq!(FontSize::Large.pixel_height(1080), 72);
567 assert_eq!(FontSize::Custom(50).pixel_height(1080), 50);
568 }
569
570 #[test]
571 fn test_estimate_text_width() {
572 let w = estimate_text_width(11, 40); assert_eq!(w, 11 * 24); }
575
576 #[test]
577 fn test_background_rect() {
578 let (x, y, w, h) = background_rect(100, 200, 264, 40, 8);
579 assert_eq!(x, 92);
580 assert_eq!(y, 192);
581 assert_eq!(w, 280);
582 assert_eq!(h, 56);
583 }
584
585 #[test]
586 fn test_render_batch() {
587 let tcs = vec![tc25(0, 0, 0, 0), tc25(0, 0, 0, 1), tc25(0, 0, 0, 2)];
588 let config = OverlayConfig::default();
589 let overlays = render_batch(&tcs, &config);
590 assert_eq!(overlays.len(), 3);
591 assert!(overlays[1].text.contains("01"));
592 }
593
594 #[test]
595 fn test_overlay_stamper() {
596 let config = OverlayConfig::monitoring();
597 let stamper = OverlayStamper::new(config, 1920, 1080);
598 let tc = tc25(12, 0, 0, 0);
599 let (x, y, overlay) = stamper.stamp(&tc);
600 assert!(x < 1920);
601 assert!(y < 1080);
602 assert!(overlay.text.contains("12:00:00:00"));
603 assert_eq!(stamper.frame_size(), (1920, 1080));
604 }
605
606 #[test]
607 fn test_rgba_blend_over() {
608 let fg = Rgba::new(255, 0, 0, 128);
609 let bg = Rgba::WHITE;
610 let result = fg.blend_over(&bg);
611 assert!(result.r > result.g);
613 assert_eq!(result.a, 255);
614 }
615
616 #[test]
617 fn test_monitoring_preset() {
618 let config = OverlayConfig::monitoring();
619 assert_eq!(config.font_size, FontSize::Small);
620 assert_eq!(config.position, OverlayPosition::BottomLeft);
621 }
622
623 #[test]
624 fn test_burn_in_preset() {
625 let config = OverlayConfig::burn_in();
626 assert_eq!(config.font_size, FontSize::Large);
627 assert_eq!(config.position, OverlayPosition::TopCenter);
628 assert_eq!(config.bg_color, Rgba::BLACK);
629 }
630}