Skip to main content

ez_ffmpeg/subtitle/
filter.rs

1//! The subtitle burn-in frame filter.
2
3use super::backend::SubtitleRenderer;
4use super::blend::{self, ColorMatrix, ColorRange, OverlayImage, PlaneView, SampleFormat};
5use super::layout::{self, ColorModel, FormatSpec};
6use super::options::SubtitleFilterBuilder;
7use crate::core::filter::frame_filter::{FrameFilter, FrameFilterError, RequestFrameMode};
8use crate::core::filter::frame_filter_context::FrameFilterContext;
9use crate::util::ffmpeg_utils::av_err2str;
10use ffmpeg_next::Frame;
11use ffmpeg_sys_next::{
12    av_frame_make_writable, av_get_pix_fmt_name, av_rescale_q, AVColorRange, AVColorSpace, AVFrame,
13    AVMediaType, AVPixelFormat, AVRational, AV_NOPTS_VALUE,
14};
15use std::ffi::CStr;
16
17/// Burns ASS/SRT subtitles onto video frames inside a frame pipeline.
18///
19/// Build one with [`SubtitleFilter::builder`], then attach it to an output
20/// (recommended: subtitles are rendered against the final, post-filter-graph
21/// geometry) via a frame pipeline:
22///
23/// ```rust,ignore
24/// use ez_ffmpeg::filter::frame_pipeline_builder::FramePipelineBuilder;
25/// use ez_ffmpeg::subtitle::SubtitleFilter;
26/// use ez_ffmpeg::{AVMediaType, FfmpegContext, Output};
27///
28/// let filter = SubtitleFilter::builder()
29///     .ass_content(ass_script)
30///     .build()?;
31/// let pipeline: FramePipelineBuilder = AVMediaType::AVMEDIA_TYPE_VIDEO.into();
32/// FfmpegContext::builder()
33///     .input("input.mp4")
34///     .output(Output::from("output.mp4")
35///         .add_frame_pipeline(pipeline.filter("subtitles", Box::new(filter))))
36///     .build()?.start()?.wait()?;
37/// ```
38///
39/// Timing comes from each frame's `pts` × `time_base` (the scheduler keeps
40/// both valid on either pipeline side), so subtitle timestamps line up with
41/// the post-filter-graph timeline exactly like FFmpeg's own `subtitles`
42/// filter.
43pub struct SubtitleFilter {
44    /// The rendering backend (the pure-Rust renderer; only ever reached
45    /// through the [`SubtitleRenderer`] trait).
46    renderer: Box<dyn SubtitleRenderer>,
47    /// Authoring resolution for aspect compensation (FFmpeg `original_size`).
48    original_size: Option<(u32, u32)>,
49    /// Frame geometry the renderer is currently configured for.
50    configured_dims: Option<(i32, i32)>,
51    /// Reusable blend buffers (grow once, then no per-frame allocation).
52    blend_scratch: BlendScratch,
53    warned_missing_time: bool,
54    warned_colorspace: bool,
55    /// Matrix/range locked on the first timed frame (vf_subtitles wires
56    /// ff_draw once in config_input; per-frame drift is ignored). `None`
57    /// range = unspecified, resolved by the format default at blend time.
58    locked_color: Option<(ColorMatrix, Option<ColorRange>)>,
59}
60
61impl std::fmt::Debug for SubtitleFilter {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        f.debug_struct("SubtitleFilter")
64            .field("original_size", &self.original_size)
65            .finish_non_exhaustive()
66    }
67}
68
69impl SubtitleFilter {
70    /// Starts configuring a subtitle filter.
71    pub fn builder() -> SubtitleFilterBuilder {
72        SubtitleFilterBuilder::default()
73    }
74
75    /// Test access to the rendering backend.
76    #[cfg(test)]
77    pub(crate) fn renderer_mut(&mut self) -> &mut dyn SubtitleRenderer {
78        self.renderer.as_mut()
79    }
80
81    pub(crate) fn new(
82        renderer: Box<dyn SubtitleRenderer>,
83        original_size: Option<(u32, u32)>,
84    ) -> Self {
85        Self {
86            renderer,
87            original_size,
88            configured_dims: None,
89            blend_scratch: BlendScratch::default(),
90            warned_missing_time: false,
91            warned_colorspace: false,
92            locked_color: None,
93        }
94    }
95
96    /// FFmpeg vf_subtitles `config_input` semantics (n7.1.3): frame size is
97    /// the real geometry; with `original_size` the storage size is the
98    /// authoring resolution plus a pixel-aspect compensation.
99    fn configure_renderer(&mut self, width: i32, height: i32) {
100        if self.configured_dims == Some((width, height)) {
101            return;
102        }
103        self.configured_dims = Some((width, height));
104        self.renderer.set_frame_size(width, height);
105        match self.original_size {
106            Some((original_w, original_h)) if original_w > 0 && original_h > 0 => {
107                let par = (f64::from(width) / f64::from(height))
108                    / (f64::from(original_w) / f64::from(original_h));
109                self.renderer.set_pixel_aspect(par);
110                self.renderer
111                    .set_storage_size(original_w as i32, original_h as i32);
112            }
113            _ => self.renderer.set_storage_size(width, height),
114        }
115    }
116
117    /// Maps colorspace/range to the subtitle color conversion with
118    /// FFmpeg's semantics: the matrix set covers `av_csp_luma_coeffs`
119    /// entries; UNSPECIFIED falls back to BT.601; the range stays
120    /// undecided here (`None`) so the per-format default can resolve it
121    /// like `ff_draw_init2` (yuvj/RGB default to full, else limited).
122    /// Like vf_subtitles' config_input, the choice is LOCKED on first use —
123    /// later frames reuse it even if their metadata drifts.
124    fn frame_color(
125        &mut self,
126        colorspace: AVColorSpace,
127        color_range: AVColorRange,
128    ) -> (ColorMatrix, Option<ColorRange>) {
129        if let Some(locked) = self.locked_color {
130            return locked;
131        }
132        let matrix = match colorspace {
133            AVColorSpace::AVCOL_SPC_BT709 => ColorMatrix::Bt709,
134            AVColorSpace::AVCOL_SPC_SMPTE170M | AVColorSpace::AVCOL_SPC_BT470BG => {
135                ColorMatrix::Bt601
136            }
137            AVColorSpace::AVCOL_SPC_BT2020_NCL | AVColorSpace::AVCOL_SPC_BT2020_CL => {
138                ColorMatrix::Bt2020
139            }
140            AVColorSpace::AVCOL_SPC_FCC => ColorMatrix::Fcc,
141            AVColorSpace::AVCOL_SPC_SMPTE240M => ColorMatrix::Smpte240m,
142            AVColorSpace::AVCOL_SPC_YCGCO => ColorMatrix::YCoCg,
143            AVColorSpace::AVCOL_SPC_UNSPECIFIED => ColorMatrix::Bt601,
144            other => {
145                if !self.warned_colorspace {
146                    self.warned_colorspace = true;
147                    log::warn!(
148                        "subtitle filter: colorspace {other:?} not supported for subtitle \
149                         color conversion, falling back to BT.601 (logged once; FFmpeg's \
150                         subtitles filter leaves its draw context uninitialized here)"
151                    );
152                }
153                ColorMatrix::Bt601
154            }
155        };
156        let range = match color_range {
157            AVColorRange::AVCOL_RANGE_JPEG => Some(ColorRange::Full),
158            AVColorRange::AVCOL_RANGE_MPEG => Some(ColorRange::Limited),
159            _ => None,
160        };
161        self.locked_color = Some((matrix, range));
162        (matrix, range)
163    }
164
165    /// Composites rendered overlays onto a writable software frame laid out
166    /// as described by `spec`. With `parallel` (callers gate it through
167    /// [`should_parallelize`]) the components split into two plane-disjoint
168    /// groups blended on two threads; the split silently stays serial when
169    /// the format only touches one plane (packed RGB, gray).
170    ///
171    /// # Safety
172    /// `frame` must be a writable software frame whose pixel format matches
173    /// `spec`, with positive linesizes no smaller than each component's row
174    /// byte width (`(plane_w - 1) * pixel_step + sample_bytes` — true of any
175    /// FFmpeg-allocated frame; the component views are sized assuming it),
176    /// exclusively owned by the caller for the duration of the call
177    /// (`filter_frame` guarantees this after `av_frame_make_writable`).
178    unsafe fn blend_images(
179        frame: *mut AVFrame,
180        images: &[OverlayImage<'_>],
181        spec: &FormatSpec,
182        matrix: ColorMatrix,
183        range: Option<ColorRange>,
184        scratch: &mut BlendScratch,
185        parallel: bool,
186    ) {
187        // ff_draw_init2: an EXPLICIT range is honored; only an unspecified
188        // one falls back to the format default (yuvj/RGB full, else
189        // limited). RGB is inherently full-range in ff_draw, so an
190        // unspecified-range RGB frame must not compress the overlay to
191        // limited (that dimmed burned-in subtitles).
192        let default_full = spec.force_full_range || matches!(spec.model, ColorModel::Rgb);
193        let range = range.unwrap_or(if default_full {
194            ColorRange::Full
195        } else {
196            ColorRange::Limited
197        });
198        let width = (*frame).width as usize;
199        let height = (*frame).height as usize;
200
201        fill_overlay_preps(images, spec, matrix, range, &mut scratch.preps);
202        collect_comp_tasks(frame, spec, (width, height), &mut scratch.tasks);
203
204        let dims = (width, height);
205        let sample = spec.sample;
206        let preps = &scratch.preps;
207        let tasks = &scratch.tasks;
208        if parallel {
209            if let Some((group_a, group_b)) = split_tasks(tasks) {
210                let (pool_a, pool_b) = (&mut scratch.pool_a, &mut scratch.pool_b);
211                std::thread::scope(|s| {
212                    // Group A (the first plane — luma) on the spawned
213                    // thread, the remaining planes inline: one spawn per
214                    // frame. `split_tasks` verified the groups' byte
215                    // ranges are disjoint, so the two workers never touch
216                    // the same memory; per-plane compositing order is
217                    // preserved because every plane lives entirely inside
218                    // one group and each worker walks overlays in order.
219                    s.spawn(move || blend_task_group(group_a, images, preps, sample, dims, pool_a));
220                    blend_task_group(group_b, images, preps, sample, dims, pool_b);
221                });
222                return;
223            }
224        }
225        blend_task_group(tasks, images, preps, sample, dims, &mut scratch.pool_a);
226    }
227}
228
229/// Per-overlay alpha + converted color, computed once and shared by both
230/// blend workers. A tiny linear-probe cache dedups the f64 color
231/// conversion: dense frames carry ~100 nodes reusing a handful of colors
232/// (fill, outline, shadow), and each conversion is ~15 f64 ops plus three
233/// float->int rounds.
234fn fill_overlay_preps(
235    images: &[OverlayImage<'_>],
236    spec: &FormatSpec,
237    matrix: ColorMatrix,
238    range: ColorRange,
239    preps: &mut Vec<OverlayPrep>,
240) {
241    preps.clear();
242    preps.reserve(images.len());
243    let mut colors = [(u32::MAX, [0u32; 3]); 8]; // key is 24-bit RGB: MAX never collides
244    let mut color_count = 0usize;
245    for overlay in images {
246        let alpha = spec.sample.alpha_fixed(overlay.opacity());
247        if alpha == 0 {
248            preps.push(OverlayPrep { alpha, src: [0; 3] });
249            continue;
250        }
251        let key = overlay.color >> 8; // RGB part; the conversion ignores alpha
252        let src = match colors[..color_count].iter().find(|(k, _)| *k == key) {
253            Some(&(_, hit)) => hit,
254            None => {
255                let converted = match spec.model {
256                    ColorModel::Yuv => {
257                        blend::yuv_components(overlay.rgb(), matrix, range, spec.scale_bits)
258                    }
259                    ColorModel::Rgb => blend::rgb_components(overlay.rgb(), range, spec.scale_bits),
260                };
261                if color_count < colors.len() {
262                    colors[color_count] = (key, converted);
263                    color_count += 1;
264                }
265                converted
266            }
267        };
268        preps.push(OverlayPrep { alpha, src });
269    }
270}
271
272/// Raw component geometry, captured once on the calling thread. Unusable
273/// planes are skipped (defensive; the scheduler never feeds such frames)
274/// exactly like the old per-view checks.
275///
276/// # Safety
277/// `frame` must satisfy [`SubtitleFilter::blend_images`]'s contract.
278unsafe fn collect_comp_tasks(
279    frame: *mut AVFrame,
280    spec: &FormatSpec,
281    (width, height): (usize, usize),
282    tasks: &mut Vec<CompTask>,
283) {
284    tasks.clear();
285    for (source, placement) in spec.comps {
286        let plane_w = (width + (1usize << placement.hsub) - 1) >> placement.hsub;
287        let plane_h = (height + (1usize << placement.vsub) - 1) >> placement.vsub;
288        let linesize = (*frame).linesize[placement.plane];
289        let data = (*frame).data[placement.plane];
290        if linesize <= 0 || data.is_null() || plane_w == 0 || plane_h == 0 {
291            continue;
292        }
293        let linesize = linesize as usize;
294        tasks.push(CompTask {
295            plane: placement.plane,
296            data: data.add(placement.offset),
297            // View length relative to the offset-advanced pointer, ending
298            // at the component's LAST SAMPLE:
299            //   linesize*(plane_h-1) + (plane_w-1)*pixel_step + sample_bytes.
300            // The trailing interleave bytes after that sample belong to
301            // sibling components (or don't exist on tight buffers); the
302            // kernels never touch them (`row_len` ends at the last sample
303            // too). `plane_w`/`plane_h` are >= 1 past the guard above.
304            len: linesize * (plane_h - 1)
305                + (plane_w - 1) * placement.pixel_step
306                + spec.sample.bytes(),
307            linesize,
308            pixel_step: placement.pixel_step,
309            source: *source,
310            hsub: placement.hsub,
311            vsub: placement.vsub,
312        });
313    }
314}
315
316/// Reusable buffers for [`SubtitleFilter::blend_images`] (grow once, then
317/// no per-frame allocation).
318#[derive(Default)]
319struct BlendScratch {
320    /// Per-overlay alpha + converted color, shared by both blend workers.
321    preps: Vec<OverlayPrep>,
322    /// Component-geometry tasks for the current frame. Cleared and refilled
323    /// on every call; entries hold frame-local pointers, so they are never
324    /// read across calls — only the capacity is reused.
325    tasks: Vec<CompTask>,
326    /// Mask-sum buffer for the serial path / parallel group A.
327    pool_a: Vec<u16>,
328    /// Mask-sum buffer for parallel group B (each worker pools into its
329    /// own buffer).
330    pool_b: Vec<u16>,
331}
332
333/// Precomputed per-overlay blend parameters (`alpha_fixed` + the converted
334/// component triple).
335struct OverlayPrep {
336    alpha: u32,
337    src: [u32; 3],
338}
339
340/// One component's blend work over one frame, described by raw plane
341/// geometry so component groups can cross the `thread::scope` boundary.
342#[derive(Clone, Copy)]
343struct CompTask {
344    /// AVFrame plane index (drives the group split only).
345    plane: usize,
346    /// First byte of this component (plane base + component offset).
347    data: *mut u8,
348    /// Addressable bytes from `data`, ending at the component's LAST
349    /// SAMPLE: `linesize * (plane_h - 1) + (plane_w - 1) * pixel_step +
350    /// sample_bytes` (view-relative).
351    len: usize,
352    linesize: usize,
353    pixel_step: usize,
354    /// Index into the converted color triple.
355    source: usize,
356    hsub: u32,
357    vsub: u32,
358}
359
360/// SAFETY: a `CompTask` only describes a byte range inside an AVFrame that
361/// `filter_frame` exclusively owns after `av_frame_make_writable`; the
362/// bytes are touched exclusively through [`CompTask::view`] under its
363/// contract (one live view at a time per worker, and byte-disjoint task
364/// groups across workers — enforced by [`split_tasks`] before any spawn).
365unsafe impl Send for CompTask {}
366/// SAFETY: shared references expose only the plain-data fields; all writes
367/// go through [`CompTask::view`] under the same contract as `Send` above.
368unsafe impl Sync for CompTask {}
369
370impl CompTask {
371    /// Materializes the writable component view.
372    ///
373    /// # Safety
374    /// No other view over an overlapping byte range may be alive anywhere:
375    /// within a worker, views are created and dropped strictly one at a
376    /// time (NV12/P010 interleaved components overlap in memory); across
377    /// workers, [`split_tasks`] verified the groups byte-disjoint. The
378    /// backing frame outlives the view (it is owned by the running
379    /// `filter_frame` call).
380    unsafe fn view(&self) -> PlaneView<'_> {
381        PlaneView {
382            data: std::slice::from_raw_parts_mut(self.data, self.len),
383            linesize: self.linesize,
384            pixel_step: self.pixel_step,
385        }
386    }
387}
388
389/// One view spanning the three interleaved components of a packed-RGB
390/// plane, with each component's byte offset inside a pixel group and its
391/// converted-color index.
392struct FusedRgb {
393    view_task: CompTask,
394    offsets: [usize; 3],
395    sources: [usize; 3],
396}
397
398impl FusedRgb {
399    /// # Safety
400    /// Same contract as [`CompTask::view`]; the fused range is the union
401    /// of the three component ranges inside one plane allocation.
402    unsafe fn view(&self) -> PlaneView<'_> {
403        self.view_task.view()
404    }
405}
406
407/// Detects the packed-RGB shape: exactly three unsubsampled U8 components
408/// on one plane sharing a linesize and a pixel step of 3 or 4, each offset
409/// inside one pixel group (rgb24/bgr24 and the rgba family in the layout
410/// table). Returns one task spanning all three so they blend in a single
411/// mask traversal.
412fn fused_packed_rgb(tasks: &[CompTask], sample: SampleFormat) -> Option<FusedRgb> {
413    if sample != SampleFormat::U8 {
414        return None;
415    }
416    let [a, b, c] = tasks else {
417        return None;
418    };
419    let step = a.pixel_step;
420    if step != 3 && step != 4 {
421        return None;
422    }
423    let same_shape = |t: &CompTask| {
424        t.plane == a.plane
425            && t.linesize == a.linesize
426            && t.pixel_step == step
427            && t.hsub == 0
428            && t.vsub == 0
429    };
430    if !(same_shape(a) && same_shape(b) && same_shape(c)) {
431        return None;
432    }
433    let base = [a, b, c]
434        .into_iter()
435        .min_by_key(|t| t.data as usize)
436        .expect("three tasks");
437    let mut offsets = [0usize; 3];
438    let mut sources = [0usize; 3];
439    let mut len = 0usize;
440    for (i, t) in [a, b, c].into_iter().enumerate() {
441        // SAFETY: all three pointers derive from the same plane base
442        // (`frame.data[plane].add(placement.offset)` in blend_images), so
443        // the offset stays inside one allocation.
444        let off = usize::try_from(unsafe { t.data.offset_from(base.data) }).ok()?;
445        if off >= step {
446            return None;
447        }
448        offsets[i] = off;
449        sources[i] = t.source;
450        len = len.max(off + t.len);
451    }
452    Some(FusedRgb {
453        view_task: CompTask {
454            data: base.data,
455            len,
456            ..*base
457        },
458        offsets,
459        sources,
460    })
461}
462
463/// Splits tasks into (components on the first plane, the rest) for the
464/// two-thread blend. `None` when the split is impossible: fewer than two
465/// planes touched (packed RGB, gray) or any byte overlap between the groups
466/// (never true for the layout table, but checked so the unsafe plane split
467/// can never alias).
468fn split_tasks(tasks: &[CompTask]) -> Option<(&[CompTask], &[CompTask])> {
469    let first_plane = tasks.first()?.plane;
470    let split = tasks.iter().position(|task| task.plane != first_plane)?;
471    let (a, b) = tasks.split_at(split);
472    if b.iter().any(|task| task.plane == first_plane) {
473        return None;
474    }
475    let disjoint = a.iter().all(|ta| {
476        b.iter().all(|tb| {
477            let (a0, a1) = (ta.data as usize, ta.data as usize + ta.len);
478            let (b0, b1) = (tb.data as usize, tb.data as usize + tb.len);
479            a1 <= b0 || b1 <= a0
480        })
481    });
482    disjoint.then_some((a, b))
483}
484
485/// Blends every overlay onto the components in `tasks`, in overlay order.
486/// This is the whole per-frame blend when called with all components
487/// (serial path) and one worker's half in the parallel split; compositing
488/// order is a per-plane contract and every plane lives entirely inside one
489/// group, so both call shapes produce identical bytes.
490fn blend_task_group(
491    tasks: &[CompTask],
492    images: &[OverlayImage<'_>],
493    preps: &[OverlayPrep],
494    sample: SampleFormat,
495    (width, height): (usize, usize),
496    pool: &mut Vec<u16>,
497) {
498    // Packed-RGB fusion: when this group is exactly the three interleaved
499    // U8 color components of one packed plane, each overlay's mask is
500    // traversed once and all three samples per covered pixel blend in that
501    // pass, instead of three independent strided passes over the same rows
502    // (same detect-once/apply-many idea as the chroma pooling below). A
503    // packed-RGB group is single-plane, so `split_tasks` never carries it
504    // into the parallel split — this only ever runs on the serial path.
505    if let Some(fused) = fused_packed_rgb(tasks, sample) {
506        for (overlay, prep) in images.iter().zip(preps) {
507            if prep.alpha == 0 {
508                continue;
509            }
510            // SAFETY: the fused view is the only live view — the
511            // per-component loop below never runs for a fused group.
512            let mut plane = unsafe { fused.view() };
513            blend::blend_packed_rgb_u8(
514                &mut plane,
515                width,
516                height,
517                overlay,
518                [
519                    prep.src[fused.sources[0]],
520                    prep.src[fused.sources[1]],
521                    prep.src[fused.sources[2]],
522                ],
523                fused.offsets,
524                prep.alpha,
525            );
526        }
527        return;
528    }
529
530    // When this group carries exactly two h2-subsampled components
531    // (4:2:0/4:2:2 planar, NV12/NV21, P010 chroma) and the two-phase route
532    // is preferred for the sample width, each node's mask is pooled ONCE
533    // and applied to both components instead of re-pooling per component.
534    let mut pooled = [usize::MAX; 2];
535    let mut pooled_count = 0usize;
536    for (index, task) in tasks.iter().enumerate() {
537        if task.hsub != 0 || task.vsub != 0 {
538            if pooled_count < 2 {
539                pooled[pooled_count] = index;
540            }
541            pooled_count += 1;
542        }
543    }
544    let share_pooling = pooled_count == 2 && blend::two_phase_pooled_preferred(sample) && {
545        let (a, b) = (&tasks[pooled[0]], &tasks[pooled[1]]);
546        a.hsub == 1 && b.hsub == 1 && a.vsub == b.vsub && a.vsub <= 1
547    };
548
549    for (overlay, prep) in images.iter().zip(preps) {
550        if prep.alpha == 0 {
551            continue;
552        }
553        if share_pooling {
554            if let Some(rect) =
555                blend::pool_sums_h2(width, height, overlay, tasks[pooled[0]].vsub, pool)
556            {
557                for &index in &pooled {
558                    let task = &tasks[index];
559                    // SAFETY: views live one at a time inside this worker;
560                    // other workers' tasks are byte-disjoint (split_tasks).
561                    let mut plane = unsafe { task.view() };
562                    blend::blend_pooled_from_sums(
563                        &mut plane,
564                        pool,
565                        rect,
566                        prep.src[task.source],
567                        prep.alpha,
568                        sample,
569                    );
570                }
571            }
572        }
573        for (index, task) in tasks.iter().enumerate() {
574            if share_pooling && (index == pooled[0] || index == pooled[1]) {
575                continue;
576            }
577            // SAFETY: views live one at a time inside this worker; other
578            // workers' tasks are byte-disjoint (split_tasks).
579            let mut plane = unsafe { task.view() };
580            blend::blend_component(
581                &mut plane,
582                width,
583                height,
584                overlay,
585                prep.src[task.source],
586                prep.alpha,
587                task.hsub,
588                task.vsub,
589                sample,
590            );
591        }
592    }
593}
594
595/// Minimum total clipped mask pixels before [`SubtitleFilter::blend_images`]
596/// splits plane work across two threads. Below this the spawn/join overhead
597/// (tens of microseconds) rivals the blend itself: the bench's sparse
598/// one-line 1080p dialogue measures ~42k mask px and blends in well under
599/// 50us with the AVX2 kernels, while the dense multi-line scene measures
600/// ~640k px and ~220us — 256k separates the two regimes with margin on
601/// both sides (`bench_kernels.rs` scenario stats).
602const PARALLEL_MASK_PX_THRESHOLD: usize = 256 * 1024;
603
604/// The parallel-blend gate, kept pure for unit testing.
605fn should_parallelize(clipped_mask_px: usize) -> bool {
606    clipped_mask_px > PARALLEL_MASK_PX_THRESHOLD
607}
608
609/// Total overlay mask pixels that actually intersect the frame.
610fn clipped_mask_px(images: &[OverlayImage<'_>], width: usize, height: usize) -> usize {
611    images
612        .iter()
613        .map(|image| blend::clipped_area(width, height, image))
614        .sum()
615}
616
617impl FrameFilter for SubtitleFilter {
618    fn media_type(&self) -> AVMediaType {
619        AVMediaType::AVMEDIA_TYPE_VIDEO
620    }
621
622    /// Synchronous: every frame is rendered and returned inside `filter_frame`
623    /// and no frames are held back, so the pipeline never needs to poll this
624    /// filter for deferred output (PERF-8).
625    fn request_frame_mode(&self) -> RequestFrameMode {
626        RequestFrameMode::Never
627    }
628
629    fn filter_frame(
630        &mut self,
631        mut frame: Frame,
632        _ctx: &mut FrameFilterContext,
633    ) -> Result<Option<Frame>, FrameFilterError> {
634        // An end-of-stream flush marker passes through untouched — this filter
635        // never returns Ok(None), which would starve downstream consumers. The
636        // buf[0] test correctly leaves a hardware frame (data[0] == null but
637        // buf[0] set) to be caught by the software-frame check below.
638        if crate::util::ffmpeg_utils::frame_is_eof_marker(&frame) {
639            return Ok(Some(frame));
640        }
641        // SAFETY: non-null software frame owned by us for the duration.
642        let (width, height, format, pts, time_base, is_hw, colorspace, color_range) = unsafe {
643            let p = frame.as_ptr();
644            (
645                (*p).width,
646                (*p).height,
647                (*p).format,
648                (*p).pts,
649                (*p).time_base,
650                !(*p).hw_frames_ctx.is_null(),
651                (*p).colorspace,
652                (*p).color_range,
653            )
654        };
655        if width <= 0 || height <= 0 {
656            return Ok(Some(frame));
657        }
658        if is_hw {
659            return Err(
660                "SubtitleFilter requires software frames; do not set hwaccel_output_format to a \
661                 hardware format (frames must be downloaded before this filter)"
662                    .into(),
663            );
664        }
665        // `format` is a raw C int; matching it against known `AV_PIX_FMT_*`
666        // constants (in `format_spec`) avoids constructing an out-of-range
667        // AVPixelFormat enum value, which would be UB for a frame carrying an
668        // unlisted format id.
669        let Some(spec) = layout::format_spec(format) else {
670            return Err(format!(
671                "SubtitleFilter: unsupported pixel format {}; convert first, e.g. \
672                 .filter_desc(\"format=yuv420p\") or Output::set_pix_fmt(\"yuv420p\"). \
673                 Supported: {}",
674                pix_fmt_name(format),
675                layout::SUPPORTED_LIST
676            )
677            .into());
678        };
679
680        if pts == AV_NOPTS_VALUE || time_base.num <= 0 || time_base.den <= 0 {
681            if !self.warned_missing_time {
682                self.warned_missing_time = true;
683                log::warn!(
684                    "subtitle filter: frame without pts/time_base cannot be timed, \
685                     passing through (logged once)"
686                );
687            }
688            return Ok(Some(frame));
689        }
690        // SAFETY: pure arithmetic FFI.
691        let now_ms = unsafe { av_rescale_q(pts, time_base, AVRational { num: 1, den: 1000 }) };
692
693        self.configure_renderer(width, height);
694        // Computed before rendering: `images` below borrows the renderer
695        // field, so no whole-`self` calls may sit between render and blend.
696        let (matrix, range) = self.frame_color(colorspace, color_range);
697
698        let images = self.renderer.render_frame(now_ms);
699        if images.is_empty() {
700            // Nothing visible at this timestamp: zero-cost passthrough (no
701            // make_writable copy on subtitle-free frames).
702            return Ok(Some(frame));
703        }
704        // A non-empty list can still consist solely of clipped-away or fully
705        // transparent nodes; those must not pay the make_writable either
706        // (on fanned-out frames it copies the whole frame).
707        if !any_visible_image(&images, width, height, spec.sample) {
708            return Ok(Some(frame));
709        }
710
711        // Frames are fanned out with av_frame_ref; unshare before writing.
712        // SAFETY: valid owned frame.
713        let ret = unsafe { av_frame_make_writable(frame.as_mut_ptr()) };
714        if ret < 0 {
715            return Err(format!("av_frame_make_writable failed: {}", av_err2str(ret)).into());
716        }
717
718        // Negative linesizes (bottom-up layouts, e.g. produced by vflip) can
719        // survive make_writable when no copy was needed. FFmpeg blends those
720        // natively; this filter does not yet — fail deterministically instead
721        // of silently skipping planes.
722        // SAFETY: valid owned frame; only reads plane linesizes.
723        let negative_linesize = unsafe {
724            let p = frame.as_ptr();
725            spec.comps
726                .iter()
727                .any(|(_, placement)| (*p).linesize[placement.plane] < 0)
728        };
729        if negative_linesize {
730            return Err(
731                "SubtitleFilter: negative linesize (bottom-up/vertically flipped frames) is \
732                 not supported; apply vflip after this filter or convert the frame first"
733                    .into(),
734            );
735        }
736
737        let parallel =
738            should_parallelize(clipped_mask_px(&images, width as usize, height as usize));
739        // SAFETY: frame verified software with a supported layout and made
740        // writable (exclusively owned for the call); `images` comes from
741        // the render call above.
742        unsafe {
743            Self::blend_images(
744                frame.as_mut_ptr(),
745                &images,
746                spec,
747                matrix,
748                range,
749                &mut self.blend_scratch,
750                parallel,
751            )
752        };
753
754        Ok(Some(frame))
755    }
756
757    fn uninit(&mut self, _ctx: &mut FrameFilterContext) {
758        // Idempotent; Drop covers the paths where uninit never runs.
759        self.renderer.teardown();
760    }
761}
762
763/// True when at least one overlay would actually touch the frame:
764/// non-transparent and intersecting the frame rectangle.
765fn any_visible_image(
766    images: &[OverlayImage<'_>],
767    width: i32,
768    height: i32,
769    sample: SampleFormat,
770) -> bool {
771    images.iter().any(|image| {
772        if sample.alpha_fixed(image.opacity()) == 0 {
773            return false;
774        }
775        let (x0, y0) = (i64::from(image.dst_x), i64::from(image.dst_y));
776        x0 < i64::from(width)
777            && y0 < i64::from(height)
778            && x0 + image.w as i64 > 0
779            && y0 + image.h as i64 > 0
780    })
781}
782
783fn pix_fmt_name(format: i32) -> String {
784    // Only ask FFmpeg to name `format` when it is a real AVPixelFormat
785    // discriminant (0..NB); an unlisted id is printed raw rather than
786    // transmuted into an out-of-range enum value (which would be UB).
787    if (0..AVPixelFormat::AV_PIX_FMT_NB as i32).contains(&format) {
788        // SAFETY: `format` is in the valid discriminant range checked above.
789        let name =
790            unsafe { av_get_pix_fmt_name(std::mem::transmute::<i32, AVPixelFormat>(format)) };
791        if !name.is_null() {
792            return unsafe { CStr::from_ptr(name) }
793                .to_string_lossy()
794                .into_owned();
795        }
796    }
797    format!("#{format}")
798}
799
800#[cfg(test)]
801mod tests {
802    use super::*;
803    use crate::subtitle::test_util::{self, diff_stats, temp_path, transcode_test_mp4};
804    use crate::subtitle::FontProvider;
805    use ffmpeg_sys_next::{av_frame_alloc, av_frame_free, av_frame_get_buffer};
806
807    const W: usize = 320;
808    const H: usize = 240;
809    const FRAME_BYTES: usize = W * H + 2 * ((W / 2) * (H / 2));
810
811    /// Full-opacity white drawing covering the top-left quarter of the
812    /// 640x360 playfield (=> top-left quarter of the frame), visible for the
813    /// given window.
814    fn quarter_box_script(start: &str, end: &str) -> String {
815        test_util::minimal_ass(&format!(
816            "Dialogue: 0,{start},{end},Default,,0,0,0,,{{\\an7\\pos(0,0)\\p1}}m 0 0 l 320 0 320 180 0 180{{\\p0}}\n"
817        ))
818    }
819
820    fn build_filter(script: String, font: &str) -> SubtitleFilter {
821        SubtitleFilter::builder()
822            .ass_content(script)
823            .default_font_file(font)
824            .font_provider(FontProvider::None)
825            .build()
826            .expect("build subtitle filter")
827    }
828
829    /// End-to-end through the real scheduler: rawvideo output is compared
830    /// byte-by-byte against a baseline run without the filter.
831    #[test]
832    #[allow(clippy::manual_is_multiple_of)] // is_multiple_of needs Rust 1.87
833    fn burns_subtitles_into_the_pipeline_output() {
834        let Some(font) = test_util::test_font() else {
835            eprintln!("skipping: no known test font present on this machine");
836            return;
837        };
838
839        let baseline_path = temp_path("baseline.yuv");
840        let burned_path = temp_path("burned.yuv");
841        let unburned_path = temp_path("unburned.yuv");
842
843        transcode_test_mp4(&baseline_path, None, "yuv420p", None);
844        transcode_test_mp4(
845            &burned_path,
846            Some(build_filter(
847                quarter_box_script("0:00:00.00", "0:00:10.00"),
848                font,
849            )),
850            "yuv420p",
851            None,
852        );
853        // Event entirely outside the 5s clip: must be byte-identical to the
854        // baseline (proves the no-image fast path + untouched passthrough).
855        transcode_test_mp4(
856            &unburned_path,
857            Some(build_filter(
858                quarter_box_script("0:00:20.00", "0:00:25.00"),
859                font,
860            )),
861            "yuv420p",
862            None,
863        );
864
865        let baseline = std::fs::read(&baseline_path).expect("read baseline");
866        let burned = std::fs::read(&burned_path).expect("read burned");
867        let unburned = std::fs::read(&unburned_path).expect("read unburned");
868        let _ = std::fs::remove_file(&baseline_path);
869        let _ = std::fs::remove_file(&burned_path);
870        let _ = std::fs::remove_file(&unburned_path);
871
872        assert!(!baseline.is_empty() && baseline.len() % FRAME_BYTES == 0);
873        assert_eq!(baseline.len(), burned.len(), "same frame count");
874        assert_eq!(
875            baseline, unburned,
876            "out-of-window subtitles must not alter any byte"
877        );
878        assert_ne!(baseline, burned, "in-window subtitles must alter the video");
879
880        // First frame, luma plane: inside the drawn quarter (white fill) vs
881        // far outside it.
882        let inside = 60 * W + 80; // (80, 60) — inside 160x120 top-left quarter
883        let outside = 230 * W + 300; // (300, 230) — far bottom-right
884        assert_ne!(
885            baseline[inside], burned[inside],
886            "pixel inside the subtitle box should change"
887        );
888        assert_eq!(
889            baseline[outside], burned[outside],
890            "pixel far outside the subtitle box must not change"
891        );
892        // White in limited range lands near 235.
893        assert!(
894            burned[inside] >= 230,
895            "expected near-white luma inside the box, got {}",
896            burned[inside]
897        );
898    }
899
900    /// The blend engine reaches every supported layout family: semi-planar
901    /// chroma (nv12), no-subsampling planar (yuv444p), and packed RGB.
902    #[test]
903    fn burns_into_nv12_yuv444p_and_rgb24() {
904        let Some(font) = test_util::test_font() else {
905            eprintln!("skipping: no known test font present on this machine");
906            return;
907        };
908        let script = || quarter_box_script("0:00:00.00", "0:00:10.00");
909        let inside_luma = 60 * W + 80;
910        let outside_luma = 230 * W + 300;
911
912        for (pix_fmt, white_check) in [("nv12", None), ("yuv444p", None), ("rgb24", Some(250u8))] {
913            let baseline_path = temp_path(&format!("base_{pix_fmt}"));
914            let burned_path = temp_path(&format!("burn_{pix_fmt}"));
915            transcode_test_mp4(&baseline_path, None, pix_fmt, None);
916            transcode_test_mp4(
917                &burned_path,
918                Some(build_filter(script(), font)),
919                pix_fmt,
920                None,
921            );
922            let baseline = std::fs::read(&baseline_path).expect("read baseline");
923            let burned = std::fs::read(&burned_path).expect("read burned");
924            let _ = std::fs::remove_file(&baseline_path);
925            let _ = std::fs::remove_file(&burned_path);
926
927            assert_eq!(baseline.len(), burned.len(), "{pix_fmt}: frame count");
928            assert_ne!(baseline, burned, "{pix_fmt}: subtitles must alter video");
929            match white_check {
930                None => {
931                    // Planar/semi-planar: luma plane comes first in both.
932                    assert!(
933                        burned[inside_luma] >= 230,
934                        "{pix_fmt}: near-white luma expected, got {}",
935                        burned[inside_luma]
936                    );
937                    assert_eq!(
938                        baseline[outside_luma], burned[outside_luma],
939                        "{pix_fmt}: outside pixel must not change"
940                    );
941                }
942                Some(threshold) => {
943                    // Packed RGB: 3 bytes per pixel.
944                    let inside = inside_luma * 3;
945                    let outside = outside_luma * 3;
946                    for c in 0..3 {
947                        assert!(
948                            burned[inside + c] >= threshold,
949                            "{pix_fmt}: white component {c} expected, got {}",
950                            burned[inside + c]
951                        );
952                        assert_eq!(
953                            baseline[outside + c],
954                            burned[outside + c],
955                            "{pix_fmt}: outside component {c} must not change"
956                        );
957                    }
958                }
959            }
960        }
961    }
962
963    fn u16le_at(buf: &[u8], sample_index: usize) -> u16 {
964        u16::from_le_bytes([buf[sample_index * 2], buf[sample_index * 2 + 1]])
965    }
966
967    /// High-depth formats end-to-end: 10-bit planar (values 0..1023) and
968    /// P010 (10 bits MSB-aligned in u16 containers).
969    #[test]
970    fn burns_into_10bit_planar_and_p010() {
971        let Some(font) = test_util::test_font() else {
972            eprintln!("skipping: no known test font present on this machine");
973            return;
974        };
975        let script = || quarter_box_script("0:00:00.00", "0:00:10.00");
976        let inside_luma = 60 * W + 80; // sample index within the luma plane
977        let outside_luma = 230 * W + 300;
978
979        // White floors: ff_draw-scaled white is 943 (10-bit) / 60395 (P010),
980        // and the FFmpeg 16-bit blend keeps a 257/65537 residual of the dark
981        // background underneath.
982        for (pix_fmt, white_floor) in [("yuv420p10le", 900u16), ("p010le", 57000u16)] {
983            let baseline_path = temp_path(&format!("base_{pix_fmt}"));
984            let burned_path = temp_path(&format!("burn_{pix_fmt}"));
985            transcode_test_mp4(&baseline_path, None, pix_fmt, None);
986            transcode_test_mp4(
987                &burned_path,
988                Some(build_filter(script(), font)),
989                pix_fmt,
990                None,
991            );
992            let baseline = std::fs::read(&baseline_path).expect("read baseline");
993            let burned = std::fs::read(&burned_path).expect("read burned");
994            let _ = std::fs::remove_file(&baseline_path);
995            let _ = std::fs::remove_file(&burned_path);
996
997            assert_eq!(baseline.len(), burned.len(), "{pix_fmt}: frame count");
998            assert_ne!(baseline, burned, "{pix_fmt}: subtitles must alter video");
999            let inside = u16le_at(&burned, inside_luma);
1000            assert!(
1001                inside >= white_floor,
1002                "{pix_fmt}: near-white luma expected inside the box, got {inside}"
1003            );
1004            assert_eq!(
1005                u16le_at(&baseline, outside_luma),
1006                u16le_at(&burned, outside_luma),
1007                "{pix_fmt}: outside sample must not change"
1008            );
1009        }
1010    }
1011
1012    /// Deterministic LCG byte stream for synthetic planes and masks.
1013    fn lcg_bytes(len: usize, seed: &mut u64) -> Vec<u8> {
1014        (0..len)
1015            .map(|_| {
1016                *seed = seed
1017                    .wrapping_mul(6364136223846793005)
1018                    .wrapping_add(1442695040888963407);
1019                (*seed >> 33) as u8
1020            })
1021            .collect()
1022    }
1023
1024    /// Rows of one AVFrame plane, derived from the format spec (0 when the
1025    /// plane is unused by the format).
1026    fn plane_rows(spec: &FormatSpec, plane: usize, height: usize) -> usize {
1027        spec.comps
1028            .iter()
1029            .filter(|(_, p)| p.plane == plane)
1030            .map(|(_, p)| (height + (1usize << p.vsub) - 1) >> p.vsub)
1031            .max()
1032            .unwrap_or(0)
1033    }
1034
1035    /// Allocates a `w x h` frame of `format` and fills every used plane
1036    /// (including alignment padding) with a deterministic pattern.
1037    ///
1038    /// # Safety
1039    /// Caller frees the frame with `av_frame_free`.
1040    unsafe fn filled_frame(
1041        w: i32,
1042        h: i32,
1043        format: AVPixelFormat,
1044        spec: &FormatSpec,
1045        mut seed: u64,
1046    ) -> *mut AVFrame {
1047        let frame = av_frame_alloc();
1048        assert!(!frame.is_null());
1049        (*frame).width = w;
1050        (*frame).height = h;
1051        (*frame).format = format as i32;
1052        assert!(av_frame_get_buffer(frame, 0) >= 0, "av_frame_get_buffer");
1053        for plane in 0..4usize {
1054            let rows = plane_rows(spec, plane, h as usize);
1055            let data = (*frame).data[plane];
1056            if rows == 0 || data.is_null() {
1057                continue;
1058            }
1059            let len = (*frame).linesize[plane] as usize * rows;
1060            let bytes = lcg_bytes(len, &mut seed);
1061            std::slice::from_raw_parts_mut(data, len).copy_from_slice(&bytes);
1062        }
1063        frame
1064    }
1065
1066    /// Copies out every used plane (full linesize x rows, padding included).
1067    ///
1068    /// # Safety
1069    /// `frame` must be a valid frame produced by [`filled_frame`].
1070    unsafe fn plane_bytes(frame: *mut AVFrame, spec: &FormatSpec, height: usize) -> Vec<Vec<u8>> {
1071        (0..4usize)
1072            .map(|plane| {
1073                let rows = plane_rows(spec, plane, height);
1074                let data = (*frame).data[plane];
1075                if rows == 0 || data.is_null() {
1076                    return Vec::new();
1077                }
1078                let len = (*frame).linesize[plane] as usize * rows;
1079                std::slice::from_raw_parts(data, len).to_vec()
1080            })
1081            .collect()
1082    }
1083
1084    /// The parallel plane split must produce byte-identical frames to the
1085    /// serial path on every layout family: planar 4:2:0, semi-planar
1086    /// (NV12), no-subsampling planar, high-depth planar + P010, and the
1087    /// single-plane formats where the split falls back to serial.
1088    #[test]
1089    fn parallel_blend_matches_serial_bitexact() {
1090        use ffmpeg_sys_next::AVPixelFormat::*;
1091        let (w, h) = (318i32, 178i32);
1092        let mut seed = 0x00C0_FFEE_0DDB_A11Du64;
1093
1094        // Three overlays: dense structured (overhanging top-left),
1095        // translucent red, and a solid overhanging the bottom — different
1096        // colors and opacities so every source component and the
1097        // compositing order matter. (Right-edge interleaved geometry is
1098        // covered separately by right_edge_offset_component_stays_in_bounds
1099        // since the row_len fix.)
1100        let mask_a = {
1101            let mut mask = lcg_bytes(220 * 130, &mut seed);
1102            for (i, byte) in mask.iter_mut().enumerate() {
1103                if (i / 40) % 3 == 0 {
1104                    *byte = 0;
1105                }
1106            }
1107            mask
1108        };
1109        let mask_b = lcg_bytes(97 * 53, &mut seed);
1110        let mask_c = vec![255u8; 64 * 33];
1111        let images = [
1112            OverlayImage {
1113                w: 220,
1114                h: 130,
1115                stride: 220,
1116                bitmap: &mask_a,
1117                color: 0xFFFFFF00,
1118                dst_x: -8,
1119                dst_y: -6,
1120            },
1121            OverlayImage {
1122                w: 97,
1123                h: 53,
1124                stride: 97,
1125                bitmap: &mask_b,
1126                color: 0xFF000040,
1127                dst_x: 40,
1128                dst_y: 30,
1129            },
1130            OverlayImage {
1131                w: 64,
1132                h: 33,
1133                stride: 64,
1134                bitmap: &mask_c,
1135                color: 0x00FF0080,
1136                dst_x: 240,
1137                dst_y: 160,
1138            },
1139        ];
1140
1141        for format in [
1142            AV_PIX_FMT_YUV420P,
1143            AV_PIX_FMT_NV12,
1144            AV_PIX_FMT_YUV444P,
1145            AV_PIX_FMT_YUV420P10LE,
1146            AV_PIX_FMT_P010LE,
1147            AV_PIX_FMT_RGB24,
1148            AV_PIX_FMT_GRAY8,
1149        ] {
1150            let spec = layout::format_spec(format as i32).expect("supported format");
1151            let fill_seed = 0x5EED_0000 ^ format as u64;
1152            // SAFETY: frames allocated and freed here; blend_images gets a
1153            // writable, exclusively-owned software frame matching `spec`.
1154            unsafe {
1155                let serial = filled_frame(w, h, format, spec, fill_seed);
1156                let parallel = filled_frame(w, h, format, spec, fill_seed);
1157                let original = plane_bytes(serial, spec, h as usize);
1158
1159                let mut scratch_serial = BlendScratch::default();
1160                let mut scratch_parallel = BlendScratch::default();
1161                SubtitleFilter::blend_images(
1162                    serial,
1163                    &images,
1164                    spec,
1165                    ColorMatrix::Bt601,
1166                    Some(ColorRange::Limited),
1167                    &mut scratch_serial,
1168                    false,
1169                );
1170                SubtitleFilter::blend_images(
1171                    parallel,
1172                    &images,
1173                    spec,
1174                    ColorMatrix::Bt601,
1175                    Some(ColorRange::Limited),
1176                    &mut scratch_parallel,
1177                    true,
1178                );
1179
1180                let serial_planes = plane_bytes(serial, spec, h as usize);
1181                let parallel_planes = plane_bytes(parallel, spec, h as usize);
1182                assert_ne!(
1183                    original, serial_planes,
1184                    "{format:?}: blend must alter the frame"
1185                );
1186                assert_eq!(
1187                    serial_planes, parallel_planes,
1188                    "{format:?}: parallel blend diverged from serial"
1189                );
1190
1191                let mut serial = serial;
1192                let mut parallel = parallel;
1193                av_frame_free(&mut serial);
1194                av_frame_free(&mut parallel);
1195            }
1196        }
1197    }
1198
1199    /// The fused packed-RGB path must be byte-identical to blending each
1200    /// interleaved component independently (the general path it replaces).
1201    /// A single-task group never matches the fused shape, so running each
1202    /// component as its own group exercises the reference; component byte
1203    /// ranges are disjoint, so the reference's per-component ordering is
1204    /// equivalent to the fused per-overlay ordering.
1205    #[test]
1206    fn packed_rgb_fused_blend_matches_per_component_reference() {
1207        use ffmpeg_sys_next::AVPixelFormat::*;
1208        let (w, h) = (318i32, 178i32);
1209        let mut seed = 0x0DDB_A11D_00C0_FFEEu64;
1210
1211        // Structured mask overhanging the top-left, a translucent interior
1212        // red, and a solid block overhanging the bottom-right: clipping on
1213        // all sides, zero-mask skip blocks, and compositing order all in
1214        // play.
1215        let mask_a = {
1216            let mut mask = lcg_bytes(220 * 130, &mut seed);
1217            for (i, byte) in mask.iter_mut().enumerate() {
1218                if (i / 40) % 3 == 0 {
1219                    *byte = 0;
1220                }
1221            }
1222            mask
1223        };
1224        let mask_b = lcg_bytes(97 * 53, &mut seed);
1225        let mask_c = vec![255u8; 90 * 60];
1226        let images = [
1227            OverlayImage {
1228                w: 220,
1229                h: 130,
1230                stride: 220,
1231                bitmap: &mask_a,
1232                color: 0xFFFFFF00,
1233                dst_x: -8,
1234                dst_y: -6,
1235            },
1236            OverlayImage {
1237                w: 97,
1238                h: 53,
1239                stride: 97,
1240                bitmap: &mask_b,
1241                color: 0xFF000040,
1242                dst_x: 40,
1243                dst_y: 30,
1244            },
1245            OverlayImage {
1246                w: 90,
1247                h: 60,
1248                stride: 90,
1249                bitmap: &mask_c,
1250                color: 0x00FF0080,
1251                dst_x: 260,
1252                dst_y: 140,
1253            },
1254        ];
1255
1256        for format in [
1257            AV_PIX_FMT_RGB24,
1258            AV_PIX_FMT_BGR24,
1259            AV_PIX_FMT_RGBA,
1260            AV_PIX_FMT_BGRA,
1261            AV_PIX_FMT_ARGB,
1262            AV_PIX_FMT_ABGR,
1263        ] {
1264            let spec = layout::format_spec(format as i32).expect("supported format");
1265            let fill_seed = 0xF05E_ED00 ^ format as u64;
1266            let dims = (w as usize, h as usize);
1267            // SAFETY: frames allocated and freed here; the task views obey
1268            // the one-live-view-per-worker contract (everything is serial).
1269            unsafe {
1270                let fused_frame = filled_frame(w, h, format, spec, fill_seed);
1271                let reference = filled_frame(w, h, format, spec, fill_seed);
1272                let original = plane_bytes(fused_frame, spec, h as usize);
1273
1274                let mut preps = Vec::new();
1275                fill_overlay_preps(
1276                    &images,
1277                    spec,
1278                    ColorMatrix::Bt601,
1279                    ColorRange::Full,
1280                    &mut preps,
1281                );
1282                let mut tasks = Vec::new();
1283                let mut pool = Vec::new();
1284
1285                collect_comp_tasks(fused_frame, spec, dims, &mut tasks);
1286                assert!(
1287                    fused_packed_rgb(&tasks, spec.sample).is_some(),
1288                    "{format:?} must take the fused packed-RGB path"
1289                );
1290                blend_task_group(&tasks, &images, &preps, spec.sample, dims, &mut pool);
1291
1292                collect_comp_tasks(reference, spec, dims, &mut tasks);
1293                for task in &tasks {
1294                    blend_task_group(
1295                        std::slice::from_ref(task),
1296                        &images,
1297                        &preps,
1298                        spec.sample,
1299                        dims,
1300                        &mut pool,
1301                    );
1302                }
1303
1304                let fused_planes = plane_bytes(fused_frame, spec, h as usize);
1305                let reference_planes = plane_bytes(reference, spec, h as usize);
1306                assert_ne!(
1307                    original, fused_planes,
1308                    "{format:?}: blend must alter the frame"
1309                );
1310                assert_eq!(
1311                    fused_planes, reference_planes,
1312                    "{format:?}: fused packed-RGB blend diverged from the per-component reference"
1313                );
1314
1315                let mut fused_frame = fused_frame;
1316                let mut reference = reference;
1317                av_frame_free(&mut fused_frame);
1318                av_frame_free(&mut reference);
1319            }
1320        }
1321    }
1322
1323    /// A subtitle overlay reaching the bottom-right corner must stay in
1324    /// bounds for components whose byte offset is nonzero (packed RGB
1325    /// green/blue, NV12/NV21/P010 interleaved chroma). Regression for the
1326    /// component view length that subtracted `offset` a second time and left
1327    /// the view `offset` bytes short of the last sample — a full-cover
1328    /// overlay used to panic (slice OOB) on the last row. Odd dimensions
1329    /// also stress the subsampled right/bottom edges.
1330    #[test]
1331    fn right_edge_offset_component_stays_in_bounds() {
1332        use ffmpeg_sys_next::AVPixelFormat::*;
1333        let (w, h) = (17i32, 9i32);
1334        for format in [
1335            AV_PIX_FMT_RGB24,
1336            AV_PIX_FMT_BGR24,
1337            AV_PIX_FMT_RGBA,
1338            AV_PIX_FMT_BGRA,
1339            AV_PIX_FMT_ARGB,
1340            AV_PIX_FMT_ABGR,
1341            AV_PIX_FMT_NV12,
1342            AV_PIX_FMT_NV21,
1343            AV_PIX_FMT_P010LE,
1344            AV_PIX_FMT_YUV420P,
1345        ] {
1346            let spec = layout::format_spec(format as i32).expect("supported format");
1347            // Opaque white overlay covering the WHOLE frame incl. the corner.
1348            let mask = vec![255u8; (w * h) as usize];
1349            let images = [OverlayImage {
1350                w: w as usize,
1351                h: h as usize,
1352                stride: w as usize,
1353                bitmap: &mask,
1354                color: 0xFFFFFF00,
1355                dst_x: 0,
1356                dst_y: 0,
1357            }];
1358            // SAFETY: frame allocated and freed here; blend_images gets a
1359            // writable, exclusively-owned software frame matching `spec`.
1360            unsafe {
1361                let frame = filled_frame(w, h, format, spec, 0xC0DE ^ format as u64);
1362                let before = plane_bytes(frame, spec, h as usize);
1363                let mut scratch = BlendScratch::default();
1364                // Would panic (slice OOB) before the view-length fix.
1365                SubtitleFilter::blend_images(
1366                    frame,
1367                    &images,
1368                    spec,
1369                    ColorMatrix::Bt601,
1370                    Some(ColorRange::Limited),
1371                    &mut scratch,
1372                    false,
1373                );
1374                let after = plane_bytes(frame, spec, h as usize);
1375                assert_ne!(
1376                    before, after,
1377                    "{format:?}: full-cover blend must alter the frame"
1378                );
1379                let mut frame = frame;
1380                av_frame_free(&mut frame);
1381            }
1382        }
1383    }
1384
1385    /// An RGB frame with UNSPECIFIED color range must burn subtitles at full
1386    /// range (white = 255), matching FFmpeg ff_draw — not limited (235).
1387    #[test]
1388    fn rgb_unspecified_range_is_full() {
1389        use ffmpeg_sys_next::AVPixelFormat::AV_PIX_FMT_RGB24;
1390        let (w, h) = (4i32, 2i32);
1391        let spec = layout::format_spec(AV_PIX_FMT_RGB24 as i32).expect("rgb24");
1392        let mask = vec![255u8; (w * h) as usize];
1393        let images = [OverlayImage {
1394            w: w as usize,
1395            h: h as usize,
1396            stride: w as usize,
1397            bitmap: &mask,
1398            color: 0xFFFFFF00,
1399            dst_x: 0,
1400            dst_y: 0,
1401        }];
1402        // SAFETY: frame allocated and freed here; blend_images gets a
1403        // writable, exclusively-owned software frame matching `spec`.
1404        unsafe {
1405            let frame = filled_frame(w, h, AV_PIX_FMT_RGB24, spec, 0x1);
1406            let mut scratch = BlendScratch::default();
1407            // range = None => unspecified => must default to full for RGB.
1408            SubtitleFilter::blend_images(
1409                frame,
1410                &images,
1411                spec,
1412                ColorMatrix::Bt601,
1413                None,
1414                &mut scratch,
1415                false,
1416            );
1417            let r = *(*frame).data[0];
1418            assert!(
1419                r > 240,
1420                "unspecified-range RGB white must be full-range (got {r}, limited=235)"
1421            );
1422            let mut frame = frame;
1423            av_frame_free(&mut frame);
1424        }
1425    }
1426
1427    /// A hostile force-style `Blur` must be clamped like inline `\blur` so it
1428    /// cannot drive an unbounded gaussian-padding allocation.
1429    #[test]
1430    fn force_style_blur_is_bounded() {
1431        let Some(font) = test_util::test_font() else {
1432            eprintln!("skipping: no known test font present on this machine");
1433            return;
1434        };
1435        let mut filter = SubtitleFilter::builder()
1436            .ass_content(quarter_box_script("0:00:00.00", "0:00:10.00"))
1437            .force_style("Blur=100000")
1438            .default_font_file(font)
1439            .font_provider(FontProvider::None)
1440            .build()
1441            .expect("build subtitle filter");
1442        filter.configure_renderer(W as i32, H as i32);
1443        // Must complete without OOM/panic; the huge Blur is clamped.
1444        let _ = filter.renderer_mut().render_frame(1_000);
1445    }
1446
1447    /// End-to-end `blend_images` timing, serial vs plane-parallel, on the
1448    /// captured dense/sparse scenes (real rendered masks). Deliberately NOT
1449    /// named `bench_blend...` so it never times concurrently with the
1450    /// kernel tables; run it on its own with:
1451    ///
1452    /// ```text
1453    /// cargo test --release --features subtitle bench_plane_parallel -- --ignored --nocapture
1454    /// ```
1455    #[test]
1456    #[ignore = "manual micro-benchmark; run in release with --nocapture"]
1457    fn bench_plane_parallel_blend_images() {
1458        use crate::subtitle::bench_kernels::{capture, dense_events, measure, sparse_events};
1459        use ffmpeg_sys_next::AVPixelFormat::*;
1460
1461        let Some(dense) = capture(dense_events()) else {
1462            eprintln!("skipping: no known test font present on this machine");
1463            return;
1464        };
1465        let sparse = capture(sparse_events()).expect("font present per the check above");
1466        for (name, owned) in [("dense", dense), ("sparse", sparse)] {
1467            let images: Vec<OverlayImage<'_>> = owned.iter().map(|o| o.as_view()).collect();
1468            let px = clipped_mask_px(&images, 1920, 1080);
1469            println!(
1470                "blend_images {name}: mask_px={px} gate_parallel={}",
1471                should_parallelize(px)
1472            );
1473            for (format, label) in [
1474                (AV_PIX_FMT_YUV420P, "yuv420p"),
1475                (AV_PIX_FMT_YUV420P10LE, "yuv420p10le"),
1476            ] {
1477                let spec = layout::format_spec(format as i32).expect("supported format");
1478                // SAFETY: frame allocated and freed here; blend_images gets
1479                // a writable, exclusively-owned software frame matching
1480                // `spec`. Repeated composites only change dst values, not
1481                // the amount of work (the kernels are branchless in dst).
1482                unsafe {
1483                    let frame = filled_frame(1920, 1080, format, spec, 0xBEEF);
1484                    let mut scratch = BlendScratch::default();
1485                    let serial = measure(|| {
1486                        SubtitleFilter::blend_images(
1487                            frame,
1488                            &images,
1489                            spec,
1490                            ColorMatrix::Bt601,
1491                            Some(ColorRange::Limited),
1492                            &mut scratch,
1493                            false,
1494                        );
1495                    });
1496                    let parallel = measure(|| {
1497                        SubtitleFilter::blend_images(
1498                            frame,
1499                            &images,
1500                            spec,
1501                            ColorMatrix::Bt601,
1502                            Some(ColorRange::Limited),
1503                            &mut scratch,
1504                            true,
1505                        );
1506                    });
1507                    println!(
1508                        "  {label:<12} serial {serial:>10.0} ns/frame   parallel \
1509                         {parallel:>10.0} ns/frame   {:>5.2}x",
1510                        serial / parallel
1511                    );
1512                    let mut frame = frame;
1513                    av_frame_free(&mut frame);
1514                }
1515            }
1516        }
1517    }
1518
1519    /// The gate is pure and exclusive at the threshold.
1520    #[test]
1521    fn parallel_gate_threshold_is_exclusive() {
1522        assert!(!should_parallelize(0));
1523        assert!(!should_parallelize(PARALLEL_MASK_PX_THRESHOLD));
1524        assert!(should_parallelize(PARALLEL_MASK_PX_THRESHOLD + 1));
1525    }
1526
1527    /// Work estimation counts only mask area intersecting the frame.
1528    #[test]
1529    fn clipped_mask_px_counts_only_intersecting_area() {
1530        let bitmap = vec![255u8; 100 * 50];
1531        let image = |dst_x: i32, dst_y: i32| OverlayImage {
1532            w: 100,
1533            h: 50,
1534            stride: 100,
1535            bitmap: &bitmap,
1536            color: 0xFFFFFF00,
1537            dst_x,
1538            dst_y,
1539        };
1540        let on_frame = image(10, 10);
1541        assert_eq!(
1542            clipped_mask_px(std::slice::from_ref(&on_frame), 640, 360),
1543            5000
1544        );
1545        let half_off = image(-50, 0);
1546        assert_eq!(
1547            clipped_mask_px(std::slice::from_ref(&half_off), 640, 360),
1548            2500
1549        );
1550        let fully_off = image(640, 0);
1551        assert_eq!(
1552            clipped_mask_px(std::slice::from_ref(&fully_off), 640, 360),
1553            0
1554        );
1555        let both = [image(10, 10), image(-50, 0)];
1556        assert_eq!(clipped_mask_px(&both, 640, 360), 7500);
1557    }
1558
1559    /// Split rules: multi-plane formats split after the first plane's
1560    /// components; single-plane formats and (defensively) overlapping
1561    /// ranges refuse to split.
1562    #[test]
1563    fn split_tasks_by_plane_and_overlap() {
1564        let mut buf_a = vec![0u8; 64];
1565        let mut buf_b = vec![0u8; 64];
1566        let task = |plane: usize, data: *mut u8, len: usize| CompTask {
1567            plane,
1568            data,
1569            len,
1570            linesize: 8,
1571            pixel_step: 1,
1572            source: 0,
1573            hsub: 0,
1574            vsub: 0,
1575        };
1576        let a_ptr = buf_a.as_mut_ptr();
1577        let b_ptr = buf_b.as_mut_ptr();
1578
1579        // Planar layout: luma group + two chroma planes.
1580        // SAFETY: pointer arithmetic stays inside the owned buffers.
1581        let chroma2 = unsafe { b_ptr.add(32) };
1582        let tasks = [task(0, a_ptr, 64), task(1, b_ptr, 32), task(2, chroma2, 32)];
1583        let (group_a, group_b) = split_tasks(&tasks).expect("planar split");
1584        assert_eq!((group_a.len(), group_b.len()), (1, 2));
1585
1586        // Packed RGB: everything on plane 0 -> no split.
1587        // SAFETY: as above.
1588        let (rgb1, rgb2) = unsafe { (a_ptr.add(1), a_ptr.add(2)) };
1589        let tasks = [task(0, a_ptr, 62), task(0, rgb1, 62), task(0, rgb2, 62)];
1590        assert!(split_tasks(&tasks).is_none());
1591
1592        // Single component (gray): no split.
1593        assert!(split_tasks(&[task(0, a_ptr, 64)]).is_none());
1594        assert!(split_tasks(&[]).is_none());
1595
1596        // Defensive: distinct plane indices but overlapping bytes.
1597        // SAFETY: as above.
1598        let overlap = unsafe { a_ptr.add(32) };
1599        let tasks = [task(0, a_ptr, 64), task(1, overlap, 32)];
1600        assert!(split_tasks(&tasks).is_none());
1601
1602        // Defensive: the first plane reappears after the split point.
1603        let tasks = [task(0, a_ptr, 32), task(1, b_ptr, 32), task(0, overlap, 32)];
1604        assert!(split_tasks(&tasks).is_none());
1605    }
1606
1607    fn ffmpeg_cli_has_libass() -> bool {
1608        std::process::Command::new("ffmpeg")
1609            .args(["-hide_banner", "-buildconf"])
1610            .output()
1611            .map(|out| String::from_utf8_lossy(&out.stdout).contains("--enable-libass"))
1612            .unwrap_or(false)
1613    }
1614
1615    /// Parity against FFmpeg's own subtitles filter, run with an identical
1616    /// font setup. Ignored by default: requires an ffmpeg CLI built with
1617    /// --enable-libass (run with `cargo test --features subtitle -- --ignored`).
1618    #[test]
1619    #[ignore = "requires ffmpeg CLI built with --enable-libass"]
1620    fn parity_with_ffmpeg_cli() {
1621        let Some(font) = test_util::test_font() else {
1622            eprintln!("skipping: no known test font present on this machine");
1623            return;
1624        };
1625        if !ffmpeg_cli_has_libass() {
1626            eprintln!("skipping: no ffmpeg CLI with --enable-libass on PATH");
1627            return;
1628        }
1629
1630        // Shared, single-font directory so both sides resolve the same face.
1631        let fonts_dir = temp_path("parity_fonts");
1632        std::fs::create_dir_all(&fonts_dir).expect("create fonts dir");
1633        let font_file = fonts_dir.join("DejaVuSans.ttf");
1634        std::fs::copy(font, &font_file).expect("copy test font");
1635
1636        // Red text + red drawing: red is matrix-dependent (white is not), so
1637        // the BT.709 variant actually validates the color matrix selection.
1638        const FORCE_STYLE: &str = "PrimaryColour=&H0000FF&";
1639        let fonts_str = fonts_dir.to_str().expect("utf8 path");
1640
1641        let run_pair = |script: &str,
1642                        name: &str,
1643                        setparams: Option<&str>,
1644                        pix_fmt: &str|
1645         -> (Vec<u8>, Vec<u8>) {
1646            let label = format!("{name}_{}", setparams.map_or("default", |_| "bt709"));
1647            let script_path = temp_path(&format!("parity_{label}.ass"));
1648            std::fs::write(&script_path, script).expect("write script");
1649            let script_str = script_path.to_str().expect("utf8 path");
1650            // Keep lavfi escaping trivial by construction.
1651            assert!(
1652                !script_str.contains([':', '\'']) && !fonts_str.contains([':', '\'']),
1653                "temp paths must not need lavfi escaping"
1654            );
1655            let reference_path = temp_path(&format!("parity_ref_{label}.yuv"));
1656            let ours_path = temp_path(&format!("parity_ours_{label}.yuv"));
1657
1658            let mut chain = String::new();
1659            if let Some(setparams) = setparams {
1660                chain.push_str(setparams);
1661                chain.push(',');
1662            }
1663            chain.push_str(&format!(
1664                "format={pix_fmt},subtitles=filename='{script_str}':fontsdir='{fonts_str}':force_style='{FORCE_STYLE}'"
1665            ));
1666            let status = std::process::Command::new("ffmpeg")
1667                .args(["-y", "-v", "error", "-i", "test.mp4", "-an", "-vf"])
1668                .arg(&chain)
1669                .args(["-f", "rawvideo"])
1670                .arg(&reference_path)
1671                .status()
1672                .expect("run ffmpeg CLI");
1673            assert!(status.success(), "ffmpeg CLI failed for {label}");
1674
1675            let filter = SubtitleFilter::builder()
1676                .ass_content(script)
1677                .fonts_dir(&fonts_dir)
1678                .default_font_file(&font_file)
1679                .font_provider(FontProvider::None)
1680                .force_style(FORCE_STYLE)
1681                .build()
1682                .expect("build subtitle filter");
1683            transcode_test_mp4(&ours_path, Some(filter), pix_fmt, setparams);
1684
1685            let reference = std::fs::read(&reference_path).expect("read reference");
1686            let ours = std::fs::read(&ours_path).expect("read ours");
1687            // Keep intermediates for offline diffing when requested.
1688            if std::env::var_os("EZ_PARITY_KEEP").is_none() {
1689                let _ = std::fs::remove_file(&script_path);
1690                let _ = std::fs::remove_file(&reference_path);
1691                let _ = std::fs::remove_file(&ours_path);
1692            } else {
1693                eprintln!("kept: {reference_path:?} vs {ours_path:?}");
1694            }
1695            assert_eq!(reference.len(), ours.len(), "{label}: frame count");
1696            (reference, ours)
1697        };
1698
1699        // Phase 1 — vector drawing only. Interiors must be byte-exact
1700        // (proves blend math, color conversion for both matrices, and
1701        // geometry); shape-EDGE pixels may differ by a few percent of
1702        // coverage because zeno and libass rasterize anti-aliased edges
1703        // with different algorithms (measured: <=11/255 on ~0.3% of
1704        // pixels, all on the outline perimeter).
1705        let drawing = test_util::minimal_ass(test_util::DRAWING_EVENT);
1706        let mut ours_by_matrix = Vec::new();
1707        for setparams in [None, Some("setparams=colorspace=bt709")] {
1708            let (reference, ours) = run_pair(&drawing, "draw", setparams, "yuv420p");
1709            let (max, mean) = diff_stats(&reference, &ours);
1710            let big_diffs = reference
1711                .iter()
1712                .zip(&ours)
1713                .filter(|(a, b)| a.abs_diff(**b) > 8)
1714                .count();
1715            let big_fraction = big_diffs as f64 / reference.len() as f64;
1716            assert!(
1717                max <= 16 && mean < 0.05 && big_fraction < 0.0005,
1718                "drawing parity diverged (setparams {setparams:?}: max {max}, mean {mean:.4}, \
1719                 >8-diff fraction {big_fraction:.6}) — interiors must stay byte-exact, edges \
1720                 within cross-rasterizer AA noise"
1721            );
1722            ours_by_matrix.push(ours);
1723        }
1724        // Red is matrix-dependent (white is not): both matrices must differ,
1725        // guarding against both sides being wrong identically.
1726        assert_ne!(
1727            ours_by_matrix[0], ours_by_matrix[1],
1728            "BT.601 and BT.709 renders should differ for a red subtitle"
1729        );
1730
1731        // Same drawing at 10 bit — compared in 10-bit code units (byte-wise
1732        // diffs would misreport u16 carries). Edge-AA differences scale by
1733        // 4x in 10-bit units, so the bounds scale accordingly.
1734        let (reference, ours) = run_pair(&drawing, "draw10", None, "yuv420p10le");
1735        let (max, mean) = test_util::diff_stats_u16le(&reference, &ours);
1736        assert!(
1737            max <= 64 && mean < 0.2,
1738            "10-bit drawing parity diverged (max {max}, mean {mean:.4})"
1739        );
1740
1741        // Phase 2 — text glyphs. Glyph GEOMETRY must match (advances,
1742        // positions, line placement — a scaling or shaping bug shifts whole
1743        // glyph runs and explodes these bounds); pixel-level anti-aliasing
1744        // legitimately differs because zeno and libass rasterize and stroke
1745        // outlines with different algorithms. Measured divergence with
1746        // correct geometry: mean 0.59, >8-diff fraction 0.018 (edge pixels
1747        // only); with the pre-fix aspect-scaling bug: mean 2.56, fraction
1748        // 0.044 — the bounds separate the two regimes cleanly.
1749        let text = test_util::minimal_ass(&format!(
1750            "{}{}",
1751            test_util::HELLO_EVENT,
1752            test_util::DRAWING_EVENT
1753        ));
1754        let (reference, ours) = run_pair(&text, "text", None, "yuv420p");
1755        let (_, mean) = diff_stats(&reference, &ours);
1756        let outliers = reference
1757            .iter()
1758            .zip(&ours)
1759            .filter(|(a, b)| a.abs_diff(**b) > 8)
1760            .count();
1761        let outlier_fraction = outliers as f64 / reference.len() as f64;
1762        assert!(
1763            mean < 1.0 && outlier_fraction < 0.03,
1764            "text parity diverged (mean {mean:.4}, >8-diff fraction {outlier_fraction:.5})"
1765        );
1766
1767        let _ = std::fs::remove_file(&font_file);
1768        let _ = std::fs::remove_dir(&fonts_dir);
1769    }
1770}