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