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::with_capacity(if inverse {
3475        planes.len().saturating_mul(2)
3476    } else {
3477        planes.len()
3478    });
3479    for plane in planes {
3480        if inverse {
3481            clipped.extend(inverse_clip_plane(plane, clip_rect));
3482        } else if let Some(plane) = clip_plane(plane, clip_rect) {
3483            clipped.push(plane);
3484        }
3485    }
3486    clipped
3487}
3488
3489fn apply_vector_clip(
3490    planes: Vec<ImagePlane>,
3491    clip: &ParsedVectorClip,
3492    inverse: bool,
3493) -> Vec<ImagePlane> {
3494    planes
3495        .into_iter()
3496        .filter_map(|plane| mask_plane_with_vector_clip(plane, clip, inverse))
3497        .collect()
3498}
3499
3500fn mask_plane_with_vector_clip(
3501    plane: ImagePlane,
3502    clip: &ParsedVectorClip,
3503    inverse: bool,
3504) -> Option<ImagePlane> {
3505    let mut bitmap = plane.bitmap.clone();
3506    let stride = plane.stride as usize;
3507    let mut any_visible = false;
3508
3509    for row in 0..plane.size.height as usize {
3510        for column in 0..plane.size.width as usize {
3511            let global_x = plane.destination.x + column as i32;
3512            let global_y = plane.destination.y + row as i32;
3513            let inside = clip
3514                .polygons
3515                .iter()
3516                .any(|polygon| point_in_polygon(global_x, global_y, polygon));
3517            let keep = if inverse { !inside } else { inside };
3518            if !keep {
3519                bitmap[row * stride + column] = 0;
3520            } else if bitmap[row * stride + column] > 0 {
3521                any_visible = true;
3522            }
3523        }
3524    }
3525
3526    any_visible.then_some(ImagePlane { bitmap, ..plane })
3527}
3528
3529fn point_in_polygon(x: i32, y: i32, polygon: &[Point]) -> bool {
3530    if polygon.len() < 3 {
3531        return false;
3532    }
3533
3534    let mut inside = false;
3535    let mut previous = polygon[polygon.len() - 1];
3536    let sample_x = x as f64 + 0.5;
3537    let sample_y = y as f64 + 0.5;
3538
3539    for &current in polygon {
3540        let current_y = current.y as f64;
3541        let previous_y = previous.y as f64;
3542        let intersects = (current_y > sample_y) != (previous_y > sample_y);
3543        if intersects {
3544            let current_x = current.x as f64;
3545            let previous_x = previous.x as f64;
3546            let x_intersection = (previous_x - current_x) * (sample_y - current_y)
3547                / (previous_y - current_y)
3548                + current_x;
3549            if sample_x < x_intersection {
3550                inside = !inside;
3551            }
3552        }
3553        previous = current;
3554    }
3555
3556    inside
3557}
3558
3559fn clip_plane(plane: ImagePlane, clip_rect: Rect) -> Option<ImagePlane> {
3560    let plane_rect = plane_rect(&plane);
3561    let intersection = plane_rect.intersect(clip_rect)?;
3562    if intersection == plane_rect {
3563        return Some(plane);
3564    }
3565    crop_plane_to_rect(plane, intersection)
3566}
3567
3568fn inverse_clip_plane(plane: ImagePlane, clip_rect: Rect) -> Vec<ImagePlane> {
3569    let plane_rect = plane_rect(&plane);
3570    let Some(intersection) = plane_rect.intersect(clip_rect) else {
3571        return vec![plane];
3572    };
3573
3574    let mut result = Vec::new();
3575    let regions = [
3576        Rect {
3577            x_min: plane_rect.x_min,
3578            y_min: plane_rect.y_min,
3579            x_max: plane_rect.x_max,
3580            y_max: intersection.y_min,
3581        },
3582        Rect {
3583            x_min: plane_rect.x_min,
3584            y_min: intersection.y_max,
3585            x_max: plane_rect.x_max,
3586            y_max: plane_rect.y_max,
3587        },
3588        Rect {
3589            x_min: plane_rect.x_min,
3590            y_min: intersection.y_min,
3591            x_max: intersection.x_min,
3592            y_max: intersection.y_max,
3593        },
3594        Rect {
3595            x_min: intersection.x_max,
3596            y_min: intersection.y_min,
3597            x_max: plane_rect.x_max,
3598            y_max: intersection.y_max,
3599        },
3600    ];
3601    for region in regions {
3602        if region.is_empty() {
3603            continue;
3604        }
3605        if let Some(cropped) = crop_plane_to_rect(plane.clone(), region) {
3606            result.push(cropped);
3607        }
3608    }
3609    result
3610}
3611
3612fn plane_rect(plane: &ImagePlane) -> Rect {
3613    Rect {
3614        x_min: plane.destination.x,
3615        y_min: plane.destination.y,
3616        x_max: plane.destination.x + plane.size.width,
3617        y_max: plane.destination.y + plane.size.height,
3618    }
3619}
3620
3621fn crop_plane_to_rect(plane: ImagePlane, rect: Rect) -> Option<ImagePlane> {
3622    let plane_rect = plane_rect(&plane);
3623    let rect = plane_rect.intersect(rect)?;
3624    if rect == plane_rect {
3625        return Some(plane);
3626    }
3627    let offset_x = (rect.x_min - plane_rect.x_min) as usize;
3628    let offset_y = (rect.y_min - plane_rect.y_min) as usize;
3629    let width = rect.width() as usize;
3630    let height = rect.height() as usize;
3631    let src_stride = plane.stride as usize;
3632    let mut bitmap = Vec::with_capacity(width * height);
3633
3634    for row in 0..height {
3635        let start = (offset_y + row) * src_stride + offset_x;
3636        bitmap.extend_from_slice(&plane.bitmap[start..start + width]);
3637    }
3638
3639    Some(ImagePlane {
3640        size: Size {
3641            width: rect.width(),
3642            height: rect.height(),
3643        },
3644        stride: rect.width(),
3645        destination: Point {
3646            x: rect.x_min,
3647            y: rect.y_min,
3648        },
3649        bitmap,
3650        ..plane
3651    })
3652}
3653fn is_event_active(event: &ParsedEvent, now_ms: i64) -> bool {
3654    now_ms >= event.start && now_ms < event.start + event.duration
3655}
3656
3657#[cfg(test)]
3658mod tests {
3659    use super::*;
3660    use rassa_fonts::{FontconfigProvider, NullFontProvider};
3661    use rassa_parse::parse_script_text;
3662
3663    fn config(
3664        frame_width: i32,
3665        frame_height: i32,
3666        margins: rassa_core::Margins,
3667        use_margins: bool,
3668    ) -> RendererConfig {
3669        RendererConfig {
3670            frame: Size {
3671                width: frame_width,
3672                height: frame_height,
3673            },
3674            margins,
3675            use_margins,
3676            ..RendererConfig::default()
3677        }
3678    }
3679
3680    fn total_plane_area(planes: &[ImagePlane]) -> i32 {
3681        planes
3682            .iter()
3683            .map(|plane| plane.size.width * plane.size.height)
3684            .sum()
3685    }
3686
3687    #[test]
3688    fn fad_uses_libass_truncating_alpha_interpolation() {
3689        let event = ParsedEvent {
3690            start: 0,
3691            duration: 4000,
3692            ..ParsedEvent::default()
3693        };
3694
3695        assert_eq!(
3696            compute_fad_alpha(
3697                ParsedFade::Simple {
3698                    fade_in_ms: 1000,
3699                    fade_out_ms: 1000,
3700                },
3701                Some(&event),
3702                500,
3703            ),
3704            127
3705        );
3706        assert_eq!(
3707            compute_fad_alpha(
3708                ParsedFade::Simple {
3709                    fade_in_ms: 1000,
3710                    fade_out_ms: 1000,
3711                },
3712                Some(&event),
3713                3500,
3714            ),
3715            127
3716        );
3717    }
3718
3719    #[test]
3720    fn fad_uses_libass_wrapping_out_start_when_fade_out_exceeds_duration() {
3721        let event = ParsedEvent {
3722            start: 0,
3723            duration: 800,
3724            ..ParsedEvent::default()
3725        };
3726
3727        assert_eq!(
3728            compute_fad_alpha(
3729                ParsedFade::Simple {
3730                    fade_in_ms: 100,
3731                    fade_out_ms: 1000,
3732                },
3733                Some(&event),
3734                100,
3735            ),
3736            76
3737        );
3738        assert_eq!(
3739            compute_fad_alpha(
3740                ParsedFade::Simple {
3741                    fade_in_ms: 100,
3742                    fade_out_ms: 1000,
3743                },
3744                Some(&event),
3745                400,
3746            ),
3747            153
3748        );
3749    }
3750
3751    #[test]
3752    fn fade_alpha_combines_with_existing_colour_alpha() {
3753        assert_eq!(with_fade_alpha(0xFF00_0080, 0), 0xFF00_0080);
3754        assert_eq!(with_fade_alpha(0xFF00_0000, 127), 0xFF00_007F);
3755        assert_eq!(with_fade_alpha(0xFF00_0080, 127), 0xFF00_00BF);
3756    }
3757
3758    fn vertical_span(planes: &[ImagePlane]) -> i32 {
3759        let min_y = planes
3760            .iter()
3761            .map(|plane| plane.destination.y)
3762            .min()
3763            .expect("plane");
3764        let max_y = planes
3765            .iter()
3766            .map(|plane| plane.destination.y + plane.size.height)
3767            .max()
3768            .expect("plane");
3769        max_y - min_y
3770    }
3771
3772    fn kind_bounds(planes: &[ImagePlane], kind: ass::ImageType) -> Option<Rect> {
3773        let mut matching_planes = planes.iter().filter(|plane| plane.kind == kind);
3774        let first = matching_planes.next()?;
3775        let mut bounds = Rect {
3776            x_min: first.destination.x,
3777            y_min: first.destination.y,
3778            x_max: first.destination.x + first.size.width,
3779            y_max: first.destination.y + first.size.height,
3780        };
3781        for plane in matching_planes {
3782            bounds.x_min = bounds.x_min.min(plane.destination.x);
3783            bounds.y_min = bounds.y_min.min(plane.destination.y);
3784            bounds.x_max = bounds.x_max.max(plane.destination.x + plane.size.width);
3785            bounds.y_max = bounds.y_max.max(plane.destination.y + plane.size.height);
3786        }
3787        Some(bounds)
3788    }
3789
3790    fn character_bounds(planes: &[ImagePlane]) -> Option<Rect> {
3791        kind_bounds(planes, ass::ImageType::Character)
3792    }
3793
3794    fn visible_bounds(planes: &[ImagePlane]) -> Option<Rect> {
3795        let mut bounds: Option<Rect> = None;
3796        for plane in planes {
3797            let stride = plane.stride.max(0) as usize;
3798            if stride == 0 {
3799                continue;
3800            }
3801            for y in 0..plane.size.height.max(0) as usize {
3802                for x in 0..plane.size.width.max(0) as usize {
3803                    if plane.bitmap[y * stride + x] == 0 {
3804                        continue;
3805                    }
3806                    let px = plane.destination.x + x as i32;
3807                    let py = plane.destination.y + y as i32;
3808                    match &mut bounds {
3809                        Some(rect) => {
3810                            rect.x_min = rect.x_min.min(px);
3811                            rect.y_min = rect.y_min.min(py);
3812                            rect.x_max = rect.x_max.max(px + 1);
3813                            rect.y_max = rect.y_max.max(py + 1);
3814                        }
3815                        None => {
3816                            bounds = Some(Rect {
3817                                x_min: px,
3818                                y_min: py,
3819                                x_max: px + 1,
3820                                y_max: py + 1,
3821                            });
3822                        }
3823                    }
3824                }
3825            }
3826        }
3827        bounds
3828    }
3829
3830    fn drawing_alignment_script(
3831        alignment: i32,
3832        override_tags: &str,
3833        event_margins: &str,
3834    ) -> String {
3835        format!(
3836            "[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"
3837        )
3838    }
3839
3840    fn render_drawing_bounds(script: &str) -> Rect {
3841        let track = parse_script_text(script).expect("alignment probe script should parse");
3842        let engine = RenderEngine::new();
3843        let provider = NullFontProvider;
3844        let planes = engine.render_frame_with_provider(&track, &provider, 500);
3845        visible_bounds(&planes).expect("drawing probe should produce visible pixels")
3846    }
3847
3848    fn text_alignment_script(alignment: i32, event_margins: &str) -> String {
3849        format!(
3850            "[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"
3851        )
3852    }
3853
3854    fn render_text_bounds(script: &str) -> Option<Rect> {
3855        let track = parse_script_text(script).expect("text alignment probe script should parse");
3856        let engine = RenderEngine::new();
3857        let provider = FontconfigProvider::new();
3858        let planes = engine.render_frame_with_provider(&track, &provider, 500);
3859        visible_bounds(&planes)
3860    }
3861
3862    fn render_text_bounds_with_config(script: &str, config: &RendererConfig) -> Option<Rect> {
3863        let track = parse_script_text(script).expect("text alignment probe script should parse");
3864        let engine = RenderEngine::new();
3865        let provider = FontconfigProvider::new();
3866        let planes = engine.render_frame_with_provider_and_config(&track, &provider, 500, config);
3867        visible_bounds(&planes)
3868    }
3869
3870    #[test]
3871    fn downscaled_positioned_text_scales_font_and_anchor_like_libass() {
3872        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";
3873        let config = RendererConfig {
3874            frame: Size {
3875                width: 320,
3876                height: 180,
3877            },
3878            storage: Size {
3879                width: 320,
3880                height: 180,
3881            },
3882            pixel_aspect: 1.0,
3883            shaping: ass::ShapingLevel::Complex,
3884            ..Default::default()
3885        };
3886        let actual = render_text_bounds_with_config(script, &config)
3887            .expect("positioned text should render in downscaled frame");
3888        let expected = Rect {
3889            x_min: 141,
3890            y_min: 83,
3891            x_max: 179,
3892            y_max: 97,
3893        };
3894
3895        assert!(
3896            (actual.x_min - expected.x_min).abs() <= 2
3897                && (actual.y_min - expected.y_min).abs() <= 1,
3898            "downscaled \\pos anchor should stay in libass position: actual={actual:?} expected={expected:?}"
3899        );
3900        assert!(
3901            (actual.width() - expected.width()).abs() <= 2
3902                && (actual.height() - expected.height()).abs() <= 2,
3903            "downscaled \\pos text must scale glyph dimensions like libass: actual={actual:?} expected={expected:?}"
3904        );
3905    }
3906
3907    #[test]
3908    fn borderstyle3_opaque_box_follows_text_transform() {
3909        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";
3910        let track = parse_script_text(script).expect("borderstyle transform script should parse");
3911        let engine = RenderEngine::new();
3912        let provider = FontconfigProvider::new();
3913        let planes = engine.render_frame_with_provider(&track, &provider, 500);
3914        let box_bounds = kind_bounds(&planes, ass::ImageType::Outline)
3915            .expect("BorderStyle=3 should emit an opaque box outline plane");
3916
3917        assert!(
3918            box_bounds.height() > 90,
3919            "opaque box must be transformed with the rotated/sheared text, got bounds {box_bounds:?}"
3920        );
3921    }
3922
3923    #[test]
3924    fn positioned_drawing_an_anchors_match_libass_for_all_alignments() {
3925        // Expected boxes were probed from libass/ffmpeg for a 40x20 vector drawing at \pos(x,y):
3926        // bottom align => y - 20, middle align => y - 10, top align => y.
3927        let cases = [
3928            (
3929                1,
3930                "\\an1\\pos(60,60)",
3931                Rect {
3932                    x_min: 60,
3933                    y_min: 40,
3934                    x_max: 100,
3935                    y_max: 60,
3936                },
3937            ),
3938            (
3939                2,
3940                "\\an2\\pos(160,60)",
3941                Rect {
3942                    x_min: 140,
3943                    y_min: 40,
3944                    x_max: 180,
3945                    y_max: 60,
3946                },
3947            ),
3948            (
3949                3,
3950                "\\an3\\pos(260,60)",
3951                Rect {
3952                    x_min: 220,
3953                    y_min: 40,
3954                    x_max: 260,
3955                    y_max: 60,
3956                },
3957            ),
3958            (
3959                4,
3960                "\\an4\\pos(60,100)",
3961                Rect {
3962                    x_min: 60,
3963                    y_min: 90,
3964                    x_max: 100,
3965                    y_max: 110,
3966                },
3967            ),
3968            (
3969                5,
3970                "\\an5\\pos(160,100)",
3971                Rect {
3972                    x_min: 140,
3973                    y_min: 90,
3974                    x_max: 180,
3975                    y_max: 110,
3976                },
3977            ),
3978            (
3979                6,
3980                "\\an6\\pos(260,100)",
3981                Rect {
3982                    x_min: 220,
3983                    y_min: 90,
3984                    x_max: 260,
3985                    y_max: 110,
3986                },
3987            ),
3988            (
3989                7,
3990                "\\an7\\pos(60,140)",
3991                Rect {
3992                    x_min: 60,
3993                    y_min: 140,
3994                    x_max: 100,
3995                    y_max: 160,
3996                },
3997            ),
3998            (
3999                8,
4000                "\\an8\\pos(160,140)",
4001                Rect {
4002                    x_min: 140,
4003                    y_min: 140,
4004                    x_max: 180,
4005                    y_max: 160,
4006                },
4007            ),
4008            (
4009                9,
4010                "\\an9\\pos(260,140)",
4011                Rect {
4012                    x_min: 220,
4013                    y_min: 140,
4014                    x_max: 260,
4015                    y_max: 160,
4016                },
4017            ),
4018        ];
4019
4020        for (alignment, override_tags, expected) in cases {
4021            let script = drawing_alignment_script(alignment, override_tags, "0,0,0");
4022            assert_eq!(
4023                render_drawing_bounds(&script),
4024                expected,
4025                "\\an{alignment} positioned drawing anchor should match libass"
4026            );
4027        }
4028    }
4029
4030    #[test]
4031    fn moved_drawing_an_anchors_match_libass_for_all_alignments_at_midpoint() {
4032        let cases = [
4033            (
4034                1,
4035                "\\an1\\move(40,60,80,60)",
4036                Rect {
4037                    x_min: 60,
4038                    y_min: 40,
4039                    x_max: 100,
4040                    y_max: 60,
4041                },
4042            ),
4043            (
4044                2,
4045                "\\an2\\move(140,60,180,60)",
4046                Rect {
4047                    x_min: 140,
4048                    y_min: 40,
4049                    x_max: 180,
4050                    y_max: 60,
4051                },
4052            ),
4053            (
4054                3,
4055                "\\an3\\move(240,60,280,60)",
4056                Rect {
4057                    x_min: 220,
4058                    y_min: 40,
4059                    x_max: 260,
4060                    y_max: 60,
4061                },
4062            ),
4063            (
4064                4,
4065                "\\an4\\move(40,100,80,100)",
4066                Rect {
4067                    x_min: 60,
4068                    y_min: 90,
4069                    x_max: 100,
4070                    y_max: 110,
4071                },
4072            ),
4073            (
4074                5,
4075                "\\an5\\move(140,100,180,100)",
4076                Rect {
4077                    x_min: 140,
4078                    y_min: 90,
4079                    x_max: 180,
4080                    y_max: 110,
4081                },
4082            ),
4083            (
4084                6,
4085                "\\an6\\move(240,100,280,100)",
4086                Rect {
4087                    x_min: 220,
4088                    y_min: 90,
4089                    x_max: 260,
4090                    y_max: 110,
4091                },
4092            ),
4093            (
4094                7,
4095                "\\an7\\move(40,140,80,140)",
4096                Rect {
4097                    x_min: 60,
4098                    y_min: 140,
4099                    x_max: 100,
4100                    y_max: 160,
4101                },
4102            ),
4103            (
4104                8,
4105                "\\an8\\move(140,140,180,140)",
4106                Rect {
4107                    x_min: 140,
4108                    y_min: 140,
4109                    x_max: 180,
4110                    y_max: 160,
4111                },
4112            ),
4113            (
4114                9,
4115                "\\an9\\move(240,140,280,140)",
4116                Rect {
4117                    x_min: 220,
4118                    y_min: 140,
4119                    x_max: 260,
4120                    y_max: 160,
4121                },
4122            ),
4123        ];
4124
4125        for (alignment, override_tags, expected) in cases {
4126            let script = drawing_alignment_script(alignment, override_tags, "0,0,0");
4127            assert_eq!(
4128                render_drawing_bounds(&script),
4129                expected,
4130                "\\an{alignment} moved drawing anchor should match libass at the event midpoint"
4131            );
4132        }
4133    }
4134
4135    #[test]
4136    fn margin_positioned_text_uses_style_and_event_margins_like_libass() {
4137        let cases = [
4138            (
4139                1,
4140                "0,0,0",
4141                Rect {
4142                    x_min: 32,
4143                    y_min: 138,
4144                    x_max: 116,
4145                    y_max: 165,
4146                },
4147            ),
4148            (
4149                2,
4150                "0,0,0",
4151                Rect {
4152                    x_min: 108,
4153                    y_min: 138,
4154                    x_max: 192,
4155                    y_max: 165,
4156                },
4157            ),
4158            (
4159                3,
4160                "0,0,0",
4161                Rect {
4162                    x_min: 184,
4163                    y_min: 138,
4164                    x_max: 269,
4165                    y_max: 165,
4166                },
4167            ),
4168            (
4169                5,
4170                "0,0,0",
4171                Rect {
4172                    x_min: 108,
4173                    y_min: 79,
4174                    x_max: 192,
4175                    y_max: 106,
4176                },
4177            ),
4178            (
4179                7,
4180                "0,0,0",
4181                Rect {
4182                    x_min: 32,
4183                    y_min: 20,
4184                    x_max: 116,
4185                    y_max: 47,
4186                },
4187            ),
4188            (
4189                8,
4190                "0,0,0",
4191                Rect {
4192                    x_min: 108,
4193                    y_min: 20,
4194                    x_max: 192,
4195                    y_max: 47,
4196                },
4197            ),
4198            (
4199                9,
4200                "7,9,11",
4201                Rect {
4202                    x_min: 225,
4203                    y_min: 16,
4204                    x_max: 310,
4205                    y_max: 43,
4206                },
4207            ),
4208        ];
4209
4210        for (alignment, event_margins, expected) in cases {
4211            let script = text_alignment_script(alignment, event_margins);
4212            let Some(actual) = render_text_bounds(&script) else {
4213                return;
4214            };
4215            // Text rasterization can have a few pixels of coverage-width drift from libass even
4216            // with the same Fontconfig face. This regression guards the placement bug: the
4217            // effective style/event margin anchor must no longer be shifted left or sunk.
4218            assert!(
4219                (actual.x_min - expected.x_min).abs() <= 1,
4220                "text style/event margins and \\an{alignment} x placement should match libass within raster rounding: actual={actual:?} expected={expected:?}"
4221            );
4222            assert_eq!(
4223                (actual.y_min, actual.y_max),
4224                (expected.y_min, expected.y_max),
4225                "text style/event margins and \\an{alignment} vertical placement should match libass"
4226            );
4227        }
4228    }
4229
4230    #[test]
4231    fn margin_positioned_drawing_uses_style_and_event_margins_like_libass() {
4232        // Expected boxes were probed from libass/ffmpeg for a 40x20 vector drawing with
4233        // style margins L=30/R=50/V=15. Event margins of 0 should fall back to style margins.
4234        let cases = [
4235            (
4236                1,
4237                Rect {
4238                    x_min: 30,
4239                    y_min: 145,
4240                    x_max: 70,
4241                    y_max: 165,
4242                },
4243            ),
4244            (
4245                2,
4246                Rect {
4247                    x_min: 130,
4248                    y_min: 145,
4249                    x_max: 170,
4250                    y_max: 165,
4251                },
4252            ),
4253            (
4254                3,
4255                Rect {
4256                    x_min: 230,
4257                    y_min: 145,
4258                    x_max: 270,
4259                    y_max: 165,
4260                },
4261            ),
4262            (
4263                4,
4264                Rect {
4265                    x_min: 30,
4266                    y_min: 80,
4267                    x_max: 70,
4268                    y_max: 100,
4269                },
4270            ),
4271            (
4272                5,
4273                Rect {
4274                    x_min: 130,
4275                    y_min: 80,
4276                    x_max: 170,
4277                    y_max: 100,
4278                },
4279            ),
4280            (
4281                6,
4282                Rect {
4283                    x_min: 230,
4284                    y_min: 80,
4285                    x_max: 270,
4286                    y_max: 100,
4287                },
4288            ),
4289            (
4290                7,
4291                Rect {
4292                    x_min: 30,
4293                    y_min: 15,
4294                    x_max: 70,
4295                    y_max: 35,
4296                },
4297            ),
4298            (
4299                8,
4300                Rect {
4301                    x_min: 130,
4302                    y_min: 15,
4303                    x_max: 170,
4304                    y_max: 35,
4305                },
4306            ),
4307            (
4308                9,
4309                Rect {
4310                    x_min: 230,
4311                    y_min: 15,
4312                    x_max: 270,
4313                    y_max: 35,
4314                },
4315            ),
4316        ];
4317
4318        for (alignment, expected) in cases {
4319            let script = drawing_alignment_script(alignment, "", "0,0,0");
4320            assert_eq!(
4321                render_drawing_bounds(&script),
4322                expected,
4323                "style margins and \\an{alignment} should match libass when no explicit position exists"
4324            );
4325        }
4326
4327        let script = drawing_alignment_script(7, "", "7,9,11");
4328        assert_eq!(
4329            render_drawing_bounds(&script),
4330            Rect {
4331                x_min: 7,
4332                y_min: 11,
4333                x_max: 47,
4334                y_max: 31
4335            },
4336            "non-zero event margins should override style margins for top-left alignment"
4337        );
4338    }
4339
4340    #[test]
4341    fn projective_transform_keeps_frx_and_fry_axes_distinct() {
4342        let origin = (320.0, 180.0);
4343        let frx = ProjectiveMatrix::from_ass_transform_at_origin(
4344            EventTransform {
4345                rotation_x: 45.0,
4346                ..EventTransform::default()
4347            },
4348            origin.0,
4349            origin.1,
4350            1.0,
4351        );
4352        let fry = ProjectiveMatrix::from_ass_transform_at_origin(
4353            EventTransform {
4354                rotation_y: 45.0,
4355                ..EventTransform::default()
4356            },
4357            origin.0,
4358            origin.1,
4359            1.0,
4360        );
4361
4362        let (frx_x, frx_y) = frx.transform_point(320.0, 140.0);
4363        let (fry_x, fry_y) = fry.transform_point(360.0, 180.0);
4364
4365        assert!(
4366            (frx_x - 320.0).abs() < 0.5,
4367            "frx must not act like fry: {frx_x}"
4368        );
4369        assert!(
4370            frx_y > 140.0,
4371            "positive frx should pitch the top edge downward: {frx_y}"
4372        );
4373        assert!(
4374            fry_x < 360.0,
4375            "positive fry should yaw the right edge leftward: {fry_x}"
4376        );
4377        assert!(
4378            (fry_y - 180.0).abs() < 0.5,
4379            "fry must not act like frx: {fry_y}"
4380        );
4381    }
4382
4383    #[test]
4384    fn projective_transform_uses_deep_org_as_perspective_lever_arm() {
4385        let transform = EventTransform {
4386            rotation_x: 55.0,
4387            ..EventTransform::default()
4388        };
4389        let shallow = ProjectiveMatrix::from_ass_transform_at_origin(transform, 320.0, 240.0, 1.0);
4390        let deep = ProjectiveMatrix::from_ass_transform_at_origin(transform, 320.0, 420.0, 1.0);
4391
4392        let (_, shallow_y) = shallow.transform_point(320.0, 240.0);
4393        let (_, deep_y) = deep.transform_point(320.0, 240.0);
4394
4395        assert!((shallow_y - 240.0).abs() < 0.5);
4396        assert!(
4397            deep_y > shallow_y + 70.0,
4398            "deep \\org below text should pull frx text substantially downward like libass, got shallow={shallow_y} deep={deep_y}"
4399        );
4400    }
4401
4402    #[test]
4403    fn prepare_frame_only_keeps_active_events() {
4404        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");
4405        let engine = RenderEngine::new();
4406        let provider = NullFontProvider;
4407        let frame = engine.prepare_frame(&track, &provider, 500);
4408
4409        assert_eq!(frame.active_events.len(), 1);
4410        assert_eq!(frame.active_events[0].text, "First");
4411    }
4412
4413    #[test]
4414    fn render_frame_produces_image_planes_for_active_text() {
4415        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");
4416        let engine = RenderEngine::new();
4417        let provider = FontconfigProvider::new();
4418        let planes = engine.render_frame_with_provider(&track, &provider, 500);
4419
4420        assert!(!planes.is_empty());
4421        assert!(planes.iter().all(|plane| plane.size.width >= 0));
4422        assert!(planes.iter().all(|plane| plane.size.height >= 0));
4423    }
4424
4425    #[test]
4426    fn render_frame_supports_multiple_override_runs() {
4427        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");
4428        let engine = RenderEngine::new();
4429        let provider = FontconfigProvider::new();
4430        let planes = engine.render_frame_with_provider(&track, &provider, 500);
4431
4432        assert!(!planes.is_empty());
4433    }
4434
4435    #[test]
4436    fn render_frame_uses_axis_specific_shadow_offsets() {
4437        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");
4438        let engine = RenderEngine::new();
4439        let provider = FontconfigProvider::new();
4440        let planes = engine.render_frame_with_provider(&track, &provider, 500);
4441        let character_planes = planes
4442            .iter()
4443            .filter(|plane| plane.kind == ass::ImageType::Character)
4444            .cloned()
4445            .collect::<Vec<_>>();
4446        let shadow_planes = planes
4447            .iter()
4448            .filter(|plane| plane.kind == ass::ImageType::Shadow)
4449            .cloned()
4450            .collect::<Vec<_>>();
4451
4452        let character = visible_bounds(&character_planes).expect("character bounds");
4453        let shadow = visible_bounds(&shadow_planes).expect("axis-specific shadow should render");
4454        assert_eq!(shadow.x_min - character.x_min, 9);
4455        assert_eq!(shadow.y_min - character.y_min, 3);
4456    }
4457
4458    #[test]
4459    fn render_frame_renders_underline_and_strikeout_decorations() {
4460        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");
4461        let engine = RenderEngine::new();
4462        let provider = FontconfigProvider::new();
4463        let planes = engine.render_frame_with_provider(&track, &provider, 500);
4464        let decoration_planes = planes
4465            .iter()
4466            .filter(|plane| {
4467                plane.kind == ass::ImageType::Character
4468                    && plane.size.height <= 3
4469                    && plane.size.width > plane.size.height * 4
4470            })
4471            .collect::<Vec<_>>();
4472
4473        assert!(decoration_planes.len() >= 2);
4474    }
4475
4476    #[test]
4477    fn render_frame_uses_override_colors_and_shadow_planes() {
4478        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");
4479        let engine = RenderEngine::new();
4480        let provider = FontconfigProvider::new();
4481        let planes = engine.render_frame_with_provider(&track, &provider, 500);
4482
4483        assert!(
4484            planes.iter().any(
4485                |plane| plane.kind == ass::ImageType::Character && plane.color.0 == 0x3322_1100
4486            )
4487        );
4488        assert!(
4489            planes
4490                .iter()
4491                .any(|plane| plane.kind == ass::ImageType::Shadow && plane.color.0 == 0x6655_4400)
4492        );
4493    }
4494
4495    #[test]
4496    fn render_frame_orders_events_by_layer_then_read_order() {
4497        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");
4498        let engine = RenderEngine::new();
4499        let provider = FontconfigProvider::new();
4500        let planes = engine.render_frame_with_provider(&track, &provider, 500);
4501
4502        let first_character = planes
4503            .iter()
4504            .find(|plane| plane.kind == ass::ImageType::Character)
4505            .expect("character plane");
4506        assert_eq!(first_character.color.0, 0x00FF_0000);
4507    }
4508
4509    #[test]
4510    fn render_frame_orders_shadow_outline_before_character_within_event() {
4511        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");
4512        let engine = RenderEngine::new();
4513        let provider = FontconfigProvider::new();
4514        let planes = engine.render_frame_with_provider(&track, &provider, 500);
4515        let kinds = planes.iter().map(|plane| plane.kind).collect::<Vec<_>>();
4516
4517        let first_shadow = kinds
4518            .iter()
4519            .position(|kind| *kind == ass::ImageType::Shadow)
4520            .expect("shadow plane");
4521        let first_outline = kinds
4522            .iter()
4523            .position(|kind| *kind == ass::ImageType::Outline)
4524            .expect("outline plane");
4525        let first_character = kinds
4526            .iter()
4527            .position(|kind| *kind == ass::ImageType::Character)
4528            .expect("character plane");
4529
4530        assert!(first_shadow < first_outline);
4531        assert!(first_outline < first_character);
4532    }
4533
4534    #[test]
4535    fn render_frame_emits_outline_planes_for_border_override() {
4536        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");
4537        let engine = RenderEngine::new();
4538        let provider = FontconfigProvider::new();
4539        let planes = engine.render_frame_with_provider(&track, &provider, 500);
4540
4541        assert!(
4542            planes
4543                .iter()
4544                .any(|plane| plane.kind == ass::ImageType::Outline && plane.color.0 == 0x0C0B_0A00)
4545        );
4546    }
4547
4548    #[test]
4549    fn render_frame_emits_opaque_box_for_border_style_3() {
4550        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");
4551        let engine = RenderEngine::new();
4552        let provider = FontconfigProvider::new();
4553        let planes = engine.render_frame_with_provider(&track, &provider, 500);
4554        let character_planes = planes
4555            .iter()
4556            .filter(|plane| plane.kind == ass::ImageType::Character)
4557            .cloned()
4558            .collect::<Vec<_>>();
4559        let outline_planes = planes
4560            .iter()
4561            .filter(|plane| plane.kind == ass::ImageType::Outline)
4562            .cloned()
4563            .collect::<Vec<_>>();
4564
4565        assert_eq!(
4566            outline_planes.len(),
4567            1,
4568            "BorderStyle=3 should emit only the opaque box outline plane, not a separate stroked glyph outline"
4569        );
4570        let _character = visible_bounds(&character_planes).expect("character bounds");
4571        let outline = outline_planes
4572            .iter()
4573            .find(|plane| plane.color.0 == 0x0000_0000 && plane.bitmap.contains(&255))
4574            .expect("opaque border-style box plane uses outline colour");
4575        assert!(outline.size.width > 0);
4576        assert!(outline.size.height > 0);
4577        let bounds = visible_bounds(std::slice::from_ref(outline)).expect("opaque box bounds");
4578        let center_x = (bounds.x_min + bounds.x_max) / 2;
4579        assert!(
4580            (center_x - 250).abs() <= 2,
4581            "opaque box should stay centered at \\pos, got {bounds:?}"
4582        );
4583        let center_y = (bounds.y_min + bounds.y_max) / 2;
4584        assert!(
4585            (center_y - 80).abs() <= 1,
4586            "opaque box should stay vertically centered at \\pos like libass, got {bounds:?}"
4587        );
4588        assert_eq!(
4589            bounds.height(),
4590            36,
4591            "BorderStyle=3 box plane height should be font size plus two borders plus edge rows like libass"
4592        );
4593        assert!(
4594            bounds.width() < 370,
4595            "opaque box should use actual raster advance like libass, not inflated layout width: {bounds:?}"
4596        );
4597    }
4598
4599    #[test]
4600    fn render_frame_blurs_outline_and_shadow_layers() {
4601        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");
4602        let engine = RenderEngine::new();
4603        let provider = FontconfigProvider::new();
4604        let planes = engine.render_frame_with_provider(&track, &provider, 500);
4605
4606        assert!(
4607            planes
4608                .iter()
4609                .any(|plane| plane.kind == ass::ImageType::Outline
4610                    && plane.bitmap.iter().any(|value| *value > 0 && *value < 255))
4611        );
4612        assert!(
4613            planes
4614                .iter()
4615                .any(|plane| plane.kind == ass::ImageType::Shadow
4616                    && plane.bitmap.iter().any(|value| *value > 0 && *value < 255))
4617        );
4618    }
4619
4620    #[test]
4621    fn render_frame_blurs_fill_only_without_outline_or_shadow() {
4622        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");
4623        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");
4624        let engine = RenderEngine::new();
4625        let provider = FontconfigProvider::new();
4626        let base_planes = engine.render_frame_with_provider(&base, &provider, 500);
4627        let blurred_planes = engine.render_frame_with_provider(&blurred, &provider, 500);
4628        let base_character = visible_bounds(
4629            &base_planes
4630                .iter()
4631                .filter(|plane| plane.kind == ass::ImageType::Character)
4632                .cloned()
4633                .collect::<Vec<_>>(),
4634        )
4635        .expect("base character bounds");
4636        let blurred_character = visible_bounds(
4637            &blurred_planes
4638                .iter()
4639                .filter(|plane| plane.kind == ass::ImageType::Character)
4640                .cloned()
4641                .collect::<Vec<_>>(),
4642        )
4643        .expect("blurred character bounds");
4644
4645        assert!(blurred_character.x_min < base_character.x_min);
4646        assert!(blurred_character.x_max > base_character.x_max);
4647        assert!(blurred_character.y_min < base_character.y_min);
4648        assert!(blurred_character.y_max > base_character.y_max);
4649    }
4650
4651    #[test]
4652    fn render_frame_does_not_blur_fill_when_outline_or_shadow_exists() {
4653        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");
4654        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");
4655        let engine = RenderEngine::new();
4656        let provider = FontconfigProvider::new();
4657        let base_planes = engine.render_frame_with_provider(&base, &provider, 500);
4658        let blurred_planes = engine.render_frame_with_provider(&blurred, &provider, 500);
4659        let character_bounds = |planes: &[ImagePlane]| {
4660            visible_bounds(
4661                &planes
4662                    .iter()
4663                    .filter(|plane| plane.kind == ass::ImageType::Character)
4664                    .cloned()
4665                    .collect::<Vec<_>>(),
4666            )
4667            .expect("character bounds")
4668        };
4669
4670        assert_eq!(
4671            character_bounds(&blurred_planes),
4672            character_bounds(&base_planes)
4673        );
4674        assert!(
4675            blurred_planes
4676                .iter()
4677                .filter(|plane| plane.kind == ass::ImageType::Outline)
4678                .any(|plane| plane.bitmap.iter().any(|value| *value > 0 && *value < 255))
4679        );
4680        assert!(
4681            blurred_planes
4682                .iter()
4683                .filter(|plane| plane.kind == ass::ImageType::Shadow)
4684                .any(|plane| plane.bitmap.iter().any(|value| *value > 0 && *value < 255))
4685        );
4686    }
4687
4688    #[test]
4689    fn render_frame_applies_rectangular_clip() {
4690        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");
4691        let engine = RenderEngine::new();
4692        let provider = FontconfigProvider::new();
4693        let planes = engine.render_frame_with_provider(&track, &provider, 500);
4694
4695        assert!(!planes.is_empty());
4696        assert!(planes.iter().all(|plane| plane.destination.x >= 0));
4697        assert!(planes.iter().all(|plane| plane.destination.y >= 0));
4698        assert!(
4699            planes
4700                .iter()
4701                .all(|plane| plane.destination.x + plane.size.width <= 64)
4702        );
4703        assert!(
4704            planes
4705                .iter()
4706                .all(|plane| plane.destination.y + plane.size.height <= 64)
4707        );
4708    }
4709
4710    #[test]
4711    fn render_frame_accepts_renderer_shaping_mode() {
4712        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");
4713        let engine = RenderEngine::new();
4714        let provider = FontconfigProvider::new();
4715        let simple = engine.render_frame_with_provider_and_config(
4716            &track,
4717            &provider,
4718            500,
4719            &RendererConfig {
4720                shaping: ass::ShapingLevel::Simple,
4721                ..default_renderer_config(&track)
4722            },
4723        );
4724        let complex = engine.render_frame_with_provider_and_config(
4725            &track,
4726            &provider,
4727            500,
4728            &RendererConfig {
4729                shaping: ass::ShapingLevel::Complex,
4730                ..default_renderer_config(&track)
4731            },
4732        );
4733
4734        assert!(!simple.is_empty());
4735        assert!(!complex.is_empty());
4736    }
4737
4738    #[test]
4739    fn render_frame_applies_inverse_rectangular_clip() {
4740        let plane = ImagePlane {
4741            size: Size {
4742                width: 6,
4743                height: 4,
4744            },
4745            stride: 6,
4746            color: RgbaColor(0x00FF_FFFF),
4747            destination: Point { x: 0, y: 0 },
4748            kind: ass::ImageType::Character,
4749            bitmap: vec![255; 24],
4750        };
4751        let parts = inverse_clip_plane(
4752            plane,
4753            Rect {
4754                x_min: 2,
4755                y_min: 1,
4756                x_max: 4,
4757                y_max: 3,
4758            },
4759        );
4760
4761        assert_eq!(parts.len(), 4);
4762        assert_eq!(
4763            parts.iter().map(|plane| plane.bitmap.len()).sum::<usize>(),
4764            20
4765        );
4766    }
4767
4768    #[test]
4769    fn inverse_clip_bleed_covers_outline_growth_to_prevent_stray_glyph_leakage() {
4770        let style = ParsedSpanStyle {
4771            border: 5.0,
4772            border_x: 5.0,
4773            border_y: 5.0,
4774            shadow: 0.0,
4775            shadow_x: 0.0,
4776            shadow_y: 0.0,
4777            blur: 0.0,
4778            be: 0.0,
4779            ..ParsedSpanStyle::default()
4780        };
4781        let clip = Rect {
4782            x_min: 20,
4783            y_min: 0,
4784            x_max: 24,
4785            y_max: 10,
4786        };
4787        let glyph = ImagePlane {
4788            size: Size {
4789                width: 44,
4790                height: 10,
4791            },
4792            stride: 44,
4793            color: RgbaColor(0x00FF_FFFF),
4794            destination: Point { x: 0, y: 0 },
4795            kind: ass::ImageType::Outline,
4796            bitmap: vec![255; 440],
4797        };
4798
4799        let expanded = expand_rect(clip, style_clip_bleed(&style));
4800        let parts = inverse_clip_plane(glyph, expanded);
4801
4802        assert!(
4803            parts
4804                .iter()
4805                .all(|plane| plane.destination.x + plane.size.width <= 0
4806                    || plane.destination.x >= 44),
4807            "inverse clip must mask outline bleed around the nominal clip, got {parts:?}"
4808        );
4809    }
4810
4811    #[test]
4812    fn render_frame_applies_vector_clip() {
4813        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");
4814        let engine = RenderEngine::new();
4815        let provider = FontconfigProvider::new();
4816        let planes = engine.render_frame_with_provider(&track, &provider, 500);
4817
4818        assert!(!planes.is_empty());
4819        assert!(
4820            planes
4821                .iter()
4822                .all(|plane| plane.bitmap.iter().any(|value| *value > 0))
4823        );
4824        assert!(planes.iter().all(|plane| plane.destination.x >= 0));
4825        assert!(planes.iter().all(|plane| plane.destination.y >= 0));
4826    }
4827
4828    #[test]
4829    fn render_frame_clips_to_frame_bounds() {
4830        let plane = ImagePlane {
4831            size: Size {
4832                width: 20,
4833                height: 20,
4834            },
4835            stride: 20,
4836            color: RgbaColor(0x00FF_FFFF),
4837            destination: Point { x: 50, y: 50 },
4838            kind: ass::ImageType::Character,
4839            bitmap: vec![255; 400],
4840        };
4841        let clipped = apply_event_clip(
4842            vec![plane],
4843            Rect {
4844                x_min: 0,
4845                y_min: 0,
4846                x_max: 60,
4847                y_max: 60,
4848            },
4849            false,
4850        );
4851
4852        assert_eq!(clipped.len(), 1);
4853        assert_eq!(clipped[0].size.width, 10);
4854        assert_eq!(clipped[0].size.height, 10);
4855    }
4856
4857    #[test]
4858    fn render_frame_applies_margin_clip_when_enabled() {
4859        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");
4860        let engine = RenderEngine::new();
4861        let provider = FontconfigProvider::new();
4862        let planes = engine.render_frame_with_provider_and_config(
4863            &track,
4864            &provider,
4865            500,
4866            &config(
4867                100,
4868                100,
4869                rassa_core::Margins {
4870                    top: 10,
4871                    bottom: 10,
4872                    left: 10,
4873                    right: 10,
4874                },
4875                true,
4876            ),
4877        );
4878
4879        assert!(!planes.is_empty());
4880        assert!(planes.iter().all(|plane| plane.destination.x >= 10));
4881        assert!(planes.iter().all(|plane| plane.destination.y >= 10));
4882        assert!(
4883            planes
4884                .iter()
4885                .all(|plane| plane.destination.x + plane.size.width <= 90)
4886        );
4887        assert!(
4888            planes
4889                .iter()
4890                .all(|plane| plane.destination.y + plane.size.height <= 90)
4891        );
4892    }
4893
4894    #[test]
4895    fn render_frame_maps_into_content_area_when_margins_are_not_used() {
4896        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");
4897        let engine = RenderEngine::new();
4898        let provider = FontconfigProvider::new();
4899        let planes = engine.render_frame_with_provider_and_config(
4900            &track,
4901            &provider,
4902            500,
4903            &config(
4904                120,
4905                120,
4906                rassa_core::Margins {
4907                    top: 10,
4908                    bottom: 10,
4909                    left: 10,
4910                    right: 10,
4911                },
4912                false,
4913            ),
4914        );
4915
4916        assert!(!planes.is_empty());
4917        let bounds = visible_bounds(&planes).expect("visible bounds");
4918        assert!(
4919            bounds.x_min >= 10,
4920            "visible bounds should start inside content area: {bounds:?}"
4921        );
4922        assert!(
4923            bounds.y_min >= 9,
4924            "libass-style antialiasing may allocate one guard row above the content area: {bounds:?}"
4925        );
4926        assert!(
4927            bounds.x_max <= 110,
4928            "visible bounds should end inside content area: {bounds:?}"
4929        );
4930        assert!(
4931            bounds.y_max <= 110,
4932            "visible bounds should end inside content area: {bounds:?}"
4933        );
4934    }
4935
4936    #[test]
4937    fn render_frame_keeps_border_closer_to_device_size_when_scaled_border_is_disabled() {
4938        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");
4939        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");
4940        let engine = RenderEngine::new();
4941        let provider = FontconfigProvider::new();
4942        let config = config(200, 200, rassa_core::Margins::default(), true);
4943        let enabled_planes =
4944            engine.render_frame_with_provider_and_config(&enabled, &provider, 500, &config);
4945        let disabled_planes =
4946            engine.render_frame_with_provider_and_config(&disabled, &provider, 500, &config);
4947        let enabled_outline_area: i32 = enabled_planes
4948            .iter()
4949            .filter(|plane| plane.kind == ass::ImageType::Outline)
4950            .map(|plane| plane.size.width * plane.size.height)
4951            .sum();
4952        let disabled_outline_area: i32 = disabled_planes
4953            .iter()
4954            .filter(|plane| plane.kind == ass::ImageType::Outline)
4955            .map(|plane| plane.size.width * plane.size.height)
4956            .sum();
4957
4958        assert!(disabled_outline_area > 0);
4959        assert!(disabled_outline_area < enabled_outline_area);
4960    }
4961
4962    #[test]
4963    fn render_frame_applies_font_scale_to_output() {
4964        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");
4965        let engine = RenderEngine::new();
4966        let provider = FontconfigProvider::new();
4967
4968        let baseline = engine.render_frame_with_provider(&track, &provider, 500);
4969        let scaled = engine.render_frame_with_provider_and_config(
4970            &track,
4971            &provider,
4972            500,
4973            &RendererConfig {
4974                frame: Size {
4975                    width: 200,
4976                    height: 120,
4977                },
4978                font_scale: 2.0,
4979                ..RendererConfig::default()
4980            },
4981        );
4982
4983        assert!(!baseline.is_empty());
4984        assert!(!scaled.is_empty());
4985        assert!(total_plane_area(&scaled) > total_plane_area(&baseline));
4986    }
4987
4988    #[test]
4989    fn render_frame_applies_text_scale_overrides() {
4990        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");
4991        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");
4992        let engine = RenderEngine::new();
4993        let provider = FontconfigProvider::new();
4994        let baseline = engine.render_frame_with_provider(&track, &provider, 500);
4995        let scaled = engine.render_frame_with_provider(&stretched, &provider, 500);
4996        let baseline_width = baseline
4997            .iter()
4998            .filter(|plane| plane.kind == ass::ImageType::Character)
4999            .map(|plane| plane.destination.x + plane.size.width)
5000            .max()
5001            .expect("baseline max x")
5002            - baseline
5003                .iter()
5004                .filter(|plane| plane.kind == ass::ImageType::Character)
5005                .map(|plane| plane.destination.x)
5006                .min()
5007                .expect("baseline min x");
5008        let scaled_width = scaled
5009            .iter()
5010            .filter(|plane| plane.kind == ass::ImageType::Character)
5011            .map(|plane| plane.destination.x + plane.size.width)
5012            .max()
5013            .expect("scaled max x")
5014            - scaled
5015                .iter()
5016                .filter(|plane| plane.kind == ass::ImageType::Character)
5017                .map(|plane| plane.destination.x)
5018                .min()
5019                .expect("scaled min x");
5020
5021        assert!(scaled_width > baseline_width);
5022        assert!(total_plane_area(&scaled) < total_plane_area(&baseline) * 2);
5023    }
5024
5025    #[test]
5026    fn render_frame_applies_drawing_scale_overrides() {
5027        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");
5028        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");
5029        let engine = RenderEngine::new();
5030        let provider = FontconfigProvider::new();
5031        let baseline_planes = engine.render_frame_with_provider(&baseline, &provider, 500);
5032        let scaled_planes = engine.render_frame_with_provider(&scaled, &provider, 500);
5033        let baseline_plane = baseline_planes
5034            .iter()
5035            .find(|plane| plane.kind == ass::ImageType::Character)
5036            .expect("baseline drawing plane");
5037        let scaled_plane = scaled_planes
5038            .iter()
5039            .find(|plane| plane.kind == ass::ImageType::Character)
5040            .expect("scaled drawing plane");
5041
5042        assert!(scaled_plane.size.width > baseline_plane.size.width);
5043        assert!(scaled_plane.size.height < baseline_plane.size.height);
5044        assert_eq!(scaled_plane.destination, Point { x: 10, y: 10 });
5045    }
5046
5047    #[test]
5048    fn non_positioned_drawing_does_not_receive_positioned_overhang_compensation() {
5049        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");
5050        let engine = RenderEngine::new();
5051        let provider = FontconfigProvider::new();
5052        let plane = engine
5053            .render_frame_with_provider(&track, &provider, 500)
5054            .into_iter()
5055            .find(|plane| plane.kind == ass::ImageType::Character)
5056            .expect("drawing plane");
5057
5058        assert_eq!(
5059            plane.size.width, 11,
5060            "libass-style positioned overhang compensation is specific to explicit \\pos vector drawings"
5061        );
5062    }
5063
5064    #[test]
5065    #[ignore = "parked while rassa stops treating pixel-perfect libass drawing pbo residuals as an optimization blocker"]
5066    fn render_frame_applies_drawing_baseline_offset() {
5067        fn pbo_track(pbo_tag: &str) -> ParsedTrack {
5068            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"))
5069                .expect("script should parse")
5070        }
5071
5072        let baseline = pbo_track("");
5073        let pbo5 = pbo_track("\\pbo5");
5074        let shifted = pbo_track("\\pbo12");
5075        let negative = pbo_track("\\pbo-12");
5076        let engine = RenderEngine::new();
5077        let provider = FontconfigProvider::new();
5078        let drawing_plane = |track: &ParsedTrack| {
5079            engine
5080                .render_frame_with_provider(track, &provider, 500)
5081                .into_iter()
5082                .find(|plane| {
5083                    plane.kind == ass::ImageType::Character
5084                        && plane.size.width == 11
5085                        && plane.size.height == 11
5086                })
5087                .expect("drawing plane")
5088        };
5089        let baseline_drawing = drawing_plane(&baseline);
5090        let pbo5_drawing = drawing_plane(&pbo5);
5091        let shifted_drawing = drawing_plane(&shifted);
5092        let negative_drawing = drawing_plane(&negative);
5093
5094        assert_eq!(
5095            pbo5_drawing.destination, baseline_drawing.destination,
5096            "libass keeps pbo below drawing height anchored for this 10-unit positioned drawing"
5097        );
5098        assert_eq!(
5099            shifted_drawing.destination.x,
5100            baseline_drawing.destination.x
5101        );
5102        assert_eq!(
5103            shifted_drawing.destination.y,
5104            baseline_drawing.destination.y + 2,
5105            "libass applies \\pbo as max(pbo - drawing_height, 0) for this top-anchored positioned drawing"
5106        );
5107        assert_eq!(
5108            negative_drawing.destination, baseline_drawing.destination,
5109            "libass keeps negative \\pbo top-anchored for this positioned drawing"
5110        );
5111    }
5112
5113    #[test]
5114    fn render_frame_applies_banner_effect_motion() {
5115        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");
5116        let engine = RenderEngine::new();
5117        let provider = FontconfigProvider::new();
5118        let early = character_bounds(&engine.render_frame_with_provider(&track, &provider, 100))
5119            .expect("early banner bounds");
5120        let late = character_bounds(&engine.render_frame_with_provider(&track, &provider, 1500))
5121            .expect("late banner bounds");
5122
5123        assert!(
5124            late.x_min < early.x_min,
5125            "right-to-left banner should move left over time"
5126        );
5127        assert!(
5128            (194..=198).contains(&early.x_min),
5129            "libass positions a right-to-left banner by PlayResX - elapsed/delay, got {early:?}"
5130        );
5131    }
5132
5133    #[test]
5134    fn banner_effect_delay_uses_layout_scale_not_render_supersampling() {
5135        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");
5136        let engine = RenderEngine::new();
5137        let provider = FontconfigProvider::new();
5138        let bounds = character_bounds(&engine.render_frame_with_provider_and_config(
5139            &track,
5140            &provider,
5141            1500,
5142            &RendererConfig {
5143                frame: Size {
5144                    width: 1600,
5145                    height: 800,
5146                },
5147                storage: Size {
5148                    width: 200,
5149                    height: 100,
5150                },
5151                ..RendererConfig::default()
5152            },
5153        ))
5154        .expect("supersampled banner bounds");
5155
5156        assert!(
5157            bounds.x_min >= 1112,
5158            "Banner delay should be based on layout/storage resolution rather than render supersampling; got {bounds:?}"
5159        );
5160    }
5161
5162    #[test]
5163    fn render_frame_applies_scroll_effect_motion() {
5164        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");
5165        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");
5166        let engine = RenderEngine::new();
5167        let provider = FontconfigProvider::new();
5168        let up_early = character_bounds(&engine.render_frame_with_provider(&up, &provider, 100))
5169            .expect("early scroll-up bounds");
5170        let up_late = character_bounds(&engine.render_frame_with_provider(&up, &provider, 1500))
5171            .expect("late scroll-up bounds");
5172        let down_early =
5173            character_bounds(&engine.render_frame_with_provider(&down, &provider, 100))
5174                .expect("early scroll-down bounds");
5175        let down_late =
5176            character_bounds(&engine.render_frame_with_provider(&down, &provider, 1500))
5177                .expect("late scroll-down bounds");
5178
5179        assert!(
5180            up_late.y_min < up_early.y_min,
5181            "scroll up should move upward"
5182        );
5183        assert!(
5184            down_late.y_min > down_early.y_min,
5185            "scroll down should move downward"
5186        );
5187    }
5188
5189    #[test]
5190    fn render_frame_applies_text_spacing_override() {
5191        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");
5192        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");
5193        let engine = RenderEngine::new();
5194        let provider = FontconfigProvider::new();
5195        let baseline_planes = engine.render_frame_with_provider(&baseline, &provider, 500);
5196        let spaced_planes = engine.render_frame_with_provider(&spaced, &provider, 500);
5197        let baseline_width = character_bounds(&baseline_planes)
5198            .expect("baseline bounds")
5199            .width();
5200        let spaced_width = character_bounds(&spaced_planes)
5201            .expect("spaced bounds")
5202            .width();
5203
5204        assert!(spaced_width > baseline_width);
5205    }
5206
5207    #[test]
5208    fn render_frame_scales_output_to_frame_size() {
5209        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");
5210        let engine = RenderEngine::new();
5211        let provider = FontconfigProvider::new();
5212
5213        let baseline = engine.render_frame_with_provider(&track, &provider, 500);
5214        let scaled = engine.render_frame_with_provider_and_config(
5215            &track,
5216            &provider,
5217            500,
5218            &RendererConfig {
5219                frame: Size {
5220                    width: 400,
5221                    height: 240,
5222                },
5223                ..default_renderer_config(&track)
5224            },
5225        );
5226
5227        assert!(total_plane_area(&baseline) > 0);
5228        assert!(total_plane_area(&scaled) > total_plane_area(&baseline));
5229    }
5230
5231    #[test]
5232    fn render_frame_applies_pixel_aspect_horizontally() {
5233        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");
5234        let engine = RenderEngine::new();
5235        let provider = FontconfigProvider::new();
5236
5237        let baseline = engine.render_frame_with_provider_and_config(
5238            &track,
5239            &provider,
5240            500,
5241            &RendererConfig {
5242                frame: Size {
5243                    width: 400,
5244                    height: 120,
5245                },
5246                ..default_renderer_config(&track)
5247            },
5248        );
5249        let widened = engine.render_frame_with_provider_and_config(
5250            &track,
5251            &provider,
5252            500,
5253            &RendererConfig {
5254                frame: Size {
5255                    width: 400,
5256                    height: 120,
5257                },
5258                pixel_aspect: 2.0,
5259                ..default_renderer_config(&track)
5260            },
5261        );
5262
5263        let baseline_bounds = character_bounds(&baseline).expect("baseline character bounds");
5264        let widened_bounds = character_bounds(&widened).expect("widened character bounds");
5265        assert!(
5266            widened_bounds.x_min > baseline_bounds.x_min,
5267            "pixel aspect should affect horizontal placement: baseline={baseline_bounds:?} widened={widened_bounds:?}"
5268        );
5269    }
5270
5271    #[test]
5272    fn render_frame_derives_pixel_aspect_from_storage_size_when_unset() {
5273        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");
5274        let engine = RenderEngine::new();
5275        let provider = FontconfigProvider::new();
5276
5277        let baseline = engine.render_frame_with_provider_and_config(
5278            &track,
5279            &provider,
5280            500,
5281            &RendererConfig {
5282                frame: Size {
5283                    width: 400,
5284                    height: 240,
5285                },
5286                ..default_renderer_config(&track)
5287            },
5288        );
5289        let storage_adjusted = engine.render_frame_with_provider_and_config(
5290            &track,
5291            &provider,
5292            500,
5293            &RendererConfig {
5294                frame: Size {
5295                    width: 400,
5296                    height: 240,
5297                },
5298                storage: Size {
5299                    width: 400,
5300                    height: 120,
5301                },
5302                ..default_renderer_config(&track)
5303            },
5304        );
5305
5306        assert!(total_plane_area(&baseline) > 0);
5307        assert!(total_plane_area(&storage_adjusted) < total_plane_area(&baseline));
5308    }
5309
5310    #[test]
5311    fn render_frame_layout_resolution_takes_precedence_over_storage_and_explicit_aspect() {
5312        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");
5313        let engine = RenderEngine::new();
5314        let provider = FontconfigProvider::new();
5315
5316        let baseline = engine.render_frame_with_provider_and_config(
5317            &track,
5318            &provider,
5319            500,
5320            &RendererConfig {
5321                frame: Size {
5322                    width: 400,
5323                    height: 240,
5324                },
5325                ..default_renderer_config(&track)
5326            },
5327        );
5328        let overridden_inputs = engine.render_frame_with_provider_and_config(
5329            &track,
5330            &provider,
5331            500,
5332            &RendererConfig {
5333                frame: Size {
5334                    width: 400,
5335                    height: 240,
5336                },
5337                storage: Size {
5338                    width: 400,
5339                    height: 120,
5340                },
5341                pixel_aspect: 2.0,
5342                ..default_renderer_config(&track)
5343            },
5344        );
5345
5346        assert_eq!(
5347            total_plane_area(&overridden_inputs),
5348            total_plane_area(&baseline)
5349        );
5350    }
5351
5352    #[test]
5353    fn render_frame_applies_line_position_to_subtitles() {
5354        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");
5355        let engine = RenderEngine::new();
5356        let provider = FontconfigProvider::new();
5357
5358        let baseline = engine.render_frame_with_provider(&track, &provider, 500);
5359        let shifted = engine.render_frame_with_provider_and_config(
5360            &track,
5361            &provider,
5362            500,
5363            &RendererConfig {
5364                frame: Size {
5365                    width: 200,
5366                    height: 120,
5367                },
5368                line_position: 50.0,
5369                ..RendererConfig::default()
5370            },
5371        );
5372
5373        let baseline_y = baseline
5374            .iter()
5375            .map(|plane| plane.destination.y)
5376            .min()
5377            .expect("baseline plane");
5378        let shifted_y = shifted
5379            .iter()
5380            .map(|plane| plane.destination.y)
5381            .min()
5382            .expect("shifted plane");
5383
5384        assert!(shifted_y < baseline_y);
5385    }
5386
5387    #[test]
5388    fn render_frame_applies_line_spacing_to_multiline_subtitles() {
5389        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");
5390        let engine = RenderEngine::new();
5391        let provider = FontconfigProvider::new();
5392
5393        let baseline = engine.render_frame_with_provider(&track, &provider, 500);
5394        let spaced = engine.render_frame_with_provider_and_config(
5395            &track,
5396            &provider,
5397            500,
5398            &RendererConfig {
5399                frame: Size {
5400                    width: 200,
5401                    height: 140,
5402                },
5403                line_spacing: 20.0,
5404                ..RendererConfig::default()
5405            },
5406        );
5407
5408        assert!(vertical_span(&spaced) > vertical_span(&baseline));
5409    }
5410
5411    #[test]
5412    fn render_frame_avoids_basic_bottom_collision_for_unpositioned_events() {
5413        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");
5414        let engine = RenderEngine::new();
5415        let provider = FontconfigProvider::new();
5416        let planes = engine.render_frame_with_provider(&track, &provider, 500);
5417
5418        let mut ys = planes
5419            .iter()
5420            .filter(|plane| plane.kind == ass::ImageType::Character)
5421            .map(|plane| plane.destination.y)
5422            .collect::<Vec<_>>();
5423        ys.sort_unstable();
5424        ys.dedup();
5425
5426        assert!(ys.len() >= 2);
5427        assert!(ys.last().expect("max y") - ys.first().expect("min y") >= 20);
5428    }
5429
5430    #[test]
5431    fn render_frame_allows_basic_collision_across_different_layers() {
5432        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");
5433        let engine = RenderEngine::new();
5434        let provider = FontconfigProvider::new();
5435        let planes = engine.render_frame_with_provider(&track, &provider, 500);
5436
5437        let layer0_y = planes
5438            .iter()
5439            .filter(|plane| plane.kind == ass::ImageType::Character && plane.color.0 == 0xFF00_0000)
5440            .map(|plane| plane.destination.y)
5441            .min()
5442            .expect("layer 0 character plane");
5443        let layer1_y = planes
5444            .iter()
5445            .filter(|plane| plane.kind == ass::ImageType::Character && plane.color.0 == 0x00FF_0000)
5446            .map(|plane| plane.destination.y)
5447            .min()
5448            .expect("layer 1 character plane");
5449
5450        assert_eq!(layer0_y, layer1_y);
5451    }
5452
5453    #[test]
5454    fn render_frame_interpolates_move_position() {
5455        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");
5456        let engine = RenderEngine::new();
5457        let provider = FontconfigProvider::new();
5458        let start_planes = engine.render_frame_with_provider(&track, &provider, 0);
5459        let mid_planes = engine.render_frame_with_provider(&track, &provider, 500);
5460        let end_planes = engine.render_frame_with_provider(&track, &provider, 999);
5461
5462        let start_x = start_planes
5463            .iter()
5464            .map(|plane| plane.destination.x)
5465            .min()
5466            .expect("start plane");
5467        let mid_x = mid_planes
5468            .iter()
5469            .map(|plane| plane.destination.x)
5470            .min()
5471            .expect("mid plane");
5472        let end_x = end_planes
5473            .iter()
5474            .map(|plane| plane.destination.x)
5475            .min()
5476            .expect("end plane");
5477
5478        assert!(start_x <= mid_x);
5479        assert!(mid_x <= end_x);
5480        assert!(end_x - start_x >= 80);
5481    }
5482
5483    #[test]
5484    fn render_frame_applies_z_rotation_to_event_planes() {
5485        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");
5486        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");
5487        let engine = RenderEngine::new();
5488        let provider = FontconfigProvider::new();
5489        let baseline_planes = engine.render_frame_with_provider(&baseline, &provider, 500);
5490        let rotated_planes = engine.render_frame_with_provider(&rotated, &provider, 500);
5491        let baseline_bounds = character_bounds(&baseline_planes).expect("baseline bounds");
5492        let rotated_bounds = character_bounds(&rotated_planes).expect("rotated bounds");
5493
5494        assert!(baseline_bounds.width() > baseline_bounds.height());
5495        assert!(rotated_bounds.height() > rotated_bounds.width());
5496    }
5497
5498    #[test]
5499    #[ignore = "strict libass positioned-vector overhang coverage residual kept as diagnostic after optimization pivot"]
5500    fn positioned_drawing_uses_position_y_before_compare_supersample_offset() {
5501        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");
5502        let engine = RenderEngine::new();
5503        let provider = FontconfigProvider::new();
5504        let planes = engine.render_frame_with_provider_and_config(
5505            &track,
5506            &provider,
5507            500,
5508            &RendererConfig {
5509                frame: Size {
5510                    width: 1760,
5511                    height: 1120,
5512                },
5513                storage: Size {
5514                    width: 220,
5515                    height: 140,
5516                },
5517                ..RendererConfig::default()
5518            },
5519        );
5520        let bounds = character_bounds(&planes).expect("positioned drawing bounds");
5521        let visible = visible_bounds(&planes).expect("positioned drawing visible bounds");
5522
5523        assert_eq!(
5524            bounds.y_min,
5525            24 * 8,
5526            "libass keeps top-aligned positioned vector drawings anchored at \\pos y before final supersample offset; got {bounds:?}"
5527        );
5528        assert_eq!(
5529            bounds.x_min,
5530            19 * 8,
5531            "libass gives positioned vector drawings one output-pixel left overhang at compare superscale; got {bounds:?}"
5532        );
5533        assert_eq!(
5534            bounds.x_max,
5535            63 * 8,
5536            "libass keeps the allocated right drawing edge available for transforms; got {bounds:?}"
5537        );
5538        assert_eq!(
5539            visible.x_min,
5540            19 * 8 + 7,
5541            "libass leaves only a subpixel-thin antialias sample in the positioned drawing's left overhang; got visible {visible:?}"
5542        );
5543        assert_eq!(
5544            visible.x_max,
5545            62 * 8 + 1,
5546            "positioned vector drawing keeps a subpixel-thin antialias sample in the allocated right overhang; got visible {visible:?}"
5547        );
5548    }
5549
5550    #[test]
5551    fn render_frame_shears_positioned_drawing_from_run_baseline_not_org() {
5552        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}")
5553            .expect("script should parse");
5554        let engine = RenderEngine::new();
5555        let provider = FontconfigProvider::new();
5556        let planes = engine.render_frame_with_provider(&track, &provider, 500);
5557        let bounds = planes_bounds(&planes).expect("drawing plane should render");
5558
5559        assert!(
5560            bounds.x_min >= 116,
5561            "libass applies \\fax in drawing-local baseline space before \\org perspective; global \\org shear pulls this too far left: {bounds:?}"
5562        );
5563    }
5564
5565    #[test]
5566    fn render_frame_applies_z_rotation_per_override_run() {
5567        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");
5568        let engine = RenderEngine::new();
5569        let provider = FontconfigProvider::new();
5570        let planes = engine.render_frame_with_provider(&track, &provider, 500);
5571        let red_planes = planes
5572            .iter()
5573            .filter(|plane| plane.kind == ass::ImageType::Character && plane.color.0 == 0xFF00_0000)
5574            .collect::<Vec<_>>();
5575        let green = planes
5576            .iter()
5577            .find(|plane| plane.kind == ass::ImageType::Character && plane.color.0 == 0x00FF_0000)
5578            .expect("rotated green drawing plane");
5579
5580        assert!(
5581            red_planes.len() >= 2,
5582            "expected multiple unrotated red glyph planes"
5583        );
5584        let red_y_min = red_planes
5585            .iter()
5586            .map(|plane| plane.destination.y)
5587            .min()
5588            .expect("red y min");
5589        let red_y_max = red_planes
5590            .iter()
5591            .map(|plane| plane.destination.y)
5592            .max()
5593            .expect("red y max");
5594        assert!(
5595            red_y_max - red_y_min <= 1,
5596            "unrotated run should stay on a horizontal baseline: {red_planes:?}"
5597        );
5598        assert!(
5599            green.size.height >= green.size.width,
5600            "rotated run should become vertical-ish: {green:?}"
5601        );
5602    }
5603
5604    #[test]
5605    fn render_frame_interpolates_z_rotation_transform() {
5606        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");
5607        let engine = RenderEngine::new();
5608        let provider = FontconfigProvider::new();
5609        let start_planes = engine.render_frame_with_provider(&track, &provider, 0);
5610        let end_planes = engine.render_frame_with_provider(&track, &provider, 999);
5611        let start_bounds = character_bounds(&start_planes).expect("start bounds");
5612        let end_bounds = character_bounds(&end_planes).expect("end bounds");
5613
5614        assert!(start_bounds.width() > start_bounds.height());
5615        assert!(end_bounds.height() > end_bounds.width());
5616    }
5617
5618    #[test]
5619    fn render_frame_applies_fad_alpha() {
5620        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");
5621        let engine = RenderEngine::new();
5622        let provider = FontconfigProvider::new();
5623        let start_planes = engine.render_frame_with_provider(&track, &provider, 0);
5624        let mid_planes = engine.render_frame_with_provider(&track, &provider, 500);
5625        let end_planes = engine.render_frame_with_provider(&track, &provider, 999);
5626
5627        let start_alpha = start_planes
5628            .iter()
5629            .map(|plane| plane.color.0 & 0xFF)
5630            .max()
5631            .expect("start alpha");
5632        let mid_alpha = mid_planes
5633            .iter()
5634            .map(|plane| plane.color.0 & 0xFF)
5635            .max()
5636            .expect("mid alpha");
5637        let end_alpha = end_planes
5638            .iter()
5639            .map(|plane| plane.color.0 & 0xFF)
5640            .max()
5641            .expect("end alpha");
5642
5643        assert!(start_alpha > mid_alpha);
5644        assert!(end_alpha > mid_alpha);
5645    }
5646
5647    #[test]
5648    fn render_frame_applies_full_fade_alpha() {
5649        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");
5650        let engine = RenderEngine::new();
5651        let provider = FontconfigProvider::new();
5652        let start_planes = engine.render_frame_with_provider(&track, &provider, 0);
5653        let middle_planes = engine.render_frame_with_provider(&track, &provider, 400);
5654        let late_planes = engine.render_frame_with_provider(&track, &provider, 850);
5655
5656        let start_alpha = start_planes
5657            .iter()
5658            .map(|plane| plane.color.0 & 0xFF)
5659            .max()
5660            .expect("start alpha");
5661        let middle_alpha = middle_planes
5662            .iter()
5663            .map(|plane| plane.color.0 & 0xFF)
5664            .max()
5665            .expect("middle alpha");
5666        let late_alpha = late_planes
5667            .iter()
5668            .map(|plane| plane.color.0 & 0xFF)
5669            .max()
5670            .expect("late alpha");
5671
5672        assert!(start_alpha > middle_alpha);
5673        assert!(late_alpha > middle_alpha);
5674        assert!(late_alpha < start_alpha);
5675    }
5676
5677    #[test]
5678    fn render_frame_switches_karaoke_fill_after_elapsed_span() {
5679        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");
5680        let engine = RenderEngine::new();
5681        let provider = FontconfigProvider::new();
5682        let early_planes = engine.render_frame_with_provider(&track, &provider, 200);
5683        let late_planes = engine.render_frame_with_provider(&track, &provider, 700);
5684
5685        assert!(
5686            early_planes.iter().any(
5687                |plane| plane.kind == ass::ImageType::Character && plane.color.0 == 0x6655_4400
5688            )
5689        );
5690        assert!(
5691            late_planes.iter().any(
5692                |plane| plane.kind == ass::ImageType::Character && plane.color.0 == 0x3322_1100
5693            )
5694        );
5695    }
5696
5697    #[test]
5698    fn render_frame_sweeps_karaoke_fill_during_active_span() {
5699        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");
5700        let engine = RenderEngine::new();
5701        let provider = FontconfigProvider::new();
5702        let mid_planes = engine.render_frame_with_provider(&track, &provider, 500);
5703
5704        assert!(
5705            mid_planes.iter().any(
5706                |plane| plane.kind == ass::ImageType::Character && plane.color.0 == 0x3322_1100
5707            )
5708        );
5709        assert!(
5710            mid_planes.iter().any(
5711                |plane| plane.kind == ass::ImageType::Character && plane.color.0 == 0x6655_4400
5712            )
5713        );
5714    }
5715
5716    #[test]
5717    fn render_frame_hides_outline_for_ko_until_span_ends() {
5718        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");
5719        let engine = RenderEngine::new();
5720        let provider = FontconfigProvider::new();
5721        let early_planes = engine.render_frame_with_provider(&track, &provider, 200);
5722        let late_planes = engine.render_frame_with_provider(&track, &provider, 700);
5723
5724        assert!(
5725            !early_planes
5726                .iter()
5727                .any(|plane| plane.kind == ass::ImageType::Outline)
5728        );
5729        assert!(
5730            late_planes
5731                .iter()
5732                .any(|plane| plane.kind == ass::ImageType::Outline)
5733        );
5734    }
5735
5736    #[test]
5737    fn render_frame_renders_drawing_plane() {
5738        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");
5739        let engine = RenderEngine::new();
5740        let provider = FontconfigProvider::new();
5741        let planes = engine.render_frame_with_provider(&track, &provider, 500);
5742
5743        assert!(
5744            planes.iter().any(
5745                |plane| plane.kind == ass::ImageType::Character && plane.color.0 == 0x3322_1100
5746            )
5747        );
5748        let plane = planes
5749            .iter()
5750            .find(|plane| plane.kind == ass::ImageType::Character)
5751            .expect("drawing plane");
5752        assert_eq!(plane.destination.x, 10);
5753        assert_eq!(plane.destination.y, 10);
5754        assert!(plane.bitmap.contains(&255));
5755    }
5756
5757    #[test]
5758    fn render_frame_renders_bezier_drawing_plane() {
5759        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");
5760        let engine = RenderEngine::new();
5761        let provider = FontconfigProvider::new();
5762        let planes = engine.render_frame_with_provider(&track, &provider, 500);
5763
5764        let plane = planes
5765            .iter()
5766            .find(|plane| plane.kind == ass::ImageType::Character)
5767            .expect("drawing plane");
5768        assert!(plane.bitmap.contains(&255));
5769        assert!(plane.size.width >= 8);
5770        assert!(plane.size.height >= 8);
5771    }
5772
5773    #[test]
5774    fn render_frame_emits_outline_and_shadow_for_drawings() {
5775        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");
5776        let engine = RenderEngine::new();
5777        let provider = FontconfigProvider::new();
5778        let planes = engine.render_frame_with_provider(&track, &provider, 500);
5779
5780        assert!(
5781            planes
5782                .iter()
5783                .any(|plane| plane.kind == ass::ImageType::Outline && plane.color.0 == 0x0C0B_0A00)
5784        );
5785        assert!(
5786            planes
5787                .iter()
5788                .any(|plane| plane.kind == ass::ImageType::Shadow && plane.color.0 == 0x6655_4400)
5789        );
5790    }
5791
5792    #[test]
5793    fn render_frame_renders_spline_drawing_plane() {
5794        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");
5795        let engine = RenderEngine::new();
5796        let provider = FontconfigProvider::new();
5797        let planes = engine.render_frame_with_provider(&track, &provider, 500);
5798
5799        let plane = planes
5800            .iter()
5801            .find(|plane| plane.kind == ass::ImageType::Character)
5802            .expect("drawing plane");
5803        assert!(plane.bitmap.contains(&255));
5804        assert!(plane.size.width >= 10);
5805        assert!(plane.size.height >= 10);
5806    }
5807
5808    #[test]
5809    fn render_frame_renders_non_closing_move_subpaths() {
5810        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");
5811        let engine = RenderEngine::new();
5812        let provider = FontconfigProvider::new();
5813        let planes = engine.render_frame_with_provider(&track, &provider, 500);
5814
5815        let plane = planes
5816            .iter()
5817            .find(|plane| plane.kind == ass::ImageType::Character)
5818            .expect("drawing plane");
5819        assert!(plane.bitmap.contains(&255));
5820        assert!(plane.size.width >= 28);
5821        assert!(plane.size.height >= 28);
5822    }
5823
5824    #[test]
5825    fn render_frame_applies_timed_transform_style() {
5826        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");
5827        let engine = RenderEngine::new();
5828        let provider = FontconfigProvider::new();
5829        let start_planes = engine.render_frame_with_provider(&track, &provider, 0);
5830        let mid_planes = engine.render_frame_with_provider(&track, &provider, 500);
5831        let end_planes = engine.render_frame_with_provider(&track, &provider, 999);
5832
5833        assert!(
5834            !start_planes
5835                .iter()
5836                .any(|plane| plane.kind == ass::ImageType::Outline)
5837        );
5838        assert!(
5839            mid_planes
5840                .iter()
5841                .any(|plane| plane.kind == ass::ImageType::Outline)
5842        );
5843        assert!(
5844            end_planes
5845                .iter()
5846                .any(|plane| plane.kind == ass::ImageType::Outline)
5847        );
5848
5849        let start_fill = start_planes
5850            .iter()
5851            .find(|plane| plane.kind == ass::ImageType::Character)
5852            .expect("start fill")
5853            .color
5854            .0;
5855        let end_fill = end_planes
5856            .iter()
5857            .find(|plane| plane.kind == ass::ImageType::Character)
5858            .expect("end fill")
5859            .color
5860            .0;
5861        assert_ne!(start_fill, end_fill);
5862        assert!(total_plane_area(&end_planes) > total_plane_area(&start_planes));
5863    }
5864}