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