1use super::ass;
4use super::filter::SubtitleFilter;
5use super::loader;
6use super::render::fonts::FontStore;
7use super::render::layout::RenderOptions;
8use super::render::PureRenderer;
9use super::source::SubtitleSource;
10use std::ffi::CString;
11use std::path::{Path, PathBuf};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15#[non_exhaustive]
16pub enum FontProvider {
17 #[default]
19 Autodetect,
20 None,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30#[non_exhaustive]
31pub enum TextShaping {
32 #[default]
34 Auto,
35 Simple,
39 Complex,
41}
42
43#[derive(Debug, thiserror::Error)]
50#[non_exhaustive]
51pub enum SubtitleError {
52 #[error("libass initialization failed: {0}")]
53 Init(String),
54 #[error("subtitle parsing failed: {0}")]
55 Parse(String),
56 #[error("invalid subtitle option: {0}")]
57 InvalidOption(String),
58 #[error("font path not usable: {0}")]
59 FontPath(String),
60 #[error("cannot open subtitle source: {0}")]
61 Open(String),
62 #[error("no subtitle stream: {0}")]
63 NoSubtitleStream(String),
64 #[error("bitmap subtitles ({0}) cannot be burned; only text-based subtitles are supported")]
65 BitmapSubtitles(String),
66 #[error("subtitle decoding failed: {0}")]
67 Decode(String),
68}
69
70#[derive(Default)]
72#[must_use = "builders do nothing unless build() is called"]
73pub struct SubtitleFilterBuilder {
74 source: Option<SubtitleSource>,
75 fonts_dir: Option<PathBuf>,
76 default_font_file: Option<PathBuf>,
77 default_family: Option<String>,
78 font_provider: FontProvider,
79 force_style: Option<String>,
80 original_size: Option<(u32, u32)>,
81 charenc: Option<String>,
82 stream_index: Option<usize>,
83 wrap_unicode: Option<bool>,
84 shaping: TextShaping,
85 font_scale: Option<f64>,
86 line_position: Option<f64>,
87 margins: Option<(i32, i32, i32, i32)>,
88 use_margins: Option<bool>,
89}
90
91impl std::fmt::Debug for SubtitleFilterBuilder {
92 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93 let source_kind = match &self.source {
96 None => "none",
97 Some(SubtitleSource::AssContent(_)) => "ass_content",
98 Some(SubtitleSource::File(_)) => "file",
99 Some(SubtitleSource::SrtContent(_)) => "srt_content",
100 };
101 f.debug_struct("SubtitleFilterBuilder")
102 .field("source", &source_kind)
103 .field("fonts_dir", &self.fonts_dir)
104 .field("default_font_file", &self.default_font_file)
105 .field("default_family", &self.default_family)
106 .field("font_provider", &self.font_provider)
107 .field("force_style", &self.force_style)
108 .field("original_size", &self.original_size)
109 .field("charenc", &self.charenc)
110 .field("stream_index", &self.stream_index)
111 .field("wrap_unicode", &self.wrap_unicode)
112 .field("shaping", &self.shaping)
113 .field("font_scale", &self.font_scale)
114 .field("line_position", &self.line_position)
115 .field("margins", &self.margins)
116 .field("use_margins", &self.use_margins)
117 .finish()
118 }
119}
120
121impl SubtitleFilterBuilder {
122 pub fn ass_content(mut self, content: impl Into<String>) -> Self {
124 self.source = Some(SubtitleSource::AssContent(content.into()));
125 self
126 }
127
128 pub fn fonts_dir(mut self, dir: impl Into<PathBuf>) -> Self {
131 self.fonts_dir = Some(dir.into());
132 self
133 }
134
135 pub fn default_font_file(mut self, font: impl Into<PathBuf>) -> Self {
139 self.default_font_file = Some(font.into());
140 self
141 }
142
143 pub fn default_family(mut self, family: impl Into<String>) -> Self {
145 self.default_family = Some(family.into());
146 self
147 }
148
149 pub fn font_provider(mut self, provider: FontProvider) -> Self {
151 self.font_provider = provider;
152 self
153 }
154
155 pub fn force_style(mut self, style: impl Into<String>) -> Self {
159 self.force_style = Some(style.into());
160 self
161 }
162
163 pub fn original_size(mut self, width: u32, height: u32) -> Self {
166 self.original_size = Some((width, height));
167 self
168 }
169
170 pub fn charenc(mut self, charenc: impl Into<String>) -> Self {
174 self.charenc = Some(charenc.into());
175 self
176 }
177
178 pub fn file(mut self, path: impl Into<PathBuf>) -> Self {
182 self.source = Some(SubtitleSource::File(path.into()));
183 self
184 }
185
186 pub fn srt_content(mut self, content: impl Into<String>) -> Self {
189 self.source = Some(SubtitleSource::SrtContent(content.into()));
190 self
191 }
192
193 pub fn stream_index(mut self, index: usize) -> Self {
197 self.stream_index = Some(index);
198 self
199 }
200
201 pub fn wrap_unicode(mut self, enable: bool) -> Self {
207 self.wrap_unicode = Some(enable);
208 self
209 }
210
211 pub fn shaping(mut self, shaping: TextShaping) -> Self {
215 self.shaping = shaping;
216 self
217 }
218
219 pub fn font_scale(mut self, scale: f64) -> Self {
222 self.font_scale = Some(scale);
223 self
224 }
225
226 pub fn line_position(mut self, percent: f64) -> Self {
230 self.line_position = Some(percent);
231 self
232 }
233
234 pub fn margins(mut self, top: i32, bottom: i32, left: i32, right: i32) -> Self {
240 self.margins = Some((top, bottom, left, right));
241 self
242 }
243
244 pub fn use_margins(mut self, enable: bool) -> Self {
248 self.use_margins = Some(enable);
249 self
250 }
251
252 pub fn build(self) -> Result<SubtitleFilter, SubtitleError> {
255 let source = self.source.ok_or_else(|| {
256 SubtitleError::InvalidOption(
257 "no subtitle source set; call ass_content(...) first".to_string(),
258 )
259 })?;
260
261 if let Some((width, height)) = self.original_size {
262 if width == 0 || height == 0 || width > i32::MAX as u32 || height > i32::MAX as u32 {
263 return Err(SubtitleError::InvalidOption(format!(
264 "original_size must be within 1..=i32::MAX, got {width}x{height}"
265 )));
266 }
267 }
268
269 if let Some(scale) = self.font_scale {
270 if !(scale.is_finite() && scale > 0.0) {
271 return Err(SubtitleError::InvalidOption(format!(
272 "font_scale must be finite and > 0, got {scale}"
273 )));
274 }
275 }
276 if let Some(percent) = self.line_position {
277 if !(percent.is_finite() && (0.0..=100.0).contains(&percent)) {
278 return Err(SubtitleError::InvalidOption(format!(
279 "line_position must be within 0.0..=100.0 percent, got {percent}"
280 )));
281 }
282 }
283
284 if let Some(dir) = &self.fonts_dir {
285 if !dir.is_dir() {
286 return Err(SubtitleError::FontPath(format!(
287 "fonts_dir is not an existing directory: {}",
288 dir.display()
289 )));
290 }
291 }
292 if let Some(font) = &self.default_font_file {
293 if !font.is_file() {
294 return Err(SubtitleError::FontPath(format!(
295 "default_font_file is not an existing file: {}",
296 font.display()
297 )));
298 }
299 }
300
301 let force_style_entries: Vec<String> = match &self.force_style {
302 Some(style) => {
303 let entries: Vec<String> = style
304 .split(',')
305 .filter(|s| !s.is_empty())
306 .map(str::to_string)
307 .collect();
308 if entries.iter().any(|s| s.as_bytes().contains(&0)) {
309 return Err(SubtitleError::InvalidOption(
310 "force_style contains a NUL byte".to_string(),
311 ));
312 }
313 entries
314 }
315 None => Vec::new(),
316 };
317
318 let (mut script, attachments, native_ass) = match &source {
320 SubtitleSource::AssContent(content) => {
321 if self.charenc.is_some() {
322 return Err(SubtitleError::InvalidOption(
323 "charenc only applies to file/SRT sources; ass_content must be UTF-8"
324 .to_string(),
325 ));
326 }
327 if self.stream_index.is_some() {
328 return Err(SubtitleError::InvalidOption(
329 "stream_index only applies to file sources".to_string(),
330 ));
331 }
332 if content.as_bytes().contains(&0) {
333 return Err(SubtitleError::InvalidOption(
334 "ass_content contains a NUL byte".to_string(),
335 ));
336 }
337 let script =
338 ass::parse(content).map_err(|e| SubtitleError::Parse(e.to_string()))?;
339 if !content.contains("Dialogue:") {
340 log::warn!(
341 "subtitle script parsed but contains no Dialogue events; \
342 nothing will be rendered"
343 );
344 }
345 (script, Vec::new(), true)
346 }
347 SubtitleSource::File(path) => {
348 let loaded = loader::load_subtitles(
349 loader::LoaderInput::Path(path),
350 &loader::LoaderOptions {
351 charenc: self.charenc.as_deref(),
352 stream_index: self.stream_index,
353 },
354 )?;
355 (loaded.script, loaded.attachments, loaded.native_ass)
356 }
357 SubtitleSource::SrtContent(content) => {
358 let loaded = loader::load_subtitles(
359 loader::LoaderInput::Memory(content.as_bytes()),
360 &loader::LoaderOptions {
361 charenc: self.charenc.as_deref(),
362 stream_index: self.stream_index,
363 },
364 )?;
365 (loaded.script, loaded.attachments, loaded.native_ass)
366 }
367 };
368
369 if !force_style_entries.is_empty() {
370 let refs: Vec<&str> = force_style_entries.iter().map(String::as_str).collect();
371 ass::apply_force_style(&mut script, &refs);
372 }
373
374 let mut fonts = FontStore::new(self.font_provider == FontProvider::Autodetect);
377 if let Some(dir) = &self.fonts_dir {
378 fonts.load_fonts_dir(dir);
379 }
380 if let Some(font) = &self.default_font_file {
381 if !fonts.load_default_font_file(font) {
382 return Err(SubtitleError::FontPath(format!(
383 "default_font_file is not a parsable font file: {}",
384 font.display()
385 )));
386 }
387 }
388 for embedded in &script.fonts {
389 fonts.add_memory_font(&embedded.filename.clone(), embedded.data.clone());
390 }
391 for (name, data) in attachments {
392 fonts.add_memory_font(&name, data);
393 }
394 fonts.set_default_family(self.default_family.clone());
395 if self.font_provider == FontProvider::None && fonts.loaded_faces() == 0 {
396 log::warn!(
397 "subtitle filter: FontProvider::None with no fonts_dir/default_font_file/embedded \
398 fonts; text events cannot be rendered"
399 );
400 }
401
402 let wrap_unicode = self.wrap_unicode.unwrap_or(!native_ass);
405
406 let opts = RenderOptions {
407 font_scale: self.font_scale.unwrap_or(1.0),
408 line_position: self.line_position.unwrap_or(0.0),
409 margins: self.margins,
410 use_margins: self.use_margins.unwrap_or(false),
411 complex_shaping: self.shaping != TextShaping::Simple,
412 wrap_unicode,
413 };
414
415 let renderer = PureRenderer::new(script, fonts, opts);
416 Ok(SubtitleFilter::new(Box::new(renderer), self.original_size))
417 }
418}
419
420pub(crate) fn path_cstring(path: &Path) -> Result<CString, String> {
424 #[cfg(unix)]
425 let bytes = std::os::unix::ffi::OsStrExt::as_bytes(path.as_os_str()).to_vec();
426 #[cfg(not(unix))]
427 let bytes = path
428 .to_str()
429 .ok_or_else(|| format!("path is not valid UTF-8: {}", path.display()))?
430 .as_bytes()
431 .to_vec();
432 CString::new(bytes).map_err(|_| format!("path contains a NUL byte: {}", path.display()))
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438 use crate::subtitle::test_util;
439
440 #[test]
441 fn builds_from_valid_ass_content() {
442 let filter = SubtitleFilter::builder()
443 .ass_content(test_util::minimal_ass(test_util::HELLO_EVENT))
444 .build();
445 assert!(filter.is_ok(), "{:?}", filter.err());
446 }
447
448 #[test]
449 fn builds_with_zero_events() {
450 let filter = SubtitleFilter::builder()
452 .ass_content(test_util::minimal_ass(""))
453 .build();
454 assert!(filter.is_ok(), "{:?}", filter.err());
455 }
456
457 #[test]
458 fn rejects_missing_source() {
459 let err = SubtitleFilter::builder().build().unwrap_err();
460 assert!(matches!(err, SubtitleError::InvalidOption(_)), "{err:?}");
461 }
462
463 #[test]
464 fn rejects_garbage_content() {
465 let err = SubtitleFilter::builder()
466 .ass_content("definitely not an ass script")
467 .build()
468 .unwrap_err();
469 assert!(matches!(err, SubtitleError::Parse(_)), "{err:?}");
470 }
471
472 #[test]
473 fn rejects_nul_byte_in_content() {
474 let err = SubtitleFilter::builder()
475 .ass_content(format!("{}\0", test_util::minimal_ass("")))
476 .build()
477 .unwrap_err();
478 assert!(matches!(err, SubtitleError::InvalidOption(_)), "{err:?}");
479 }
480
481 #[test]
482 fn rejects_charenc_with_ass_content() {
483 let err = SubtitleFilter::builder()
484 .ass_content(test_util::minimal_ass(""))
485 .charenc("GBK")
486 .build()
487 .unwrap_err();
488 assert!(matches!(err, SubtitleError::InvalidOption(_)), "{err:?}");
489 }
490
491 #[test]
492 fn rejects_missing_font_file() {
493 let err = SubtitleFilter::builder()
494 .ass_content(test_util::minimal_ass(""))
495 .default_font_file("/nonexistent/definitely/missing.ttf")
496 .build()
497 .unwrap_err();
498 assert!(matches!(err, SubtitleError::FontPath(_)), "{err:?}");
499 }
500
501 #[test]
502 fn rejects_missing_fonts_dir() {
503 let err = SubtitleFilter::builder()
504 .ass_content(test_util::minimal_ass(""))
505 .fonts_dir("/nonexistent/definitely/missing-dir")
506 .build()
507 .unwrap_err();
508 assert!(matches!(err, SubtitleError::FontPath(_)), "{err:?}");
509 }
510
511 #[test]
514 fn force_style_changes_rendering() {
515 let Some(font) = test_util::test_font() else {
516 eprintln!("skipping: no known test font present on this machine");
517 return;
518 };
519
520 let max_bitmap_width = |force_style: Option<&str>| -> i32 {
521 let mut builder = SubtitleFilter::builder()
522 .ass_content(test_util::minimal_ass(test_util::HELLO_EVENT))
523 .default_font_file(font)
524 .font_provider(FontProvider::None);
525 if let Some(style) = force_style {
526 builder = builder.force_style(style);
527 }
528 let mut filter = builder.build().expect("build");
529 let renderer = filter.renderer_mut();
530 renderer.set_frame_size(640, 360);
531 renderer.set_storage_size(640, 360);
532 renderer
533 .render_frame(1000)
534 .iter()
535 .map(|image| image.w as i32)
536 .max()
537 .unwrap_or(0)
538 };
539
540 let base = max_bitmap_width(None);
541 let styled = max_bitmap_width(Some("FontSize=96"));
542 assert!(base > 0, "baseline render produced no bitmaps");
543 assert!(
544 styled > base,
545 "FontSize=96 should widen bitmaps (base {base}, styled {styled})"
546 );
547 }
548
549 fn hello_metrics(
553 configure: impl FnOnce(SubtitleFilterBuilder) -> SubtitleFilterBuilder,
554 ) -> Option<(usize, i32)> {
555 let font = test_util::test_font()?;
556 let builder = SubtitleFilter::builder()
557 .ass_content(test_util::minimal_ass(test_util::HELLO_EVENT))
558 .default_font_file(font)
559 .font_provider(FontProvider::None);
560 let mut filter = configure(builder).build().expect("build");
561 let renderer = filter.renderer_mut();
562 renderer.set_frame_size(640, 360);
563 renderer.set_storage_size(640, 360);
564 let overlays = renderer.render_frame(1000);
565 assert!(!overlays.is_empty(), "expected visible overlays");
566 let max_w = overlays.iter().map(|image| image.w).max().unwrap_or(0);
567 let bottom = overlays
568 .iter()
569 .map(|image| image.dst_y + image.h as i32)
570 .max()
571 .unwrap_or(0);
572 Some((max_w, bottom))
573 }
574
575 #[test]
576 fn shaping_option_renders_with_both_engines() {
577 for shaping in [TextShaping::Simple, TextShaping::Complex] {
578 let Some((width, _)) = hello_metrics(|builder| builder.shaping(shaping)) else {
579 eprintln!("skipping: no known test font present on this machine");
580 return;
581 };
582 assert!(width > 0, "{shaping:?} shaping produced empty overlays");
583 }
584 }
585
586 #[test]
587 fn font_scale_scales_rendering() {
588 let Some((base_width, _)) = hello_metrics(|builder| builder) else {
589 eprintln!("skipping: no known test font present on this machine");
590 return;
591 };
592 let (scaled_width, _) =
593 hello_metrics(|builder| builder.font_scale(2.0)).expect("font probed above");
594 assert!(
595 scaled_width > base_width,
596 "font_scale(2.0) should widen bitmaps (base {base_width}, scaled {scaled_width})"
597 );
598 }
599
600 #[test]
601 fn line_position_raises_regular_events() {
602 let Some((_, base_bottom)) = hello_metrics(|builder| builder) else {
603 eprintln!("skipping: no known test font present on this machine");
604 return;
605 };
606 let (_, raised_bottom) =
607 hello_metrics(|builder| builder.line_position(50.0)).expect("font probed above");
608 assert!(
610 raised_bottom + 100 < base_bottom,
611 "line_position(50) should raise the line (base {base_bottom}, raised {raised_bottom})"
612 );
613 }
614
615 #[test]
616 fn margins_letterbox_and_whole_frame_placement() {
617 let Some((_, base_bottom)) = hello_metrics(|builder| builder) else {
618 eprintln!("skipping: no known test font present on this machine");
619 return;
620 };
621 let (_, boxed_bottom) =
622 hello_metrics(|builder| builder.margins(0, 100, 0, 0)).expect("font probed above");
623 let (_, whole_bottom) =
624 hello_metrics(|builder| builder.margins(0, 100, 0, 0).use_margins(true))
625 .expect("font probed above");
626 assert!(
627 boxed_bottom <= base_bottom - 80,
628 "a 100px bottom margin should lift regular events above the bar \
629 (base {base_bottom}, letterboxed {boxed_bottom})"
630 );
631 assert!(
632 whole_bottom > boxed_bottom,
633 "use_margins(true) should allow placement on the bar again \
634 (letterboxed {boxed_bottom}, whole-frame {whole_bottom})"
635 );
636 }
637
638 #[test]
639 fn rejects_invalid_font_scale_and_line_position() {
640 for scale in [0.0, -1.0, f64::NAN, f64::INFINITY] {
641 let err = SubtitleFilter::builder()
642 .ass_content(test_util::minimal_ass(""))
643 .font_scale(scale)
644 .build()
645 .unwrap_err();
646 assert!(
647 matches!(err, SubtitleError::InvalidOption(_)),
648 "{scale}: {err:?}"
649 );
650 }
651 for percent in [-0.1, 100.1, f64::NAN] {
652 let err = SubtitleFilter::builder()
653 .ass_content(test_util::minimal_ass(""))
654 .line_position(percent)
655 .build()
656 .unwrap_err();
657 assert!(
658 matches!(err, SubtitleError::InvalidOption(_)),
659 "{percent}: {err:?}"
660 );
661 }
662 }
663
664 const SRT_SAMPLE: &str = "1\n00:00:00,000 --> 00:00:05,000\nHello from SRT\n\n2\n00:00:06,000 --> 00:00:08,000\n<i>Second line</i>\n";
665
666 fn write_temp(name: &str, content: &str) -> std::path::PathBuf {
667 let path = std::env::temp_dir().join(format!(
668 "ez_ffmpeg_subtitle_test_{}_{name}",
669 std::process::id()
670 ));
671 std::fs::write(&path, content).expect("write temp subtitle file");
672 path
673 }
674
675 #[test]
678 fn builds_from_srt_content_and_times_events() {
679 let Some(font) = test_util::test_font() else {
680 eprintln!("skipping: no known test font present on this machine");
681 return;
682 };
683 let mut filter = SubtitleFilter::builder()
684 .srt_content(SRT_SAMPLE)
685 .default_font_file(font)
686 .font_provider(FontProvider::None)
687 .build()
688 .expect("build from srt_content");
689 let renderer = filter.renderer_mut();
690 renderer.set_frame_size(640, 360);
691 renderer.set_storage_size(640, 360);
692 assert!(
693 !renderer.render_frame(1_000).is_empty(),
694 "event 1 should render at t=1s"
695 );
696 assert!(
697 renderer.render_frame(5_500).is_empty(),
698 "no event spans t=5.5s"
699 );
700 assert!(
701 !renderer.render_frame(7_000).is_empty(),
702 "event 2 should render at t=7s"
703 );
704 }
705
706 #[test]
707 fn wrap_unicode_override_is_accepted_on_all_sources() {
708 for enable in [true, false] {
712 assert!(SubtitleFilter::builder()
713 .srt_content(SRT_SAMPLE)
714 .wrap_unicode(enable)
715 .build()
716 .is_ok());
717 assert!(SubtitleFilter::builder()
718 .ass_content(test_util::minimal_ass(test_util::HELLO_EVENT))
719 .wrap_unicode(enable)
720 .build()
721 .is_ok());
722 }
723 }
724
725 #[test]
726 fn builds_from_srt_file() {
727 let path = write_temp("sample.srt", SRT_SAMPLE);
728 let result = SubtitleFilter::builder().file(&path).build();
729 let _ = std::fs::remove_file(&path);
730 assert!(result.is_ok(), "{:?}", result.err());
731 }
732
733 #[test]
734 fn builds_from_ass_file() {
735 let path = write_temp(
736 "sample.ass",
737 &test_util::minimal_ass(test_util::HELLO_EVENT),
738 );
739 let result = SubtitleFilter::builder().file(&path).build();
740 let _ = std::fs::remove_file(&path);
741 assert!(result.is_ok(), "{:?}", result.err());
742 }
743
744 #[test]
745 fn rejects_missing_file() {
746 let err = SubtitleFilter::builder()
747 .file("/nonexistent/definitely/missing.srt")
748 .build()
749 .unwrap_err();
750 assert!(matches!(err, SubtitleError::Open(_)), "{err:?}");
751 }
752
753 #[test]
754 fn rejects_source_without_subtitle_stream() {
755 let err = SubtitleFilter::builder()
757 .file("test.mp4")
758 .build()
759 .unwrap_err();
760 assert!(matches!(err, SubtitleError::NoSubtitleStream(_)), "{err:?}");
761 }
762
763 #[test]
764 fn rejects_out_of_range_stream_index() {
765 let path = write_temp("index.srt", SRT_SAMPLE);
766 let err = SubtitleFilter::builder()
767 .file(&path)
768 .stream_index(3)
769 .build()
770 .unwrap_err();
771 let _ = std::fs::remove_file(&path);
772 assert!(matches!(err, SubtitleError::NoSubtitleStream(_)), "{err:?}");
773 }
774
775 #[test]
776 fn rejects_invalid_original_size() {
777 for (width, height) in [(0u32, 1080u32), (1920, 0), (u32::MAX, 1080)] {
778 let err = SubtitleFilter::builder()
779 .ass_content(test_util::minimal_ass(""))
780 .original_size(width, height)
781 .build()
782 .unwrap_err();
783 assert!(
784 matches!(err, SubtitleError::InvalidOption(_)),
785 "{width}x{height}: {err:?}"
786 );
787 }
788 }
789
790 #[test]
791 fn rejects_stream_index_with_ass_content() {
792 let err = SubtitleFilter::builder()
793 .ass_content(test_util::minimal_ass(""))
794 .stream_index(0)
795 .build()
796 .unwrap_err();
797 assert!(matches!(err, SubtitleError::InvalidOption(_)), "{err:?}");
798 }
799}