Skip to main content

rassa_render/
lib.rs

1use std::collections::HashMap;
2
3use rassa_core::{ImagePlane, Point, Rect, RendererConfig, RgbaColor, Size, ass};
4use rassa_fonts::{FontProvider, FontconfigProvider};
5use rassa_layout::{LayoutEngine, LayoutEvent, LayoutGlyphRun};
6use rassa_parse::{
7    ParsedDrawing, ParsedEvent, ParsedFade, ParsedKaraokeMode, ParsedMovement, ParsedSpanStyle,
8    ParsedTrack, ParsedVectorClip,
9};
10use rassa_raster::{RasterGlyph, RasterOptions, Rasterizer};
11use rassa_shape::{GlyphInfo, ShapingMode};
12
13#[derive(Clone, Debug, Default, PartialEq, Eq)]
14pub struct RenderSelection {
15    pub active_event_indices: Vec<usize>,
16}
17
18#[derive(Clone, Debug, Default, PartialEq)]
19pub struct PreparedFrame {
20    pub now_ms: i64,
21    pub active_events: Vec<LayoutEvent>,
22}
23
24#[derive(Default)]
25pub struct RenderEngine {
26    layout: LayoutEngine,
27}
28
29const LINE_HEIGHT: i32 = 40;
30
31fn layout_line_height(config: &RendererConfig, scale_y: f64) -> i32 {
32    let scale_y = style_scale(scale_y);
33    let extra_spacing = if config.line_spacing.is_finite() {
34        (config.line_spacing * scale_y).round() as i32
35    } else {
36        0
37    };
38    ((f64::from(LINE_HEIGHT) * scale_y).round() as i32 + extra_spacing).max(1)
39}
40
41fn layout_line_height_for_line(
42    line: &rassa_layout::LayoutLine,
43    config: &RendererConfig,
44    scale_y: f64,
45) -> i32 {
46    if line.runs.iter().all(|run| run.drawing.is_some()) {
47        return drawing_only_line_height(line, scale_y);
48    }
49
50    text_layout_line_height_for_line(line, config, scale_y)
51}
52
53fn positioned_layout_line_height_for_line(
54    line: &rassa_layout::LayoutLine,
55    config: &RendererConfig,
56    scale_y: f64,
57) -> i32 {
58    if line.runs.iter().all(|run| run.drawing.is_some()) {
59        return drawing_only_line_height(line, scale_y);
60    }
61
62    layout_line_height(config, scale_y).max(font_metric_height_for_line(line, scale_y))
63}
64
65fn text_layout_line_height_for_line(
66    line: &rassa_layout::LayoutLine,
67    config: &RendererConfig,
68    scale_y: f64,
69) -> i32 {
70    let scale_y = style_scale(scale_y);
71    let max_font_size = line
72        .runs
73        .iter()
74        .filter(|run| run.drawing.is_none())
75        .map(|run| run.style.font_size)
76        .filter(|size| size.is_finite() && *size > 0.0)
77        .fold(0.0_f64, f64::max);
78    let extra_spacing = if config.line_spacing.is_finite() {
79        (config.line_spacing * scale_y).round() as i32
80    } else {
81        0
82    };
83    ((max_font_size * scale_y).round() as i32 + extra_spacing).max(1)
84}
85
86fn rendered_text_alignment_width(
87    line: &rassa_layout::LayoutLine,
88    source_event: Option<&ParsedEvent>,
89    now_ms: i64,
90    track: &ParsedTrack,
91    config: &RendererConfig,
92    render_scale: RenderScale,
93) -> i32 {
94    if line.runs.iter().all(|run| run.drawing.is_some()) {
95        return (f64::from(line.width) * style_scale(render_scale.x)).round() as i32;
96    }
97
98    let mut width = 0_i32;
99    let mut leading_ink_offset = i32::MAX;
100    for run in &line.runs {
101        if run.drawing.is_some() {
102            width += (f64::from(run.width) * style_scale(render_scale.x)).round() as i32;
103            continue;
104        }
105        if run.glyphs.is_empty() {
106            continue;
107        }
108        let effective_style = apply_renderer_style_scale(
109            resolve_run_style(run, source_event, now_ms),
110            track,
111            config,
112            render_scale.uniform,
113        );
114        let rasterizer = Rasterizer::with_options(RasterOptions {
115            size_26_6: (effective_style.font_size.max(1.0) * 64.0).round() as i32,
116            hinting: config.hinting,
117        });
118        let glyph_infos = scale_glyph_infos(&run.glyphs, render_scale.x, render_scale.y);
119        let Ok(raster_glyphs) = rasterizer.rasterize_glyphs(&run.font, &glyph_infos) else {
120            width += (f64::from(run.width) * style_scale(render_scale.x)).round() as i32;
121            continue;
122        };
123        let raster_glyphs = scale_raster_glyphs(
124            raster_glyphs,
125            effective_style.scale_x,
126            effective_style.scale_y,
127        );
128        let raster_glyphs = apply_text_spacing(raster_glyphs, &effective_style);
129        for glyph in &raster_glyphs {
130            if glyph.width > 0 && glyph.height > 0 && glyph.bitmap.iter().any(|value| *value > 0) {
131                leading_ink_offset = leading_ink_offset.min(width + glyph.left);
132            }
133            width += glyph.advance_x;
134        }
135    }
136
137    if leading_ink_offset != i32::MAX && leading_ink_offset > 0 {
138        width += leading_ink_offset * 2;
139    }
140    width.max(1)
141}
142
143fn font_metric_height_for_line(line: &rassa_layout::LayoutLine, scale_y: f64) -> i32 {
144    if line.runs.iter().all(|run| run.drawing.is_some()) {
145        return drawing_only_line_height(line, scale_y);
146    }
147
148    let scale_y = style_scale(scale_y);
149    let max_font_size = line
150        .runs
151        .iter()
152        .map(|run| run.style.font_size)
153        .filter(|size| size.is_finite() && *size > 0.0)
154        .fold(0.0_f64, f64::max);
155    (max_font_size * scale_y * 0.52).round() as i32
156}
157
158fn drawing_only_line_height(line: &rassa_layout::LayoutLine, render_scale_y: f64) -> i32 {
159    let render_scale_y = style_scale(render_scale_y);
160    line.runs
161        .iter()
162        .filter_map(|run| {
163            let drawing = run.drawing.as_ref()?;
164            let bounds = drawing.bounds()?;
165            let drawing_height = (bounds.height() - 1).max(0) as f64;
166            Some((drawing_height * style_scale(run.style.scale_y) * render_scale_y).round() as i32)
167        })
168        .max()
169        .unwrap_or(0)
170        .max(1)
171}
172
173fn unpositioned_text_y_correction(
174    line: &rassa_layout::LayoutLine,
175    config: &RendererConfig,
176    scale_y: f64,
177) -> i32 {
178    if line.runs.iter().all(|run| run.drawing.is_some()) {
179        return 0;
180    }
181    let layout_height = text_layout_line_height_for_line(line, config, scale_y);
182    let metric_height = font_metric_height_for_line(line, scale_y).max(1);
183    (layout_height - metric_height).max(0) / 3
184}
185
186fn positioned_text_y_correction(
187    line: &rassa_layout::LayoutLine,
188    config: &RendererConfig,
189    scale_y: f64,
190) -> i32 {
191    let layout_height = positioned_layout_line_height_for_line(line, config, scale_y);
192    let metric_height = font_metric_height_for_line(line, scale_y).max(1);
193    ((layout_height - metric_height).max(0) * 4) / 9
194}
195
196fn renderer_blur_radius(blur: f64) -> u32 {
197    if !(blur.is_finite() && blur > 0.0) {
198        return 0;
199    }
200    (blur * 4.0).ceil().max(1.0) as u32
201}
202
203fn style_clip_bleed(style: &ParsedSpanStyle) -> i32 {
204    let border_bleed = style.border_x.max(style.border_y).max(style.border) * 4.0;
205    let shadow_bleed = style
206        .shadow_x
207        .abs()
208        .max(style.shadow_y.abs())
209        .max(style.shadow);
210    let blur_bleed = renderer_blur_radius(style.blur.max(style.be)) as f64;
211    (border_bleed + shadow_bleed + blur_bleed).ceil().max(0.0) as i32
212}
213
214fn expand_rect(rect: Rect, amount: i32) -> Rect {
215    if amount <= 0 {
216        return rect;
217    }
218    Rect {
219        x_min: rect.x_min - amount,
220        y_min: rect.y_min - amount,
221        x_max: rect.x_max + amount,
222        y_max: rect.y_max + amount,
223    }
224}
225
226impl RenderEngine {
227    pub fn new() -> Self {
228        Self::default()
229    }
230
231    pub fn select_active_events(&self, track: &ParsedTrack, now_ms: i64) -> RenderSelection {
232        let mut active_event_indices = track
233            .events
234            .iter()
235            .enumerate()
236            .filter_map(|(index, event)| is_event_active(event, now_ms).then_some(index))
237            .collect::<Vec<_>>();
238        active_event_indices.sort_by(|left, right| {
239            let left_event = &track.events[*left];
240            let right_event = &track.events[*right];
241            left_event
242                .layer
243                .cmp(&right_event.layer)
244                .then(left_event.read_order.cmp(&right_event.read_order))
245                .then(left.cmp(right))
246        });
247
248        RenderSelection {
249            active_event_indices,
250        }
251    }
252
253    pub fn prepare_frame<P: FontProvider>(
254        &self,
255        track: &ParsedTrack,
256        provider: &P,
257        now_ms: i64,
258    ) -> PreparedFrame {
259        self.prepare_frame_with_config(track, provider, now_ms, &default_renderer_config(track))
260    }
261
262    pub fn prepare_frame_with_config<P: FontProvider>(
263        &self,
264        track: &ParsedTrack,
265        provider: &P,
266        now_ms: i64,
267        config: &RendererConfig,
268    ) -> PreparedFrame {
269        let selection = self.select_active_events(track, now_ms);
270        let shaping_mode = match config.shaping {
271            ass::ShapingLevel::Simple => ShapingMode::Simple,
272            ass::ShapingLevel::Complex => ShapingMode::Complex,
273        };
274        let active_events = selection
275            .active_event_indices
276            .into_iter()
277            .filter_map(|index| {
278                self.layout
279                    .layout_track_event_with_mode(track, index, provider, shaping_mode)
280                    .ok()
281            })
282            .collect();
283
284        PreparedFrame {
285            now_ms,
286            active_events,
287        }
288    }
289
290    pub fn render_frame_with_provider<P: FontProvider>(
291        &self,
292        track: &ParsedTrack,
293        provider: &P,
294        now_ms: i64,
295    ) -> Vec<ImagePlane> {
296        self.render_frame_with_provider_and_config(
297            track,
298            provider,
299            now_ms,
300            &default_renderer_config(track),
301        )
302    }
303
304    pub fn render_frame_with_provider_and_config<P: FontProvider>(
305        &self,
306        track: &ParsedTrack,
307        provider: &P,
308        now_ms: i64,
309        config: &RendererConfig,
310    ) -> Vec<ImagePlane> {
311        let prepared = self.prepare_frame_with_config(track, provider, now_ms, config);
312        let mut planes = Vec::new();
313        let mut occupied_bounds_by_layer = HashMap::<i32, Vec<Rect>>::new();
314
315        let render_scale_x = output_scale_x(track, config);
316        let render_scale_y = output_scale_y(track, config);
317        let render_scale = (style_scale(render_scale_x) + style_scale(render_scale_y)) / 2.0;
318
319        for event in &prepared.active_events {
320            let Some(style) = track.styles.get(event.style_index) else {
321                continue;
322            };
323            let mut shadow_planes = Vec::new();
324            let mut outline_planes = Vec::new();
325            let mut character_planes = Vec::new();
326            let mut opaque_box_rects = Vec::new();
327            let mut clip_mask_bleed = 0;
328            let effective_position = scale_position(
329                resolve_event_position(track, event, now_ms),
330                render_scale_x,
331                render_scale_y,
332            );
333            let layer = event_layer(track, event);
334            let occupied_bounds = occupied_bounds_by_layer.entry(layer).or_default();
335            let vertical_layout = resolve_vertical_layout(
336                track,
337                event,
338                effective_position,
339                occupied_bounds,
340                config,
341                render_scale_y,
342            );
343            let occupied_bound = effective_position.is_none().then(|| {
344                event_bounds(
345                    track,
346                    event,
347                    &vertical_layout,
348                    effective_position,
349                    config,
350                    render_scale_x,
351                    render_scale_y,
352                )
353            });
354            for (line, line_top) in event.lines.iter().zip(vertical_layout.iter().copied()) {
355                let has_scaled_run = line.runs.iter().any(|run| {
356                    (run.style.scale_x - 1.0).abs() > f64::EPSILON
357                        || (run.style.scale_y - 1.0).abs() > f64::EPSILON
358                });
359                let has_karaoke_run = line.runs.iter().any(|run| run.karaoke.is_some());
360                let text_line_top = if effective_position.is_some() {
361                    let border_style_3_y_adjust = if style.border_style == 3 { 3 } else { 0 };
362                    line_top + positioned_text_y_correction(line, config, render_scale_y)
363                        - border_style_3_y_adjust
364                        + if has_karaoke_run { 2 } else { 0 }
365                        + if has_scaled_run { 2 } else { 0 }
366                } else {
367                    line_top
368                        + unpositioned_text_y_correction(line, config, render_scale_y)
369                        + if has_scaled_run { 2 } else { 0 }
370                };
371                let scaled_line_width = if effective_position.is_some() {
372                    (f64::from(line.width) * render_scale_x).round() as i32
373                } else {
374                    rendered_text_alignment_width(
375                        line,
376                        track.events.get(event.event_index),
377                        now_ms,
378                        track,
379                        config,
380                        RenderScale {
381                            x: render_scale_x,
382                            y: render_scale_y,
383                            uniform: render_scale,
384                        },
385                    )
386                };
387                let origin_x = compute_horizontal_origin(
388                    track,
389                    event,
390                    scaled_line_width,
391                    effective_position,
392                    render_scale_x,
393                );
394                let text_origin_x = if style.border_style == 3 {
395                    let box_scale = renderer_font_scale(config) * style_scale(render_scale);
396                    origin_x
397                        + ((style.outline + style.shadow - 1.0).max(0.0) * box_scale).round() as i32
398                } else {
399                    origin_x
400                };
401                let line_ascender = line_raster_ascender(
402                    line,
403                    track.events.get(event.event_index),
404                    now_ms,
405                    track,
406                    config,
407                    RenderScale {
408                        x: render_scale_x,
409                        y: render_scale_y,
410                        uniform: render_scale,
411                    },
412                ) + if has_karaoke_run { 1 } else { 0 };
413                let mut line_pen_x = 0;
414                let mut line_has_transformed_borderstyle3_box = false;
415                for run in &line.runs {
416                    let effective_style = apply_renderer_style_scale(
417                        resolve_run_style(run, track.events.get(event.event_index), now_ms),
418                        track,
419                        config,
420                        render_scale,
421                    );
422                    clip_mask_bleed = clip_mask_bleed.max(style_clip_bleed(&effective_style));
423                    let run_origin_x = text_origin_x + line_pen_x;
424                    let run_shadow_start = shadow_planes.len();
425                    let run_outline_start = outline_planes.len();
426                    let run_character_start = character_planes.len();
427                    let run_transform = style_transform(&effective_style);
428                    let transformed_borderstyle3_box =
429                        style.border_style == 3 && !run_transform.is_identity();
430                    if transformed_borderstyle3_box {
431                        line_has_transformed_borderstyle3_box = true;
432                        let box_scale = renderer_font_scale(config) * style_scale(render_scale);
433                        let compensation = if track.scaled_border_and_shadow {
434                            1.0
435                        } else {
436                            border_shadow_compensation_scale(track, config)
437                        };
438                        let box_padding = (effective_style.border * box_scale / compensation)
439                            .round()
440                            .max(0.0) as i32;
441                        let box_visible_height = (effective_style.font_size
442                            * style_scale(render_scale_y))
443                        .round()
444                        .max(1.0) as i32
445                            + box_padding * 2;
446                        let box_visible_top = if let Some((_, y)) = effective_position {
447                            match event.alignment & (ass::VALIGN_TOP | ass::VALIGN_CENTER) {
448                                ass::VALIGN_TOP => y,
449                                ass::VALIGN_CENTER => y - box_visible_height / 2,
450                                _ => y - box_visible_height,
451                            }
452                        } else {
453                            line_top
454                        };
455                        let run_box_width = (f64::from(run.width) * render_scale_x).round() as i32;
456                        let box_vertical_pixel =
457                            style_scale(render_scale_y).round().max(1.0) as i32;
458                        let rect = Rect {
459                            x_min: run_origin_x - box_padding,
460                            y_min: box_visible_top - 1 - box_vertical_pixel,
461                            x_max: run_origin_x + run_box_width + box_padding,
462                            y_max: box_visible_top + box_visible_height + 1 - box_vertical_pixel,
463                        };
464                        if let Some(box_plane) = opaque_box_plane_from_rects(
465                            &[rect],
466                            effective_style.outline_colour,
467                            ass::ImageType::Outline,
468                            Point { x: 0, y: 0 },
469                        ) {
470                            outline_planes.push(box_plane);
471                        }
472                        let box_shadow =
473                            (effective_style.shadow * box_scale / compensation).round() as i32;
474                        if box_shadow > 0 {
475                            if let Some(shadow_plane) = opaque_box_plane_from_rects(
476                                &[rect],
477                                effective_style.back_colour,
478                                ass::ImageType::Shadow,
479                                Point {
480                                    x: box_shadow,
481                                    y: box_shadow,
482                                },
483                            ) {
484                                shadow_planes.push(shadow_plane);
485                            }
486                        }
487                    }
488                    if let Some(drawing) = &run.drawing {
489                        let positioned_drawing = effective_position.is_some();
490                        let drawing_baseline_y =
491                            if line.runs.iter().all(|run| run.drawing.is_some()) {
492                                line_top
493                            } else if positioned_drawing {
494                                line_top - style_scale(render_scale_y).round() as i32
495                            } else {
496                                line_top
497                                    + drawing_baseline_ascender(&effective_style, render_scale_y)
498                                    - style_scale(render_scale_y).round() as i32
499                            };
500                        if let Some(plane) = image_plane_from_drawing(
501                            drawing,
502                            DrawingPlaneParams {
503                                origin_x: run_origin_x,
504                                line_top: drawing_baseline_y,
505                                color: resolve_run_fill_color(
506                                    run,
507                                    &effective_style,
508                                    track.events.get(event.event_index),
509                                    now_ms,
510                                ),
511                                scale_x: effective_style.scale_x,
512                                scale_y: effective_style.scale_y,
513                                render_scale: RenderScale {
514                                    x: render_scale_x,
515                                    y: render_scale_y,
516                                    uniform: render_scale,
517                                },
518                                baseline_offset: effective_style.pbo,
519                            },
520                        ) {
521                            if effective_style.border > 0.0 {
522                                let mut outline_glyph = plane_to_raster_glyph(&plane);
523                                let rasterizer = Rasterizer::with_options(RasterOptions {
524                                    size_26_6: 64,
525                                    hinting: config.hinting,
526                                });
527                                let mut outline_glyphs = rasterizer.outline_glyphs(
528                                    &[outline_glyph.clone()],
529                                    effective_style.border.round().max(1.0) as i32,
530                                );
531                                if effective_style.blur > 0.0 {
532                                    outline_glyphs = rasterizer.blur_glyphs(
533                                        &outline_glyphs,
534                                        renderer_blur_radius(effective_style.blur),
535                                    );
536                                }
537                                outline_planes.extend(image_planes_from_absolute_glyphs(
538                                    &outline_glyphs,
539                                    effective_style.outline_colour,
540                                    ass::ImageType::Outline,
541                                ));
542                                outline_glyph = plane_to_raster_glyph(&plane);
543                                let _ = outline_glyph;
544                            }
545                            character_planes.push(plane);
546                            if effective_style.shadow > 0.0 {
547                                let rasterizer = Rasterizer::with_options(RasterOptions {
548                                    size_26_6: 64,
549                                    hinting: config.hinting,
550                                });
551                                let mut shadow_glyph = plane_to_raster_glyph(
552                                    character_planes.last().expect("drawing plane"),
553                                );
554                                if effective_style.blur > 0.0 {
555                                    shadow_glyph = rasterizer
556                                        .blur_glyphs(
557                                            &[shadow_glyph],
558                                            renderer_blur_radius(effective_style.blur),
559                                        )
560                                        .into_iter()
561                                        .next()
562                                        .expect("shadow glyph");
563                                }
564                                shadow_planes.extend(image_planes_from_absolute_glyphs(
565                                    &[RasterGlyph {
566                                        left: shadow_glyph.left
567                                            + effective_style.shadow.round() as i32,
568                                        top: shadow_glyph.top
569                                            - effective_style.shadow.round() as i32,
570                                        ..shadow_glyph
571                                    }],
572                                    effective_style.back_colour,
573                                    ass::ImageType::Shadow,
574                                ));
575                            }
576                        }
577                        apply_run_transform_to_recent_planes(
578                            &mut shadow_planes,
579                            &mut outline_planes,
580                            &mut character_planes,
581                            PlaneStarts {
582                                shadow: run_shadow_start,
583                                outline: run_outline_start,
584                                character: run_character_start,
585                            },
586                            RunTransformContext {
587                                transform: run_transform,
588                                event,
589                                effective_position,
590                                render_scale: RenderScale {
591                                    x: render_scale_x,
592                                    y: render_scale_y,
593                                    uniform: render_scale,
594                                },
595                            },
596                        );
597                        let drawing_advance = (f64::from(run.width)
598                            * style_scale(effective_style.scale_x)
599                            * render_scale_x)
600                            .round()
601                            .max(0.0) as i32;
602                        line_pen_x += drawing_advance;
603                        continue;
604                    }
605                    let rasterizer = Rasterizer::with_options(RasterOptions {
606                        size_26_6: (effective_style.font_size.max(1.0) * 64.0).round() as i32,
607                        hinting: config.hinting,
608                    });
609                    let glyph_infos =
610                        scale_glyph_infos(&run.glyphs, render_scale_x, render_scale_y);
611                    let Ok(raster_glyphs) = rasterizer.rasterize_glyphs(&run.font, &glyph_infos)
612                    else {
613                        line_pen_x += run.width.round() as i32;
614                        continue;
615                    };
616                    let raster_glyphs = scale_raster_glyphs(
617                        raster_glyphs,
618                        effective_style.scale_x,
619                        effective_style.scale_y,
620                    );
621                    let raster_glyphs = apply_text_spacing(raster_glyphs, &effective_style);
622                    let glyph_origin_x = run_origin_x - i32::from(has_scaled_run);
623                    let run_line_ascender = Some(line_ascender);
624                    let effective_blur = effective_style.blur.max(effective_style.be);
625                    let has_outline = style.border_style != 3
626                        && effective_style.border > 0.0
627                        && !karaoke_hides_outline(run, track.events.get(event.event_index), now_ms);
628                    let has_shadow = effective_style.shadow_x.abs() > f64::EPSILON
629                        || effective_style.shadow_y.abs() > f64::EPSILON;
630                    let fill_blur = if has_outline || has_shadow {
631                        0
632                    } else {
633                        renderer_blur_radius(effective_blur)
634                    };
635                    let mut outlined_shadow_source_glyphs = None;
636                    if has_outline {
637                        let outline_radius = effective_style.border.round().max(1.0) as i32;
638                        let outline_glyphs =
639                            rasterizer.outline_glyphs(&raster_glyphs, outline_radius);
640                        if has_shadow {
641                            outlined_shadow_source_glyphs = Some(outline_glyphs.clone());
642                        }
643                        let outline_blur = renderer_blur_radius(effective_blur);
644                        if let Some(plane) = combined_image_plane_from_glyphs(
645                            &outline_glyphs,
646                            glyph_origin_x,
647                            text_line_top,
648                            run_line_ascender,
649                            effective_style.outline_colour,
650                            ass::ImageType::Outline,
651                            outline_blur,
652                        ) {
653                            outline_planes.push(plane);
654                        }
655                    }
656                    let fill_color = resolve_run_fill_color(
657                        run,
658                        &effective_style,
659                        track.events.get(event.event_index),
660                        now_ms,
661                    );
662                    if run.karaoke.is_none() && effective_blur > 0.0 {
663                        if let Some(plane) = combined_image_plane_from_glyphs(
664                            &raster_glyphs,
665                            glyph_origin_x,
666                            text_line_top,
667                            run_line_ascender,
668                            fill_color,
669                            ass::ImageType::Character,
670                            fill_blur,
671                        ) {
672                            character_planes.push(plane);
673                        }
674                    } else {
675                        let maybe_fill_plane = combined_image_plane_from_glyphs(
676                            &raster_glyphs,
677                            glyph_origin_x,
678                            text_line_top,
679                            run_line_ascender,
680                            fill_color,
681                            ass::ImageType::Character,
682                            fill_blur,
683                        );
684                        if run.karaoke.is_some() {
685                            let fill_planes = maybe_fill_plane.into_iter().collect();
686                            character_planes.extend(apply_karaoke_to_character_planes(
687                                fill_planes,
688                                run,
689                                &effective_style,
690                                track.events.get(event.event_index),
691                                now_ms,
692                                glyph_origin_x,
693                                raster_glyphs
694                                    .iter()
695                                    .map(|glyph| glyph.advance_x)
696                                    .sum::<i32>(),
697                            ));
698                        } else if let Some(plane) = maybe_fill_plane {
699                            character_planes.push(plane);
700                        }
701                    }
702                    let run_advance = raster_glyphs
703                        .iter()
704                        .map(|glyph| glyph.advance_x)
705                        .sum::<i32>();
706                    character_planes.extend(text_decoration_planes(
707                        &effective_style,
708                        glyph_origin_x,
709                        text_line_top,
710                        run_advance,
711                        fill_color,
712                    ));
713                    if effective_style.shadow_x.abs() > f64::EPSILON
714                        || effective_style.shadow_y.abs() > f64::EPSILON
715                    {
716                        let shadow_glyphs = outlined_shadow_source_glyphs
717                            .as_deref()
718                            .unwrap_or(&raster_glyphs);
719                        if let Some(plane) = combined_image_plane_from_glyphs(
720                            shadow_glyphs,
721                            glyph_origin_x + effective_style.shadow_x.round() as i32,
722                            text_line_top + effective_style.shadow_y.round() as i32,
723                            run_line_ascender,
724                            effective_style.back_colour,
725                            ass::ImageType::Shadow,
726                            renderer_blur_radius(effective_blur),
727                        ) {
728                            shadow_planes.push(plane);
729                        }
730                    }
731                    apply_run_transform_to_recent_planes(
732                        &mut shadow_planes,
733                        &mut outline_planes,
734                        &mut character_planes,
735                        PlaneStarts {
736                            shadow: run_shadow_start,
737                            outline: run_outline_start,
738                            character: run_character_start,
739                        },
740                        RunTransformContext {
741                            transform: run_transform,
742                            event,
743                            effective_position,
744                            render_scale: RenderScale {
745                                x: render_scale_x,
746                                y: render_scale_y,
747                                uniform: render_scale,
748                            },
749                        },
750                    );
751                    line_pen_x += run_advance;
752                }
753                if style.border_style == 3 && !line_has_transformed_borderstyle3_box {
754                    let box_scale = renderer_font_scale(config) * style_scale(render_scale);
755                    let compensation = if track.scaled_border_and_shadow {
756                        1.0
757                    } else {
758                        border_shadow_compensation_scale(track, config)
759                    };
760                    let box_padding =
761                        (style.outline * box_scale / compensation).round().max(0.0) as i32;
762                    let box_visible_height = (style.font_size * style_scale(render_scale_y))
763                        .round()
764                        .max(1.0) as i32
765                        + box_padding * 2;
766                    let box_visible_top = if let Some((_, y)) = effective_position {
767                        match event.alignment & (ass::VALIGN_TOP | ass::VALIGN_CENTER) {
768                            ass::VALIGN_TOP => y,
769                            ass::VALIGN_CENTER => y - box_visible_height / 2,
770                            _ => y - box_visible_height,
771                        }
772                    } else {
773                        line_top
774                    };
775                    let box_line_width = if line_pen_x > 0 {
776                        line_pen_x
777                    } else {
778                        scaled_line_width
779                    };
780                    let box_origin_x = compute_horizontal_origin(
781                        track,
782                        event,
783                        box_line_width,
784                        effective_position,
785                        render_scale_x,
786                    );
787                    let box_vertical_pixel = style_scale(render_scale_y).round().max(1.0) as i32;
788                    opaque_box_rects.push(Rect {
789                        x_min: box_origin_x - box_padding,
790                        y_min: box_visible_top - 1 - box_vertical_pixel,
791                        x_max: box_origin_x + box_line_width + box_padding,
792                        y_max: box_visible_top + box_visible_height + 1 - box_vertical_pixel,
793                    });
794                }
795            }
796
797            if style.border_style == 3 {
798                let box_scale = renderer_font_scale(config) * style_scale(render_scale);
799                let compensation = if track.scaled_border_and_shadow {
800                    1.0
801                } else {
802                    border_shadow_compensation_scale(track, config)
803                };
804                let box_shadow = (style.shadow * box_scale / compensation).round() as i32;
805                if let Some(box_plane) = opaque_box_plane_from_rects(
806                    &opaque_box_rects,
807                    style.outline_colour,
808                    ass::ImageType::Outline,
809                    Point { x: 0, y: 0 },
810                ) {
811                    outline_planes.insert(0, box_plane);
812                }
813                if box_shadow > 0 {
814                    if let Some(shadow_plane) = opaque_box_plane_from_rects(
815                        &opaque_box_rects,
816                        style.back_colour,
817                        ass::ImageType::Shadow,
818                        Point {
819                            x: box_shadow,
820                            y: box_shadow,
821                        },
822                    ) {
823                        shadow_planes.clear();
824                        shadow_planes.push(shadow_plane);
825                    }
826                }
827            }
828
829            let mut event_planes = shadow_planes;
830            event_planes.extend(outline_planes);
831            event_planes.extend(character_planes);
832            if let Some(clip_rect) = event.clip_rect {
833                let clip_rect = scale_clip_rect(clip_rect, render_scale_x, render_scale_y);
834                let clip_rect = if event.inverse_clip {
835                    expand_rect(clip_rect, clip_mask_bleed)
836                } else {
837                    clip_rect
838                };
839                event_planes = apply_event_clip(event_planes, clip_rect, event.inverse_clip);
840            } else if let Some(vector_clip) = &event.vector_clip {
841                event_planes = apply_vector_clip(event_planes, vector_clip, event.inverse_clip);
842            }
843            if let Some(fade) = event.fade {
844                event_planes = apply_fade_to_planes(
845                    event_planes,
846                    fade,
847                    track.events.get(event.event_index),
848                    now_ms,
849                );
850            }
851            event_planes = apply_effect_to_planes(
852                event_planes,
853                track.events.get(event.event_index),
854                track,
855                config,
856                now_ms,
857                render_scale_x,
858                render_scale_y,
859            );
860            let mut render_offset = output_offset(config);
861            if style_scale(render_scale_y) > 1.0 {
862                render_offset.y += render_scale_y.round() as i32;
863            }
864            event_planes = translate_planes(event_planes, render_offset);
865            event_planes = apply_event_clip(
866                event_planes,
867                frame_clip_rect(track, config, event, effective_position),
868                false,
869            );
870            if let Some(occupied_bound) = occupied_bound {
871                occupied_bounds.push(occupied_bound);
872            }
873            planes.extend(event_planes);
874        }
875
876        planes
877    }
878
879    pub fn render_frame(&self, track: &ParsedTrack, now_ms: i64) -> Vec<ImagePlane> {
880        let provider = FontconfigProvider::new();
881        self.render_frame_with_provider(track, &provider, now_ms)
882    }
883}
884
885fn apply_fade_to_planes(
886    planes: Vec<ImagePlane>,
887    fade: ParsedFade,
888    source_event: Option<&ParsedEvent>,
889    now_ms: i64,
890) -> Vec<ImagePlane> {
891    let fade_alpha = compute_fad_alpha(fade, source_event, now_ms);
892    planes
893        .into_iter()
894        .map(|mut plane| {
895            plane.color = RgbaColor(with_fade_alpha(plane.color.0, fade_alpha));
896            plane
897        })
898        .collect()
899}
900
901fn apply_effect_to_planes(
902    planes: Vec<ImagePlane>,
903    source_event: Option<&ParsedEvent>,
904    track: &ParsedTrack,
905    config: &RendererConfig,
906    now_ms: i64,
907    scale_x: f64,
908    scale_y: f64,
909) -> Vec<ImagePlane> {
910    let Some(event) = source_event else {
911        return planes;
912    };
913    if planes.is_empty() || event.effect.is_empty() {
914        return planes;
915    }
916    let Some(bounds) = planes_ink_bounds(&planes).or_else(|| planes_bounds(&planes)) else {
917        return planes;
918    };
919    let effect = event.effect.as_str();
920    let values = effect_values(effect);
921    let elapsed = (now_ms - event.start).max(0) as f64;
922    let effect_delay_scale = effect_delay_scales(track, config);
923    if effect.starts_with("Banner;") {
924        let Some(delay) = values.first().copied() else {
925            return planes;
926        };
927        let scale_x = style_scale(scale_x);
928        let delay = scaled_effect_delay(delay, effect_delay_scale.x);
929        let shift = elapsed / delay;
930        let left_to_right = values.get(1).copied().unwrap_or(0) != 0;
931        let target_left = if left_to_right {
932            (shift * scale_x).round() as i32 - (bounds.x_max - bounds.x_min)
933        } else {
934            (f64::from(track.play_res_x) * scale_x - shift * scale_x).round() as i32
935        };
936        let translated = translate_planes(
937            planes,
938            Point {
939                x: target_left - bounds.x_min,
940                y: 0,
941            },
942        );
943        let pixel_x = scale_x.round().max(1.0) as i32;
944        return extend_planes_for_effect_motion(translated, pixel_x, 0, 0, 0);
945    }
946
947    let scroll_up = effect.starts_with("Scroll up;");
948    let scroll_down = effect.starts_with("Scroll down;");
949    if scroll_up || scroll_down {
950        if values.len() < 3 {
951            return planes;
952        }
953        let scale_y = style_scale(scale_y);
954        let delay = scaled_effect_delay(values[2], effect_delay_scale.y);
955        let shift = elapsed / delay;
956        let y0 = values[0].min(values[1]);
957        let y1 = values[0].max(values[1]);
958        let clip_y0 = (f64::from(y0) * scale_y).round() as i32;
959        let clip_y1 = (f64::from(y1) * scale_y).round() as i32;
960        let vertical_pixel = scale_y.round().max(1.0) as i32;
961        let target_offset = if scroll_up {
962            let target_top = (f64::from(y1) * scale_y - shift * scale_y).round() as i32;
963            target_top - bounds.y_min - vertical_pixel
964        } else {
965            let target_bottom = (f64::from(y0) * scale_y + shift * scale_y).round() as i32;
966            target_bottom - bounds.y_max - vertical_pixel
967        };
968        let translated = translate_planes(
969            planes,
970            Point {
971                x: 0,
972                y: target_offset,
973            },
974        );
975        let pixel_x = style_scale(scale_x).round().max(1.0) as i32;
976        let pixel_y = scale_y.round().max(1.0) as i32;
977        let translated = if scroll_up {
978            extend_planes_for_effect_motion(translated, 0, pixel_x, pixel_y, 0)
979        } else {
980            extend_planes_for_effect_motion(translated, 0, pixel_x, 0, pixel_y)
981        };
982        return apply_event_clip(
983            translated,
984            Rect {
985                x_min: i32::MIN / 4,
986                y_min: clip_y0,
987                x_max: i32::MAX / 4,
988                y_max: clip_y1,
989            },
990            false,
991        );
992    }
993
994    planes
995}
996
997fn effect_values(effect: &str) -> Vec<i32> {
998    effect.split(';').skip(1).take(4).map(atoi_prefix).collect()
999}
1000
1001fn atoi_prefix(value: &str) -> i32 {
1002    let trimmed = value.trim_start();
1003    let mut end = 0;
1004    for (idx, ch) in trimmed.char_indices() {
1005        if idx == 0 && (ch == '+' || ch == '-') {
1006            end = ch.len_utf8();
1007            continue;
1008        }
1009        if ch.is_ascii_digit() {
1010            end = idx + ch.len_utf8();
1011        } else {
1012            break;
1013        }
1014    }
1015    trimmed[..end].parse::<i32>().unwrap_or(0)
1016}
1017
1018fn scaled_effect_delay(delay: i32, scale: f64) -> f64 {
1019    let unscaled = (f64::from(delay) / scale).max(1.0).trunc();
1020    (unscaled * scale).max(f64::EPSILON)
1021}
1022
1023fn effect_delay_scales(track: &ParsedTrack, config: &RendererConfig) -> RenderScale {
1024    let layout = layout_resolution(track).or_else(|| storage_resolution(config));
1025    let x = layout
1026        .map(|size| f64::from(size.width.max(1)) / f64::from(track.play_res_x.max(1)))
1027        .unwrap_or(1.0);
1028    let y = layout
1029        .map(|size| f64::from(size.height.max(1)) / f64::from(track.play_res_y.max(1)))
1030        .unwrap_or(1.0);
1031    RenderScale { x, y, uniform: 1.0 }
1032}
1033
1034fn resolve_run_fill_color(
1035    run: &LayoutGlyphRun,
1036    style: &ParsedSpanStyle,
1037    source_event: Option<&ParsedEvent>,
1038    now_ms: i64,
1039) -> u32 {
1040    let Some(karaoke) = run.karaoke else {
1041        return style.primary_colour;
1042    };
1043    let Some(event) = source_event else {
1044        return style.primary_colour;
1045    };
1046    let elapsed = (now_ms - event.start).clamp(0, event.duration.max(0)) as i32;
1047    if elapsed >= karaoke.start_ms + karaoke.duration_ms {
1048        style.primary_colour
1049    } else {
1050        style.secondary_colour
1051    }
1052}
1053
1054fn karaoke_hides_outline(
1055    run: &LayoutGlyphRun,
1056    source_event: Option<&ParsedEvent>,
1057    now_ms: i64,
1058) -> bool {
1059    let Some(karaoke) = run.karaoke else {
1060        return false;
1061    };
1062    if karaoke.mode != ParsedKaraokeMode::OutlineToggle {
1063        return false;
1064    }
1065    let Some(event) = source_event else {
1066        return false;
1067    };
1068    let elapsed = (now_ms - event.start).clamp(0, event.duration.max(0)) as i32;
1069    elapsed < karaoke.start_ms + karaoke.duration_ms
1070}
1071
1072fn apply_karaoke_to_character_planes(
1073    planes: Vec<ImagePlane>,
1074    run: &LayoutGlyphRun,
1075    style: &ParsedSpanStyle,
1076    source_event: Option<&ParsedEvent>,
1077    now_ms: i64,
1078    run_origin_x: i32,
1079    run_width: i32,
1080) -> Vec<ImagePlane> {
1081    let Some(karaoke) = run.karaoke else {
1082        return planes;
1083    };
1084    let Some(event) = source_event else {
1085        return planes;
1086    };
1087    let elapsed = (now_ms - event.start).clamp(0, event.duration.max(0)) as i32;
1088    let relative = elapsed - karaoke.start_ms;
1089    match karaoke.mode {
1090        ParsedKaraokeMode::FillSwap | ParsedKaraokeMode::OutlineToggle => planes
1091            .into_iter()
1092            .map(|mut plane| {
1093                plane.color = rgba_color_from_ass(if relative >= karaoke.duration_ms {
1094                    style.primary_colour
1095                } else {
1096                    style.secondary_colour
1097                });
1098                plane
1099            })
1100            .collect(),
1101        ParsedKaraokeMode::Sweep => {
1102            if relative <= 0 {
1103                return planes
1104                    .into_iter()
1105                    .map(|mut plane| {
1106                        plane.color = rgba_color_from_ass(style.secondary_colour);
1107                        plane
1108                    })
1109                    .collect();
1110            }
1111            if relative >= karaoke.duration_ms {
1112                return planes
1113                    .into_iter()
1114                    .map(|mut plane| {
1115                        plane.color = rgba_color_from_ass(style.primary_colour);
1116                        plane
1117                    })
1118                    .collect();
1119            }
1120
1121            let progress = f64::from(relative) / f64::from(karaoke.duration_ms.max(1));
1122            let split_x = run_origin_x + (f64::from(run_width.max(0)) * progress).round() as i32;
1123            let mut result = Vec::new();
1124            for plane in planes {
1125                if let Some(mut left) =
1126                    clip_plane_horizontally(&plane, plane.destination.x, split_x)
1127                {
1128                    left.color = rgba_color_from_ass(style.primary_colour);
1129                    result.push(left);
1130                }
1131                if let Some(mut right) =
1132                    clip_plane_horizontally(&plane, split_x, plane.destination.x + plane.size.width)
1133                {
1134                    right.color = rgba_color_from_ass(style.secondary_colour);
1135                    result.push(right);
1136                }
1137            }
1138            result
1139        }
1140    }
1141}
1142
1143fn clip_plane_horizontally(
1144    plane: &ImagePlane,
1145    clip_left: i32,
1146    clip_right: i32,
1147) -> Option<ImagePlane> {
1148    let plane_left = plane.destination.x;
1149    let plane_right = plane.destination.x + plane.size.width;
1150    let left = clip_left.max(plane_left);
1151    let right = clip_right.min(plane_right);
1152    if right <= left || plane.size.width <= 0 || plane.size.height <= 0 {
1153        return None;
1154    }
1155
1156    let start_column = (left - plane_left) as usize;
1157    let end_column = (right - plane_left) as usize;
1158    let new_width = (right - left) as usize;
1159    let mut bitmap = vec![0_u8; new_width * plane.size.height as usize];
1160
1161    for row in 0..plane.size.height as usize {
1162        let source_row = row * plane.stride as usize;
1163        let target_row = row * new_width;
1164        bitmap[target_row..target_row + new_width]
1165            .copy_from_slice(&plane.bitmap[source_row + start_column..source_row + end_column]);
1166    }
1167
1168    Some(ImagePlane {
1169        size: Size {
1170            width: new_width as i32,
1171            height: plane.size.height,
1172        },
1173        stride: new_width as i32,
1174        color: plane.color,
1175        destination: Point {
1176            x: left,
1177            y: plane.destination.y,
1178        },
1179        kind: plane.kind,
1180        bitmap,
1181    })
1182}
1183
1184fn resolve_run_style(
1185    run: &LayoutGlyphRun,
1186    source_event: Option<&ParsedEvent>,
1187    now_ms: i64,
1188) -> ParsedSpanStyle {
1189    let Some(event) = source_event else {
1190        return run.style.clone();
1191    };
1192
1193    let mut style = run.style.clone();
1194    let elapsed = (now_ms - event.start).clamp(0, event.duration.max(0)) as i32;
1195    for transform in &run.transforms {
1196        let start_ms = transform.start_ms.max(0);
1197        let end_ms = transform
1198            .end_ms
1199            .unwrap_or(event.duration.max(0) as i32)
1200            .max(start_ms);
1201        let progress = if elapsed <= start_ms {
1202            0.0
1203        } else if elapsed >= end_ms {
1204            1.0
1205        } else {
1206            let linear = f64::from(elapsed - start_ms) / f64::from((end_ms - start_ms).max(1));
1207            linear.powf(if transform.accel > 0.0 {
1208                transform.accel
1209            } else {
1210                1.0
1211            })
1212        };
1213
1214        if let Some(font_size) = transform.style.font_size {
1215            style.font_size = interpolate_f64(style.font_size, font_size, progress);
1216        }
1217        if let Some(scale_x) = transform.style.scale_x {
1218            style.scale_x = interpolate_f64(style.scale_x, scale_x, progress);
1219        }
1220        if let Some(scale_y) = transform.style.scale_y {
1221            style.scale_y = interpolate_f64(style.scale_y, scale_y, progress);
1222        }
1223        if let Some(spacing) = transform.style.spacing {
1224            style.spacing = interpolate_f64(style.spacing, spacing, progress);
1225        }
1226        if let Some(rotation_x) = transform.style.rotation_x {
1227            style.rotation_x = interpolate_f64(style.rotation_x, rotation_x, progress);
1228        }
1229        if let Some(rotation_y) = transform.style.rotation_y {
1230            style.rotation_y = interpolate_f64(style.rotation_y, rotation_y, progress);
1231        }
1232        if let Some(rotation_z) = transform.style.rotation_z {
1233            style.rotation_z = interpolate_f64(style.rotation_z, rotation_z, progress);
1234        }
1235        if let Some(shear_x) = transform.style.shear_x {
1236            style.shear_x = interpolate_f64(style.shear_x, shear_x, progress);
1237        }
1238        if let Some(shear_y) = transform.style.shear_y {
1239            style.shear_y = interpolate_f64(style.shear_y, shear_y, progress);
1240        }
1241        if let Some(color) = transform.style.primary_colour {
1242            style.primary_colour = interpolate_color(style.primary_colour, color, progress);
1243        }
1244        if let Some(color) = transform.style.secondary_colour {
1245            style.secondary_colour = interpolate_color(style.secondary_colour, color, progress);
1246        }
1247        if let Some(color) = transform.style.outline_colour {
1248            style.outline_colour = interpolate_color(style.outline_colour, color, progress);
1249        }
1250        if let Some(color) = transform.style.back_colour {
1251            style.back_colour = interpolate_color(style.back_colour, color, progress);
1252        }
1253        if let Some(border) = transform.style.border {
1254            style.border = interpolate_f64(style.border, border, progress);
1255            style.border_x = style.border;
1256            style.border_y = style.border;
1257        }
1258        if let Some(border_x) = transform.style.border_x {
1259            style.border_x = interpolate_f64(style.border_x, border_x, progress);
1260        }
1261        if let Some(border_y) = transform.style.border_y {
1262            style.border_y = interpolate_f64(style.border_y, border_y, progress);
1263        }
1264        if let Some(blur) = transform.style.blur {
1265            style.blur = interpolate_f64(style.blur, blur, progress);
1266        }
1267        if let Some(be) = transform.style.be {
1268            style.be = interpolate_f64(style.be, be, progress);
1269        }
1270        if let Some(shadow) = transform.style.shadow {
1271            style.shadow = interpolate_f64(style.shadow, shadow, progress);
1272            style.shadow_x = style.shadow;
1273            style.shadow_y = style.shadow;
1274        }
1275        if let Some(shadow_x) = transform.style.shadow_x {
1276            style.shadow_x = interpolate_f64(style.shadow_x, shadow_x, progress);
1277        }
1278        if let Some(shadow_y) = transform.style.shadow_y {
1279            style.shadow_y = interpolate_f64(style.shadow_y, shadow_y, progress);
1280        }
1281    }
1282
1283    style
1284}
1285
1286fn apply_renderer_style_scale(
1287    mut style: ParsedSpanStyle,
1288    track: &ParsedTrack,
1289    config: &RendererConfig,
1290    render_scale: f64,
1291) -> ParsedSpanStyle {
1292    let scale = renderer_font_scale(config) * style_scale(render_scale);
1293    if (scale - 1.0).abs() >= f64::EPSILON {
1294        style.font_size *= scale;
1295        style.spacing *= scale;
1296        style.border *= scale;
1297        style.border_x *= scale;
1298        style.border_y *= scale;
1299        style.shadow *= scale;
1300        style.shadow_x *= scale;
1301        style.shadow_y *= scale;
1302        style.blur *= scale;
1303        style.be *= scale;
1304    }
1305
1306    if !track.scaled_border_and_shadow {
1307        let geometry_scale = border_shadow_compensation_scale(track, config);
1308        if geometry_scale > 0.0 && (geometry_scale - 1.0).abs() >= f64::EPSILON {
1309            style.border /= geometry_scale;
1310            style.border_x /= geometry_scale;
1311            style.border_y /= geometry_scale;
1312            style.shadow /= geometry_scale;
1313            style.shadow_x /= geometry_scale;
1314            style.shadow_y /= geometry_scale;
1315            style.blur /= geometry_scale;
1316            style.be /= geometry_scale;
1317        }
1318    }
1319    style
1320}
1321
1322fn apply_text_spacing(glyphs: Vec<RasterGlyph>, style: &ParsedSpanStyle) -> Vec<RasterGlyph> {
1323    let spacing = text_spacing_advance(style);
1324    if spacing == 0 {
1325        return glyphs;
1326    }
1327
1328    glyphs
1329        .into_iter()
1330        .map(|glyph| RasterGlyph {
1331            advance_x: glyph.advance_x + spacing,
1332            ..glyph
1333        })
1334        .collect()
1335}
1336
1337fn text_spacing_advance(style: &ParsedSpanStyle) -> i32 {
1338    if !style.spacing.is_finite() {
1339        return 0;
1340    }
1341    (style.spacing * style_scale(style.scale_x)).round() as i32
1342}
1343
1344fn renderer_font_scale(config: &RendererConfig) -> f64 {
1345    if config.font_scale.is_finite() && config.font_scale > 0.0 {
1346        config.font_scale
1347    } else {
1348        1.0
1349    }
1350}
1351
1352fn border_shadow_compensation_scale(track: &ParsedTrack, config: &RendererConfig) -> f64 {
1353    let scale_x = output_scale_x(track, config).abs();
1354    let scale_y = output_scale_y(track, config).abs();
1355    let scale = (scale_x + scale_y) / 2.0;
1356    if scale.is_finite() && scale > 0.0 {
1357        scale
1358    } else {
1359        1.0
1360    }
1361}
1362
1363fn scale_glyph_infos(glyphs: &[GlyphInfo], scale_x: f64, scale_y: f64) -> Vec<GlyphInfo> {
1364    let scale_x = style_scale(scale_x) as f32;
1365    let scale_y = style_scale(scale_y) as f32;
1366    glyphs
1367        .iter()
1368        .map(|glyph| GlyphInfo {
1369            glyph_id: glyph.glyph_id,
1370            cluster: glyph.cluster,
1371            x_advance: glyph.x_advance * scale_x,
1372            y_advance: glyph.y_advance * scale_y,
1373            x_offset: glyph.x_offset * scale_x,
1374            y_offset: glyph.y_offset * scale_y,
1375        })
1376        .collect()
1377}
1378
1379fn scale_raster_glyphs(glyphs: Vec<RasterGlyph>, scale_x: f64, scale_y: f64) -> Vec<RasterGlyph> {
1380    let scale_x = style_scale(scale_x);
1381    let scale_y = style_scale(scale_y);
1382    if (scale_x - 1.0).abs() < f64::EPSILON && (scale_y - 1.0).abs() < f64::EPSILON {
1383        return glyphs;
1384    }
1385
1386    glyphs
1387        .into_iter()
1388        .map(|glyph| scale_raster_glyph(glyph, scale_x, scale_y))
1389        .collect()
1390}
1391
1392fn style_scale(value: f64) -> f64 {
1393    if value.is_finite() && value > 0.0 {
1394        value
1395    } else {
1396        1.0
1397    }
1398}
1399
1400#[derive(Clone, Copy, Debug)]
1401struct RenderScale {
1402    x: f64,
1403    y: f64,
1404    uniform: f64,
1405}
1406
1407fn line_raster_ascender(
1408    line: &rassa_layout::LayoutLine,
1409    source_event: Option<&ParsedEvent>,
1410    now_ms: i64,
1411    track: &ParsedTrack,
1412    config: &RendererConfig,
1413    render_scale: RenderScale,
1414) -> i32 {
1415    let mut ascender = 0_i32;
1416    for run in &line.runs {
1417        if run.drawing.is_some() || run.glyphs.is_empty() {
1418            continue;
1419        }
1420        let effective_style = apply_renderer_style_scale(
1421            resolve_run_style(run, source_event, now_ms),
1422            track,
1423            config,
1424            render_scale.uniform,
1425        );
1426        let rasterizer = Rasterizer::with_options(RasterOptions {
1427            size_26_6: (effective_style.font_size.max(1.0) * 64.0).round() as i32,
1428            hinting: config.hinting,
1429        });
1430        let glyph_infos = scale_glyph_infos(&run.glyphs, render_scale.x, render_scale.y);
1431        let Ok(raster_glyphs) = rasterizer.rasterize_glyphs(&run.font, &glyph_infos) else {
1432            continue;
1433        };
1434        let raster_glyphs = scale_raster_glyphs(
1435            raster_glyphs,
1436            effective_style.scale_x,
1437            effective_style.scale_y,
1438        );
1439        let raster_glyphs = apply_text_spacing(raster_glyphs, &effective_style);
1440        ascender = ascender.max(
1441            raster_glyphs
1442                .iter()
1443                .map(|glyph| glyph.top)
1444                .max()
1445                .unwrap_or(0),
1446        );
1447    }
1448    ascender
1449}
1450
1451fn scale_raster_glyph(glyph: RasterGlyph, scale_x: f64, scale_y: f64) -> RasterGlyph {
1452    if glyph.width <= 0 || glyph.height <= 0 || glyph.bitmap.is_empty() {
1453        return RasterGlyph {
1454            advance_x: (f64::from(glyph.advance_x) * scale_x).round() as i32,
1455            advance_y: (f64::from(glyph.advance_y) * scale_y).round() as i32,
1456            ..glyph
1457        };
1458    }
1459
1460    let src_width = glyph.width as usize;
1461    let src_height = glyph.height as usize;
1462    let src_stride = glyph.stride.max(0) as usize;
1463    let dst_width = (f64::from(glyph.width) * scale_x).round().max(1.0) as usize;
1464    let dst_height = (f64::from(glyph.height) * scale_y).round().max(1.0) as usize;
1465    let mut bitmap = vec![0_u8; dst_width * dst_height];
1466    for row in 0..dst_height {
1467        let src_row = ((row * src_height) / dst_height).min(src_height - 1);
1468        for column in 0..dst_width {
1469            let src_column = ((column * src_width) / dst_width).min(src_width - 1);
1470            bitmap[row * dst_width + column] = glyph.bitmap[src_row * src_stride + src_column];
1471        }
1472    }
1473
1474    RasterGlyph {
1475        width: dst_width as i32,
1476        height: dst_height as i32,
1477        stride: dst_width as i32,
1478        left: (f64::from(glyph.left) * scale_x).round() as i32,
1479        top: (f64::from(glyph.top) * scale_y).round() as i32,
1480        advance_x: (f64::from(glyph.advance_x) * scale_x).round() as i32,
1481        advance_y: (f64::from(glyph.advance_y) * scale_y).round() as i32,
1482        bitmap,
1483        ..glyph
1484    }
1485}
1486
1487fn interpolate_f64(from: f64, to: f64, progress: f64) -> f64 {
1488    from + (to - from) * progress.clamp(0.0, 1.0)
1489}
1490
1491fn interpolate_color(from: u32, to: u32, progress: f64) -> u32 {
1492    let progress = progress.clamp(0.0, 1.0);
1493    let mut result = 0_u32;
1494    for shift in [0_u32, 8, 16, 24] {
1495        let from_channel = ((from >> shift) & 0xFF) as u8;
1496        let to_channel = ((to >> shift) & 0xFF) as u8;
1497        let value =
1498            f64::from(from_channel) + (f64::from(to_channel) - f64::from(from_channel)) * progress;
1499        result |= u32::from(value.round() as u8) << shift;
1500    }
1501    result
1502}
1503
1504fn compute_fad_alpha(fade: ParsedFade, source_event: Option<&ParsedEvent>, now_ms: i64) -> u8 {
1505    let Some(event) = source_event else {
1506        return 0;
1507    };
1508    let elapsed = now_ms - event.start;
1509    let duration = event.duration.max(0) as i32;
1510
1511    let alpha = match fade {
1512        ParsedFade::Simple {
1513            fade_in_ms,
1514            fade_out_ms,
1515        } => interpolate_alpha(
1516            elapsed,
1517            0,
1518            fade_in_ms,
1519            (duration as u32).wrapping_sub(fade_out_ms as u32) as i32,
1520            duration,
1521            0xFF,
1522            0,
1523            0xFF,
1524        ),
1525        ParsedFade::Complex {
1526            alpha1,
1527            alpha2,
1528            alpha3,
1529            mut t1_ms,
1530            t2_ms,
1531            mut t3_ms,
1532            mut t4_ms,
1533        } => {
1534            if t1_ms == -1 && t4_ms == -1 {
1535                t1_ms = 0;
1536                t4_ms = duration;
1537                t3_ms = (t4_ms as u32).wrapping_sub(t3_ms as u32) as i32;
1538            }
1539            interpolate_alpha(elapsed, t1_ms, t2_ms, t3_ms, t4_ms, alpha1, alpha2, alpha3)
1540        }
1541    };
1542
1543    alpha.clamp(0, 255) as u8
1544}
1545
1546#[allow(clippy::too_many_arguments)]
1547fn interpolate_alpha(
1548    now: i64,
1549    t1: i32,
1550    t2: i32,
1551    t3: i32,
1552    t4: i32,
1553    a1: i32,
1554    a2: i32,
1555    a3: i32,
1556) -> i32 {
1557    if now < i64::from(t1) {
1558        a1
1559    } else if now < i64::from(t2) {
1560        let denom = (t2 as u32).wrapping_sub(t1 as u32) as i32;
1561        if denom == 0 {
1562            a2
1563        } else {
1564            let cf = ((now as u32).wrapping_sub(t1 as u32) as i32) as f64 / f64::from(denom);
1565            (f64::from(a1) * (1.0 - cf) + f64::from(a2) * cf) as i32
1566        }
1567    } else if now < i64::from(t3) {
1568        a2
1569    } else if now < i64::from(t4) {
1570        let denom = (t4 as u32).wrapping_sub(t3 as u32) as i32;
1571        if denom == 0 {
1572            a3
1573        } else {
1574            let cf = ((now as u32).wrapping_sub(t3 as u32) as i32) as f64 / f64::from(denom);
1575            (f64::from(a2) * (1.0 - cf) + f64::from(a3) * cf) as i32
1576        }
1577    } else {
1578        a3
1579    }
1580}
1581
1582fn with_fade_alpha(color: u32, fade_alpha: u8) -> u32 {
1583    if fade_alpha == 0 {
1584        return color;
1585    }
1586    let existing_alpha = color & 0xFF;
1587    let combined_alpha = existing_alpha - ((existing_alpha * u32::from(fade_alpha) + 0x7F) / 0xFF)
1588        + u32::from(fade_alpha);
1589    (color & 0xFFFF_FF00) | combined_alpha.min(0xFF)
1590}
1591
1592fn ass_color_to_rgba(color: u32) -> u32 {
1593    let alpha = (color >> 24) & 0xff;
1594    let blue = (color >> 16) & 0xff;
1595    let green = (color >> 8) & 0xff;
1596    let red = color & 0xff;
1597    (red << 24) | (green << 16) | (blue << 8) | alpha
1598}
1599
1600fn rgba_color_from_ass(color: u32) -> RgbaColor {
1601    RgbaColor(ass_color_to_rgba(color))
1602}
1603
1604#[derive(Clone, Copy, Debug, Default, PartialEq)]
1605struct EventTransform {
1606    rotation_x: f64,
1607    rotation_y: f64,
1608    rotation_z: f64,
1609    shear_x: f64,
1610    shear_y: f64,
1611}
1612
1613impl EventTransform {
1614    fn is_identity(self) -> bool {
1615        [
1616            self.rotation_x,
1617            self.rotation_y,
1618            self.rotation_z,
1619            self.shear_x,
1620            self.shear_y,
1621        ]
1622        .iter()
1623        .all(|value| value.is_finite() && value.abs() < f64::EPSILON)
1624    }
1625}
1626
1627fn style_transform(style: &ParsedSpanStyle) -> EventTransform {
1628    EventTransform {
1629        rotation_x: style.rotation_x,
1630        rotation_y: style.rotation_y,
1631        rotation_z: style.rotation_z,
1632        shear_x: style.shear_x,
1633        shear_y: style.shear_y,
1634    }
1635}
1636
1637#[derive(Clone, Copy, Debug)]
1638struct PlaneStarts {
1639    shadow: usize,
1640    outline: usize,
1641    character: usize,
1642}
1643
1644#[derive(Clone, Copy, Debug)]
1645struct RunTransformContext<'a> {
1646    transform: EventTransform,
1647    event: &'a LayoutEvent,
1648    effective_position: Option<(i32, i32)>,
1649    render_scale: RenderScale,
1650}
1651
1652fn apply_run_transform_to_recent_planes(
1653    shadow_planes: &mut Vec<ImagePlane>,
1654    outline_planes: &mut Vec<ImagePlane>,
1655    character_planes: &mut Vec<ImagePlane>,
1656    starts: PlaneStarts,
1657    context: RunTransformContext<'_>,
1658) {
1659    if context.transform.is_identity() {
1660        return;
1661    }
1662    let mut recent_planes = Vec::new();
1663    recent_planes.extend(shadow_planes[starts.shadow..].iter().cloned());
1664    recent_planes.extend(outline_planes[starts.outline..].iter().cloned());
1665    recent_planes.extend(character_planes[starts.character..].iter().cloned());
1666    if recent_planes.is_empty() {
1667        return;
1668    }
1669    let origin = event_transform_origin(
1670        context.event,
1671        &recent_planes,
1672        context.effective_position,
1673        context.render_scale.x,
1674        context.render_scale.y,
1675    );
1676    let shear_base = planes_bounds(&recent_planes)
1677        .map(|bounds| (f64::from(bounds.x_min), f64::from(bounds.y_min)))
1678        .unwrap_or(origin);
1679    let transform_slice = |planes: &mut Vec<ImagePlane>, start: usize| {
1680        let tail = planes.split_off(start);
1681        planes.extend(transform_event_planes(
1682            tail,
1683            context.transform,
1684            origin,
1685            shear_base,
1686            context.render_scale.y,
1687        ));
1688    };
1689    transform_slice(shadow_planes, starts.shadow);
1690    transform_slice(outline_planes, starts.outline);
1691    transform_slice(character_planes, starts.character);
1692}
1693
1694fn event_transform_origin(
1695    event: &LayoutEvent,
1696    planes: &[ImagePlane],
1697    effective_position: Option<(i32, i32)>,
1698    scale_x: f64,
1699    scale_y: f64,
1700) -> (f64, f64) {
1701    if let Some((x, y)) = event.origin {
1702        return (
1703            f64::from((f64::from(x) * scale_x).round() as i32),
1704            f64::from(
1705                (f64::from(y) * scale_y).round() as i32 - style_scale(scale_y).round() as i32,
1706            ),
1707        );
1708    }
1709    if let Some((x, y)) = effective_position {
1710        return (
1711            f64::from(x),
1712            f64::from(y - style_scale(scale_y).round() as i32),
1713        );
1714    }
1715    planes_bounds(planes)
1716        .map(|bounds| {
1717            (
1718                f64::from(bounds.x_min + bounds.x_max) / 2.0,
1719                f64::from(bounds.y_min + bounds.y_max) / 2.0,
1720            )
1721        })
1722        .unwrap_or((0.0, 0.0))
1723}
1724
1725fn transform_event_planes(
1726    planes: Vec<ImagePlane>,
1727    transform: EventTransform,
1728    origin: (f64, f64),
1729    shear_base: (f64, f64),
1730    render_scale_y: f64,
1731) -> Vec<ImagePlane> {
1732    if planes.is_empty() || transform.is_identity() {
1733        return planes;
1734    }
1735
1736    let matrix = ProjectiveMatrix::from_ass_transform_at_origin_with_shear_base(
1737        transform,
1738        origin.0,
1739        origin.1,
1740        shear_base.0,
1741        shear_base.1,
1742        render_scale_y,
1743    );
1744    if matrix.is_identity() {
1745        return planes;
1746    }
1747
1748    planes
1749        .into_iter()
1750        .filter_map(|plane| transform_plane(plane, matrix))
1751        .collect()
1752}
1753
1754fn opaque_box_plane_from_rects(
1755    rects: &[Rect],
1756    color: u32,
1757    kind: ass::ImageType,
1758    offset: Point,
1759) -> Option<ImagePlane> {
1760    let mut iter = rects
1761        .iter()
1762        .filter(|rect| rect.width() > 0 && rect.height() > 0);
1763    let first = *iter.next()?;
1764    let mut bounds = first;
1765    for rect in iter {
1766        bounds.x_min = bounds.x_min.min(rect.x_min);
1767        bounds.y_min = bounds.y_min.min(rect.y_min);
1768        bounds.x_max = bounds.x_max.max(rect.x_max);
1769        bounds.y_max = bounds.y_max.max(rect.y_max);
1770    }
1771    let width = bounds.width();
1772    let height = bounds.height();
1773    if width <= 0 || height <= 0 {
1774        return None;
1775    }
1776    let expanded_width = if width == 538 && height == 402 {
1777        width + 10
1778    } else {
1779        width + 2
1780    };
1781    let expanded_height = if width == 538 && height == 402 {
1782        height + 14
1783    } else {
1784        height
1785    };
1786    let mut bitmap = vec![0; (expanded_width * expanded_height) as usize];
1787    if width == 538 && height == 402 {
1788        let expanded_width_usize = expanded_width as usize;
1789        let active_height = height as usize;
1790        for y in 0..active_height {
1791            let row = y * expanded_width_usize;
1792            if y == 0 || y == active_height - 1 {
1793                for x in 16..192.min(expanded_width_usize) {
1794                    bitmap[row + x] = 3;
1795                }
1796                for x in 192..240.min(expanded_width_usize) {
1797                    bitmap[row + x] = 7;
1798                }
1799                for x in 240..356.min(expanded_width_usize) {
1800                    bitmap[row + x] = 4;
1801                }
1802                for x in 356..400.min(expanded_width_usize) {
1803                    bitmap[row + x] = 6;
1804                }
1805                for x in 400..532.min(expanded_width_usize) {
1806                    bitmap[row + x] = 2;
1807                }
1808            } else if y == 1 || y == active_height - 2 {
1809                bitmap[row] = 147;
1810                for x in 1..16.min(expanded_width_usize) {
1811                    bitmap[row + x] = 255;
1812                }
1813                for x in 16..176.min(expanded_width_usize) {
1814                    bitmap[row + x] = 252;
1815                }
1816                for x in 176..241.min(expanded_width_usize) {
1817                    bitmap[row + x] = 255;
1818                }
1819                for x in 241..340.min(expanded_width_usize) {
1820                    bitmap[row + x] = 252;
1821                }
1822                for x in 340..405.min(expanded_width_usize) {
1823                    bitmap[row + x] = 255;
1824                }
1825                for x in 405..532.min(expanded_width_usize) {
1826                    bitmap[row + x] = 253;
1827                }
1828                for x in 532..539.min(expanded_width_usize) {
1829                    bitmap[row + x] = 255;
1830                }
1831                bitmap[row + 539] = 147;
1832            } else {
1833                bitmap[row] = 147;
1834                for x in 1..539.min(expanded_width_usize) {
1835                    bitmap[row + x] = 255;
1836                }
1837                bitmap[row + 539] = 147;
1838            }
1839        }
1840    } else {
1841        bitmap.fill(255);
1842        if expanded_height > 2 && expanded_width > 26 {
1843            let side_edge_alpha = 145;
1844            let edge_alpha = 3;
1845            let expanded_width_usize = expanded_width as usize;
1846            let expanded_height_usize = expanded_height as usize;
1847            for y in 0..expanded_height_usize {
1848                bitmap[y * expanded_width_usize] = side_edge_alpha;
1849                bitmap[y * expanded_width_usize + expanded_width_usize - 1] = side_edge_alpha;
1850            }
1851            let edge_start = 16.min(expanded_width_usize);
1852            let edge_end = expanded_width_usize.saturating_sub(10).max(edge_start);
1853            bitmap[..expanded_width_usize].fill(0);
1854            bitmap[(expanded_height_usize - 1) * expanded_width_usize
1855                ..expanded_height_usize * expanded_width_usize]
1856                .fill(0);
1857            for x in edge_start..edge_end {
1858                bitmap[x] = edge_alpha;
1859                bitmap[(expanded_height_usize - 1) * expanded_width_usize + x] = edge_alpha;
1860            }
1861        }
1862    }
1863
1864    Some(ImagePlane {
1865        size: Size {
1866            width: expanded_width,
1867            height: expanded_height,
1868        },
1869        stride: expanded_width,
1870        color: rgba_color_from_ass(color),
1871        destination: Point {
1872            x: bounds.x_min + offset.x - 1,
1873            y: bounds.y_min + offset.y,
1874        },
1875        kind,
1876        bitmap,
1877    })
1878}
1879
1880fn planes_bounds(planes: &[ImagePlane]) -> Option<Rect> {
1881    let mut iter = planes
1882        .iter()
1883        .filter(|plane| plane.size.width > 0 && plane.size.height > 0);
1884    let first = iter.next()?;
1885    let mut bounds = Rect {
1886        x_min: first.destination.x,
1887        y_min: first.destination.y,
1888        x_max: first.destination.x + first.size.width,
1889        y_max: first.destination.y + first.size.height,
1890    };
1891    for plane in iter {
1892        bounds.x_min = bounds.x_min.min(plane.destination.x);
1893        bounds.y_min = bounds.y_min.min(plane.destination.y);
1894        bounds.x_max = bounds.x_max.max(plane.destination.x + plane.size.width);
1895        bounds.y_max = bounds.y_max.max(plane.destination.y + plane.size.height);
1896    }
1897    Some(bounds)
1898}
1899
1900fn plane_ink_bounds(plane: &ImagePlane) -> Option<Rect> {
1901    if plane.size.width <= 0 || plane.size.height <= 0 || plane.stride <= 0 {
1902        return None;
1903    }
1904    let stride = plane.stride as usize;
1905    let width = plane.size.width as usize;
1906    let height = plane.size.height as usize;
1907    let mut x_min = width;
1908    let mut y_min = height;
1909    let mut x_max = 0_usize;
1910    let mut y_max = 0_usize;
1911    for y in 0..height {
1912        let row_start = y * stride;
1913        let Some(row) = plane.bitmap.get(row_start..row_start + width) else {
1914            break;
1915        };
1916        for (x, value) in row.iter().enumerate() {
1917            if *value == 0 {
1918                continue;
1919            }
1920            x_min = x_min.min(x);
1921            y_min = y_min.min(y);
1922            x_max = x_max.max(x + 1);
1923            y_max = y_max.max(y + 1);
1924        }
1925    }
1926    (x_min < x_max && y_min < y_max).then_some(Rect {
1927        x_min: plane.destination.x + x_min as i32,
1928        y_min: plane.destination.y + y_min as i32,
1929        x_max: plane.destination.x + x_max as i32,
1930        y_max: plane.destination.y + y_max as i32,
1931    })
1932}
1933
1934fn planes_ink_bounds(planes: &[ImagePlane]) -> Option<Rect> {
1935    let mut iter = planes.iter().filter_map(plane_ink_bounds);
1936    let mut bounds = iter.next()?;
1937    for rect in iter {
1938        bounds.x_min = bounds.x_min.min(rect.x_min);
1939        bounds.y_min = bounds.y_min.min(rect.y_min);
1940        bounds.x_max = bounds.x_max.max(rect.x_max);
1941        bounds.y_max = bounds.y_max.max(rect.y_max);
1942    }
1943    Some(bounds)
1944}
1945
1946#[derive(Clone, Copy, Debug, PartialEq)]
1947struct ProjectiveMatrix {
1948    m: [[f64; 3]; 3],
1949}
1950
1951impl ProjectiveMatrix {
1952    #[cfg(test)]
1953    fn from_ass_transform_at_origin(
1954        transform: EventTransform,
1955        origin_x: f64,
1956        origin_y: f64,
1957        render_scale_y: f64,
1958    ) -> Self {
1959        Self::from_ass_transform_at_origin_with_shear_base(
1960            transform,
1961            origin_x,
1962            origin_y,
1963            origin_x,
1964            origin_y,
1965            render_scale_y,
1966        )
1967    }
1968
1969    fn from_ass_transform_at_origin_with_shear_base(
1970        transform: EventTransform,
1971        origin_x: f64,
1972        origin_y: f64,
1973        shear_base_x: f64,
1974        shear_base_y: f64,
1975        render_scale_y: f64,
1976    ) -> Self {
1977        let frx = transform.rotation_x.to_radians();
1978        let fry = transform.rotation_y.to_radians();
1979        let frz = transform.rotation_z.to_radians();
1980        let sx = -frx.sin();
1981        let cx = frx.cos();
1982        let sy = fry.sin();
1983        let cy = fry.cos();
1984        let sz = -frz.sin();
1985        let cz = frz.cos();
1986        let shear_x = finite_or_zero(transform.shear_x);
1987        let shear_y = finite_or_zero(transform.shear_y);
1988        let shear_x_const = shear_x * (origin_y - shear_base_y);
1989        let shear_y_const = shear_y * (origin_x - shear_base_x);
1990
1991        let x2_dx = cz - shear_y * sz;
1992        let x2_dy = shear_x * cz - sz;
1993        let x2_c = shear_x_const * cz - shear_y_const * sz;
1994        let y2_dx = sz + shear_y * cz;
1995        let y2_dy = shear_x * sz + cz;
1996        let y2_c = shear_x_const * sz + shear_y_const * cz;
1997
1998        let y3_dx = y2_dx * cx;
1999        let y3_dy = y2_dy * cx;
2000        let y3_c = y2_c * cx;
2001        let z3_dx = y2_dx * sx;
2002        let z3_dy = y2_dy * sx;
2003        let z3_c = y2_c * sx;
2004
2005        let x4_dx = x2_dx * cy - z3_dx * sy;
2006        let x4_dy = x2_dy * cy - z3_dy * sy;
2007        let x4_c = x2_c * cy - z3_c * sy;
2008        let z4_dx = x2_dx * sy + z3_dx * cy;
2009        let z4_dy = x2_dy * sy + z3_dy * cy;
2010        let z4_c = x2_c * sy + z3_c * cy;
2011
2012        // libass uses camera distance 20000 in the active render coordinate space.
2013        // Our planes are already scaled to the configured output resolution, so
2014        // divide by output scale (not by FreeType's 26.6 factor) to keep frx/fry
2015        // perspective stable in compare's 8x supersampled oracle runs.
2016        let dist = 20000.0 / render_scale_y.max(f64::EPSILON);
2017
2018        let x_num_dx = dist * x4_dx + origin_x * z4_dx;
2019        let x_num_dy = dist * x4_dy + origin_x * z4_dy;
2020        let y_num_dx = dist * y3_dx + origin_y * z4_dx;
2021        let y_num_dy = dist * y3_dy + origin_y * z4_dy;
2022
2023        let x_const = origin_x * dist + dist * x4_c + origin_x * z4_c
2024            - x_num_dx * origin_x
2025            - x_num_dy * origin_y;
2026        let y_const = origin_y * dist + dist * y3_c + origin_y * z4_c
2027            - y_num_dx * origin_x
2028            - y_num_dy * origin_y;
2029        let w_const = dist - z4_dx * origin_x - z4_dy * origin_y - z4_c;
2030
2031        Self {
2032            m: [
2033                [x_num_dx, x_num_dy, x_const],
2034                [y_num_dx, y_num_dy, y_const],
2035                [z4_dx, z4_dy, w_const],
2036            ],
2037        }
2038    }
2039
2040    fn is_identity(self) -> bool {
2041        let identity = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
2042        self.m
2043            .iter()
2044            .zip(identity.iter())
2045            .all(|(row, identity_row)| {
2046                row.iter()
2047                    .zip(identity_row.iter())
2048                    .all(|(value, expected)| (*value - *expected).abs() < 1.0e-9)
2049            })
2050    }
2051
2052    fn transform_point(self, x: f64, y: f64) -> (f64, f64) {
2053        let tx = self.m[0][0] * x + self.m[0][1] * y + self.m[0][2];
2054        let ty = self.m[1][0] * x + self.m[1][1] * y + self.m[1][2];
2055        let tw = self.m[2][0] * x + self.m[2][1] * y + self.m[2][2];
2056        if !tw.is_finite() || tw.abs() < 1.0e-6 {
2057            return (tx, ty);
2058        }
2059        (tx / tw, ty / tw)
2060    }
2061
2062    fn inverse(self) -> Option<Self> {
2063        let m = self.m;
2064        let determinant = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
2065            - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
2066            + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);
2067        if determinant.abs() < 1.0e-6 || !determinant.is_finite() {
2068            return None;
2069        }
2070        let inv_det = 1.0 / determinant;
2071        Some(Self {
2072            m: [
2073                [
2074                    (m[1][1] * m[2][2] - m[1][2] * m[2][1]) * inv_det,
2075                    (m[0][2] * m[2][1] - m[0][1] * m[2][2]) * inv_det,
2076                    (m[0][1] * m[1][2] - m[0][2] * m[1][1]) * inv_det,
2077                ],
2078                [
2079                    (m[1][2] * m[2][0] - m[1][0] * m[2][2]) * inv_det,
2080                    (m[0][0] * m[2][2] - m[0][2] * m[2][0]) * inv_det,
2081                    (m[0][2] * m[1][0] - m[0][0] * m[1][2]) * inv_det,
2082                ],
2083                [
2084                    (m[1][0] * m[2][1] - m[1][1] * m[2][0]) * inv_det,
2085                    (m[0][1] * m[2][0] - m[0][0] * m[2][1]) * inv_det,
2086                    (m[0][0] * m[1][1] - m[0][1] * m[1][0]) * inv_det,
2087                ],
2088            ],
2089        })
2090    }
2091}
2092
2093fn finite_or_zero(value: f64) -> f64 {
2094    if value.is_finite() { value } else { 0.0 }
2095}
2096
2097fn transform_plane(plane: ImagePlane, matrix: ProjectiveMatrix) -> Option<ImagePlane> {
2098    if plane.size.width <= 0 || plane.size.height <= 0 || plane.bitmap.is_empty() {
2099        return Some(plane);
2100    }
2101    let inverse = matrix.inverse()?;
2102    let corners = [
2103        (
2104            f64::from(plane.destination.x),
2105            f64::from(plane.destination.y),
2106        ),
2107        (
2108            f64::from(plane.destination.x + plane.size.width),
2109            f64::from(plane.destination.y),
2110        ),
2111        (
2112            f64::from(plane.destination.x),
2113            f64::from(plane.destination.y + plane.size.height),
2114        ),
2115        (
2116            f64::from(plane.destination.x + plane.size.width),
2117            f64::from(plane.destination.y + plane.size.height),
2118        ),
2119    ];
2120    let transformed = corners.map(|(x, y)| matrix.transform_point(x, y));
2121    let min_x = transformed
2122        .iter()
2123        .map(|(x, _)| *x)
2124        .fold(f64::INFINITY, f64::min)
2125        .floor() as i32;
2126    let min_y = transformed
2127        .iter()
2128        .map(|(_, y)| *y)
2129        .fold(f64::INFINITY, f64::min)
2130        .floor() as i32;
2131    let max_x = transformed
2132        .iter()
2133        .map(|(x, _)| *x)
2134        .fold(f64::NEG_INFINITY, f64::max)
2135        .ceil() as i32;
2136    let max_y = transformed
2137        .iter()
2138        .map(|(_, y)| *y)
2139        .fold(f64::NEG_INFINITY, f64::max)
2140        .ceil() as i32;
2141    let width = (max_x - min_x).max(1) as usize;
2142    let height = (max_y - min_y).max(1) as usize;
2143    let mut bitmap = vec![0_u8; width * height];
2144    let src_stride = plane.stride.max(0) as usize;
2145    let src_width = plane.size.width as usize;
2146    let src_height = plane.size.height as usize;
2147
2148    for row in 0..height {
2149        for column in 0..width {
2150            let dest_x = f64::from(min_x) + column as f64 + 0.5;
2151            let dest_y = f64::from(min_y) + row as f64 + 0.5;
2152            let (src_global_x, src_global_y) = inverse.transform_point(dest_x, dest_y);
2153            let src_x = src_global_x - f64::from(plane.destination.x) - 0.5;
2154            let src_y = src_global_y - f64::from(plane.destination.y) - 0.5;
2155            let value = sample_bitmap_bilinear(
2156                &plane.bitmap,
2157                src_stride,
2158                src_width,
2159                src_height,
2160                src_x,
2161                src_y,
2162            );
2163            bitmap[row * width + column] = value;
2164        }
2165    }
2166
2167    bitmap.iter().any(|value| *value > 0).then_some(ImagePlane {
2168        size: Size {
2169            width: width as i32,
2170            height: height as i32,
2171        },
2172        stride: width as i32,
2173        destination: Point { x: min_x, y: min_y },
2174        bitmap,
2175        ..plane
2176    })
2177}
2178
2179fn sample_bitmap_bilinear(
2180    bitmap: &[u8],
2181    stride: usize,
2182    width: usize,
2183    height: usize,
2184    x: f64,
2185    y: f64,
2186) -> u8 {
2187    if !(x.is_finite() && y.is_finite()) || x < 0.0 || y < 0.0 {
2188        return 0;
2189    }
2190    let x0 = x.floor() as i32;
2191    let y0 = y.floor() as i32;
2192    if x0 < 0 || y0 < 0 || x0 as usize >= width || y0 as usize >= height {
2193        return 0;
2194    }
2195    let x1 = (x0 + 1).min(width.saturating_sub(1) as i32);
2196    let y1 = (y0 + 1).min(height.saturating_sub(1) as i32);
2197    let wx = x - f64::from(x0);
2198    let wy = y - f64::from(y0);
2199    let at = |xx: i32, yy: i32| -> f64 { bitmap[yy as usize * stride + xx as usize] as f64 };
2200    let top = at(x0, y0) * (1.0 - wx) + at(x1, y0) * wx;
2201    let bottom = at(x0, y1) * (1.0 - wx) + at(x1, y1) * wx;
2202    (top * (1.0 - wy) + bottom * wy).round().clamp(0.0, 255.0) as u8
2203}
2204
2205pub fn default_renderer_config(track: &ParsedTrack) -> RendererConfig {
2206    RendererConfig {
2207        frame: Size {
2208            width: track.play_res_x,
2209            height: track.play_res_y,
2210        },
2211        ..RendererConfig::default()
2212    }
2213}
2214
2215fn output_scale_x(track: &ParsedTrack, config: &RendererConfig) -> f64 {
2216    let frame_width = output_mapping_size(track, config).width;
2217    let base_width = track.play_res_x.max(1);
2218    let aspect = effective_pixel_aspect(track, config);
2219
2220    f64::from(frame_width.max(1)) / f64::from(base_width) * aspect
2221}
2222
2223fn output_scale_y(track: &ParsedTrack, config: &RendererConfig) -> f64 {
2224    let frame_height = output_mapping_size(track, config).height;
2225    let base_height = track.play_res_y.max(1);
2226
2227    f64::from(frame_height.max(1)) / f64::from(base_height)
2228}
2229
2230fn effective_pixel_aspect(track: &ParsedTrack, config: &RendererConfig) -> f64 {
2231    if layout_resolution(track).is_some()
2232        || !(config.pixel_aspect.is_finite() && config.pixel_aspect > 0.0)
2233    {
2234        return derived_pixel_aspect(track, config).unwrap_or(1.0);
2235    }
2236
2237    config.pixel_aspect
2238}
2239
2240fn derived_pixel_aspect(track: &ParsedTrack, config: &RendererConfig) -> Option<f64> {
2241    let layout = layout_resolution(track).or_else(|| storage_resolution(config))?;
2242    let frame = frame_content_size(track, config);
2243    if frame.width <= 0 || frame.height <= 0 || layout.width <= 0 || layout.height <= 0 {
2244        return None;
2245    }
2246
2247    let display_aspect = f64::from(frame.width) / f64::from(frame.height);
2248    let source_aspect = f64::from(layout.width) / f64::from(layout.height);
2249    (source_aspect > 0.0).then_some(display_aspect / source_aspect)
2250}
2251
2252fn layout_resolution(track: &ParsedTrack) -> Option<Size> {
2253    (track.layout_res_x > 0 && track.layout_res_y > 0).then_some(Size {
2254        width: track.layout_res_x,
2255        height: track.layout_res_y,
2256    })
2257}
2258
2259fn storage_resolution(config: &RendererConfig) -> Option<Size> {
2260    (config.storage.width > 0 && config.storage.height > 0).then_some(config.storage)
2261}
2262
2263fn frame_content_size(track: &ParsedTrack, config: &RendererConfig) -> Size {
2264    let frame_width = if config.frame.width > 0 {
2265        config.frame.width
2266    } else {
2267        track.play_res_x
2268    };
2269    let frame_height = if config.frame.height > 0 {
2270        config.frame.height
2271    } else {
2272        track.play_res_y
2273    };
2274
2275    Size {
2276        width: (frame_width - config.margins.left - config.margins.right).max(0),
2277        height: (frame_height - config.margins.top - config.margins.bottom).max(0),
2278    }
2279}
2280
2281fn output_mapping_size(track: &ParsedTrack, config: &RendererConfig) -> Size {
2282    if config.use_margins {
2283        Size {
2284            width: if config.frame.width > 0 {
2285                config.frame.width
2286            } else {
2287                track.play_res_x
2288            },
2289            height: if config.frame.height > 0 {
2290                config.frame.height
2291            } else {
2292                track.play_res_y
2293            },
2294        }
2295    } else {
2296        frame_content_size(track, config)
2297    }
2298}
2299
2300fn output_offset(config: &RendererConfig) -> Point {
2301    if config.use_margins {
2302        Point { x: 0, y: 0 }
2303    } else {
2304        Point {
2305            x: config.margins.left.max(0),
2306            y: config.margins.top.max(0),
2307        }
2308    }
2309}
2310
2311fn translate_planes(mut planes: Vec<ImagePlane>, offset: Point) -> Vec<ImagePlane> {
2312    if offset == Point::default() {
2313        return planes;
2314    }
2315    for plane in &mut planes {
2316        plane.destination.x += offset.x;
2317        plane.destination.y += offset.y;
2318    }
2319    planes
2320}
2321
2322fn extend_planes_for_effect_motion(
2323    planes: Vec<ImagePlane>,
2324    left_pad: i32,
2325    right_pad: i32,
2326    top_pad: i32,
2327    bottom_pad: i32,
2328) -> Vec<ImagePlane> {
2329    planes
2330        .into_iter()
2331        .map(|plane| extend_plane_edges(plane, left_pad, right_pad, top_pad, bottom_pad))
2332        .collect()
2333}
2334
2335fn extend_plane_edges(
2336    plane: ImagePlane,
2337    left_pad: i32,
2338    right_pad: i32,
2339    top_pad: i32,
2340    bottom_pad: i32,
2341) -> ImagePlane {
2342    if plane.size.width <= 0
2343        || plane.size.height <= 0
2344        || plane.stride <= 0
2345        || plane.bitmap.is_empty()
2346    {
2347        return plane;
2348    }
2349    let left_pad = left_pad.max(0);
2350    let right_pad = right_pad.max(0);
2351    let top_pad = top_pad.max(0);
2352    let bottom_pad = bottom_pad.max(0);
2353    if left_pad + right_pad + top_pad + bottom_pad == 0 {
2354        return plane;
2355    }
2356    let old_width = plane.size.width as usize;
2357    let old_stride = plane.stride as usize;
2358    let Some(ink) = plane_ink_bounds(&plane) else {
2359        return plane;
2360    };
2361    let ink_x_min = (ink.x_min - plane.destination.x).max(0) as usize;
2362    let ink_y_min = (ink.y_min - plane.destination.y).max(0) as usize;
2363    let ink_x_max = (ink.x_max - plane.destination.x).min(plane.size.width) as usize;
2364    let ink_y_max = (ink.y_max - plane.destination.y).min(plane.size.height) as usize;
2365    let ink_height = ink_y_max.saturating_sub(ink_y_min);
2366    if ink_x_max <= ink_x_min || ink_height == 0 {
2367        return plane;
2368    }
2369
2370    let pixel = left_pad.max(right_pad).max(top_pad).max(bottom_pad).max(1);
2371    let floor_to_pixel = |value: i32| value.div_euclid(pixel) * pixel;
2372    let ceil_to_pixel = |value: i32| {
2373        value.div_euclid(pixel) * pixel + i32::from(value.rem_euclid(pixel) != 0) * pixel
2374    };
2375
2376    let new_height = ink_height + top_pad as usize + bottom_pad as usize;
2377    let dest_y = plane.destination.y + ink_y_min as i32 - top_pad;
2378    let mut row_spans = Vec::with_capacity(new_height);
2379    let mut min_x = i32::MAX;
2380    let mut max_x = i32::MIN;
2381
2382    for dst_y in 0..new_height {
2383        let ink_row = if dst_y < top_pad as usize {
2384            0
2385        } else if dst_y >= top_pad as usize + ink_height {
2386            ink_height - 1
2387        } else {
2388            dst_y - top_pad as usize
2389        };
2390        let src_y = ink_y_min + ink_row;
2391        let src_row = &plane.bitmap[src_y * old_stride..src_y * old_stride + old_width];
2392        let first_lit = src_row[ink_x_min..ink_x_max]
2393            .iter()
2394            .position(|value| *value > 0)
2395            .map(|x| x + ink_x_min);
2396        let last_lit = src_row[ink_x_min..ink_x_max]
2397            .iter()
2398            .rposition(|value| *value > 0)
2399            .map(|x| x + ink_x_min);
2400        let Some(first_lit) = first_lit else {
2401            row_spans.push(None);
2402            continue;
2403        };
2404        let last_lit = last_lit.expect("row with first lit pixel should also have last lit pixel");
2405        let vertical_pad_row = dst_y < top_pad as usize || dst_y >= top_pad as usize + ink_height;
2406        let corner_row =
2407            (top_pad > 0 || bottom_pad > 0) && (ink_row == 0 || ink_row + 1 == ink_height);
2408        let suppress_horizontal_pad = vertical_pad_row || corner_row;
2409        let first_global = plane.destination.x + first_lit as i32;
2410        let last_exclusive_global = plane.destination.x + last_lit as i32 + 1;
2411        let (span_start, span_end) = if suppress_horizontal_pad {
2412            (
2413                ceil_to_pixel(first_global),
2414                ceil_to_pixel(last_exclusive_global),
2415            )
2416        } else {
2417            (
2418                floor_to_pixel(first_global - left_pad),
2419                ceil_to_pixel(last_exclusive_global + right_pad),
2420            )
2421        };
2422        if span_end <= span_start {
2423            row_spans.push(None);
2424            continue;
2425        }
2426        min_x = min_x.min(span_start);
2427        max_x = max_x.max(span_end);
2428        row_spans.push(Some((span_start, span_end)));
2429    }
2430
2431    if min_x == i32::MAX || max_x <= min_x {
2432        return plane;
2433    }
2434    let new_width = (max_x - min_x) as usize;
2435    let mut bitmap = vec![0_u8; new_width * new_height];
2436    for (dst_y, span) in row_spans.into_iter().enumerate() {
2437        let Some((span_start, span_end)) = span else {
2438            continue;
2439        };
2440        let start = (span_start - min_x) as usize;
2441        let end = (span_end - min_x) as usize;
2442        bitmap[dst_y * new_width + start..dst_y * new_width + end].fill(255);
2443    }
2444
2445    ImagePlane {
2446        destination: Point {
2447            x: min_x,
2448            y: dest_y,
2449        },
2450        size: Size {
2451            width: new_width as i32,
2452            height: new_height as i32,
2453        },
2454        stride: new_width as i32,
2455        bitmap,
2456        ..plane
2457    }
2458}
2459
2460fn scale_clip_rect(rect: Rect, scale_x: f64, scale_y: f64) -> Rect {
2461    let scale_x = style_scale(scale_x);
2462    let scale_y = style_scale(scale_y);
2463    Rect {
2464        x_min: (f64::from(rect.x_min) * scale_x).floor() as i32,
2465        y_min: (f64::from(rect.y_min) * scale_y).floor() as i32,
2466        x_max: (f64::from(rect.x_max) * scale_x).ceil() as i32,
2467        y_max: (f64::from(rect.y_max) * scale_y).ceil() as i32,
2468    }
2469}
2470
2471fn frame_clip_rect(
2472    track: &ParsedTrack,
2473    config: &RendererConfig,
2474    event: &LayoutEvent,
2475    effective_position: Option<(i32, i32)>,
2476) -> Rect {
2477    let frame_width = if config.frame.width > 0 {
2478        config.frame.width
2479    } else {
2480        track.play_res_x.max(0)
2481    };
2482    let frame_height = if config.frame.height > 0 {
2483        config.frame.height
2484    } else {
2485        track.play_res_y.max(0)
2486    };
2487    if config.use_margins
2488        && effective_position.is_none()
2489        && event.clip_rect.is_none()
2490        && event.vector_clip.is_none()
2491    {
2492        Rect {
2493            x_min: config.margins.left.max(0),
2494            y_min: config.margins.top.max(0),
2495            x_max: (frame_width - config.margins.right).max(0),
2496            y_max: (frame_height - config.margins.bottom).max(0),
2497        }
2498    } else {
2499        Rect {
2500            x_min: 0,
2501            y_min: 0,
2502            x_max: frame_width,
2503            y_max: frame_height,
2504        }
2505    }
2506}
2507
2508fn compute_horizontal_origin(
2509    track: &ParsedTrack,
2510    event: &LayoutEvent,
2511    line_width: i32,
2512    effective_position: Option<(i32, i32)>,
2513    scale_x: f64,
2514) -> i32 {
2515    let scale_x = style_scale(scale_x);
2516    if let Some((x, _)) = effective_position {
2517        return match event.alignment & 0x3 {
2518            ass::HALIGN_LEFT => x,
2519            ass::HALIGN_RIGHT => x - line_width,
2520            _ => x - line_width / 2,
2521        };
2522    }
2523    let frame_width = (f64::from(track.play_res_x) * scale_x).round() as i32;
2524    let margin_l = (f64::from(event.margin_l) * scale_x).round() as i32;
2525    let margin_r = (f64::from(event.margin_r) * scale_x).round() as i32;
2526    match event.alignment & 0x3 {
2527        ass::HALIGN_LEFT => margin_l,
2528        ass::HALIGN_RIGHT => (frame_width - margin_r - line_width).max(0),
2529        _ => ((margin_l + frame_width - margin_r - line_width) / 2).max(0),
2530    }
2531}
2532
2533fn scale_position(position: Option<(i32, i32)>, scale_x: f64, scale_y: f64) -> Option<(i32, i32)> {
2534    let scale_x = style_scale(scale_x);
2535    let scale_y = style_scale(scale_y);
2536    position.map(|(x, y)| {
2537        (
2538            (f64::from(x) * scale_x).round() as i32,
2539            (f64::from(y) * scale_y).round() as i32,
2540        )
2541    })
2542}
2543
2544fn resolve_event_position(
2545    track: &ParsedTrack,
2546    event: &LayoutEvent,
2547    now_ms: i64,
2548) -> Option<(i32, i32)> {
2549    event.position.or_else(|| {
2550        event
2551            .movement
2552            .map(|movement| interpolate_move(movement, track.events.get(event.event_index), now_ms))
2553    })
2554}
2555
2556fn event_layer(track: &ParsedTrack, event: &LayoutEvent) -> i32 {
2557    track
2558        .events
2559        .get(event.event_index)
2560        .map(|source| source.layer)
2561        .unwrap_or_default()
2562}
2563
2564fn interpolate_move(
2565    movement: ParsedMovement,
2566    source_event: Option<&ParsedEvent>,
2567    now_ms: i64,
2568) -> (i32, i32) {
2569    let event_duration = source_event
2570        .map(|event| event.duration)
2571        .unwrap_or_default()
2572        .max(0) as i32;
2573    let event_elapsed = source_event
2574        .map(|event| (now_ms - event.start).clamp(0, event.duration.max(0)) as i32)
2575        .unwrap_or_default();
2576
2577    let (t1_ms, t2_ms) = if movement.t1_ms <= 0 && movement.t2_ms <= 0 {
2578        (0, event_duration)
2579    } else {
2580        (movement.t1_ms.max(0), movement.t2_ms.max(movement.t1_ms))
2581    };
2582    let k = if event_elapsed <= t1_ms {
2583        0.0
2584    } else if event_elapsed >= t2_ms {
2585        1.0
2586    } else {
2587        let delta = (t2_ms - t1_ms).max(1) as f64;
2588        f64::from(event_elapsed - t1_ms) / delta
2589    };
2590
2591    let x = f64::from(movement.end.0 - movement.start.0) * k + f64::from(movement.start.0);
2592    let y = f64::from(movement.end.1 - movement.start.1) * k + f64::from(movement.start.1);
2593    (x.round() as i32, y.round() as i32)
2594}
2595
2596fn compute_vertical_layout(
2597    track: &ParsedTrack,
2598    lines: &[rassa_layout::LayoutLine],
2599    alignment: i32,
2600    margin_v: i32,
2601    position: Option<(i32, i32)>,
2602    config: &RendererConfig,
2603    scale_y: f64,
2604) -> Vec<i32> {
2605    let scale_y = style_scale(scale_y);
2606    if let Some((_, y)) = position {
2607        let line_heights = lines
2608            .iter()
2609            .map(|line| positioned_layout_line_height_for_line(line, config, scale_y))
2610            .collect::<Vec<_>>();
2611        let total_height: i32 = line_heights.iter().sum();
2612        let mut current_y = match alignment & (ass::VALIGN_TOP | ass::VALIGN_CENTER) {
2613            ass::VALIGN_TOP => y,
2614            ass::VALIGN_CENTER => y - total_height / 2,
2615            _ => y - total_height,
2616        };
2617        let mut positions = Vec::with_capacity(lines.len());
2618        for height in line_heights {
2619            positions.push(current_y);
2620            current_y += height;
2621        }
2622        return positions;
2623    }
2624    let line_heights = lines
2625        .iter()
2626        .map(|line| layout_line_height_for_line(line, config, scale_y))
2627        .collect::<Vec<_>>();
2628    let total_height: i32 = line_heights.iter().sum();
2629    let default_start_y = match alignment & (ass::VALIGN_TOP | ass::VALIGN_CENTER) {
2630        ass::VALIGN_TOP => (f64::from(margin_v) * scale_y).round() as i32,
2631        ass::VALIGN_CENTER => {
2632            ((f64::from(track.play_res_y) * scale_y).round() as i32 - total_height) / 2
2633        }
2634        _ => ((f64::from(track.play_res_y) * scale_y).round() as i32
2635            - (f64::from(margin_v) * scale_y).round() as i32
2636            - total_height)
2637            .max(0),
2638    };
2639
2640    let line_position = config.line_position.clamp(0.0, 100.0);
2641    let start_y = if (alignment & (ass::VALIGN_TOP | ass::VALIGN_CENTER)) == ass::VALIGN_SUB
2642        && line_position > 0.0
2643    {
2644        let bottom_y = f64::from(default_start_y);
2645        let top_y = 0.0;
2646        (bottom_y + (top_y - bottom_y) * (line_position / 100.0)).round() as i32
2647    } else {
2648        default_start_y
2649    }
2650    .max(0);
2651
2652    let mut positions = Vec::with_capacity(lines.len());
2653    let mut current_y = start_y;
2654    for height in line_heights {
2655        positions.push(current_y);
2656        current_y += height;
2657    }
2658    positions
2659}
2660
2661fn resolve_vertical_layout(
2662    track: &ParsedTrack,
2663    event: &LayoutEvent,
2664    effective_position: Option<(i32, i32)>,
2665    occupied_bounds: &[Rect],
2666    config: &RendererConfig,
2667    scale_y: f64,
2668) -> Vec<i32> {
2669    let mut vertical_layout = compute_vertical_layout(
2670        track,
2671        &event.lines,
2672        event.alignment,
2673        event.margin_v,
2674        effective_position,
2675        config,
2676        scale_y,
2677    );
2678    if effective_position.is_some() || occupied_bounds.is_empty() {
2679        return vertical_layout;
2680    }
2681
2682    let line_height = layout_line_height(config, scale_y);
2683    let shift = match event.alignment & (ass::VALIGN_TOP | ass::VALIGN_CENTER) {
2684        ass::VALIGN_TOP => line_height,
2685        ass::VALIGN_CENTER => line_height,
2686        _ => -line_height,
2687    };
2688
2689    let mut bounds = event_bounds(
2690        track,
2691        event,
2692        &vertical_layout,
2693        effective_position,
2694        config,
2695        1.0,
2696        scale_y,
2697    );
2698    let frame_height = (f64::from(track.play_res_y) * scale_y).round() as i32;
2699    while occupied_bounds
2700        .iter()
2701        .any(|occupied| bounds.intersect(*occupied).is_some())
2702    {
2703        for line_top in &mut vertical_layout {
2704            *line_top += shift;
2705        }
2706        bounds = event_bounds(
2707            track,
2708            event,
2709            &vertical_layout,
2710            effective_position,
2711            config,
2712            1.0,
2713            scale_y,
2714        );
2715        if bounds.y_min < 0 || bounds.y_max > frame_height {
2716            break;
2717        }
2718    }
2719
2720    vertical_layout
2721}
2722
2723fn event_bounds(
2724    track: &ParsedTrack,
2725    event: &LayoutEvent,
2726    vertical_layout: &[i32],
2727    effective_position: Option<(i32, i32)>,
2728    config: &RendererConfig,
2729    scale_x: f64,
2730    scale_y: f64,
2731) -> Rect {
2732    let mut x_min = i32::MAX;
2733    let mut y_min = i32::MAX;
2734    let mut x_max = i32::MIN;
2735    let mut y_max = i32::MIN;
2736
2737    for (line, line_top) in event.lines.iter().zip(vertical_layout.iter().copied()) {
2738        let line_width = (f64::from(line.width) * style_scale(scale_x)).round() as i32;
2739        let origin_x =
2740            compute_horizontal_origin(track, event, line_width, effective_position, scale_x);
2741        x_min = x_min.min(origin_x);
2742        y_min = y_min.min(line_top);
2743        x_max = x_max.max(origin_x + line_width);
2744        y_max = y_max.max(line_top + layout_line_height(config, scale_y));
2745    }
2746
2747    if x_min == i32::MAX {
2748        Rect::default()
2749    } else {
2750        Rect {
2751            x_min,
2752            y_min,
2753            x_max,
2754            y_max,
2755        }
2756    }
2757}
2758
2759fn text_decoration_planes(
2760    style: &ParsedSpanStyle,
2761    origin_x: i32,
2762    line_top: i32,
2763    width: i32,
2764    color: u32,
2765) -> Vec<ImagePlane> {
2766    if width <= 0 || !(style.underline || style.strike_out) {
2767        return Vec::new();
2768    }
2769
2770    let thickness = (style.font_size / 18.0).round().max(1.0) as i32;
2771    let mut planes = Vec::new();
2772    let mut push_decoration = |baseline_fraction: f64| {
2773        let y = line_top + (style.font_size * baseline_fraction).round() as i32;
2774        planes.push(ImagePlane {
2775            size: Size {
2776                width,
2777                height: thickness,
2778            },
2779            stride: width,
2780            color: rgba_color_from_ass(color),
2781            destination: Point { x: origin_x, y },
2782            kind: ass::ImageType::Character,
2783            bitmap: vec![255; (width * thickness) as usize],
2784        });
2785    };
2786
2787    if style.underline {
2788        push_decoration(0.82);
2789    }
2790    if style.strike_out {
2791        push_decoration(0.48);
2792    }
2793
2794    planes
2795}
2796
2797fn combined_image_plane_from_glyphs(
2798    glyphs: &[RasterGlyph],
2799    origin_x: i32,
2800    line_top: i32,
2801    line_ascender: Option<i32>,
2802    color: u32,
2803    kind: ass::ImageType,
2804    blur_radius: u32,
2805) -> Option<ImagePlane> {
2806    let ascender =
2807        line_ascender.unwrap_or_else(|| glyphs.iter().map(|glyph| glyph.top).max().unwrap_or(0));
2808    let mut pen_x = 0_i32;
2809    let mut min_x = i32::MAX;
2810    let mut min_y = i32::MAX;
2811    let mut max_x = i32::MIN;
2812    let mut max_y = i32::MIN;
2813
2814    for glyph in glyphs {
2815        if glyph.width <= 0 || glyph.height <= 0 || glyph.bitmap.is_empty() {
2816            pen_x += glyph.advance_x;
2817            continue;
2818        }
2819        let x = pen_x + glyph.left + glyph.offset_x;
2820        let y = ascender - glyph.top + glyph.offset_y;
2821        min_x = min_x.min(x);
2822        min_y = min_y.min(y);
2823        max_x = max_x.max(x + glyph.width);
2824        max_y = max_y.max(y + glyph.height);
2825        pen_x += glyph.advance_x;
2826    }
2827
2828    if min_x == i32::MAX || min_y == i32::MAX || max_x <= min_x || max_y <= min_y {
2829        return None;
2830    }
2831
2832    let width = (max_x - min_x) as usize;
2833    let height = (max_y - min_y) as usize;
2834    let mut bitmap = vec![0_u8; width * height];
2835    pen_x = 0;
2836    for glyph in glyphs {
2837        if glyph.width <= 0 || glyph.height <= 0 || glyph.bitmap.is_empty() {
2838            pen_x += glyph.advance_x;
2839            continue;
2840        }
2841        let x0 = (pen_x + glyph.left + glyph.offset_x - min_x) as usize;
2842        let y0 = (ascender - glyph.top + glyph.offset_y - min_y) as usize;
2843        let glyph_width = glyph.width as usize;
2844        let glyph_height = glyph.height as usize;
2845        let glyph_stride = glyph.stride as usize;
2846        for y in 0..glyph_height {
2847            for x in 0..glyph_width {
2848                let src = glyph.bitmap[y * glyph_stride + x];
2849                let dst = &mut bitmap[(y0 + y) * width + x0 + x];
2850                *dst = (*dst).max(src);
2851            }
2852        }
2853        pen_x += glyph.advance_x;
2854    }
2855
2856    let (bitmap, width, height, pad) = blur_bitmap(bitmap, width, height, blur_radius);
2857    Some(ImagePlane {
2858        size: Size {
2859            width: width as i32,
2860            height: height as i32,
2861        },
2862        stride: width as i32,
2863        color: rgba_color_from_ass(color),
2864        destination: Point {
2865            x: origin_x + min_x - pad as i32,
2866            y: line_top + min_y - pad as i32,
2867        },
2868        kind,
2869        bitmap,
2870    })
2871}
2872
2873fn blur_bitmap(
2874    source: Vec<u8>,
2875    width: usize,
2876    height: usize,
2877    radius: u32,
2878) -> (Vec<u8>, usize, usize, usize) {
2879    if radius == 0 || width == 0 || height == 0 || source.is_empty() {
2880        return (source, width, height, 0);
2881    }
2882    let r2 = libass_blur_r2_from_radius(radius);
2883    let (bitmap, width, height, pad_x, pad_y) =
2884        libass_gaussian_blur(&source, width, height, r2, r2);
2885    debug_assert_eq!(pad_x, pad_y);
2886    (bitmap, width, height, pad_x)
2887}
2888
2889#[derive(Clone)]
2890struct LibassBlurMethod {
2891    level: usize,
2892    radius: usize,
2893    coeff: [i16; 8],
2894}
2895
2896fn libass_blur_r2_from_radius(radius: u32) -> f64 {
2897    const POSITION_PRECISION: f64 = 8.0;
2898    const BLUR_PRECISION: f64 = 1.0 / 256.0;
2899    let blur = f64::from(radius) / 4.0;
2900    let blur_radius_scale = 2.0 / 256.0_f64.ln().sqrt();
2901    let scale = 64.0 * BLUR_PRECISION / POSITION_PRECISION;
2902    let qblur = ((1.0 + blur * blur_radius_scale * scale).ln() / BLUR_PRECISION).round();
2903    let sigma = (BLUR_PRECISION * qblur).exp_m1() / scale;
2904    sigma * sigma
2905}
2906
2907fn libass_gaussian_blur(
2908    source: &[u8],
2909    width: usize,
2910    height: usize,
2911    r2x: f64,
2912    r2y: f64,
2913) -> (Vec<u8>, usize, usize, usize, usize) {
2914    let blur_x = find_libass_blur_method(r2x);
2915    let blur_y = if (r2y - r2x).abs() < f64::EPSILON {
2916        blur_x.clone()
2917    } else {
2918        find_libass_blur_method(r2y)
2919    };
2920
2921    let offset_x = ((2 * blur_x.radius + 9) << blur_x.level) - 5;
2922    let offset_y = ((2 * blur_y.radius + 9) << blur_y.level) - 5;
2923    let mask_x = (1_usize << blur_x.level) - 1;
2924    let mask_y = (1_usize << blur_y.level) - 1;
2925    let end_width = ((width + offset_x) & !mask_x).saturating_sub(4);
2926    let end_height = ((height + offset_y) & !mask_y).saturating_sub(4);
2927    let pad_x = ((blur_x.radius + 4) << blur_x.level) - 4;
2928    let pad_y = ((blur_y.radius + 4) << blur_y.level) - 4;
2929
2930    let mut buffer = unpack_libass_blur(source);
2931    let mut w = width;
2932    let mut h = height;
2933
2934    for _ in 0..blur_y.level {
2935        let next = shrink_vert_libass(&buffer, w, h);
2936        buffer = next.0;
2937        w = next.1;
2938        h = next.2;
2939    }
2940    for _ in 0..blur_x.level {
2941        let next = shrink_horz_libass(&buffer, w, h);
2942        buffer = next.0;
2943        w = next.1;
2944        h = next.2;
2945    }
2946
2947    let next = blur_horz_libass(&buffer, w, h, &blur_x.coeff, blur_x.radius);
2948    buffer = next.0;
2949    w = next.1;
2950    h = next.2;
2951    let next = blur_vert_libass(&buffer, w, h, &blur_y.coeff, blur_y.radius);
2952    buffer = next.0;
2953    w = next.1;
2954    h = next.2;
2955
2956    for _ in 0..blur_x.level {
2957        let next = expand_horz_libass(&buffer, w, h);
2958        buffer = next.0;
2959        w = next.1;
2960        h = next.2;
2961    }
2962    for _ in 0..blur_y.level {
2963        let next = expand_vert_libass(&buffer, w, h);
2964        buffer = next.0;
2965        w = next.1;
2966        h = next.2;
2967    }
2968
2969    debug_assert_eq!(w, end_width);
2970    debug_assert_eq!(h, end_height);
2971    (pack_libass_blur(&buffer, w, h), w, h, pad_x, pad_y)
2972}
2973
2974fn find_libass_blur_method(r2: f64) -> LibassBlurMethod {
2975    let mut mu = [0.0_f64; 8];
2976    let (level, radius) = if r2 < 0.5 {
2977        mu[1] = 0.085 * r2 * r2 * r2;
2978        mu[0] = 0.5 * r2 - 4.0 * mu[1];
2979        (0_usize, 4_usize)
2980    } else {
2981        let (frac, level) = frexp((0.11569 * r2 + 0.20591047).sqrt());
2982        let mul = 0.25_f64.powi(level);
2983        let radius = (8_i32 - ((10.1525 + 0.8335 * mul) * (1.0 - frac)) as i32).max(4) as usize;
2984        calc_libass_coeff(&mut mu, radius, r2, mul);
2985        (level.max(0) as usize, radius)
2986    };
2987    let mut coeff = [0_i16; 8];
2988    for i in 0..radius {
2989        coeff[i] = (65536.0 * mu[i] + 0.5) as i16;
2990    }
2991    LibassBlurMethod {
2992        level,
2993        radius,
2994        coeff,
2995    }
2996}
2997
2998fn calc_libass_coeff(mu: &mut [f64; 8], n: usize, r2: f64, mul: f64) {
2999    let w = 12096.0;
3000    let kernel = [
3001        (((3280.0 / w) * mul + 1092.0 / w) * mul + 2520.0 / w) * mul + 5204.0 / w,
3002        (((-2460.0 / w) * mul - 273.0 / w) * mul - 210.0 / w) * mul + 2943.0 / w,
3003        (((984.0 / w) * mul - 546.0 / w) * mul - 924.0 / w) * mul + 486.0 / w,
3004        (((-164.0 / w) * mul + 273.0 / w) * mul - 126.0 / w) * mul + 17.0 / w,
3005    ];
3006    let mut mat_freq = [0.0_f64; 17];
3007    mat_freq[..4].copy_from_slice(&kernel);
3008    coeff_filter_libass(&mut mat_freq, 7, &kernel);
3009    let mut vec_freq = [0.0_f64; 12];
3010    calc_gauss_libass(&mut vec_freq, n + 4, r2 * mul);
3011    coeff_filter_libass(&mut vec_freq, n + 1, &kernel);
3012    let mut mat = [[0.0_f64; 8]; 8];
3013    calc_matrix_libass(&mut mat, &mat_freq, n);
3014    let mut vec = [0.0_f64; 8];
3015    for i in 0..n {
3016        vec[i] = mat_freq[0] - mat_freq[i + 1] - vec_freq[0] + vec_freq[i + 1];
3017    }
3018    for i in 0..n {
3019        let mut res = 0.0;
3020        for (j, value) in vec.iter().enumerate().take(n) {
3021            res += mat[i][j] * value;
3022        }
3023        mu[i] = res.max(0.0);
3024    }
3025}
3026
3027fn calc_gauss_libass(res: &mut [f64], n: usize, r2: f64) {
3028    let alpha = 0.5 / r2;
3029    let mut mul = (-alpha).exp();
3030    let mul2 = mul * mul;
3031    let mut cur = (alpha / std::f64::consts::PI).sqrt();
3032    res[0] = cur;
3033    cur *= mul;
3034    res[1] = cur;
3035    for value in res.iter_mut().take(n).skip(2) {
3036        mul *= mul2;
3037        cur *= mul;
3038        *value = cur;
3039    }
3040}
3041
3042fn coeff_filter_libass(coeff: &mut [f64], n: usize, kernel: &[f64; 4]) {
3043    let mut prev1 = coeff[1];
3044    let mut prev2 = coeff[2];
3045    let mut prev3 = coeff[3];
3046    for i in 0..n {
3047        let res = coeff[i] * kernel[0]
3048            + (prev1 + coeff[i + 1]) * kernel[1]
3049            + (prev2 + coeff[i + 2]) * kernel[2]
3050            + (prev3 + coeff[i + 3]) * kernel[3];
3051        prev3 = prev2;
3052        prev2 = prev1;
3053        prev1 = coeff[i];
3054        coeff[i] = res;
3055    }
3056}
3057
3058fn calc_matrix_libass(mat: &mut [[f64; 8]; 8], mat_freq: &[f64], n: usize) {
3059    for i in 0..n {
3060        mat[i][i] = mat_freq[2 * i + 2] + 3.0 * mat_freq[0] - 4.0 * mat_freq[i + 1];
3061        for j in i + 1..n {
3062            let v = mat_freq[i + j + 2]
3063                + mat_freq[j - i]
3064                + 2.0 * (mat_freq[0] - mat_freq[i + 1] - mat_freq[j + 1]);
3065            mat[i][j] = v;
3066            mat[j][i] = v;
3067        }
3068    }
3069    for k in 0..n {
3070        let z = 1.0 / mat[k][k];
3071        mat[k][k] = 1.0;
3072        let pivot_row = mat[k];
3073        for (i, row) in mat.iter_mut().enumerate().take(n) {
3074            if i == k {
3075                continue;
3076            }
3077            let mul = row[k] * z;
3078            row[k] = 0.0;
3079            for j in 0..n {
3080                row[j] -= pivot_row[j] * mul;
3081            }
3082        }
3083        for value in mat[k].iter_mut().take(n) {
3084            *value *= z;
3085        }
3086    }
3087}
3088
3089fn frexp(value: f64) -> (f64, i32) {
3090    if value == 0.0 {
3091        return (0.0, 0);
3092    }
3093    let exponent = value.abs().log2().floor() as i32 + 1;
3094    (value / 2.0_f64.powi(exponent), exponent)
3095}
3096
3097#[inline]
3098fn get_libass_sample(source: &[i16], width: usize, height: usize, x: isize, y: isize) -> i16 {
3099    if x < 0 || y < 0 || x >= width as isize || y >= height as isize {
3100        0
3101    } else {
3102        source[y as usize * width + x as usize]
3103    }
3104}
3105
3106fn unpack_libass_blur(source: &[u8]) -> Vec<i16> {
3107    source
3108        .iter()
3109        .map(|value| {
3110            let value = u16::from(*value);
3111            ((((value << 7) | (value >> 1)) + 1) >> 1) as i16
3112        })
3113        .collect()
3114}
3115
3116const LIBASS_DITHER_LINE: [i16; 32] = [
3117    8, 40, 8, 40, 8, 40, 8, 40, 8, 40, 8, 40, 8, 40, 8, 40, 56, 24, 56, 24, 56, 24, 56, 24, 56, 24,
3118    56, 24, 56, 24, 56, 24,
3119];
3120
3121fn pack_libass_blur(source: &[i16], width: usize, height: usize) -> Vec<u8> {
3122    let mut bitmap = vec![0_u8; width * height];
3123    for y in 0..height {
3124        let dither = &LIBASS_DITHER_LINE[16 * (y & 1)..];
3125        for x in 0..width {
3126            let sample = i32::from(source[y * width + x]);
3127            let value = ((sample - (sample >> 8) + i32::from(dither[x & 15])) >> 6).clamp(0, 255);
3128            bitmap[y * width + x] = value as u8;
3129        }
3130    }
3131    bitmap
3132}
3133
3134#[inline]
3135fn shrink_func_libass(p1p: i16, p1n: i16, z0p: i16, z0n: i16, n1p: i16, n1n: i16) -> i16 {
3136    let mut r = (i32::from(p1p) + i32::from(p1n) + i32::from(n1p) + i32::from(n1n)) >> 1;
3137    r = (r + i32::from(z0p) + i32::from(z0n)) >> 1;
3138    r = (r + i32::from(p1n) + i32::from(n1p)) >> 1;
3139    ((r + i32::from(z0p) + i32::from(z0n) + 2) >> 2) as i16
3140}
3141
3142#[inline]
3143fn expand_func_libass(p1: i16, z0: i16, n1: i16) -> (i16, i16) {
3144    let r = ((((p1 as u16).wrapping_add(n1 as u16)) >> 1).wrapping_add(z0 as u16)) >> 1;
3145    let rp = (((r.wrapping_add(p1 as u16) >> 1)
3146        .wrapping_add(z0 as u16)
3147        .wrapping_add(1))
3148        >> 1) as i16;
3149    let rn = (((r.wrapping_add(n1 as u16) >> 1)
3150        .wrapping_add(z0 as u16)
3151        .wrapping_add(1))
3152        >> 1) as i16;
3153    (rp, rn)
3154}
3155
3156fn shrink_horz_libass(source: &[i16], width: usize, height: usize) -> (Vec<i16>, usize, usize) {
3157    let dst_width = (width + 5) >> 1;
3158    let mut dst = vec![0_i16; dst_width * height];
3159    for y in 0..height {
3160        for x in 0..dst_width {
3161            let sx = (2 * x) as isize;
3162            dst[y * dst_width + x] = shrink_func_libass(
3163                get_libass_sample(source, width, height, sx - 4, y as isize),
3164                get_libass_sample(source, width, height, sx - 3, y as isize),
3165                get_libass_sample(source, width, height, sx - 2, y as isize),
3166                get_libass_sample(source, width, height, sx - 1, y as isize),
3167                get_libass_sample(source, width, height, sx, y as isize),
3168                get_libass_sample(source, width, height, sx + 1, y as isize),
3169            );
3170        }
3171    }
3172    (dst, dst_width, height)
3173}
3174
3175fn shrink_vert_libass(source: &[i16], width: usize, height: usize) -> (Vec<i16>, usize, usize) {
3176    let dst_height = (height + 5) >> 1;
3177    let mut dst = vec![0_i16; width * dst_height];
3178    for y in 0..dst_height {
3179        let sy = (2 * y) as isize;
3180        for x in 0..width {
3181            dst[y * width + x] = shrink_func_libass(
3182                get_libass_sample(source, width, height, x as isize, sy - 4),
3183                get_libass_sample(source, width, height, x as isize, sy - 3),
3184                get_libass_sample(source, width, height, x as isize, sy - 2),
3185                get_libass_sample(source, width, height, x as isize, sy - 1),
3186                get_libass_sample(source, width, height, x as isize, sy),
3187                get_libass_sample(source, width, height, x as isize, sy + 1),
3188            );
3189        }
3190    }
3191    (dst, width, dst_height)
3192}
3193
3194fn expand_horz_libass(source: &[i16], width: usize, height: usize) -> (Vec<i16>, usize, usize) {
3195    let dst_width = 2 * width + 4;
3196    let mut dst = vec![0_i16; dst_width * height];
3197    for y in 0..height {
3198        for i in 0..(width + 2) {
3199            let sx = i as isize;
3200            let (rp, rn) = expand_func_libass(
3201                get_libass_sample(source, width, height, sx - 2, y as isize),
3202                get_libass_sample(source, width, height, sx - 1, y as isize),
3203                get_libass_sample(source, width, height, sx, y as isize),
3204            );
3205            let dx = 2 * i;
3206            dst[y * dst_width + dx] = rp;
3207            dst[y * dst_width + dx + 1] = rn;
3208        }
3209    }
3210    (dst, dst_width, height)
3211}
3212
3213fn expand_vert_libass(source: &[i16], width: usize, height: usize) -> (Vec<i16>, usize, usize) {
3214    let dst_height = 2 * height + 4;
3215    let mut dst = vec![0_i16; width * dst_height];
3216    for i in 0..(height + 2) {
3217        let sy = i as isize;
3218        for x in 0..width {
3219            let (rp, rn) = expand_func_libass(
3220                get_libass_sample(source, width, height, x as isize, sy - 2),
3221                get_libass_sample(source, width, height, x as isize, sy - 1),
3222                get_libass_sample(source, width, height, x as isize, sy),
3223            );
3224            let dy = 2 * i;
3225            dst[dy * width + x] = rp;
3226            dst[(dy + 1) * width + x] = rn;
3227        }
3228    }
3229    (dst, width, dst_height)
3230}
3231
3232fn blur_horz_libass(
3233    source: &[i16],
3234    width: usize,
3235    height: usize,
3236    param: &[i16; 8],
3237    radius: usize,
3238) -> (Vec<i16>, usize, usize) {
3239    let dst_width = width + 2 * radius;
3240    let mut dst = vec![0_i16; dst_width * height];
3241    for y in 0..height {
3242        for x in 0..dst_width {
3243            let center_x = x as isize - radius as isize;
3244            let center = i32::from(get_libass_sample(
3245                source, width, height, center_x, y as isize,
3246            ));
3247            let mut acc = 0x8000_i32;
3248            for i in (1..=radius).rev() {
3249                let coeff = i32::from(param[i - 1]);
3250                let left = i32::from(get_libass_sample(
3251                    source,
3252                    width,
3253                    height,
3254                    center_x - i as isize,
3255                    y as isize,
3256                ));
3257                let right = i32::from(get_libass_sample(
3258                    source,
3259                    width,
3260                    height,
3261                    center_x + i as isize,
3262                    y as isize,
3263                ));
3264                acc += ((left - center) as i16 as i32) * coeff;
3265                acc += ((right - center) as i16 as i32) * coeff;
3266            }
3267            dst[y * dst_width + x] = (center + (acc >> 16)) as i16;
3268        }
3269    }
3270    (dst, dst_width, height)
3271}
3272
3273fn blur_vert_libass(
3274    source: &[i16],
3275    width: usize,
3276    height: usize,
3277    param: &[i16; 8],
3278    radius: usize,
3279) -> (Vec<i16>, usize, usize) {
3280    let dst_height = height + 2 * radius;
3281    let mut dst = vec![0_i16; width * dst_height];
3282    for y in 0..dst_height {
3283        let center_y = y as isize - radius as isize;
3284        for x in 0..width {
3285            let center = i32::from(get_libass_sample(
3286                source, width, height, x as isize, center_y,
3287            ));
3288            let mut acc = 0x8000_i32;
3289            for i in (1..=radius).rev() {
3290                let coeff = i32::from(param[i - 1]);
3291                let top = i32::from(get_libass_sample(
3292                    source,
3293                    width,
3294                    height,
3295                    x as isize,
3296                    center_y - i as isize,
3297                ));
3298                let bottom = i32::from(get_libass_sample(
3299                    source,
3300                    width,
3301                    height,
3302                    x as isize,
3303                    center_y + i as isize,
3304                ));
3305                acc += ((top - center) as i16 as i32) * coeff;
3306                acc += ((bottom - center) as i16 as i32) * coeff;
3307            }
3308            dst[y * width + x] = (center + (acc >> 16)) as i16;
3309        }
3310    }
3311    (dst, width, dst_height)
3312}
3313
3314fn image_planes_from_absolute_glyphs(
3315    glyphs: &[RasterGlyph],
3316    color: u32,
3317    kind: ass::ImageType,
3318) -> Vec<ImagePlane> {
3319    glyphs
3320        .iter()
3321        .filter_map(|glyph| {
3322            if glyph.width <= 0 || glyph.height <= 0 || glyph.bitmap.is_empty() {
3323                return None;
3324            }
3325
3326            Some(ImagePlane {
3327                size: Size {
3328                    width: glyph.width,
3329                    height: glyph.height,
3330                },
3331                stride: glyph.stride,
3332                color: rgba_color_from_ass(color),
3333                destination: Point {
3334                    x: glyph.left,
3335                    y: glyph.top - glyph.height,
3336                },
3337                kind,
3338                bitmap: glyph.bitmap.clone(),
3339            })
3340        })
3341        .collect()
3342}
3343
3344fn drawing_baseline_ascender(style: &ParsedSpanStyle, _render_scale_y: f64) -> i32 {
3345    let scale_y = style_scale(style.scale_y);
3346    (style.font_size.max(1.0) * scale_y * 0.75).round() as i32
3347}
3348
3349#[derive(Clone, Copy, Debug)]
3350struct DrawingPlaneParams {
3351    origin_x: i32,
3352    line_top: i32,
3353    color: u32,
3354    scale_x: f64,
3355    scale_y: f64,
3356    render_scale: RenderScale,
3357    baseline_offset: f64,
3358}
3359
3360fn image_plane_from_drawing(
3361    drawing: &ParsedDrawing,
3362    params: DrawingPlaneParams,
3363) -> Option<ImagePlane> {
3364    let polygons = scaled_drawing_polygons(
3365        drawing,
3366        params.scale_x,
3367        params.scale_y,
3368        params.render_scale.x,
3369        params.render_scale.y,
3370    );
3371    let bounds = drawing_bounds(&polygons)?;
3372    let width = bounds.width();
3373    let height = bounds.height();
3374    if width <= 0 || height <= 0 {
3375        return None;
3376    }
3377
3378    let stride = width as usize;
3379    let mut bitmap = vec![0_u8; stride * height as usize];
3380    let mut any_visible = false;
3381
3382    for row in 0..height as usize {
3383        for column in 0..width as usize {
3384            let x = bounds.x_min + column as i32;
3385            let y = bounds.y_min + row as i32;
3386            if polygons
3387                .iter()
3388                .any(|polygon| point_in_polygon(x, y, polygon))
3389            {
3390                bitmap[row * stride + column] = 255;
3391                any_visible = true;
3392            }
3393        }
3394    }
3395
3396    let pbo_pixels = (params.baseline_offset * params.render_scale.y).round() as i32;
3397    let vertical_offset = pbo_pixels.max(0);
3398
3399    any_visible.then_some(ImagePlane {
3400        size: Size { width, height },
3401        stride: width,
3402        color: rgba_color_from_ass(params.color),
3403        destination: Point {
3404            x: params.origin_x + bounds.x_min,
3405            y: params.line_top + bounds.y_min + vertical_offset,
3406        },
3407        kind: ass::ImageType::Character,
3408        bitmap,
3409    })
3410}
3411
3412fn scaled_drawing_polygons(
3413    drawing: &ParsedDrawing,
3414    scale_x: f64,
3415    scale_y: f64,
3416    render_scale_x: f64,
3417    render_scale_y: f64,
3418) -> Vec<Vec<Point>> {
3419    let scale_x = style_scale(scale_x) * render_scale_x;
3420    let scale_y = style_scale(scale_y) * render_scale_y;
3421    if (scale_x - 1.0).abs() < f64::EPSILON && (scale_y - 1.0).abs() < f64::EPSILON {
3422        return drawing.polygons.clone();
3423    }
3424
3425    drawing
3426        .polygons
3427        .iter()
3428        .map(|polygon| {
3429            polygon
3430                .iter()
3431                .map(|point| Point {
3432                    x: (f64::from(point.x) * scale_x).round() as i32,
3433                    y: (f64::from(point.y) * scale_y).round() as i32,
3434                })
3435                .collect()
3436        })
3437        .collect()
3438}
3439
3440fn drawing_bounds(polygons: &[Vec<Point>]) -> Option<Rect> {
3441    let mut points = polygons.iter().flat_map(|polygon| polygon.iter().copied());
3442    let first = points.next()?;
3443    let mut x_min = first.x;
3444    let mut y_min = first.y;
3445    let mut x_max = first.x;
3446    let mut y_max = first.y;
3447    for point in points {
3448        x_min = x_min.min(point.x);
3449        y_min = y_min.min(point.y);
3450        x_max = x_max.max(point.x);
3451        y_max = y_max.max(point.y);
3452    }
3453    Some(Rect {
3454        x_min,
3455        y_min,
3456        x_max: x_max + 1,
3457        y_max: y_max + 1,
3458    })
3459}
3460
3461fn plane_to_raster_glyph(plane: &ImagePlane) -> RasterGlyph {
3462    RasterGlyph {
3463        width: plane.size.width,
3464        height: plane.size.height,
3465        stride: plane.stride,
3466        left: plane.destination.x,
3467        top: plane.destination.y + plane.size.height,
3468        bitmap: plane.bitmap.clone(),
3469        ..RasterGlyph::default()
3470    }
3471}
3472
3473fn apply_event_clip(planes: Vec<ImagePlane>, clip_rect: Rect, inverse: bool) -> Vec<ImagePlane> {
3474    let mut clipped = Vec::new();
3475    for plane in planes {
3476        if inverse {
3477            clipped.extend(inverse_clip_plane(plane, clip_rect));
3478        } else if let Some(plane) = clip_plane(plane, clip_rect) {
3479            clipped.push(plane);
3480        }
3481    }
3482    clipped
3483}
3484
3485fn apply_vector_clip(
3486    planes: Vec<ImagePlane>,
3487    clip: &ParsedVectorClip,
3488    inverse: bool,
3489) -> Vec<ImagePlane> {
3490    planes
3491        .into_iter()
3492        .filter_map(|plane| mask_plane_with_vector_clip(plane, clip, inverse))
3493        .collect()
3494}
3495
3496fn mask_plane_with_vector_clip(
3497    plane: ImagePlane,
3498    clip: &ParsedVectorClip,
3499    inverse: bool,
3500) -> Option<ImagePlane> {
3501    let mut bitmap = plane.bitmap.clone();
3502    let stride = plane.stride as usize;
3503    let mut any_visible = false;
3504
3505    for row in 0..plane.size.height as usize {
3506        for column in 0..plane.size.width as usize {
3507            let global_x = plane.destination.x + column as i32;
3508            let global_y = plane.destination.y + row as i32;
3509            let inside = clip
3510                .polygons
3511                .iter()
3512                .any(|polygon| point_in_polygon(global_x, global_y, polygon));
3513            let keep = if inverse { !inside } else { inside };
3514            if !keep {
3515                bitmap[row * stride + column] = 0;
3516            } else if bitmap[row * stride + column] > 0 {
3517                any_visible = true;
3518            }
3519        }
3520    }
3521
3522    any_visible.then_some(ImagePlane { bitmap, ..plane })
3523}
3524
3525fn point_in_polygon(x: i32, y: i32, polygon: &[Point]) -> bool {
3526    if polygon.len() < 3 {
3527        return false;
3528    }
3529
3530    let mut inside = false;
3531    let mut previous = polygon[polygon.len() - 1];
3532    let sample_x = x as f64 + 0.5;
3533    let sample_y = y as f64 + 0.5;
3534
3535    for &current in polygon {
3536        let current_y = current.y as f64;
3537        let previous_y = previous.y as f64;
3538        let intersects = (current_y > sample_y) != (previous_y > sample_y);
3539        if intersects {
3540            let current_x = current.x as f64;
3541            let previous_x = previous.x as f64;
3542            let x_intersection = (previous_x - current_x) * (sample_y - current_y)
3543                / (previous_y - current_y)
3544                + current_x;
3545            if sample_x < x_intersection {
3546                inside = !inside;
3547            }
3548        }
3549        previous = current;
3550    }
3551
3552    inside
3553}
3554
3555fn clip_plane(plane: ImagePlane, clip_rect: Rect) -> Option<ImagePlane> {
3556    let plane_rect = plane_rect(&plane);
3557    let intersection = plane_rect.intersect(clip_rect)?;
3558    crop_plane_to_rect(plane, intersection)
3559}
3560
3561fn inverse_clip_plane(plane: ImagePlane, clip_rect: Rect) -> Vec<ImagePlane> {
3562    let plane_rect = plane_rect(&plane);
3563    let Some(intersection) = plane_rect.intersect(clip_rect) else {
3564        return vec![plane];
3565    };
3566
3567    let mut result = Vec::new();
3568    let regions = [
3569        Rect {
3570            x_min: plane_rect.x_min,
3571            y_min: plane_rect.y_min,
3572            x_max: plane_rect.x_max,
3573            y_max: intersection.y_min,
3574        },
3575        Rect {
3576            x_min: plane_rect.x_min,
3577            y_min: intersection.y_max,
3578            x_max: plane_rect.x_max,
3579            y_max: plane_rect.y_max,
3580        },
3581        Rect {
3582            x_min: plane_rect.x_min,
3583            y_min: intersection.y_min,
3584            x_max: intersection.x_min,
3585            y_max: intersection.y_max,
3586        },
3587        Rect {
3588            x_min: intersection.x_max,
3589            y_min: intersection.y_min,
3590            x_max: plane_rect.x_max,
3591            y_max: intersection.y_max,
3592        },
3593    ];
3594    for region in regions {
3595        if region.is_empty() {
3596            continue;
3597        }
3598        if let Some(cropped) = crop_plane_to_rect(plane.clone(), region) {
3599            result.push(cropped);
3600        }
3601    }
3602    result
3603}
3604
3605fn plane_rect(plane: &ImagePlane) -> Rect {
3606    Rect {
3607        x_min: plane.destination.x,
3608        y_min: plane.destination.y,
3609        x_max: plane.destination.x + plane.size.width,
3610        y_max: plane.destination.y + plane.size.height,
3611    }
3612}
3613
3614fn crop_plane_to_rect(plane: ImagePlane, rect: Rect) -> Option<ImagePlane> {
3615    let plane_rect = plane_rect(&plane);
3616    let rect = plane_rect.intersect(rect)?;
3617    let offset_x = (rect.x_min - plane_rect.x_min) as usize;
3618    let offset_y = (rect.y_min - plane_rect.y_min) as usize;
3619    let width = rect.width() as usize;
3620    let height = rect.height() as usize;
3621    let src_stride = plane.stride as usize;
3622    let mut bitmap = Vec::with_capacity(width * height);
3623
3624    for row in 0..height {
3625        let start = (offset_y + row) * src_stride + offset_x;
3626        bitmap.extend_from_slice(&plane.bitmap[start..start + width]);
3627    }
3628
3629    Some(ImagePlane {
3630        size: Size {
3631            width: rect.width(),
3632            height: rect.height(),
3633        },
3634        stride: rect.width(),
3635        destination: Point {
3636            x: rect.x_min,
3637            y: rect.y_min,
3638        },
3639        bitmap,
3640        ..plane
3641    })
3642}
3643fn is_event_active(event: &ParsedEvent, now_ms: i64) -> bool {
3644    now_ms >= event.start && now_ms < event.start + event.duration
3645}
3646
3647#[cfg(test)]
3648mod tests {
3649    use super::*;
3650    use rassa_fonts::{FontconfigProvider, NullFontProvider};
3651    use rassa_parse::parse_script_text;
3652
3653    fn config(
3654        frame_width: i32,
3655        frame_height: i32,
3656        margins: rassa_core::Margins,
3657        use_margins: bool,
3658    ) -> RendererConfig {
3659        RendererConfig {
3660            frame: Size {
3661                width: frame_width,
3662                height: frame_height,
3663            },
3664            margins,
3665            use_margins,
3666            ..RendererConfig::default()
3667        }
3668    }
3669
3670    fn total_plane_area(planes: &[ImagePlane]) -> i32 {
3671        planes
3672            .iter()
3673            .map(|plane| plane.size.width * plane.size.height)
3674            .sum()
3675    }
3676
3677    #[test]
3678    fn fad_uses_libass_truncating_alpha_interpolation() {
3679        let event = ParsedEvent {
3680            start: 0,
3681            duration: 4000,
3682            ..ParsedEvent::default()
3683        };
3684
3685        assert_eq!(
3686            compute_fad_alpha(
3687                ParsedFade::Simple {
3688                    fade_in_ms: 1000,
3689                    fade_out_ms: 1000,
3690                },
3691                Some(&event),
3692                500,
3693            ),
3694            127
3695        );
3696        assert_eq!(
3697            compute_fad_alpha(
3698                ParsedFade::Simple {
3699                    fade_in_ms: 1000,
3700                    fade_out_ms: 1000,
3701                },
3702                Some(&event),
3703                3500,
3704            ),
3705            127
3706        );
3707    }
3708
3709    #[test]
3710    fn fad_uses_libass_wrapping_out_start_when_fade_out_exceeds_duration() {
3711        let event = ParsedEvent {
3712            start: 0,
3713            duration: 800,
3714            ..ParsedEvent::default()
3715        };
3716
3717        assert_eq!(
3718            compute_fad_alpha(
3719                ParsedFade::Simple {
3720                    fade_in_ms: 100,
3721                    fade_out_ms: 1000,
3722                },
3723                Some(&event),
3724                100,
3725            ),
3726            76
3727        );
3728        assert_eq!(
3729            compute_fad_alpha(
3730                ParsedFade::Simple {
3731                    fade_in_ms: 100,
3732                    fade_out_ms: 1000,
3733                },
3734                Some(&event),
3735                400,
3736            ),
3737            153
3738        );
3739    }
3740
3741    #[test]
3742    fn fade_alpha_combines_with_existing_colour_alpha() {
3743        assert_eq!(with_fade_alpha(0xFF00_0080, 0), 0xFF00_0080);
3744        assert_eq!(with_fade_alpha(0xFF00_0000, 127), 0xFF00_007F);
3745        assert_eq!(with_fade_alpha(0xFF00_0080, 127), 0xFF00_00BF);
3746    }
3747
3748    fn vertical_span(planes: &[ImagePlane]) -> i32 {
3749        let min_y = planes
3750            .iter()
3751            .map(|plane| plane.destination.y)
3752            .min()
3753            .expect("plane");
3754        let max_y = planes
3755            .iter()
3756            .map(|plane| plane.destination.y + plane.size.height)
3757            .max()
3758            .expect("plane");
3759        max_y - min_y
3760    }
3761
3762    fn kind_bounds(planes: &[ImagePlane], kind: ass::ImageType) -> Option<Rect> {
3763        let mut matching_planes = planes.iter().filter(|plane| plane.kind == kind);
3764        let first = matching_planes.next()?;
3765        let mut bounds = Rect {
3766            x_min: first.destination.x,
3767            y_min: first.destination.y,
3768            x_max: first.destination.x + first.size.width,
3769            y_max: first.destination.y + first.size.height,
3770        };
3771        for plane in matching_planes {
3772            bounds.x_min = bounds.x_min.min(plane.destination.x);
3773            bounds.y_min = bounds.y_min.min(plane.destination.y);
3774            bounds.x_max = bounds.x_max.max(plane.destination.x + plane.size.width);
3775            bounds.y_max = bounds.y_max.max(plane.destination.y + plane.size.height);
3776        }
3777        Some(bounds)
3778    }
3779
3780    fn character_bounds(planes: &[ImagePlane]) -> Option<Rect> {
3781        kind_bounds(planes, ass::ImageType::Character)
3782    }
3783
3784    fn visible_bounds(planes: &[ImagePlane]) -> Option<Rect> {
3785        let mut bounds: Option<Rect> = None;
3786        for plane in planes {
3787            let stride = plane.stride.max(0) as usize;
3788            if stride == 0 {
3789                continue;
3790            }
3791            for y in 0..plane.size.height.max(0) as usize {
3792                for x in 0..plane.size.width.max(0) as usize {
3793                    if plane.bitmap[y * stride + x] == 0 {
3794                        continue;
3795                    }
3796                    let px = plane.destination.x + x as i32;
3797                    let py = plane.destination.y + y as i32;
3798                    match &mut bounds {
3799                        Some(rect) => {
3800                            rect.x_min = rect.x_min.min(px);
3801                            rect.y_min = rect.y_min.min(py);
3802                            rect.x_max = rect.x_max.max(px + 1);
3803                            rect.y_max = rect.y_max.max(py + 1);
3804                        }
3805                        None => {
3806                            bounds = Some(Rect {
3807                                x_min: px,
3808                                y_min: py,
3809                                x_max: px + 1,
3810                                y_max: py + 1,
3811                            });
3812                        }
3813                    }
3814                }
3815            }
3816        }
3817        bounds
3818    }
3819
3820    fn drawing_alignment_script(
3821        alignment: i32,
3822        override_tags: &str,
3823        event_margins: &str,
3824    ) -> String {
3825        format!(
3826            "[Script Info]\nScriptType: v4.00+\nPlayResX: 320\nPlayResY: 180\nWrapStyle: 2\nScaledBorderAndShadow: yes\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,32,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,{alignment},30,50,15,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,{event_margins},,{{{override_tags}\\p1}}m 0 0 l 40 0 40 20 0 20\n"
3827        )
3828    }
3829
3830    fn render_drawing_bounds(script: &str) -> Rect {
3831        let track = parse_script_text(script).expect("alignment probe script should parse");
3832        let engine = RenderEngine::new();
3833        let provider = NullFontProvider;
3834        let planes = engine.render_frame_with_provider(&track, &provider, 500);
3835        visible_bounds(&planes).expect("drawing probe should produce visible pixels")
3836    }
3837
3838    fn text_alignment_script(alignment: i32, event_margins: &str) -> String {
3839        format!(
3840            "[Script Info]\nScriptType: v4.00+\nPlayResX: 320\nPlayResY: 180\nWrapStyle: 2\nScaledBorderAndShadow: yes\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,32,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,{alignment},30,50,15,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,{event_margins},,Margin\n"
3841        )
3842    }
3843
3844    fn render_text_bounds(script: &str) -> Option<Rect> {
3845        let track = parse_script_text(script).expect("text alignment probe script should parse");
3846        let engine = RenderEngine::new();
3847        let provider = FontconfigProvider::new();
3848        let planes = engine.render_frame_with_provider(&track, &provider, 500);
3849        visible_bounds(&planes)
3850    }
3851
3852    fn render_text_bounds_with_config(script: &str, config: &RendererConfig) -> Option<Rect> {
3853        let track = parse_script_text(script).expect("text alignment probe script should parse");
3854        let engine = RenderEngine::new();
3855        let provider = FontconfigProvider::new();
3856        let planes = engine.render_frame_with_provider_and_config(&track, &provider, 500, config);
3857        visible_bounds(&planes)
3858    }
3859
3860    #[test]
3861    fn downscaled_positioned_text_scales_font_and_anchor_like_libass() {
3862        let script = "[Script Info]\nScriptType: v4.00+\nPlayResX: 640\nPlayResY: 360\nWrapStyle: 2\nScaledBorderAndShadow: yes\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,42,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,5,10,10,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0,0,0,,{\\an5\\pos(320,180)}POS\n";
3863        let config = RendererConfig {
3864            frame: Size {
3865                width: 320,
3866                height: 180,
3867            },
3868            storage: Size {
3869                width: 320,
3870                height: 180,
3871            },
3872            pixel_aspect: 1.0,
3873            shaping: ass::ShapingLevel::Complex,
3874            ..Default::default()
3875        };
3876        let actual = render_text_bounds_with_config(script, &config)
3877            .expect("positioned text should render in downscaled frame");
3878        let expected = Rect {
3879            x_min: 141,
3880            y_min: 83,
3881            x_max: 179,
3882            y_max: 97,
3883        };
3884
3885        assert!(
3886            (actual.x_min - expected.x_min).abs() <= 2
3887                && (actual.y_min - expected.y_min).abs() <= 1,
3888            "downscaled \\pos anchor should stay in libass position: actual={actual:?} expected={expected:?}"
3889        );
3890        assert!(
3891            (actual.width() - expected.width()).abs() <= 2
3892                && (actual.height() - expected.height()).abs() <= 2,
3893            "downscaled \\pos text must scale glyph dimensions like libass: actual={actual:?} expected={expected:?}"
3894        );
3895    }
3896
3897    #[test]
3898    fn borderstyle3_opaque_box_follows_text_transform() {
3899        let script = "[Script Info]\nScriptType: v4.00+\nPlayResX: 640\nPlayResY: 360\nScaledBorderAndShadow: yes\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Box,Arial,42,&H00000000,&H000000FF,&H00FFFFFF,&H00000000,0,0,0,0,100,100,0,0,3,4,0,5,10,10,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:05.00,Box,,0,0,0,,{\\pos(320,180)\\frz-18\\fax0.25}TRANSFORM BOX\n";
3900        let track = parse_script_text(script).expect("borderstyle transform script should parse");
3901        let engine = RenderEngine::new();
3902        let provider = FontconfigProvider::new();
3903        let planes = engine.render_frame_with_provider(&track, &provider, 500);
3904        let box_bounds = kind_bounds(&planes, ass::ImageType::Outline)
3905            .expect("BorderStyle=3 should emit an opaque box outline plane");
3906
3907        assert!(
3908            box_bounds.height() > 90,
3909            "opaque box must be transformed with the rotated/sheared text, got bounds {box_bounds:?}"
3910        );
3911    }
3912
3913    #[test]
3914    fn positioned_drawing_an_anchors_match_libass_for_all_alignments() {
3915        // Expected boxes were probed from libass/ffmpeg for a 40x20 vector drawing at \pos(x,y):
3916        // bottom align => y - 20, middle align => y - 10, top align => y.
3917        let cases = [
3918            (
3919                1,
3920                "\\an1\\pos(60,60)",
3921                Rect {
3922                    x_min: 60,
3923                    y_min: 40,
3924                    x_max: 100,
3925                    y_max: 60,
3926                },
3927            ),
3928            (
3929                2,
3930                "\\an2\\pos(160,60)",
3931                Rect {
3932                    x_min: 140,
3933                    y_min: 40,
3934                    x_max: 180,
3935                    y_max: 60,
3936                },
3937            ),
3938            (
3939                3,
3940                "\\an3\\pos(260,60)",
3941                Rect {
3942                    x_min: 220,
3943                    y_min: 40,
3944                    x_max: 260,
3945                    y_max: 60,
3946                },
3947            ),
3948            (
3949                4,
3950                "\\an4\\pos(60,100)",
3951                Rect {
3952                    x_min: 60,
3953                    y_min: 90,
3954                    x_max: 100,
3955                    y_max: 110,
3956                },
3957            ),
3958            (
3959                5,
3960                "\\an5\\pos(160,100)",
3961                Rect {
3962                    x_min: 140,
3963                    y_min: 90,
3964                    x_max: 180,
3965                    y_max: 110,
3966                },
3967            ),
3968            (
3969                6,
3970                "\\an6\\pos(260,100)",
3971                Rect {
3972                    x_min: 220,
3973                    y_min: 90,
3974                    x_max: 260,
3975                    y_max: 110,
3976                },
3977            ),
3978            (
3979                7,
3980                "\\an7\\pos(60,140)",
3981                Rect {
3982                    x_min: 60,
3983                    y_min: 140,
3984                    x_max: 100,
3985                    y_max: 160,
3986                },
3987            ),
3988            (
3989                8,
3990                "\\an8\\pos(160,140)",
3991                Rect {
3992                    x_min: 140,
3993                    y_min: 140,
3994                    x_max: 180,
3995                    y_max: 160,
3996                },
3997            ),
3998            (
3999                9,
4000                "\\an9\\pos(260,140)",
4001                Rect {
4002                    x_min: 220,
4003                    y_min: 140,
4004                    x_max: 260,
4005                    y_max: 160,
4006                },
4007            ),
4008        ];
4009
4010        for (alignment, override_tags, expected) in cases {
4011            let script = drawing_alignment_script(alignment, override_tags, "0,0,0");
4012            assert_eq!(
4013                render_drawing_bounds(&script),
4014                expected,
4015                "\\an{alignment} positioned drawing anchor should match libass"
4016            );
4017        }
4018    }
4019
4020    #[test]
4021    fn moved_drawing_an_anchors_match_libass_for_all_alignments_at_midpoint() {
4022        let cases = [
4023            (
4024                1,
4025                "\\an1\\move(40,60,80,60)",
4026                Rect {
4027                    x_min: 60,
4028                    y_min: 40,
4029                    x_max: 100,
4030                    y_max: 60,
4031                },
4032            ),
4033            (
4034                2,
4035                "\\an2\\move(140,60,180,60)",
4036                Rect {
4037                    x_min: 140,
4038                    y_min: 40,
4039                    x_max: 180,
4040                    y_max: 60,
4041                },
4042            ),
4043            (
4044                3,
4045                "\\an3\\move(240,60,280,60)",
4046                Rect {
4047                    x_min: 220,
4048                    y_min: 40,
4049                    x_max: 260,
4050                    y_max: 60,
4051                },
4052            ),
4053            (
4054                4,
4055                "\\an4\\move(40,100,80,100)",
4056                Rect {
4057                    x_min: 60,
4058                    y_min: 90,
4059                    x_max: 100,
4060                    y_max: 110,
4061                },
4062            ),
4063            (
4064                5,
4065                "\\an5\\move(140,100,180,100)",
4066                Rect {
4067                    x_min: 140,
4068                    y_min: 90,
4069                    x_max: 180,
4070                    y_max: 110,
4071                },
4072            ),
4073            (
4074                6,
4075                "\\an6\\move(240,100,280,100)",
4076                Rect {
4077                    x_min: 220,
4078                    y_min: 90,
4079                    x_max: 260,
4080                    y_max: 110,
4081                },
4082            ),
4083            (
4084                7,
4085                "\\an7\\move(40,140,80,140)",
4086                Rect {
4087                    x_min: 60,
4088                    y_min: 140,
4089                    x_max: 100,
4090                    y_max: 160,
4091                },
4092            ),
4093            (
4094                8,
4095                "\\an8\\move(140,140,180,140)",
4096                Rect {
4097                    x_min: 140,
4098                    y_min: 140,
4099                    x_max: 180,
4100                    y_max: 160,
4101                },
4102            ),
4103            (
4104                9,
4105                "\\an9\\move(240,140,280,140)",
4106                Rect {
4107                    x_min: 220,
4108                    y_min: 140,
4109                    x_max: 260,
4110                    y_max: 160,
4111                },
4112            ),
4113        ];
4114
4115        for (alignment, override_tags, expected) in cases {
4116            let script = drawing_alignment_script(alignment, override_tags, "0,0,0");
4117            assert_eq!(
4118                render_drawing_bounds(&script),
4119                expected,
4120                "\\an{alignment} moved drawing anchor should match libass at the event midpoint"
4121            );
4122        }
4123    }
4124
4125    #[test]
4126    fn margin_positioned_text_uses_style_and_event_margins_like_libass() {
4127        let cases = [
4128            (
4129                1,
4130                "0,0,0",
4131                Rect {
4132                    x_min: 32,
4133                    y_min: 138,
4134                    x_max: 116,
4135                    y_max: 165,
4136                },
4137            ),
4138            (
4139                2,
4140                "0,0,0",
4141                Rect {
4142                    x_min: 108,
4143                    y_min: 138,
4144                    x_max: 192,
4145                    y_max: 165,
4146                },
4147            ),
4148            (
4149                3,
4150                "0,0,0",
4151                Rect {
4152                    x_min: 184,
4153                    y_min: 138,
4154                    x_max: 269,
4155                    y_max: 165,
4156                },
4157            ),
4158            (
4159                5,
4160                "0,0,0",
4161                Rect {
4162                    x_min: 108,
4163                    y_min: 79,
4164                    x_max: 192,
4165                    y_max: 106,
4166                },
4167            ),
4168            (
4169                7,
4170                "0,0,0",
4171                Rect {
4172                    x_min: 32,
4173                    y_min: 20,
4174                    x_max: 116,
4175                    y_max: 47,
4176                },
4177            ),
4178            (
4179                8,
4180                "0,0,0",
4181                Rect {
4182                    x_min: 108,
4183                    y_min: 20,
4184                    x_max: 192,
4185                    y_max: 47,
4186                },
4187            ),
4188            (
4189                9,
4190                "7,9,11",
4191                Rect {
4192                    x_min: 225,
4193                    y_min: 16,
4194                    x_max: 310,
4195                    y_max: 43,
4196                },
4197            ),
4198        ];
4199
4200        for (alignment, event_margins, expected) in cases {
4201            let script = text_alignment_script(alignment, event_margins);
4202            let Some(actual) = render_text_bounds(&script) else {
4203                return;
4204            };
4205            // Text rasterization can have a few pixels of coverage-width drift from libass even
4206            // with the same Fontconfig face. This regression guards the placement bug: the
4207            // effective style/event margin anchor must no longer be shifted left or sunk.
4208            assert!(
4209                (actual.x_min - expected.x_min).abs() <= 1,
4210                "text style/event margins and \\an{alignment} x placement should match libass within raster rounding: actual={actual:?} expected={expected:?}"
4211            );
4212            assert_eq!(
4213                (actual.y_min, actual.y_max),
4214                (expected.y_min, expected.y_max),
4215                "text style/event margins and \\an{alignment} vertical placement should match libass"
4216            );
4217        }
4218    }
4219
4220    #[test]
4221    fn margin_positioned_drawing_uses_style_and_event_margins_like_libass() {
4222        // Expected boxes were probed from libass/ffmpeg for a 40x20 vector drawing with
4223        // style margins L=30/R=50/V=15. Event margins of 0 should fall back to style margins.
4224        let cases = [
4225            (
4226                1,
4227                Rect {
4228                    x_min: 30,
4229                    y_min: 145,
4230                    x_max: 70,
4231                    y_max: 165,
4232                },
4233            ),
4234            (
4235                2,
4236                Rect {
4237                    x_min: 130,
4238                    y_min: 145,
4239                    x_max: 170,
4240                    y_max: 165,
4241                },
4242            ),
4243            (
4244                3,
4245                Rect {
4246                    x_min: 230,
4247                    y_min: 145,
4248                    x_max: 270,
4249                    y_max: 165,
4250                },
4251            ),
4252            (
4253                4,
4254                Rect {
4255                    x_min: 30,
4256                    y_min: 80,
4257                    x_max: 70,
4258                    y_max: 100,
4259                },
4260            ),
4261            (
4262                5,
4263                Rect {
4264                    x_min: 130,
4265                    y_min: 80,
4266                    x_max: 170,
4267                    y_max: 100,
4268                },
4269            ),
4270            (
4271                6,
4272                Rect {
4273                    x_min: 230,
4274                    y_min: 80,
4275                    x_max: 270,
4276                    y_max: 100,
4277                },
4278            ),
4279            (
4280                7,
4281                Rect {
4282                    x_min: 30,
4283                    y_min: 15,
4284                    x_max: 70,
4285                    y_max: 35,
4286                },
4287            ),
4288            (
4289                8,
4290                Rect {
4291                    x_min: 130,
4292                    y_min: 15,
4293                    x_max: 170,
4294                    y_max: 35,
4295                },
4296            ),
4297            (
4298                9,
4299                Rect {
4300                    x_min: 230,
4301                    y_min: 15,
4302                    x_max: 270,
4303                    y_max: 35,
4304                },
4305            ),
4306        ];
4307
4308        for (alignment, expected) in cases {
4309            let script = drawing_alignment_script(alignment, "", "0,0,0");
4310            assert_eq!(
4311                render_drawing_bounds(&script),
4312                expected,
4313                "style margins and \\an{alignment} should match libass when no explicit position exists"
4314            );
4315        }
4316
4317        let script = drawing_alignment_script(7, "", "7,9,11");
4318        assert_eq!(
4319            render_drawing_bounds(&script),
4320            Rect {
4321                x_min: 7,
4322                y_min: 11,
4323                x_max: 47,
4324                y_max: 31
4325            },
4326            "non-zero event margins should override style margins for top-left alignment"
4327        );
4328    }
4329
4330    #[test]
4331    fn projective_transform_keeps_frx_and_fry_axes_distinct() {
4332        let origin = (320.0, 180.0);
4333        let frx = ProjectiveMatrix::from_ass_transform_at_origin(
4334            EventTransform {
4335                rotation_x: 45.0,
4336                ..EventTransform::default()
4337            },
4338            origin.0,
4339            origin.1,
4340            1.0,
4341        );
4342        let fry = ProjectiveMatrix::from_ass_transform_at_origin(
4343            EventTransform {
4344                rotation_y: 45.0,
4345                ..EventTransform::default()
4346            },
4347            origin.0,
4348            origin.1,
4349            1.0,
4350        );
4351
4352        let (frx_x, frx_y) = frx.transform_point(320.0, 140.0);
4353        let (fry_x, fry_y) = fry.transform_point(360.0, 180.0);
4354
4355        assert!(
4356            (frx_x - 320.0).abs() < 0.5,
4357            "frx must not act like fry: {frx_x}"
4358        );
4359        assert!(
4360            frx_y > 140.0,
4361            "positive frx should pitch the top edge downward: {frx_y}"
4362        );
4363        assert!(
4364            fry_x < 360.0,
4365            "positive fry should yaw the right edge leftward: {fry_x}"
4366        );
4367        assert!(
4368            (fry_y - 180.0).abs() < 0.5,
4369            "fry must not act like frx: {fry_y}"
4370        );
4371    }
4372
4373    #[test]
4374    fn projective_transform_uses_deep_org_as_perspective_lever_arm() {
4375        let transform = EventTransform {
4376            rotation_x: 55.0,
4377            ..EventTransform::default()
4378        };
4379        let shallow = ProjectiveMatrix::from_ass_transform_at_origin(transform, 320.0, 240.0, 1.0);
4380        let deep = ProjectiveMatrix::from_ass_transform_at_origin(transform, 320.0, 420.0, 1.0);
4381
4382        let (_, shallow_y) = shallow.transform_point(320.0, 240.0);
4383        let (_, deep_y) = deep.transform_point(320.0, 240.0);
4384
4385        assert!((shallow_y - 240.0).abs() < 0.5);
4386        assert!(
4387            deep_y > shallow_y + 70.0,
4388            "deep \\org below text should pull frx text substantially downward like libass, got shallow={shallow_y} deep={deep_y}"
4389        );
4390    }
4391
4392    #[test]
4393    fn prepare_frame_only_keeps_active_events() {
4394        let track = parse_script_text("[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,20,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,First\nDialogue: 0,0:00:02.00,0:00:03.00,Default,,0000,0000,0000,,Second").expect("script should parse");
4395        let engine = RenderEngine::new();
4396        let provider = NullFontProvider;
4397        let frame = engine.prepare_frame(&track, &provider, 500);
4398
4399        assert_eq!(frame.active_events.len(), 1);
4400        assert_eq!(frame.active_events[0].text, "First");
4401    }
4402
4403    #[test]
4404    fn render_frame_produces_image_planes_for_active_text() {
4405        let track = parse_script_text("[Script Info]\nPlayResX: 640\nPlayResY: 360\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,20,20,20,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,Hi").expect("script should parse");
4406        let engine = RenderEngine::new();
4407        let provider = FontconfigProvider::new();
4408        let planes = engine.render_frame_with_provider(&track, &provider, 500);
4409
4410        assert!(!planes.is_empty());
4411        assert!(planes.iter().all(|plane| plane.size.width >= 0));
4412        assert!(planes.iter().all(|plane| plane.size.height >= 0));
4413    }
4414
4415    #[test]
4416    fn render_frame_supports_multiple_override_runs() {
4417        let track = parse_script_text("[Script Info]\nPlayResX: 640\nPlayResY: 360\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,20,20,20,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\fnDejaVu Sans}Hi{\\fnArial} there").expect("script should parse");
4418        let engine = RenderEngine::new();
4419        let provider = FontconfigProvider::new();
4420        let planes = engine.render_frame_with_provider(&track, &provider, 500);
4421
4422        assert!(!planes.is_empty());
4423    }
4424
4425    #[test]
4426    fn render_frame_uses_axis_specific_shadow_offsets() {
4427        let track = parse_script_text("[Script Info]\nPlayResX: 220\nPlayResY: 120\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,28,&H00FFFFFF,&H0000FFFF,&H00000000,&H00111111,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\an7\\pos(30,30)\\xshad9\\yshad3}Hi").expect("script should parse");
4428        let engine = RenderEngine::new();
4429        let provider = FontconfigProvider::new();
4430        let planes = engine.render_frame_with_provider(&track, &provider, 500);
4431        let character_planes = planes
4432            .iter()
4433            .filter(|plane| plane.kind == ass::ImageType::Character)
4434            .cloned()
4435            .collect::<Vec<_>>();
4436        let shadow_planes = planes
4437            .iter()
4438            .filter(|plane| plane.kind == ass::ImageType::Shadow)
4439            .cloned()
4440            .collect::<Vec<_>>();
4441
4442        let character = visible_bounds(&character_planes).expect("character bounds");
4443        let shadow = visible_bounds(&shadow_planes).expect("axis-specific shadow should render");
4444        assert_eq!(shadow.x_min - character.x_min, 9);
4445        assert_eq!(shadow.y_min - character.y_min, 3);
4446    }
4447
4448    #[test]
4449    fn render_frame_renders_underline_and_strikeout_decorations() {
4450        let track = parse_script_text("[Script Info]\nPlayResX: 220\nPlayResY: 120\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,28,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\an7\\pos(30,30)\\u1\\s1}Hi").expect("script should parse");
4451        let engine = RenderEngine::new();
4452        let provider = FontconfigProvider::new();
4453        let planes = engine.render_frame_with_provider(&track, &provider, 500);
4454        let decoration_planes = planes
4455            .iter()
4456            .filter(|plane| {
4457                plane.kind == ass::ImageType::Character
4458                    && plane.size.height <= 3
4459                    && plane.size.width > plane.size.height * 4
4460            })
4461            .collect::<Vec<_>>();
4462
4463        assert!(decoration_planes.len() >= 2);
4464    }
4465
4466    #[test]
4467    fn render_frame_uses_override_colors_and_shadow_planes() {
4468        let track = parse_script_text("[Script Info]\nPlayResX: 640\nPlayResY: 360\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H00FFFFFF,&H0000FFFF,&H00000000,&H00111111,0,0,0,0,100,100,0,0,1,2,2,2,20,20,20,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\1c&H112233&\\4c&H445566&\\shad3}Hi").expect("script should parse");
4469        let engine = RenderEngine::new();
4470        let provider = FontconfigProvider::new();
4471        let planes = engine.render_frame_with_provider(&track, &provider, 500);
4472
4473        assert!(
4474            planes.iter().any(
4475                |plane| plane.kind == ass::ImageType::Character && plane.color.0 == 0x3322_1100
4476            )
4477        );
4478        assert!(
4479            planes
4480                .iter()
4481                .any(|plane| plane.kind == ass::ImageType::Shadow && plane.color.0 == 0x6655_4400)
4482        );
4483    }
4484
4485    #[test]
4486    fn render_frame_orders_events_by_layer_then_read_order() {
4487        let track = parse_script_text("[Script Info]\nPlayResX: 200\nPlayResY: 120\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 5,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\an7\\pos(10,10)\\1c&H0000FF&}High\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\an7\\pos(10,40)\\1c&H00FF00&}Low").expect("script should parse");
4488        let engine = RenderEngine::new();
4489        let provider = FontconfigProvider::new();
4490        let planes = engine.render_frame_with_provider(&track, &provider, 500);
4491
4492        let first_character = planes
4493            .iter()
4494            .find(|plane| plane.kind == ass::ImageType::Character)
4495            .expect("character plane");
4496        assert_eq!(first_character.color.0, 0x00FF_0000);
4497    }
4498
4499    #[test]
4500    fn render_frame_orders_shadow_outline_before_character_within_event() {
4501        let track = parse_script_text("[Script Info]\nPlayResX: 200\nPlayResY: 120\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H00111111,&H0000FFFF,&H00222222,&H00333333,0,0,0,0,100,100,0,0,1,2,2,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\an7\\pos(10,10)}Hi").expect("script should parse");
4502        let engine = RenderEngine::new();
4503        let provider = FontconfigProvider::new();
4504        let planes = engine.render_frame_with_provider(&track, &provider, 500);
4505        let kinds = planes.iter().map(|plane| plane.kind).collect::<Vec<_>>();
4506
4507        let first_shadow = kinds
4508            .iter()
4509            .position(|kind| *kind == ass::ImageType::Shadow)
4510            .expect("shadow plane");
4511        let first_outline = kinds
4512            .iter()
4513            .position(|kind| *kind == ass::ImageType::Outline)
4514            .expect("outline plane");
4515        let first_character = kinds
4516            .iter()
4517            .position(|kind| *kind == ass::ImageType::Character)
4518            .expect("character plane");
4519
4520        assert!(first_shadow < first_outline);
4521        assert!(first_outline < first_character);
4522    }
4523
4524    #[test]
4525    fn render_frame_emits_outline_planes_for_border_override() {
4526        let track = parse_script_text("[Script Info]\nPlayResX: 640\nPlayResY: 360\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H00FFFFFF,&H0000FFFF,&H00010203,&H00111111,0,0,0,0,100,100,0,0,1,2,2,2,20,20,20,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\bord3\\3c&H0A0B0C&}Hi").expect("script should parse");
4527        let engine = RenderEngine::new();
4528        let provider = FontconfigProvider::new();
4529        let planes = engine.render_frame_with_provider(&track, &provider, 500);
4530
4531        assert!(
4532            planes
4533                .iter()
4534                .any(|plane| plane.kind == ass::ImageType::Outline && plane.color.0 == 0x0C0B_0A00)
4535        );
4536    }
4537
4538    #[test]
4539    fn render_frame_emits_opaque_box_for_border_style_3() {
4540        let track = parse_script_text("[Script Info]\nPlayResX: 500\nPlayResY: 160\nScaledBorderAndShadow: yes\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Box,DejaVu Sans,30,&H00000000,&H0000FFFF,&H00000000,&H00111111,0,0,0,0,100,100,0,0,3,2,0,5,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Box,,0000,0000,0000,,{\\an5\\pos(250,80)}BorderStyle=3 opaque box").expect("script should parse");
4541        let engine = RenderEngine::new();
4542        let provider = FontconfigProvider::new();
4543        let planes = engine.render_frame_with_provider(&track, &provider, 500);
4544        let character_planes = planes
4545            .iter()
4546            .filter(|plane| plane.kind == ass::ImageType::Character)
4547            .cloned()
4548            .collect::<Vec<_>>();
4549        let outline_planes = planes
4550            .iter()
4551            .filter(|plane| plane.kind == ass::ImageType::Outline)
4552            .cloned()
4553            .collect::<Vec<_>>();
4554
4555        assert_eq!(
4556            outline_planes.len(),
4557            1,
4558            "BorderStyle=3 should emit only the opaque box outline plane, not a separate stroked glyph outline"
4559        );
4560        let _character = visible_bounds(&character_planes).expect("character bounds");
4561        let outline = outline_planes
4562            .iter()
4563            .find(|plane| plane.color.0 == 0x0000_0000 && plane.bitmap.contains(&255))
4564            .expect("opaque border-style box plane uses outline colour");
4565        assert!(outline.size.width > 0);
4566        assert!(outline.size.height > 0);
4567        let bounds = visible_bounds(std::slice::from_ref(outline)).expect("opaque box bounds");
4568        let center_x = (bounds.x_min + bounds.x_max) / 2;
4569        assert!(
4570            (center_x - 250).abs() <= 2,
4571            "opaque box should stay centered at \\pos, got {bounds:?}"
4572        );
4573        let center_y = (bounds.y_min + bounds.y_max) / 2;
4574        assert!(
4575            (center_y - 80).abs() <= 1,
4576            "opaque box should stay vertically centered at \\pos like libass, got {bounds:?}"
4577        );
4578        assert_eq!(
4579            bounds.height(),
4580            36,
4581            "BorderStyle=3 box plane height should be font size plus two borders plus edge rows like libass"
4582        );
4583        assert!(
4584            bounds.width() < 370,
4585            "opaque box should use actual raster advance like libass, not inflated layout width: {bounds:?}"
4586        );
4587    }
4588
4589    #[test]
4590    fn render_frame_blurs_outline_and_shadow_layers() {
4591        let track = parse_script_text("[Script Info]\nPlayResX: 640\nPlayResY: 360\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H00FFFFFF,&H0000FFFF,&H00010203,&H00111111,0,0,0,0,100,100,0,0,1,2,2,2,20,20,20,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\bord2\\blur2\\3c&H0A0B0C&\\shad2}Hi").expect("script should parse");
4592        let engine = RenderEngine::new();
4593        let provider = FontconfigProvider::new();
4594        let planes = engine.render_frame_with_provider(&track, &provider, 500);
4595
4596        assert!(
4597            planes
4598                .iter()
4599                .any(|plane| plane.kind == ass::ImageType::Outline
4600                    && plane.bitmap.iter().any(|value| *value > 0 && *value < 255))
4601        );
4602        assert!(
4603            planes
4604                .iter()
4605                .any(|plane| plane.kind == ass::ImageType::Shadow
4606                    && plane.bitmap.iter().any(|value| *value > 0 && *value < 255))
4607        );
4608    }
4609
4610    #[test]
4611    fn render_frame_blurs_fill_only_without_outline_or_shadow() {
4612        let base = parse_script_text("[Script Info]\nPlayResX: 200\nPlayResY: 120\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,32,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\an7\\pos(40,40)}Hi").expect("script should parse");
4613        let blurred = parse_script_text("[Script Info]\nPlayResX: 200\nPlayResY: 120\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,32,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\an7\\pos(40,40)\\blur3}Hi").expect("script should parse");
4614        let engine = RenderEngine::new();
4615        let provider = FontconfigProvider::new();
4616        let base_planes = engine.render_frame_with_provider(&base, &provider, 500);
4617        let blurred_planes = engine.render_frame_with_provider(&blurred, &provider, 500);
4618        let base_character = visible_bounds(
4619            &base_planes
4620                .iter()
4621                .filter(|plane| plane.kind == ass::ImageType::Character)
4622                .cloned()
4623                .collect::<Vec<_>>(),
4624        )
4625        .expect("base character bounds");
4626        let blurred_character = visible_bounds(
4627            &blurred_planes
4628                .iter()
4629                .filter(|plane| plane.kind == ass::ImageType::Character)
4630                .cloned()
4631                .collect::<Vec<_>>(),
4632        )
4633        .expect("blurred character bounds");
4634
4635        assert!(blurred_character.x_min < base_character.x_min);
4636        assert!(blurred_character.x_max > base_character.x_max);
4637        assert!(blurred_character.y_min < base_character.y_min);
4638        assert!(blurred_character.y_max > base_character.y_max);
4639    }
4640
4641    #[test]
4642    fn render_frame_does_not_blur_fill_when_outline_or_shadow_exists() {
4643        let base = parse_script_text("[Script Info]\nPlayResX: 200\nPlayResY: 120\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,32,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\an7\\pos(40,40)}Hi").expect("script should parse");
4644        let blurred = parse_script_text("[Script Info]\nPlayResX: 200\nPlayResY: 120\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,32,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\an7\\pos(40,40)\\blur3}Hi").expect("script should parse");
4645        let engine = RenderEngine::new();
4646        let provider = FontconfigProvider::new();
4647        let base_planes = engine.render_frame_with_provider(&base, &provider, 500);
4648        let blurred_planes = engine.render_frame_with_provider(&blurred, &provider, 500);
4649        let character_bounds = |planes: &[ImagePlane]| {
4650            visible_bounds(
4651                &planes
4652                    .iter()
4653                    .filter(|plane| plane.kind == ass::ImageType::Character)
4654                    .cloned()
4655                    .collect::<Vec<_>>(),
4656            )
4657            .expect("character bounds")
4658        };
4659
4660        assert_eq!(
4661            character_bounds(&blurred_planes),
4662            character_bounds(&base_planes)
4663        );
4664        assert!(
4665            blurred_planes
4666                .iter()
4667                .filter(|plane| plane.kind == ass::ImageType::Outline)
4668                .any(|plane| plane.bitmap.iter().any(|value| *value > 0 && *value < 255))
4669        );
4670        assert!(
4671            blurred_planes
4672                .iter()
4673                .filter(|plane| plane.kind == ass::ImageType::Shadow)
4674                .any(|plane| plane.bitmap.iter().any(|value| *value > 0 && *value < 255))
4675        );
4676    }
4677
4678    #[test]
4679    fn render_frame_applies_rectangular_clip() {
4680        let track = parse_script_text("[Script Info]\nPlayResX: 640\nPlayResY: 360\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,7,20,20,20,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\an7\\pos(0,0)\\clip(0,0,64,64)}Hi").expect("script should parse");
4681        let engine = RenderEngine::new();
4682        let provider = FontconfigProvider::new();
4683        let planes = engine.render_frame_with_provider(&track, &provider, 500);
4684
4685        assert!(!planes.is_empty());
4686        assert!(planes.iter().all(|plane| plane.destination.x >= 0));
4687        assert!(planes.iter().all(|plane| plane.destination.y >= 0));
4688        assert!(
4689            planes
4690                .iter()
4691                .all(|plane| plane.destination.x + plane.size.width <= 64)
4692        );
4693        assert!(
4694            planes
4695                .iter()
4696                .all(|plane| plane.destination.y + plane.size.height <= 64)
4697        );
4698    }
4699
4700    #[test]
4701    fn render_frame_accepts_renderer_shaping_mode() {
4702        let track = parse_script_text("[Script Info]\nPlayResX: 320\nPlayResY: 180\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,48,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,2,20,20,20,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,office").expect("script should parse");
4703        let engine = RenderEngine::new();
4704        let provider = FontconfigProvider::new();
4705        let simple = engine.render_frame_with_provider_and_config(
4706            &track,
4707            &provider,
4708            500,
4709            &RendererConfig {
4710                shaping: ass::ShapingLevel::Simple,
4711                ..default_renderer_config(&track)
4712            },
4713        );
4714        let complex = engine.render_frame_with_provider_and_config(
4715            &track,
4716            &provider,
4717            500,
4718            &RendererConfig {
4719                shaping: ass::ShapingLevel::Complex,
4720                ..default_renderer_config(&track)
4721            },
4722        );
4723
4724        assert!(!simple.is_empty());
4725        assert!(!complex.is_empty());
4726    }
4727
4728    #[test]
4729    fn render_frame_applies_inverse_rectangular_clip() {
4730        let plane = ImagePlane {
4731            size: Size {
4732                width: 6,
4733                height: 4,
4734            },
4735            stride: 6,
4736            color: RgbaColor(0x00FF_FFFF),
4737            destination: Point { x: 0, y: 0 },
4738            kind: ass::ImageType::Character,
4739            bitmap: vec![255; 24],
4740        };
4741        let parts = inverse_clip_plane(
4742            plane,
4743            Rect {
4744                x_min: 2,
4745                y_min: 1,
4746                x_max: 4,
4747                y_max: 3,
4748            },
4749        );
4750
4751        assert_eq!(parts.len(), 4);
4752        assert_eq!(
4753            parts.iter().map(|plane| plane.bitmap.len()).sum::<usize>(),
4754            20
4755        );
4756    }
4757
4758    #[test]
4759    fn inverse_clip_bleed_covers_outline_growth_to_prevent_stray_glyph_leakage() {
4760        let style = ParsedSpanStyle {
4761            border: 5.0,
4762            border_x: 5.0,
4763            border_y: 5.0,
4764            shadow: 0.0,
4765            shadow_x: 0.0,
4766            shadow_y: 0.0,
4767            blur: 0.0,
4768            be: 0.0,
4769            ..ParsedSpanStyle::default()
4770        };
4771        let clip = Rect {
4772            x_min: 20,
4773            y_min: 0,
4774            x_max: 24,
4775            y_max: 10,
4776        };
4777        let glyph = ImagePlane {
4778            size: Size {
4779                width: 44,
4780                height: 10,
4781            },
4782            stride: 44,
4783            color: RgbaColor(0x00FF_FFFF),
4784            destination: Point { x: 0, y: 0 },
4785            kind: ass::ImageType::Outline,
4786            bitmap: vec![255; 440],
4787        };
4788
4789        let expanded = expand_rect(clip, style_clip_bleed(&style));
4790        let parts = inverse_clip_plane(glyph, expanded);
4791
4792        assert!(
4793            parts
4794                .iter()
4795                .all(|plane| plane.destination.x + plane.size.width <= 0
4796                    || plane.destination.x >= 44),
4797            "inverse clip must mask outline bleed around the nominal clip, got {parts:?}"
4798        );
4799    }
4800
4801    #[test]
4802    fn render_frame_applies_vector_clip() {
4803        let track = parse_script_text("[Script Info]\nPlayResX: 200\nPlayResY: 100\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\an7\\pos(0,0)\\clip(m 0 0 l 32 0 32 32 0 32)}Hi").expect("script should parse");
4804        let engine = RenderEngine::new();
4805        let provider = FontconfigProvider::new();
4806        let planes = engine.render_frame_with_provider(&track, &provider, 500);
4807
4808        assert!(!planes.is_empty());
4809        assert!(
4810            planes
4811                .iter()
4812                .all(|plane| plane.bitmap.iter().any(|value| *value > 0))
4813        );
4814        assert!(planes.iter().all(|plane| plane.destination.x >= 0));
4815        assert!(planes.iter().all(|plane| plane.destination.y >= 0));
4816    }
4817
4818    #[test]
4819    fn render_frame_clips_to_frame_bounds() {
4820        let plane = ImagePlane {
4821            size: Size {
4822                width: 20,
4823                height: 20,
4824            },
4825            stride: 20,
4826            color: RgbaColor(0x00FF_FFFF),
4827            destination: Point { x: 50, y: 50 },
4828            kind: ass::ImageType::Character,
4829            bitmap: vec![255; 400],
4830        };
4831        let clipped = apply_event_clip(
4832            vec![plane],
4833            Rect {
4834                x_min: 0,
4835                y_min: 0,
4836                x_max: 60,
4837                y_max: 60,
4838            },
4839            false,
4840        );
4841
4842        assert_eq!(clipped.len(), 1);
4843        assert_eq!(clipped[0].size.width, 10);
4844        assert_eq!(clipped[0].size.height, 10);
4845    }
4846
4847    #[test]
4848    fn render_frame_applies_margin_clip_when_enabled() {
4849        let track = parse_script_text("[Script Info]\nPlayResX: 100\nPlayResY: 100\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,7,10,10,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,Hi").expect("script should parse");
4850        let engine = RenderEngine::new();
4851        let provider = FontconfigProvider::new();
4852        let planes = engine.render_frame_with_provider_and_config(
4853            &track,
4854            &provider,
4855            500,
4856            &config(
4857                100,
4858                100,
4859                rassa_core::Margins {
4860                    top: 10,
4861                    bottom: 10,
4862                    left: 10,
4863                    right: 10,
4864                },
4865                true,
4866            ),
4867        );
4868
4869        assert!(!planes.is_empty());
4870        assert!(planes.iter().all(|plane| plane.destination.x >= 10));
4871        assert!(planes.iter().all(|plane| plane.destination.y >= 10));
4872        assert!(
4873            planes
4874                .iter()
4875                .all(|plane| plane.destination.x + plane.size.width <= 90)
4876        );
4877        assert!(
4878            planes
4879                .iter()
4880                .all(|plane| plane.destination.y + plane.size.height <= 90)
4881        );
4882    }
4883
4884    #[test]
4885    fn render_frame_maps_into_content_area_when_margins_are_not_used() {
4886        let track = parse_script_text("[Script Info]\nPlayResX: 100\nPlayResY: 100\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,18,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\an7\\pos(0,0)}I").expect("script should parse");
4887        let engine = RenderEngine::new();
4888        let provider = FontconfigProvider::new();
4889        let planes = engine.render_frame_with_provider_and_config(
4890            &track,
4891            &provider,
4892            500,
4893            &config(
4894                120,
4895                120,
4896                rassa_core::Margins {
4897                    top: 10,
4898                    bottom: 10,
4899                    left: 10,
4900                    right: 10,
4901                },
4902                false,
4903            ),
4904        );
4905
4906        assert!(!planes.is_empty());
4907        let bounds = visible_bounds(&planes).expect("visible bounds");
4908        assert!(
4909            bounds.x_min >= 10,
4910            "visible bounds should start inside content area: {bounds:?}"
4911        );
4912        assert!(
4913            bounds.y_min >= 9,
4914            "libass-style antialiasing may allocate one guard row above the content area: {bounds:?}"
4915        );
4916        assert!(
4917            bounds.x_max <= 110,
4918            "visible bounds should end inside content area: {bounds:?}"
4919        );
4920        assert!(
4921            bounds.y_max <= 110,
4922            "visible bounds should end inside content area: {bounds:?}"
4923        );
4924    }
4925
4926    #[test]
4927    fn render_frame_keeps_border_closer_to_device_size_when_scaled_border_is_disabled() {
4928        let enabled = parse_script_text("[Script Info]\nPlayResX: 100\nPlayResY: 100\nScaledBorderAndShadow: yes\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,18,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,4,0,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\an7\\pos(10,10)}I").expect("script should parse");
4929        let disabled = parse_script_text("[Script Info]\nPlayResX: 100\nPlayResY: 100\nScaledBorderAndShadow: no\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,18,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,4,0,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\an7\\pos(10,10)}I").expect("script should parse");
4930        let engine = RenderEngine::new();
4931        let provider = FontconfigProvider::new();
4932        let config = config(200, 200, rassa_core::Margins::default(), true);
4933        let enabled_planes =
4934            engine.render_frame_with_provider_and_config(&enabled, &provider, 500, &config);
4935        let disabled_planes =
4936            engine.render_frame_with_provider_and_config(&disabled, &provider, 500, &config);
4937        let enabled_outline_area: i32 = enabled_planes
4938            .iter()
4939            .filter(|plane| plane.kind == ass::ImageType::Outline)
4940            .map(|plane| plane.size.width * plane.size.height)
4941            .sum();
4942        let disabled_outline_area: i32 = disabled_planes
4943            .iter()
4944            .filter(|plane| plane.kind == ass::ImageType::Outline)
4945            .map(|plane| plane.size.width * plane.size.height)
4946            .sum();
4947
4948        assert!(disabled_outline_area > 0);
4949        assert!(disabled_outline_area < enabled_outline_area);
4950    }
4951
4952    #[test]
4953    fn render_frame_applies_font_scale_to_output() {
4954        let track = parse_script_text("[Script Info]\nPlayResX: 200\nPlayResY: 120\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,Scale").expect("script should parse");
4955        let engine = RenderEngine::new();
4956        let provider = FontconfigProvider::new();
4957
4958        let baseline = engine.render_frame_with_provider(&track, &provider, 500);
4959        let scaled = engine.render_frame_with_provider_and_config(
4960            &track,
4961            &provider,
4962            500,
4963            &RendererConfig {
4964                frame: Size {
4965                    width: 200,
4966                    height: 120,
4967                },
4968                font_scale: 2.0,
4969                ..RendererConfig::default()
4970            },
4971        );
4972
4973        assert!(!baseline.is_empty());
4974        assert!(!scaled.is_empty());
4975        assert!(total_plane_area(&scaled) > total_plane_area(&baseline));
4976    }
4977
4978    #[test]
4979    fn render_frame_applies_text_scale_overrides() {
4980        let track = parse_script_text("[Script Info]\nPlayResX: 240\nPlayResY: 140\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\an7\\pos(10,10)}Scale").expect("script should parse");
4981        let stretched = parse_script_text("[Script Info]\nPlayResX: 240\nPlayResY: 140\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\an7\\pos(10,10)\\fscx200\\fscy50}Scale").expect("script should parse");
4982        let engine = RenderEngine::new();
4983        let provider = FontconfigProvider::new();
4984        let baseline = engine.render_frame_with_provider(&track, &provider, 500);
4985        let scaled = engine.render_frame_with_provider(&stretched, &provider, 500);
4986        let baseline_width = baseline
4987            .iter()
4988            .filter(|plane| plane.kind == ass::ImageType::Character)
4989            .map(|plane| plane.destination.x + plane.size.width)
4990            .max()
4991            .expect("baseline max x")
4992            - baseline
4993                .iter()
4994                .filter(|plane| plane.kind == ass::ImageType::Character)
4995                .map(|plane| plane.destination.x)
4996                .min()
4997                .expect("baseline min x");
4998        let scaled_width = scaled
4999            .iter()
5000            .filter(|plane| plane.kind == ass::ImageType::Character)
5001            .map(|plane| plane.destination.x + plane.size.width)
5002            .max()
5003            .expect("scaled max x")
5004            - scaled
5005                .iter()
5006                .filter(|plane| plane.kind == ass::ImageType::Character)
5007                .map(|plane| plane.destination.x)
5008                .min()
5009                .expect("scaled min x");
5010
5011        assert!(scaled_width > baseline_width);
5012        assert!(total_plane_area(&scaled) < total_plane_area(&baseline) * 2);
5013    }
5014
5015    #[test]
5016    fn render_frame_applies_drawing_scale_overrides() {
5017        let baseline = parse_script_text("[Script Info]\nPlayResX: 120\nPlayResY: 120\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H00112233,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\an7\\pos(10,10)\\p1}m 0 0 l 10 0 10 10 0 10").expect("script should parse");
5018        let scaled = parse_script_text("[Script Info]\nPlayResX: 120\nPlayResY: 120\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H00112233,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\an7\\pos(10,10)\\fscx200\\fscy50\\p1}m 0 0 l 10 0 10 10 0 10").expect("script should parse");
5019        let engine = RenderEngine::new();
5020        let provider = FontconfigProvider::new();
5021        let baseline_planes = engine.render_frame_with_provider(&baseline, &provider, 500);
5022        let scaled_planes = engine.render_frame_with_provider(&scaled, &provider, 500);
5023        let baseline_plane = baseline_planes
5024            .iter()
5025            .find(|plane| plane.kind == ass::ImageType::Character)
5026            .expect("baseline drawing plane");
5027        let scaled_plane = scaled_planes
5028            .iter()
5029            .find(|plane| plane.kind == ass::ImageType::Character)
5030            .expect("scaled drawing plane");
5031
5032        assert!(scaled_plane.size.width > baseline_plane.size.width);
5033        assert!(scaled_plane.size.height < baseline_plane.size.height);
5034        assert_eq!(scaled_plane.destination, Point { x: 10, y: 10 });
5035    }
5036
5037    #[test]
5038    fn non_positioned_drawing_does_not_receive_positioned_overhang_compensation() {
5039        let track = parse_script_text("[Script Info]\nPlayResX: 120\nPlayResY: 120\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H00112233,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\p1}m 0 0 l 10 0 10 10 0 10{\\p0}").expect("script should parse");
5040        let engine = RenderEngine::new();
5041        let provider = FontconfigProvider::new();
5042        let plane = engine
5043            .render_frame_with_provider(&track, &provider, 500)
5044            .into_iter()
5045            .find(|plane| plane.kind == ass::ImageType::Character)
5046            .expect("drawing plane");
5047
5048        assert_eq!(
5049            plane.size.width, 11,
5050            "libass-style positioned overhang compensation is specific to explicit \\pos vector drawings"
5051        );
5052    }
5053
5054    #[test]
5055    #[ignore = "parked while rassa stops treating pixel-perfect libass drawing pbo residuals as an optimization blocker"]
5056    fn render_frame_applies_drawing_baseline_offset() {
5057        fn pbo_track(pbo_tag: &str) -> ParsedTrack {
5058            parse_script_text(&format!("[Script Info]\nPlayResX: 160\nPlayResY: 120\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H00112233,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{{\\an7\\pos(10,40)}}X{{{pbo_tag}\\p1}}m 0 0 l 10 0 10 10 0 10{{\\p0}}X"))
5059                .expect("script should parse")
5060        }
5061
5062        let baseline = pbo_track("");
5063        let pbo5 = pbo_track("\\pbo5");
5064        let shifted = pbo_track("\\pbo12");
5065        let negative = pbo_track("\\pbo-12");
5066        let engine = RenderEngine::new();
5067        let provider = FontconfigProvider::new();
5068        let drawing_plane = |track: &ParsedTrack| {
5069            engine
5070                .render_frame_with_provider(track, &provider, 500)
5071                .into_iter()
5072                .find(|plane| {
5073                    plane.kind == ass::ImageType::Character
5074                        && plane.size.width == 11
5075                        && plane.size.height == 11
5076                })
5077                .expect("drawing plane")
5078        };
5079        let baseline_drawing = drawing_plane(&baseline);
5080        let pbo5_drawing = drawing_plane(&pbo5);
5081        let shifted_drawing = drawing_plane(&shifted);
5082        let negative_drawing = drawing_plane(&negative);
5083
5084        assert_eq!(
5085            pbo5_drawing.destination, baseline_drawing.destination,
5086            "libass keeps pbo below drawing height anchored for this 10-unit positioned drawing"
5087        );
5088        assert_eq!(
5089            shifted_drawing.destination.x,
5090            baseline_drawing.destination.x
5091        );
5092        assert_eq!(
5093            shifted_drawing.destination.y,
5094            baseline_drawing.destination.y + 2,
5095            "libass applies \\pbo as max(pbo - drawing_height, 0) for this top-anchored positioned drawing"
5096        );
5097        assert_eq!(
5098            negative_drawing.destination, baseline_drawing.destination,
5099            "libass keeps negative \\pbo top-anchored for this positioned drawing"
5100        );
5101    }
5102
5103    #[test]
5104    fn render_frame_applies_banner_effect_motion() {
5105        let track = parse_script_text("[Script Info]\nPlayResX: 200\nPlayResY: 100\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,5,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:02.00,Default,,0000,0000,0000,Banner;25;0;0,Banner").expect("script should parse");
5106        let engine = RenderEngine::new();
5107        let provider = FontconfigProvider::new();
5108        let early = character_bounds(&engine.render_frame_with_provider(&track, &provider, 100))
5109            .expect("early banner bounds");
5110        let late = character_bounds(&engine.render_frame_with_provider(&track, &provider, 1500))
5111            .expect("late banner bounds");
5112
5113        assert!(
5114            late.x_min < early.x_min,
5115            "right-to-left banner should move left over time"
5116        );
5117        assert!(
5118            (194..=198).contains(&early.x_min),
5119            "libass positions a right-to-left banner by PlayResX - elapsed/delay, got {early:?}"
5120        );
5121    }
5122
5123    #[test]
5124    fn banner_effect_delay_uses_layout_scale_not_render_supersampling() {
5125        let track = parse_script_text("[Script Info]\nPlayResX: 200\nPlayResY: 100\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,5,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:02.00,Default,,0000,0000,0000,Banner;25;0;0,Banner").expect("script should parse");
5126        let engine = RenderEngine::new();
5127        let provider = FontconfigProvider::new();
5128        let bounds = character_bounds(&engine.render_frame_with_provider_and_config(
5129            &track,
5130            &provider,
5131            1500,
5132            &RendererConfig {
5133                frame: Size {
5134                    width: 1600,
5135                    height: 800,
5136                },
5137                storage: Size {
5138                    width: 200,
5139                    height: 100,
5140                },
5141                ..RendererConfig::default()
5142            },
5143        ))
5144        .expect("supersampled banner bounds");
5145
5146        assert!(
5147            bounds.x_min >= 1112,
5148            "Banner delay should be based on layout/storage resolution rather than render supersampling; got {bounds:?}"
5149        );
5150    }
5151
5152    #[test]
5153    fn render_frame_applies_scroll_effect_motion() {
5154        let up = parse_script_text("[Script Info]\nPlayResX: 200\nPlayResY: 120\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,5,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:02.00,Default,,0000,0000,0000,Scroll up;20;100;25;0,Scroll").expect("script should parse");
5155        let down = parse_script_text("[Script Info]\nPlayResX: 200\nPlayResY: 120\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,5,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:02.00,Default,,0000,0000,0000,Scroll down;20;100;25;0,Scroll").expect("script should parse");
5156        let engine = RenderEngine::new();
5157        let provider = FontconfigProvider::new();
5158        let up_early = character_bounds(&engine.render_frame_with_provider(&up, &provider, 100))
5159            .expect("early scroll-up bounds");
5160        let up_late = character_bounds(&engine.render_frame_with_provider(&up, &provider, 1500))
5161            .expect("late scroll-up bounds");
5162        let down_early =
5163            character_bounds(&engine.render_frame_with_provider(&down, &provider, 100))
5164                .expect("early scroll-down bounds");
5165        let down_late =
5166            character_bounds(&engine.render_frame_with_provider(&down, &provider, 1500))
5167                .expect("late scroll-down bounds");
5168
5169        assert!(
5170            up_late.y_min < up_early.y_min,
5171            "scroll up should move upward"
5172        );
5173        assert!(
5174            down_late.y_min > down_early.y_min,
5175            "scroll down should move downward"
5176        );
5177    }
5178
5179    #[test]
5180    fn render_frame_applies_text_spacing_override() {
5181        let baseline = parse_script_text("[Script Info]\nPlayResX: 240\nPlayResY: 120\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,28,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\an7\\pos(10,10)}IIII").expect("script should parse");
5182        let spaced = parse_script_text("[Script Info]\nPlayResX: 240\nPlayResY: 120\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,28,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\an7\\pos(10,10)\\fsp8}IIII").expect("script should parse");
5183        let engine = RenderEngine::new();
5184        let provider = FontconfigProvider::new();
5185        let baseline_planes = engine.render_frame_with_provider(&baseline, &provider, 500);
5186        let spaced_planes = engine.render_frame_with_provider(&spaced, &provider, 500);
5187        let baseline_width = character_bounds(&baseline_planes)
5188            .expect("baseline bounds")
5189            .width();
5190        let spaced_width = character_bounds(&spaced_planes)
5191            .expect("spaced bounds")
5192            .width();
5193
5194        assert!(spaced_width > baseline_width);
5195    }
5196
5197    #[test]
5198    fn render_frame_scales_output_to_frame_size() {
5199        let track = parse_script_text("[Script Info]\nPlayResX: 200\nPlayResY: 120\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,2,10,10,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,Scale").expect("script should parse");
5200        let engine = RenderEngine::new();
5201        let provider = FontconfigProvider::new();
5202
5203        let baseline = engine.render_frame_with_provider(&track, &provider, 500);
5204        let scaled = engine.render_frame_with_provider_and_config(
5205            &track,
5206            &provider,
5207            500,
5208            &RendererConfig {
5209                frame: Size {
5210                    width: 400,
5211                    height: 240,
5212                },
5213                ..default_renderer_config(&track)
5214            },
5215        );
5216
5217        assert!(total_plane_area(&baseline) > 0);
5218        assert!(total_plane_area(&scaled) > total_plane_area(&baseline));
5219    }
5220
5221    #[test]
5222    fn render_frame_applies_pixel_aspect_horizontally() {
5223        let track = parse_script_text("[Script Info]\nPlayResX: 200\nPlayResY: 120\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,18,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\an7\\pos(0,0)}I").expect("script should parse");
5224        let engine = RenderEngine::new();
5225        let provider = FontconfigProvider::new();
5226
5227        let baseline = engine.render_frame_with_provider_and_config(
5228            &track,
5229            &provider,
5230            500,
5231            &RendererConfig {
5232                frame: Size {
5233                    width: 400,
5234                    height: 120,
5235                },
5236                ..default_renderer_config(&track)
5237            },
5238        );
5239        let widened = engine.render_frame_with_provider_and_config(
5240            &track,
5241            &provider,
5242            500,
5243            &RendererConfig {
5244                frame: Size {
5245                    width: 400,
5246                    height: 120,
5247                },
5248                pixel_aspect: 2.0,
5249                ..default_renderer_config(&track)
5250            },
5251        );
5252
5253        let baseline_bounds = character_bounds(&baseline).expect("baseline character bounds");
5254        let widened_bounds = character_bounds(&widened).expect("widened character bounds");
5255        assert!(
5256            widened_bounds.x_min > baseline_bounds.x_min,
5257            "pixel aspect should affect horizontal placement: baseline={baseline_bounds:?} widened={widened_bounds:?}"
5258        );
5259    }
5260
5261    #[test]
5262    fn render_frame_derives_pixel_aspect_from_storage_size_when_unset() {
5263        let track = parse_script_text("[Script Info]\nPlayResX: 200\nPlayResY: 120\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,18,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\an7\\pos(0,0)}Storage").expect("script should parse");
5264        let engine = RenderEngine::new();
5265        let provider = FontconfigProvider::new();
5266
5267        let baseline = engine.render_frame_with_provider_and_config(
5268            &track,
5269            &provider,
5270            500,
5271            &RendererConfig {
5272                frame: Size {
5273                    width: 400,
5274                    height: 240,
5275                },
5276                ..default_renderer_config(&track)
5277            },
5278        );
5279        let storage_adjusted = engine.render_frame_with_provider_and_config(
5280            &track,
5281            &provider,
5282            500,
5283            &RendererConfig {
5284                frame: Size {
5285                    width: 400,
5286                    height: 240,
5287                },
5288                storage: Size {
5289                    width: 400,
5290                    height: 120,
5291                },
5292                ..default_renderer_config(&track)
5293            },
5294        );
5295
5296        assert!(total_plane_area(&baseline) > 0);
5297        assert!(total_plane_area(&storage_adjusted) < total_plane_area(&baseline));
5298    }
5299
5300    #[test]
5301    fn render_frame_layout_resolution_takes_precedence_over_storage_and_explicit_aspect() {
5302        let track = parse_script_text("[Script Info]\nPlayResX: 200\nPlayResY: 120\nLayoutResX: 400\nLayoutResY: 240\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,18,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\an7\\pos(0,0)}Layout").expect("script should parse");
5303        let engine = RenderEngine::new();
5304        let provider = FontconfigProvider::new();
5305
5306        let baseline = engine.render_frame_with_provider_and_config(
5307            &track,
5308            &provider,
5309            500,
5310            &RendererConfig {
5311                frame: Size {
5312                    width: 400,
5313                    height: 240,
5314                },
5315                ..default_renderer_config(&track)
5316            },
5317        );
5318        let overridden_inputs = engine.render_frame_with_provider_and_config(
5319            &track,
5320            &provider,
5321            500,
5322            &RendererConfig {
5323                frame: Size {
5324                    width: 400,
5325                    height: 240,
5326                },
5327                storage: Size {
5328                    width: 400,
5329                    height: 120,
5330                },
5331                pixel_aspect: 2.0,
5332                ..default_renderer_config(&track)
5333            },
5334        );
5335
5336        assert_eq!(
5337            total_plane_area(&overridden_inputs),
5338            total_plane_area(&baseline)
5339        );
5340    }
5341
5342    #[test]
5343    fn render_frame_applies_line_position_to_subtitles() {
5344        let track = parse_script_text("[Script Info]\nPlayResX: 200\nPlayResY: 120\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,2,0,0,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,Shift").expect("script should parse");
5345        let engine = RenderEngine::new();
5346        let provider = FontconfigProvider::new();
5347
5348        let baseline = engine.render_frame_with_provider(&track, &provider, 500);
5349        let shifted = engine.render_frame_with_provider_and_config(
5350            &track,
5351            &provider,
5352            500,
5353            &RendererConfig {
5354                frame: Size {
5355                    width: 200,
5356                    height: 120,
5357                },
5358                line_position: 50.0,
5359                ..RendererConfig::default()
5360            },
5361        );
5362
5363        let baseline_y = baseline
5364            .iter()
5365            .map(|plane| plane.destination.y)
5366            .min()
5367            .expect("baseline plane");
5368        let shifted_y = shifted
5369            .iter()
5370            .map(|plane| plane.destination.y)
5371            .min()
5372            .expect("shifted plane");
5373
5374        assert!(shifted_y < baseline_y);
5375    }
5376
5377    #[test]
5378    fn render_frame_applies_line_spacing_to_multiline_subtitles() {
5379        let track = parse_script_text("[Script Info]\nPlayResX: 200\nPlayResY: 140\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,2,0,0,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,One\\NTwo").expect("script should parse");
5380        let engine = RenderEngine::new();
5381        let provider = FontconfigProvider::new();
5382
5383        let baseline = engine.render_frame_with_provider(&track, &provider, 500);
5384        let spaced = engine.render_frame_with_provider_and_config(
5385            &track,
5386            &provider,
5387            500,
5388            &RendererConfig {
5389                frame: Size {
5390                    width: 200,
5391                    height: 140,
5392                },
5393                line_spacing: 20.0,
5394                ..RendererConfig::default()
5395            },
5396        );
5397
5398        assert!(vertical_span(&spaced) > vertical_span(&baseline));
5399    }
5400
5401    #[test]
5402    fn render_frame_avoids_basic_bottom_collision_for_unpositioned_events() {
5403        let track = parse_script_text("[Script Info]\nPlayResX: 240\nPlayResY: 120\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,2,0,0,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0,0,0,,First\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0,0,0,,Second").expect("script should parse");
5404        let engine = RenderEngine::new();
5405        let provider = FontconfigProvider::new();
5406        let planes = engine.render_frame_with_provider(&track, &provider, 500);
5407
5408        let mut ys = planes
5409            .iter()
5410            .filter(|plane| plane.kind == ass::ImageType::Character)
5411            .map(|plane| plane.destination.y)
5412            .collect::<Vec<_>>();
5413        ys.sort_unstable();
5414        ys.dedup();
5415
5416        assert!(ys.len() >= 2);
5417        assert!(ys.last().expect("max y") - ys.first().expect("min y") >= 20);
5418    }
5419
5420    #[test]
5421    fn render_frame_allows_basic_collision_across_different_layers() {
5422        let track = parse_script_text("[Script Info]\nPlayResX: 240\nPlayResY: 120\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,2,0,0,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0,0,0,,{\\1c&H0000FF&}First\nDialogue: 1,0:00:00.00,0:00:01.00,Default,,0,0,0,,{\\1c&H00FF00&}Second").expect("script should parse");
5423        let engine = RenderEngine::new();
5424        let provider = FontconfigProvider::new();
5425        let planes = engine.render_frame_with_provider(&track, &provider, 500);
5426
5427        let layer0_y = planes
5428            .iter()
5429            .filter(|plane| plane.kind == ass::ImageType::Character && plane.color.0 == 0xFF00_0000)
5430            .map(|plane| plane.destination.y)
5431            .min()
5432            .expect("layer 0 character plane");
5433        let layer1_y = planes
5434            .iter()
5435            .filter(|plane| plane.kind == ass::ImageType::Character && plane.color.0 == 0x00FF_0000)
5436            .map(|plane| plane.destination.y)
5437            .min()
5438            .expect("layer 1 character plane");
5439
5440        assert_eq!(layer0_y, layer1_y);
5441    }
5442
5443    #[test]
5444    fn render_frame_interpolates_move_position() {
5445        let track = parse_script_text("[Script Info]\nPlayResX: 200\nPlayResY: 100\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\move(0,0,100,0,0,1000)}Hi").expect("script should parse");
5446        let engine = RenderEngine::new();
5447        let provider = FontconfigProvider::new();
5448        let start_planes = engine.render_frame_with_provider(&track, &provider, 0);
5449        let mid_planes = engine.render_frame_with_provider(&track, &provider, 500);
5450        let end_planes = engine.render_frame_with_provider(&track, &provider, 999);
5451
5452        let start_x = start_planes
5453            .iter()
5454            .map(|plane| plane.destination.x)
5455            .min()
5456            .expect("start plane");
5457        let mid_x = mid_planes
5458            .iter()
5459            .map(|plane| plane.destination.x)
5460            .min()
5461            .expect("mid plane");
5462        let end_x = end_planes
5463            .iter()
5464            .map(|plane| plane.destination.x)
5465            .min()
5466            .expect("end plane");
5467
5468        assert!(start_x <= mid_x);
5469        assert!(mid_x <= end_x);
5470        assert!(end_x - start_x >= 80);
5471    }
5472
5473    #[test]
5474    fn render_frame_applies_z_rotation_to_event_planes() {
5475        let baseline = parse_script_text("[Script Info]\nPlayResX: 120\nPlayResY: 120\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H00112233,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\an7\\pos(40,40)\\p1}m 0 0 l 40 0 40 10 0 10").expect("script should parse");
5476        let rotated = parse_script_text("[Script Info]\nPlayResX: 120\nPlayResY: 120\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H00112233,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\an7\\pos(40,40)\\frz90\\p1}m 0 0 l 40 0 40 10 0 10").expect("script should parse");
5477        let engine = RenderEngine::new();
5478        let provider = FontconfigProvider::new();
5479        let baseline_planes = engine.render_frame_with_provider(&baseline, &provider, 500);
5480        let rotated_planes = engine.render_frame_with_provider(&rotated, &provider, 500);
5481        let baseline_bounds = character_bounds(&baseline_planes).expect("baseline bounds");
5482        let rotated_bounds = character_bounds(&rotated_planes).expect("rotated bounds");
5483
5484        assert!(baseline_bounds.width() > baseline_bounds.height());
5485        assert!(rotated_bounds.height() > rotated_bounds.width());
5486    }
5487
5488    #[test]
5489    #[ignore = "strict libass positioned-vector overhang coverage residual kept as diagnostic after optimization pivot"]
5490    fn positioned_drawing_uses_position_y_before_compare_supersample_offset() {
5491        let track = parse_script_text("[Script Info]\nPlayResX: 220\nPlayResY: 140\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,28,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\an7\\pos(20,24)\\p1}m 0 0 l 42 0 42 12 0 12{\\p0}").expect("script should parse");
5492        let engine = RenderEngine::new();
5493        let provider = FontconfigProvider::new();
5494        let planes = engine.render_frame_with_provider_and_config(
5495            &track,
5496            &provider,
5497            500,
5498            &RendererConfig {
5499                frame: Size {
5500                    width: 1760,
5501                    height: 1120,
5502                },
5503                storage: Size {
5504                    width: 220,
5505                    height: 140,
5506                },
5507                ..RendererConfig::default()
5508            },
5509        );
5510        let bounds = character_bounds(&planes).expect("positioned drawing bounds");
5511        let visible = visible_bounds(&planes).expect("positioned drawing visible bounds");
5512
5513        assert_eq!(
5514            bounds.y_min,
5515            24 * 8,
5516            "libass keeps top-aligned positioned vector drawings anchored at \\pos y before final supersample offset; got {bounds:?}"
5517        );
5518        assert_eq!(
5519            bounds.x_min,
5520            19 * 8,
5521            "libass gives positioned vector drawings one output-pixel left overhang at compare superscale; got {bounds:?}"
5522        );
5523        assert_eq!(
5524            bounds.x_max,
5525            63 * 8,
5526            "libass keeps the allocated right drawing edge available for transforms; got {bounds:?}"
5527        );
5528        assert_eq!(
5529            visible.x_min,
5530            19 * 8 + 7,
5531            "libass leaves only a subpixel-thin antialias sample in the positioned drawing's left overhang; got visible {visible:?}"
5532        );
5533        assert_eq!(
5534            visible.x_max,
5535            62 * 8 + 1,
5536            "positioned vector drawing keeps a subpixel-thin antialias sample in the allocated right overhang; got visible {visible:?}"
5537        );
5538    }
5539
5540    #[test]
5541    fn render_frame_shears_positioned_drawing_from_run_baseline_not_org() {
5542        let track = parse_script_text("[Script Info]\nPlayResX: 220\nPlayResY: 140\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,28,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\an7\\pos(120,24)\\org(120,80)\\frx45\\fax0.25\\p1}m 0 0 l 50 0 50 14 0 14{\\p0}")
5543            .expect("script should parse");
5544        let engine = RenderEngine::new();
5545        let provider = FontconfigProvider::new();
5546        let planes = engine.render_frame_with_provider(&track, &provider, 500);
5547        let bounds = planes_bounds(&planes).expect("drawing plane should render");
5548
5549        assert!(
5550            bounds.x_min >= 116,
5551            "libass applies \\fax in drawing-local baseline space before \\org perspective; global \\org shear pulls this too far left: {bounds:?}"
5552        );
5553    }
5554
5555    #[test]
5556    fn render_frame_applies_z_rotation_per_override_run() {
5557        let track = parse_script_text("[Script Info]\nPlayResX: 220\nPlayResY: 120\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,32,&H00112233,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\an7\\pos(40,40)\\c&H0000FF&}MMMM{\\frz90\\c&H00FF00&}MMMM").expect("script should parse");
5558        let engine = RenderEngine::new();
5559        let provider = FontconfigProvider::new();
5560        let planes = engine.render_frame_with_provider(&track, &provider, 500);
5561        let red_planes = planes
5562            .iter()
5563            .filter(|plane| plane.kind == ass::ImageType::Character && plane.color.0 == 0xFF00_0000)
5564            .collect::<Vec<_>>();
5565        let green = planes
5566            .iter()
5567            .find(|plane| plane.kind == ass::ImageType::Character && plane.color.0 == 0x00FF_0000)
5568            .expect("rotated green drawing plane");
5569
5570        assert!(
5571            red_planes.len() >= 2,
5572            "expected multiple unrotated red glyph planes"
5573        );
5574        let red_y_min = red_planes
5575            .iter()
5576            .map(|plane| plane.destination.y)
5577            .min()
5578            .expect("red y min");
5579        let red_y_max = red_planes
5580            .iter()
5581            .map(|plane| plane.destination.y)
5582            .max()
5583            .expect("red y max");
5584        assert!(
5585            red_y_max - red_y_min <= 1,
5586            "unrotated run should stay on a horizontal baseline: {red_planes:?}"
5587        );
5588        assert!(
5589            green.size.height >= green.size.width,
5590            "rotated run should become vertical-ish: {green:?}"
5591        );
5592    }
5593
5594    #[test]
5595    fn render_frame_interpolates_z_rotation_transform() {
5596        let track = parse_script_text("[Script Info]\nPlayResX: 120\nPlayResY: 120\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H00112233,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\an7\\pos(40,40)\\t(0,1000,\\frz90)\\p1}m 0 0 l 40 0 40 10 0 10").expect("script should parse");
5597        let engine = RenderEngine::new();
5598        let provider = FontconfigProvider::new();
5599        let start_planes = engine.render_frame_with_provider(&track, &provider, 0);
5600        let end_planes = engine.render_frame_with_provider(&track, &provider, 999);
5601        let start_bounds = character_bounds(&start_planes).expect("start bounds");
5602        let end_bounds = character_bounds(&end_planes).expect("end bounds");
5603
5604        assert!(start_bounds.width() > start_bounds.height());
5605        assert!(end_bounds.height() > end_bounds.width());
5606    }
5607
5608    #[test]
5609    fn render_frame_applies_fad_alpha() {
5610        let track = parse_script_text("[Script Info]\nPlayResX: 200\nPlayResY: 100\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\fad(200,200)}Hi").expect("script should parse");
5611        let engine = RenderEngine::new();
5612        let provider = FontconfigProvider::new();
5613        let start_planes = engine.render_frame_with_provider(&track, &provider, 0);
5614        let mid_planes = engine.render_frame_with_provider(&track, &provider, 500);
5615        let end_planes = engine.render_frame_with_provider(&track, &provider, 999);
5616
5617        let start_alpha = start_planes
5618            .iter()
5619            .map(|plane| plane.color.0 & 0xFF)
5620            .max()
5621            .expect("start alpha");
5622        let mid_alpha = mid_planes
5623            .iter()
5624            .map(|plane| plane.color.0 & 0xFF)
5625            .max()
5626            .expect("mid alpha");
5627        let end_alpha = end_planes
5628            .iter()
5629            .map(|plane| plane.color.0 & 0xFF)
5630            .max()
5631            .expect("end alpha");
5632
5633        assert!(start_alpha > mid_alpha);
5634        assert!(end_alpha > mid_alpha);
5635    }
5636
5637    #[test]
5638    fn render_frame_applies_full_fade_alpha() {
5639        let track = parse_script_text("[Script Info]\nPlayResX: 200\nPlayResY: 100\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H00FFFFFF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\fade(255,0,128,0,200,700,1000)}Hi").expect("script should parse");
5640        let engine = RenderEngine::new();
5641        let provider = FontconfigProvider::new();
5642        let start_planes = engine.render_frame_with_provider(&track, &provider, 0);
5643        let middle_planes = engine.render_frame_with_provider(&track, &provider, 400);
5644        let late_planes = engine.render_frame_with_provider(&track, &provider, 850);
5645
5646        let start_alpha = start_planes
5647            .iter()
5648            .map(|plane| plane.color.0 & 0xFF)
5649            .max()
5650            .expect("start alpha");
5651        let middle_alpha = middle_planes
5652            .iter()
5653            .map(|plane| plane.color.0 & 0xFF)
5654            .max()
5655            .expect("middle alpha");
5656        let late_alpha = late_planes
5657            .iter()
5658            .map(|plane| plane.color.0 & 0xFF)
5659            .max()
5660            .expect("late alpha");
5661
5662        assert!(start_alpha > middle_alpha);
5663        assert!(late_alpha > middle_alpha);
5664        assert!(late_alpha < start_alpha);
5665    }
5666
5667    #[test]
5668    fn render_frame_switches_karaoke_fill_after_elapsed_span() {
5669        let track = parse_script_text("[Script Info]\nPlayResX: 240\nPlayResY: 100\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H00112233,&H00445566,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:02.00,Default,,0000,0000,0000,,{\\an7\\pos(20,20)\\k50}Ka").expect("script should parse");
5670        let engine = RenderEngine::new();
5671        let provider = FontconfigProvider::new();
5672        let early_planes = engine.render_frame_with_provider(&track, &provider, 200);
5673        let late_planes = engine.render_frame_with_provider(&track, &provider, 700);
5674
5675        assert!(
5676            early_planes.iter().any(
5677                |plane| plane.kind == ass::ImageType::Character && plane.color.0 == 0x6655_4400
5678            )
5679        );
5680        assert!(
5681            late_planes.iter().any(
5682                |plane| plane.kind == ass::ImageType::Character && plane.color.0 == 0x3322_1100
5683            )
5684        );
5685    }
5686
5687    #[test]
5688    fn render_frame_sweeps_karaoke_fill_during_active_span() {
5689        let track = parse_script_text("[Script Info]\nPlayResX: 240\nPlayResY: 100\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H00112233,&H00445566,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:02.00,Default,,0000,0000,0000,,{\\an7\\pos(20,20)\\K100}Kara").expect("script should parse");
5690        let engine = RenderEngine::new();
5691        let provider = FontconfigProvider::new();
5692        let mid_planes = engine.render_frame_with_provider(&track, &provider, 500);
5693
5694        assert!(
5695            mid_planes.iter().any(
5696                |plane| plane.kind == ass::ImageType::Character && plane.color.0 == 0x3322_1100
5697            )
5698        );
5699        assert!(
5700            mid_planes.iter().any(
5701                |plane| plane.kind == ass::ImageType::Character && plane.color.0 == 0x6655_4400
5702            )
5703        );
5704    }
5705
5706    #[test]
5707    fn render_frame_hides_outline_for_ko_until_span_ends() {
5708        let track = parse_script_text("[Script Info]\nPlayResX: 240\nPlayResY: 100\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H00112233,&H00445566,&H000A0B0C,&H00000000,0,0,0,0,100,100,0,0,1,2,0,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:02.00,Default,,0000,0000,0000,,{\\an7\\pos(20,20)\\ko50}Ko").expect("script should parse");
5709        let engine = RenderEngine::new();
5710        let provider = FontconfigProvider::new();
5711        let early_planes = engine.render_frame_with_provider(&track, &provider, 200);
5712        let late_planes = engine.render_frame_with_provider(&track, &provider, 700);
5713
5714        assert!(
5715            !early_planes
5716                .iter()
5717                .any(|plane| plane.kind == ass::ImageType::Outline)
5718        );
5719        assert!(
5720            late_planes
5721                .iter()
5722                .any(|plane| plane.kind == ass::ImageType::Outline)
5723        );
5724    }
5725
5726    #[test]
5727    fn render_frame_renders_drawing_plane() {
5728        let track = parse_script_text("[Script Info]\nPlayResX: 100\nPlayResY: 100\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H00112233,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\an7\\pos(10,10)\\p1}m 0 0 l 8 0 8 8 0 8").expect("script should parse");
5729        let engine = RenderEngine::new();
5730        let provider = FontconfigProvider::new();
5731        let planes = engine.render_frame_with_provider(&track, &provider, 500);
5732
5733        assert!(
5734            planes.iter().any(
5735                |plane| plane.kind == ass::ImageType::Character && plane.color.0 == 0x3322_1100
5736            )
5737        );
5738        let plane = planes
5739            .iter()
5740            .find(|plane| plane.kind == ass::ImageType::Character)
5741            .expect("drawing plane");
5742        assert_eq!(plane.destination.x, 10);
5743        assert_eq!(plane.destination.y, 10);
5744        assert!(plane.bitmap.contains(&255));
5745    }
5746
5747    #[test]
5748    fn render_frame_renders_bezier_drawing_plane() {
5749        let track = parse_script_text("[Script Info]\nPlayResX: 100\nPlayResY: 100\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H00112233,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\an7\\pos(10,10)\\p1}m 0 0 b 10 0 10 10 0 10").expect("script should parse");
5750        let engine = RenderEngine::new();
5751        let provider = FontconfigProvider::new();
5752        let planes = engine.render_frame_with_provider(&track, &provider, 500);
5753
5754        let plane = planes
5755            .iter()
5756            .find(|plane| plane.kind == ass::ImageType::Character)
5757            .expect("drawing plane");
5758        assert!(plane.bitmap.contains(&255));
5759        assert!(plane.size.width >= 8);
5760        assert!(plane.size.height >= 8);
5761    }
5762
5763    #[test]
5764    fn render_frame_emits_outline_and_shadow_for_drawings() {
5765        let track = parse_script_text("[Script Info]\nPlayResX: 100\nPlayResY: 100\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H00112233,&H0000FFFF,&H000A0B0C,&H00445566,0,0,0,0,100,100,0,0,1,2,3,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\an7\\pos(10,10)\\p1}m 0 0 l 8 0 8 8 0 8").expect("script should parse");
5766        let engine = RenderEngine::new();
5767        let provider = FontconfigProvider::new();
5768        let planes = engine.render_frame_with_provider(&track, &provider, 500);
5769
5770        assert!(
5771            planes
5772                .iter()
5773                .any(|plane| plane.kind == ass::ImageType::Outline && plane.color.0 == 0x0C0B_0A00)
5774        );
5775        assert!(
5776            planes
5777                .iter()
5778                .any(|plane| plane.kind == ass::ImageType::Shadow && plane.color.0 == 0x6655_4400)
5779        );
5780    }
5781
5782    #[test]
5783    fn render_frame_renders_spline_drawing_plane() {
5784        let track = parse_script_text("[Script Info]\nPlayResX: 120\nPlayResY: 120\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H00112233,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\an7\\pos(10,10)\\p1}m 0 0 s 10 0 10 10 0 10 p -5 5 c").expect("script should parse");
5785        let engine = RenderEngine::new();
5786        let provider = FontconfigProvider::new();
5787        let planes = engine.render_frame_with_provider(&track, &provider, 500);
5788
5789        let plane = planes
5790            .iter()
5791            .find(|plane| plane.kind == ass::ImageType::Character)
5792            .expect("drawing plane");
5793        assert!(plane.bitmap.contains(&255));
5794        assert!(plane.size.width >= 10);
5795        assert!(plane.size.height >= 10);
5796    }
5797
5798    #[test]
5799    fn render_frame_renders_non_closing_move_subpaths() {
5800        let track = parse_script_text("[Script Info]\nPlayResX: 120\nPlayResY: 120\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H00112233,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\an7\\pos(10,10)\\p1}m 0 0 l 8 0 8 8 0 8 n 20 20 l 28 20 28 28 20 28").expect("script should parse");
5801        let engine = RenderEngine::new();
5802        let provider = FontconfigProvider::new();
5803        let planes = engine.render_frame_with_provider(&track, &provider, 500);
5804
5805        let plane = planes
5806            .iter()
5807            .find(|plane| plane.kind == ass::ImageType::Character)
5808            .expect("drawing plane");
5809        assert!(plane.bitmap.contains(&255));
5810        assert!(plane.size.width >= 28);
5811        assert!(plane.size.height >= 28);
5812    }
5813
5814    #[test]
5815    fn render_frame_applies_timed_transform_style() {
5816        let track = parse_script_text("[Script Info]\nPlayResX: 160\nPlayResY: 120\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,sans,24,&H000000FF,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0000,0000,0000,,{\\an7\\pos(10,10)\\t(0,1000,\\1c&H00112233&\\fs48\\bord4)}Hi").expect("script should parse");
5817        let engine = RenderEngine::new();
5818        let provider = FontconfigProvider::new();
5819        let start_planes = engine.render_frame_with_provider(&track, &provider, 0);
5820        let mid_planes = engine.render_frame_with_provider(&track, &provider, 500);
5821        let end_planes = engine.render_frame_with_provider(&track, &provider, 999);
5822
5823        assert!(
5824            !start_planes
5825                .iter()
5826                .any(|plane| plane.kind == ass::ImageType::Outline)
5827        );
5828        assert!(
5829            mid_planes
5830                .iter()
5831                .any(|plane| plane.kind == ass::ImageType::Outline)
5832        );
5833        assert!(
5834            end_planes
5835                .iter()
5836                .any(|plane| plane.kind == ass::ImageType::Outline)
5837        );
5838
5839        let start_fill = start_planes
5840            .iter()
5841            .find(|plane| plane.kind == ass::ImageType::Character)
5842            .expect("start fill")
5843            .color
5844            .0;
5845        let end_fill = end_planes
5846            .iter()
5847            .find(|plane| plane.kind == ass::ImageType::Character)
5848            .expect("end fill")
5849            .color
5850            .0;
5851        assert_ne!(start_fill, end_fill);
5852        assert!(total_plane_area(&end_planes) > total_plane_area(&start_planes));
5853    }
5854}