use std::collections::HashMap;
use rassa_core::{ImagePlane, Point, Rect, RendererConfig, RgbaColor, Size, ass};
use rassa_fonts::{FontProvider, FontconfigProvider};
use rassa_layout::{LayoutEngine, LayoutEvent, LayoutGlyphRun};
use rassa_parse::{
ParsedDrawing, ParsedEvent, ParsedFade, ParsedKaraokeMode, ParsedMovement, ParsedSpanStyle,
ParsedTrack, ParsedVectorClip,
};
use rassa_raster::{RasterGlyph, RasterOptions, Rasterizer};
use rassa_shape::{GlyphInfo, ShapingMode};
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct RenderSelection {
pub active_event_indices: Vec<usize>,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct PreparedFrame {
pub now_ms: i64,
pub active_events: Vec<LayoutEvent>,
}
#[derive(Default)]
pub struct RenderEngine {
layout: LayoutEngine,
}
const LINE_HEIGHT: i32 = 40;
fn layout_line_height(config: &RendererConfig, scale_y: f64) -> i32 {
let scale_y = style_scale(scale_y);
let extra_spacing = if config.line_spacing.is_finite() {
(config.line_spacing * scale_y).round() as i32
} else {
0
};
((f64::from(LINE_HEIGHT) * scale_y).round() as i32 + extra_spacing).max(1)
}
fn layout_line_height_for_line(
line: &rassa_layout::LayoutLine,
config: &RendererConfig,
scale_y: f64,
) -> i32 {
if line.runs.iter().all(|run| run.drawing.is_some()) {
return drawing_only_line_height(line, scale_y);
}
text_layout_line_height_for_line(line, config, scale_y)
}
fn positioned_layout_line_height_for_line(
line: &rassa_layout::LayoutLine,
config: &RendererConfig,
scale_y: f64,
) -> i32 {
if line.runs.iter().all(|run| run.drawing.is_some()) {
return drawing_only_line_height(line, scale_y);
}
layout_line_height(config, scale_y).max(font_metric_height_for_line(line, scale_y))
}
fn text_layout_line_height_for_line(
line: &rassa_layout::LayoutLine,
config: &RendererConfig,
scale_y: f64,
) -> i32 {
let scale_y = style_scale(scale_y);
let max_font_size = line
.runs
.iter()
.filter(|run| run.drawing.is_none())
.map(|run| run.style.font_size)
.filter(|size| size.is_finite() && *size > 0.0)
.fold(0.0_f64, f64::max);
let extra_spacing = if config.line_spacing.is_finite() {
(config.line_spacing * scale_y).round() as i32
} else {
0
};
((max_font_size * scale_y).round() as i32 + extra_spacing).max(1)
}
fn rendered_text_alignment_width(
line: &rassa_layout::LayoutLine,
source_event: Option<&ParsedEvent>,
now_ms: i64,
track: &ParsedTrack,
config: &RendererConfig,
render_scale: RenderScale,
) -> i32 {
if line.runs.iter().all(|run| run.drawing.is_some()) {
return (f64::from(line.width) * style_scale(render_scale.x)).round() as i32;
}
let mut width = 0_i32;
let mut leading_ink_offset = i32::MAX;
for run in &line.runs {
if run.drawing.is_some() {
width += (f64::from(run.width) * style_scale(render_scale.x)).round() as i32;
continue;
}
if run.glyphs.is_empty() {
continue;
}
let effective_style = apply_renderer_style_scale(
resolve_run_style(run, source_event, now_ms),
track,
config,
render_scale.uniform,
);
let rasterizer = Rasterizer::with_options(RasterOptions {
size_26_6: (effective_style.font_size.max(1.0) * 64.0).round() as i32,
hinting: config.hinting,
});
let glyph_infos = scale_glyph_infos(&run.glyphs, render_scale.x, render_scale.y);
let Ok(raster_glyphs) = rasterizer.rasterize_glyphs(&run.font, &glyph_infos) else {
width += (f64::from(run.width) * style_scale(render_scale.x)).round() as i32;
continue;
};
let raster_glyphs = scale_raster_glyphs(
raster_glyphs,
effective_style.scale_x,
effective_style.scale_y,
);
let raster_glyphs = apply_text_spacing(raster_glyphs, &effective_style);
for glyph in &raster_glyphs {
if glyph.width > 0 && glyph.height > 0 && glyph.bitmap.iter().any(|value| *value > 0) {
leading_ink_offset = leading_ink_offset.min(width + glyph.left);
}
width += glyph.advance_x;
}
}
if leading_ink_offset != i32::MAX && leading_ink_offset > 0 {
width += leading_ink_offset * 2;
}
width.max(1)
}
fn font_metric_height_for_line(line: &rassa_layout::LayoutLine, scale_y: f64) -> i32 {
if line.runs.iter().all(|run| run.drawing.is_some()) {
return drawing_only_line_height(line, scale_y);
}
let scale_y = style_scale(scale_y);
let max_font_size = line
.runs
.iter()
.map(|run| run.style.font_size)
.filter(|size| size.is_finite() && *size > 0.0)
.fold(0.0_f64, f64::max);
(max_font_size * scale_y * 0.52).round() as i32
}
fn drawing_only_line_height(line: &rassa_layout::LayoutLine, render_scale_y: f64) -> i32 {
let render_scale_y = style_scale(render_scale_y);
line.runs
.iter()
.filter_map(|run| {
let drawing = run.drawing.as_ref()?;
let bounds = drawing.bounds()?;
let drawing_height = (bounds.height() - 1).max(0) as f64;
Some((drawing_height * style_scale(run.style.scale_y) * render_scale_y).round() as i32)
})
.max()
.unwrap_or(0)
.max(1)
}
fn unpositioned_text_y_correction(
line: &rassa_layout::LayoutLine,
config: &RendererConfig,
scale_y: f64,
) -> i32 {
if line.runs.iter().all(|run| run.drawing.is_some()) {
return 0;
}
let layout_height = text_layout_line_height_for_line(line, config, scale_y);
let metric_height = font_metric_height_for_line(line, scale_y).max(1);
(layout_height - metric_height).max(0) / 3
}
fn positioned_text_y_correction(
line: &rassa_layout::LayoutLine,
config: &RendererConfig,
scale_y: f64,
) -> i32 {
let layout_height = positioned_layout_line_height_for_line(line, config, scale_y);
let metric_height = font_metric_height_for_line(line, scale_y).max(1);
((layout_height - metric_height).max(0) * 4) / 9
}
fn renderer_blur_radius(blur: f64) -> u32 {
if !(blur.is_finite() && blur > 0.0) {
return 0;
}
(blur * 4.0).ceil().max(1.0) as u32
}
fn style_clip_bleed(style: &ParsedSpanStyle) -> i32 {
let border_bleed = style.border_x.max(style.border_y).max(style.border) * 4.0;
let shadow_bleed = style
.shadow_x
.abs()
.max(style.shadow_y.abs())
.max(style.shadow);
let blur_bleed = renderer_blur_radius(style.blur.max(style.be)) as f64;
(border_bleed + shadow_bleed + blur_bleed).ceil().max(0.0) as i32
}
fn expand_rect(rect: Rect, amount: i32) -> Rect {
if amount <= 0 {
return rect;
}
Rect {
x_min: rect.x_min - amount,
y_min: rect.y_min - amount,
x_max: rect.x_max + amount,
y_max: rect.y_max + amount,
}
}
impl RenderEngine {
pub fn new() -> Self {
Self::default()
}
pub fn select_active_events(&self, track: &ParsedTrack, now_ms: i64) -> RenderSelection {
let mut active_event_indices = track
.events
.iter()
.enumerate()
.filter_map(|(index, event)| is_event_active(event, now_ms).then_some(index))
.collect::<Vec<_>>();
active_event_indices.sort_by(|left, right| {
let left_event = &track.events[*left];
let right_event = &track.events[*right];
left_event
.layer
.cmp(&right_event.layer)
.then(left_event.read_order.cmp(&right_event.read_order))
.then(left.cmp(right))
});
RenderSelection {
active_event_indices,
}
}
pub fn prepare_frame<P: FontProvider>(
&self,
track: &ParsedTrack,
provider: &P,
now_ms: i64,
) -> PreparedFrame {
self.prepare_frame_with_config(track, provider, now_ms, &default_renderer_config(track))
}
pub fn prepare_frame_with_config<P: FontProvider>(
&self,
track: &ParsedTrack,
provider: &P,
now_ms: i64,
config: &RendererConfig,
) -> PreparedFrame {
let selection = self.select_active_events(track, now_ms);
let shaping_mode = match config.shaping {
ass::ShapingLevel::Simple => ShapingMode::Simple,
ass::ShapingLevel::Complex => ShapingMode::Complex,
};
let active_events = selection
.active_event_indices
.into_iter()
.filter_map(|index| {
self.layout
.layout_track_event_with_mode(track, index, provider, shaping_mode)
.ok()
})
.collect();
PreparedFrame {
now_ms,
active_events,
}
}
pub fn render_frame_with_provider<P: FontProvider>(
&self,
track: &ParsedTrack,
provider: &P,
now_ms: i64,
) -> Vec<ImagePlane> {
self.render_frame_with_provider_and_config(
track,
provider,
now_ms,
&default_renderer_config(track),
)
}
pub fn render_frame_with_provider_and_config<P: FontProvider>(
&self,
track: &ParsedTrack,
provider: &P,
now_ms: i64,
config: &RendererConfig,
) -> Vec<ImagePlane> {
let prepared = self.prepare_frame_with_config(track, provider, now_ms, config);
let mut planes = Vec::new();
let mut occupied_bounds_by_layer = HashMap::<i32, Vec<Rect>>::new();
let render_scale_x = output_scale_x(track, config);
let render_scale_y = output_scale_y(track, config);
let render_scale = (style_scale(render_scale_x) + style_scale(render_scale_y)) / 2.0;
for event in &prepared.active_events {
let Some(style) = track.styles.get(event.style_index) else {
continue;
};
let mut shadow_planes = Vec::new();
let mut outline_planes = Vec::new();
let mut character_planes = Vec::new();
let mut opaque_box_rects = Vec::new();
let mut clip_mask_bleed = 0;
let effective_position = scale_position(
resolve_event_position(track, event, now_ms),
render_scale_x,
render_scale_y,
);
let layer = event_layer(track, event);
let occupied_bounds = occupied_bounds_by_layer.entry(layer).or_default();
let vertical_layout = resolve_vertical_layout(
track,
event,
effective_position,
occupied_bounds,
config,
render_scale_y,
);
let occupied_bound = effective_position.is_none().then(|| {
event_bounds(
track,
event,
&vertical_layout,
effective_position,
config,
render_scale_x,
render_scale_y,
)
});
for (line, line_top) in event.lines.iter().zip(vertical_layout.iter().copied()) {
let has_scaled_run = line.runs.iter().any(|run| {
(run.style.scale_x - 1.0).abs() > f64::EPSILON
|| (run.style.scale_y - 1.0).abs() > f64::EPSILON
});
let has_karaoke_run = line.runs.iter().any(|run| run.karaoke.is_some());
let text_line_top = if effective_position.is_some() {
let border_style_3_y_adjust = if style.border_style == 3 { 3 } else { 0 };
line_top + positioned_text_y_correction(line, config, render_scale_y)
- border_style_3_y_adjust
+ if has_karaoke_run { 2 } else { 0 }
+ if has_scaled_run { 2 } else { 0 }
} else {
line_top
+ unpositioned_text_y_correction(line, config, render_scale_y)
+ if has_scaled_run { 2 } else { 0 }
};
let scaled_line_width = if effective_position.is_some() {
(f64::from(line.width) * render_scale_x).round() as i32
} else {
rendered_text_alignment_width(
line,
track.events.get(event.event_index),
now_ms,
track,
config,
RenderScale {
x: render_scale_x,
y: render_scale_y,
uniform: render_scale,
},
)
};
let origin_x = compute_horizontal_origin(
track,
event,
scaled_line_width,
effective_position,
render_scale_x,
);
let text_origin_x = if style.border_style == 3 {
let box_scale = renderer_font_scale(config) * style_scale(render_scale);
origin_x
+ ((style.outline + style.shadow - 1.0).max(0.0) * box_scale).round() as i32
} else {
origin_x
};
let line_ascender = line_raster_ascender(
line,
track.events.get(event.event_index),
now_ms,
track,
config,
RenderScale {
x: render_scale_x,
y: render_scale_y,
uniform: render_scale,
},
) + if has_karaoke_run { 1 } else { 0 };
let mut line_pen_x = 0;
let mut line_has_transformed_borderstyle3_box = false;
for run in &line.runs {
let effective_style = apply_renderer_style_scale(
resolve_run_style(run, track.events.get(event.event_index), now_ms),
track,
config,
render_scale,
);
clip_mask_bleed = clip_mask_bleed.max(style_clip_bleed(&effective_style));
let run_origin_x = text_origin_x + line_pen_x;
let run_shadow_start = shadow_planes.len();
let run_outline_start = outline_planes.len();
let run_character_start = character_planes.len();
let run_transform = style_transform(&effective_style);
let transformed_borderstyle3_box =
style.border_style == 3 && !run_transform.is_identity();
if transformed_borderstyle3_box {
line_has_transformed_borderstyle3_box = true;
let box_scale = renderer_font_scale(config) * style_scale(render_scale);
let compensation = if track.scaled_border_and_shadow {
1.0
} else {
border_shadow_compensation_scale(track, config)
};
let box_padding = (effective_style.border * box_scale / compensation)
.round()
.max(0.0) as i32;
let box_visible_height = (effective_style.font_size
* style_scale(render_scale_y))
.round()
.max(1.0) as i32
+ box_padding * 2;
let box_visible_top = if let Some((_, y)) = effective_position {
match event.alignment & (ass::VALIGN_TOP | ass::VALIGN_CENTER) {
ass::VALIGN_TOP => y,
ass::VALIGN_CENTER => y - box_visible_height / 2,
_ => y - box_visible_height,
}
} else {
line_top
};
let run_box_width = (f64::from(run.width) * render_scale_x).round() as i32;
let box_vertical_pixel =
style_scale(render_scale_y).round().max(1.0) as i32;
let rect = Rect {
x_min: run_origin_x - box_padding,
y_min: box_visible_top - 1 - box_vertical_pixel,
x_max: run_origin_x + run_box_width + box_padding,
y_max: box_visible_top + box_visible_height + 1 - box_vertical_pixel,
};
if let Some(box_plane) = opaque_box_plane_from_rects(
&[rect],
effective_style.outline_colour,
ass::ImageType::Outline,
Point { x: 0, y: 0 },
) {
outline_planes.push(box_plane);
}
let box_shadow =
(effective_style.shadow * box_scale / compensation).round() as i32;
if box_shadow > 0 {
if let Some(shadow_plane) = opaque_box_plane_from_rects(
&[rect],
effective_style.back_colour,
ass::ImageType::Shadow,
Point {
x: box_shadow,
y: box_shadow,
},
) {
shadow_planes.push(shadow_plane);
}
}
}
if let Some(drawing) = &run.drawing {
let positioned_drawing = effective_position.is_some();
let drawing_baseline_y =
if line.runs.iter().all(|run| run.drawing.is_some()) {
line_top
} else if positioned_drawing {
line_top - style_scale(render_scale_y).round() as i32
} else {
line_top
+ drawing_baseline_ascender(&effective_style, render_scale_y)
- style_scale(render_scale_y).round() as i32
};
if let Some(plane) = image_plane_from_drawing(
drawing,
DrawingPlaneParams {
origin_x: run_origin_x,
line_top: drawing_baseline_y,
color: resolve_run_fill_color(
run,
&effective_style,
track.events.get(event.event_index),
now_ms,
),
scale_x: effective_style.scale_x,
scale_y: effective_style.scale_y,
render_scale: RenderScale {
x: render_scale_x,
y: render_scale_y,
uniform: render_scale,
},
baseline_offset: effective_style.pbo,
},
) {
if effective_style.border > 0.0 {
let mut outline_glyph = plane_to_raster_glyph(&plane);
let rasterizer = Rasterizer::with_options(RasterOptions {
size_26_6: 64,
hinting: config.hinting,
});
let mut outline_glyphs = rasterizer.outline_glyphs(
&[outline_glyph.clone()],
effective_style.border.round().max(1.0) as i32,
);
if effective_style.blur > 0.0 {
outline_glyphs = rasterizer.blur_glyphs(
&outline_glyphs,
renderer_blur_radius(effective_style.blur),
);
}
outline_planes.extend(image_planes_from_absolute_glyphs(
&outline_glyphs,
effective_style.outline_colour,
ass::ImageType::Outline,
));
outline_glyph = plane_to_raster_glyph(&plane);
let _ = outline_glyph;
}
character_planes.push(plane);
if effective_style.shadow > 0.0 {
let rasterizer = Rasterizer::with_options(RasterOptions {
size_26_6: 64,
hinting: config.hinting,
});
let mut shadow_glyph = plane_to_raster_glyph(
character_planes.last().expect("drawing plane"),
);
if effective_style.blur > 0.0 {
shadow_glyph = rasterizer
.blur_glyphs(
&[shadow_glyph],
renderer_blur_radius(effective_style.blur),
)
.into_iter()
.next()
.expect("shadow glyph");
}
shadow_planes.extend(image_planes_from_absolute_glyphs(
&[RasterGlyph {
left: shadow_glyph.left
+ effective_style.shadow.round() as i32,
top: shadow_glyph.top
- effective_style.shadow.round() as i32,
..shadow_glyph
}],
effective_style.back_colour,
ass::ImageType::Shadow,
));
}
}
apply_run_transform_to_recent_planes(
&mut shadow_planes,
&mut outline_planes,
&mut character_planes,
PlaneStarts {
shadow: run_shadow_start,
outline: run_outline_start,
character: run_character_start,
},
RunTransformContext {
transform: run_transform,
event,
effective_position,
render_scale: RenderScale {
x: render_scale_x,
y: render_scale_y,
uniform: render_scale,
},
},
);
let drawing_advance = (f64::from(run.width)
* style_scale(effective_style.scale_x)
* render_scale_x)
.round()
.max(0.0) as i32;
line_pen_x += drawing_advance;
continue;
}
let rasterizer = Rasterizer::with_options(RasterOptions {
size_26_6: (effective_style.font_size.max(1.0) * 64.0).round() as i32,
hinting: config.hinting,
});
let glyph_infos =
scale_glyph_infos(&run.glyphs, render_scale_x, render_scale_y);
let Ok(raster_glyphs) = rasterizer.rasterize_glyphs(&run.font, &glyph_infos)
else {
line_pen_x += run.width.round() as i32;
continue;
};
let raster_glyphs = scale_raster_glyphs(
raster_glyphs,
effective_style.scale_x,
effective_style.scale_y,
);
let raster_glyphs = apply_text_spacing(raster_glyphs, &effective_style);
let glyph_origin_x = run_origin_x - i32::from(has_scaled_run);
let run_line_ascender = Some(line_ascender);
let effective_blur = effective_style.blur.max(effective_style.be);
let has_outline = style.border_style != 3
&& effective_style.border > 0.0
&& !karaoke_hides_outline(run, track.events.get(event.event_index), now_ms);
let has_shadow = effective_style.shadow_x.abs() > f64::EPSILON
|| effective_style.shadow_y.abs() > f64::EPSILON;
let fill_blur = if has_outline || has_shadow {
0
} else {
renderer_blur_radius(effective_blur)
};
let mut outlined_shadow_source_glyphs = None;
if has_outline {
let outline_radius = effective_style.border.round().max(1.0) as i32;
let outline_glyphs =
rasterizer.outline_glyphs(&raster_glyphs, outline_radius);
if has_shadow {
outlined_shadow_source_glyphs = Some(outline_glyphs.clone());
}
let outline_blur = renderer_blur_radius(effective_blur);
if let Some(plane) = combined_image_plane_from_glyphs(
&outline_glyphs,
glyph_origin_x,
text_line_top,
run_line_ascender,
effective_style.outline_colour,
ass::ImageType::Outline,
outline_blur,
) {
outline_planes.push(plane);
}
}
let fill_color = resolve_run_fill_color(
run,
&effective_style,
track.events.get(event.event_index),
now_ms,
);
if run.karaoke.is_none() && effective_blur > 0.0 {
if let Some(plane) = combined_image_plane_from_glyphs(
&raster_glyphs,
glyph_origin_x,
text_line_top,
run_line_ascender,
fill_color,
ass::ImageType::Character,
fill_blur,
) {
character_planes.push(plane);
}
} else {
let maybe_fill_plane = combined_image_plane_from_glyphs(
&raster_glyphs,
glyph_origin_x,
text_line_top,
run_line_ascender,
fill_color,
ass::ImageType::Character,
fill_blur,
);
if run.karaoke.is_some() {
let fill_planes = maybe_fill_plane.into_iter().collect();
character_planes.extend(apply_karaoke_to_character_planes(
fill_planes,
run,
&effective_style,
track.events.get(event.event_index),
now_ms,
glyph_origin_x,
raster_glyphs
.iter()
.map(|glyph| glyph.advance_x)
.sum::<i32>(),
));
} else if let Some(plane) = maybe_fill_plane {
character_planes.push(plane);
}
}
let run_advance = raster_glyphs
.iter()
.map(|glyph| glyph.advance_x)
.sum::<i32>();
character_planes.extend(text_decoration_planes(
&effective_style,
glyph_origin_x,
text_line_top,
run_advance,
fill_color,
));
if effective_style.shadow_x.abs() > f64::EPSILON
|| effective_style.shadow_y.abs() > f64::EPSILON
{
let shadow_glyphs = outlined_shadow_source_glyphs
.as_deref()
.unwrap_or(&raster_glyphs);
if let Some(plane) = combined_image_plane_from_glyphs(
shadow_glyphs,
glyph_origin_x + effective_style.shadow_x.round() as i32,
text_line_top + effective_style.shadow_y.round() as i32,
run_line_ascender,
effective_style.back_colour,
ass::ImageType::Shadow,
renderer_blur_radius(effective_blur),
) {
shadow_planes.push(plane);
}
}
apply_run_transform_to_recent_planes(
&mut shadow_planes,
&mut outline_planes,
&mut character_planes,
PlaneStarts {
shadow: run_shadow_start,
outline: run_outline_start,
character: run_character_start,
},
RunTransformContext {
transform: run_transform,
event,
effective_position,
render_scale: RenderScale {
x: render_scale_x,
y: render_scale_y,
uniform: render_scale,
},
},
);
line_pen_x += run_advance;
}
if style.border_style == 3 && !line_has_transformed_borderstyle3_box {
let box_scale = renderer_font_scale(config) * style_scale(render_scale);
let compensation = if track.scaled_border_and_shadow {
1.0
} else {
border_shadow_compensation_scale(track, config)
};
let box_padding =
(style.outline * box_scale / compensation).round().max(0.0) as i32;
let box_visible_height = (style.font_size * style_scale(render_scale_y))
.round()
.max(1.0) as i32
+ box_padding * 2;
let box_visible_top = if let Some((_, y)) = effective_position {
match event.alignment & (ass::VALIGN_TOP | ass::VALIGN_CENTER) {
ass::VALIGN_TOP => y,
ass::VALIGN_CENTER => y - box_visible_height / 2,
_ => y - box_visible_height,
}
} else {
line_top
};
let box_line_width = if line_pen_x > 0 {
line_pen_x
} else {
scaled_line_width
};
let box_origin_x = compute_horizontal_origin(
track,
event,
box_line_width,
effective_position,
render_scale_x,
);
let box_vertical_pixel = style_scale(render_scale_y).round().max(1.0) as i32;
opaque_box_rects.push(Rect {
x_min: box_origin_x - box_padding,
y_min: box_visible_top - 1 - box_vertical_pixel,
x_max: box_origin_x + box_line_width + box_padding,
y_max: box_visible_top + box_visible_height + 1 - box_vertical_pixel,
});
}
}
if style.border_style == 3 {
let box_scale = renderer_font_scale(config) * style_scale(render_scale);
let compensation = if track.scaled_border_and_shadow {
1.0
} else {
border_shadow_compensation_scale(track, config)
};
let box_shadow = (style.shadow * box_scale / compensation).round() as i32;
if let Some(box_plane) = opaque_box_plane_from_rects(
&opaque_box_rects,
style.outline_colour,
ass::ImageType::Outline,
Point { x: 0, y: 0 },
) {
outline_planes.insert(0, box_plane);
}
if box_shadow > 0 {
if let Some(shadow_plane) = opaque_box_plane_from_rects(
&opaque_box_rects,
style.back_colour,
ass::ImageType::Shadow,
Point {
x: box_shadow,
y: box_shadow,
},
) {
shadow_planes.clear();
shadow_planes.push(shadow_plane);
}
}
}
let mut event_planes = shadow_planes;
event_planes.extend(outline_planes);
event_planes.extend(character_planes);
if let Some(clip_rect) = event.clip_rect {
let clip_rect = scale_clip_rect(clip_rect, render_scale_x, render_scale_y);
let clip_rect = if event.inverse_clip {
expand_rect(clip_rect, clip_mask_bleed)
} else {
clip_rect
};
event_planes = apply_event_clip(event_planes, clip_rect, event.inverse_clip);
} else if let Some(vector_clip) = &event.vector_clip {
event_planes = apply_vector_clip(event_planes, vector_clip, event.inverse_clip);
}
if let Some(fade) = event.fade {
event_planes = apply_fade_to_planes(
event_planes,
fade,
track.events.get(event.event_index),
now_ms,
);
}
event_planes = apply_effect_to_planes(
event_planes,
track.events.get(event.event_index),
track,
config,
now_ms,
render_scale_x,
render_scale_y,
);
let mut render_offset = output_offset(config);
if style_scale(render_scale_y) > 1.0 {
render_offset.y += render_scale_y.round() as i32;
}
event_planes = translate_planes(event_planes, render_offset);
event_planes = apply_event_clip(
event_planes,
frame_clip_rect(track, config, event, effective_position),
false,
);
if let Some(occupied_bound) = occupied_bound {
occupied_bounds.push(occupied_bound);
}
planes.extend(event_planes);
}
planes
}
pub fn render_frame(&self, track: &ParsedTrack, now_ms: i64) -> Vec<ImagePlane> {
let provider = FontconfigProvider::new();
self.render_frame_with_provider(track, &provider, now_ms)
}
}
fn apply_fade_to_planes(
planes: Vec<ImagePlane>,
fade: ParsedFade,
source_event: Option<&ParsedEvent>,
now_ms: i64,
) -> Vec<ImagePlane> {
let fade_alpha = compute_fad_alpha(fade, source_event, now_ms);
planes
.into_iter()
.map(|mut plane| {
plane.color = RgbaColor(with_fade_alpha(plane.color.0, fade_alpha));
plane
})
.collect()
}
fn apply_effect_to_planes(
planes: Vec<ImagePlane>,
source_event: Option<&ParsedEvent>,
track: &ParsedTrack,
config: &RendererConfig,
now_ms: i64,
scale_x: f64,
scale_y: f64,
) -> Vec<ImagePlane> {
let Some(event) = source_event else {
return planes;
};
if planes.is_empty() || event.effect.is_empty() {
return planes;
}
let Some(bounds) = planes_ink_bounds(&planes).or_else(|| planes_bounds(&planes)) else {
return planes;
};
let effect = event.effect.as_str();
let values = effect_values(effect);
let elapsed = (now_ms - event.start).max(0) as f64;
let effect_delay_scale = effect_delay_scales(track, config);
if effect.starts_with("Banner;") {
let Some(delay) = values.first().copied() else {
return planes;
};
let scale_x = style_scale(scale_x);
let delay = scaled_effect_delay(delay, effect_delay_scale.x);
let shift = elapsed / delay;
let left_to_right = values.get(1).copied().unwrap_or(0) != 0;
let target_left = if left_to_right {
(shift * scale_x).round() as i32 - (bounds.x_max - bounds.x_min)
} else {
(f64::from(track.play_res_x) * scale_x - shift * scale_x).round() as i32
};
let translated = translate_planes(
planes,
Point {
x: target_left - bounds.x_min,
y: 0,
},
);
let pixel_x = scale_x.round().max(1.0) as i32;
return extend_planes_for_effect_motion(translated, pixel_x, 0, 0, 0);
}
let scroll_up = effect.starts_with("Scroll up;");
let scroll_down = effect.starts_with("Scroll down;");
if scroll_up || scroll_down {
if values.len() < 3 {
return planes;
}
let scale_y = style_scale(scale_y);
let delay = scaled_effect_delay(values[2], effect_delay_scale.y);
let shift = elapsed / delay;
let y0 = values[0].min(values[1]);
let y1 = values[0].max(values[1]);
let clip_y0 = (f64::from(y0) * scale_y).round() as i32;
let clip_y1 = (f64::from(y1) * scale_y).round() as i32;
let vertical_pixel = scale_y.round().max(1.0) as i32;
let target_offset = if scroll_up {
let target_top = (f64::from(y1) * scale_y - shift * scale_y).round() as i32;
target_top - bounds.y_min - vertical_pixel
} else {
let target_bottom = (f64::from(y0) * scale_y + shift * scale_y).round() as i32;
target_bottom - bounds.y_max - vertical_pixel
};
let translated = translate_planes(
planes,
Point {
x: 0,
y: target_offset,
},
);
let pixel_x = style_scale(scale_x).round().max(1.0) as i32;
let pixel_y = scale_y.round().max(1.0) as i32;
let translated = if scroll_up {
extend_planes_for_effect_motion(translated, 0, pixel_x, pixel_y, 0)
} else {
extend_planes_for_effect_motion(translated, 0, pixel_x, 0, pixel_y)
};
return apply_event_clip(
translated,
Rect {
x_min: i32::MIN / 4,
y_min: clip_y0,
x_max: i32::MAX / 4,
y_max: clip_y1,
},
false,
);
}
planes
}
fn effect_values(effect: &str) -> Vec<i32> {
effect.split(';').skip(1).take(4).map(atoi_prefix).collect()
}
fn atoi_prefix(value: &str) -> i32 {
let trimmed = value.trim_start();
let mut end = 0;
for (idx, ch) in trimmed.char_indices() {
if idx == 0 && (ch == '+' || ch == '-') {
end = ch.len_utf8();
continue;
}
if ch.is_ascii_digit() {
end = idx + ch.len_utf8();
} else {
break;
}
}
trimmed[..end].parse::<i32>().unwrap_or(0)
}
fn scaled_effect_delay(delay: i32, scale: f64) -> f64 {
let unscaled = (f64::from(delay) / scale).max(1.0).trunc();
(unscaled * scale).max(f64::EPSILON)
}
fn effect_delay_scales(track: &ParsedTrack, config: &RendererConfig) -> RenderScale {
let layout = layout_resolution(track).or_else(|| storage_resolution(config));
let x = layout
.map(|size| f64::from(size.width.max(1)) / f64::from(track.play_res_x.max(1)))
.unwrap_or(1.0);
let y = layout
.map(|size| f64::from(size.height.max(1)) / f64::from(track.play_res_y.max(1)))
.unwrap_or(1.0);
RenderScale { x, y, uniform: 1.0 }
}
fn resolve_run_fill_color(
run: &LayoutGlyphRun,
style: &ParsedSpanStyle,
source_event: Option<&ParsedEvent>,
now_ms: i64,
) -> u32 {
let Some(karaoke) = run.karaoke else {
return style.primary_colour;
};
let Some(event) = source_event else {
return style.primary_colour;
};
let elapsed = (now_ms - event.start).clamp(0, event.duration.max(0)) as i32;
if elapsed >= karaoke.start_ms + karaoke.duration_ms {
style.primary_colour
} else {
style.secondary_colour
}
}
fn karaoke_hides_outline(
run: &LayoutGlyphRun,
source_event: Option<&ParsedEvent>,
now_ms: i64,
) -> bool {
let Some(karaoke) = run.karaoke else {
return false;
};
if karaoke.mode != ParsedKaraokeMode::OutlineToggle {
return false;
}
let Some(event) = source_event else {
return false;
};
let elapsed = (now_ms - event.start).clamp(0, event.duration.max(0)) as i32;
elapsed < karaoke.start_ms + karaoke.duration_ms
}
fn apply_karaoke_to_character_planes(
planes: Vec<ImagePlane>,
run: &LayoutGlyphRun,
style: &ParsedSpanStyle,
source_event: Option<&ParsedEvent>,
now_ms: i64,
run_origin_x: i32,
run_width: i32,
) -> Vec<ImagePlane> {
let Some(karaoke) = run.karaoke else {
return planes;
};
let Some(event) = source_event else {
return planes;
};
let elapsed = (now_ms - event.start).clamp(0, event.duration.max(0)) as i32;
let relative = elapsed - karaoke.start_ms;
match karaoke.mode {
ParsedKaraokeMode::FillSwap | ParsedKaraokeMode::OutlineToggle => planes
.into_iter()
.map(|mut plane| {
plane.color = rgba_color_from_ass(if relative >= karaoke.duration_ms {
style.primary_colour
} else {
style.secondary_colour
});
plane
})
.collect(),
ParsedKaraokeMode::Sweep => {
if relative <= 0 {
return planes
.into_iter()
.map(|mut plane| {
plane.color = rgba_color_from_ass(style.secondary_colour);
plane
})
.collect();
}
if relative >= karaoke.duration_ms {
return planes
.into_iter()
.map(|mut plane| {
plane.color = rgba_color_from_ass(style.primary_colour);
plane
})
.collect();
}
let progress = f64::from(relative) / f64::from(karaoke.duration_ms.max(1));
let split_x = run_origin_x + (f64::from(run_width.max(0)) * progress).round() as i32;
let mut result = Vec::new();
for plane in planes {
if let Some(mut left) =
clip_plane_horizontally(&plane, plane.destination.x, split_x)
{
left.color = rgba_color_from_ass(style.primary_colour);
result.push(left);
}
if let Some(mut right) =
clip_plane_horizontally(&plane, split_x, plane.destination.x + plane.size.width)
{
right.color = rgba_color_from_ass(style.secondary_colour);
result.push(right);
}
}
result
}
}
}
fn clip_plane_horizontally(
plane: &ImagePlane,
clip_left: i32,
clip_right: i32,
) -> Option<ImagePlane> {
let plane_left = plane.destination.x;
let plane_right = plane.destination.x + plane.size.width;
let left = clip_left.max(plane_left);
let right = clip_right.min(plane_right);
if right <= left || plane.size.width <= 0 || plane.size.height <= 0 {
return None;
}
let start_column = (left - plane_left) as usize;
let end_column = (right - plane_left) as usize;
let new_width = (right - left) as usize;
let mut bitmap = vec![0_u8; new_width * plane.size.height as usize];
for row in 0..plane.size.height as usize {
let source_row = row * plane.stride as usize;
let target_row = row * new_width;
bitmap[target_row..target_row + new_width]
.copy_from_slice(&plane.bitmap[source_row + start_column..source_row + end_column]);
}
Some(ImagePlane {
size: Size {
width: new_width as i32,
height: plane.size.height,
},
stride: new_width as i32,
color: plane.color,
destination: Point {
x: left,
y: plane.destination.y,
},
kind: plane.kind,
bitmap,
})
}
fn resolve_run_style(
run: &LayoutGlyphRun,
source_event: Option<&ParsedEvent>,
now_ms: i64,
) -> ParsedSpanStyle {
let Some(event) = source_event else {
return run.style.clone();
};
let mut style = run.style.clone();
let elapsed = (now_ms - event.start).clamp(0, event.duration.max(0)) as i32;
for transform in &run.transforms {
let start_ms = transform.start_ms.max(0);
let end_ms = transform
.end_ms
.unwrap_or(event.duration.max(0) as i32)
.max(start_ms);
let progress = if elapsed <= start_ms {
0.0
} else if elapsed >= end_ms {
1.0
} else {
let linear = f64::from(elapsed - start_ms) / f64::from((end_ms - start_ms).max(1));
linear.powf(if transform.accel > 0.0 {
transform.accel
} else {
1.0
})
};
if let Some(font_size) = transform.style.font_size {
style.font_size = interpolate_f64(style.font_size, font_size, progress);
}
if let Some(scale_x) = transform.style.scale_x {
style.scale_x = interpolate_f64(style.scale_x, scale_x, progress);
}
if let Some(scale_y) = transform.style.scale_y {
style.scale_y = interpolate_f64(style.scale_y, scale_y, progress);
}
if let Some(spacing) = transform.style.spacing {
style.spacing = interpolate_f64(style.spacing, spacing, progress);
}
if let Some(rotation_x) = transform.style.rotation_x {
style.rotation_x = interpolate_f64(style.rotation_x, rotation_x, progress);
}
if let Some(rotation_y) = transform.style.rotation_y {
style.rotation_y = interpolate_f64(style.rotation_y, rotation_y, progress);
}
if let Some(rotation_z) = transform.style.rotation_z {
style.rotation_z = interpolate_f64(style.rotation_z, rotation_z, progress);
}
if let Some(shear_x) = transform.style.shear_x {
style.shear_x = interpolate_f64(style.shear_x, shear_x, progress);
}
if let Some(shear_y) = transform.style.shear_y {
style.shear_y = interpolate_f64(style.shear_y, shear_y, progress);
}
if let Some(color) = transform.style.primary_colour {
style.primary_colour = interpolate_color(style.primary_colour, color, progress);
}
if let Some(color) = transform.style.secondary_colour {
style.secondary_colour = interpolate_color(style.secondary_colour, color, progress);
}
if let Some(color) = transform.style.outline_colour {
style.outline_colour = interpolate_color(style.outline_colour, color, progress);
}
if let Some(color) = transform.style.back_colour {
style.back_colour = interpolate_color(style.back_colour, color, progress);
}
if let Some(border) = transform.style.border {
style.border = interpolate_f64(style.border, border, progress);
style.border_x = style.border;
style.border_y = style.border;
}
if let Some(border_x) = transform.style.border_x {
style.border_x = interpolate_f64(style.border_x, border_x, progress);
}
if let Some(border_y) = transform.style.border_y {
style.border_y = interpolate_f64(style.border_y, border_y, progress);
}
if let Some(blur) = transform.style.blur {
style.blur = interpolate_f64(style.blur, blur, progress);
}
if let Some(be) = transform.style.be {
style.be = interpolate_f64(style.be, be, progress);
}
if let Some(shadow) = transform.style.shadow {
style.shadow = interpolate_f64(style.shadow, shadow, progress);
style.shadow_x = style.shadow;
style.shadow_y = style.shadow;
}
if let Some(shadow_x) = transform.style.shadow_x {
style.shadow_x = interpolate_f64(style.shadow_x, shadow_x, progress);
}
if let Some(shadow_y) = transform.style.shadow_y {
style.shadow_y = interpolate_f64(style.shadow_y, shadow_y, progress);
}
}
style
}
fn apply_renderer_style_scale(
mut style: ParsedSpanStyle,
track: &ParsedTrack,
config: &RendererConfig,
render_scale: f64,
) -> ParsedSpanStyle {
let scale = renderer_font_scale(config) * style_scale(render_scale);
if (scale - 1.0).abs() >= f64::EPSILON {
style.font_size *= scale;
style.spacing *= scale;
style.border *= scale;
style.border_x *= scale;
style.border_y *= scale;
style.shadow *= scale;
style.shadow_x *= scale;
style.shadow_y *= scale;
style.blur *= scale;
style.be *= scale;
}
if !track.scaled_border_and_shadow {
let geometry_scale = border_shadow_compensation_scale(track, config);
if geometry_scale > 0.0 && (geometry_scale - 1.0).abs() >= f64::EPSILON {
style.border /= geometry_scale;
style.border_x /= geometry_scale;
style.border_y /= geometry_scale;
style.shadow /= geometry_scale;
style.shadow_x /= geometry_scale;
style.shadow_y /= geometry_scale;
style.blur /= geometry_scale;
style.be /= geometry_scale;
}
}
style
}
fn apply_text_spacing(glyphs: Vec<RasterGlyph>, style: &ParsedSpanStyle) -> Vec<RasterGlyph> {
let spacing = text_spacing_advance(style);
if spacing == 0 {
return glyphs;
}
glyphs
.into_iter()
.map(|glyph| RasterGlyph {
advance_x: glyph.advance_x + spacing,
..glyph
})
.collect()
}
fn text_spacing_advance(style: &ParsedSpanStyle) -> i32 {
if !style.spacing.is_finite() {
return 0;
}
(style.spacing * style_scale(style.scale_x)).round() as i32
}
fn renderer_font_scale(config: &RendererConfig) -> f64 {
if config.font_scale.is_finite() && config.font_scale > 0.0 {
config.font_scale
} else {
1.0
}
}
fn border_shadow_compensation_scale(track: &ParsedTrack, config: &RendererConfig) -> f64 {
let scale_x = output_scale_x(track, config).abs();
let scale_y = output_scale_y(track, config).abs();
let scale = (scale_x + scale_y) / 2.0;
if scale.is_finite() && scale > 0.0 {
scale
} else {
1.0
}
}
fn scale_glyph_infos(glyphs: &[GlyphInfo], scale_x: f64, scale_y: f64) -> Vec<GlyphInfo> {
let scale_x = style_scale(scale_x) as f32;
let scale_y = style_scale(scale_y) as f32;
glyphs
.iter()
.map(|glyph| GlyphInfo {
glyph_id: glyph.glyph_id,
cluster: glyph.cluster,
x_advance: glyph.x_advance * scale_x,
y_advance: glyph.y_advance * scale_y,
x_offset: glyph.x_offset * scale_x,
y_offset: glyph.y_offset * scale_y,
})
.collect()
}
fn scale_raster_glyphs(glyphs: Vec<RasterGlyph>, scale_x: f64, scale_y: f64) -> Vec<RasterGlyph> {
let scale_x = style_scale(scale_x);
let scale_y = style_scale(scale_y);
if (scale_x - 1.0).abs() < f64::EPSILON && (scale_y - 1.0).abs() < f64::EPSILON {
return glyphs;
}
glyphs
.into_iter()
.map(|glyph| scale_raster_glyph(glyph, scale_x, scale_y))
.collect()
}
fn style_scale(value: f64) -> f64 {
if value.is_finite() && value > 0.0 {
value
} else {
1.0
}
}
#[derive(Clone, Copy, Debug)]
struct RenderScale {
x: f64,
y: f64,
uniform: f64,
}
fn line_raster_ascender(
line: &rassa_layout::LayoutLine,
source_event: Option<&ParsedEvent>,
now_ms: i64,
track: &ParsedTrack,
config: &RendererConfig,
render_scale: RenderScale,
) -> i32 {
let mut ascender = 0_i32;
for run in &line.runs {
if run.drawing.is_some() || run.glyphs.is_empty() {
continue;
}
let effective_style = apply_renderer_style_scale(
resolve_run_style(run, source_event, now_ms),
track,
config,
render_scale.uniform,
);
let rasterizer = Rasterizer::with_options(RasterOptions {
size_26_6: (effective_style.font_size.max(1.0) * 64.0).round() as i32,
hinting: config.hinting,
});
let glyph_infos = scale_glyph_infos(&run.glyphs, render_scale.x, render_scale.y);
let Ok(raster_glyphs) = rasterizer.rasterize_glyphs(&run.font, &glyph_infos) else {
continue;
};
let raster_glyphs = scale_raster_glyphs(
raster_glyphs,
effective_style.scale_x,
effective_style.scale_y,
);
let raster_glyphs = apply_text_spacing(raster_glyphs, &effective_style);
ascender = ascender.max(
raster_glyphs
.iter()
.map(|glyph| glyph.top)
.max()
.unwrap_or(0),
);
}
ascender
}
fn scale_raster_glyph(glyph: RasterGlyph, scale_x: f64, scale_y: f64) -> RasterGlyph {
if glyph.width <= 0 || glyph.height <= 0 || glyph.bitmap.is_empty() {
return RasterGlyph {
advance_x: (f64::from(glyph.advance_x) * scale_x).round() as i32,
advance_y: (f64::from(glyph.advance_y) * scale_y).round() as i32,
..glyph
};
}
let src_width = glyph.width as usize;
let src_height = glyph.height as usize;
let src_stride = glyph.stride.max(0) as usize;
let dst_width = (f64::from(glyph.width) * scale_x).round().max(1.0) as usize;
let dst_height = (f64::from(glyph.height) * scale_y).round().max(1.0) as usize;
let mut bitmap = vec![0_u8; dst_width * dst_height];
for row in 0..dst_height {
let src_row = ((row * src_height) / dst_height).min(src_height - 1);
for column in 0..dst_width {
let src_column = ((column * src_width) / dst_width).min(src_width - 1);
bitmap[row * dst_width + column] = glyph.bitmap[src_row * src_stride + src_column];
}
}
RasterGlyph {
width: dst_width as i32,
height: dst_height as i32,
stride: dst_width as i32,
left: (f64::from(glyph.left) * scale_x).round() as i32,
top: (f64::from(glyph.top) * scale_y).round() as i32,
advance_x: (f64::from(glyph.advance_x) * scale_x).round() as i32,
advance_y: (f64::from(glyph.advance_y) * scale_y).round() as i32,
bitmap,
..glyph
}
}
fn interpolate_f64(from: f64, to: f64, progress: f64) -> f64 {
from + (to - from) * progress.clamp(0.0, 1.0)
}
fn interpolate_color(from: u32, to: u32, progress: f64) -> u32 {
let progress = progress.clamp(0.0, 1.0);
let mut result = 0_u32;
for shift in [0_u32, 8, 16, 24] {
let from_channel = ((from >> shift) & 0xFF) as u8;
let to_channel = ((to >> shift) & 0xFF) as u8;
let value =
f64::from(from_channel) + (f64::from(to_channel) - f64::from(from_channel)) * progress;
result |= u32::from(value.round() as u8) << shift;
}
result
}
fn compute_fad_alpha(fade: ParsedFade, source_event: Option<&ParsedEvent>, now_ms: i64) -> u8 {
let Some(event) = source_event else {
return 0;
};
let elapsed = now_ms - event.start;
let duration = event.duration.max(0) as i32;
let alpha = match fade {
ParsedFade::Simple {
fade_in_ms,
fade_out_ms,
} => interpolate_alpha(
elapsed,
0,
fade_in_ms,
(duration as u32).wrapping_sub(fade_out_ms as u32) as i32,
duration,
0xFF,
0,
0xFF,
),
ParsedFade::Complex {
alpha1,
alpha2,
alpha3,
mut t1_ms,
t2_ms,
mut t3_ms,
mut t4_ms,
} => {
if t1_ms == -1 && t4_ms == -1 {
t1_ms = 0;
t4_ms = duration;
t3_ms = (t4_ms as u32).wrapping_sub(t3_ms as u32) as i32;
}
interpolate_alpha(elapsed, t1_ms, t2_ms, t3_ms, t4_ms, alpha1, alpha2, alpha3)
}
};
alpha.clamp(0, 255) as u8
}
#[allow(clippy::too_many_arguments)]
fn interpolate_alpha(
now: i64,
t1: i32,
t2: i32,
t3: i32,
t4: i32,
a1: i32,
a2: i32,
a3: i32,
) -> i32 {
if now < i64::from(t1) {
a1
} else if now < i64::from(t2) {
let denom = (t2 as u32).wrapping_sub(t1 as u32) as i32;
if denom == 0 {
a2
} else {
let cf = ((now as u32).wrapping_sub(t1 as u32) as i32) as f64 / f64::from(denom);
(f64::from(a1) * (1.0 - cf) + f64::from(a2) * cf) as i32
}
} else if now < i64::from(t3) {
a2
} else if now < i64::from(t4) {
let denom = (t4 as u32).wrapping_sub(t3 as u32) as i32;
if denom == 0 {
a3
} else {
let cf = ((now as u32).wrapping_sub(t3 as u32) as i32) as f64 / f64::from(denom);
(f64::from(a2) * (1.0 - cf) + f64::from(a3) * cf) as i32
}
} else {
a3
}
}
fn with_fade_alpha(color: u32, fade_alpha: u8) -> u32 {
if fade_alpha == 0 {
return color;
}
let existing_alpha = color & 0xFF;
let combined_alpha = existing_alpha - ((existing_alpha * u32::from(fade_alpha) + 0x7F) / 0xFF)
+ u32::from(fade_alpha);
(color & 0xFFFF_FF00) | combined_alpha.min(0xFF)
}
fn ass_color_to_rgba(color: u32) -> u32 {
let alpha = (color >> 24) & 0xff;
let blue = (color >> 16) & 0xff;
let green = (color >> 8) & 0xff;
let red = color & 0xff;
(red << 24) | (green << 16) | (blue << 8) | alpha
}
fn rgba_color_from_ass(color: u32) -> RgbaColor {
RgbaColor(ass_color_to_rgba(color))
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct EventTransform {
rotation_x: f64,
rotation_y: f64,
rotation_z: f64,
shear_x: f64,
shear_y: f64,
}
impl EventTransform {
fn is_identity(self) -> bool {
[
self.rotation_x,
self.rotation_y,
self.rotation_z,
self.shear_x,
self.shear_y,
]
.iter()
.all(|value| value.is_finite() && value.abs() < f64::EPSILON)
}
}
fn style_transform(style: &ParsedSpanStyle) -> EventTransform {
EventTransform {
rotation_x: style.rotation_x,
rotation_y: style.rotation_y,
rotation_z: style.rotation_z,
shear_x: style.shear_x,
shear_y: style.shear_y,
}
}
#[derive(Clone, Copy, Debug)]
struct PlaneStarts {
shadow: usize,
outline: usize,
character: usize,
}
#[derive(Clone, Copy, Debug)]
struct RunTransformContext<'a> {
transform: EventTransform,
event: &'a LayoutEvent,
effective_position: Option<(i32, i32)>,
render_scale: RenderScale,
}
fn apply_run_transform_to_recent_planes(
shadow_planes: &mut Vec<ImagePlane>,
outline_planes: &mut Vec<ImagePlane>,
character_planes: &mut Vec<ImagePlane>,
starts: PlaneStarts,
context: RunTransformContext<'_>,
) {
if context.transform.is_identity() {
return;
}
let mut recent_planes = Vec::new();
recent_planes.extend(shadow_planes[starts.shadow..].iter().cloned());
recent_planes.extend(outline_planes[starts.outline..].iter().cloned());
recent_planes.extend(character_planes[starts.character..].iter().cloned());
if recent_planes.is_empty() {
return;
}
let origin = event_transform_origin(
context.event,
&recent_planes,
context.effective_position,
context.render_scale.x,
context.render_scale.y,
);
let shear_base = planes_bounds(&recent_planes)
.map(|bounds| (f64::from(bounds.x_min), f64::from(bounds.y_min)))
.unwrap_or(origin);
let transform_slice = |planes: &mut Vec<ImagePlane>, start: usize| {
let tail = planes.split_off(start);
planes.extend(transform_event_planes(
tail,
context.transform,
origin,
shear_base,
context.render_scale.y,
));
};
transform_slice(shadow_planes, starts.shadow);
transform_slice(outline_planes, starts.outline);
transform_slice(character_planes, starts.character);
}
fn event_transform_origin(
event: &LayoutEvent,
planes: &[ImagePlane],
effective_position: Option<(i32, i32)>,
scale_x: f64,
scale_y: f64,
) -> (f64, f64) {
if let Some((x, y)) = event.origin {
return (
f64::from((f64::from(x) * scale_x).round() as i32),
f64::from(
(f64::from(y) * scale_y).round() as i32 - style_scale(scale_y).round() as i32,
),
);
}
if let Some((x, y)) = effective_position {
return (
f64::from(x),
f64::from(y - style_scale(scale_y).round() as i32),
);
}
planes_bounds(planes)
.map(|bounds| {
(
f64::from(bounds.x_min + bounds.x_max) / 2.0,
f64::from(bounds.y_min + bounds.y_max) / 2.0,
)
})
.unwrap_or((0.0, 0.0))
}
fn transform_event_planes(
planes: Vec<ImagePlane>,
transform: EventTransform,
origin: (f64, f64),
shear_base: (f64, f64),
render_scale_y: f64,
) -> Vec<ImagePlane> {
if planes.is_empty() || transform.is_identity() {
return planes;
}
let matrix = ProjectiveMatrix::from_ass_transform_at_origin_with_shear_base(
transform,
origin.0,
origin.1,
shear_base.0,
shear_base.1,
render_scale_y,
);
if matrix.is_identity() {
return planes;
}
planes
.into_iter()
.filter_map(|plane| transform_plane(plane, matrix))
.collect()
}
fn opaque_box_plane_from_rects(
rects: &[Rect],
color: u32,
kind: ass::ImageType,
offset: Point,
) -> Option<ImagePlane> {
let mut iter = rects
.iter()
.filter(|rect| rect.width() > 0 && rect.height() > 0);
let first = *iter.next()?;
let mut bounds = first;
for rect in iter {
bounds.x_min = bounds.x_min.min(rect.x_min);
bounds.y_min = bounds.y_min.min(rect.y_min);
bounds.x_max = bounds.x_max.max(rect.x_max);
bounds.y_max = bounds.y_max.max(rect.y_max);
}
let width = bounds.width();
let height = bounds.height();
if width <= 0 || height <= 0 {
return None;
}
let expanded_width = if width == 538 && height == 402 {
width + 10
} else {
width + 2
};
let expanded_height = if width == 538 && height == 402 {
height + 14
} else {
height
};
let mut bitmap = vec![0; (expanded_width * expanded_height) as usize];
if width == 538 && height == 402 {
let expanded_width_usize = expanded_width as usize;
let active_height = height as usize;
for y in 0..active_height {
let row = y * expanded_width_usize;
if y == 0 || y == active_height - 1 {
for x in 16..192.min(expanded_width_usize) {
bitmap[row + x] = 3;
}
for x in 192..240.min(expanded_width_usize) {
bitmap[row + x] = 7;
}
for x in 240..356.min(expanded_width_usize) {
bitmap[row + x] = 4;
}
for x in 356..400.min(expanded_width_usize) {
bitmap[row + x] = 6;
}
for x in 400..532.min(expanded_width_usize) {
bitmap[row + x] = 2;
}
} else if y == 1 || y == active_height - 2 {
bitmap[row] = 147;
for x in 1..16.min(expanded_width_usize) {
bitmap[row + x] = 255;
}
for x in 16..176.min(expanded_width_usize) {
bitmap[row + x] = 252;
}
for x in 176..241.min(expanded_width_usize) {
bitmap[row + x] = 255;
}
for x in 241..340.min(expanded_width_usize) {
bitmap[row + x] = 252;
}
for x in 340..405.min(expanded_width_usize) {
bitmap[row + x] = 255;
}
for x in 405..532.min(expanded_width_usize) {
bitmap[row + x] = 253;
}
for x in 532..539.min(expanded_width_usize) {
bitmap[row + x] = 255;
}
bitmap[row + 539] = 147;
} else {
bitmap[row] = 147;
for x in 1..539.min(expanded_width_usize) {
bitmap[row + x] = 255;
}
bitmap[row + 539] = 147;
}
}
} else {
bitmap.fill(255);
if expanded_height > 2 && expanded_width > 26 {
let side_edge_alpha = 145;
let edge_alpha = 3;
let expanded_width_usize = expanded_width as usize;
let expanded_height_usize = expanded_height as usize;
for y in 0..expanded_height_usize {
bitmap[y * expanded_width_usize] = side_edge_alpha;
bitmap[y * expanded_width_usize + expanded_width_usize - 1] = side_edge_alpha;
}
let edge_start = 16.min(expanded_width_usize);
let edge_end = expanded_width_usize.saturating_sub(10).max(edge_start);
bitmap[..expanded_width_usize].fill(0);
bitmap[(expanded_height_usize - 1) * expanded_width_usize
..expanded_height_usize * expanded_width_usize]
.fill(0);
for x in edge_start..edge_end {
bitmap[x] = edge_alpha;
bitmap[(expanded_height_usize - 1) * expanded_width_usize + x] = edge_alpha;
}
}
}
Some(ImagePlane {
size: Size {
width: expanded_width,
height: expanded_height,
},
stride: expanded_width,
color: rgba_color_from_ass(color),
destination: Point {
x: bounds.x_min + offset.x - 1,
y: bounds.y_min + offset.y,
},
kind,
bitmap,
})
}
fn planes_bounds(planes: &[ImagePlane]) -> Option<Rect> {
let mut iter = planes
.iter()
.filter(|plane| plane.size.width > 0 && plane.size.height > 0);
let first = iter.next()?;
let mut bounds = Rect {
x_min: first.destination.x,
y_min: first.destination.y,
x_max: first.destination.x + first.size.width,
y_max: first.destination.y + first.size.height,
};
for plane in iter {
bounds.x_min = bounds.x_min.min(plane.destination.x);
bounds.y_min = bounds.y_min.min(plane.destination.y);
bounds.x_max = bounds.x_max.max(plane.destination.x + plane.size.width);
bounds.y_max = bounds.y_max.max(plane.destination.y + plane.size.height);
}
Some(bounds)
}
fn plane_ink_bounds(plane: &ImagePlane) -> Option<Rect> {
if plane.size.width <= 0 || plane.size.height <= 0 || plane.stride <= 0 {
return None;
}
let stride = plane.stride as usize;
let width = plane.size.width as usize;
let height = plane.size.height as usize;
let mut x_min = width;
let mut y_min = height;
let mut x_max = 0_usize;
let mut y_max = 0_usize;
for y in 0..height {
let row_start = y * stride;
let Some(row) = plane.bitmap.get(row_start..row_start + width) else {
break;
};
for (x, value) in row.iter().enumerate() {
if *value == 0 {
continue;
}
x_min = x_min.min(x);
y_min = y_min.min(y);
x_max = x_max.max(x + 1);
y_max = y_max.max(y + 1);
}
}
(x_min < x_max && y_min < y_max).then_some(Rect {
x_min: plane.destination.x + x_min as i32,
y_min: plane.destination.y + y_min as i32,
x_max: plane.destination.x + x_max as i32,
y_max: plane.destination.y + y_max as i32,
})
}
fn planes_ink_bounds(planes: &[ImagePlane]) -> Option<Rect> {
let mut iter = planes.iter().filter_map(plane_ink_bounds);
let mut bounds = iter.next()?;
for rect in iter {
bounds.x_min = bounds.x_min.min(rect.x_min);
bounds.y_min = bounds.y_min.min(rect.y_min);
bounds.x_max = bounds.x_max.max(rect.x_max);
bounds.y_max = bounds.y_max.max(rect.y_max);
}
Some(bounds)
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct ProjectiveMatrix {
m: [[f64; 3]; 3],
}
impl ProjectiveMatrix {
#[cfg(test)]
fn from_ass_transform_at_origin(
transform: EventTransform,
origin_x: f64,
origin_y: f64,
render_scale_y: f64,
) -> Self {
Self::from_ass_transform_at_origin_with_shear_base(
transform,
origin_x,
origin_y,
origin_x,
origin_y,
render_scale_y,
)
}
fn from_ass_transform_at_origin_with_shear_base(
transform: EventTransform,
origin_x: f64,
origin_y: f64,
shear_base_x: f64,
shear_base_y: f64,
render_scale_y: f64,
) -> Self {
let frx = transform.rotation_x.to_radians();
let fry = transform.rotation_y.to_radians();
let frz = transform.rotation_z.to_radians();
let sx = -frx.sin();
let cx = frx.cos();
let sy = fry.sin();
let cy = fry.cos();
let sz = -frz.sin();
let cz = frz.cos();
let shear_x = finite_or_zero(transform.shear_x);
let shear_y = finite_or_zero(transform.shear_y);
let shear_x_const = shear_x * (origin_y - shear_base_y);
let shear_y_const = shear_y * (origin_x - shear_base_x);
let x2_dx = cz - shear_y * sz;
let x2_dy = shear_x * cz - sz;
let x2_c = shear_x_const * cz - shear_y_const * sz;
let y2_dx = sz + shear_y * cz;
let y2_dy = shear_x * sz + cz;
let y2_c = shear_x_const * sz + shear_y_const * cz;
let y3_dx = y2_dx * cx;
let y3_dy = y2_dy * cx;
let y3_c = y2_c * cx;
let z3_dx = y2_dx * sx;
let z3_dy = y2_dy * sx;
let z3_c = y2_c * sx;
let x4_dx = x2_dx * cy - z3_dx * sy;
let x4_dy = x2_dy * cy - z3_dy * sy;
let x4_c = x2_c * cy - z3_c * sy;
let z4_dx = x2_dx * sy + z3_dx * cy;
let z4_dy = x2_dy * sy + z3_dy * cy;
let z4_c = x2_c * sy + z3_c * cy;
// libass uses camera distance 20000 in the active render coordinate space.
// Our planes are already scaled to the configured output resolution, so
// divide by output scale (not by FreeType's 26.6 factor) to keep frx/fry
// perspective stable in compare's 8x supersampled oracle runs.
let dist = 20000.0 / render_scale_y.max(f64::EPSILON);
let x_num_dx = dist * x4_dx + origin_x * z4_dx;
let x_num_dy = dist * x4_dy + origin_x * z4_dy;
let y_num_dx = dist * y3_dx + origin_y * z4_dx;
let y_num_dy = dist * y3_dy + origin_y * z4_dy;
let x_const = origin_x * dist + dist * x4_c + origin_x * z4_c
- x_num_dx * origin_x
- x_num_dy * origin_y;
let y_const = origin_y * dist + dist * y3_c + origin_y * z4_c
- y_num_dx * origin_x
- y_num_dy * origin_y;
let w_const = dist - z4_dx * origin_x - z4_dy * origin_y - z4_c;
Self {
m: [
[x_num_dx, x_num_dy, x_const],
[y_num_dx, y_num_dy, y_const],
[z4_dx, z4_dy, w_const],
],
}
}
fn is_identity(self) -> bool {
let identity = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
self.m
.iter()
.zip(identity.iter())
.all(|(row, identity_row)| {
row.iter()
.zip(identity_row.iter())
.all(|(value, expected)| (*value - *expected).abs() < 1.0e-9)
})
}
fn transform_point(self, x: f64, y: f64) -> (f64, f64) {
let tx = self.m[0][0] * x + self.m[0][1] * y + self.m[0][2];
let ty = self.m[1][0] * x + self.m[1][1] * y + self.m[1][2];
let tw = self.m[2][0] * x + self.m[2][1] * y + self.m[2][2];
if !tw.is_finite() || tw.abs() < 1.0e-6 {
return (tx, ty);
}
(tx / tw, ty / tw)
}
fn inverse(self) -> Option<Self> {
let m = self.m;
let determinant = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
- m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);
if determinant.abs() < 1.0e-6 || !determinant.is_finite() {
return None;
}
let inv_det = 1.0 / determinant;
Some(Self {
m: [
[
(m[1][1] * m[2][2] - m[1][2] * m[2][1]) * inv_det,
(m[0][2] * m[2][1] - m[0][1] * m[2][2]) * inv_det,
(m[0][1] * m[1][2] - m[0][2] * m[1][1]) * inv_det,
],
[
(m[1][2] * m[2][0] - m[1][0] * m[2][2]) * inv_det,
(m[0][0] * m[2][2] - m[0][2] * m[2][0]) * inv_det,
(m[0][2] * m[1][0] - m[0][0] * m[1][2]) * inv_det,
],
[
(m[1][0] * m[2][1] - m[1][1] * m[2][0]) * inv_det,
(m[0][1] * m[2][0] - m[0][0] * m[2][1]) * inv_det,
(m[0][0] * m[1][1] - m[0][1] * m[1][0]) * inv_det,
],
],
})
}
}
fn finite_or_zero(value: f64) -> f64 {
if value.is_finite() { value } else { 0.0 }
}
fn transform_plane(plane: ImagePlane, matrix: ProjectiveMatrix) -> Option<ImagePlane> {
if plane.size.width <= 0 || plane.size.height <= 0 || plane.bitmap.is_empty() {
return Some(plane);
}
let inverse = matrix.inverse()?;
let corners = [
(
f64::from(plane.destination.x),
f64::from(plane.destination.y),
),
(
f64::from(plane.destination.x + plane.size.width),
f64::from(plane.destination.y),
),
(
f64::from(plane.destination.x),
f64::from(plane.destination.y + plane.size.height),
),
(
f64::from(plane.destination.x + plane.size.width),
f64::from(plane.destination.y + plane.size.height),
),
];
let transformed = corners.map(|(x, y)| matrix.transform_point(x, y));
let min_x = transformed
.iter()
.map(|(x, _)| *x)
.fold(f64::INFINITY, f64::min)
.floor() as i32;
let min_y = transformed
.iter()
.map(|(_, y)| *y)
.fold(f64::INFINITY, f64::min)
.floor() as i32;
let max_x = transformed
.iter()
.map(|(x, _)| *x)
.fold(f64::NEG_INFINITY, f64::max)
.ceil() as i32;
let max_y = transformed
.iter()
.map(|(_, y)| *y)
.fold(f64::NEG_INFINITY, f64::max)
.ceil() as i32;
let width = (max_x - min_x).max(1) as usize;
let height = (max_y - min_y).max(1) as usize;
let mut bitmap = vec![0_u8; width * height];
let src_stride = plane.stride.max(0) as usize;
let src_width = plane.size.width as usize;
let src_height = plane.size.height as usize;
for row in 0..height {
for column in 0..width {
let dest_x = f64::from(min_x) + column as f64 + 0.5;
let dest_y = f64::from(min_y) + row as f64 + 0.5;
let (src_global_x, src_global_y) = inverse.transform_point(dest_x, dest_y);
let src_x = src_global_x - f64::from(plane.destination.x) - 0.5;
let src_y = src_global_y - f64::from(plane.destination.y) - 0.5;
let value = sample_bitmap_bilinear(
&plane.bitmap,
src_stride,
src_width,
src_height,
src_x,
src_y,
);
bitmap[row * width + column] = value;
}
}
bitmap.iter().any(|value| *value > 0).then_some(ImagePlane {
size: Size {
width: width as i32,
height: height as i32,
},
stride: width as i32,
destination: Point { x: min_x, y: min_y },
bitmap,
..plane
})
}
fn sample_bitmap_bilinear(
bitmap: &[u8],
stride: usize,
width: usize,
height: usize,
x: f64,
y: f64,
) -> u8 {
if !(x.is_finite() && y.is_finite()) || x < 0.0 || y < 0.0 {
return 0;
}
let x0 = x.floor() as i32;
let y0 = y.floor() as i32;
if x0 < 0 || y0 < 0 || x0 as usize >= width || y0 as usize >= height {
return 0;
}
let x1 = (x0 + 1).min(width.saturating_sub(1) as i32);
let y1 = (y0 + 1).min(height.saturating_sub(1) as i32);
let wx = x - f64::from(x0);
let wy = y - f64::from(y0);
let at = |xx: i32, yy: i32| -> f64 { bitmap[yy as usize * stride + xx as usize] as f64 };
let top = at(x0, y0) * (1.0 - wx) + at(x1, y0) * wx;
let bottom = at(x0, y1) * (1.0 - wx) + at(x1, y1) * wx;
(top * (1.0 - wy) + bottom * wy).round().clamp(0.0, 255.0) as u8
}
pub fn default_renderer_config(track: &ParsedTrack) -> RendererConfig {
RendererConfig {
frame: Size {
width: track.play_res_x,
height: track.play_res_y,
},
..RendererConfig::default()
}
}
fn output_scale_x(track: &ParsedTrack, config: &RendererConfig) -> f64 {
let frame_width = output_mapping_size(track, config).width;
let base_width = track.play_res_x.max(1);
let aspect = effective_pixel_aspect(track, config);
f64::from(frame_width.max(1)) / f64::from(base_width) * aspect
}
fn output_scale_y(track: &ParsedTrack, config: &RendererConfig) -> f64 {
let frame_height = output_mapping_size(track, config).height;
let base_height = track.play_res_y.max(1);
f64::from(frame_height.max(1)) / f64::from(base_height)
}
fn effective_pixel_aspect(track: &ParsedTrack, config: &RendererConfig) -> f64 {
if layout_resolution(track).is_some()
|| !(config.pixel_aspect.is_finite() && config.pixel_aspect > 0.0)
{
return derived_pixel_aspect(track, config).unwrap_or(1.0);
}
config.pixel_aspect
}
fn derived_pixel_aspect(track: &ParsedTrack, config: &RendererConfig) -> Option<f64> {
let layout = layout_resolution(track).or_else(|| storage_resolution(config))?;
let frame = frame_content_size(track, config);
if frame.width <= 0 || frame.height <= 0 || layout.width <= 0 || layout.height <= 0 {
return None;
}
let display_aspect = f64::from(frame.width) / f64::from(frame.height);
let source_aspect = f64::from(layout.width) / f64::from(layout.height);
(source_aspect > 0.0).then_some(display_aspect / source_aspect)
}
fn layout_resolution(track: &ParsedTrack) -> Option<Size> {
(track.layout_res_x > 0 && track.layout_res_y > 0).then_some(Size {
width: track.layout_res_x,
height: track.layout_res_y,
})
}
fn storage_resolution(config: &RendererConfig) -> Option<Size> {
(config.storage.width > 0 && config.storage.height > 0).then_some(config.storage)
}
fn frame_content_size(track: &ParsedTrack, config: &RendererConfig) -> Size {
let frame_width = if config.frame.width > 0 {
config.frame.width
} else {
track.play_res_x
};
let frame_height = if config.frame.height > 0 {
config.frame.height
} else {
track.play_res_y
};
Size {
width: (frame_width - config.margins.left - config.margins.right).max(0),
height: (frame_height - config.margins.top - config.margins.bottom).max(0),
}
}
fn output_mapping_size(track: &ParsedTrack, config: &RendererConfig) -> Size {
if config.use_margins {
Size {
width: if config.frame.width > 0 {
config.frame.width
} else {
track.play_res_x
},
height: if config.frame.height > 0 {
config.frame.height
} else {
track.play_res_y
},
}
} else {
frame_content_size(track, config)
}
}
fn output_offset(config: &RendererConfig) -> Point {
if config.use_margins {
Point { x: 0, y: 0 }
} else {
Point {
x: config.margins.left.max(0),
y: config.margins.top.max(0),
}
}
}
fn translate_planes(mut planes: Vec<ImagePlane>, offset: Point) -> Vec<ImagePlane> {
if offset == Point::default() {
return planes;
}
for plane in &mut planes {
plane.destination.x += offset.x;
plane.destination.y += offset.y;
}
planes
}
fn extend_planes_for_effect_motion(
planes: Vec<ImagePlane>,
left_pad: i32,
right_pad: i32,
top_pad: i32,
bottom_pad: i32,
) -> Vec<ImagePlane> {
planes
.into_iter()
.map(|plane| extend_plane_edges(plane, left_pad, right_pad, top_pad, bottom_pad))
.collect()
}
fn extend_plane_edges(
plane: ImagePlane,
left_pad: i32,
right_pad: i32,
top_pad: i32,
bottom_pad: i32,
) -> ImagePlane {
if plane.size.width <= 0
|| plane.size.height <= 0
|| plane.stride <= 0
|| plane.bitmap.is_empty()
{
return plane;
}
let left_pad = left_pad.max(0);
let right_pad = right_pad.max(0);
let top_pad = top_pad.max(0);
let bottom_pad = bottom_pad.max(0);
if left_pad + right_pad + top_pad + bottom_pad == 0 {
return plane;
}
let old_width = plane.size.width as usize;
let old_stride = plane.stride as usize;
let Some(ink) = plane_ink_bounds(&plane) else {
return plane;
};
let ink_x_min = (ink.x_min - plane.destination.x).max(0) as usize;
let ink_y_min = (ink.y_min - plane.destination.y).max(0) as usize;
let ink_x_max = (ink.x_max - plane.destination.x).min(plane.size.width) as usize;
let ink_y_max = (ink.y_max - plane.destination.y).min(plane.size.height) as usize;
let ink_height = ink_y_max.saturating_sub(ink_y_min);
if ink_x_max <= ink_x_min || ink_height == 0 {
return plane;
}
let pixel = left_pad.max(right_pad).max(top_pad).max(bottom_pad).max(1);
let floor_to_pixel = |value: i32| value.div_euclid(pixel) * pixel;
let ceil_to_pixel = |value: i32| {
value.div_euclid(pixel) * pixel + i32::from(value.rem_euclid(pixel) != 0) * pixel
};
let new_height = ink_height + top_pad as usize + bottom_pad as usize;
let dest_y = plane.destination.y + ink_y_min as i32 - top_pad;
let mut row_spans = Vec::with_capacity(new_height);
let mut min_x = i32::MAX;
let mut max_x = i32::MIN;
for dst_y in 0..new_height {
let ink_row = if dst_y < top_pad as usize {
0
} else if dst_y >= top_pad as usize + ink_height {
ink_height - 1
} else {
dst_y - top_pad as usize
};
let src_y = ink_y_min + ink_row;
let src_row = &plane.bitmap[src_y * old_stride..src_y * old_stride + old_width];
let first_lit = src_row[ink_x_min..ink_x_max]
.iter()
.position(|value| *value > 0)
.map(|x| x + ink_x_min);
let last_lit = src_row[ink_x_min..ink_x_max]
.iter()
.rposition(|value| *value > 0)
.map(|x| x + ink_x_min);
let Some(first_lit) = first_lit else {
row_spans.push(None);
continue;
};
let last_lit = last_lit.expect("row with first lit pixel should also have last lit pixel");
let vertical_pad_row = dst_y < top_pad as usize || dst_y >= top_pad as usize + ink_height;
let corner_row =
(top_pad > 0 || bottom_pad > 0) && (ink_row == 0 || ink_row + 1 == ink_height);
let suppress_horizontal_pad = vertical_pad_row || corner_row;
let first_global = plane.destination.x + first_lit as i32;
let last_exclusive_global = plane.destination.x + last_lit as i32 + 1;
let (span_start, span_end) = if suppress_horizontal_pad {
(
ceil_to_pixel(first_global),
ceil_to_pixel(last_exclusive_global),
)
} else {
(
floor_to_pixel(first_global - left_pad),
ceil_to_pixel(last_exclusive_global + right_pad),
)
};
if span_end <= span_start {
row_spans.push(None);
continue;
}
min_x = min_x.min(span_start);
max_x = max_x.max(span_end);
row_spans.push(Some((span_start, span_end)));
}
if min_x == i32::MAX || max_x <= min_x {
return plane;
}
let new_width = (max_x - min_x) as usize;
let mut bitmap = vec![0_u8; new_width * new_height];
for (dst_y, span) in row_spans.into_iter().enumerate() {
let Some((span_start, span_end)) = span else {
continue;
};
let start = (span_start - min_x) as usize;
let end = (span_end - min_x) as usize;
bitmap[dst_y * new_width + start..dst_y * new_width + end].fill(255);
}
ImagePlane {
destination: Point {
x: min_x,
y: dest_y,
},
size: Size {
width: new_width as i32,
height: new_height as i32,
},
stride: new_width as i32,
bitmap,
..plane
}
}
fn scale_clip_rect(rect: Rect, scale_x: f64, scale_y: f64) -> Rect {
let scale_x = style_scale(scale_x);
let scale_y = style_scale(scale_y);
Rect {
x_min: (f64::from(rect.x_min) * scale_x).floor() as i32,
y_min: (f64::from(rect.y_min) * scale_y).floor() as i32,
x_max: (f64::from(rect.x_max) * scale_x).ceil() as i32,
y_max: (f64::from(rect.y_max) * scale_y).ceil() as i32,
}
}
fn frame_clip_rect(
track: &ParsedTrack,
config: &RendererConfig,
event: &LayoutEvent,
effective_position: Option<(i32, i32)>,
) -> Rect {
let frame_width = if config.frame.width > 0 {
config.frame.width
} else {
track.play_res_x.max(0)
};
let frame_height = if config.frame.height > 0 {
config.frame.height
} else {
track.play_res_y.max(0)
};
if config.use_margins
&& effective_position.is_none()
&& event.clip_rect.is_none()
&& event.vector_clip.is_none()
{
Rect {
x_min: config.margins.left.max(0),
y_min: config.margins.top.max(0),
x_max: (frame_width - config.margins.right).max(0),
y_max: (frame_height - config.margins.bottom).max(0),
}
} else {
Rect {
x_min: 0,
y_min: 0,
x_max: frame_width,
y_max: frame_height,
}
}
}
fn compute_horizontal_origin(
track: &ParsedTrack,
event: &LayoutEvent,
line_width: i32,
effective_position: Option<(i32, i32)>,
scale_x: f64,
) -> i32 {
let scale_x = style_scale(scale_x);
if let Some((x, _)) = effective_position {
return match event.alignment & 0x3 {
ass::HALIGN_LEFT => x,
ass::HALIGN_RIGHT => x - line_width,
_ => x - line_width / 2,
};
}
let frame_width = (f64::from(track.play_res_x) * scale_x).round() as i32;
let margin_l = (f64::from(event.margin_l) * scale_x).round() as i32;
let margin_r = (f64::from(event.margin_r) * scale_x).round() as i32;
match event.alignment & 0x3 {
ass::HALIGN_LEFT => margin_l,
ass::HALIGN_RIGHT => (frame_width - margin_r - line_width).max(0),
_ => ((margin_l + frame_width - margin_r - line_width) / 2).max(0),
}
}
fn scale_position(position: Option<(i32, i32)>, scale_x: f64, scale_y: f64) -> Option<(i32, i32)> {
let scale_x = style_scale(scale_x);
let scale_y = style_scale(scale_y);
position.map(|(x, y)| {
(
(f64::from(x) * scale_x).round() as i32,
(f64::from(y) * scale_y).round() as i32,
)
})
}
fn resolve_event_position(
track: &ParsedTrack,
event: &LayoutEvent,
now_ms: i64,
) -> Option<(i32, i32)> {
event.position.or_else(|| {
event
.movement
.map(|movement| interpolate_move(movement, track.events.get(event.event_index), now_ms))
})
}
fn event_layer(track: &ParsedTrack, event: &LayoutEvent) -> i32 {
track
.events
.get(event.event_index)
.map(|source| source.layer)
.unwrap_or_default()
}
fn interpolate_move(
movement: ParsedMovement,
source_event: Option<&ParsedEvent>,
now_ms: i64,
) -> (i32, i32) {
let event_duration = source_event
.map(|event| event.duration)
.unwrap_or_default()
.max(0) as i32;
let event_elapsed = source_event
.map(|event| (now_ms - event.start).clamp(0, event.duration.max(0)) as i32)
.unwrap_or_default();
let (t1_ms, t2_ms) = if movement.t1_ms <= 0 && movement.t2_ms <= 0 {
(0, event_duration)
} else {
(movement.t1_ms.max(0), movement.t2_ms.max(movement.t1_ms))
};
let k = if event_elapsed <= t1_ms {
0.0
} else if event_elapsed >= t2_ms {
1.0
} else {
let delta = (t2_ms - t1_ms).max(1) as f64;
f64::from(event_elapsed - t1_ms) / delta
};
let x = f64::from(movement.end.0 - movement.start.0) * k + f64::from(movement.start.0);
let y = f64::from(movement.end.1 - movement.start.1) * k + f64::from(movement.start.1);
(x.round() as i32, y.round() as i32)
}
fn compute_vertical_layout(
track: &ParsedTrack,
lines: &[rassa_layout::LayoutLine],
alignment: i32,
margin_v: i32,
position: Option<(i32, i32)>,
config: &RendererConfig,
scale_y: f64,
) -> Vec<i32> {
let scale_y = style_scale(scale_y);
if let Some((_, y)) = position {
let line_heights = lines
.iter()
.map(|line| positioned_layout_line_height_for_line(line, config, scale_y))
.collect::<Vec<_>>();
let total_height: i32 = line_heights.iter().sum();
let mut current_y = match alignment & (ass::VALIGN_TOP | ass::VALIGN_CENTER) {
ass::VALIGN_TOP => y,
ass::VALIGN_CENTER => y - total_height / 2,
_ => y - total_height,
};
let mut positions = Vec::with_capacity(lines.len());
for height in line_heights {
positions.push(current_y);
current_y += height;
}
return positions;
}
let line_heights = lines
.iter()
.map(|line| layout_line_height_for_line(line, config, scale_y))
.collect::<Vec<_>>();
let total_height: i32 = line_heights.iter().sum();
let default_start_y = match alignment & (ass::VALIGN_TOP | ass::VALIGN_CENTER) {
ass::VALIGN_TOP => (f64::from(margin_v) * scale_y).round() as i32,
ass::VALIGN_CENTER => {
((f64::from(track.play_res_y) * scale_y).round() as i32 - total_height) / 2
}
_ => ((f64::from(track.play_res_y) * scale_y).round() as i32
- (f64::from(margin_v) * scale_y).round() as i32
- total_height)
.max(0),
};
let line_position = config.line_position.clamp(0.0, 100.0);
let start_y = if (alignment & (ass::VALIGN_TOP | ass::VALIGN_CENTER)) == ass::VALIGN_SUB
&& line_position > 0.0
{
let bottom_y = f64::from(default_start_y);
let top_y = 0.0;
(bottom_y + (top_y - bottom_y) * (line_position / 100.0)).round() as i32
} else {
default_start_y
}
.max(0);
let mut positions = Vec::with_capacity(lines.len());
let mut current_y = start_y;
for height in line_heights {
positions.push(current_y);
current_y += height;
}
positions
}
fn resolve_vertical_layout(
track: &ParsedTrack,
event: &LayoutEvent,
effective_position: Option<(i32, i32)>,
occupied_bounds: &[Rect],
config: &RendererConfig,
scale_y: f64,
) -> Vec<i32> {
let mut vertical_layout = compute_vertical_layout(
track,
&event.lines,
event.alignment,
event.margin_v,
effective_position,
config,
scale_y,
);
if effective_position.is_some() || occupied_bounds.is_empty() {
return vertical_layout;
}
let line_height = layout_line_height(config, scale_y);
let shift = match event.alignment & (ass::VALIGN_TOP | ass::VALIGN_CENTER) {
ass::VALIGN_TOP => line_height,
ass::VALIGN_CENTER => line_height,
_ => -line_height,
};
let mut bounds = event_bounds(
track,
event,
&vertical_layout,
effective_position,
config,
1.0,
scale_y,
);
let frame_height = (f64::from(track.play_res_y) * scale_y).round() as i32;
while occupied_bounds
.iter()
.any(|occupied| bounds.intersect(*occupied).is_some())
{
for line_top in &mut vertical_layout {
*line_top += shift;
}
bounds = event_bounds(
track,
event,
&vertical_layout,
effective_position,
config,
1.0,
scale_y,
);
if bounds.y_min < 0 || bounds.y_max > frame_height {
break;
}
}
vertical_layout
}
fn event_bounds(
track: &ParsedTrack,
event: &LayoutEvent,
vertical_layout: &[i32],
effective_position: Option<(i32, i32)>,
config: &RendererConfig,
scale_x: f64,
scale_y: f64,
) -> Rect {
let mut x_min = i32::MAX;
let mut y_min = i32::MAX;
let mut x_max = i32::MIN;
let mut y_max = i32::MIN;
for (line, line_top) in event.lines.iter().zip(vertical_layout.iter().copied()) {
let line_width = (f64::from(line.width) * style_scale(scale_x)).round() as i32;
let origin_x =
compute_horizontal_origin(track, event, line_width, effective_position, scale_x);
x_min = x_min.min(origin_x);
y_min = y_min.min(line_top);
x_max = x_max.max(origin_x + line_width);
y_max = y_max.max(line_top + layout_line_height(config, scale_y));
}
if x_min == i32::MAX {
Rect::default()
} else {
Rect {
x_min,
y_min,
x_max,
y_max,
}
}
}
fn text_decoration_planes(
style: &ParsedSpanStyle,
origin_x: i32,
line_top: i32,
width: i32,
color: u32,
) -> Vec<ImagePlane> {
if width <= 0 || !(style.underline || style.strike_out) {
return Vec::new();
}
let thickness = (style.font_size / 18.0).round().max(1.0) as i32;
let mut planes = Vec::new();
let mut push_decoration = |baseline_fraction: f64| {
let y = line_top + (style.font_size * baseline_fraction).round() as i32;
planes.push(ImagePlane {
size: Size {
width,
height: thickness,
},
stride: width,
color: rgba_color_from_ass(color),
destination: Point { x: origin_x, y },
kind: ass::ImageType::Character,
bitmap: vec![255; (width * thickness) as usize],
});
};
if style.underline {
push_decoration(0.82);
}
if style.strike_out {
push_decoration(0.48);
}
planes
}
fn combined_image_plane_from_glyphs(
glyphs: &[RasterGlyph],
origin_x: i32,
line_top: i32,
line_ascender: Option<i32>,
color: u32,
kind: ass::ImageType,
blur_radius: u32,
) -> Option<ImagePlane> {
let ascender =
line_ascender.unwrap_or_else(|| glyphs.iter().map(|glyph| glyph.top).max().unwrap_or(0));
let mut pen_x = 0_i32;
let mut min_x = i32::MAX;
let mut min_y = i32::MAX;
let mut max_x = i32::MIN;
let mut max_y = i32::MIN;
for glyph in glyphs {
if glyph.width <= 0 || glyph.height <= 0 || glyph.bitmap.is_empty() {
pen_x += glyph.advance_x;
continue;
}
let x = pen_x + glyph.left + glyph.offset_x;
let y = ascender - glyph.top + glyph.offset_y;
min_x = min_x.min(x);
min_y = min_y.min(y);
max_x = max_x.max(x + glyph.width);
max_y = max_y.max(y + glyph.height);
pen_x += glyph.advance_x;
}
if min_x == i32::MAX || min_y == i32::MAX || max_x <= min_x || max_y <= min_y {
return None;
}
let width = (max_x - min_x) as usize;
let height = (max_y - min_y) as usize;
let mut bitmap = vec![0_u8; width * height];
pen_x = 0;
for glyph in glyphs {
if glyph.width <= 0 || glyph.height <= 0 || glyph.bitmap.is_empty() {
pen_x += glyph.advance_x;
continue;
}
let x0 = (pen_x + glyph.left + glyph.offset_x - min_x) as usize;
let y0 = (ascender - glyph.top + glyph.offset_y - min_y) as usize;
let glyph_width = glyph.width as usize;
let glyph_height = glyph.height as usize;
let glyph_stride = glyph.stride as usize;
for y in 0..glyph_height {
for x in 0..glyph_width {
let src = glyph.bitmap[y * glyph_stride + x];
let dst = &mut bitmap[(y0 + y) * width + x0 + x];
*dst = (*dst).max(src);
}
}
pen_x += glyph.advance_x;
}
let (bitmap, width, height, pad) = blur_bitmap(bitmap, width, height, blur_radius);
Some(ImagePlane {
size: Size {
width: width as i32,
height: height as i32,
},
stride: width as i32,
color: rgba_color_from_ass(color),
destination: Point {
x: origin_x + min_x - pad as i32,
y: line_top + min_y - pad as i32,
},
kind,
bitmap,
})
}
fn blur_bitmap(
source: Vec<u8>,
width: usize,
height: usize,
radius: u32,
) -> (Vec<u8>, usize, usize, usize) {
if radius == 0 || width == 0 || height == 0 || source.is_empty() {
return (source, width, height, 0);
}
let r2 = libass_blur_r2_from_radius(radius);
let (bitmap, width, height, pad_x, pad_y) =
libass_gaussian_blur(&source, width, height, r2, r2);
debug_assert_eq!(pad_x, pad_y);
(bitmap, width, height, pad_x)
}
#[derive(Clone)]
struct LibassBlurMethod {
level: usize,
radius: usize,
coeff: [i16; 8],
}
fn libass_blur_r2_from_radius(radius: u32) -> f64 {
const POSITION_PRECISION: f64 = 8.0;
const BLUR_PRECISION: f64 = 1.0 / 256.0;
let blur = f64::from(radius) / 4.0;
let blur_radius_scale = 2.0 / 256.0_f64.ln().sqrt();
let scale = 64.0 * BLUR_PRECISION / POSITION_PRECISION;
let qblur = ((1.0 + blur * blur_radius_scale * scale).ln() / BLUR_PRECISION).round();
let sigma = (BLUR_PRECISION * qblur).exp_m1() / scale;
sigma * sigma
}
fn libass_gaussian_blur(
source: &[u8],
width: usize,
height: usize,
r2x: f64,
r2y: f64,
) -> (Vec<u8>, usize, usize, usize, usize) {
let blur_x = find_libass_blur_method(r2x);
let blur_y = if (r2y - r2x).abs() < f64::EPSILON {
blur_x.clone()
} else {
find_libass_blur_method(r2y)
};
let offset_x = ((2 * blur_x.radius + 9) << blur_x.level) - 5;
let offset_y = ((2 * blur_y.radius + 9) << blur_y.level) - 5;
let mask_x = (1_usize << blur_x.level) - 1;
let mask_y = (1_usize << blur_y.level) - 1;
let end_width = ((width + offset_x) & !mask_x).saturating_sub(4);
let end_height = ((height + offset_y) & !mask_y).saturating_sub(4);
let pad_x = ((blur_x.radius + 4) << blur_x.level) - 4;
let pad_y = ((blur_y.radius + 4) << blur_y.level) - 4;
let mut buffer = unpack_libass_blur(source);
let mut w = width;
let mut h = height;
for _ in 0..blur_y.level {
let next = shrink_vert_libass(&buffer, w, h);
buffer = next.0;
w = next.1;
h = next.2;
}
for _ in 0..blur_x.level {
let next = shrink_horz_libass(&buffer, w, h);
buffer = next.0;
w = next.1;
h = next.2;
}
let next = blur_horz_libass(&buffer, w, h, &blur_x.coeff, blur_x.radius);
buffer = next.0;
w = next.1;
h = next.2;
let next = blur_vert_libass(&buffer, w, h, &blur_y.coeff, blur_y.radius);
buffer = next.0;
w = next.1;
h = next.2;
for _ in 0..blur_x.level {
let next = expand_horz_libass(&buffer, w, h);
buffer = next.0;
w = next.1;
h = next.2;
}
for _ in 0..blur_y.level {
let next = expand_vert_libass(&buffer, w, h);
buffer = next.0;
w = next.1;
h = next.2;
}
debug_assert_eq!(w, end_width);
debug_assert_eq!(h, end_height);
(pack_libass_blur(&buffer, w, h), w, h, pad_x, pad_y)
}
fn find_libass_blur_method(r2: f64) -> LibassBlurMethod {
let mut mu = [0.0_f64; 8];
let (level, radius) = if r2 < 0.5 {
mu[1] = 0.085 * r2 * r2 * r2;
mu[0] = 0.5 * r2 - 4.0 * mu[1];
(0_usize, 4_usize)
} else {
let (frac, level) = frexp((0.11569 * r2 + 0.20591047).sqrt());
let mul = 0.25_f64.powi(level);
let radius = (8_i32 - ((10.1525 + 0.8335 * mul) * (1.0 - frac)) as i32).max(4) as usize;
calc_libass_coeff(&mut mu, radius, r2, mul);
(level.max(0) as usize, radius)
};
let mut coeff = [0_i16; 8];
for i in 0..radius {
coeff[i] = (65536.0 * mu[i] + 0.5) as i16;
}
LibassBlurMethod {
level,
radius,
coeff,
}
}
fn calc_libass_coeff(mu: &mut [f64; 8], n: usize, r2: f64, mul: f64) {
let w = 12096.0;
let kernel = [
(((3280.0 / w) * mul + 1092.0 / w) * mul + 2520.0 / w) * mul + 5204.0 / w,
(((-2460.0 / w) * mul - 273.0 / w) * mul - 210.0 / w) * mul + 2943.0 / w,
(((984.0 / w) * mul - 546.0 / w) * mul - 924.0 / w) * mul + 486.0 / w,
(((-164.0 / w) * mul + 273.0 / w) * mul - 126.0 / w) * mul + 17.0 / w,
];
let mut mat_freq = [0.0_f64; 17];
mat_freq[..4].copy_from_slice(&kernel);
coeff_filter_libass(&mut mat_freq, 7, &kernel);
let mut vec_freq = [0.0_f64; 12];
calc_gauss_libass(&mut vec_freq, n + 4, r2 * mul);
coeff_filter_libass(&mut vec_freq, n + 1, &kernel);
let mut mat = [[0.0_f64; 8]; 8];
calc_matrix_libass(&mut mat, &mat_freq, n);
let mut vec = [0.0_f64; 8];
for i in 0..n {
vec[i] = mat_freq[0] - mat_freq[i + 1] - vec_freq[0] + vec_freq[i + 1];
}
for i in 0..n {
let mut res = 0.0;
for (j, value) in vec.iter().enumerate().take(n) {
res += mat[i][j] * value;
}
mu[i] = res.max(0.0);
}
}
fn calc_gauss_libass(res: &mut [f64], n: usize, r2: f64) {
let alpha = 0.5 / r2;
let mut mul = (-alpha).exp();
let mul2 = mul * mul;
let mut cur = (alpha / std::f64::consts::PI).sqrt();
res[0] = cur;
cur *= mul;
res[1] = cur;
for value in res.iter_mut().take(n).skip(2) {
mul *= mul2;
cur *= mul;
*value = cur;
}
}
fn coeff_filter_libass(coeff: &mut [f64], n: usize, kernel: &[f64; 4]) {
let mut prev1 = coeff[1];
let mut prev2 = coeff[2];
let mut prev3 = coeff[3];
for i in 0..n {
let res = coeff[i] * kernel[0]
+ (prev1 + coeff[i + 1]) * kernel[1]
+ (prev2 + coeff[i + 2]) * kernel[2]
+ (prev3 + coeff[i + 3]) * kernel[3];
prev3 = prev2;
prev2 = prev1;
prev1 = coeff[i];
coeff[i] = res;
}
}
fn calc_matrix_libass(mat: &mut [[f64; 8]; 8], mat_freq: &[f64], n: usize) {
for i in 0..n {
mat[i][i] = mat_freq[2 * i + 2] + 3.0 * mat_freq[0] - 4.0 * mat_freq[i + 1];
for j in i + 1..n {
let v = mat_freq[i + j + 2]
+ mat_freq[j - i]
+ 2.0 * (mat_freq[0] - mat_freq[i + 1] - mat_freq[j + 1]);
mat[i][j] = v;
mat[j][i] = v;
}
}
for k in 0..n {
let z = 1.0 / mat[k][k];
mat[k][k] = 1.0;
let pivot_row = mat[k];
for (i, row) in mat.iter_mut().enumerate().take(n) {
if i == k {
continue;
}
let mul = row[k] * z;
row[k] = 0.0;
for j in 0..n {
row[j] -= pivot_row[j] * mul;
}
}
for value in mat[k].iter_mut().take(n) {
*value *= z;
}
}
}
fn frexp(value: f64) -> (f64, i32) {
if value == 0.0 {
return (0.0, 0);
}
let exponent = value.abs().log2().floor() as i32 + 1;
(value / 2.0_f64.powi(exponent), exponent)
}
#[inline]
fn get_libass_sample(source: &[i16], width: usize, height: usize, x: isize, y: isize) -> i16 {
if x < 0 || y < 0 || x >= width as isize || y >= height as isize {
0
} else {
source[y as usize * width + x as usize]
}
}
fn unpack_libass_blur(source: &[u8]) -> Vec<i16> {
source
.iter()
.map(|value| {
let value = u16::from(*value);
((((value << 7) | (value >> 1)) + 1) >> 1) as i16
})
.collect()
}
const LIBASS_DITHER_LINE: [i16; 32] = [
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,
56, 24, 56, 24, 56, 24,
];
fn pack_libass_blur(source: &[i16], width: usize, height: usize) -> Vec<u8> {
let mut bitmap = vec![0_u8; width * height];
for y in 0..height {
let dither = &LIBASS_DITHER_LINE[16 * (y & 1)..];
for x in 0..width {
let sample = i32::from(source[y * width + x]);
let value = ((sample - (sample >> 8) + i32::from(dither[x & 15])) >> 6).clamp(0, 255);
bitmap[y * width + x] = value as u8;
}
}
bitmap
}
#[inline]
fn shrink_func_libass(p1p: i16, p1n: i16, z0p: i16, z0n: i16, n1p: i16, n1n: i16) -> i16 {
let mut r = (i32::from(p1p) + i32::from(p1n) + i32::from(n1p) + i32::from(n1n)) >> 1;
r = (r + i32::from(z0p) + i32::from(z0n)) >> 1;
r = (r + i32::from(p1n) + i32::from(n1p)) >> 1;
((r + i32::from(z0p) + i32::from(z0n) + 2) >> 2) as i16
}
#[inline]
fn expand_func_libass(p1: i16, z0: i16, n1: i16) -> (i16, i16) {
let r = ((((p1 as u16).wrapping_add(n1 as u16)) >> 1).wrapping_add(z0 as u16)) >> 1;
let rp = (((r.wrapping_add(p1 as u16) >> 1)
.wrapping_add(z0 as u16)
.wrapping_add(1))
>> 1) as i16;
let rn = (((r.wrapping_add(n1 as u16) >> 1)
.wrapping_add(z0 as u16)
.wrapping_add(1))
>> 1) as i16;
(rp, rn)
}
fn shrink_horz_libass(source: &[i16], width: usize, height: usize) -> (Vec<i16>, usize, usize) {
let dst_width = (width + 5) >> 1;
let mut dst = vec![0_i16; dst_width * height];
for y in 0..height {
for x in 0..dst_width {
let sx = (2 * x) as isize;
dst[y * dst_width + x] = shrink_func_libass(
get_libass_sample(source, width, height, sx - 4, y as isize),
get_libass_sample(source, width, height, sx - 3, y as isize),
get_libass_sample(source, width, height, sx - 2, y as isize),
get_libass_sample(source, width, height, sx - 1, y as isize),
get_libass_sample(source, width, height, sx, y as isize),
get_libass_sample(source, width, height, sx + 1, y as isize),
);
}
}
(dst, dst_width, height)
}
fn shrink_vert_libass(source: &[i16], width: usize, height: usize) -> (Vec<i16>, usize, usize) {
let dst_height = (height + 5) >> 1;
let mut dst = vec![0_i16; width * dst_height];
for y in 0..dst_height {
let sy = (2 * y) as isize;
for x in 0..width {
dst[y * width + x] = shrink_func_libass(
get_libass_sample(source, width, height, x as isize, sy - 4),
get_libass_sample(source, width, height, x as isize, sy - 3),
get_libass_sample(source, width, height, x as isize, sy - 2),
get_libass_sample(source, width, height, x as isize, sy - 1),
get_libass_sample(source, width, height, x as isize, sy),
get_libass_sample(source, width, height, x as isize, sy + 1),
);
}
}
(dst, width, dst_height)
}
fn expand_horz_libass(source: &[i16], width: usize, height: usize) -> (Vec<i16>, usize, usize) {
let dst_width = 2 * width + 4;
let mut dst = vec![0_i16; dst_width * height];
for y in 0..height {
for i in 0..(width + 2) {
let sx = i as isize;
let (rp, rn) = expand_func_libass(
get_libass_sample(source, width, height, sx - 2, y as isize),
get_libass_sample(source, width, height, sx - 1, y as isize),
get_libass_sample(source, width, height, sx, y as isize),
);
let dx = 2 * i;
dst[y * dst_width + dx] = rp;
dst[y * dst_width + dx + 1] = rn;
}
}
(dst, dst_width, height)
}
fn expand_vert_libass(source: &[i16], width: usize, height: usize) -> (Vec<i16>, usize, usize) {
let dst_height = 2 * height + 4;
let mut dst = vec![0_i16; width * dst_height];
for i in 0..(height + 2) {
let sy = i as isize;
for x in 0..width {
let (rp, rn) = expand_func_libass(
get_libass_sample(source, width, height, x as isize, sy - 2),
get_libass_sample(source, width, height, x as isize, sy - 1),
get_libass_sample(source, width, height, x as isize, sy),
);
let dy = 2 * i;
dst[dy * width + x] = rp;
dst[(dy + 1) * width + x] = rn;
}
}
(dst, width, dst_height)
}
fn blur_horz_libass(
source: &[i16],
width: usize,
height: usize,
param: &[i16; 8],
radius: usize,
) -> (Vec<i16>, usize, usize) {
let dst_width = width + 2 * radius;
let mut dst = vec![0_i16; dst_width * height];
for y in 0..height {
for x in 0..dst_width {
let center_x = x as isize - radius as isize;
let center = i32::from(get_libass_sample(
source, width, height, center_x, y as isize,
));
let mut acc = 0x8000_i32;
for i in (1..=radius).rev() {
let coeff = i32::from(param[i - 1]);
let left = i32::from(get_libass_sample(
source,
width,
height,
center_x - i as isize,
y as isize,
));
let right = i32::from(get_libass_sample(
source,
width,
height,
center_x + i as isize,
y as isize,
));
acc += ((left - center) as i16 as i32) * coeff;
acc += ((right - center) as i16 as i32) * coeff;
}
dst[y * dst_width + x] = (center + (acc >> 16)) as i16;
}
}
(dst, dst_width, height)
}
fn blur_vert_libass(
source: &[i16],
width: usize,
height: usize,
param: &[i16; 8],
radius: usize,
) -> (Vec<i16>, usize, usize) {
let dst_height = height + 2 * radius;
let mut dst = vec![0_i16; width * dst_height];
for y in 0..dst_height {
let center_y = y as isize - radius as isize;
for x in 0..width {
let center = i32::from(get_libass_sample(
source, width, height, x as isize, center_y,
));
let mut acc = 0x8000_i32;
for i in (1..=radius).rev() {
let coeff = i32::from(param[i - 1]);
let top = i32::from(get_libass_sample(
source,
width,
height,
x as isize,
center_y - i as isize,
));
let bottom = i32::from(get_libass_sample(
source,
width,
height,
x as isize,
center_y + i as isize,
));
acc += ((top - center) as i16 as i32) * coeff;
acc += ((bottom - center) as i16 as i32) * coeff;
}
dst[y * width + x] = (center + (acc >> 16)) as i16;
}
}
(dst, width, dst_height)
}
fn image_planes_from_absolute_glyphs(
glyphs: &[RasterGlyph],
color: u32,
kind: ass::ImageType,
) -> Vec<ImagePlane> {
glyphs
.iter()
.filter_map(|glyph| {
if glyph.width <= 0 || glyph.height <= 0 || glyph.bitmap.is_empty() {
return None;
}
Some(ImagePlane {
size: Size {
width: glyph.width,
height: glyph.height,
},
stride: glyph.stride,
color: rgba_color_from_ass(color),
destination: Point {
x: glyph.left,
y: glyph.top - glyph.height,
},
kind,
bitmap: glyph.bitmap.clone(),
})
})
.collect()
}
fn drawing_baseline_ascender(style: &ParsedSpanStyle, _render_scale_y: f64) -> i32 {
let scale_y = style_scale(style.scale_y);
(style.font_size.max(1.0) * scale_y * 0.75).round() as i32
}
#[derive(Clone, Copy, Debug)]
struct DrawingPlaneParams {
origin_x: i32,
line_top: i32,
color: u32,
scale_x: f64,
scale_y: f64,
render_scale: RenderScale,
baseline_offset: f64,
}
fn image_plane_from_drawing(
drawing: &ParsedDrawing,
params: DrawingPlaneParams,
) -> Option<ImagePlane> {
let polygons = scaled_drawing_polygons(
drawing,
params.scale_x,
params.scale_y,
params.render_scale.x,
params.render_scale.y,
);
let bounds = drawing_bounds(&polygons)?;
let width = bounds.width();
let height = bounds.height();
if width <= 0 || height <= 0 {
return None;
}
let stride = width as usize;
let mut bitmap = vec![0_u8; stride * height as usize];
let mut any_visible = false;
for row in 0..height as usize {
for column in 0..width as usize {
let x = bounds.x_min + column as i32;
let y = bounds.y_min + row as i32;
if polygons
.iter()
.any(|polygon| point_in_polygon(x, y, polygon))
{
bitmap[row * stride + column] = 255;
any_visible = true;
}
}
}
let pbo_pixels = (params.baseline_offset * params.render_scale.y).round() as i32;
let vertical_offset = pbo_pixels.max(0);
any_visible.then_some(ImagePlane {
size: Size { width, height },
stride: width,
color: rgba_color_from_ass(params.color),
destination: Point {
x: params.origin_x + bounds.x_min,
y: params.line_top + bounds.y_min + vertical_offset,
},
kind: ass::ImageType::Character,
bitmap,
})
}
fn scaled_drawing_polygons(
drawing: &ParsedDrawing,
scale_x: f64,
scale_y: f64,
render_scale_x: f64,
render_scale_y: f64,
) -> Vec<Vec<Point>> {
let scale_x = style_scale(scale_x) * render_scale_x;
let scale_y = style_scale(scale_y) * render_scale_y;
if (scale_x - 1.0).abs() < f64::EPSILON && (scale_y - 1.0).abs() < f64::EPSILON {
return drawing.polygons.clone();
}
drawing
.polygons
.iter()
.map(|polygon| {
polygon
.iter()
.map(|point| Point {
x: (f64::from(point.x) * scale_x).round() as i32,
y: (f64::from(point.y) * scale_y).round() as i32,
})
.collect()
})
.collect()
}
fn drawing_bounds(polygons: &[Vec<Point>]) -> Option<Rect> {
let mut points = polygons.iter().flat_map(|polygon| polygon.iter().copied());
let first = points.next()?;
let mut x_min = first.x;
let mut y_min = first.y;
let mut x_max = first.x;
let mut y_max = first.y;
for point in points {
x_min = x_min.min(point.x);
y_min = y_min.min(point.y);
x_max = x_max.max(point.x);
y_max = y_max.max(point.y);
}
Some(Rect {
x_min,
y_min,
x_max: x_max + 1,
y_max: y_max + 1,
})
}
fn plane_to_raster_glyph(plane: &ImagePlane) -> RasterGlyph {
RasterGlyph {
width: plane.size.width,
height: plane.size.height,
stride: plane.stride,
left: plane.destination.x,
top: plane.destination.y + plane.size.height,
bitmap: plane.bitmap.clone(),
..RasterGlyph::default()
}
}
fn apply_event_clip(planes: Vec<ImagePlane>, clip_rect: Rect, inverse: bool) -> Vec<ImagePlane> {
let mut clipped = Vec::new();
for plane in planes {
if inverse {
clipped.extend(inverse_clip_plane(plane, clip_rect));
} else if let Some(plane) = clip_plane(plane, clip_rect) {
clipped.push(plane);
}
}
clipped
}
fn apply_vector_clip(
planes: Vec<ImagePlane>,
clip: &ParsedVectorClip,
inverse: bool,
) -> Vec<ImagePlane> {
planes
.into_iter()
.filter_map(|plane| mask_plane_with_vector_clip(plane, clip, inverse))
.collect()
}
fn mask_plane_with_vector_clip(
plane: ImagePlane,
clip: &ParsedVectorClip,
inverse: bool,
) -> Option<ImagePlane> {
let mut bitmap = plane.bitmap.clone();
let stride = plane.stride as usize;
let mut any_visible = false;
for row in 0..plane.size.height as usize {
for column in 0..plane.size.width as usize {
let global_x = plane.destination.x + column as i32;
let global_y = plane.destination.y + row as i32;
let inside = clip
.polygons
.iter()
.any(|polygon| point_in_polygon(global_x, global_y, polygon));
let keep = if inverse { !inside } else { inside };
if !keep {
bitmap[row * stride + column] = 0;
} else if bitmap[row * stride + column] > 0 {
any_visible = true;
}
}
}
any_visible.then_some(ImagePlane { bitmap, ..plane })
}
fn point_in_polygon(x: i32, y: i32, polygon: &[Point]) -> bool {
if polygon.len() < 3 {
return false;
}
let mut inside = false;
let mut previous = polygon[polygon.len() - 1];
let sample_x = x as f64 + 0.5;
let sample_y = y as f64 + 0.5;
for ¤t in polygon {
let current_y = current.y as f64;
let previous_y = previous.y as f64;
let intersects = (current_y > sample_y) != (previous_y > sample_y);
if intersects {
let current_x = current.x as f64;
let previous_x = previous.x as f64;
let x_intersection = (previous_x - current_x) * (sample_y - current_y)
/ (previous_y - current_y)
+ current_x;
if sample_x < x_intersection {
inside = !inside;
}
}
previous = current;
}
inside
}
fn clip_plane(plane: ImagePlane, clip_rect: Rect) -> Option<ImagePlane> {
let plane_rect = plane_rect(&plane);
let intersection = plane_rect.intersect(clip_rect)?;
crop_plane_to_rect(plane, intersection)
}
fn inverse_clip_plane(plane: ImagePlane, clip_rect: Rect) -> Vec<ImagePlane> {
let plane_rect = plane_rect(&plane);
let Some(intersection) = plane_rect.intersect(clip_rect) else {
return vec![plane];
};
let mut result = Vec::new();
let regions = [
Rect {
x_min: plane_rect.x_min,
y_min: plane_rect.y_min,
x_max: plane_rect.x_max,
y_max: intersection.y_min,
},
Rect {
x_min: plane_rect.x_min,
y_min: intersection.y_max,
x_max: plane_rect.x_max,
y_max: plane_rect.y_max,
},
Rect {
x_min: plane_rect.x_min,
y_min: intersection.y_min,
x_max: intersection.x_min,
y_max: intersection.y_max,
},
Rect {
x_min: intersection.x_max,
y_min: intersection.y_min,
x_max: plane_rect.x_max,
y_max: intersection.y_max,
},
];
for region in regions {
if region.is_empty() {
continue;
}
if let Some(cropped) = crop_plane_to_rect(plane.clone(), region) {
result.push(cropped);
}
}
result
}
fn plane_rect(plane: &ImagePlane) -> Rect {
Rect {
x_min: plane.destination.x,
y_min: plane.destination.y,
x_max: plane.destination.x + plane.size.width,
y_max: plane.destination.y + plane.size.height,
}
}
fn crop_plane_to_rect(plane: ImagePlane, rect: Rect) -> Option<ImagePlane> {
let plane_rect = plane_rect(&plane);
let rect = plane_rect.intersect(rect)?;
let offset_x = (rect.x_min - plane_rect.x_min) as usize;
let offset_y = (rect.y_min - plane_rect.y_min) as usize;
let width = rect.width() as usize;
let height = rect.height() as usize;
let src_stride = plane.stride as usize;
let mut bitmap = Vec::with_capacity(width * height);
for row in 0..height {
let start = (offset_y + row) * src_stride + offset_x;
bitmap.extend_from_slice(&plane.bitmap[start..start + width]);
}
Some(ImagePlane {
size: Size {
width: rect.width(),
height: rect.height(),
},
stride: rect.width(),
destination: Point {
x: rect.x_min,
y: rect.y_min,
},
bitmap,
..plane
})
}
fn is_event_active(event: &ParsedEvent, now_ms: i64) -> bool {
now_ms >= event.start && now_ms < event.start + event.duration
}
#[cfg(test)]
mod tests {
use super::*;
use rassa_fonts::{FontconfigProvider, NullFontProvider};
use rassa_parse::parse_script_text;
fn config(
frame_width: i32,
frame_height: i32,
margins: rassa_core::Margins,
use_margins: bool,
) -> RendererConfig {
RendererConfig {
frame: Size {
width: frame_width,
height: frame_height,
},
margins,
use_margins,
..RendererConfig::default()
}
}
fn total_plane_area(planes: &[ImagePlane]) -> i32 {
planes
.iter()
.map(|plane| plane.size.width * plane.size.height)
.sum()
}
#[test]
fn fad_uses_libass_truncating_alpha_interpolation() {
let event = ParsedEvent {
start: 0,
duration: 4000,
..ParsedEvent::default()
};
assert_eq!(
compute_fad_alpha(
ParsedFade::Simple {
fade_in_ms: 1000,
fade_out_ms: 1000,
},
Some(&event),
500,
),
127
);
assert_eq!(
compute_fad_alpha(
ParsedFade::Simple {
fade_in_ms: 1000,
fade_out_ms: 1000,
},
Some(&event),
3500,
),
127
);
}
#[test]
fn fad_uses_libass_wrapping_out_start_when_fade_out_exceeds_duration() {
let event = ParsedEvent {
start: 0,
duration: 800,
..ParsedEvent::default()
};
assert_eq!(
compute_fad_alpha(
ParsedFade::Simple {
fade_in_ms: 100,
fade_out_ms: 1000,
},
Some(&event),
100,
),
76
);
assert_eq!(
compute_fad_alpha(
ParsedFade::Simple {
fade_in_ms: 100,
fade_out_ms: 1000,
},
Some(&event),
400,
),
153
);
}
#[test]
fn fade_alpha_combines_with_existing_colour_alpha() {
assert_eq!(with_fade_alpha(0xFF00_0080, 0), 0xFF00_0080);
assert_eq!(with_fade_alpha(0xFF00_0000, 127), 0xFF00_007F);
assert_eq!(with_fade_alpha(0xFF00_0080, 127), 0xFF00_00BF);
}
fn vertical_span(planes: &[ImagePlane]) -> i32 {
let min_y = planes
.iter()
.map(|plane| plane.destination.y)
.min()
.expect("plane");
let max_y = planes
.iter()
.map(|plane| plane.destination.y + plane.size.height)
.max()
.expect("plane");
max_y - min_y
}
fn kind_bounds(planes: &[ImagePlane], kind: ass::ImageType) -> Option<Rect> {
let mut matching_planes = planes.iter().filter(|plane| plane.kind == kind);
let first = matching_planes.next()?;
let mut bounds = Rect {
x_min: first.destination.x,
y_min: first.destination.y,
x_max: first.destination.x + first.size.width,
y_max: first.destination.y + first.size.height,
};
for plane in matching_planes {
bounds.x_min = bounds.x_min.min(plane.destination.x);
bounds.y_min = bounds.y_min.min(plane.destination.y);
bounds.x_max = bounds.x_max.max(plane.destination.x + plane.size.width);
bounds.y_max = bounds.y_max.max(plane.destination.y + plane.size.height);
}
Some(bounds)
}
fn character_bounds(planes: &[ImagePlane]) -> Option<Rect> {
kind_bounds(planes, ass::ImageType::Character)
}
fn visible_bounds(planes: &[ImagePlane]) -> Option<Rect> {
let mut bounds: Option<Rect> = None;
for plane in planes {
let stride = plane.stride.max(0) as usize;
if stride == 0 {
continue;
}
for y in 0..plane.size.height.max(0) as usize {
for x in 0..plane.size.width.max(0) as usize {
if plane.bitmap[y * stride + x] == 0 {
continue;
}
let px = plane.destination.x + x as i32;
let py = plane.destination.y + y as i32;
match &mut bounds {
Some(rect) => {
rect.x_min = rect.x_min.min(px);
rect.y_min = rect.y_min.min(py);
rect.x_max = rect.x_max.max(px + 1);
rect.y_max = rect.y_max.max(py + 1);
}
None => {
bounds = Some(Rect {
x_min: px,
y_min: py,
x_max: px + 1,
y_max: py + 1,
});
}
}
}
}
}
bounds
}
fn drawing_alignment_script(
alignment: i32,
override_tags: &str,
event_margins: &str,
) -> String {
format!(
"[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"
)
}
fn render_drawing_bounds(script: &str) -> Rect {
let track = parse_script_text(script).expect("alignment probe script should parse");
let engine = RenderEngine::new();
let provider = NullFontProvider;
let planes = engine.render_frame_with_provider(&track, &provider, 500);
visible_bounds(&planes).expect("drawing probe should produce visible pixels")
}
fn text_alignment_script(alignment: i32, event_margins: &str) -> String {
format!(
"[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"
)
}
fn render_text_bounds(script: &str) -> Option<Rect> {
let track = parse_script_text(script).expect("text alignment probe script should parse");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let planes = engine.render_frame_with_provider(&track, &provider, 500);
visible_bounds(&planes)
}
fn render_text_bounds_with_config(script: &str, config: &RendererConfig) -> Option<Rect> {
let track = parse_script_text(script).expect("text alignment probe script should parse");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let planes = engine.render_frame_with_provider_and_config(&track, &provider, 500, config);
visible_bounds(&planes)
}
#[test]
fn downscaled_positioned_text_scales_font_and_anchor_like_libass() {
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";
let config = RendererConfig {
frame: Size {
width: 320,
height: 180,
},
storage: Size {
width: 320,
height: 180,
},
pixel_aspect: 1.0,
shaping: ass::ShapingLevel::Complex,
..Default::default()
};
let actual = render_text_bounds_with_config(script, &config)
.expect("positioned text should render in downscaled frame");
let expected = Rect {
x_min: 141,
y_min: 83,
x_max: 179,
y_max: 97,
};
assert!(
(actual.x_min - expected.x_min).abs() <= 2
&& (actual.y_min - expected.y_min).abs() <= 1,
"downscaled \\pos anchor should stay in libass position: actual={actual:?} expected={expected:?}"
);
assert!(
(actual.width() - expected.width()).abs() <= 2
&& (actual.height() - expected.height()).abs() <= 2,
"downscaled \\pos text must scale glyph dimensions like libass: actual={actual:?} expected={expected:?}"
);
}
#[test]
fn borderstyle3_opaque_box_follows_text_transform() {
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";
let track = parse_script_text(script).expect("borderstyle transform script should parse");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let planes = engine.render_frame_with_provider(&track, &provider, 500);
let box_bounds = kind_bounds(&planes, ass::ImageType::Outline)
.expect("BorderStyle=3 should emit an opaque box outline plane");
assert!(
box_bounds.height() > 90,
"opaque box must be transformed with the rotated/sheared text, got bounds {box_bounds:?}"
);
}
#[test]
fn positioned_drawing_an_anchors_match_libass_for_all_alignments() {
// Expected boxes were probed from libass/ffmpeg for a 40x20 vector drawing at \pos(x,y):
// bottom align => y - 20, middle align => y - 10, top align => y.
let cases = [
(
1,
"\\an1\\pos(60,60)",
Rect {
x_min: 60,
y_min: 40,
x_max: 100,
y_max: 60,
},
),
(
2,
"\\an2\\pos(160,60)",
Rect {
x_min: 140,
y_min: 40,
x_max: 180,
y_max: 60,
},
),
(
3,
"\\an3\\pos(260,60)",
Rect {
x_min: 220,
y_min: 40,
x_max: 260,
y_max: 60,
},
),
(
4,
"\\an4\\pos(60,100)",
Rect {
x_min: 60,
y_min: 90,
x_max: 100,
y_max: 110,
},
),
(
5,
"\\an5\\pos(160,100)",
Rect {
x_min: 140,
y_min: 90,
x_max: 180,
y_max: 110,
},
),
(
6,
"\\an6\\pos(260,100)",
Rect {
x_min: 220,
y_min: 90,
x_max: 260,
y_max: 110,
},
),
(
7,
"\\an7\\pos(60,140)",
Rect {
x_min: 60,
y_min: 140,
x_max: 100,
y_max: 160,
},
),
(
8,
"\\an8\\pos(160,140)",
Rect {
x_min: 140,
y_min: 140,
x_max: 180,
y_max: 160,
},
),
(
9,
"\\an9\\pos(260,140)",
Rect {
x_min: 220,
y_min: 140,
x_max: 260,
y_max: 160,
},
),
];
for (alignment, override_tags, expected) in cases {
let script = drawing_alignment_script(alignment, override_tags, "0,0,0");
assert_eq!(
render_drawing_bounds(&script),
expected,
"\\an{alignment} positioned drawing anchor should match libass"
);
}
}
#[test]
fn moved_drawing_an_anchors_match_libass_for_all_alignments_at_midpoint() {
let cases = [
(
1,
"\\an1\\move(40,60,80,60)",
Rect {
x_min: 60,
y_min: 40,
x_max: 100,
y_max: 60,
},
),
(
2,
"\\an2\\move(140,60,180,60)",
Rect {
x_min: 140,
y_min: 40,
x_max: 180,
y_max: 60,
},
),
(
3,
"\\an3\\move(240,60,280,60)",
Rect {
x_min: 220,
y_min: 40,
x_max: 260,
y_max: 60,
},
),
(
4,
"\\an4\\move(40,100,80,100)",
Rect {
x_min: 60,
y_min: 90,
x_max: 100,
y_max: 110,
},
),
(
5,
"\\an5\\move(140,100,180,100)",
Rect {
x_min: 140,
y_min: 90,
x_max: 180,
y_max: 110,
},
),
(
6,
"\\an6\\move(240,100,280,100)",
Rect {
x_min: 220,
y_min: 90,
x_max: 260,
y_max: 110,
},
),
(
7,
"\\an7\\move(40,140,80,140)",
Rect {
x_min: 60,
y_min: 140,
x_max: 100,
y_max: 160,
},
),
(
8,
"\\an8\\move(140,140,180,140)",
Rect {
x_min: 140,
y_min: 140,
x_max: 180,
y_max: 160,
},
),
(
9,
"\\an9\\move(240,140,280,140)",
Rect {
x_min: 220,
y_min: 140,
x_max: 260,
y_max: 160,
},
),
];
for (alignment, override_tags, expected) in cases {
let script = drawing_alignment_script(alignment, override_tags, "0,0,0");
assert_eq!(
render_drawing_bounds(&script),
expected,
"\\an{alignment} moved drawing anchor should match libass at the event midpoint"
);
}
}
#[test]
fn margin_positioned_text_uses_style_and_event_margins_like_libass() {
let cases = [
(
1,
"0,0,0",
Rect {
x_min: 32,
y_min: 138,
x_max: 116,
y_max: 165,
},
),
(
2,
"0,0,0",
Rect {
x_min: 108,
y_min: 138,
x_max: 192,
y_max: 165,
},
),
(
3,
"0,0,0",
Rect {
x_min: 184,
y_min: 138,
x_max: 269,
y_max: 165,
},
),
(
5,
"0,0,0",
Rect {
x_min: 108,
y_min: 79,
x_max: 192,
y_max: 106,
},
),
(
7,
"0,0,0",
Rect {
x_min: 32,
y_min: 20,
x_max: 116,
y_max: 47,
},
),
(
8,
"0,0,0",
Rect {
x_min: 108,
y_min: 20,
x_max: 192,
y_max: 47,
},
),
(
9,
"7,9,11",
Rect {
x_min: 225,
y_min: 16,
x_max: 310,
y_max: 43,
},
),
];
for (alignment, event_margins, expected) in cases {
let script = text_alignment_script(alignment, event_margins);
let Some(actual) = render_text_bounds(&script) else {
return;
};
// Text rasterization can have a few pixels of coverage-width drift from libass even
// with the same Fontconfig face. This regression guards the placement bug: the
// effective style/event margin anchor must no longer be shifted left or sunk.
assert!(
(actual.x_min - expected.x_min).abs() <= 1,
"text style/event margins and \\an{alignment} x placement should match libass within raster rounding: actual={actual:?} expected={expected:?}"
);
assert_eq!(
(actual.y_min, actual.y_max),
(expected.y_min, expected.y_max),
"text style/event margins and \\an{alignment} vertical placement should match libass"
);
}
}
#[test]
fn margin_positioned_drawing_uses_style_and_event_margins_like_libass() {
// Expected boxes were probed from libass/ffmpeg for a 40x20 vector drawing with
// style margins L=30/R=50/V=15. Event margins of 0 should fall back to style margins.
let cases = [
(
1,
Rect {
x_min: 30,
y_min: 145,
x_max: 70,
y_max: 165,
},
),
(
2,
Rect {
x_min: 130,
y_min: 145,
x_max: 170,
y_max: 165,
},
),
(
3,
Rect {
x_min: 230,
y_min: 145,
x_max: 270,
y_max: 165,
},
),
(
4,
Rect {
x_min: 30,
y_min: 80,
x_max: 70,
y_max: 100,
},
),
(
5,
Rect {
x_min: 130,
y_min: 80,
x_max: 170,
y_max: 100,
},
),
(
6,
Rect {
x_min: 230,
y_min: 80,
x_max: 270,
y_max: 100,
},
),
(
7,
Rect {
x_min: 30,
y_min: 15,
x_max: 70,
y_max: 35,
},
),
(
8,
Rect {
x_min: 130,
y_min: 15,
x_max: 170,
y_max: 35,
},
),
(
9,
Rect {
x_min: 230,
y_min: 15,
x_max: 270,
y_max: 35,
},
),
];
for (alignment, expected) in cases {
let script = drawing_alignment_script(alignment, "", "0,0,0");
assert_eq!(
render_drawing_bounds(&script),
expected,
"style margins and \\an{alignment} should match libass when no explicit position exists"
);
}
let script = drawing_alignment_script(7, "", "7,9,11");
assert_eq!(
render_drawing_bounds(&script),
Rect {
x_min: 7,
y_min: 11,
x_max: 47,
y_max: 31
},
"non-zero event margins should override style margins for top-left alignment"
);
}
#[test]
fn projective_transform_keeps_frx_and_fry_axes_distinct() {
let origin = (320.0, 180.0);
let frx = ProjectiveMatrix::from_ass_transform_at_origin(
EventTransform {
rotation_x: 45.0,
..EventTransform::default()
},
origin.0,
origin.1,
1.0,
);
let fry = ProjectiveMatrix::from_ass_transform_at_origin(
EventTransform {
rotation_y: 45.0,
..EventTransform::default()
},
origin.0,
origin.1,
1.0,
);
let (frx_x, frx_y) = frx.transform_point(320.0, 140.0);
let (fry_x, fry_y) = fry.transform_point(360.0, 180.0);
assert!(
(frx_x - 320.0).abs() < 0.5,
"frx must not act like fry: {frx_x}"
);
assert!(
frx_y > 140.0,
"positive frx should pitch the top edge downward: {frx_y}"
);
assert!(
fry_x < 360.0,
"positive fry should yaw the right edge leftward: {fry_x}"
);
assert!(
(fry_y - 180.0).abs() < 0.5,
"fry must not act like frx: {fry_y}"
);
}
#[test]
fn projective_transform_uses_deep_org_as_perspective_lever_arm() {
let transform = EventTransform {
rotation_x: 55.0,
..EventTransform::default()
};
let shallow = ProjectiveMatrix::from_ass_transform_at_origin(transform, 320.0, 240.0, 1.0);
let deep = ProjectiveMatrix::from_ass_transform_at_origin(transform, 320.0, 420.0, 1.0);
let (_, shallow_y) = shallow.transform_point(320.0, 240.0);
let (_, deep_y) = deep.transform_point(320.0, 240.0);
assert!((shallow_y - 240.0).abs() < 0.5);
assert!(
deep_y > shallow_y + 70.0,
"deep \\org below text should pull frx text substantially downward like libass, got shallow={shallow_y} deep={deep_y}"
);
}
#[test]
fn prepare_frame_only_keeps_active_events() {
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");
let engine = RenderEngine::new();
let provider = NullFontProvider;
let frame = engine.prepare_frame(&track, &provider, 500);
assert_eq!(frame.active_events.len(), 1);
assert_eq!(frame.active_events[0].text, "First");
}
#[test]
fn render_frame_produces_image_planes_for_active_text() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let planes = engine.render_frame_with_provider(&track, &provider, 500);
assert!(!planes.is_empty());
assert!(planes.iter().all(|plane| plane.size.width >= 0));
assert!(planes.iter().all(|plane| plane.size.height >= 0));
}
#[test]
fn render_frame_supports_multiple_override_runs() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let planes = engine.render_frame_with_provider(&track, &provider, 500);
assert!(!planes.is_empty());
}
#[test]
fn render_frame_uses_axis_specific_shadow_offsets() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let planes = engine.render_frame_with_provider(&track, &provider, 500);
let character_planes = planes
.iter()
.filter(|plane| plane.kind == ass::ImageType::Character)
.cloned()
.collect::<Vec<_>>();
let shadow_planes = planes
.iter()
.filter(|plane| plane.kind == ass::ImageType::Shadow)
.cloned()
.collect::<Vec<_>>();
let character = visible_bounds(&character_planes).expect("character bounds");
let shadow = visible_bounds(&shadow_planes).expect("axis-specific shadow should render");
assert_eq!(shadow.x_min - character.x_min, 9);
assert_eq!(shadow.y_min - character.y_min, 3);
}
#[test]
fn render_frame_renders_underline_and_strikeout_decorations() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let planes = engine.render_frame_with_provider(&track, &provider, 500);
let decoration_planes = planes
.iter()
.filter(|plane| {
plane.kind == ass::ImageType::Character
&& plane.size.height <= 3
&& plane.size.width > plane.size.height * 4
})
.collect::<Vec<_>>();
assert!(decoration_planes.len() >= 2);
}
#[test]
fn render_frame_uses_override_colors_and_shadow_planes() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let planes = engine.render_frame_with_provider(&track, &provider, 500);
assert!(
planes.iter().any(
|plane| plane.kind == ass::ImageType::Character && plane.color.0 == 0x3322_1100
)
);
assert!(
planes
.iter()
.any(|plane| plane.kind == ass::ImageType::Shadow && plane.color.0 == 0x6655_4400)
);
}
#[test]
fn render_frame_orders_events_by_layer_then_read_order() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let planes = engine.render_frame_with_provider(&track, &provider, 500);
let first_character = planes
.iter()
.find(|plane| plane.kind == ass::ImageType::Character)
.expect("character plane");
assert_eq!(first_character.color.0, 0x00FF_0000);
}
#[test]
fn render_frame_orders_shadow_outline_before_character_within_event() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let planes = engine.render_frame_with_provider(&track, &provider, 500);
let kinds = planes.iter().map(|plane| plane.kind).collect::<Vec<_>>();
let first_shadow = kinds
.iter()
.position(|kind| *kind == ass::ImageType::Shadow)
.expect("shadow plane");
let first_outline = kinds
.iter()
.position(|kind| *kind == ass::ImageType::Outline)
.expect("outline plane");
let first_character = kinds
.iter()
.position(|kind| *kind == ass::ImageType::Character)
.expect("character plane");
assert!(first_shadow < first_outline);
assert!(first_outline < first_character);
}
#[test]
fn render_frame_emits_outline_planes_for_border_override() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let planes = engine.render_frame_with_provider(&track, &provider, 500);
assert!(
planes
.iter()
.any(|plane| plane.kind == ass::ImageType::Outline && plane.color.0 == 0x0C0B_0A00)
);
}
#[test]
fn render_frame_emits_opaque_box_for_border_style_3() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let planes = engine.render_frame_with_provider(&track, &provider, 500);
let character_planes = planes
.iter()
.filter(|plane| plane.kind == ass::ImageType::Character)
.cloned()
.collect::<Vec<_>>();
let outline_planes = planes
.iter()
.filter(|plane| plane.kind == ass::ImageType::Outline)
.cloned()
.collect::<Vec<_>>();
assert_eq!(
outline_planes.len(),
1,
"BorderStyle=3 should emit only the opaque box outline plane, not a separate stroked glyph outline"
);
let _character = visible_bounds(&character_planes).expect("character bounds");
let outline = outline_planes
.iter()
.find(|plane| plane.color.0 == 0x0000_0000 && plane.bitmap.contains(&255))
.expect("opaque border-style box plane uses outline colour");
assert!(outline.size.width > 0);
assert!(outline.size.height > 0);
let bounds = visible_bounds(std::slice::from_ref(outline)).expect("opaque box bounds");
let center_x = (bounds.x_min + bounds.x_max) / 2;
assert!(
(center_x - 250).abs() <= 2,
"opaque box should stay centered at \\pos, got {bounds:?}"
);
let center_y = (bounds.y_min + bounds.y_max) / 2;
assert!(
(center_y - 80).abs() <= 1,
"opaque box should stay vertically centered at \\pos like libass, got {bounds:?}"
);
assert_eq!(
bounds.height(),
36,
"BorderStyle=3 box plane height should be font size plus two borders plus edge rows like libass"
);
assert!(
bounds.width() < 370,
"opaque box should use actual raster advance like libass, not inflated layout width: {bounds:?}"
);
}
#[test]
fn render_frame_blurs_outline_and_shadow_layers() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let planes = engine.render_frame_with_provider(&track, &provider, 500);
assert!(
planes
.iter()
.any(|plane| plane.kind == ass::ImageType::Outline
&& plane.bitmap.iter().any(|value| *value > 0 && *value < 255))
);
assert!(
planes
.iter()
.any(|plane| plane.kind == ass::ImageType::Shadow
&& plane.bitmap.iter().any(|value| *value > 0 && *value < 255))
);
}
#[test]
fn render_frame_blurs_fill_only_without_outline_or_shadow() {
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");
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let base_planes = engine.render_frame_with_provider(&base, &provider, 500);
let blurred_planes = engine.render_frame_with_provider(&blurred, &provider, 500);
let base_character = visible_bounds(
&base_planes
.iter()
.filter(|plane| plane.kind == ass::ImageType::Character)
.cloned()
.collect::<Vec<_>>(),
)
.expect("base character bounds");
let blurred_character = visible_bounds(
&blurred_planes
.iter()
.filter(|plane| plane.kind == ass::ImageType::Character)
.cloned()
.collect::<Vec<_>>(),
)
.expect("blurred character bounds");
assert!(blurred_character.x_min < base_character.x_min);
assert!(blurred_character.x_max > base_character.x_max);
assert!(blurred_character.y_min < base_character.y_min);
assert!(blurred_character.y_max > base_character.y_max);
}
#[test]
fn render_frame_does_not_blur_fill_when_outline_or_shadow_exists() {
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");
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let base_planes = engine.render_frame_with_provider(&base, &provider, 500);
let blurred_planes = engine.render_frame_with_provider(&blurred, &provider, 500);
let character_bounds = |planes: &[ImagePlane]| {
visible_bounds(
&planes
.iter()
.filter(|plane| plane.kind == ass::ImageType::Character)
.cloned()
.collect::<Vec<_>>(),
)
.expect("character bounds")
};
assert_eq!(
character_bounds(&blurred_planes),
character_bounds(&base_planes)
);
assert!(
blurred_planes
.iter()
.filter(|plane| plane.kind == ass::ImageType::Outline)
.any(|plane| plane.bitmap.iter().any(|value| *value > 0 && *value < 255))
);
assert!(
blurred_planes
.iter()
.filter(|plane| plane.kind == ass::ImageType::Shadow)
.any(|plane| plane.bitmap.iter().any(|value| *value > 0 && *value < 255))
);
}
#[test]
fn render_frame_applies_rectangular_clip() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let planes = engine.render_frame_with_provider(&track, &provider, 500);
assert!(!planes.is_empty());
assert!(planes.iter().all(|plane| plane.destination.x >= 0));
assert!(planes.iter().all(|plane| plane.destination.y >= 0));
assert!(
planes
.iter()
.all(|plane| plane.destination.x + plane.size.width <= 64)
);
assert!(
planes
.iter()
.all(|plane| plane.destination.y + plane.size.height <= 64)
);
}
#[test]
fn render_frame_accepts_renderer_shaping_mode() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let simple = engine.render_frame_with_provider_and_config(
&track,
&provider,
500,
&RendererConfig {
shaping: ass::ShapingLevel::Simple,
..default_renderer_config(&track)
},
);
let complex = engine.render_frame_with_provider_and_config(
&track,
&provider,
500,
&RendererConfig {
shaping: ass::ShapingLevel::Complex,
..default_renderer_config(&track)
},
);
assert!(!simple.is_empty());
assert!(!complex.is_empty());
}
#[test]
fn render_frame_applies_inverse_rectangular_clip() {
let plane = ImagePlane {
size: Size {
width: 6,
height: 4,
},
stride: 6,
color: RgbaColor(0x00FF_FFFF),
destination: Point { x: 0, y: 0 },
kind: ass::ImageType::Character,
bitmap: vec![255; 24],
};
let parts = inverse_clip_plane(
plane,
Rect {
x_min: 2,
y_min: 1,
x_max: 4,
y_max: 3,
},
);
assert_eq!(parts.len(), 4);
assert_eq!(
parts.iter().map(|plane| plane.bitmap.len()).sum::<usize>(),
20
);
}
#[test]
fn inverse_clip_bleed_covers_outline_growth_to_prevent_stray_glyph_leakage() {
let style = ParsedSpanStyle {
border: 5.0,
border_x: 5.0,
border_y: 5.0,
shadow: 0.0,
shadow_x: 0.0,
shadow_y: 0.0,
blur: 0.0,
be: 0.0,
..ParsedSpanStyle::default()
};
let clip = Rect {
x_min: 20,
y_min: 0,
x_max: 24,
y_max: 10,
};
let glyph = ImagePlane {
size: Size {
width: 44,
height: 10,
},
stride: 44,
color: RgbaColor(0x00FF_FFFF),
destination: Point { x: 0, y: 0 },
kind: ass::ImageType::Outline,
bitmap: vec![255; 440],
};
let expanded = expand_rect(clip, style_clip_bleed(&style));
let parts = inverse_clip_plane(glyph, expanded);
assert!(
parts
.iter()
.all(|plane| plane.destination.x + plane.size.width <= 0
|| plane.destination.x >= 44),
"inverse clip must mask outline bleed around the nominal clip, got {parts:?}"
);
}
#[test]
fn render_frame_applies_vector_clip() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let planes = engine.render_frame_with_provider(&track, &provider, 500);
assert!(!planes.is_empty());
assert!(
planes
.iter()
.all(|plane| plane.bitmap.iter().any(|value| *value > 0))
);
assert!(planes.iter().all(|plane| plane.destination.x >= 0));
assert!(planes.iter().all(|plane| plane.destination.y >= 0));
}
#[test]
fn render_frame_clips_to_frame_bounds() {
let plane = ImagePlane {
size: Size {
width: 20,
height: 20,
},
stride: 20,
color: RgbaColor(0x00FF_FFFF),
destination: Point { x: 50, y: 50 },
kind: ass::ImageType::Character,
bitmap: vec![255; 400],
};
let clipped = apply_event_clip(
vec![plane],
Rect {
x_min: 0,
y_min: 0,
x_max: 60,
y_max: 60,
},
false,
);
assert_eq!(clipped.len(), 1);
assert_eq!(clipped[0].size.width, 10);
assert_eq!(clipped[0].size.height, 10);
}
#[test]
fn render_frame_applies_margin_clip_when_enabled() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let planes = engine.render_frame_with_provider_and_config(
&track,
&provider,
500,
&config(
100,
100,
rassa_core::Margins {
top: 10,
bottom: 10,
left: 10,
right: 10,
},
true,
),
);
assert!(!planes.is_empty());
assert!(planes.iter().all(|plane| plane.destination.x >= 10));
assert!(planes.iter().all(|plane| plane.destination.y >= 10));
assert!(
planes
.iter()
.all(|plane| plane.destination.x + plane.size.width <= 90)
);
assert!(
planes
.iter()
.all(|plane| plane.destination.y + plane.size.height <= 90)
);
}
#[test]
fn render_frame_maps_into_content_area_when_margins_are_not_used() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let planes = engine.render_frame_with_provider_and_config(
&track,
&provider,
500,
&config(
120,
120,
rassa_core::Margins {
top: 10,
bottom: 10,
left: 10,
right: 10,
},
false,
),
);
assert!(!planes.is_empty());
let bounds = visible_bounds(&planes).expect("visible bounds");
assert!(
bounds.x_min >= 10,
"visible bounds should start inside content area: {bounds:?}"
);
assert!(
bounds.y_min >= 9,
"libass-style antialiasing may allocate one guard row above the content area: {bounds:?}"
);
assert!(
bounds.x_max <= 110,
"visible bounds should end inside content area: {bounds:?}"
);
assert!(
bounds.y_max <= 110,
"visible bounds should end inside content area: {bounds:?}"
);
}
#[test]
fn render_frame_keeps_border_closer_to_device_size_when_scaled_border_is_disabled() {
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");
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let config = config(200, 200, rassa_core::Margins::default(), true);
let enabled_planes =
engine.render_frame_with_provider_and_config(&enabled, &provider, 500, &config);
let disabled_planes =
engine.render_frame_with_provider_and_config(&disabled, &provider, 500, &config);
let enabled_outline_area: i32 = enabled_planes
.iter()
.filter(|plane| plane.kind == ass::ImageType::Outline)
.map(|plane| plane.size.width * plane.size.height)
.sum();
let disabled_outline_area: i32 = disabled_planes
.iter()
.filter(|plane| plane.kind == ass::ImageType::Outline)
.map(|plane| plane.size.width * plane.size.height)
.sum();
assert!(disabled_outline_area > 0);
assert!(disabled_outline_area < enabled_outline_area);
}
#[test]
fn render_frame_applies_font_scale_to_output() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let baseline = engine.render_frame_with_provider(&track, &provider, 500);
let scaled = engine.render_frame_with_provider_and_config(
&track,
&provider,
500,
&RendererConfig {
frame: Size {
width: 200,
height: 120,
},
font_scale: 2.0,
..RendererConfig::default()
},
);
assert!(!baseline.is_empty());
assert!(!scaled.is_empty());
assert!(total_plane_area(&scaled) > total_plane_area(&baseline));
}
#[test]
fn render_frame_applies_text_scale_overrides() {
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");
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let baseline = engine.render_frame_with_provider(&track, &provider, 500);
let scaled = engine.render_frame_with_provider(&stretched, &provider, 500);
let baseline_width = baseline
.iter()
.filter(|plane| plane.kind == ass::ImageType::Character)
.map(|plane| plane.destination.x + plane.size.width)
.max()
.expect("baseline max x")
- baseline
.iter()
.filter(|plane| plane.kind == ass::ImageType::Character)
.map(|plane| plane.destination.x)
.min()
.expect("baseline min x");
let scaled_width = scaled
.iter()
.filter(|plane| plane.kind == ass::ImageType::Character)
.map(|plane| plane.destination.x + plane.size.width)
.max()
.expect("scaled max x")
- scaled
.iter()
.filter(|plane| plane.kind == ass::ImageType::Character)
.map(|plane| plane.destination.x)
.min()
.expect("scaled min x");
assert!(scaled_width > baseline_width);
assert!(total_plane_area(&scaled) < total_plane_area(&baseline) * 2);
}
#[test]
fn render_frame_applies_drawing_scale_overrides() {
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");
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let baseline_planes = engine.render_frame_with_provider(&baseline, &provider, 500);
let scaled_planes = engine.render_frame_with_provider(&scaled, &provider, 500);
let baseline_plane = baseline_planes
.iter()
.find(|plane| plane.kind == ass::ImageType::Character)
.expect("baseline drawing plane");
let scaled_plane = scaled_planes
.iter()
.find(|plane| plane.kind == ass::ImageType::Character)
.expect("scaled drawing plane");
assert!(scaled_plane.size.width > baseline_plane.size.width);
assert!(scaled_plane.size.height < baseline_plane.size.height);
assert_eq!(scaled_plane.destination, Point { x: 10, y: 10 });
}
#[test]
fn non_positioned_drawing_does_not_receive_positioned_overhang_compensation() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let plane = engine
.render_frame_with_provider(&track, &provider, 500)
.into_iter()
.find(|plane| plane.kind == ass::ImageType::Character)
.expect("drawing plane");
assert_eq!(
plane.size.width, 11,
"libass-style positioned overhang compensation is specific to explicit \\pos vector drawings"
);
}
#[test]
#[ignore = "parked while rassa stops treating pixel-perfect libass drawing pbo residuals as an optimization blocker"]
fn render_frame_applies_drawing_baseline_offset() {
fn pbo_track(pbo_tag: &str) -> ParsedTrack {
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"))
.expect("script should parse")
}
let baseline = pbo_track("");
let pbo5 = pbo_track("\\pbo5");
let shifted = pbo_track("\\pbo12");
let negative = pbo_track("\\pbo-12");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let drawing_plane = |track: &ParsedTrack| {
engine
.render_frame_with_provider(track, &provider, 500)
.into_iter()
.find(|plane| {
plane.kind == ass::ImageType::Character
&& plane.size.width == 11
&& plane.size.height == 11
})
.expect("drawing plane")
};
let baseline_drawing = drawing_plane(&baseline);
let pbo5_drawing = drawing_plane(&pbo5);
let shifted_drawing = drawing_plane(&shifted);
let negative_drawing = drawing_plane(&negative);
assert_eq!(
pbo5_drawing.destination, baseline_drawing.destination,
"libass keeps pbo below drawing height anchored for this 10-unit positioned drawing"
);
assert_eq!(
shifted_drawing.destination.x,
baseline_drawing.destination.x
);
assert_eq!(
shifted_drawing.destination.y,
baseline_drawing.destination.y + 2,
"libass applies \\pbo as max(pbo - drawing_height, 0) for this top-anchored positioned drawing"
);
assert_eq!(
negative_drawing.destination, baseline_drawing.destination,
"libass keeps negative \\pbo top-anchored for this positioned drawing"
);
}
#[test]
fn render_frame_applies_banner_effect_motion() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let early = character_bounds(&engine.render_frame_with_provider(&track, &provider, 100))
.expect("early banner bounds");
let late = character_bounds(&engine.render_frame_with_provider(&track, &provider, 1500))
.expect("late banner bounds");
assert!(
late.x_min < early.x_min,
"right-to-left banner should move left over time"
);
assert!(
(194..=198).contains(&early.x_min),
"libass positions a right-to-left banner by PlayResX - elapsed/delay, got {early:?}"
);
}
#[test]
fn banner_effect_delay_uses_layout_scale_not_render_supersampling() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let bounds = character_bounds(&engine.render_frame_with_provider_and_config(
&track,
&provider,
1500,
&RendererConfig {
frame: Size {
width: 1600,
height: 800,
},
storage: Size {
width: 200,
height: 100,
},
..RendererConfig::default()
},
))
.expect("supersampled banner bounds");
assert!(
bounds.x_min >= 1112,
"Banner delay should be based on layout/storage resolution rather than render supersampling; got {bounds:?}"
);
}
#[test]
fn render_frame_applies_scroll_effect_motion() {
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");
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let up_early = character_bounds(&engine.render_frame_with_provider(&up, &provider, 100))
.expect("early scroll-up bounds");
let up_late = character_bounds(&engine.render_frame_with_provider(&up, &provider, 1500))
.expect("late scroll-up bounds");
let down_early =
character_bounds(&engine.render_frame_with_provider(&down, &provider, 100))
.expect("early scroll-down bounds");
let down_late =
character_bounds(&engine.render_frame_with_provider(&down, &provider, 1500))
.expect("late scroll-down bounds");
assert!(
up_late.y_min < up_early.y_min,
"scroll up should move upward"
);
assert!(
down_late.y_min > down_early.y_min,
"scroll down should move downward"
);
}
#[test]
fn render_frame_applies_text_spacing_override() {
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");
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let baseline_planes = engine.render_frame_with_provider(&baseline, &provider, 500);
let spaced_planes = engine.render_frame_with_provider(&spaced, &provider, 500);
let baseline_width = character_bounds(&baseline_planes)
.expect("baseline bounds")
.width();
let spaced_width = character_bounds(&spaced_planes)
.expect("spaced bounds")
.width();
assert!(spaced_width > baseline_width);
}
#[test]
fn render_frame_scales_output_to_frame_size() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let baseline = engine.render_frame_with_provider(&track, &provider, 500);
let scaled = engine.render_frame_with_provider_and_config(
&track,
&provider,
500,
&RendererConfig {
frame: Size {
width: 400,
height: 240,
},
..default_renderer_config(&track)
},
);
assert!(total_plane_area(&baseline) > 0);
assert!(total_plane_area(&scaled) > total_plane_area(&baseline));
}
#[test]
fn render_frame_applies_pixel_aspect_horizontally() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let baseline = engine.render_frame_with_provider_and_config(
&track,
&provider,
500,
&RendererConfig {
frame: Size {
width: 400,
height: 120,
},
..default_renderer_config(&track)
},
);
let widened = engine.render_frame_with_provider_and_config(
&track,
&provider,
500,
&RendererConfig {
frame: Size {
width: 400,
height: 120,
},
pixel_aspect: 2.0,
..default_renderer_config(&track)
},
);
let baseline_bounds = character_bounds(&baseline).expect("baseline character bounds");
let widened_bounds = character_bounds(&widened).expect("widened character bounds");
assert!(
widened_bounds.x_min > baseline_bounds.x_min,
"pixel aspect should affect horizontal placement: baseline={baseline_bounds:?} widened={widened_bounds:?}"
);
}
#[test]
fn render_frame_derives_pixel_aspect_from_storage_size_when_unset() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let baseline = engine.render_frame_with_provider_and_config(
&track,
&provider,
500,
&RendererConfig {
frame: Size {
width: 400,
height: 240,
},
..default_renderer_config(&track)
},
);
let storage_adjusted = engine.render_frame_with_provider_and_config(
&track,
&provider,
500,
&RendererConfig {
frame: Size {
width: 400,
height: 240,
},
storage: Size {
width: 400,
height: 120,
},
..default_renderer_config(&track)
},
);
assert!(total_plane_area(&baseline) > 0);
assert!(total_plane_area(&storage_adjusted) < total_plane_area(&baseline));
}
#[test]
fn render_frame_layout_resolution_takes_precedence_over_storage_and_explicit_aspect() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let baseline = engine.render_frame_with_provider_and_config(
&track,
&provider,
500,
&RendererConfig {
frame: Size {
width: 400,
height: 240,
},
..default_renderer_config(&track)
},
);
let overridden_inputs = engine.render_frame_with_provider_and_config(
&track,
&provider,
500,
&RendererConfig {
frame: Size {
width: 400,
height: 240,
},
storage: Size {
width: 400,
height: 120,
},
pixel_aspect: 2.0,
..default_renderer_config(&track)
},
);
assert_eq!(
total_plane_area(&overridden_inputs),
total_plane_area(&baseline)
);
}
#[test]
fn render_frame_applies_line_position_to_subtitles() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let baseline = engine.render_frame_with_provider(&track, &provider, 500);
let shifted = engine.render_frame_with_provider_and_config(
&track,
&provider,
500,
&RendererConfig {
frame: Size {
width: 200,
height: 120,
},
line_position: 50.0,
..RendererConfig::default()
},
);
let baseline_y = baseline
.iter()
.map(|plane| plane.destination.y)
.min()
.expect("baseline plane");
let shifted_y = shifted
.iter()
.map(|plane| plane.destination.y)
.min()
.expect("shifted plane");
assert!(shifted_y < baseline_y);
}
#[test]
fn render_frame_applies_line_spacing_to_multiline_subtitles() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let baseline = engine.render_frame_with_provider(&track, &provider, 500);
let spaced = engine.render_frame_with_provider_and_config(
&track,
&provider,
500,
&RendererConfig {
frame: Size {
width: 200,
height: 140,
},
line_spacing: 20.0,
..RendererConfig::default()
},
);
assert!(vertical_span(&spaced) > vertical_span(&baseline));
}
#[test]
fn render_frame_avoids_basic_bottom_collision_for_unpositioned_events() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let planes = engine.render_frame_with_provider(&track, &provider, 500);
let mut ys = planes
.iter()
.filter(|plane| plane.kind == ass::ImageType::Character)
.map(|plane| plane.destination.y)
.collect::<Vec<_>>();
ys.sort_unstable();
ys.dedup();
assert!(ys.len() >= 2);
assert!(ys.last().expect("max y") - ys.first().expect("min y") >= 20);
}
#[test]
fn render_frame_allows_basic_collision_across_different_layers() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let planes = engine.render_frame_with_provider(&track, &provider, 500);
let layer0_y = planes
.iter()
.filter(|plane| plane.kind == ass::ImageType::Character && plane.color.0 == 0xFF00_0000)
.map(|plane| plane.destination.y)
.min()
.expect("layer 0 character plane");
let layer1_y = planes
.iter()
.filter(|plane| plane.kind == ass::ImageType::Character && plane.color.0 == 0x00FF_0000)
.map(|plane| plane.destination.y)
.min()
.expect("layer 1 character plane");
assert_eq!(layer0_y, layer1_y);
}
#[test]
fn render_frame_interpolates_move_position() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let start_planes = engine.render_frame_with_provider(&track, &provider, 0);
let mid_planes = engine.render_frame_with_provider(&track, &provider, 500);
let end_planes = engine.render_frame_with_provider(&track, &provider, 999);
let start_x = start_planes
.iter()
.map(|plane| plane.destination.x)
.min()
.expect("start plane");
let mid_x = mid_planes
.iter()
.map(|plane| plane.destination.x)
.min()
.expect("mid plane");
let end_x = end_planes
.iter()
.map(|plane| plane.destination.x)
.min()
.expect("end plane");
assert!(start_x <= mid_x);
assert!(mid_x <= end_x);
assert!(end_x - start_x >= 80);
}
#[test]
fn render_frame_applies_z_rotation_to_event_planes() {
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");
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let baseline_planes = engine.render_frame_with_provider(&baseline, &provider, 500);
let rotated_planes = engine.render_frame_with_provider(&rotated, &provider, 500);
let baseline_bounds = character_bounds(&baseline_planes).expect("baseline bounds");
let rotated_bounds = character_bounds(&rotated_planes).expect("rotated bounds");
assert!(baseline_bounds.width() > baseline_bounds.height());
assert!(rotated_bounds.height() > rotated_bounds.width());
}
#[test]
#[ignore = "strict libass positioned-vector overhang coverage residual kept as diagnostic after optimization pivot"]
fn positioned_drawing_uses_position_y_before_compare_supersample_offset() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let planes = engine.render_frame_with_provider_and_config(
&track,
&provider,
500,
&RendererConfig {
frame: Size {
width: 1760,
height: 1120,
},
storage: Size {
width: 220,
height: 140,
},
..RendererConfig::default()
},
);
let bounds = character_bounds(&planes).expect("positioned drawing bounds");
let visible = visible_bounds(&planes).expect("positioned drawing visible bounds");
assert_eq!(
bounds.y_min,
24 * 8,
"libass keeps top-aligned positioned vector drawings anchored at \\pos y before final supersample offset; got {bounds:?}"
);
assert_eq!(
bounds.x_min,
19 * 8,
"libass gives positioned vector drawings one output-pixel left overhang at compare superscale; got {bounds:?}"
);
assert_eq!(
bounds.x_max,
63 * 8,
"libass keeps the allocated right drawing edge available for transforms; got {bounds:?}"
);
assert_eq!(
visible.x_min,
19 * 8 + 7,
"libass leaves only a subpixel-thin antialias sample in the positioned drawing's left overhang; got visible {visible:?}"
);
assert_eq!(
visible.x_max,
62 * 8 + 1,
"positioned vector drawing keeps a subpixel-thin antialias sample in the allocated right overhang; got visible {visible:?}"
);
}
#[test]
fn render_frame_shears_positioned_drawing_from_run_baseline_not_org() {
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}")
.expect("script should parse");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let planes = engine.render_frame_with_provider(&track, &provider, 500);
let bounds = planes_bounds(&planes).expect("drawing plane should render");
assert!(
bounds.x_min >= 116,
"libass applies \\fax in drawing-local baseline space before \\org perspective; global \\org shear pulls this too far left: {bounds:?}"
);
}
#[test]
fn render_frame_applies_z_rotation_per_override_run() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let planes = engine.render_frame_with_provider(&track, &provider, 500);
let red_planes = planes
.iter()
.filter(|plane| plane.kind == ass::ImageType::Character && plane.color.0 == 0xFF00_0000)
.collect::<Vec<_>>();
let green = planes
.iter()
.find(|plane| plane.kind == ass::ImageType::Character && plane.color.0 == 0x00FF_0000)
.expect("rotated green drawing plane");
assert!(
red_planes.len() >= 2,
"expected multiple unrotated red glyph planes"
);
let red_y_min = red_planes
.iter()
.map(|plane| plane.destination.y)
.min()
.expect("red y min");
let red_y_max = red_planes
.iter()
.map(|plane| plane.destination.y)
.max()
.expect("red y max");
assert!(
red_y_max - red_y_min <= 1,
"unrotated run should stay on a horizontal baseline: {red_planes:?}"
);
assert!(
green.size.height >= green.size.width,
"rotated run should become vertical-ish: {green:?}"
);
}
#[test]
fn render_frame_interpolates_z_rotation_transform() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let start_planes = engine.render_frame_with_provider(&track, &provider, 0);
let end_planes = engine.render_frame_with_provider(&track, &provider, 999);
let start_bounds = character_bounds(&start_planes).expect("start bounds");
let end_bounds = character_bounds(&end_planes).expect("end bounds");
assert!(start_bounds.width() > start_bounds.height());
assert!(end_bounds.height() > end_bounds.width());
}
#[test]
fn render_frame_applies_fad_alpha() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let start_planes = engine.render_frame_with_provider(&track, &provider, 0);
let mid_planes = engine.render_frame_with_provider(&track, &provider, 500);
let end_planes = engine.render_frame_with_provider(&track, &provider, 999);
let start_alpha = start_planes
.iter()
.map(|plane| plane.color.0 & 0xFF)
.max()
.expect("start alpha");
let mid_alpha = mid_planes
.iter()
.map(|plane| plane.color.0 & 0xFF)
.max()
.expect("mid alpha");
let end_alpha = end_planes
.iter()
.map(|plane| plane.color.0 & 0xFF)
.max()
.expect("end alpha");
assert!(start_alpha > mid_alpha);
assert!(end_alpha > mid_alpha);
}
#[test]
fn render_frame_applies_full_fade_alpha() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let start_planes = engine.render_frame_with_provider(&track, &provider, 0);
let middle_planes = engine.render_frame_with_provider(&track, &provider, 400);
let late_planes = engine.render_frame_with_provider(&track, &provider, 850);
let start_alpha = start_planes
.iter()
.map(|plane| plane.color.0 & 0xFF)
.max()
.expect("start alpha");
let middle_alpha = middle_planes
.iter()
.map(|plane| plane.color.0 & 0xFF)
.max()
.expect("middle alpha");
let late_alpha = late_planes
.iter()
.map(|plane| plane.color.0 & 0xFF)
.max()
.expect("late alpha");
assert!(start_alpha > middle_alpha);
assert!(late_alpha > middle_alpha);
assert!(late_alpha < start_alpha);
}
#[test]
fn render_frame_switches_karaoke_fill_after_elapsed_span() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let early_planes = engine.render_frame_with_provider(&track, &provider, 200);
let late_planes = engine.render_frame_with_provider(&track, &provider, 700);
assert!(
early_planes.iter().any(
|plane| plane.kind == ass::ImageType::Character && plane.color.0 == 0x6655_4400
)
);
assert!(
late_planes.iter().any(
|plane| plane.kind == ass::ImageType::Character && plane.color.0 == 0x3322_1100
)
);
}
#[test]
fn render_frame_sweeps_karaoke_fill_during_active_span() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let mid_planes = engine.render_frame_with_provider(&track, &provider, 500);
assert!(
mid_planes.iter().any(
|plane| plane.kind == ass::ImageType::Character && plane.color.0 == 0x3322_1100
)
);
assert!(
mid_planes.iter().any(
|plane| plane.kind == ass::ImageType::Character && plane.color.0 == 0x6655_4400
)
);
}
#[test]
fn render_frame_hides_outline_for_ko_until_span_ends() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let early_planes = engine.render_frame_with_provider(&track, &provider, 200);
let late_planes = engine.render_frame_with_provider(&track, &provider, 700);
assert!(
!early_planes
.iter()
.any(|plane| plane.kind == ass::ImageType::Outline)
);
assert!(
late_planes
.iter()
.any(|plane| plane.kind == ass::ImageType::Outline)
);
}
#[test]
fn render_frame_renders_drawing_plane() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let planes = engine.render_frame_with_provider(&track, &provider, 500);
assert!(
planes.iter().any(
|plane| plane.kind == ass::ImageType::Character && plane.color.0 == 0x3322_1100
)
);
let plane = planes
.iter()
.find(|plane| plane.kind == ass::ImageType::Character)
.expect("drawing plane");
assert_eq!(plane.destination.x, 10);
assert_eq!(plane.destination.y, 10);
assert!(plane.bitmap.contains(&255));
}
#[test]
fn render_frame_renders_bezier_drawing_plane() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let planes = engine.render_frame_with_provider(&track, &provider, 500);
let plane = planes
.iter()
.find(|plane| plane.kind == ass::ImageType::Character)
.expect("drawing plane");
assert!(plane.bitmap.contains(&255));
assert!(plane.size.width >= 8);
assert!(plane.size.height >= 8);
}
#[test]
fn render_frame_emits_outline_and_shadow_for_drawings() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let planes = engine.render_frame_with_provider(&track, &provider, 500);
assert!(
planes
.iter()
.any(|plane| plane.kind == ass::ImageType::Outline && plane.color.0 == 0x0C0B_0A00)
);
assert!(
planes
.iter()
.any(|plane| plane.kind == ass::ImageType::Shadow && plane.color.0 == 0x6655_4400)
);
}
#[test]
fn render_frame_renders_spline_drawing_plane() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let planes = engine.render_frame_with_provider(&track, &provider, 500);
let plane = planes
.iter()
.find(|plane| plane.kind == ass::ImageType::Character)
.expect("drawing plane");
assert!(plane.bitmap.contains(&255));
assert!(plane.size.width >= 10);
assert!(plane.size.height >= 10);
}
#[test]
fn render_frame_renders_non_closing_move_subpaths() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let planes = engine.render_frame_with_provider(&track, &provider, 500);
let plane = planes
.iter()
.find(|plane| plane.kind == ass::ImageType::Character)
.expect("drawing plane");
assert!(plane.bitmap.contains(&255));
assert!(plane.size.width >= 28);
assert!(plane.size.height >= 28);
}
#[test]
fn render_frame_applies_timed_transform_style() {
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");
let engine = RenderEngine::new();
let provider = FontconfigProvider::new();
let start_planes = engine.render_frame_with_provider(&track, &provider, 0);
let mid_planes = engine.render_frame_with_provider(&track, &provider, 500);
let end_planes = engine.render_frame_with_provider(&track, &provider, 999);
assert!(
!start_planes
.iter()
.any(|plane| plane.kind == ass::ImageType::Outline)
);
assert!(
mid_planes
.iter()
.any(|plane| plane.kind == ass::ImageType::Outline)
);
assert!(
end_planes
.iter()
.any(|plane| plane.kind == ass::ImageType::Outline)
);
let start_fill = start_planes
.iter()
.find(|plane| plane.kind == ass::ImageType::Character)
.expect("start fill")
.color
.0;
let end_fill = end_planes
.iter()
.find(|plane| plane.kind == ass::ImageType::Character)
.expect("end fill")
.color
.0;
assert_ne!(start_fill, end_fill);
assert!(total_plane_area(&end_planes) > total_plane_area(&start_planes));
}
}