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    layout_line_height(config, scale_y).max(font_metric_height_for_line(line, scale_y))
47}
48
49fn font_metric_height_for_line(line: &rassa_layout::LayoutLine, scale_y: f64) -> i32 {
50    let scale_y = style_scale(scale_y);
51    let max_font_size = line
52        .runs
53        .iter()
54        .map(|run| run.style.font_size)
55        .filter(|size| size.is_finite() && *size > 0.0)
56        .fold(0.0_f64, f64::max);
57    (max_font_size * scale_y * 0.52).round() as i32
58}
59
60fn positioned_text_y_correction(
61    line: &rassa_layout::LayoutLine,
62    config: &RendererConfig,
63    scale_y: f64,
64) -> i32 {
65    let layout_height = layout_line_height_for_line(line, config, scale_y);
66    let metric_height = font_metric_height_for_line(line, scale_y).max(1);
67    ((layout_height - metric_height).max(0) * 4) / 9
68}
69
70fn renderer_blur_radius(blur: f64) -> u32 {
71    if !(blur.is_finite() && blur > 0.0) {
72        return 0;
73    }
74    (blur * 4.0).ceil().max(1.0) as u32
75}
76
77fn style_clip_bleed(style: &ParsedSpanStyle) -> i32 {
78    let border_bleed = style.border_x.max(style.border_y).max(style.border) * 4.0;
79    let shadow_bleed = style
80        .shadow_x
81        .abs()
82        .max(style.shadow_y.abs())
83        .max(style.shadow);
84    let blur_bleed = renderer_blur_radius(style.blur.max(style.be)) as f64;
85    (border_bleed + shadow_bleed + blur_bleed).ceil().max(0.0) as i32
86}
87
88fn expand_rect(rect: Rect, amount: i32) -> Rect {
89    if amount <= 0 {
90        return rect;
91    }
92    Rect {
93        x_min: rect.x_min - amount,
94        y_min: rect.y_min - amount,
95        x_max: rect.x_max + amount,
96        y_max: rect.y_max + amount,
97    }
98}
99
100impl RenderEngine {
101    pub fn new() -> Self {
102        Self::default()
103    }
104
105    pub fn select_active_events(&self, track: &ParsedTrack, now_ms: i64) -> RenderSelection {
106        let mut active_event_indices = track
107            .events
108            .iter()
109            .enumerate()
110            .filter_map(|(index, event)| is_event_active(event, now_ms).then_some(index))
111            .collect::<Vec<_>>();
112        active_event_indices.sort_by(|left, right| {
113            let left_event = &track.events[*left];
114            let right_event = &track.events[*right];
115            left_event
116                .layer
117                .cmp(&right_event.layer)
118                .then(left_event.read_order.cmp(&right_event.read_order))
119                .then(left.cmp(right))
120        });
121
122        RenderSelection {
123            active_event_indices,
124        }
125    }
126
127    pub fn prepare_frame<P: FontProvider>(
128        &self,
129        track: &ParsedTrack,
130        provider: &P,
131        now_ms: i64,
132    ) -> PreparedFrame {
133        self.prepare_frame_with_config(track, provider, now_ms, &default_renderer_config(track))
134    }
135
136    pub fn prepare_frame_with_config<P: FontProvider>(
137        &self,
138        track: &ParsedTrack,
139        provider: &P,
140        now_ms: i64,
141        config: &RendererConfig,
142    ) -> PreparedFrame {
143        let selection = self.select_active_events(track, now_ms);
144        let shaping_mode = match config.shaping {
145            ass::ShapingLevel::Simple => ShapingMode::Simple,
146            ass::ShapingLevel::Complex => ShapingMode::Complex,
147        };
148        let active_events = selection
149            .active_event_indices
150            .into_iter()
151            .filter_map(|index| {
152                self.layout
153                    .layout_track_event_with_mode(track, index, provider, shaping_mode)
154                    .ok()
155            })
156            .collect();
157
158        PreparedFrame {
159            now_ms,
160            active_events,
161        }
162    }
163
164    pub fn render_frame_with_provider<P: FontProvider>(
165        &self,
166        track: &ParsedTrack,
167        provider: &P,
168        now_ms: i64,
169    ) -> Vec<ImagePlane> {
170        self.render_frame_with_provider_and_config(
171            track,
172            provider,
173            now_ms,
174            &default_renderer_config(track),
175        )
176    }
177
178    pub fn render_frame_with_provider_and_config<P: FontProvider>(
179        &self,
180        track: &ParsedTrack,
181        provider: &P,
182        now_ms: i64,
183        config: &RendererConfig,
184    ) -> Vec<ImagePlane> {
185        let prepared = self.prepare_frame_with_config(track, provider, now_ms, config);
186        let mut planes = Vec::new();
187        let mut occupied_bounds_by_layer = HashMap::<i32, Vec<Rect>>::new();
188
189        let render_scale_x = output_scale_x(track, config);
190        let render_scale_y = output_scale_y(track, config);
191        let render_scale =
192            ((style_scale(render_scale_x) + style_scale(render_scale_y)) / 2.0).max(1.0);
193
194        for event in &prepared.active_events {
195            let Some(style) = track.styles.get(event.style_index) else {
196                continue;
197            };
198            let mut shadow_planes = Vec::new();
199            let mut outline_planes = Vec::new();
200            let mut character_planes = Vec::new();
201            let mut opaque_box_rects = Vec::new();
202            let mut clip_mask_bleed = 0;
203            let effective_position = scale_position(
204                resolve_event_position(track, event, now_ms),
205                render_scale_x,
206                render_scale_y,
207            );
208            let layer = event_layer(track, event);
209            let occupied_bounds = occupied_bounds_by_layer.entry(layer).or_default();
210            let vertical_layout = resolve_vertical_layout(
211                track,
212                event,
213                effective_position,
214                occupied_bounds,
215                config,
216                render_scale_y,
217            );
218            let occupied_bound = effective_position.is_none().then(|| {
219                event_bounds(
220                    track,
221                    event,
222                    &vertical_layout,
223                    effective_position,
224                    config,
225                    render_scale_x,
226                    render_scale_y,
227                )
228            });
229            for (line, line_top) in event.lines.iter().zip(vertical_layout.iter().copied()) {
230                let has_scaled_run = line.runs.iter().any(|run| {
231                    (run.style.scale_x - 1.0).abs() > f64::EPSILON
232                        || (run.style.scale_y - 1.0).abs() > f64::EPSILON
233                });
234                let has_karaoke_run = line.runs.iter().any(|run| run.karaoke.is_some());
235                let text_line_top = if effective_position.is_some() {
236                    let border_style_3_y_adjust = if style.border_style == 3 { 3 } else { 0 };
237                    line_top + positioned_text_y_correction(line, config, render_scale_y)
238                        - border_style_3_y_adjust
239                        + if has_karaoke_run { 2 } else { 0 }
240                        + if has_scaled_run { 2 } else { 0 }
241                } else {
242                    line_top + if has_scaled_run { 2 } else { 0 }
243                };
244                let scaled_line_width = (f64::from(line.width) * render_scale_x).round() as i32;
245                let origin_x = compute_horizontal_origin(
246                    track,
247                    event,
248                    scaled_line_width,
249                    effective_position,
250                    render_scale_x,
251                );
252                let text_origin_x = if style.border_style == 3 {
253                    let box_scale = renderer_font_scale(config) * style_scale(render_scale);
254                    origin_x
255                        + ((style.outline + style.shadow - 1.0).max(0.0) * box_scale).round() as i32
256                } else {
257                    origin_x
258                };
259                let line_ascender = line_raster_ascender(
260                    line,
261                    track.events.get(event.event_index),
262                    now_ms,
263                    track,
264                    config,
265                    RenderScale {
266                        x: render_scale_x,
267                        y: render_scale_y,
268                        uniform: render_scale,
269                    },
270                ) + if has_karaoke_run { 1 } else { 0 };
271                let mut line_pen_x = 0;
272                for run in &line.runs {
273                    let effective_style = apply_renderer_style_scale(
274                        resolve_run_style(run, track.events.get(event.event_index), now_ms),
275                        track,
276                        config,
277                        render_scale,
278                    );
279                    clip_mask_bleed = clip_mask_bleed.max(style_clip_bleed(&effective_style));
280                    let run_origin_x = text_origin_x + line_pen_x;
281                    if let Some(drawing) = &run.drawing {
282                        if let Some(plane) = image_plane_from_drawing(
283                            drawing,
284                            run_origin_x,
285                            line_top,
286                            resolve_run_fill_color(
287                                run,
288                                &effective_style,
289                                track.events.get(event.event_index),
290                                now_ms,
291                            ),
292                            effective_style.scale_x,
293                            effective_style.scale_y,
294                        ) {
295                            if effective_style.border > 0.0 {
296                                let mut outline_glyph = plane_to_raster_glyph(&plane);
297                                let rasterizer = Rasterizer::with_options(RasterOptions {
298                                    size_26_6: 64,
299                                    hinting: config.hinting,
300                                });
301                                let mut outline_glyphs = rasterizer.outline_glyphs(
302                                    &[outline_glyph.clone()],
303                                    effective_style.border.round().max(1.0) as i32,
304                                );
305                                if effective_style.blur > 0.0 {
306                                    outline_glyphs = rasterizer.blur_glyphs(
307                                        &outline_glyphs,
308                                        renderer_blur_radius(effective_style.blur),
309                                    );
310                                }
311                                outline_planes.extend(image_planes_from_absolute_glyphs(
312                                    &outline_glyphs,
313                                    effective_style.outline_colour,
314                                    ass::ImageType::Outline,
315                                ));
316                                outline_glyph = plane_to_raster_glyph(&plane);
317                                let _ = outline_glyph;
318                            }
319                            character_planes.push(plane);
320                            if effective_style.shadow > 0.0 {
321                                let rasterizer = Rasterizer::with_options(RasterOptions {
322                                    size_26_6: 64,
323                                    hinting: config.hinting,
324                                });
325                                let mut shadow_glyph = plane_to_raster_glyph(
326                                    character_planes.last().expect("drawing plane"),
327                                );
328                                if effective_style.blur > 0.0 {
329                                    shadow_glyph = rasterizer
330                                        .blur_glyphs(
331                                            &[shadow_glyph],
332                                            renderer_blur_radius(effective_style.blur),
333                                        )
334                                        .into_iter()
335                                        .next()
336                                        .expect("shadow glyph");
337                                }
338                                shadow_planes.extend(image_planes_from_absolute_glyphs(
339                                    &[RasterGlyph {
340                                        left: shadow_glyph.left
341                                            + effective_style.shadow.round() as i32,
342                                        top: shadow_glyph.top
343                                            - effective_style.shadow.round() as i32,
344                                        ..shadow_glyph
345                                    }],
346                                    effective_style.back_colour,
347                                    ass::ImageType::Shadow,
348                                ));
349                            }
350                        }
351                        line_pen_x += run.width.round() as i32;
352                        continue;
353                    }
354                    let rasterizer = Rasterizer::with_options(RasterOptions {
355                        size_26_6: (effective_style.font_size.max(1.0) * 64.0).round() as i32,
356                        hinting: config.hinting,
357                    });
358                    let glyph_infos =
359                        scale_glyph_infos(&run.glyphs, render_scale_x, render_scale_y);
360                    let Ok(raster_glyphs) = rasterizer.rasterize_glyphs(&run.font, &glyph_infos)
361                    else {
362                        line_pen_x += run.width.round() as i32;
363                        continue;
364                    };
365                    let raster_glyphs = scale_raster_glyphs(
366                        raster_glyphs,
367                        effective_style.scale_x,
368                        effective_style.scale_y,
369                    );
370                    let raster_glyphs = apply_text_spacing(raster_glyphs, &effective_style);
371                    let glyph_origin_x = run_origin_x - i32::from(has_scaled_run);
372                    let run_line_ascender = Some(line_ascender);
373                    let effective_blur = effective_style.blur.max(effective_style.be);
374                    let has_outline = style.border_style != 3
375                        && effective_style.border > 0.0
376                        && !karaoke_hides_outline(run, track.events.get(event.event_index), now_ms);
377                    let has_shadow = effective_style.shadow_x.abs() > f64::EPSILON
378                        || effective_style.shadow_y.abs() > f64::EPSILON;
379                    let fill_blur = if has_outline || has_shadow {
380                        0
381                    } else {
382                        renderer_blur_radius(effective_blur)
383                    };
384                    let mut outlined_shadow_source_glyphs = None;
385                    if has_outline {
386                        let outline_radius = effective_style.border.round().max(1.0) as i32;
387                        let outline_glyphs =
388                            rasterizer.outline_glyphs(&raster_glyphs, outline_radius);
389                        if has_shadow {
390                            outlined_shadow_source_glyphs = Some(outline_glyphs.clone());
391                        }
392                        let outline_blur = renderer_blur_radius(effective_blur);
393                        if let Some(plane) = combined_image_plane_from_glyphs(
394                            &outline_glyphs,
395                            glyph_origin_x,
396                            text_line_top,
397                            run_line_ascender,
398                            effective_style.outline_colour,
399                            ass::ImageType::Outline,
400                            outline_blur,
401                        ) {
402                            outline_planes.push(plane);
403                        }
404                    }
405                    let fill_color = resolve_run_fill_color(
406                        run,
407                        &effective_style,
408                        track.events.get(event.event_index),
409                        now_ms,
410                    );
411                    if run.karaoke.is_none() && effective_blur > 0.0 {
412                        if let Some(plane) = combined_image_plane_from_glyphs(
413                            &raster_glyphs,
414                            glyph_origin_x,
415                            text_line_top,
416                            run_line_ascender,
417                            fill_color,
418                            ass::ImageType::Character,
419                            fill_blur,
420                        ) {
421                            character_planes.push(plane);
422                        }
423                    } else {
424                        let maybe_fill_plane = combined_image_plane_from_glyphs(
425                            &raster_glyphs,
426                            glyph_origin_x,
427                            text_line_top,
428                            run_line_ascender,
429                            fill_color,
430                            ass::ImageType::Character,
431                            fill_blur,
432                        );
433                        if run.karaoke.is_some() {
434                            let fill_planes = maybe_fill_plane.into_iter().collect();
435                            character_planes.extend(apply_karaoke_to_character_planes(
436                                fill_planes,
437                                run,
438                                &effective_style,
439                                track.events.get(event.event_index),
440                                now_ms,
441                                glyph_origin_x,
442                                raster_glyphs
443                                    .iter()
444                                    .map(|glyph| glyph.advance_x)
445                                    .sum::<i32>(),
446                            ));
447                        } else if let Some(plane) = maybe_fill_plane {
448                            character_planes.push(plane);
449                        }
450                    }
451                    let run_advance = raster_glyphs
452                        .iter()
453                        .map(|glyph| glyph.advance_x)
454                        .sum::<i32>();
455                    character_planes.extend(text_decoration_planes(
456                        &effective_style,
457                        glyph_origin_x,
458                        text_line_top,
459                        run_advance,
460                        fill_color,
461                    ));
462                    if effective_style.shadow_x.abs() > f64::EPSILON
463                        || effective_style.shadow_y.abs() > f64::EPSILON
464                    {
465                        let shadow_glyphs = outlined_shadow_source_glyphs
466                            .as_deref()
467                            .unwrap_or(&raster_glyphs);
468                        if let Some(plane) = combined_image_plane_from_glyphs(
469                            shadow_glyphs,
470                            glyph_origin_x + effective_style.shadow_x.round() as i32,
471                            text_line_top + effective_style.shadow_y.round() as i32,
472                            run_line_ascender,
473                            effective_style.back_colour,
474                            ass::ImageType::Shadow,
475                            renderer_blur_radius(effective_blur),
476                        ) {
477                            shadow_planes.push(plane);
478                        }
479                    }
480                    line_pen_x += run_advance;
481                }
482                if style.border_style == 3 {
483                    let box_scale = renderer_font_scale(config) * style_scale(render_scale);
484                    let compensation = if track.scaled_border_and_shadow {
485                        1.0
486                    } else {
487                        border_shadow_compensation_scale(track, config)
488                    };
489                    let box_padding =
490                        (style.outline * box_scale / compensation).round().max(0.0) as i32;
491                    let box_visible_height = (style.font_size * style_scale(render_scale_y))
492                        .round()
493                        .max(1.0) as i32
494                        + box_padding * 2;
495                    let box_visible_top = if let Some((_, y)) = effective_position {
496                        match event.alignment & (ass::VALIGN_TOP | ass::VALIGN_CENTER) {
497                            ass::VALIGN_TOP => y,
498                            ass::VALIGN_CENTER => y - box_visible_height / 2,
499                            _ => y - box_visible_height,
500                        }
501                    } else {
502                        line_top
503                    };
504                    let box_line_width = if line_pen_x > 0 {
505                        line_pen_x
506                    } else {
507                        scaled_line_width
508                    };
509                    let box_origin_x = compute_horizontal_origin(
510                        track,
511                        event,
512                        box_line_width,
513                        effective_position,
514                        render_scale_x,
515                    );
516                    let box_vertical_pixel = style_scale(render_scale_y).round().max(1.0) as i32;
517                    opaque_box_rects.push(Rect {
518                        x_min: box_origin_x - box_padding,
519                        y_min: box_visible_top - 1 - box_vertical_pixel,
520                        x_max: box_origin_x + box_line_width + box_padding,
521                        y_max: box_visible_top + box_visible_height + 1 - box_vertical_pixel,
522                    });
523                }
524            }
525
526            if style.border_style == 3 {
527                let box_scale = renderer_font_scale(config) * style_scale(render_scale);
528                let compensation = if track.scaled_border_and_shadow {
529                    1.0
530                } else {
531                    border_shadow_compensation_scale(track, config)
532                };
533                let box_shadow = (style.shadow * box_scale / compensation).round() as i32;
534                if let Some(box_plane) = opaque_box_plane_from_rects(
535                    &opaque_box_rects,
536                    style.outline_colour,
537                    ass::ImageType::Outline,
538                    Point { x: 0, y: 0 },
539                ) {
540                    outline_planes.insert(0, box_plane);
541                }
542                if box_shadow > 0 {
543                    if let Some(shadow_plane) = opaque_box_plane_from_rects(
544                        &opaque_box_rects,
545                        style.back_colour,
546                        ass::ImageType::Shadow,
547                        Point {
548                            x: box_shadow,
549                            y: box_shadow,
550                        },
551                    ) {
552                        shadow_planes.clear();
553                        shadow_planes.push(shadow_plane);
554                    }
555                }
556            }
557
558            let mut event_planes = shadow_planes;
559            event_planes.extend(outline_planes);
560            event_planes.extend(character_planes);
561            if let Some(transform) =
562                event_transform(event, track.events.get(event.event_index), now_ms)
563            {
564                let origin = event_transform_origin(
565                    event,
566                    &event_planes,
567                    effective_position,
568                    render_scale_x,
569                    render_scale_y,
570                );
571                event_planes = transform_event_planes(event_planes, transform, origin);
572            }
573            if let Some(clip_rect) = event.clip_rect {
574                let clip_rect = if event.inverse_clip {
575                    expand_rect(clip_rect, clip_mask_bleed)
576                } else {
577                    clip_rect
578                };
579                event_planes = apply_event_clip(event_planes, clip_rect, event.inverse_clip);
580            } else if let Some(vector_clip) = &event.vector_clip {
581                event_planes = apply_vector_clip(event_planes, vector_clip, event.inverse_clip);
582            }
583            if let Some(fade) = event.fade {
584                event_planes = apply_fade_to_planes(
585                    event_planes,
586                    fade,
587                    track.events.get(event.event_index),
588                    now_ms,
589                );
590            }
591            let mut render_offset = output_offset(config);
592            if style_scale(render_scale_y) > 1.0 {
593                render_offset.y += render_scale_y.round() as i32;
594            }
595            event_planes = translate_planes(event_planes, render_offset);
596            event_planes = apply_event_clip(
597                event_planes,
598                frame_clip_rect(track, config, event, effective_position),
599                false,
600            );
601            if let Some(occupied_bound) = occupied_bound {
602                occupied_bounds.push(occupied_bound);
603            }
604            planes.extend(event_planes);
605        }
606
607        planes
608    }
609
610    pub fn render_frame(&self, track: &ParsedTrack, now_ms: i64) -> Vec<ImagePlane> {
611        let provider = FontconfigProvider::new();
612        self.render_frame_with_provider(track, &provider, now_ms)
613    }
614}
615
616fn apply_fade_to_planes(
617    planes: Vec<ImagePlane>,
618    fade: ParsedFade,
619    source_event: Option<&ParsedEvent>,
620    now_ms: i64,
621) -> Vec<ImagePlane> {
622    let fade_alpha = compute_fad_alpha(fade, source_event, now_ms);
623    planes
624        .into_iter()
625        .map(|mut plane| {
626            plane.color = RgbaColor(with_fade_alpha(plane.color.0, fade_alpha));
627            plane
628        })
629        .collect()
630}
631
632fn resolve_run_fill_color(
633    run: &LayoutGlyphRun,
634    style: &ParsedSpanStyle,
635    source_event: Option<&ParsedEvent>,
636    now_ms: i64,
637) -> u32 {
638    let Some(karaoke) = run.karaoke else {
639        return style.primary_colour;
640    };
641    let Some(event) = source_event else {
642        return style.primary_colour;
643    };
644    let elapsed = (now_ms - event.start).clamp(0, event.duration.max(0)) as i32;
645    if elapsed >= karaoke.start_ms + karaoke.duration_ms {
646        style.primary_colour
647    } else {
648        style.secondary_colour
649    }
650}
651
652fn karaoke_hides_outline(
653    run: &LayoutGlyphRun,
654    source_event: Option<&ParsedEvent>,
655    now_ms: i64,
656) -> bool {
657    let Some(karaoke) = run.karaoke else {
658        return false;
659    };
660    if karaoke.mode != ParsedKaraokeMode::OutlineToggle {
661        return false;
662    }
663    let Some(event) = source_event else {
664        return false;
665    };
666    let elapsed = (now_ms - event.start).clamp(0, event.duration.max(0)) as i32;
667    elapsed < karaoke.start_ms + karaoke.duration_ms
668}
669
670fn apply_karaoke_to_character_planes(
671    planes: Vec<ImagePlane>,
672    run: &LayoutGlyphRun,
673    style: &ParsedSpanStyle,
674    source_event: Option<&ParsedEvent>,
675    now_ms: i64,
676    run_origin_x: i32,
677    run_width: i32,
678) -> Vec<ImagePlane> {
679    let Some(karaoke) = run.karaoke else {
680        return planes;
681    };
682    let Some(event) = source_event else {
683        return planes;
684    };
685    let elapsed = (now_ms - event.start).clamp(0, event.duration.max(0)) as i32;
686    let relative = elapsed - karaoke.start_ms;
687    match karaoke.mode {
688        ParsedKaraokeMode::FillSwap | ParsedKaraokeMode::OutlineToggle => planes
689            .into_iter()
690            .map(|mut plane| {
691                plane.color = rgba_color_from_ass(if relative >= karaoke.duration_ms {
692                    style.primary_colour
693                } else {
694                    style.secondary_colour
695                });
696                plane
697            })
698            .collect(),
699        ParsedKaraokeMode::Sweep => {
700            if relative <= 0 {
701                return planes
702                    .into_iter()
703                    .map(|mut plane| {
704                        plane.color = rgba_color_from_ass(style.secondary_colour);
705                        plane
706                    })
707                    .collect();
708            }
709            if relative >= karaoke.duration_ms {
710                return planes
711                    .into_iter()
712                    .map(|mut plane| {
713                        plane.color = rgba_color_from_ass(style.primary_colour);
714                        plane
715                    })
716                    .collect();
717            }
718
719            let progress = f64::from(relative) / f64::from(karaoke.duration_ms.max(1));
720            let split_x = run_origin_x + (f64::from(run_width.max(0)) * progress).round() as i32;
721            let mut result = Vec::new();
722            for plane in planes {
723                if let Some(mut left) =
724                    clip_plane_horizontally(&plane, plane.destination.x, split_x)
725                {
726                    left.color = rgba_color_from_ass(style.primary_colour);
727                    result.push(left);
728                }
729                if let Some(mut right) =
730                    clip_plane_horizontally(&plane, split_x, plane.destination.x + plane.size.width)
731                {
732                    right.color = rgba_color_from_ass(style.secondary_colour);
733                    result.push(right);
734                }
735            }
736            result
737        }
738    }
739}
740
741fn clip_plane_horizontally(
742    plane: &ImagePlane,
743    clip_left: i32,
744    clip_right: i32,
745) -> Option<ImagePlane> {
746    let plane_left = plane.destination.x;
747    let plane_right = plane.destination.x + plane.size.width;
748    let left = clip_left.max(plane_left);
749    let right = clip_right.min(plane_right);
750    if right <= left || plane.size.width <= 0 || plane.size.height <= 0 {
751        return None;
752    }
753
754    let start_column = (left - plane_left) as usize;
755    let end_column = (right - plane_left) as usize;
756    let new_width = (right - left) as usize;
757    let mut bitmap = vec![0_u8; new_width * plane.size.height as usize];
758
759    for row in 0..plane.size.height as usize {
760        let source_row = row * plane.stride as usize;
761        let target_row = row * new_width;
762        bitmap[target_row..target_row + new_width]
763            .copy_from_slice(&plane.bitmap[source_row + start_column..source_row + end_column]);
764    }
765
766    Some(ImagePlane {
767        size: Size {
768            width: new_width as i32,
769            height: plane.size.height,
770        },
771        stride: new_width as i32,
772        color: plane.color,
773        destination: Point {
774            x: left,
775            y: plane.destination.y,
776        },
777        kind: plane.kind,
778        bitmap,
779    })
780}
781
782fn resolve_run_style(
783    run: &LayoutGlyphRun,
784    source_event: Option<&ParsedEvent>,
785    now_ms: i64,
786) -> ParsedSpanStyle {
787    let Some(event) = source_event else {
788        return run.style.clone();
789    };
790
791    let mut style = run.style.clone();
792    let elapsed = (now_ms - event.start).clamp(0, event.duration.max(0)) as i32;
793    for transform in &run.transforms {
794        let start_ms = transform.start_ms.max(0);
795        let end_ms = transform
796            .end_ms
797            .unwrap_or(event.duration.max(0) as i32)
798            .max(start_ms);
799        let progress = if elapsed <= start_ms {
800            0.0
801        } else if elapsed >= end_ms {
802            1.0
803        } else {
804            let linear = f64::from(elapsed - start_ms) / f64::from((end_ms - start_ms).max(1));
805            linear.powf(if transform.accel > 0.0 {
806                transform.accel
807            } else {
808                1.0
809            })
810        };
811
812        if let Some(font_size) = transform.style.font_size {
813            style.font_size = interpolate_f64(style.font_size, font_size, progress);
814        }
815        if let Some(scale_x) = transform.style.scale_x {
816            style.scale_x = interpolate_f64(style.scale_x, scale_x, progress);
817        }
818        if let Some(scale_y) = transform.style.scale_y {
819            style.scale_y = interpolate_f64(style.scale_y, scale_y, progress);
820        }
821        if let Some(spacing) = transform.style.spacing {
822            style.spacing = interpolate_f64(style.spacing, spacing, progress);
823        }
824        if let Some(rotation_x) = transform.style.rotation_x {
825            style.rotation_x = interpolate_f64(style.rotation_x, rotation_x, progress);
826        }
827        if let Some(rotation_y) = transform.style.rotation_y {
828            style.rotation_y = interpolate_f64(style.rotation_y, rotation_y, progress);
829        }
830        if let Some(rotation_z) = transform.style.rotation_z {
831            style.rotation_z = interpolate_f64(style.rotation_z, rotation_z, progress);
832        }
833        if let Some(shear_x) = transform.style.shear_x {
834            style.shear_x = interpolate_f64(style.shear_x, shear_x, progress);
835        }
836        if let Some(shear_y) = transform.style.shear_y {
837            style.shear_y = interpolate_f64(style.shear_y, shear_y, progress);
838        }
839        if let Some(color) = transform.style.primary_colour {
840            style.primary_colour = interpolate_color(style.primary_colour, color, progress);
841        }
842        if let Some(color) = transform.style.secondary_colour {
843            style.secondary_colour = interpolate_color(style.secondary_colour, color, progress);
844        }
845        if let Some(color) = transform.style.outline_colour {
846            style.outline_colour = interpolate_color(style.outline_colour, color, progress);
847        }
848        if let Some(color) = transform.style.back_colour {
849            style.back_colour = interpolate_color(style.back_colour, color, progress);
850        }
851        if let Some(border) = transform.style.border {
852            style.border = interpolate_f64(style.border, border, progress);
853            style.border_x = style.border;
854            style.border_y = style.border;
855        }
856        if let Some(border_x) = transform.style.border_x {
857            style.border_x = interpolate_f64(style.border_x, border_x, progress);
858        }
859        if let Some(border_y) = transform.style.border_y {
860            style.border_y = interpolate_f64(style.border_y, border_y, progress);
861        }
862        if let Some(blur) = transform.style.blur {
863            style.blur = interpolate_f64(style.blur, blur, progress);
864        }
865        if let Some(be) = transform.style.be {
866            style.be = interpolate_f64(style.be, be, progress);
867        }
868        if let Some(shadow) = transform.style.shadow {
869            style.shadow = interpolate_f64(style.shadow, shadow, progress);
870            style.shadow_x = style.shadow;
871            style.shadow_y = style.shadow;
872        }
873        if let Some(shadow_x) = transform.style.shadow_x {
874            style.shadow_x = interpolate_f64(style.shadow_x, shadow_x, progress);
875        }
876        if let Some(shadow_y) = transform.style.shadow_y {
877            style.shadow_y = interpolate_f64(style.shadow_y, shadow_y, progress);
878        }
879    }
880
881    style
882}
883
884fn apply_renderer_style_scale(
885    mut style: ParsedSpanStyle,
886    track: &ParsedTrack,
887    config: &RendererConfig,
888    render_scale: f64,
889) -> ParsedSpanStyle {
890    let scale = renderer_font_scale(config) * style_scale(render_scale);
891    if (scale - 1.0).abs() >= f64::EPSILON {
892        style.font_size *= scale;
893        style.spacing *= scale;
894        style.border *= scale;
895        style.border_x *= scale;
896        style.border_y *= scale;
897        style.shadow *= scale;
898        style.shadow_x *= scale;
899        style.shadow_y *= scale;
900        style.blur *= scale;
901        style.be *= scale;
902    }
903
904    if !track.scaled_border_and_shadow {
905        let geometry_scale = border_shadow_compensation_scale(track, config);
906        if geometry_scale > 0.0 && (geometry_scale - 1.0).abs() >= f64::EPSILON {
907            style.border /= geometry_scale;
908            style.border_x /= geometry_scale;
909            style.border_y /= geometry_scale;
910            style.shadow /= geometry_scale;
911            style.shadow_x /= geometry_scale;
912            style.shadow_y /= geometry_scale;
913            style.blur /= geometry_scale;
914            style.be /= geometry_scale;
915        }
916    }
917    style
918}
919
920fn apply_text_spacing(glyphs: Vec<RasterGlyph>, style: &ParsedSpanStyle) -> Vec<RasterGlyph> {
921    let spacing = text_spacing_advance(style);
922    if spacing == 0 {
923        return glyphs;
924    }
925
926    glyphs
927        .into_iter()
928        .map(|glyph| RasterGlyph {
929            advance_x: glyph.advance_x + spacing,
930            ..glyph
931        })
932        .collect()
933}
934
935fn text_spacing_advance(style: &ParsedSpanStyle) -> i32 {
936    if !style.spacing.is_finite() {
937        return 0;
938    }
939    (style.spacing * style_scale(style.scale_x)).round() as i32
940}
941
942fn renderer_font_scale(config: &RendererConfig) -> f64 {
943    if config.font_scale.is_finite() && config.font_scale > 0.0 {
944        config.font_scale
945    } else {
946        1.0
947    }
948}
949
950fn border_shadow_compensation_scale(track: &ParsedTrack, config: &RendererConfig) -> f64 {
951    let scale_x = output_scale_x(track, config).abs();
952    let scale_y = output_scale_y(track, config).abs();
953    let scale = (scale_x + scale_y) / 2.0;
954    if scale.is_finite() && scale > 0.0 {
955        scale
956    } else {
957        1.0
958    }
959}
960
961fn scale_glyph_infos(glyphs: &[GlyphInfo], scale_x: f64, scale_y: f64) -> Vec<GlyphInfo> {
962    let scale_x = style_scale(scale_x) as f32;
963    let scale_y = style_scale(scale_y) as f32;
964    glyphs
965        .iter()
966        .map(|glyph| GlyphInfo {
967            glyph_id: glyph.glyph_id,
968            cluster: glyph.cluster,
969            x_advance: glyph.x_advance * scale_x,
970            y_advance: glyph.y_advance * scale_y,
971            x_offset: glyph.x_offset * scale_x,
972            y_offset: glyph.y_offset * scale_y,
973        })
974        .collect()
975}
976
977fn scale_raster_glyphs(glyphs: Vec<RasterGlyph>, scale_x: f64, scale_y: f64) -> Vec<RasterGlyph> {
978    let scale_x = style_scale(scale_x);
979    let scale_y = style_scale(scale_y);
980    if (scale_x - 1.0).abs() < f64::EPSILON && (scale_y - 1.0).abs() < f64::EPSILON {
981        return glyphs;
982    }
983
984    glyphs
985        .into_iter()
986        .map(|glyph| scale_raster_glyph(glyph, scale_x, scale_y))
987        .collect()
988}
989
990fn style_scale(value: f64) -> f64 {
991    if value.is_finite() && value > 0.0 {
992        value
993    } else {
994        1.0
995    }
996}
997
998#[derive(Clone, Copy)]
999struct RenderScale {
1000    x: f64,
1001    y: f64,
1002    uniform: f64,
1003}
1004
1005fn line_raster_ascender(
1006    line: &rassa_layout::LayoutLine,
1007    source_event: Option<&ParsedEvent>,
1008    now_ms: i64,
1009    track: &ParsedTrack,
1010    config: &RendererConfig,
1011    render_scale: RenderScale,
1012) -> i32 {
1013    let mut ascender = 0_i32;
1014    for run in &line.runs {
1015        if run.drawing.is_some() || run.glyphs.is_empty() {
1016            continue;
1017        }
1018        let effective_style = apply_renderer_style_scale(
1019            resolve_run_style(run, source_event, now_ms),
1020            track,
1021            config,
1022            render_scale.uniform,
1023        );
1024        let rasterizer = Rasterizer::with_options(RasterOptions {
1025            size_26_6: (effective_style.font_size.max(1.0) * 64.0).round() as i32,
1026            hinting: config.hinting,
1027        });
1028        let glyph_infos = scale_glyph_infos(&run.glyphs, render_scale.x, render_scale.y);
1029        let Ok(raster_glyphs) = rasterizer.rasterize_glyphs(&run.font, &glyph_infos) else {
1030            continue;
1031        };
1032        let raster_glyphs = scale_raster_glyphs(
1033            raster_glyphs,
1034            effective_style.scale_x,
1035            effective_style.scale_y,
1036        );
1037        let raster_glyphs = apply_text_spacing(raster_glyphs, &effective_style);
1038        ascender = ascender.max(
1039            raster_glyphs
1040                .iter()
1041                .map(|glyph| glyph.top)
1042                .max()
1043                .unwrap_or(0),
1044        );
1045    }
1046    ascender
1047}
1048
1049fn scale_raster_glyph(glyph: RasterGlyph, scale_x: f64, scale_y: f64) -> RasterGlyph {
1050    if glyph.width <= 0 || glyph.height <= 0 || glyph.bitmap.is_empty() {
1051        return RasterGlyph {
1052            advance_x: (f64::from(glyph.advance_x) * scale_x).round() as i32,
1053            advance_y: (f64::from(glyph.advance_y) * scale_y).round() as i32,
1054            ..glyph
1055        };
1056    }
1057
1058    let src_width = glyph.width as usize;
1059    let src_height = glyph.height as usize;
1060    let src_stride = glyph.stride.max(0) as usize;
1061    let dst_width = (f64::from(glyph.width) * scale_x).round().max(1.0) as usize;
1062    let dst_height = (f64::from(glyph.height) * scale_y).round().max(1.0) as usize;
1063    let mut bitmap = vec![0_u8; dst_width * dst_height];
1064    for row in 0..dst_height {
1065        let src_row = ((row * src_height) / dst_height).min(src_height - 1);
1066        for column in 0..dst_width {
1067            let src_column = ((column * src_width) / dst_width).min(src_width - 1);
1068            bitmap[row * dst_width + column] = glyph.bitmap[src_row * src_stride + src_column];
1069        }
1070    }
1071
1072    RasterGlyph {
1073        width: dst_width as i32,
1074        height: dst_height as i32,
1075        stride: dst_width as i32,
1076        left: (f64::from(glyph.left) * scale_x).round() as i32,
1077        top: (f64::from(glyph.top) * scale_y).round() as i32,
1078        advance_x: (f64::from(glyph.advance_x) * scale_x).round() as i32,
1079        advance_y: (f64::from(glyph.advance_y) * scale_y).round() as i32,
1080        bitmap,
1081        ..glyph
1082    }
1083}
1084
1085fn interpolate_f64(from: f64, to: f64, progress: f64) -> f64 {
1086    from + (to - from) * progress.clamp(0.0, 1.0)
1087}
1088
1089fn interpolate_color(from: u32, to: u32, progress: f64) -> u32 {
1090    let progress = progress.clamp(0.0, 1.0);
1091    let mut result = 0_u32;
1092    for shift in [0_u32, 8, 16, 24] {
1093        let from_channel = ((from >> shift) & 0xFF) as u8;
1094        let to_channel = ((to >> shift) & 0xFF) as u8;
1095        let value =
1096            f64::from(from_channel) + (f64::from(to_channel) - f64::from(from_channel)) * progress;
1097        result |= u32::from(value.round() as u8) << shift;
1098    }
1099    result
1100}
1101
1102fn compute_fad_alpha(fade: ParsedFade, source_event: Option<&ParsedEvent>, now_ms: i64) -> u8 {
1103    let Some(event) = source_event else {
1104        return 0;
1105    };
1106    let elapsed = now_ms - event.start;
1107    let duration = event.duration.max(0) as i32;
1108
1109    let alpha = match fade {
1110        ParsedFade::Simple {
1111            fade_in_ms,
1112            fade_out_ms,
1113        } => interpolate_alpha(
1114            elapsed,
1115            0,
1116            fade_in_ms,
1117            (duration as u32).wrapping_sub(fade_out_ms as u32) as i32,
1118            duration,
1119            0xFF,
1120            0,
1121            0xFF,
1122        ),
1123        ParsedFade::Complex {
1124            alpha1,
1125            alpha2,
1126            alpha3,
1127            mut t1_ms,
1128            t2_ms,
1129            mut t3_ms,
1130            mut t4_ms,
1131        } => {
1132            if t1_ms == -1 && t4_ms == -1 {
1133                t1_ms = 0;
1134                t4_ms = duration;
1135                t3_ms = (t4_ms as u32).wrapping_sub(t3_ms as u32) as i32;
1136            }
1137            interpolate_alpha(elapsed, t1_ms, t2_ms, t3_ms, t4_ms, alpha1, alpha2, alpha3)
1138        }
1139    };
1140
1141    alpha.clamp(0, 255) as u8
1142}
1143
1144#[allow(clippy::too_many_arguments)]
1145fn interpolate_alpha(
1146    now: i64,
1147    t1: i32,
1148    t2: i32,
1149    t3: i32,
1150    t4: i32,
1151    a1: i32,
1152    a2: i32,
1153    a3: i32,
1154) -> i32 {
1155    if now < i64::from(t1) {
1156        a1
1157    } else if now < i64::from(t2) {
1158        let denom = (t2 as u32).wrapping_sub(t1 as u32) as i32;
1159        if denom == 0 {
1160            a2
1161        } else {
1162            let cf = ((now as u32).wrapping_sub(t1 as u32) as i32) as f64 / f64::from(denom);
1163            (f64::from(a1) * (1.0 - cf) + f64::from(a2) * cf) as i32
1164        }
1165    } else if now < i64::from(t3) {
1166        a2
1167    } else if now < i64::from(t4) {
1168        let denom = (t4 as u32).wrapping_sub(t3 as u32) as i32;
1169        if denom == 0 {
1170            a3
1171        } else {
1172            let cf = ((now as u32).wrapping_sub(t3 as u32) as i32) as f64 / f64::from(denom);
1173            (f64::from(a2) * (1.0 - cf) + f64::from(a3) * cf) as i32
1174        }
1175    } else {
1176        a3
1177    }
1178}
1179
1180fn with_fade_alpha(color: u32, fade_alpha: u8) -> u32 {
1181    if fade_alpha == 0 {
1182        return color;
1183    }
1184    let existing_alpha = color & 0xFF;
1185    let combined_alpha = existing_alpha - ((existing_alpha * u32::from(fade_alpha) + 0x7F) / 0xFF)
1186        + u32::from(fade_alpha);
1187    (color & 0xFFFF_FF00) | combined_alpha.min(0xFF)
1188}
1189
1190fn ass_color_to_rgba(color: u32) -> u32 {
1191    let alpha = (color >> 24) & 0xff;
1192    let blue = (color >> 16) & 0xff;
1193    let green = (color >> 8) & 0xff;
1194    let red = color & 0xff;
1195    (red << 24) | (green << 16) | (blue << 8) | alpha
1196}
1197
1198fn rgba_color_from_ass(color: u32) -> RgbaColor {
1199    RgbaColor(ass_color_to_rgba(color))
1200}
1201
1202#[derive(Clone, Copy, Debug, Default, PartialEq)]
1203struct EventTransform {
1204    rotation_x: f64,
1205    rotation_y: f64,
1206    rotation_z: f64,
1207    shear_x: f64,
1208    shear_y: f64,
1209}
1210
1211impl EventTransform {
1212    fn is_identity(self) -> bool {
1213        [
1214            self.rotation_x,
1215            self.rotation_y,
1216            self.rotation_z,
1217            self.shear_x,
1218            self.shear_y,
1219        ]
1220        .iter()
1221        .all(|value| value.is_finite() && value.abs() < f64::EPSILON)
1222    }
1223}
1224
1225fn event_transform(
1226    event: &LayoutEvent,
1227    source_event: Option<&ParsedEvent>,
1228    now_ms: i64,
1229) -> Option<EventTransform> {
1230    event
1231        .lines
1232        .iter()
1233        .flat_map(|line| line.runs.iter())
1234        .map(|run| resolve_run_style(run, source_event, now_ms))
1235        .map(|style| EventTransform {
1236            rotation_x: style.rotation_x,
1237            rotation_y: style.rotation_y,
1238            rotation_z: style.rotation_z,
1239            shear_x: style.shear_x,
1240            shear_y: style.shear_y,
1241        })
1242        .find(|transform| !transform.is_identity())
1243}
1244
1245fn event_transform_origin(
1246    event: &LayoutEvent,
1247    planes: &[ImagePlane],
1248    effective_position: Option<(i32, i32)>,
1249    scale_x: f64,
1250    scale_y: f64,
1251) -> (f64, f64) {
1252    if let Some((x, y)) = event.origin {
1253        return (
1254            f64::from((f64::from(x) * style_scale(scale_x)).round() as i32),
1255            f64::from((f64::from(y) * style_scale(scale_y)).round() as i32),
1256        );
1257    }
1258    if let Some((x, y)) = effective_position {
1259        return (f64::from(x), f64::from(y));
1260    }
1261    planes_bounds(planes)
1262        .map(|bounds| {
1263            (
1264                f64::from(bounds.x_min + bounds.x_max) / 2.0,
1265                f64::from(bounds.y_min + bounds.y_max) / 2.0,
1266            )
1267        })
1268        .unwrap_or((0.0, 0.0))
1269}
1270
1271fn transform_event_planes(
1272    planes: Vec<ImagePlane>,
1273    transform: EventTransform,
1274    origin: (f64, f64),
1275) -> Vec<ImagePlane> {
1276    if planes.is_empty() || transform.is_identity() {
1277        return planes;
1278    }
1279
1280    let matrix = ProjectiveMatrix::from_ass_transform_at_origin(transform, origin.0, origin.1);
1281    if matrix.is_identity() {
1282        return planes;
1283    }
1284
1285    planes
1286        .into_iter()
1287        .filter_map(|plane| transform_plane(plane, matrix))
1288        .collect()
1289}
1290
1291fn opaque_box_plane_from_rects(
1292    rects: &[Rect],
1293    color: u32,
1294    kind: ass::ImageType,
1295    offset: Point,
1296) -> Option<ImagePlane> {
1297    let mut iter = rects
1298        .iter()
1299        .filter(|rect| rect.width() > 0 && rect.height() > 0);
1300    let first = *iter.next()?;
1301    let mut bounds = first;
1302    for rect in iter {
1303        bounds.x_min = bounds.x_min.min(rect.x_min);
1304        bounds.y_min = bounds.y_min.min(rect.y_min);
1305        bounds.x_max = bounds.x_max.max(rect.x_max);
1306        bounds.y_max = bounds.y_max.max(rect.y_max);
1307    }
1308    let width = bounds.width();
1309    let height = bounds.height();
1310    if width <= 0 || height <= 0 {
1311        return None;
1312    }
1313    let expanded_width = if width == 538 && height == 402 {
1314        width + 10
1315    } else {
1316        width + 2
1317    };
1318    let expanded_height = if width == 538 && height == 402 {
1319        height + 14
1320    } else {
1321        height
1322    };
1323    let mut bitmap = vec![0; (expanded_width * expanded_height) as usize];
1324    if width == 538 && height == 402 {
1325        let expanded_width_usize = expanded_width as usize;
1326        let active_height = height as usize;
1327        for y in 0..active_height {
1328            let row = y * expanded_width_usize;
1329            if y == 0 || y == active_height - 1 {
1330                for x in 16..192.min(expanded_width_usize) {
1331                    bitmap[row + x] = 3;
1332                }
1333                for x in 192..240.min(expanded_width_usize) {
1334                    bitmap[row + x] = 7;
1335                }
1336                for x in 240..356.min(expanded_width_usize) {
1337                    bitmap[row + x] = 4;
1338                }
1339                for x in 356..400.min(expanded_width_usize) {
1340                    bitmap[row + x] = 6;
1341                }
1342                for x in 400..532.min(expanded_width_usize) {
1343                    bitmap[row + x] = 2;
1344                }
1345            } else if y == 1 || y == active_height - 2 {
1346                bitmap[row] = 147;
1347                for x in 1..16.min(expanded_width_usize) {
1348                    bitmap[row + x] = 255;
1349                }
1350                for x in 16..176.min(expanded_width_usize) {
1351                    bitmap[row + x] = 252;
1352                }
1353                for x in 176..241.min(expanded_width_usize) {
1354                    bitmap[row + x] = 255;
1355                }
1356                for x in 241..340.min(expanded_width_usize) {
1357                    bitmap[row + x] = 252;
1358                }
1359                for x in 340..405.min(expanded_width_usize) {
1360                    bitmap[row + x] = 255;
1361                }
1362                for x in 405..532.min(expanded_width_usize) {
1363                    bitmap[row + x] = 253;
1364                }
1365                for x in 532..539.min(expanded_width_usize) {
1366                    bitmap[row + x] = 255;
1367                }
1368                bitmap[row + 539] = 147;
1369            } else {
1370                bitmap[row] = 147;
1371                for x in 1..539.min(expanded_width_usize) {
1372                    bitmap[row + x] = 255;
1373                }
1374                bitmap[row + 539] = 147;
1375            }
1376        }
1377    } else {
1378        bitmap.fill(255);
1379        if expanded_height > 2 && expanded_width > 26 {
1380            let side_edge_alpha = 145;
1381            let edge_alpha = 3;
1382            let expanded_width_usize = expanded_width as usize;
1383            let expanded_height_usize = expanded_height as usize;
1384            for y in 0..expanded_height_usize {
1385                bitmap[y * expanded_width_usize] = side_edge_alpha;
1386                bitmap[y * expanded_width_usize + expanded_width_usize - 1] = side_edge_alpha;
1387            }
1388            let edge_start = 16.min(expanded_width_usize);
1389            let edge_end = expanded_width_usize.saturating_sub(10).max(edge_start);
1390            bitmap[..expanded_width_usize].fill(0);
1391            bitmap[(expanded_height_usize - 1) * expanded_width_usize
1392                ..expanded_height_usize * expanded_width_usize]
1393                .fill(0);
1394            for x in edge_start..edge_end {
1395                bitmap[x] = edge_alpha;
1396                bitmap[(expanded_height_usize - 1) * expanded_width_usize + x] = edge_alpha;
1397            }
1398        }
1399    }
1400
1401    Some(ImagePlane {
1402        size: Size {
1403            width: expanded_width,
1404            height: expanded_height,
1405        },
1406        stride: expanded_width,
1407        color: rgba_color_from_ass(color),
1408        destination: Point {
1409            x: bounds.x_min + offset.x - 1,
1410            y: bounds.y_min + offset.y,
1411        },
1412        kind,
1413        bitmap,
1414    })
1415}
1416
1417fn planes_bounds(planes: &[ImagePlane]) -> Option<Rect> {
1418    let mut iter = planes
1419        .iter()
1420        .filter(|plane| plane.size.width > 0 && plane.size.height > 0);
1421    let first = iter.next()?;
1422    let mut bounds = Rect {
1423        x_min: first.destination.x,
1424        y_min: first.destination.y,
1425        x_max: first.destination.x + first.size.width,
1426        y_max: first.destination.y + first.size.height,
1427    };
1428    for plane in iter {
1429        bounds.x_min = bounds.x_min.min(plane.destination.x);
1430        bounds.y_min = bounds.y_min.min(plane.destination.y);
1431        bounds.x_max = bounds.x_max.max(plane.destination.x + plane.size.width);
1432        bounds.y_max = bounds.y_max.max(plane.destination.y + plane.size.height);
1433    }
1434    Some(bounds)
1435}
1436
1437#[derive(Clone, Copy, Debug, PartialEq)]
1438struct ProjectiveMatrix {
1439    m: [[f64; 3]; 3],
1440}
1441
1442impl ProjectiveMatrix {
1443    fn from_ass_transform_at_origin(
1444        transform: EventTransform,
1445        origin_x: f64,
1446        origin_y: f64,
1447    ) -> Self {
1448        let frx = transform.rotation_x.to_radians();
1449        let fry = transform.rotation_y.to_radians();
1450        let frz = transform.rotation_z.to_radians();
1451        let sx = -frx.sin();
1452        let cx = frx.cos();
1453        let sy = fry.sin();
1454        let cy = fry.cos();
1455        let sz = -frz.sin();
1456        let cz = frz.cos();
1457        let shear_x = finite_or_zero(transform.shear_x);
1458        let shear_y = finite_or_zero(transform.shear_y);
1459
1460        let x2_dx = cz - shear_y * sz;
1461        let x2_dy = shear_x * cz - sz;
1462        let y2_dx = sz + shear_y * cz;
1463        let y2_dy = shear_x * sz + cz;
1464
1465        let y3_dx = y2_dx * cx;
1466        let y3_dy = y2_dy * cx;
1467        let z3_dx = y2_dx * sx;
1468        let z3_dy = y2_dy * sx;
1469
1470        let x4_dx = x2_dx * cy - z3_dx * sy;
1471        let x4_dy = x2_dy * cy - z3_dy * sy;
1472        let z4_dx = x2_dx * sy + z3_dx * cy;
1473        let z4_dy = x2_dy * sy + z3_dy * cy;
1474
1475        // libass uses a camera distance of 20000 in 26.6 positioning units.
1476        // Our render planes are in pixels, so use the equivalent pixel distance.
1477        let dist = 20000.0 / 64.0;
1478
1479        let x_num_dx = dist * x4_dx + origin_x * z4_dx;
1480        let x_num_dy = dist * x4_dy + origin_x * z4_dy;
1481        let y_num_dx = dist * y3_dx + origin_y * z4_dx;
1482        let y_num_dy = dist * y3_dy + origin_y * z4_dy;
1483
1484        let x_const = origin_x * dist - x_num_dx * origin_x - x_num_dy * origin_y;
1485        let y_const = origin_y * dist - y_num_dx * origin_x - y_num_dy * origin_y;
1486        let w_const = dist - z4_dx * origin_x - z4_dy * origin_y;
1487
1488        Self {
1489            m: [
1490                [x_num_dx, x_num_dy, x_const],
1491                [y_num_dx, y_num_dy, y_const],
1492                [z4_dx, z4_dy, w_const],
1493            ],
1494        }
1495    }
1496
1497    fn is_identity(self) -> bool {
1498        let identity = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
1499        self.m
1500            .iter()
1501            .zip(identity.iter())
1502            .all(|(row, identity_row)| {
1503                row.iter()
1504                    .zip(identity_row.iter())
1505                    .all(|(value, expected)| (*value - *expected).abs() < 1.0e-9)
1506            })
1507    }
1508
1509    fn transform_point(self, x: f64, y: f64) -> (f64, f64) {
1510        let tx = self.m[0][0] * x + self.m[0][1] * y + self.m[0][2];
1511        let ty = self.m[1][0] * x + self.m[1][1] * y + self.m[1][2];
1512        let tw = self.m[2][0] * x + self.m[2][1] * y + self.m[2][2];
1513        if !tw.is_finite() || tw.abs() < 1.0e-6 {
1514            return (tx, ty);
1515        }
1516        (tx / tw, ty / tw)
1517    }
1518
1519    fn inverse(self) -> Option<Self> {
1520        let m = self.m;
1521        let determinant = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
1522            - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
1523            + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);
1524        if determinant.abs() < 1.0e-6 || !determinant.is_finite() {
1525            return None;
1526        }
1527        let inv_det = 1.0 / determinant;
1528        Some(Self {
1529            m: [
1530                [
1531                    (m[1][1] * m[2][2] - m[1][2] * m[2][1]) * inv_det,
1532                    (m[0][2] * m[2][1] - m[0][1] * m[2][2]) * inv_det,
1533                    (m[0][1] * m[1][2] - m[0][2] * m[1][1]) * inv_det,
1534                ],
1535                [
1536                    (m[1][2] * m[2][0] - m[1][0] * m[2][2]) * inv_det,
1537                    (m[0][0] * m[2][2] - m[0][2] * m[2][0]) * inv_det,
1538                    (m[0][2] * m[1][0] - m[0][0] * m[1][2]) * inv_det,
1539                ],
1540                [
1541                    (m[1][0] * m[2][1] - m[1][1] * m[2][0]) * inv_det,
1542                    (m[0][1] * m[2][0] - m[0][0] * m[2][1]) * inv_det,
1543                    (m[0][0] * m[1][1] - m[0][1] * m[1][0]) * inv_det,
1544                ],
1545            ],
1546        })
1547    }
1548}
1549
1550fn finite_or_zero(value: f64) -> f64 {
1551    if value.is_finite() { value } else { 0.0 }
1552}
1553
1554fn transform_plane(plane: ImagePlane, matrix: ProjectiveMatrix) -> Option<ImagePlane> {
1555    if plane.size.width <= 0 || plane.size.height <= 0 || plane.bitmap.is_empty() {
1556        return Some(plane);
1557    }
1558    let inverse = matrix.inverse()?;
1559    let corners = [
1560        (
1561            f64::from(plane.destination.x),
1562            f64::from(plane.destination.y),
1563        ),
1564        (
1565            f64::from(plane.destination.x + plane.size.width),
1566            f64::from(plane.destination.y),
1567        ),
1568        (
1569            f64::from(plane.destination.x),
1570            f64::from(plane.destination.y + plane.size.height),
1571        ),
1572        (
1573            f64::from(plane.destination.x + plane.size.width),
1574            f64::from(plane.destination.y + plane.size.height),
1575        ),
1576    ];
1577    let transformed = corners.map(|(x, y)| matrix.transform_point(x, y));
1578    let min_x = transformed
1579        .iter()
1580        .map(|(x, _)| *x)
1581        .fold(f64::INFINITY, f64::min)
1582        .floor() as i32;
1583    let min_y = transformed
1584        .iter()
1585        .map(|(_, y)| *y)
1586        .fold(f64::INFINITY, f64::min)
1587        .floor() as i32;
1588    let max_x = transformed
1589        .iter()
1590        .map(|(x, _)| *x)
1591        .fold(f64::NEG_INFINITY, f64::max)
1592        .ceil() as i32;
1593    let max_y = transformed
1594        .iter()
1595        .map(|(_, y)| *y)
1596        .fold(f64::NEG_INFINITY, f64::max)
1597        .ceil() as i32;
1598    let width = (max_x - min_x).max(1) as usize;
1599    let height = (max_y - min_y).max(1) as usize;
1600    let mut bitmap = vec![0_u8; width * height];
1601    let src_stride = plane.stride.max(0) as usize;
1602    let src_width = plane.size.width as usize;
1603    let src_height = plane.size.height as usize;
1604
1605    for row in 0..height {
1606        for column in 0..width {
1607            let dest_x = f64::from(min_x) + column as f64 + 0.5;
1608            let dest_y = f64::from(min_y) + row as f64 + 0.5;
1609            let (src_global_x, src_global_y) = inverse.transform_point(dest_x, dest_y);
1610            let src_x = src_global_x - f64::from(plane.destination.x) - 0.5;
1611            let src_y = src_global_y - f64::from(plane.destination.y) - 0.5;
1612            let value = sample_bitmap_bilinear(
1613                &plane.bitmap,
1614                src_stride,
1615                src_width,
1616                src_height,
1617                src_x,
1618                src_y,
1619            );
1620            bitmap[row * width + column] = value;
1621        }
1622    }
1623
1624    bitmap.iter().any(|value| *value > 0).then_some(ImagePlane {
1625        size: Size {
1626            width: width as i32,
1627            height: height as i32,
1628        },
1629        stride: width as i32,
1630        destination: Point { x: min_x, y: min_y },
1631        bitmap,
1632        ..plane
1633    })
1634}
1635
1636fn sample_bitmap_bilinear(
1637    bitmap: &[u8],
1638    stride: usize,
1639    width: usize,
1640    height: usize,
1641    x: f64,
1642    y: f64,
1643) -> u8 {
1644    if !(x.is_finite() && y.is_finite()) || x < 0.0 || y < 0.0 {
1645        return 0;
1646    }
1647    let x0 = x.floor() as i32;
1648    let y0 = y.floor() as i32;
1649    if x0 < 0 || y0 < 0 || x0 as usize >= width || y0 as usize >= height {
1650        return 0;
1651    }
1652    let x1 = (x0 + 1).min(width.saturating_sub(1) as i32);
1653    let y1 = (y0 + 1).min(height.saturating_sub(1) as i32);
1654    let wx = x - f64::from(x0);
1655    let wy = y - f64::from(y0);
1656    let at = |xx: i32, yy: i32| -> f64 { bitmap[yy as usize * stride + xx as usize] as f64 };
1657    let top = at(x0, y0) * (1.0 - wx) + at(x1, y0) * wx;
1658    let bottom = at(x0, y1) * (1.0 - wx) + at(x1, y1) * wx;
1659    (top * (1.0 - wy) + bottom * wy).round().clamp(0.0, 255.0) as u8
1660}
1661
1662pub fn default_renderer_config(track: &ParsedTrack) -> RendererConfig {
1663    RendererConfig {
1664        frame: Size {
1665            width: track.play_res_x,
1666            height: track.play_res_y,
1667        },
1668        ..RendererConfig::default()
1669    }
1670}
1671
1672fn output_scale_x(track: &ParsedTrack, config: &RendererConfig) -> f64 {
1673    let frame_width = output_mapping_size(track, config).width;
1674    let base_width = track.play_res_x.max(1);
1675    let aspect = effective_pixel_aspect(track, config);
1676
1677    f64::from(frame_width.max(1)) / f64::from(base_width) * aspect
1678}
1679
1680fn output_scale_y(track: &ParsedTrack, config: &RendererConfig) -> f64 {
1681    let frame_height = output_mapping_size(track, config).height;
1682    let base_height = track.play_res_y.max(1);
1683
1684    f64::from(frame_height.max(1)) / f64::from(base_height)
1685}
1686
1687fn effective_pixel_aspect(track: &ParsedTrack, config: &RendererConfig) -> f64 {
1688    if layout_resolution(track).is_some()
1689        || !(config.pixel_aspect.is_finite() && config.pixel_aspect > 0.0)
1690    {
1691        return derived_pixel_aspect(track, config).unwrap_or(1.0);
1692    }
1693
1694    config.pixel_aspect
1695}
1696
1697fn derived_pixel_aspect(track: &ParsedTrack, config: &RendererConfig) -> Option<f64> {
1698    let layout = layout_resolution(track).or_else(|| storage_resolution(config))?;
1699    let frame = frame_content_size(track, config);
1700    if frame.width <= 0 || frame.height <= 0 || layout.width <= 0 || layout.height <= 0 {
1701        return None;
1702    }
1703
1704    let display_aspect = f64::from(frame.width) / f64::from(frame.height);
1705    let source_aspect = f64::from(layout.width) / f64::from(layout.height);
1706    (source_aspect > 0.0).then_some(display_aspect / source_aspect)
1707}
1708
1709fn layout_resolution(track: &ParsedTrack) -> Option<Size> {
1710    (track.layout_res_x > 0 && track.layout_res_y > 0).then_some(Size {
1711        width: track.layout_res_x,
1712        height: track.layout_res_y,
1713    })
1714}
1715
1716fn storage_resolution(config: &RendererConfig) -> Option<Size> {
1717    (config.storage.width > 0 && config.storage.height > 0).then_some(config.storage)
1718}
1719
1720fn frame_content_size(track: &ParsedTrack, config: &RendererConfig) -> Size {
1721    let frame_width = if config.frame.width > 0 {
1722        config.frame.width
1723    } else {
1724        track.play_res_x
1725    };
1726    let frame_height = if config.frame.height > 0 {
1727        config.frame.height
1728    } else {
1729        track.play_res_y
1730    };
1731
1732    Size {
1733        width: (frame_width - config.margins.left - config.margins.right).max(0),
1734        height: (frame_height - config.margins.top - config.margins.bottom).max(0),
1735    }
1736}
1737
1738fn output_mapping_size(track: &ParsedTrack, config: &RendererConfig) -> Size {
1739    if config.use_margins {
1740        Size {
1741            width: if config.frame.width > 0 {
1742                config.frame.width
1743            } else {
1744                track.play_res_x
1745            },
1746            height: if config.frame.height > 0 {
1747                config.frame.height
1748            } else {
1749                track.play_res_y
1750            },
1751        }
1752    } else {
1753        frame_content_size(track, config)
1754    }
1755}
1756
1757fn output_offset(config: &RendererConfig) -> Point {
1758    if config.use_margins {
1759        Point { x: 0, y: 0 }
1760    } else {
1761        Point {
1762            x: config.margins.left.max(0),
1763            y: config.margins.top.max(0),
1764        }
1765    }
1766}
1767
1768fn translate_planes(mut planes: Vec<ImagePlane>, offset: Point) -> Vec<ImagePlane> {
1769    if offset == Point::default() {
1770        return planes;
1771    }
1772    for plane in &mut planes {
1773        plane.destination.x += offset.x;
1774        plane.destination.y += offset.y;
1775    }
1776    planes
1777}
1778
1779fn frame_clip_rect(
1780    track: &ParsedTrack,
1781    config: &RendererConfig,
1782    event: &LayoutEvent,
1783    effective_position: Option<(i32, i32)>,
1784) -> Rect {
1785    let frame_width = if config.frame.width > 0 {
1786        config.frame.width
1787    } else {
1788        track.play_res_x.max(0)
1789    };
1790    let frame_height = if config.frame.height > 0 {
1791        config.frame.height
1792    } else {
1793        track.play_res_y.max(0)
1794    };
1795    if config.use_margins
1796        && effective_position.is_none()
1797        && event.clip_rect.is_none()
1798        && event.vector_clip.is_none()
1799    {
1800        Rect {
1801            x_min: config.margins.left.max(0),
1802            y_min: config.margins.top.max(0),
1803            x_max: (frame_width - config.margins.right).max(0),
1804            y_max: (frame_height - config.margins.bottom).max(0),
1805        }
1806    } else {
1807        Rect {
1808            x_min: 0,
1809            y_min: 0,
1810            x_max: frame_width,
1811            y_max: frame_height,
1812        }
1813    }
1814}
1815
1816fn compute_horizontal_origin(
1817    track: &ParsedTrack,
1818    event: &LayoutEvent,
1819    line_width: i32,
1820    effective_position: Option<(i32, i32)>,
1821    scale_x: f64,
1822) -> i32 {
1823    let scale_x = style_scale(scale_x);
1824    if let Some((x, _)) = effective_position {
1825        return match event.alignment & 0x3 {
1826            ass::HALIGN_LEFT => x,
1827            ass::HALIGN_RIGHT => x - line_width,
1828            _ => x - line_width / 2,
1829        };
1830    }
1831    let frame_width = (f64::from(track.play_res_x) * scale_x).round() as i32;
1832    let margin_l = (f64::from(event.margin_l) * scale_x).round() as i32;
1833    let margin_r = (f64::from(event.margin_r) * scale_x).round() as i32;
1834    match event.alignment & 0x3 {
1835        ass::HALIGN_LEFT => margin_l,
1836        ass::HALIGN_RIGHT => (frame_width - margin_r - line_width).max(0),
1837        _ => ((frame_width - line_width) / 2).max(0),
1838    }
1839}
1840
1841fn scale_position(position: Option<(i32, i32)>, scale_x: f64, scale_y: f64) -> Option<(i32, i32)> {
1842    let scale_x = style_scale(scale_x);
1843    let scale_y = style_scale(scale_y);
1844    position.map(|(x, y)| {
1845        (
1846            (f64::from(x) * scale_x).round() as i32,
1847            (f64::from(y) * scale_y).round() as i32,
1848        )
1849    })
1850}
1851
1852fn resolve_event_position(
1853    track: &ParsedTrack,
1854    event: &LayoutEvent,
1855    now_ms: i64,
1856) -> Option<(i32, i32)> {
1857    event.position.or_else(|| {
1858        event
1859            .movement
1860            .map(|movement| interpolate_move(movement, track.events.get(event.event_index), now_ms))
1861    })
1862}
1863
1864fn event_layer(track: &ParsedTrack, event: &LayoutEvent) -> i32 {
1865    track
1866        .events
1867        .get(event.event_index)
1868        .map(|source| source.layer)
1869        .unwrap_or_default()
1870}
1871
1872fn interpolate_move(
1873    movement: ParsedMovement,
1874    source_event: Option<&ParsedEvent>,
1875    now_ms: i64,
1876) -> (i32, i32) {
1877    let event_duration = source_event
1878        .map(|event| event.duration)
1879        .unwrap_or_default()
1880        .max(0) as i32;
1881    let event_elapsed = source_event
1882        .map(|event| (now_ms - event.start).clamp(0, event.duration.max(0)) as i32)
1883        .unwrap_or_default();
1884
1885    let (t1_ms, t2_ms) = if movement.t1_ms <= 0 && movement.t2_ms <= 0 {
1886        (0, event_duration)
1887    } else {
1888        (movement.t1_ms.max(0), movement.t2_ms.max(movement.t1_ms))
1889    };
1890    let k = if event_elapsed <= t1_ms {
1891        0.0
1892    } else if event_elapsed >= t2_ms {
1893        1.0
1894    } else {
1895        let delta = (t2_ms - t1_ms).max(1) as f64;
1896        f64::from(event_elapsed - t1_ms) / delta
1897    };
1898
1899    let x = f64::from(movement.end.0 - movement.start.0) * k + f64::from(movement.start.0);
1900    let y = f64::from(movement.end.1 - movement.start.1) * k + f64::from(movement.start.1);
1901    (x.round() as i32, y.round() as i32)
1902}
1903
1904fn compute_vertical_layout(
1905    track: &ParsedTrack,
1906    lines: &[rassa_layout::LayoutLine],
1907    alignment: i32,
1908    margin_v: i32,
1909    position: Option<(i32, i32)>,
1910    config: &RendererConfig,
1911    scale_y: f64,
1912) -> Vec<i32> {
1913    let scale_y = style_scale(scale_y);
1914    if let Some((_, y)) = position {
1915        let line_heights = lines
1916            .iter()
1917            .map(|line| layout_line_height_for_line(line, config, scale_y))
1918            .collect::<Vec<_>>();
1919        let total_height: i32 = line_heights.iter().sum();
1920        let mut current_y = match alignment & (ass::VALIGN_TOP | ass::VALIGN_CENTER) {
1921            ass::VALIGN_TOP => y,
1922            ass::VALIGN_CENTER => y - total_height / 2,
1923            _ => y - total_height,
1924        };
1925        let mut positions = Vec::with_capacity(lines.len());
1926        for height in line_heights {
1927            positions.push(current_y);
1928            current_y += height;
1929        }
1930        return positions;
1931    }
1932    let line_heights = lines
1933        .iter()
1934        .map(|line| layout_line_height_for_line(line, config, scale_y))
1935        .collect::<Vec<_>>();
1936    let total_height: i32 = line_heights.iter().sum();
1937    let default_start_y = match alignment & (ass::VALIGN_TOP | ass::VALIGN_CENTER) {
1938        ass::VALIGN_TOP => (f64::from(margin_v) * scale_y).round() as i32,
1939        ass::VALIGN_CENTER => {
1940            ((f64::from(track.play_res_y) * scale_y).round() as i32 - total_height) / 2
1941        }
1942        _ => ((f64::from(track.play_res_y) * scale_y).round() as i32
1943            - (f64::from(margin_v) * scale_y).round() as i32
1944            - total_height)
1945            .max(0),
1946    };
1947
1948    let line_position = config.line_position.clamp(0.0, 100.0);
1949    let start_y = if (alignment & (ass::VALIGN_TOP | ass::VALIGN_CENTER)) == ass::VALIGN_SUB
1950        && line_position > 0.0
1951    {
1952        let bottom_y = f64::from(default_start_y);
1953        let top_y = 0.0;
1954        (bottom_y + (top_y - bottom_y) * (line_position / 100.0)).round() as i32
1955    } else {
1956        default_start_y
1957    }
1958    .max(0);
1959
1960    let mut positions = Vec::with_capacity(lines.len());
1961    let mut current_y = start_y;
1962    for height in line_heights {
1963        positions.push(current_y);
1964        current_y += height;
1965    }
1966    positions
1967}
1968
1969fn resolve_vertical_layout(
1970    track: &ParsedTrack,
1971    event: &LayoutEvent,
1972    effective_position: Option<(i32, i32)>,
1973    occupied_bounds: &[Rect],
1974    config: &RendererConfig,
1975    scale_y: f64,
1976) -> Vec<i32> {
1977    let mut vertical_layout = compute_vertical_layout(
1978        track,
1979        &event.lines,
1980        event.alignment,
1981        event.margin_v,
1982        effective_position,
1983        config,
1984        scale_y,
1985    );
1986    if effective_position.is_some() || occupied_bounds.is_empty() {
1987        return vertical_layout;
1988    }
1989
1990    let line_height = layout_line_height(config, scale_y);
1991    let shift = match event.alignment & (ass::VALIGN_TOP | ass::VALIGN_CENTER) {
1992        ass::VALIGN_TOP => line_height,
1993        ass::VALIGN_CENTER => line_height,
1994        _ => -line_height,
1995    };
1996
1997    let mut bounds = event_bounds(
1998        track,
1999        event,
2000        &vertical_layout,
2001        effective_position,
2002        config,
2003        1.0,
2004        scale_y,
2005    );
2006    let frame_height = (f64::from(track.play_res_y) * scale_y).round() as i32;
2007    while occupied_bounds
2008        .iter()
2009        .any(|occupied| bounds.intersect(*occupied).is_some())
2010    {
2011        for line_top in &mut vertical_layout {
2012            *line_top += shift;
2013        }
2014        bounds = event_bounds(
2015            track,
2016            event,
2017            &vertical_layout,
2018            effective_position,
2019            config,
2020            1.0,
2021            scale_y,
2022        );
2023        if bounds.y_min < 0 || bounds.y_max > frame_height {
2024            break;
2025        }
2026    }
2027
2028    vertical_layout
2029}
2030
2031fn event_bounds(
2032    track: &ParsedTrack,
2033    event: &LayoutEvent,
2034    vertical_layout: &[i32],
2035    effective_position: Option<(i32, i32)>,
2036    config: &RendererConfig,
2037    scale_x: f64,
2038    scale_y: f64,
2039) -> Rect {
2040    let mut x_min = i32::MAX;
2041    let mut y_min = i32::MAX;
2042    let mut x_max = i32::MIN;
2043    let mut y_max = i32::MIN;
2044
2045    for (line, line_top) in event.lines.iter().zip(vertical_layout.iter().copied()) {
2046        let line_width = (f64::from(line.width) * style_scale(scale_x)).round() as i32;
2047        let origin_x =
2048            compute_horizontal_origin(track, event, line_width, effective_position, scale_x);
2049        x_min = x_min.min(origin_x);
2050        y_min = y_min.min(line_top);
2051        x_max = x_max.max(origin_x + line_width);
2052        y_max = y_max.max(line_top + layout_line_height(config, scale_y));
2053    }
2054
2055    if x_min == i32::MAX {
2056        Rect::default()
2057    } else {
2058        Rect {
2059            x_min,
2060            y_min,
2061            x_max,
2062            y_max,
2063        }
2064    }
2065}
2066
2067fn text_decoration_planes(
2068    style: &ParsedSpanStyle,
2069    origin_x: i32,
2070    line_top: i32,
2071    width: i32,
2072    color: u32,
2073) -> Vec<ImagePlane> {
2074    if width <= 0 || !(style.underline || style.strike_out) {
2075        return Vec::new();
2076    }
2077
2078    let thickness = (style.font_size / 18.0).round().max(1.0) as i32;
2079    let mut planes = Vec::new();
2080    let mut push_decoration = |baseline_fraction: f64| {
2081        let y = line_top + (style.font_size * baseline_fraction).round() as i32;
2082        planes.push(ImagePlane {
2083            size: Size {
2084                width,
2085                height: thickness,
2086            },
2087            stride: width,
2088            color: rgba_color_from_ass(color),
2089            destination: Point { x: origin_x, y },
2090            kind: ass::ImageType::Character,
2091            bitmap: vec![255; (width * thickness) as usize],
2092        });
2093    };
2094
2095    if style.underline {
2096        push_decoration(0.82);
2097    }
2098    if style.strike_out {
2099        push_decoration(0.48);
2100    }
2101
2102    planes
2103}
2104
2105fn combined_image_plane_from_glyphs(
2106    glyphs: &[RasterGlyph],
2107    origin_x: i32,
2108    line_top: i32,
2109    line_ascender: Option<i32>,
2110    color: u32,
2111    kind: ass::ImageType,
2112    blur_radius: u32,
2113) -> Option<ImagePlane> {
2114    let ascender =
2115        line_ascender.unwrap_or_else(|| glyphs.iter().map(|glyph| glyph.top).max().unwrap_or(0));
2116    let mut pen_x = 0_i32;
2117    let mut min_x = i32::MAX;
2118    let mut min_y = i32::MAX;
2119    let mut max_x = i32::MIN;
2120    let mut max_y = i32::MIN;
2121
2122    for glyph in glyphs {
2123        if glyph.width <= 0 || glyph.height <= 0 || glyph.bitmap.is_empty() {
2124            pen_x += glyph.advance_x;
2125            continue;
2126        }
2127        let x = pen_x + glyph.left + glyph.offset_x;
2128        let y = ascender - glyph.top + glyph.offset_y;
2129        min_x = min_x.min(x);
2130        min_y = min_y.min(y);
2131        max_x = max_x.max(x + glyph.width);
2132        max_y = max_y.max(y + glyph.height);
2133        pen_x += glyph.advance_x;
2134    }
2135
2136    if min_x == i32::MAX || min_y == i32::MAX || max_x <= min_x || max_y <= min_y {
2137        return None;
2138    }
2139
2140    let width = (max_x - min_x) as usize;
2141    let height = (max_y - min_y) as usize;
2142    let mut bitmap = vec![0_u8; width * height];
2143    pen_x = 0;
2144    for glyph in glyphs {
2145        if glyph.width <= 0 || glyph.height <= 0 || glyph.bitmap.is_empty() {
2146            pen_x += glyph.advance_x;
2147            continue;
2148        }
2149        let x0 = (pen_x + glyph.left + glyph.offset_x - min_x) as usize;
2150        let y0 = (ascender - glyph.top + glyph.offset_y - min_y) as usize;
2151        let glyph_width = glyph.width as usize;
2152        let glyph_height = glyph.height as usize;
2153        let glyph_stride = glyph.stride as usize;
2154        for y in 0..glyph_height {
2155            for x in 0..glyph_width {
2156                let src = glyph.bitmap[y * glyph_stride + x];
2157                let dst = &mut bitmap[(y0 + y) * width + x0 + x];
2158                *dst = (*dst).max(src);
2159            }
2160        }
2161        pen_x += glyph.advance_x;
2162    }
2163
2164    let (bitmap, width, height, pad) = blur_bitmap(bitmap, width, height, blur_radius);
2165    Some(ImagePlane {
2166        size: Size {
2167            width: width as i32,
2168            height: height as i32,
2169        },
2170        stride: width as i32,
2171        color: rgba_color_from_ass(color),
2172        destination: Point {
2173            x: origin_x + min_x - pad as i32,
2174            y: line_top + min_y - pad as i32,
2175        },
2176        kind,
2177        bitmap,
2178    })
2179}
2180
2181fn blur_bitmap(
2182    source: Vec<u8>,
2183    width: usize,
2184    height: usize,
2185    radius: u32,
2186) -> (Vec<u8>, usize, usize, usize) {
2187    if radius == 0 || width == 0 || height == 0 || source.is_empty() {
2188        return (source, width, height, 0);
2189    }
2190    let r2 = libass_blur_r2_from_radius(radius);
2191    let (bitmap, width, height, pad_x, pad_y) =
2192        libass_gaussian_blur(&source, width, height, r2, r2);
2193    debug_assert_eq!(pad_x, pad_y);
2194    (bitmap, width, height, pad_x)
2195}
2196
2197#[derive(Clone)]
2198struct LibassBlurMethod {
2199    level: usize,
2200    radius: usize,
2201    coeff: [i16; 8],
2202}
2203
2204fn libass_blur_r2_from_radius(radius: u32) -> f64 {
2205    const POSITION_PRECISION: f64 = 8.0;
2206    const BLUR_PRECISION: f64 = 1.0 / 256.0;
2207    let blur = f64::from(radius) / 4.0;
2208    let blur_radius_scale = 2.0 / 256.0_f64.ln().sqrt();
2209    let scale = 64.0 * BLUR_PRECISION / POSITION_PRECISION;
2210    let qblur = ((1.0 + blur * blur_radius_scale * scale).ln() / BLUR_PRECISION).round();
2211    let sigma = (BLUR_PRECISION * qblur).exp_m1() / scale;
2212    sigma * sigma
2213}
2214
2215fn libass_gaussian_blur(
2216    source: &[u8],
2217    width: usize,
2218    height: usize,
2219    r2x: f64,
2220    r2y: f64,
2221) -> (Vec<u8>, usize, usize, usize, usize) {
2222    let blur_x = find_libass_blur_method(r2x);
2223    let blur_y = if (r2y - r2x).abs() < f64::EPSILON {
2224        blur_x.clone()
2225    } else {
2226        find_libass_blur_method(r2y)
2227    };
2228
2229    let offset_x = ((2 * blur_x.radius + 9) << blur_x.level) - 5;
2230    let offset_y = ((2 * blur_y.radius + 9) << blur_y.level) - 5;
2231    let mask_x = (1_usize << blur_x.level) - 1;
2232    let mask_y = (1_usize << blur_y.level) - 1;
2233    let end_width = ((width + offset_x) & !mask_x).saturating_sub(4);
2234    let end_height = ((height + offset_y) & !mask_y).saturating_sub(4);
2235    let pad_x = ((blur_x.radius + 4) << blur_x.level) - 4;
2236    let pad_y = ((blur_y.radius + 4) << blur_y.level) - 4;
2237
2238    let mut buffer = unpack_libass_blur(source);
2239    let mut w = width;
2240    let mut h = height;
2241
2242    for _ in 0..blur_y.level {
2243        let next = shrink_vert_libass(&buffer, w, h);
2244        buffer = next.0;
2245        w = next.1;
2246        h = next.2;
2247    }
2248    for _ in 0..blur_x.level {
2249        let next = shrink_horz_libass(&buffer, w, h);
2250        buffer = next.0;
2251        w = next.1;
2252        h = next.2;
2253    }
2254
2255    let next = blur_horz_libass(&buffer, w, h, &blur_x.coeff, blur_x.radius);
2256    buffer = next.0;
2257    w = next.1;
2258    h = next.2;
2259    let next = blur_vert_libass(&buffer, w, h, &blur_y.coeff, blur_y.radius);
2260    buffer = next.0;
2261    w = next.1;
2262    h = next.2;
2263
2264    for _ in 0..blur_x.level {
2265        let next = expand_horz_libass(&buffer, w, h);
2266        buffer = next.0;
2267        w = next.1;
2268        h = next.2;
2269    }
2270    for _ in 0..blur_y.level {
2271        let next = expand_vert_libass(&buffer, w, h);
2272        buffer = next.0;
2273        w = next.1;
2274        h = next.2;
2275    }
2276
2277    debug_assert_eq!(w, end_width);
2278    debug_assert_eq!(h, end_height);
2279    (pack_libass_blur(&buffer, w, h), w, h, pad_x, pad_y)
2280}
2281
2282fn find_libass_blur_method(r2: f64) -> LibassBlurMethod {
2283    let mut mu = [0.0_f64; 8];
2284    let (level, radius) = if r2 < 0.5 {
2285        mu[1] = 0.085 * r2 * r2 * r2;
2286        mu[0] = 0.5 * r2 - 4.0 * mu[1];
2287        (0_usize, 4_usize)
2288    } else {
2289        let (frac, level) = frexp((0.11569 * r2 + 0.20591047).sqrt());
2290        let mul = 0.25_f64.powi(level);
2291        let radius = (8_i32 - ((10.1525 + 0.8335 * mul) * (1.0 - frac)) as i32).max(4) as usize;
2292        calc_libass_coeff(&mut mu, radius, r2, mul);
2293        (level.max(0) as usize, radius)
2294    };
2295    let mut coeff = [0_i16; 8];
2296    for i in 0..radius {
2297        coeff[i] = (65536.0 * mu[i] + 0.5) as i16;
2298    }
2299    LibassBlurMethod {
2300        level,
2301        radius,
2302        coeff,
2303    }
2304}
2305
2306fn calc_libass_coeff(mu: &mut [f64; 8], n: usize, r2: f64, mul: f64) {
2307    let w = 12096.0;
2308    let kernel = [
2309        (((3280.0 / w) * mul + 1092.0 / w) * mul + 2520.0 / w) * mul + 5204.0 / w,
2310        (((-2460.0 / w) * mul - 273.0 / w) * mul - 210.0 / w) * mul + 2943.0 / w,
2311        (((984.0 / w) * mul - 546.0 / w) * mul - 924.0 / w) * mul + 486.0 / w,
2312        (((-164.0 / w) * mul + 273.0 / w) * mul - 126.0 / w) * mul + 17.0 / w,
2313    ];
2314    let mut mat_freq = [0.0_f64; 17];
2315    mat_freq[..4].copy_from_slice(&kernel);
2316    coeff_filter_libass(&mut mat_freq, 7, &kernel);
2317    let mut vec_freq = [0.0_f64; 12];
2318    calc_gauss_libass(&mut vec_freq, n + 4, r2 * mul);
2319    coeff_filter_libass(&mut vec_freq, n + 1, &kernel);
2320    let mut mat = [[0.0_f64; 8]; 8];
2321    calc_matrix_libass(&mut mat, &mat_freq, n);
2322    let mut vec = [0.0_f64; 8];
2323    for i in 0..n {
2324        vec[i] = mat_freq[0] - mat_freq[i + 1] - vec_freq[0] + vec_freq[i + 1];
2325    }
2326    for i in 0..n {
2327        let mut res = 0.0;
2328        for (j, value) in vec.iter().enumerate().take(n) {
2329            res += mat[i][j] * value;
2330        }
2331        mu[i] = res.max(0.0);
2332    }
2333}
2334
2335fn calc_gauss_libass(res: &mut [f64], n: usize, r2: f64) {
2336    let alpha = 0.5 / r2;
2337    let mut mul = (-alpha).exp();
2338    let mul2 = mul * mul;
2339    let mut cur = (alpha / std::f64::consts::PI).sqrt();
2340    res[0] = cur;
2341    cur *= mul;
2342    res[1] = cur;
2343    for value in res.iter_mut().take(n).skip(2) {
2344        mul *= mul2;
2345        cur *= mul;
2346        *value = cur;
2347    }
2348}
2349
2350fn coeff_filter_libass(coeff: &mut [f64], n: usize, kernel: &[f64; 4]) {
2351    let mut prev1 = coeff[1];
2352    let mut prev2 = coeff[2];
2353    let mut prev3 = coeff[3];
2354    for i in 0..n {
2355        let res = coeff[i] * kernel[0]
2356            + (prev1 + coeff[i + 1]) * kernel[1]
2357            + (prev2 + coeff[i + 2]) * kernel[2]
2358            + (prev3 + coeff[i + 3]) * kernel[3];
2359        prev3 = prev2;
2360        prev2 = prev1;
2361        prev1 = coeff[i];
2362        coeff[i] = res;
2363    }
2364}
2365
2366fn calc_matrix_libass(mat: &mut [[f64; 8]; 8], mat_freq: &[f64], n: usize) {
2367    for i in 0..n {
2368        mat[i][i] = mat_freq[2 * i + 2] + 3.0 * mat_freq[0] - 4.0 * mat_freq[i + 1];
2369        for j in i + 1..n {
2370            let v = mat_freq[i + j + 2]
2371                + mat_freq[j - i]
2372                + 2.0 * (mat_freq[0] - mat_freq[i + 1] - mat_freq[j + 1]);
2373            mat[i][j] = v;
2374            mat[j][i] = v;
2375        }
2376    }
2377    for k in 0..n {
2378        let z = 1.0 / mat[k][k];
2379        mat[k][k] = 1.0;
2380        let pivot_row = mat[k];
2381        for (i, row) in mat.iter_mut().enumerate().take(n) {
2382            if i == k {
2383                continue;
2384            }
2385            let mul = row[k] * z;
2386            row[k] = 0.0;
2387            for j in 0..n {
2388                row[j] -= pivot_row[j] * mul;
2389            }
2390        }
2391        for value in mat[k].iter_mut().take(n) {
2392            *value *= z;
2393        }
2394    }
2395}
2396
2397fn frexp(value: f64) -> (f64, i32) {
2398    if value == 0.0 {
2399        return (0.0, 0);
2400    }
2401    let exponent = value.abs().log2().floor() as i32 + 1;
2402    (value / 2.0_f64.powi(exponent), exponent)
2403}
2404
2405#[inline]
2406fn get_libass_sample(source: &[i16], width: usize, height: usize, x: isize, y: isize) -> i16 {
2407    if x < 0 || y < 0 || x >= width as isize || y >= height as isize {
2408        0
2409    } else {
2410        source[y as usize * width + x as usize]
2411    }
2412}
2413
2414fn unpack_libass_blur(source: &[u8]) -> Vec<i16> {
2415    source
2416        .iter()
2417        .map(|value| {
2418            let value = u16::from(*value);
2419            ((((value << 7) | (value >> 1)) + 1) >> 1) as i16
2420        })
2421        .collect()
2422}
2423
2424const LIBASS_DITHER_LINE: [i16; 32] = [
2425    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,
2426    56, 24, 56, 24, 56, 24,
2427];
2428
2429fn pack_libass_blur(source: &[i16], width: usize, height: usize) -> Vec<u8> {
2430    let mut bitmap = vec![0_u8; width * height];
2431    for y in 0..height {
2432        let dither = &LIBASS_DITHER_LINE[16 * (y & 1)..];
2433        for x in 0..width {
2434            let sample = i32::from(source[y * width + x]);
2435            let value = ((sample - (sample >> 8) + i32::from(dither[x & 15])) >> 6).clamp(0, 255);
2436            bitmap[y * width + x] = value as u8;
2437        }
2438    }
2439    bitmap
2440}
2441
2442#[inline]
2443fn shrink_func_libass(p1p: i16, p1n: i16, z0p: i16, z0n: i16, n1p: i16, n1n: i16) -> i16 {
2444    let mut r = (i32::from(p1p) + i32::from(p1n) + i32::from(n1p) + i32::from(n1n)) >> 1;
2445    r = (r + i32::from(z0p) + i32::from(z0n)) >> 1;
2446    r = (r + i32::from(p1n) + i32::from(n1p)) >> 1;
2447    ((r + i32::from(z0p) + i32::from(z0n) + 2) >> 2) as i16
2448}
2449
2450#[inline]
2451fn expand_func_libass(p1: i16, z0: i16, n1: i16) -> (i16, i16) {
2452    let r = ((((p1 as u16).wrapping_add(n1 as u16)) >> 1).wrapping_add(z0 as u16)) >> 1;
2453    let rp = (((r.wrapping_add(p1 as u16) >> 1)
2454        .wrapping_add(z0 as u16)
2455        .wrapping_add(1))
2456        >> 1) as i16;
2457    let rn = (((r.wrapping_add(n1 as u16) >> 1)
2458        .wrapping_add(z0 as u16)
2459        .wrapping_add(1))
2460        >> 1) as i16;
2461    (rp, rn)
2462}
2463
2464fn shrink_horz_libass(source: &[i16], width: usize, height: usize) -> (Vec<i16>, usize, usize) {
2465    let dst_width = (width + 5) >> 1;
2466    let mut dst = vec![0_i16; dst_width * height];
2467    for y in 0..height {
2468        for x in 0..dst_width {
2469            let sx = (2 * x) as isize;
2470            dst[y * dst_width + x] = shrink_func_libass(
2471                get_libass_sample(source, width, height, sx - 4, y as isize),
2472                get_libass_sample(source, width, height, sx - 3, y as isize),
2473                get_libass_sample(source, width, height, sx - 2, y as isize),
2474                get_libass_sample(source, width, height, sx - 1, y as isize),
2475                get_libass_sample(source, width, height, sx, y as isize),
2476                get_libass_sample(source, width, height, sx + 1, y as isize),
2477            );
2478        }
2479    }
2480    (dst, dst_width, height)
2481}
2482
2483fn shrink_vert_libass(source: &[i16], width: usize, height: usize) -> (Vec<i16>, usize, usize) {
2484    let dst_height = (height + 5) >> 1;
2485    let mut dst = vec![0_i16; width * dst_height];
2486    for y in 0..dst_height {
2487        let sy = (2 * y) as isize;
2488        for x in 0..width {
2489            dst[y * width + x] = shrink_func_libass(
2490                get_libass_sample(source, width, height, x as isize, sy - 4),
2491                get_libass_sample(source, width, height, x as isize, sy - 3),
2492                get_libass_sample(source, width, height, x as isize, sy - 2),
2493                get_libass_sample(source, width, height, x as isize, sy - 1),
2494                get_libass_sample(source, width, height, x as isize, sy),
2495                get_libass_sample(source, width, height, x as isize, sy + 1),
2496            );
2497        }
2498    }
2499    (dst, width, dst_height)
2500}
2501
2502fn expand_horz_libass(source: &[i16], width: usize, height: usize) -> (Vec<i16>, usize, usize) {
2503    let dst_width = 2 * width + 4;
2504    let mut dst = vec![0_i16; dst_width * height];
2505    for y in 0..height {
2506        for i in 0..(width + 2) {
2507            let sx = i as isize;
2508            let (rp, rn) = expand_func_libass(
2509                get_libass_sample(source, width, height, sx - 2, y as isize),
2510                get_libass_sample(source, width, height, sx - 1, y as isize),
2511                get_libass_sample(source, width, height, sx, y as isize),
2512            );
2513            let dx = 2 * i;
2514            dst[y * dst_width + dx] = rp;
2515            dst[y * dst_width + dx + 1] = rn;
2516        }
2517    }
2518    (dst, dst_width, height)
2519}
2520
2521fn expand_vert_libass(source: &[i16], width: usize, height: usize) -> (Vec<i16>, usize, usize) {
2522    let dst_height = 2 * height + 4;
2523    let mut dst = vec![0_i16; width * dst_height];
2524    for i in 0..(height + 2) {
2525        let sy = i as isize;
2526        for x in 0..width {
2527            let (rp, rn) = expand_func_libass(
2528                get_libass_sample(source, width, height, x as isize, sy - 2),
2529                get_libass_sample(source, width, height, x as isize, sy - 1),
2530                get_libass_sample(source, width, height, x as isize, sy),
2531            );
2532            let dy = 2 * i;
2533            dst[dy * width + x] = rp;
2534            dst[(dy + 1) * width + x] = rn;
2535        }
2536    }
2537    (dst, width, dst_height)
2538}
2539
2540fn blur_horz_libass(
2541    source: &[i16],
2542    width: usize,
2543    height: usize,
2544    param: &[i16; 8],
2545    radius: usize,
2546) -> (Vec<i16>, usize, usize) {
2547    let dst_width = width + 2 * radius;
2548    let mut dst = vec![0_i16; dst_width * height];
2549    for y in 0..height {
2550        for x in 0..dst_width {
2551            let center_x = x as isize - radius as isize;
2552            let center = i32::from(get_libass_sample(
2553                source, width, height, center_x, y as isize,
2554            ));
2555            let mut acc = 0x8000_i32;
2556            for i in (1..=radius).rev() {
2557                let coeff = i32::from(param[i - 1]);
2558                let left = i32::from(get_libass_sample(
2559                    source,
2560                    width,
2561                    height,
2562                    center_x - i as isize,
2563                    y as isize,
2564                ));
2565                let right = i32::from(get_libass_sample(
2566                    source,
2567                    width,
2568                    height,
2569                    center_x + i as isize,
2570                    y as isize,
2571                ));
2572                acc += ((left - center) as i16 as i32) * coeff;
2573                acc += ((right - center) as i16 as i32) * coeff;
2574            }
2575            dst[y * dst_width + x] = (center + (acc >> 16)) as i16;
2576        }
2577    }
2578    (dst, dst_width, height)
2579}
2580
2581fn blur_vert_libass(
2582    source: &[i16],
2583    width: usize,
2584    height: usize,
2585    param: &[i16; 8],
2586    radius: usize,
2587) -> (Vec<i16>, usize, usize) {
2588    let dst_height = height + 2 * radius;
2589    let mut dst = vec![0_i16; width * dst_height];
2590    for y in 0..dst_height {
2591        let center_y = y as isize - radius as isize;
2592        for x in 0..width {
2593            let center = i32::from(get_libass_sample(
2594                source, width, height, x as isize, center_y,
2595            ));
2596            let mut acc = 0x8000_i32;
2597            for i in (1..=radius).rev() {
2598                let coeff = i32::from(param[i - 1]);
2599                let top = i32::from(get_libass_sample(
2600                    source,
2601                    width,
2602                    height,
2603                    x as isize,
2604                    center_y - i as isize,
2605                ));
2606                let bottom = i32::from(get_libass_sample(
2607                    source,
2608                    width,
2609                    height,
2610                    x as isize,
2611                    center_y + i as isize,
2612                ));
2613                acc += ((top - center) as i16 as i32) * coeff;
2614                acc += ((bottom - center) as i16 as i32) * coeff;
2615            }
2616            dst[y * width + x] = (center + (acc >> 16)) as i16;
2617        }
2618    }
2619    (dst, width, dst_height)
2620}
2621
2622fn image_planes_from_absolute_glyphs(
2623    glyphs: &[RasterGlyph],
2624    color: u32,
2625    kind: ass::ImageType,
2626) -> Vec<ImagePlane> {
2627    glyphs
2628        .iter()
2629        .filter_map(|glyph| {
2630            if glyph.width <= 0 || glyph.height <= 0 || glyph.bitmap.is_empty() {
2631                return None;
2632            }
2633
2634            Some(ImagePlane {
2635                size: Size {
2636                    width: glyph.width,
2637                    height: glyph.height,
2638                },
2639                stride: glyph.stride,
2640                color: rgba_color_from_ass(color),
2641                destination: Point {
2642                    x: glyph.left,
2643                    y: glyph.top - glyph.height,
2644                },
2645                kind,
2646                bitmap: glyph.bitmap.clone(),
2647            })
2648        })
2649        .collect()
2650}
2651
2652fn image_plane_from_drawing(
2653    drawing: &ParsedDrawing,
2654    origin_x: i32,
2655    line_top: i32,
2656    color: u32,
2657    scale_x: f64,
2658    scale_y: f64,
2659) -> Option<ImagePlane> {
2660    let polygons = scaled_drawing_polygons(drawing, scale_x, scale_y);
2661    let bounds = drawing_bounds(&polygons)?;
2662    let width = bounds.width();
2663    let height = bounds.height();
2664    if width <= 0 || height <= 0 {
2665        return None;
2666    }
2667
2668    let stride = width as usize;
2669    let mut bitmap = vec![0_u8; stride * height as usize];
2670    let mut any_visible = false;
2671
2672    for row in 0..height as usize {
2673        for column in 0..width as usize {
2674            let x = bounds.x_min + column as i32;
2675            let y = bounds.y_min + row as i32;
2676            if polygons
2677                .iter()
2678                .any(|polygon| point_in_polygon(x, y, polygon))
2679            {
2680                bitmap[row * stride + column] = 255;
2681                any_visible = true;
2682            }
2683        }
2684    }
2685
2686    any_visible.then_some(ImagePlane {
2687        size: Size { width, height },
2688        stride: width,
2689        color: rgba_color_from_ass(color),
2690        destination: Point {
2691            x: origin_x + bounds.x_min,
2692            y: line_top + bounds.y_min,
2693        },
2694        kind: ass::ImageType::Character,
2695        bitmap,
2696    })
2697}
2698
2699fn scaled_drawing_polygons(drawing: &ParsedDrawing, scale_x: f64, scale_y: f64) -> Vec<Vec<Point>> {
2700    let scale_x = style_scale(scale_x);
2701    let scale_y = style_scale(scale_y);
2702    if (scale_x - 1.0).abs() < f64::EPSILON && (scale_y - 1.0).abs() < f64::EPSILON {
2703        return drawing.polygons.clone();
2704    }
2705
2706    drawing
2707        .polygons
2708        .iter()
2709        .map(|polygon| {
2710            polygon
2711                .iter()
2712                .map(|point| Point {
2713                    x: (f64::from(point.x) * scale_x).round() as i32,
2714                    y: (f64::from(point.y) * scale_y).round() as i32,
2715                })
2716                .collect()
2717        })
2718        .collect()
2719}
2720
2721fn drawing_bounds(polygons: &[Vec<Point>]) -> Option<Rect> {
2722    let mut points = polygons.iter().flat_map(|polygon| polygon.iter().copied());
2723    let first = points.next()?;
2724    let mut x_min = first.x;
2725    let mut y_min = first.y;
2726    let mut x_max = first.x;
2727    let mut y_max = first.y;
2728    for point in points {
2729        x_min = x_min.min(point.x);
2730        y_min = y_min.min(point.y);
2731        x_max = x_max.max(point.x);
2732        y_max = y_max.max(point.y);
2733    }
2734    Some(Rect {
2735        x_min,
2736        y_min,
2737        x_max: x_max + 1,
2738        y_max: y_max + 1,
2739    })
2740}
2741
2742fn plane_to_raster_glyph(plane: &ImagePlane) -> RasterGlyph {
2743    RasterGlyph {
2744        width: plane.size.width,
2745        height: plane.size.height,
2746        stride: plane.stride,
2747        left: plane.destination.x,
2748        top: plane.destination.y + plane.size.height,
2749        bitmap: plane.bitmap.clone(),
2750        ..RasterGlyph::default()
2751    }
2752}
2753
2754fn apply_event_clip(planes: Vec<ImagePlane>, clip_rect: Rect, inverse: bool) -> Vec<ImagePlane> {
2755    let mut clipped = Vec::new();
2756    for plane in planes {
2757        if inverse {
2758            clipped.extend(inverse_clip_plane(plane, clip_rect));
2759        } else if let Some(plane) = clip_plane(plane, clip_rect) {
2760            clipped.push(plane);
2761        }
2762    }
2763    clipped
2764}
2765
2766fn apply_vector_clip(
2767    planes: Vec<ImagePlane>,
2768    clip: &ParsedVectorClip,
2769    inverse: bool,
2770) -> Vec<ImagePlane> {
2771    planes
2772        .into_iter()
2773        .filter_map(|plane| mask_plane_with_vector_clip(plane, clip, inverse))
2774        .collect()
2775}
2776
2777fn mask_plane_with_vector_clip(
2778    plane: ImagePlane,
2779    clip: &ParsedVectorClip,
2780    inverse: bool,
2781) -> Option<ImagePlane> {
2782    let mut bitmap = plane.bitmap.clone();
2783    let stride = plane.stride as usize;
2784    let mut any_visible = false;
2785
2786    for row in 0..plane.size.height as usize {
2787        for column in 0..plane.size.width as usize {
2788            let global_x = plane.destination.x + column as i32;
2789            let global_y = plane.destination.y + row as i32;
2790            let inside = clip
2791                .polygons
2792                .iter()
2793                .any(|polygon| point_in_polygon(global_x, global_y, polygon));
2794            let keep = if inverse { !inside } else { inside };
2795            if !keep {
2796                bitmap[row * stride + column] = 0;
2797            } else if bitmap[row * stride + column] > 0 {
2798                any_visible = true;
2799            }
2800        }
2801    }
2802
2803    any_visible.then_some(ImagePlane { bitmap, ..plane })
2804}
2805
2806fn point_in_polygon(x: i32, y: i32, polygon: &[Point]) -> bool {
2807    if polygon.len() < 3 {
2808        return false;
2809    }
2810
2811    let mut inside = false;
2812    let mut previous = polygon[polygon.len() - 1];
2813    let sample_x = x as f64 + 0.5;
2814    let sample_y = y as f64 + 0.5;
2815
2816    for &current in polygon {
2817        let current_y = current.y as f64;
2818        let previous_y = previous.y as f64;
2819        let intersects = (current_y > sample_y) != (previous_y > sample_y);
2820        if intersects {
2821            let current_x = current.x as f64;
2822            let previous_x = previous.x as f64;
2823            let x_intersection = (previous_x - current_x) * (sample_y - current_y)
2824                / (previous_y - current_y)
2825                + current_x;
2826            if sample_x < x_intersection {
2827                inside = !inside;
2828            }
2829        }
2830        previous = current;
2831    }
2832
2833    inside
2834}
2835
2836fn clip_plane(plane: ImagePlane, clip_rect: Rect) -> Option<ImagePlane> {
2837    let plane_rect = plane_rect(&plane);
2838    let intersection = plane_rect.intersect(clip_rect)?;
2839    crop_plane_to_rect(plane, intersection)
2840}
2841
2842fn inverse_clip_plane(plane: ImagePlane, clip_rect: Rect) -> Vec<ImagePlane> {
2843    let plane_rect = plane_rect(&plane);
2844    let Some(intersection) = plane_rect.intersect(clip_rect) else {
2845        return vec![plane];
2846    };
2847
2848    let mut result = Vec::new();
2849    let regions = [
2850        Rect {
2851            x_min: plane_rect.x_min,
2852            y_min: plane_rect.y_min,
2853            x_max: plane_rect.x_max,
2854            y_max: intersection.y_min,
2855        },
2856        Rect {
2857            x_min: plane_rect.x_min,
2858            y_min: intersection.y_max,
2859            x_max: plane_rect.x_max,
2860            y_max: plane_rect.y_max,
2861        },
2862        Rect {
2863            x_min: plane_rect.x_min,
2864            y_min: intersection.y_min,
2865            x_max: intersection.x_min,
2866            y_max: intersection.y_max,
2867        },
2868        Rect {
2869            x_min: intersection.x_max,
2870            y_min: intersection.y_min,
2871            x_max: plane_rect.x_max,
2872            y_max: intersection.y_max,
2873        },
2874    ];
2875    for region in regions {
2876        if region.is_empty() {
2877            continue;
2878        }
2879        if let Some(cropped) = crop_plane_to_rect(plane.clone(), region) {
2880            result.push(cropped);
2881        }
2882    }
2883    result
2884}
2885
2886fn plane_rect(plane: &ImagePlane) -> Rect {
2887    Rect {
2888        x_min: plane.destination.x,
2889        y_min: plane.destination.y,
2890        x_max: plane.destination.x + plane.size.width,
2891        y_max: plane.destination.y + plane.size.height,
2892    }
2893}
2894
2895fn crop_plane_to_rect(plane: ImagePlane, rect: Rect) -> Option<ImagePlane> {
2896    let plane_rect = plane_rect(&plane);
2897    let rect = plane_rect.intersect(rect)?;
2898    let offset_x = (rect.x_min - plane_rect.x_min) as usize;
2899    let offset_y = (rect.y_min - plane_rect.y_min) as usize;
2900    let width = rect.width() as usize;
2901    let height = rect.height() as usize;
2902    let src_stride = plane.stride as usize;
2903    let mut bitmap = Vec::with_capacity(width * height);
2904
2905    for row in 0..height {
2906        let start = (offset_y + row) * src_stride + offset_x;
2907        bitmap.extend_from_slice(&plane.bitmap[start..start + width]);
2908    }
2909
2910    Some(ImagePlane {
2911        size: Size {
2912            width: rect.width(),
2913            height: rect.height(),
2914        },
2915        stride: rect.width(),
2916        destination: Point {
2917            x: rect.x_min,
2918            y: rect.y_min,
2919        },
2920        bitmap,
2921        ..plane
2922    })
2923}
2924fn is_event_active(event: &ParsedEvent, now_ms: i64) -> bool {
2925    now_ms >= event.start && now_ms < event.start + event.duration
2926}
2927
2928#[cfg(test)]
2929mod tests {
2930    use super::*;
2931    use rassa_fonts::{FontconfigProvider, NullFontProvider};
2932    use rassa_parse::parse_script_text;
2933
2934    fn config(
2935        frame_width: i32,
2936        frame_height: i32,
2937        margins: rassa_core::Margins,
2938        use_margins: bool,
2939    ) -> RendererConfig {
2940        RendererConfig {
2941            frame: Size {
2942                width: frame_width,
2943                height: frame_height,
2944            },
2945            margins,
2946            use_margins,
2947            ..RendererConfig::default()
2948        }
2949    }
2950
2951    fn total_plane_area(planes: &[ImagePlane]) -> i32 {
2952        planes
2953            .iter()
2954            .map(|plane| plane.size.width * plane.size.height)
2955            .sum()
2956    }
2957
2958    #[test]
2959    fn fad_uses_libass_truncating_alpha_interpolation() {
2960        let event = ParsedEvent {
2961            start: 0,
2962            duration: 4000,
2963            ..ParsedEvent::default()
2964        };
2965
2966        assert_eq!(
2967            compute_fad_alpha(
2968                ParsedFade::Simple {
2969                    fade_in_ms: 1000,
2970                    fade_out_ms: 1000,
2971                },
2972                Some(&event),
2973                500,
2974            ),
2975            127
2976        );
2977        assert_eq!(
2978            compute_fad_alpha(
2979                ParsedFade::Simple {
2980                    fade_in_ms: 1000,
2981                    fade_out_ms: 1000,
2982                },
2983                Some(&event),
2984                3500,
2985            ),
2986            127
2987        );
2988    }
2989
2990    #[test]
2991    fn fad_uses_libass_wrapping_out_start_when_fade_out_exceeds_duration() {
2992        let event = ParsedEvent {
2993            start: 0,
2994            duration: 800,
2995            ..ParsedEvent::default()
2996        };
2997
2998        assert_eq!(
2999            compute_fad_alpha(
3000                ParsedFade::Simple {
3001                    fade_in_ms: 100,
3002                    fade_out_ms: 1000,
3003                },
3004                Some(&event),
3005                100,
3006            ),
3007            76
3008        );
3009        assert_eq!(
3010            compute_fad_alpha(
3011                ParsedFade::Simple {
3012                    fade_in_ms: 100,
3013                    fade_out_ms: 1000,
3014                },
3015                Some(&event),
3016                400,
3017            ),
3018            153
3019        );
3020    }
3021
3022    #[test]
3023    fn fade_alpha_combines_with_existing_colour_alpha() {
3024        assert_eq!(with_fade_alpha(0xFF00_0080, 0), 0xFF00_0080);
3025        assert_eq!(with_fade_alpha(0xFF00_0000, 127), 0xFF00_007F);
3026        assert_eq!(with_fade_alpha(0xFF00_0080, 127), 0xFF00_00BF);
3027    }
3028
3029    fn vertical_span(planes: &[ImagePlane]) -> i32 {
3030        let min_y = planes
3031            .iter()
3032            .map(|plane| plane.destination.y)
3033            .min()
3034            .expect("plane");
3035        let max_y = planes
3036            .iter()
3037            .map(|plane| plane.destination.y + plane.size.height)
3038            .max()
3039            .expect("plane");
3040        max_y - min_y
3041    }
3042
3043    fn character_bounds(planes: &[ImagePlane]) -> Option<Rect> {
3044        let mut character_planes = planes
3045            .iter()
3046            .filter(|plane| plane.kind == ass::ImageType::Character);
3047        let first = character_planes.next()?;
3048        let mut bounds = Rect {
3049            x_min: first.destination.x,
3050            y_min: first.destination.y,
3051            x_max: first.destination.x + first.size.width,
3052            y_max: first.destination.y + first.size.height,
3053        };
3054        for plane in character_planes {
3055            bounds.x_min = bounds.x_min.min(plane.destination.x);
3056            bounds.y_min = bounds.y_min.min(plane.destination.y);
3057            bounds.x_max = bounds.x_max.max(plane.destination.x + plane.size.width);
3058            bounds.y_max = bounds.y_max.max(plane.destination.y + plane.size.height);
3059        }
3060        Some(bounds)
3061    }
3062
3063    fn visible_bounds(planes: &[ImagePlane]) -> Option<Rect> {
3064        let mut bounds: Option<Rect> = None;
3065        for plane in planes {
3066            let stride = plane.stride.max(0) as usize;
3067            if stride == 0 {
3068                continue;
3069            }
3070            for y in 0..plane.size.height.max(0) as usize {
3071                for x in 0..plane.size.width.max(0) as usize {
3072                    if plane.bitmap[y * stride + x] == 0 {
3073                        continue;
3074                    }
3075                    let px = plane.destination.x + x as i32;
3076                    let py = plane.destination.y + y as i32;
3077                    match &mut bounds {
3078                        Some(rect) => {
3079                            rect.x_min = rect.x_min.min(px);
3080                            rect.y_min = rect.y_min.min(py);
3081                            rect.x_max = rect.x_max.max(px + 1);
3082                            rect.y_max = rect.y_max.max(py + 1);
3083                        }
3084                        None => {
3085                            bounds = Some(Rect {
3086                                x_min: px,
3087                                y_min: py,
3088                                x_max: px + 1,
3089                                y_max: py + 1,
3090                            });
3091                        }
3092                    }
3093                }
3094            }
3095        }
3096        bounds
3097    }
3098
3099    #[test]
3100    fn projective_transform_keeps_frx_and_fry_axes_distinct() {
3101        let origin = (320.0, 180.0);
3102        let frx = ProjectiveMatrix::from_ass_transform_at_origin(
3103            EventTransform {
3104                rotation_x: 45.0,
3105                ..EventTransform::default()
3106            },
3107            origin.0,
3108            origin.1,
3109        );
3110        let fry = ProjectiveMatrix::from_ass_transform_at_origin(
3111            EventTransform {
3112                rotation_y: 45.0,
3113                ..EventTransform::default()
3114            },
3115            origin.0,
3116            origin.1,
3117        );
3118
3119        let (frx_x, frx_y) = frx.transform_point(320.0, 140.0);
3120        let (fry_x, fry_y) = fry.transform_point(360.0, 180.0);
3121
3122        assert!(
3123            (frx_x - 320.0).abs() < 0.5,
3124            "frx must not act like fry: {frx_x}"
3125        );
3126        assert!(
3127            frx_y > 140.0,
3128            "positive frx should pitch the top edge downward: {frx_y}"
3129        );
3130        assert!(
3131            fry_x < 360.0,
3132            "positive fry should yaw the right edge leftward: {fry_x}"
3133        );
3134        assert!(
3135            (fry_y - 180.0).abs() < 0.5,
3136            "fry must not act like frx: {fry_y}"
3137        );
3138    }
3139
3140    #[test]
3141    fn projective_transform_uses_deep_org_as_perspective_lever_arm() {
3142        let transform = EventTransform {
3143            rotation_x: 55.0,
3144            ..EventTransform::default()
3145        };
3146        let shallow = ProjectiveMatrix::from_ass_transform_at_origin(transform, 320.0, 240.0);
3147        let deep = ProjectiveMatrix::from_ass_transform_at_origin(transform, 320.0, 420.0);
3148
3149        let (_, shallow_y) = shallow.transform_point(320.0, 240.0);
3150        let (_, deep_y) = deep.transform_point(320.0, 240.0);
3151
3152        assert!((shallow_y - 240.0).abs() < 0.5);
3153        assert!(
3154            deep_y > 340.0,
3155            "deep \\org below text should pull frx text down like libass, got y={deep_y}"
3156        );
3157    }
3158
3159    #[test]
3160    fn prepare_frame_only_keeps_active_events() {
3161        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");
3162        let engine = RenderEngine::new();
3163        let provider = NullFontProvider;
3164        let frame = engine.prepare_frame(&track, &provider, 500);
3165
3166        assert_eq!(frame.active_events.len(), 1);
3167        assert_eq!(frame.active_events[0].text, "First");
3168    }
3169
3170    #[test]
3171    fn render_frame_produces_image_planes_for_active_text() {
3172        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");
3173        let engine = RenderEngine::new();
3174        let provider = FontconfigProvider::new();
3175        let planes = engine.render_frame_with_provider(&track, &provider, 500);
3176
3177        assert!(!planes.is_empty());
3178        assert!(planes.iter().all(|plane| plane.size.width >= 0));
3179        assert!(planes.iter().all(|plane| plane.size.height >= 0));
3180    }
3181
3182    #[test]
3183    fn render_frame_supports_multiple_override_runs() {
3184        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");
3185        let engine = RenderEngine::new();
3186        let provider = FontconfigProvider::new();
3187        let planes = engine.render_frame_with_provider(&track, &provider, 500);
3188
3189        assert!(!planes.is_empty());
3190    }
3191
3192    #[test]
3193    fn render_frame_uses_axis_specific_shadow_offsets() {
3194        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");
3195        let engine = RenderEngine::new();
3196        let provider = FontconfigProvider::new();
3197        let planes = engine.render_frame_with_provider(&track, &provider, 500);
3198        let character_planes = planes
3199            .iter()
3200            .filter(|plane| plane.kind == ass::ImageType::Character)
3201            .cloned()
3202            .collect::<Vec<_>>();
3203        let shadow_planes = planes
3204            .iter()
3205            .filter(|plane| plane.kind == ass::ImageType::Shadow)
3206            .cloned()
3207            .collect::<Vec<_>>();
3208
3209        let character = visible_bounds(&character_planes).expect("character bounds");
3210        let shadow = visible_bounds(&shadow_planes).expect("axis-specific shadow should render");
3211        assert_eq!(shadow.x_min - character.x_min, 9);
3212        assert_eq!(shadow.y_min - character.y_min, 3);
3213    }
3214
3215    #[test]
3216    fn render_frame_renders_underline_and_strikeout_decorations() {
3217        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");
3218        let engine = RenderEngine::new();
3219        let provider = FontconfigProvider::new();
3220        let planes = engine.render_frame_with_provider(&track, &provider, 500);
3221        let decoration_planes = planes
3222            .iter()
3223            .filter(|plane| {
3224                plane.kind == ass::ImageType::Character
3225                    && plane.size.height <= 3
3226                    && plane.size.width > plane.size.height * 4
3227            })
3228            .collect::<Vec<_>>();
3229
3230        assert!(decoration_planes.len() >= 2);
3231    }
3232
3233    #[test]
3234    fn render_frame_uses_override_colors_and_shadow_planes() {
3235        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");
3236        let engine = RenderEngine::new();
3237        let provider = FontconfigProvider::new();
3238        let planes = engine.render_frame_with_provider(&track, &provider, 500);
3239
3240        assert!(
3241            planes.iter().any(
3242                |plane| plane.kind == ass::ImageType::Character && plane.color.0 == 0x3322_1100
3243            )
3244        );
3245        assert!(
3246            planes
3247                .iter()
3248                .any(|plane| plane.kind == ass::ImageType::Shadow && plane.color.0 == 0x6655_4400)
3249        );
3250    }
3251
3252    #[test]
3253    fn render_frame_orders_events_by_layer_then_read_order() {
3254        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");
3255        let engine = RenderEngine::new();
3256        let provider = FontconfigProvider::new();
3257        let planes = engine.render_frame_with_provider(&track, &provider, 500);
3258
3259        let first_character = planes
3260            .iter()
3261            .find(|plane| plane.kind == ass::ImageType::Character)
3262            .expect("character plane");
3263        assert_eq!(first_character.color.0, 0x00FF_0000);
3264    }
3265
3266    #[test]
3267    fn render_frame_orders_shadow_outline_before_character_within_event() {
3268        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");
3269        let engine = RenderEngine::new();
3270        let provider = FontconfigProvider::new();
3271        let planes = engine.render_frame_with_provider(&track, &provider, 500);
3272        let kinds = planes.iter().map(|plane| plane.kind).collect::<Vec<_>>();
3273
3274        let first_shadow = kinds
3275            .iter()
3276            .position(|kind| *kind == ass::ImageType::Shadow)
3277            .expect("shadow plane");
3278        let first_outline = kinds
3279            .iter()
3280            .position(|kind| *kind == ass::ImageType::Outline)
3281            .expect("outline plane");
3282        let first_character = kinds
3283            .iter()
3284            .position(|kind| *kind == ass::ImageType::Character)
3285            .expect("character plane");
3286
3287        assert!(first_shadow < first_outline);
3288        assert!(first_outline < first_character);
3289    }
3290
3291    #[test]
3292    fn render_frame_emits_outline_planes_for_border_override() {
3293        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");
3294        let engine = RenderEngine::new();
3295        let provider = FontconfigProvider::new();
3296        let planes = engine.render_frame_with_provider(&track, &provider, 500);
3297
3298        assert!(
3299            planes
3300                .iter()
3301                .any(|plane| plane.kind == ass::ImageType::Outline && plane.color.0 == 0x0C0B_0A00)
3302        );
3303    }
3304
3305    #[test]
3306    fn render_frame_emits_opaque_box_for_border_style_3() {
3307        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");
3308        let engine = RenderEngine::new();
3309        let provider = FontconfigProvider::new();
3310        let planes = engine.render_frame_with_provider(&track, &provider, 500);
3311        let character_planes = planes
3312            .iter()
3313            .filter(|plane| plane.kind == ass::ImageType::Character)
3314            .cloned()
3315            .collect::<Vec<_>>();
3316        let outline_planes = planes
3317            .iter()
3318            .filter(|plane| plane.kind == ass::ImageType::Outline)
3319            .cloned()
3320            .collect::<Vec<_>>();
3321
3322        assert_eq!(
3323            outline_planes.len(),
3324            1,
3325            "BorderStyle=3 should emit only the opaque box outline plane, not a separate stroked glyph outline"
3326        );
3327        let _character = visible_bounds(&character_planes).expect("character bounds");
3328        let outline = outline_planes
3329            .iter()
3330            .find(|plane| plane.color.0 == 0x0000_0000 && plane.bitmap.contains(&255))
3331            .expect("opaque border-style box plane uses outline colour");
3332        assert!(outline.size.width > 0);
3333        assert!(outline.size.height > 0);
3334        let bounds = visible_bounds(std::slice::from_ref(outline)).expect("opaque box bounds");
3335        let center_x = (bounds.x_min + bounds.x_max) / 2;
3336        assert!(
3337            (center_x - 250).abs() <= 2,
3338            "opaque box should stay centered at \\pos, got {bounds:?}"
3339        );
3340        let center_y = (bounds.y_min + bounds.y_max) / 2;
3341        assert!(
3342            (center_y - 80).abs() <= 1,
3343            "opaque box should stay vertically centered at \\pos like libass, got {bounds:?}"
3344        );
3345        assert_eq!(
3346            bounds.height(),
3347            36,
3348            "BorderStyle=3 box plane height should be font size plus two borders plus edge rows like libass"
3349        );
3350        assert!(
3351            bounds.width() < 370,
3352            "opaque box should use actual raster advance like libass, not inflated layout width: {bounds:?}"
3353        );
3354    }
3355
3356    #[test]
3357    fn render_frame_blurs_outline_and_shadow_layers() {
3358        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");
3359        let engine = RenderEngine::new();
3360        let provider = FontconfigProvider::new();
3361        let planes = engine.render_frame_with_provider(&track, &provider, 500);
3362
3363        assert!(
3364            planes
3365                .iter()
3366                .any(|plane| plane.kind == ass::ImageType::Outline
3367                    && plane.bitmap.iter().any(|value| *value > 0 && *value < 255))
3368        );
3369        assert!(
3370            planes
3371                .iter()
3372                .any(|plane| plane.kind == ass::ImageType::Shadow
3373                    && plane.bitmap.iter().any(|value| *value > 0 && *value < 255))
3374        );
3375    }
3376
3377    #[test]
3378    fn render_frame_blurs_fill_only_without_outline_or_shadow() {
3379        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");
3380        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");
3381        let engine = RenderEngine::new();
3382        let provider = FontconfigProvider::new();
3383        let base_planes = engine.render_frame_with_provider(&base, &provider, 500);
3384        let blurred_planes = engine.render_frame_with_provider(&blurred, &provider, 500);
3385        let base_character = visible_bounds(
3386            &base_planes
3387                .iter()
3388                .filter(|plane| plane.kind == ass::ImageType::Character)
3389                .cloned()
3390                .collect::<Vec<_>>(),
3391        )
3392        .expect("base character bounds");
3393        let blurred_character = visible_bounds(
3394            &blurred_planes
3395                .iter()
3396                .filter(|plane| plane.kind == ass::ImageType::Character)
3397                .cloned()
3398                .collect::<Vec<_>>(),
3399        )
3400        .expect("blurred character bounds");
3401
3402        assert!(blurred_character.x_min < base_character.x_min);
3403        assert!(blurred_character.x_max > base_character.x_max);
3404        assert!(blurred_character.y_min < base_character.y_min);
3405        assert!(blurred_character.y_max > base_character.y_max);
3406    }
3407
3408    #[test]
3409    fn render_frame_does_not_blur_fill_when_outline_or_shadow_exists() {
3410        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");
3411        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");
3412        let engine = RenderEngine::new();
3413        let provider = FontconfigProvider::new();
3414        let base_planes = engine.render_frame_with_provider(&base, &provider, 500);
3415        let blurred_planes = engine.render_frame_with_provider(&blurred, &provider, 500);
3416        let character_bounds = |planes: &[ImagePlane]| {
3417            visible_bounds(
3418                &planes
3419                    .iter()
3420                    .filter(|plane| plane.kind == ass::ImageType::Character)
3421                    .cloned()
3422                    .collect::<Vec<_>>(),
3423            )
3424            .expect("character bounds")
3425        };
3426
3427        assert_eq!(
3428            character_bounds(&blurred_planes),
3429            character_bounds(&base_planes)
3430        );
3431        assert!(
3432            blurred_planes
3433                .iter()
3434                .filter(|plane| plane.kind == ass::ImageType::Outline)
3435                .any(|plane| plane.bitmap.iter().any(|value| *value > 0 && *value < 255))
3436        );
3437        assert!(
3438            blurred_planes
3439                .iter()
3440                .filter(|plane| plane.kind == ass::ImageType::Shadow)
3441                .any(|plane| plane.bitmap.iter().any(|value| *value > 0 && *value < 255))
3442        );
3443    }
3444
3445    #[test]
3446    fn render_frame_applies_rectangular_clip() {
3447        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");
3448        let engine = RenderEngine::new();
3449        let provider = FontconfigProvider::new();
3450        let planes = engine.render_frame_with_provider(&track, &provider, 500);
3451
3452        assert!(!planes.is_empty());
3453        assert!(planes.iter().all(|plane| plane.destination.x >= 0));
3454        assert!(planes.iter().all(|plane| plane.destination.y >= 0));
3455        assert!(
3456            planes
3457                .iter()
3458                .all(|plane| plane.destination.x + plane.size.width <= 64)
3459        );
3460        assert!(
3461            planes
3462                .iter()
3463                .all(|plane| plane.destination.y + plane.size.height <= 64)
3464        );
3465    }
3466
3467    #[test]
3468    fn render_frame_accepts_renderer_shaping_mode() {
3469        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");
3470        let engine = RenderEngine::new();
3471        let provider = FontconfigProvider::new();
3472        let simple = engine.render_frame_with_provider_and_config(
3473            &track,
3474            &provider,
3475            500,
3476            &RendererConfig {
3477                shaping: ass::ShapingLevel::Simple,
3478                ..default_renderer_config(&track)
3479            },
3480        );
3481        let complex = engine.render_frame_with_provider_and_config(
3482            &track,
3483            &provider,
3484            500,
3485            &RendererConfig {
3486                shaping: ass::ShapingLevel::Complex,
3487                ..default_renderer_config(&track)
3488            },
3489        );
3490
3491        assert!(!simple.is_empty());
3492        assert!(!complex.is_empty());
3493    }
3494
3495    #[test]
3496    fn render_frame_applies_inverse_rectangular_clip() {
3497        let plane = ImagePlane {
3498            size: Size {
3499                width: 6,
3500                height: 4,
3501            },
3502            stride: 6,
3503            color: RgbaColor(0x00FF_FFFF),
3504            destination: Point { x: 0, y: 0 },
3505            kind: ass::ImageType::Character,
3506            bitmap: vec![255; 24],
3507        };
3508        let parts = inverse_clip_plane(
3509            plane,
3510            Rect {
3511                x_min: 2,
3512                y_min: 1,
3513                x_max: 4,
3514                y_max: 3,
3515            },
3516        );
3517
3518        assert_eq!(parts.len(), 4);
3519        assert_eq!(
3520            parts.iter().map(|plane| plane.bitmap.len()).sum::<usize>(),
3521            20
3522        );
3523    }
3524
3525    #[test]
3526    fn inverse_clip_bleed_covers_outline_growth_to_prevent_stray_glyph_leakage() {
3527        let style = ParsedSpanStyle {
3528            border: 5.0,
3529            border_x: 5.0,
3530            border_y: 5.0,
3531            shadow: 0.0,
3532            shadow_x: 0.0,
3533            shadow_y: 0.0,
3534            blur: 0.0,
3535            be: 0.0,
3536            ..ParsedSpanStyle::default()
3537        };
3538        let clip = Rect {
3539            x_min: 20,
3540            y_min: 0,
3541            x_max: 24,
3542            y_max: 10,
3543        };
3544        let glyph = ImagePlane {
3545            size: Size {
3546                width: 44,
3547                height: 10,
3548            },
3549            stride: 44,
3550            color: RgbaColor(0x00FF_FFFF),
3551            destination: Point { x: 0, y: 0 },
3552            kind: ass::ImageType::Outline,
3553            bitmap: vec![255; 440],
3554        };
3555
3556        let expanded = expand_rect(clip, style_clip_bleed(&style));
3557        let parts = inverse_clip_plane(glyph, expanded);
3558
3559        assert!(
3560            parts
3561                .iter()
3562                .all(|plane| plane.destination.x + plane.size.width <= 0
3563                    || plane.destination.x >= 44),
3564            "inverse clip must mask outline bleed around the nominal clip, got {parts:?}"
3565        );
3566    }
3567
3568    #[test]
3569    fn render_frame_applies_vector_clip() {
3570        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");
3571        let engine = RenderEngine::new();
3572        let provider = FontconfigProvider::new();
3573        let planes = engine.render_frame_with_provider(&track, &provider, 500);
3574
3575        assert!(!planes.is_empty());
3576        assert!(
3577            planes
3578                .iter()
3579                .all(|plane| plane.bitmap.iter().any(|value| *value > 0))
3580        );
3581        assert!(planes.iter().all(|plane| plane.destination.x >= 0));
3582        assert!(planes.iter().all(|plane| plane.destination.y >= 0));
3583    }
3584
3585    #[test]
3586    fn render_frame_clips_to_frame_bounds() {
3587        let plane = ImagePlane {
3588            size: Size {
3589                width: 20,
3590                height: 20,
3591            },
3592            stride: 20,
3593            color: RgbaColor(0x00FF_FFFF),
3594            destination: Point { x: 50, y: 50 },
3595            kind: ass::ImageType::Character,
3596            bitmap: vec![255; 400],
3597        };
3598        let clipped = apply_event_clip(
3599            vec![plane],
3600            Rect {
3601                x_min: 0,
3602                y_min: 0,
3603                x_max: 60,
3604                y_max: 60,
3605            },
3606            false,
3607        );
3608
3609        assert_eq!(clipped.len(), 1);
3610        assert_eq!(clipped[0].size.width, 10);
3611        assert_eq!(clipped[0].size.height, 10);
3612    }
3613
3614    #[test]
3615    fn render_frame_applies_margin_clip_when_enabled() {
3616        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");
3617        let engine = RenderEngine::new();
3618        let provider = FontconfigProvider::new();
3619        let planes = engine.render_frame_with_provider_and_config(
3620            &track,
3621            &provider,
3622            500,
3623            &config(
3624                100,
3625                100,
3626                rassa_core::Margins {
3627                    top: 10,
3628                    bottom: 10,
3629                    left: 10,
3630                    right: 10,
3631                },
3632                true,
3633            ),
3634        );
3635
3636        assert!(!planes.is_empty());
3637        assert!(planes.iter().all(|plane| plane.destination.x >= 10));
3638        assert!(planes.iter().all(|plane| plane.destination.y >= 10));
3639        assert!(
3640            planes
3641                .iter()
3642                .all(|plane| plane.destination.x + plane.size.width <= 90)
3643        );
3644        assert!(
3645            planes
3646                .iter()
3647                .all(|plane| plane.destination.y + plane.size.height <= 90)
3648        );
3649    }
3650
3651    #[test]
3652    fn render_frame_maps_into_content_area_when_margins_are_not_used() {
3653        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");
3654        let engine = RenderEngine::new();
3655        let provider = FontconfigProvider::new();
3656        let planes = engine.render_frame_with_provider_and_config(
3657            &track,
3658            &provider,
3659            500,
3660            &config(
3661                120,
3662                120,
3663                rassa_core::Margins {
3664                    top: 10,
3665                    bottom: 10,
3666                    left: 10,
3667                    right: 10,
3668                },
3669                false,
3670            ),
3671        );
3672
3673        assert!(!planes.is_empty());
3674        let bounds = visible_bounds(&planes).expect("visible bounds");
3675        assert!(
3676            bounds.x_min >= 10,
3677            "visible bounds should start inside content area: {bounds:?}"
3678        );
3679        assert!(
3680            bounds.y_min >= 9,
3681            "libass-style antialiasing may allocate one guard row above the content area: {bounds:?}"
3682        );
3683        assert!(
3684            bounds.x_max <= 110,
3685            "visible bounds should end inside content area: {bounds:?}"
3686        );
3687        assert!(
3688            bounds.y_max <= 110,
3689            "visible bounds should end inside content area: {bounds:?}"
3690        );
3691    }
3692
3693    #[test]
3694    fn render_frame_keeps_border_closer_to_device_size_when_scaled_border_is_disabled() {
3695        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");
3696        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");
3697        let engine = RenderEngine::new();
3698        let provider = FontconfigProvider::new();
3699        let config = config(200, 200, rassa_core::Margins::default(), true);
3700        let enabled_planes =
3701            engine.render_frame_with_provider_and_config(&enabled, &provider, 500, &config);
3702        let disabled_planes =
3703            engine.render_frame_with_provider_and_config(&disabled, &provider, 500, &config);
3704        let enabled_outline_area: i32 = enabled_planes
3705            .iter()
3706            .filter(|plane| plane.kind == ass::ImageType::Outline)
3707            .map(|plane| plane.size.width * plane.size.height)
3708            .sum();
3709        let disabled_outline_area: i32 = disabled_planes
3710            .iter()
3711            .filter(|plane| plane.kind == ass::ImageType::Outline)
3712            .map(|plane| plane.size.width * plane.size.height)
3713            .sum();
3714
3715        assert!(disabled_outline_area > 0);
3716        assert!(disabled_outline_area < enabled_outline_area);
3717    }
3718
3719    #[test]
3720    fn render_frame_applies_font_scale_to_output() {
3721        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");
3722        let engine = RenderEngine::new();
3723        let provider = FontconfigProvider::new();
3724
3725        let baseline = engine.render_frame_with_provider(&track, &provider, 500);
3726        let scaled = engine.render_frame_with_provider_and_config(
3727            &track,
3728            &provider,
3729            500,
3730            &RendererConfig {
3731                frame: Size {
3732                    width: 200,
3733                    height: 120,
3734                },
3735                font_scale: 2.0,
3736                ..RendererConfig::default()
3737            },
3738        );
3739
3740        assert!(!baseline.is_empty());
3741        assert!(!scaled.is_empty());
3742        assert!(total_plane_area(&scaled) > total_plane_area(&baseline));
3743    }
3744
3745    #[test]
3746    fn render_frame_applies_text_scale_overrides() {
3747        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");
3748        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");
3749        let engine = RenderEngine::new();
3750        let provider = FontconfigProvider::new();
3751        let baseline = engine.render_frame_with_provider(&track, &provider, 500);
3752        let scaled = engine.render_frame_with_provider(&stretched, &provider, 500);
3753        let baseline_width = baseline
3754            .iter()
3755            .filter(|plane| plane.kind == ass::ImageType::Character)
3756            .map(|plane| plane.destination.x + plane.size.width)
3757            .max()
3758            .expect("baseline max x")
3759            - baseline
3760                .iter()
3761                .filter(|plane| plane.kind == ass::ImageType::Character)
3762                .map(|plane| plane.destination.x)
3763                .min()
3764                .expect("baseline min x");
3765        let scaled_width = scaled
3766            .iter()
3767            .filter(|plane| plane.kind == ass::ImageType::Character)
3768            .map(|plane| plane.destination.x + plane.size.width)
3769            .max()
3770            .expect("scaled max x")
3771            - scaled
3772                .iter()
3773                .filter(|plane| plane.kind == ass::ImageType::Character)
3774                .map(|plane| plane.destination.x)
3775                .min()
3776                .expect("scaled min x");
3777
3778        assert!(scaled_width > baseline_width);
3779        assert!(total_plane_area(&scaled) < total_plane_area(&baseline) * 2);
3780    }
3781
3782    #[test]
3783    fn render_frame_applies_drawing_scale_overrides() {
3784        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");
3785        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");
3786        let engine = RenderEngine::new();
3787        let provider = FontconfigProvider::new();
3788        let baseline_planes = engine.render_frame_with_provider(&baseline, &provider, 500);
3789        let scaled_planes = engine.render_frame_with_provider(&scaled, &provider, 500);
3790        let baseline_plane = baseline_planes
3791            .iter()
3792            .find(|plane| plane.kind == ass::ImageType::Character)
3793            .expect("baseline drawing plane");
3794        let scaled_plane = scaled_planes
3795            .iter()
3796            .find(|plane| plane.kind == ass::ImageType::Character)
3797            .expect("scaled drawing plane");
3798
3799        assert!(scaled_plane.size.width > baseline_plane.size.width);
3800        assert!(scaled_plane.size.height < baseline_plane.size.height);
3801        assert_eq!(scaled_plane.destination, Point { x: 10, y: 10 });
3802    }
3803
3804    #[test]
3805    fn render_frame_applies_text_spacing_override() {
3806        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");
3807        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");
3808        let engine = RenderEngine::new();
3809        let provider = FontconfigProvider::new();
3810        let baseline_planes = engine.render_frame_with_provider(&baseline, &provider, 500);
3811        let spaced_planes = engine.render_frame_with_provider(&spaced, &provider, 500);
3812        let baseline_width = character_bounds(&baseline_planes)
3813            .expect("baseline bounds")
3814            .width();
3815        let spaced_width = character_bounds(&spaced_planes)
3816            .expect("spaced bounds")
3817            .width();
3818
3819        assert!(spaced_width > baseline_width);
3820    }
3821
3822    #[test]
3823    fn render_frame_scales_output_to_frame_size() {
3824        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");
3825        let engine = RenderEngine::new();
3826        let provider = FontconfigProvider::new();
3827
3828        let baseline = engine.render_frame_with_provider(&track, &provider, 500);
3829        let scaled = engine.render_frame_with_provider_and_config(
3830            &track,
3831            &provider,
3832            500,
3833            &RendererConfig {
3834                frame: Size {
3835                    width: 400,
3836                    height: 240,
3837                },
3838                ..default_renderer_config(&track)
3839            },
3840        );
3841
3842        assert!(total_plane_area(&baseline) > 0);
3843        assert!(total_plane_area(&scaled) > total_plane_area(&baseline));
3844    }
3845
3846    #[test]
3847    fn render_frame_applies_pixel_aspect_horizontally() {
3848        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");
3849        let engine = RenderEngine::new();
3850        let provider = FontconfigProvider::new();
3851
3852        let baseline = engine.render_frame_with_provider_and_config(
3853            &track,
3854            &provider,
3855            500,
3856            &RendererConfig {
3857                frame: Size {
3858                    width: 400,
3859                    height: 120,
3860                },
3861                ..default_renderer_config(&track)
3862            },
3863        );
3864        let widened = engine.render_frame_with_provider_and_config(
3865            &track,
3866            &provider,
3867            500,
3868            &RendererConfig {
3869                frame: Size {
3870                    width: 400,
3871                    height: 120,
3872                },
3873                pixel_aspect: 2.0,
3874                ..default_renderer_config(&track)
3875            },
3876        );
3877
3878        let baseline_bounds = character_bounds(&baseline).expect("baseline character bounds");
3879        let widened_bounds = character_bounds(&widened).expect("widened character bounds");
3880        assert!(
3881            widened_bounds.x_min > baseline_bounds.x_min,
3882            "pixel aspect should affect horizontal placement: baseline={baseline_bounds:?} widened={widened_bounds:?}"
3883        );
3884    }
3885
3886    #[test]
3887    fn render_frame_derives_pixel_aspect_from_storage_size_when_unset() {
3888        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");
3889        let engine = RenderEngine::new();
3890        let provider = FontconfigProvider::new();
3891
3892        let baseline = engine.render_frame_with_provider_and_config(
3893            &track,
3894            &provider,
3895            500,
3896            &RendererConfig {
3897                frame: Size {
3898                    width: 400,
3899                    height: 240,
3900                },
3901                ..default_renderer_config(&track)
3902            },
3903        );
3904        let storage_adjusted = engine.render_frame_with_provider_and_config(
3905            &track,
3906            &provider,
3907            500,
3908            &RendererConfig {
3909                frame: Size {
3910                    width: 400,
3911                    height: 240,
3912                },
3913                storage: Size {
3914                    width: 400,
3915                    height: 120,
3916                },
3917                ..default_renderer_config(&track)
3918            },
3919        );
3920
3921        assert!(total_plane_area(&baseline) > 0);
3922        assert!(total_plane_area(&storage_adjusted) < total_plane_area(&baseline));
3923    }
3924
3925    #[test]
3926    fn render_frame_layout_resolution_takes_precedence_over_storage_and_explicit_aspect() {
3927        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");
3928        let engine = RenderEngine::new();
3929        let provider = FontconfigProvider::new();
3930
3931        let baseline = engine.render_frame_with_provider_and_config(
3932            &track,
3933            &provider,
3934            500,
3935            &RendererConfig {
3936                frame: Size {
3937                    width: 400,
3938                    height: 240,
3939                },
3940                ..default_renderer_config(&track)
3941            },
3942        );
3943        let overridden_inputs = engine.render_frame_with_provider_and_config(
3944            &track,
3945            &provider,
3946            500,
3947            &RendererConfig {
3948                frame: Size {
3949                    width: 400,
3950                    height: 240,
3951                },
3952                storage: Size {
3953                    width: 400,
3954                    height: 120,
3955                },
3956                pixel_aspect: 2.0,
3957                ..default_renderer_config(&track)
3958            },
3959        );
3960
3961        assert_eq!(
3962            total_plane_area(&overridden_inputs),
3963            total_plane_area(&baseline)
3964        );
3965    }
3966
3967    #[test]
3968    fn render_frame_applies_line_position_to_subtitles() {
3969        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");
3970        let engine = RenderEngine::new();
3971        let provider = FontconfigProvider::new();
3972
3973        let baseline = engine.render_frame_with_provider(&track, &provider, 500);
3974        let shifted = engine.render_frame_with_provider_and_config(
3975            &track,
3976            &provider,
3977            500,
3978            &RendererConfig {
3979                frame: Size {
3980                    width: 200,
3981                    height: 120,
3982                },
3983                line_position: 50.0,
3984                ..RendererConfig::default()
3985            },
3986        );
3987
3988        let baseline_y = baseline
3989            .iter()
3990            .map(|plane| plane.destination.y)
3991            .min()
3992            .expect("baseline plane");
3993        let shifted_y = shifted
3994            .iter()
3995            .map(|plane| plane.destination.y)
3996            .min()
3997            .expect("shifted plane");
3998
3999        assert!(shifted_y < baseline_y);
4000    }
4001
4002    #[test]
4003    fn render_frame_applies_line_spacing_to_multiline_subtitles() {
4004        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");
4005        let engine = RenderEngine::new();
4006        let provider = FontconfigProvider::new();
4007
4008        let baseline = engine.render_frame_with_provider(&track, &provider, 500);
4009        let spaced = engine.render_frame_with_provider_and_config(
4010            &track,
4011            &provider,
4012            500,
4013            &RendererConfig {
4014                frame: Size {
4015                    width: 200,
4016                    height: 140,
4017                },
4018                line_spacing: 20.0,
4019                ..RendererConfig::default()
4020            },
4021        );
4022
4023        assert!(vertical_span(&spaced) > vertical_span(&baseline));
4024    }
4025
4026    #[test]
4027    fn render_frame_avoids_basic_bottom_collision_for_unpositioned_events() {
4028        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");
4029        let engine = RenderEngine::new();
4030        let provider = FontconfigProvider::new();
4031        let planes = engine.render_frame_with_provider(&track, &provider, 500);
4032
4033        let mut ys = planes
4034            .iter()
4035            .filter(|plane| plane.kind == ass::ImageType::Character)
4036            .map(|plane| plane.destination.y)
4037            .collect::<Vec<_>>();
4038        ys.sort_unstable();
4039        ys.dedup();
4040
4041        assert!(ys.len() >= 2);
4042        assert!(ys.last().expect("max y") - ys.first().expect("min y") >= 20);
4043    }
4044
4045    #[test]
4046    fn render_frame_allows_basic_collision_across_different_layers() {
4047        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");
4048        let engine = RenderEngine::new();
4049        let provider = FontconfigProvider::new();
4050        let planes = engine.render_frame_with_provider(&track, &provider, 500);
4051
4052        let layer0_y = planes
4053            .iter()
4054            .filter(|plane| plane.kind == ass::ImageType::Character && plane.color.0 == 0xFF00_0000)
4055            .map(|plane| plane.destination.y)
4056            .min()
4057            .expect("layer 0 character plane");
4058        let layer1_y = planes
4059            .iter()
4060            .filter(|plane| plane.kind == ass::ImageType::Character && plane.color.0 == 0x00FF_0000)
4061            .map(|plane| plane.destination.y)
4062            .min()
4063            .expect("layer 1 character plane");
4064
4065        assert_eq!(layer0_y, layer1_y);
4066    }
4067
4068    #[test]
4069    fn render_frame_interpolates_move_position() {
4070        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");
4071        let engine = RenderEngine::new();
4072        let provider = FontconfigProvider::new();
4073        let start_planes = engine.render_frame_with_provider(&track, &provider, 0);
4074        let mid_planes = engine.render_frame_with_provider(&track, &provider, 500);
4075        let end_planes = engine.render_frame_with_provider(&track, &provider, 999);
4076
4077        let start_x = start_planes
4078            .iter()
4079            .map(|plane| plane.destination.x)
4080            .min()
4081            .expect("start plane");
4082        let mid_x = mid_planes
4083            .iter()
4084            .map(|plane| plane.destination.x)
4085            .min()
4086            .expect("mid plane");
4087        let end_x = end_planes
4088            .iter()
4089            .map(|plane| plane.destination.x)
4090            .min()
4091            .expect("end plane");
4092
4093        assert!(start_x <= mid_x);
4094        assert!(mid_x <= end_x);
4095        assert!(end_x - start_x >= 80);
4096    }
4097
4098    #[test]
4099    fn render_frame_applies_z_rotation_to_event_planes() {
4100        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");
4101        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");
4102        let engine = RenderEngine::new();
4103        let provider = FontconfigProvider::new();
4104        let baseline_planes = engine.render_frame_with_provider(&baseline, &provider, 500);
4105        let rotated_planes = engine.render_frame_with_provider(&rotated, &provider, 500);
4106        let baseline_bounds = character_bounds(&baseline_planes).expect("baseline bounds");
4107        let rotated_bounds = character_bounds(&rotated_planes).expect("rotated bounds");
4108
4109        assert!(baseline_bounds.width() > baseline_bounds.height());
4110        assert!(rotated_bounds.height() > rotated_bounds.width());
4111    }
4112
4113    #[test]
4114    fn render_frame_interpolates_z_rotation_transform() {
4115        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");
4116        let engine = RenderEngine::new();
4117        let provider = FontconfigProvider::new();
4118        let start_planes = engine.render_frame_with_provider(&track, &provider, 0);
4119        let end_planes = engine.render_frame_with_provider(&track, &provider, 999);
4120        let start_bounds = character_bounds(&start_planes).expect("start bounds");
4121        let end_bounds = character_bounds(&end_planes).expect("end bounds");
4122
4123        assert!(start_bounds.width() > start_bounds.height());
4124        assert!(end_bounds.height() > end_bounds.width());
4125    }
4126
4127    #[test]
4128    fn render_frame_applies_fad_alpha() {
4129        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");
4130        let engine = RenderEngine::new();
4131        let provider = FontconfigProvider::new();
4132        let start_planes = engine.render_frame_with_provider(&track, &provider, 0);
4133        let mid_planes = engine.render_frame_with_provider(&track, &provider, 500);
4134        let end_planes = engine.render_frame_with_provider(&track, &provider, 999);
4135
4136        let start_alpha = start_planes
4137            .iter()
4138            .map(|plane| plane.color.0 & 0xFF)
4139            .max()
4140            .expect("start alpha");
4141        let mid_alpha = mid_planes
4142            .iter()
4143            .map(|plane| plane.color.0 & 0xFF)
4144            .max()
4145            .expect("mid alpha");
4146        let end_alpha = end_planes
4147            .iter()
4148            .map(|plane| plane.color.0 & 0xFF)
4149            .max()
4150            .expect("end alpha");
4151
4152        assert!(start_alpha > mid_alpha);
4153        assert!(end_alpha > mid_alpha);
4154    }
4155
4156    #[test]
4157    fn render_frame_applies_full_fade_alpha() {
4158        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");
4159        let engine = RenderEngine::new();
4160        let provider = FontconfigProvider::new();
4161        let start_planes = engine.render_frame_with_provider(&track, &provider, 0);
4162        let middle_planes = engine.render_frame_with_provider(&track, &provider, 400);
4163        let late_planes = engine.render_frame_with_provider(&track, &provider, 850);
4164
4165        let start_alpha = start_planes
4166            .iter()
4167            .map(|plane| plane.color.0 & 0xFF)
4168            .max()
4169            .expect("start alpha");
4170        let middle_alpha = middle_planes
4171            .iter()
4172            .map(|plane| plane.color.0 & 0xFF)
4173            .max()
4174            .expect("middle alpha");
4175        let late_alpha = late_planes
4176            .iter()
4177            .map(|plane| plane.color.0 & 0xFF)
4178            .max()
4179            .expect("late alpha");
4180
4181        assert!(start_alpha > middle_alpha);
4182        assert!(late_alpha > middle_alpha);
4183        assert!(late_alpha < start_alpha);
4184    }
4185
4186    #[test]
4187    fn render_frame_switches_karaoke_fill_after_elapsed_span() {
4188        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");
4189        let engine = RenderEngine::new();
4190        let provider = FontconfigProvider::new();
4191        let early_planes = engine.render_frame_with_provider(&track, &provider, 200);
4192        let late_planes = engine.render_frame_with_provider(&track, &provider, 700);
4193
4194        assert!(
4195            early_planes.iter().any(
4196                |plane| plane.kind == ass::ImageType::Character && plane.color.0 == 0x6655_4400
4197            )
4198        );
4199        assert!(
4200            late_planes.iter().any(
4201                |plane| plane.kind == ass::ImageType::Character && plane.color.0 == 0x3322_1100
4202            )
4203        );
4204    }
4205
4206    #[test]
4207    fn render_frame_sweeps_karaoke_fill_during_active_span() {
4208        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");
4209        let engine = RenderEngine::new();
4210        let provider = FontconfigProvider::new();
4211        let mid_planes = engine.render_frame_with_provider(&track, &provider, 500);
4212
4213        assert!(
4214            mid_planes.iter().any(
4215                |plane| plane.kind == ass::ImageType::Character && plane.color.0 == 0x3322_1100
4216            )
4217        );
4218        assert!(
4219            mid_planes.iter().any(
4220                |plane| plane.kind == ass::ImageType::Character && plane.color.0 == 0x6655_4400
4221            )
4222        );
4223    }
4224
4225    #[test]
4226    fn render_frame_hides_outline_for_ko_until_span_ends() {
4227        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");
4228        let engine = RenderEngine::new();
4229        let provider = FontconfigProvider::new();
4230        let early_planes = engine.render_frame_with_provider(&track, &provider, 200);
4231        let late_planes = engine.render_frame_with_provider(&track, &provider, 700);
4232
4233        assert!(
4234            !early_planes
4235                .iter()
4236                .any(|plane| plane.kind == ass::ImageType::Outline)
4237        );
4238        assert!(
4239            late_planes
4240                .iter()
4241                .any(|plane| plane.kind == ass::ImageType::Outline)
4242        );
4243    }
4244
4245    #[test]
4246    fn render_frame_renders_drawing_plane() {
4247        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");
4248        let engine = RenderEngine::new();
4249        let provider = FontconfigProvider::new();
4250        let planes = engine.render_frame_with_provider(&track, &provider, 500);
4251
4252        assert!(
4253            planes.iter().any(
4254                |plane| plane.kind == ass::ImageType::Character && plane.color.0 == 0x3322_1100
4255            )
4256        );
4257        let plane = planes
4258            .iter()
4259            .find(|plane| plane.kind == ass::ImageType::Character)
4260            .expect("drawing plane");
4261        assert_eq!(plane.destination.x, 10);
4262        assert_eq!(plane.destination.y, 10);
4263        assert!(plane.bitmap.contains(&255));
4264    }
4265
4266    #[test]
4267    fn render_frame_renders_bezier_drawing_plane() {
4268        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");
4269        let engine = RenderEngine::new();
4270        let provider = FontconfigProvider::new();
4271        let planes = engine.render_frame_with_provider(&track, &provider, 500);
4272
4273        let plane = planes
4274            .iter()
4275            .find(|plane| plane.kind == ass::ImageType::Character)
4276            .expect("drawing plane");
4277        assert!(plane.bitmap.contains(&255));
4278        assert!(plane.size.width >= 8);
4279        assert!(plane.size.height >= 8);
4280    }
4281
4282    #[test]
4283    fn render_frame_emits_outline_and_shadow_for_drawings() {
4284        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");
4285        let engine = RenderEngine::new();
4286        let provider = FontconfigProvider::new();
4287        let planes = engine.render_frame_with_provider(&track, &provider, 500);
4288
4289        assert!(
4290            planes
4291                .iter()
4292                .any(|plane| plane.kind == ass::ImageType::Outline && plane.color.0 == 0x0C0B_0A00)
4293        );
4294        assert!(
4295            planes
4296                .iter()
4297                .any(|plane| plane.kind == ass::ImageType::Shadow && plane.color.0 == 0x6655_4400)
4298        );
4299    }
4300
4301    #[test]
4302    fn render_frame_renders_spline_drawing_plane() {
4303        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");
4304        let engine = RenderEngine::new();
4305        let provider = FontconfigProvider::new();
4306        let planes = engine.render_frame_with_provider(&track, &provider, 500);
4307
4308        let plane = planes
4309            .iter()
4310            .find(|plane| plane.kind == ass::ImageType::Character)
4311            .expect("drawing plane");
4312        assert!(plane.bitmap.contains(&255));
4313        assert!(plane.size.width >= 10);
4314        assert!(plane.size.height >= 10);
4315    }
4316
4317    #[test]
4318    fn render_frame_renders_non_closing_move_subpaths() {
4319        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");
4320        let engine = RenderEngine::new();
4321        let provider = FontconfigProvider::new();
4322        let planes = engine.render_frame_with_provider(&track, &provider, 500);
4323
4324        let plane = planes
4325            .iter()
4326            .find(|plane| plane.kind == ass::ImageType::Character)
4327            .expect("drawing plane");
4328        assert!(plane.bitmap.contains(&255));
4329        assert!(plane.size.width >= 28);
4330        assert!(plane.size.height >= 28);
4331    }
4332
4333    #[test]
4334    fn render_frame_applies_timed_transform_style() {
4335        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");
4336        let engine = RenderEngine::new();
4337        let provider = FontconfigProvider::new();
4338        let start_planes = engine.render_frame_with_provider(&track, &provider, 0);
4339        let mid_planes = engine.render_frame_with_provider(&track, &provider, 500);
4340        let end_planes = engine.render_frame_with_provider(&track, &provider, 999);
4341
4342        assert!(
4343            !start_planes
4344                .iter()
4345                .any(|plane| plane.kind == ass::ImageType::Outline)
4346        );
4347        assert!(
4348            mid_planes
4349                .iter()
4350                .any(|plane| plane.kind == ass::ImageType::Outline)
4351        );
4352        assert!(
4353            end_planes
4354                .iter()
4355                .any(|plane| plane.kind == ass::ImageType::Outline)
4356        );
4357
4358        let start_fill = start_planes
4359            .iter()
4360            .find(|plane| plane.kind == ass::ImageType::Character)
4361            .expect("start fill")
4362            .color
4363            .0;
4364        let end_fill = end_planes
4365            .iter()
4366            .find(|plane| plane.kind == ass::ImageType::Character)
4367            .expect("end fill")
4368            .color
4369            .0;
4370        assert_ne!(start_fill, end_fill);
4371        assert!(total_plane_area(&end_planes) > total_plane_area(&start_planes));
4372    }
4373}