Skip to main content

ez_ffmpeg/subtitle/
options.rs

1//! Builder and options for [`SubtitleFilter`], plus the module's error type.
2
3use 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/// Which system font lookup libass may use.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15#[non_exhaustive]
16pub enum FontProvider {
17    /// First available system provider (fontconfig / CoreText / DirectWrite).
18    #[default]
19    Autodetect,
20    /// No system font lookup: only fonts reachable through
21    /// [`SubtitleFilterBuilder::fonts_dir`] /
22    /// [`SubtitleFilterBuilder::default_font_file`] are used. This makes
23    /// rendering deterministic across machines and avoids any fontconfig
24    /// dependency at runtime.
25    None,
26}
27
28/// Text shaping engine selection (FFmpeg `ass` filter's `shaping` option).
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30#[non_exhaustive]
31pub enum TextShaping {
32    /// Leave libass at its default (complex shaping).
33    #[default]
34    Auto,
35    /// Fast left-to-right shaping only; no ligatures, no bidi. Wrong for
36    /// Arabic/Hebrew/Indic text, occasionally useful for speed or VSFilter
37    /// compatibility.
38    Simple,
39    /// Full HarfBuzz shaping (ligatures, complex scripts, bidi).
40    Complex,
41}
42
43/// Errors raised while configuring and loading subtitles — i.e. before any
44/// frame is processed.
45///
46/// [`SubtitleFilterBuilder::build`] validates everything up front so callers
47/// can treat subtitle problems as *soft* failures (log and simply not attach
48/// the filter) instead of having a running job abort mid-transcode.
49#[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/// Builder for [`SubtitleFilter`]; obtain one via [`SubtitleFilter::builder`].
71#[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        // Deliberately hand-written: never dump subtitle content (it can be
94        // an entire script).
95        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    /// Uses a complete ASS/SSA script held in memory (UTF-8) — no temp file.
123    pub fn ass_content(mut self, content: impl Into<String>) -> Self {
124        self.source = Some(SubtitleSource::AssContent(content.into()));
125        self
126    }
127
128    /// Extra directory scanned for font files (libass `fontsdir`), typically
129    /// where application-managed fonts are downloaded to.
130    pub fn fonts_dir(mut self, dir: impl Into<PathBuf>) -> Self {
131        self.fonts_dir = Some(dir.into());
132        self
133    }
134
135    /// Path to a concrete font file used as the default font. Combined with
136    /// [`FontProvider::None`] this pins rendering to exactly this file and
137    /// eliminates lookup races between a fonts dir and a family name.
138    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    /// Family name used when the script's style does not resolve to a font.
144    pub fn default_family(mut self, family: impl Into<String>) -> Self {
145        self.default_family = Some(family.into());
146        self
147    }
148
149    /// See [`FontProvider`]; defaults to [`FontProvider::Autodetect`].
150    pub fn font_provider(mut self, provider: FontProvider) -> Self {
151        self.font_provider = provider;
152        self
153    }
154
155    /// Comma-separated `Style.Field=Value` overrides applied to the script's
156    /// style definitions (same semantics as the FFmpeg `subtitles` filter's
157    /// `force_style`). Inline override tags in dialogue lines still win.
158    pub fn force_style(mut self, style: impl Into<String>) -> Self {
159        self.force_style = Some(style.into());
160        self
161    }
162
163    /// Resolution the subtitles were authored against, used to compensate the
164    /// aspect ratio when the video was resized (FFmpeg `original_size`).
165    pub fn original_size(mut self, width: u32, height: u32) -> Self {
166        self.original_size = Some((width, height));
167        self
168    }
169
170    /// Character encoding of file/SRT sources (FFmpeg `charenc`; requires the
171    /// linked FFmpeg to support iconv). Not applicable to
172    /// [`Self::ass_content`], which must already be UTF-8.
173    pub fn charenc(mut self, charenc: impl Into<String>) -> Self {
174        self.charenc = Some(charenc.into());
175        self
176    }
177
178    /// Burns subtitles from a file: .srt/.ass/.vtt, or any container holding
179    /// a text subtitle stream (same lavformat/lavcodec loader FFmpeg's
180    /// `subtitles` filter uses, including container font attachments).
181    pub fn file(mut self, path: impl Into<PathBuf>) -> Self {
182        self.source = Some(SubtitleSource::File(path.into()));
183        self
184    }
185
186    /// Uses SRT content held in memory (no temp file). Markup supported by
187    /// FFmpeg's SRT decoder (`<i>`, `<b>`, `<font color>`, ...) is honored.
188    pub fn srt_content(mut self, content: impl Into<String>) -> Self {
189        self.source = Some(SubtitleSource::SrtContent(content.into()));
190        self
191    }
192
193    /// Selects which subtitle stream to burn when the source has several
194    /// (0 = first subtitle stream), mirroring FFmpeg's `si` option. Only
195    /// applies to [`Self::file`] sources.
196    pub fn stream_index(mut self, index: usize) -> Self {
197        self.stream_index = Some(index);
198        self
199    }
200
201    /// Overrides the automatic Unicode line-wrapping rule (FFmpeg
202    /// `wrap_unicode`). Unset = FFmpeg's auto behavior: enabled for decoded
203    /// non-ASS sources (SRT/VTT/...), disabled for native ASS scripts.
204    /// Requires a libass built with libunibreak; otherwise the request is
205    /// logged and ignored, like FFmpeg.
206    pub fn wrap_unicode(mut self, enable: bool) -> Self {
207        self.wrap_unicode = Some(enable);
208        self
209    }
210
211    /// Text shaping engine; defaults to [`TextShaping::Auto`] (libass's
212    /// default, complex shaping), mirroring the FFmpeg `ass` filter's
213    /// `shaping` option.
214    pub fn shaping(mut self, shaping: TextShaping) -> Self {
215        self.shaping = shaping;
216        self
217    }
218
219    /// Global multiplier applied to all font sizes (accessibility-style
220    /// "bigger subtitles" without editing the script). 1.0 = script sizes.
221    pub fn font_scale(mut self, scale: f64) -> Self {
222        self.font_scale = Some(scale);
223        self
224    }
225
226    /// Vertical position of regular (non-explicitly-positioned) events, in
227    /// percent: 0.0 = author intent (default), 100.0 = top of the frame.
228    /// Typical "raise subtitles" values are 5–20.
229    pub fn line_position(mut self, percent: f64) -> Self {
230        self.line_position = Some(percent);
231        self
232    }
233
234    /// Distances (pixels) from the video rectangle to the frame edges, for
235    /// content with baked-in letterbox/pillarbox bars: subtitles are then
236    /// laid out relative to the picture area instead of the full frame.
237    /// Negative values express pan-and-scan. See also
238    /// [`Self::use_margins`].
239    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    /// Allows regular events to be placed on the whole frame (e.g. onto
245    /// letterbox bars declared via [`Self::margins`]) instead of only
246    /// inside the video rectangle.
247    pub fn use_margins(mut self, enable: bool) -> Self {
248        self.use_margins = Some(enable);
249        self
250    }
251
252    /// Validates the configuration, initializes libass, and loads the
253    /// subtitle source. All failures surface here, never mid-pipeline.
254    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        // Load the script (and any container attachments) first.
319        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        // Font sources: system lookup per provider, then the explicit
375        // sources (dir, pinned file, script-embedded, container-attached).
376        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        // FFmpeg wrap_unicode auto rule: enabled for decoded non-ASS
403        // sources, disabled for native ASS scripts.
404        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
420/// Converts a filesystem path into the `char *` form libass/lavformat expect.
421/// Returns a neutral message; callers wrap it into the fitting error variant
422/// (font paths vs subtitle sources).
423pub(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        // Legal (empty subtitles); only logs a warning.
451        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    /// force_style must observably change rendering (FontSize doubling widens
512    /// the produced glyph bitmaps).
513    #[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    /// Renders `HELLO_EVENT` at 640x360 through a caller-tweaked builder and
550    /// returns (max bitmap width, bottommost painted edge). `None` when no
551    /// test font is present on this machine.
552    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        // 50% of a 360px frame is ~180px; assert with slack for style margins.
609        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    /// End-to-end proof of the lavformat/lavcodec loader on in-memory SRT:
676    /// events render inside their window and not between windows.
677    #[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        // Plumbing test: the override must reach both the loader path and
709        // the native-ASS path without error (rendering differences require
710        // a libass built with libunibreak, so they are not asserted here).
711        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        // The repo fixture test.mp4 has no subtitle stream.
756        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}