1use 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
17pub struct SubtitleFilter {
44 renderer: Box<dyn SubtitleRenderer>,
47 original_size: Option<(u32, u32)>,
49 configured_dims: Option<(i32, i32)>,
51 blend_scratch: BlendScratch,
53 warned_missing_time: bool,
54 warned_colorspace: bool,
55 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 pub fn builder() -> SubtitleFilterBuilder {
72 SubtitleFilterBuilder::default()
73 }
74
75 #[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 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 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 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 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 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
229fn 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]; 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; 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
272unsafe 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 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#[derive(Default)]
319struct BlendScratch {
320 preps: Vec<OverlayPrep>,
322 tasks: Vec<CompTask>,
326 pool_a: Vec<u16>,
328 pool_b: Vec<u16>,
331}
332
333struct OverlayPrep {
336 alpha: u32,
337 src: [u32; 3],
338}
339
340#[derive(Clone, Copy)]
343struct CompTask {
344 plane: usize,
346 data: *mut u8,
348 len: usize,
352 linesize: usize,
353 pixel_step: usize,
354 source: usize,
356 hsub: u32,
357 vsub: u32,
358}
359
360unsafe impl Send for CompTask {}
366unsafe impl Sync for CompTask {}
369
370impl CompTask {
371 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
389struct FusedRgb {
393 view_task: CompTask,
394 offsets: [usize; 3],
395 sources: [usize; 3],
396}
397
398impl FusedRgb {
399 unsafe fn view(&self) -> PlaneView<'_> {
403 self.view_task.view()
404 }
405}
406
407fn 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 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
463fn 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
485fn 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 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 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 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 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 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
595const PARALLEL_MASK_PX_THRESHOLD: usize = 256 * 1024;
603
604fn should_parallelize(clipped_mask_px: usize) -> bool {
606 clipped_mask_px > PARALLEL_MASK_PX_THRESHOLD
607}
608
609fn 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 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 if crate::util::ffmpeg_utils::frame_is_eof_marker(&frame) {
639 return Ok(Some(frame));
640 }
641 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 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 let now_ms = unsafe { av_rescale_q(pts, time_base, AVRational { num: 1, den: 1000 }) };
692
693 self.configure_renderer(width, height);
694 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 return Ok(Some(frame));
703 }
704 if !any_visible_image(&images, width, height, spec.sample) {
708 return Ok(Some(frame));
709 }
710
711 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 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 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 self.renderer.teardown();
760 }
761}
762
763fn 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 if (0..AVPixelFormat::AV_PIX_FMT_NB as i32).contains(&format) {
788 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 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 #[test]
832 #[allow(clippy::manual_is_multiple_of)] 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 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 let inside = 60 * W + 80; let outside = 230 * W + 300; 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 assert!(
894 burned[inside] >= 230,
895 "expected near-white luma inside the box, got {}",
896 burned[inside]
897 );
898 }
899
900 #[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 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 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 #[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; let outside_luma = 230 * W + 300;
978
979 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 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 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 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 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 #[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 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 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 #[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 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 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 #[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 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 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 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 #[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 unsafe {
1405 let frame = filled_frame(w, h, AV_PIX_FMT_RGB24, spec, 0x1);
1406 let mut scratch = BlendScratch::default();
1407 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 #[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 let _ = filter.renderer_mut().render_frame(1_000);
1445 }
1446
1447 #[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 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 #[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 #[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 #[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 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 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 assert!(split_tasks(&[task(0, a_ptr, 64)]).is_none());
1594 assert!(split_tasks(&[]).is_none());
1595
1596 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 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 #[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 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 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 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 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 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 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 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 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}