diff --git a/Cargo.lock b/Cargo.lock index 0b2244b..01f76e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -254,6 +254,7 @@ dependencies = [ "image 0.24.9", "log", "metal", + "mini-moka-wasm", "pkg-config", "pollster 0.3.0", "pretty_assertions", diff --git a/crates/ass-renderer/Cargo.toml b/crates/ass-renderer/Cargo.toml index 594b694..891bd32 100644 --- a/crates/ass-renderer/Cargo.toml +++ b/crates/ass-renderer/Cargo.toml @@ -29,6 +29,9 @@ trait-variant = "0.1" # Logging log = "0.4" +# Caching +mini-moka-wasm = { git = "https://tangled.org/nonbinary.computer/jacquard", branch = "main" } + # Utilities smallvec = "1.14" thiserror = "1.0" @@ -92,7 +95,6 @@ vulkan = ["ash", "hardware-backend"] metal = ["dep:metal", "hardware-backend"] simd = ["ass-core/simd", "tiny-skia/simd"] -arena = ["ass-core/arena"] wasm = ["web-backend"] diff --git a/crates/ass-renderer/src/backends/software.rs b/crates/ass-renderer/src/backends/software.rs index 95b4a94..1729bd3 100644 --- a/crates/ass-renderer/src/backends/software.rs +++ b/crates/ass-renderer/src/backends/software.rs @@ -1,5 +1,17 @@ //! Software (CPU) rendering backend using tiny-skia +// The software backend uses `bytes::BytesMut`, `std::sync::Mutex`, and +// `bumpalo` unconditionally. These all require std and are not compatible +// with the `nostd` feature. If you need a no_std-compatible backend, +// a separate implementation would be required. +#[cfg(feature = "nostd")] +compile_error!( + "The software backend (software.rs) is not compatible with the `nostd` feature. \ + It uses `bytes::BytesMut`, `std::sync::Mutex`, and `bumpalo`, which all require std. \ + Either disable the software-backend feature when building with nostd, or contribute a \ + no_std-compatible alternative." +); + #[cfg(feature = "nostd")] use alloc::{boxed::Box, format, sync::Arc, vec, vec::Vec}; #[cfg(not(feature = "nostd"))] @@ -16,8 +28,6 @@ use tiny_skia::{Pixmap, PixmapMut, Transform}; /// Software rendering backend using tiny-skia pub struct SoftwareBackend { frame_buf: BytesMut, - frame_width: u32, - frame_height: u32, arena: std::sync::Mutex, font_db: Arc, glyph_renderer: crate::pipeline::shaping::GlyphRenderer, @@ -38,8 +48,6 @@ impl SoftwareBackend { let needed = (w * h * 4) as usize; Ok(Self { frame_buf: BytesMut::zeroed(needed), - frame_width: w, - frame_height: h, arena: std::sync::Mutex::new(bumpalo::Bump::new()), font_db: context.font_db_arc(), glyph_renderer: crate::pipeline::shaping::GlyphRenderer::new(), @@ -56,8 +64,6 @@ impl SoftwareBackend { } let needed = (width * height * 4) as usize; self.frame_buf = BytesMut::zeroed(needed); - self.frame_width = width; - self.frame_height = height; Ok(()) } @@ -224,581 +230,562 @@ fn draw_text_layer( arena: &bumpalo::Bump, data: &crate::pipeline::TextData, ) -> Result<(), RenderError> { - use crate::pipeline::shaping::shape_text_with_style; - - // Extract bold/italic from effects - let bold = data - .effects - .iter() - .any(|e| matches!(e, crate::pipeline::TextEffect::Bold)); - let italic = data - .effects - .iter() - .any(|e| matches!(e, crate::pipeline::TextEffect::Italic)); - let underline = data - .effects - .iter() - .any(|e| matches!(e, crate::pipeline::TextEffect::Underline)); - let strikethrough = data - .effects - .iter() - .any(|e| matches!(e, crate::pipeline::TextEffect::Strikethrough)); - - // Shape this segment's text independently. - // This matches libass's model: style changes create run boundaries, - // each run is shaped independently via HarfBuzz/rustybuzz. - let cache_key = TextCacheKey { - text: data.text.clone(), - font_family: data.font_family.clone(), - font_size: data.font_size as u32, - bold, - italic, - }; - - let shaped = if let Some(cached) = cache.get_shaped_text(&cache_key) { - cached - } else { - let shaped_text = shape_text_with_style( - &data.text, - &data.font_family, - data.font_size, - bold, - italic, - font_db, - )?; - cache.store_shaped_text(cache_key, shaped_text) - }; + use crate::pipeline::shaping::shape_text_with_style; + + // Extract bold/italic from effects + let bold = data + .effects + .iter() + .any(|e| matches!(e, crate::pipeline::TextEffect::Bold)); + let italic = data + .effects + .iter() + .any(|e| matches!(e, crate::pipeline::TextEffect::Italic)); + let underline = data + .effects + .iter() + .any(|e| matches!(e, crate::pipeline::TextEffect::Underline)); + let strikethrough = data + .effects + .iter() + .any(|e| matches!(e, crate::pipeline::TextEffect::Strikethrough)); + + // Shape this segment's text independently. + // This matches libass's model: style changes create run boundaries, + // each run is shaped independently via HarfBuzz/rustybuzz. + let cache_key = TextCacheKey { + text: data.text.clone(), + font_family: data.font_family.clone(), + font_size: data.font_size as u32, + bold, + italic, + }; - // Find font for rendering — uses the full fallback chain - let font_id = crate::pipeline::shaping::find_font( - font_db, + let shaped = if let Some(cached) = cache.get_shaped_text(&cache_key) { + cached + } else { + let shaped_text = shape_text_with_style( + &data.text, &data.font_family, + data.font_size, bold, italic, - )?; - - // Render glyphs to paths using cached renderer with spacing - let paths = glyph_renderer.render_shaped_text( - &shaped, - font_id, font_db, - data.spacing, )?; + cache.store_shaped_text(cache_key, shaped_text) + }; - log::trace!(" Rendered {} glyph paths", paths.len()); - - // Build base transform with rotation and scaling - // The data.x and data.y are the top-left corner of the text box - // But glyphs are positioned from their baseline, so we need to adjust y by adding the baseline offset - let baseline_y = data.y + shaped.baseline; - - { - // Safely truncate text for debug output, respecting UTF-8 boundaries - let debug_text = if data.text.len() > 20 { - let mut end = 20; - while !data.text.is_char_boundary(end) && end > 0 { - end -= 1; - } - &data.text[..end] - } else { - &data.text - }; + // Find font for rendering — uses the full fallback chain + let font_id = crate::pipeline::shaping::find_font( + font_db, + &data.font_family, + bold, + italic, + )?; + + // Render glyphs to paths using cached renderer with spacing + let paths = glyph_renderer.render_shaped_text( + &shaped, + font_id, + font_db, + data.spacing, + )?; + + log::trace!(" Rendered {} glyph paths", paths.len()); + + // Build base transform with rotation and scaling + // The data.x and data.y are the top-left corner of the text box + // But glyphs are positioned from their baseline, so we need to adjust y by adding the baseline offset + let baseline_y = data.y + shaped.baseline; + + { + // Safely truncate text for debug output, respecting UTF-8 boundaries + let debug_text = if data.text.len() > 20 { + let mut end = 20; + while !data.text.is_char_boundary(end) && end > 0 { + end -= 1; + } + &data.text[..end] + } else { + &data.text + }; + log::trace!( + "Drawing text: '{}' at ({}, {}), baseline={}, baseline_y={}", + debug_text, + data.x, + data.y, + shaped.baseline, + baseline_y + ); + if !(-100.0..=1080.0).contains(&baseline_y) { log::trace!( - "Drawing text: '{}' at ({}, {}), baseline={}, baseline_y={}", - debug_text, - data.x, + "WARNING: Baseline off-screen! data.y={}, baseline={}, baseline_y={}", data.y, shaped.baseline, baseline_y ); - if !(-100.0..=1080.0).contains(&baseline_y) { - log::trace!( - "WARNING: Baseline off-screen! data.y={}, baseline={}, baseline_y={}", - data.y, - shaped.baseline, - baseline_y - ); - } } + } - let mut base_transform = Transform::from_translate(data.x, baseline_y); - - // Pre-compute scale factors from effects for rotation center calculation - let (scale_x_factor, scale_y_factor) = data.effects.iter().fold((1.0f32, 1.0f32), |acc, e| { - if let crate::pipeline::TextEffect::Scale { x, y } = e { - (acc.0.max(*x / 100.0), acc.1.max(*y / 100.0)) - } else { - acc - } - }); - - for effect in &data.effects { - match effect { - crate::pipeline::TextEffect::Rotation { x, y, z } => { - // Apply Z rotation (2D rotation) - // tiny_skia::pre_rotate takes DEGREES clockwise. - // ASS \frz is counter-clockwise, so negate. - // - // libass rotates around the \pos/\move anchor point. - // The anchor is passed through TextData. In glyph-local - // coords (where base_transform origin = (data.x, baseline_y)), - // the rotation center is (anchor_x - data.x, anchor_y - baseline_y). - // Falls back to text box center if no anchor is set. - if *z != 0.0 { - // data.x/data.y are positioned using SCALED dimensions - // (alignment offsets use scaled_width/scaled_height). - // But glyph paths are in UNSCALED space (scale is - // applied later via pre_scale). So the anchor offset - // from data.x must be divided by the scale factors to - // convert from screen-space offset to glyph-local offset. - let (rot_cx, rot_cy) = if let Some((ax, ay)) = data.anchor { - (ax - data.x, ay - baseline_y) - } else { - (shaped.width / 2.0, shaped.height / 2.0 - shaped.baseline) - }; - - base_transform = base_transform - .pre_translate(rot_cx, rot_cy) - .pre_rotate(-*z) - .pre_translate(-rot_cx, -rot_cy); + let mut base_transform = Transform::from_translate(data.x, baseline_y); - log::trace!( - "Applied rotation: {} degrees around ({}, {}) anchor={:?} scale=({},{})", - z, rot_cx, rot_cy, data.anchor, scale_x_factor, scale_y_factor, - ); - } + // Pre-compute scale factors from effects for rotation center calculation + let (scale_x_factor, scale_y_factor) = data.effects.iter().fold((1.0f32, 1.0f32), |acc, e| { + if let crate::pipeline::TextEffect::Scale { x, y } = e { + (acc.0.max(*x / 100.0), acc.1.max(*y / 100.0)) + } else { + acc + } + }); - // Approximate 3D rotations with skew transformations - // X rotation (rotation around horizontal axis) - affects vertical skew - if *x != 0.0 { - let angle_rad = x * core::f32::consts::PI / 180.0; - // Use perspective approximation: tan(angle) for small angles - let skew_y = angle_rad.sin() * 0.5; // Scale down for visual effect - base_transform = - Transform::from_skew(0.0, skew_y).pre_concat(base_transform); - } + for effect in &data.effects { + match effect { + crate::pipeline::TextEffect::Rotation { x, y, z } => { + // Apply Z rotation (2D rotation) + // tiny_skia::pre_rotate takes DEGREES clockwise. + // ASS \frz is counter-clockwise, so negate. + // + // libass rotates around the \pos/\move anchor point. + // The anchor is passed through TextData. In glyph-local + // coords (where base_transform origin = (data.x, baseline_y)), + // the rotation center is (anchor_x - data.x, anchor_y - baseline_y). + // Falls back to text box center if no anchor is set. + if *z != 0.0 { + // data.x/data.y are positioned using SCALED dimensions + // (alignment offsets use scaled_width/scaled_height). + // But glyph paths are in UNSCALED space (scale is + // applied later via pre_scale). So the anchor offset + // from data.x must be divided by the scale factors to + // convert from screen-space offset to glyph-local offset. + let (rot_cx, rot_cy) = if let Some((ax, ay)) = data.anchor { + (ax - data.x, ay - baseline_y) + } else { + (shaped.width / 2.0, shaped.height / 2.0 - shaped.baseline) + }; - // Y rotation (rotation around vertical axis) - affects horizontal skew - if *y != 0.0 { - let angle_rad = y * core::f32::consts::PI / 180.0; - // Use perspective approximation - let skew_x = angle_rad.sin() * 0.5; // Scale down for visual effect - base_transform = - Transform::from_skew(skew_x, 0.0).pre_concat(base_transform); - } - } - crate::pipeline::TextEffect::Scale { x, y } => { - // Font Y-scale is already applied to the font size during shaping - // But X-scale needs to be applied as a transform since fonts don't support - // asymmetric scaling through size alone - // Apply X-scale transform if it's different from Y-scale - let x_scale = *x / 100.0; - let y_scale = *y / 100.0; + base_transform = base_transform + .pre_translate(rot_cx, rot_cy) + .pre_rotate(-*z) + .pre_translate(-rot_cx, -rot_cy); log::trace!( - "SCALE: Applying scale transform - x={:.2}, y={:.2} for text '{}'", - x_scale, - y_scale, - data.text + "Applied rotation: {} degrees around ({}, {}) anchor={:?} scale=({},{})", + z, rot_cx, rot_cy, data.anchor, scale_x_factor, scale_y_factor, ); + } - // Apply scale transform for both fscx and fscy. - // Neither is baked into the font size — both are glyph - // transforms applied after shaping, matching libass. - if (x_scale - 1.0).abs() > 0.01 || (y_scale - 1.0).abs() > 0.01 { - base_transform = base_transform - .pre_scale(x_scale, y_scale); - } + // Approximate 3D rotations with skew transformations + // X rotation (rotation around horizontal axis) - affects vertical skew + if *x != 0.0 { + let angle_rad = x * core::f32::consts::PI / 180.0; + // Use perspective approximation: tan(angle) for small angles + let skew_y = angle_rad.sin() * 0.5; // Scale down for visual effect + base_transform = + Transform::from_skew(0.0, skew_y).pre_concat(base_transform); + } + + // Y rotation (rotation around vertical axis) - affects horizontal skew + if *y != 0.0 { + let angle_rad = y * core::f32::consts::PI / 180.0; + // Use perspective approximation + let skew_x = angle_rad.sin() * 0.5; // Scale down for visual effect + base_transform = + Transform::from_skew(skew_x, 0.0).pre_concat(base_transform); } - crate::pipeline::TextEffect::Shear { x, y } => { - // Apply shear transformation (for \fax and \fay tags) - base_transform = Transform::from_skew(*x, *y).pre_concat(base_transform); + } + crate::pipeline::TextEffect::Scale { x, y } => { + // Font Y-scale is already applied to the font size during shaping + // But X-scale needs to be applied as a transform since fonts don't support + // asymmetric scaling through size alone + // Apply X-scale transform if it's different from Y-scale + let x_scale = *x / 100.0; + let y_scale = *y / 100.0; + + log::trace!( + "SCALE: Applying scale transform - x={:.2}, y={:.2} for text '{}'", + x_scale, + y_scale, + data.text + ); + + // Apply scale transform for both fscx and fscy. + // Neither is baked into the font size — both are glyph + // transforms applied after shaping, matching libass. + if (x_scale - 1.0).abs() > 0.01 || (y_scale - 1.0).abs() > 0.01 { + base_transform = base_transform + .pre_scale(x_scale, y_scale); } - _ => {} } + crate::pipeline::TextEffect::Shear { x, y } => { + // Apply shear transformation (for \fax and \fay tags) + base_transform = Transform::from_skew(*x, *y).pre_concat(base_transform); + } + _ => {} } + } - // Create clip mask if needed - let clip_mask = data.effects.iter().find_map(|e| { - if let crate::pipeline::TextEffect::Clip { - x1, - y1, - x2, - y2, - inverse, - } = e - { - // Create a mask for clipping - let width = pixmap.width(); - let height = pixmap.height(); - - if let Some(mut mask) = tiny_skia::Mask::new(width, height) { - // Fill the mask with the clipping region - let mut builder = tiny_skia::PathBuilder::new(); - builder.move_to(*x1, *y1); - builder.line_to(*x2, *y1); - builder.line_to(*x2, *y2); - builder.line_to(*x1, *y2); - builder.close(); - - if let Some(clip_path) = builder.finish() { - // Fill the mask with the appropriate pattern - mask.fill_path( - &clip_path, - tiny_skia::FillRule::Winding, - !*inverse, // For normal clip, fill inside; for inverse, fill outside - Transform::identity(), - ); + // Create clip mask if needed + let clip_mask = data.effects.iter().find_map(|e| { + if let crate::pipeline::TextEffect::Clip { + x1, + y1, + x2, + y2, + inverse, + } = e + { + // Create a mask for clipping + let width = pixmap.width(); + let height = pixmap.height(); + + if let Some(mut mask) = tiny_skia::Mask::new(width, height) { + // Fill the mask with the clipping region + let mut builder = tiny_skia::PathBuilder::new(); + builder.move_to(*x1, *y1); + builder.line_to(*x2, *y1); + builder.line_to(*x2, *y2); + builder.line_to(*x1, *y2); + builder.close(); + + if let Some(clip_path) = builder.finish() { + // Fill the mask with the appropriate pattern + mask.fill_path( + &clip_path, + tiny_skia::FillRule::Winding, + !*inverse, // For normal clip, fill inside; for inverse, fill outside + Transform::identity(), + ); - return Some(mask); - } + return Some(mask); } - None + } + None + } else { + None + } + }); + + // Check for blur effect early - when present, ALL rendering (shadow, outline, fill, + // underline, strikethrough) goes to a temp pixmap, gets blurred, then composited. + // This matches ASS spec where \blur applies to the entire composited event. + let blur_radius = data.effects.iter().find_map(|e| { + if let crate::pipeline::TextEffect::Blur { radius } = e { + if *radius > 0.0 { Some(*radius) } else { None } + } else { + None + } + }); + + { + let effect_names: Vec<&str> = data.effects.iter().map(|e| match e { + crate::pipeline::TextEffect::Blur { .. } => "Blur", + crate::pipeline::TextEffect::EdgeBlur { .. } => "EdgeBlur", + crate::pipeline::TextEffect::Outline { .. } => "Outline", + crate::pipeline::TextEffect::Shadow { .. } => "Shadow", + crate::pipeline::TextEffect::Bold => "Bold", + crate::pipeline::TextEffect::Italic => "Italic", + _ => "Other", + }).collect(); + log::trace!( + "draw_text_layer: text='{}' blur={:?} effects={:?} color=[{},{},{},{}]", + &data.text[..data.text.len().min(30)], + blur_radius, + effect_names, + data.color[0], data.color[1], data.color[2], data.color[3], + ); + } + + // When blur is active, set up a temporary render target. + // We need to calculate a bounding box that encompasses shadow offset, outline width, + // and blur padding so nothing gets clipped. + // Extract scale factors from effects — needed for temp pixmap sizing + // and baseline positioning when fscx/fscy are applied as transforms. + let (scale_x_factor, scale_y_factor) = data.effects.iter().fold((1.0f32, 1.0f32), |acc, e| { + if let crate::pipeline::TextEffect::Scale { x, y } = e { + (acc.0.max(*x / 100.0), acc.1.max(*y / 100.0)) + } else { + acc + } + }); + + let blur_state = if let Some(radius) = blur_radius { + let padding = (radius * 3.0).ceil(); + + // Extract shadow offsets and outline width from effects + let (shadow_x_offset, shadow_y_offset) = data.effects.iter().fold((0.0f32, 0.0f32), |acc, e| { + if let crate::pipeline::TextEffect::Shadow { x_offset, y_offset, .. } = e { + // Take the largest shadow offset magnitude + (acc.0.max(x_offset.abs()), acc.1.max(y_offset.abs())) } else { - None + acc } }); - - // Check for blur effect early - when present, ALL rendering (shadow, outline, fill, - // underline, strikethrough) goes to a temp pixmap, gets blurred, then composited. - // This matches ASS spec where \blur applies to the entire composited event. - let blur_radius = data.effects.iter().find_map(|e| { - if let crate::pipeline::TextEffect::Blur { radius } = e { - if *radius > 0.0 { Some(*radius) } else { None } + let outline_width = data.effects.iter().fold(0.0f32, |acc, e| { + if let crate::pipeline::TextEffect::Outline { width, .. } = e { + acc.max(*width) } else { - None + acc } }); - { - let effect_names: Vec<&str> = data.effects.iter().map(|e| match e { - crate::pipeline::TextEffect::Blur { .. } => "Blur", - crate::pipeline::TextEffect::EdgeBlur { .. } => "EdgeBlur", - crate::pipeline::TextEffect::Outline { .. } => "Outline", - crate::pipeline::TextEffect::Shadow { .. } => "Shadow", - crate::pipeline::TextEffect::Bold => "Bold", - crate::pipeline::TextEffect::Italic => "Italic", - _ => "Other", - }).collect(); - log::trace!( - "draw_text_layer: text='{}' blur={:?} effects={:?} color=[{},{},{},{}]", - &data.text[..data.text.len().min(30)], - blur_radius, - effect_names, - data.color[0], data.color[1], data.color[2], data.color[3], - ); - } - - // When blur is active, set up a temporary render target. - // We need to calculate a bounding box that encompasses shadow offset, outline width, - // and blur padding so nothing gets clipped. - // Extract scale factors from effects — needed for temp pixmap sizing - // and baseline positioning when fscx/fscy are applied as transforms. - let (scale_x_factor, scale_y_factor) = data.effects.iter().fold((1.0f32, 1.0f32), |acc, e| { - if let crate::pipeline::TextEffect::Scale { x, y } = e { - (acc.0.max(*x / 100.0), acc.1.max(*y / 100.0)) + // Extract rotation angle for bounding box expansion + let rotation_z = data.effects.iter().fold(0.0f32, |acc, e| { + if let crate::pipeline::TextEffect::Rotation { z, .. } = e { + acc.max(z.abs()) } else { acc } }); - let blur_state = if let Some(radius) = blur_radius { - let padding = (radius * 3.0).ceil(); + let scaled_width = shaped.width * scale_x_factor; + let scaled_height = shaped.height * scale_y_factor; + + // When rotated, the bounding box expands. Account for this in + // both the pixmap dimensions and the expand margin so rotated + // corners don't clip. + let rotation_expand = if rotation_z > 0.0 { + let rad = rotation_z * core::f32::consts::PI / 180.0; + let sin = rad.sin().abs(); + // Extra margin needed: max of how much width contributes to + // height and vice versa + (scaled_width * sin).max(scaled_height * sin) / 2.0 + } else { + 0.0 + }; - // Extract shadow offsets and outline width from effects - let (shadow_x_offset, shadow_y_offset) = data.effects.iter().fold((0.0f32, 0.0f32), |acc, e| { - if let crate::pipeline::TextEffect::Shadow { x_offset, y_offset, .. } = e { - // Take the largest shadow offset magnitude - (acc.0.max(x_offset.abs()), acc.1.max(y_offset.abs())) - } else { - acc - } - }); - let outline_width = data.effects.iter().fold(0.0f32, |acc, e| { + let expand = outline_width + padding + shadow_x_offset.max(shadow_y_offset) + rotation_expand; + let temp_w = (scaled_width + expand * 2.0).ceil() as u32; + let temp_h = (scaled_height + expand * 2.0).ceil() as u32; + + if let Some(mut temp) = Pixmap::new(temp_w.max(1), temp_h.max(1)) { + temp.fill(tiny_skia::Color::TRANSPARENT); + // The offset from temp-pixmap origin to where (data.x, baseline_y) maps. + // In the temp pixmap, glyphs are drawn at offset (expand, expand) relative to + // their baseline. The base_transform translates from glyph-local to screen coords + // (data.x, baseline_y). So we build a temp_base_transform that maps glyph-local + // coords to temp-pixmap coords. + let temp_base_transform = { + // Map from screen coordinates to temp pixmap coordinates. + // base_transform = translate(data.x, baseline_y) * scale * ... + // We need the scaled baseline offset so ascenders (which extend + // baseline * scale_y above the baseline) have room in the temp pixmap. + let scaled_baseline = shaped.baseline * scale_y_factor; + let delta_x = expand - data.x; + let delta_y = expand + scaled_baseline - baseline_y; + Transform::from_translate(delta_x, delta_y).pre_concat(base_transform) + }; + Some((temp, expand, radius, temp_base_transform)) + } else { + None + } + } else { + None + }; + + // Determine render target and transform to use. + // When blur is active, we draw to the temp pixmap with adjusted transforms. + // When not, we draw directly to pixmap with the original transforms. + // + // We use a macro to avoid duplicating the entire rendering body. + // The macro receives the target pixmap (mutable ref) and the base transform to use. + macro_rules! render_all_layers { + ($target:expr, $render_transform:expr, $clip:expr, $ul_x:expr, $ul_baseline_y:expr) => {{ + let render_base = $render_transform; + + // Apply effects in order: shadow, outline, then main text. + // In libass, the shadow is a copy of the full glyph+outline + // shape shifted by the shadow offset. + let outline_width_for_shadow = data.effects.iter().find_map(|e| { if let crate::pipeline::TextEffect::Outline { width, .. } = e { - acc.max(*width) + Some(*width) } else { - acc + None } }); + for effect in &data.effects { + if let crate::pipeline::TextEffect::Shadow { + color, + x_offset, + y_offset, + } = effect + { + let mut shadow_paint = tiny_skia::Paint::default(); + shadow_paint.set_color_rgba8(color[0], color[1], color[2], color[3]); + shadow_paint.anti_alias = true; + shadow_paint.blend_mode = tiny_skia::BlendMode::SourceOver; + + let shadow_transform = render_base.pre_translate(*x_offset, *y_offset); + + for path in &paths { + // Draw the shadow outline (stroked shape) if border exists + if let Some(bord_w) = outline_width_for_shadow { + if bord_w > 0.0 { + let shadow_stroke = tiny_skia::Stroke { + width: bord_w * 2.0, + line_cap: tiny_skia::LineCap::Round, + line_join: tiny_skia::LineJoin::Round, + ..Default::default() + }; + let mut shadow_stroker = tiny_skia::PathStroker::new(); + if let Some(stroked) = shadow_stroker.stroke(path, &shadow_stroke, 1.0) { + if let Some(transformed) = stroked.transform(shadow_transform) { + $target.fill_path( + &transformed, + &shadow_paint, + tiny_skia::FillRule::Winding, + Transform::identity(), + $clip, + ); + } + } + } + } + // Draw the shadow fill + if let Some(transformed) = path.clone().transform(shadow_transform) { + $target.fill_path( + &transformed, + &shadow_paint, + tiny_skia::FillRule::Winding, + Transform::identity(), + $clip, + ); + } + } + } + } - // Extract rotation angle for bounding box expansion - let rotation_z = data.effects.iter().fold(0.0f32, |acc, e| { - if let crate::pipeline::TextEffect::Rotation { z, .. } = e { - acc.max(z.abs()) + // Check for edge blur effect (applies to outline only) + let edge_blur_radius = data.effects.iter().find_map(|e| { + if let crate::pipeline::TextEffect::EdgeBlur { radius } = e { + Some(*radius) } else { - acc + None } }); - let scaled_width = shaped.width * scale_x_factor; - let scaled_height = shaped.height * scale_y_factor; - - // When rotated, the bounding box expands. Account for this in - // both the pixmap dimensions and the expand margin so rotated - // corners don't clip. - let rotation_expand = if rotation_z > 0.0 { - let rad = rotation_z * core::f32::consts::PI / 180.0; - let sin = rad.sin().abs(); - // Extra margin needed: max of how much width contributes to - // height and vice versa - (scaled_width * sin).max(scaled_height * sin) / 2.0 - } else { - 0.0 - }; - - let expand = outline_width + padding + shadow_x_offset.max(shadow_y_offset) + rotation_expand; - let temp_w = (scaled_width + expand * 2.0).ceil() as u32; - let temp_h = (scaled_height + expand * 2.0).ceil() as u32; - - if let Some(mut temp) = Pixmap::new(temp_w.max(1), temp_h.max(1)) { - temp.fill(tiny_skia::Color::TRANSPARENT); - // The offset from temp-pixmap origin to where (data.x, baseline_y) maps. - // In the temp pixmap, glyphs are drawn at offset (expand, expand) relative to - // their baseline. The base_transform translates from glyph-local to screen coords - // (data.x, baseline_y). So we build a temp_base_transform that maps glyph-local - // coords to temp-pixmap coords. - let temp_base_transform = { - // Map from screen coordinates to temp pixmap coordinates. - // base_transform = translate(data.x, baseline_y) * scale * ... - // We need the scaled baseline offset so ascenders (which extend - // baseline * scale_y above the baseline) have room in the temp pixmap. - let scaled_baseline = shaped.baseline * scale_y_factor; - let delta_x = expand - data.x; - let delta_y = expand + scaled_baseline - baseline_y; - Transform::from_translate(delta_x, delta_y).pre_concat(base_transform) - }; - Some((temp, expand, radius, temp_base_transform)) - } else { - None - } - } else { - None - }; - - // Determine render target and transform to use. - // When blur is active, we draw to the temp pixmap with adjusted transforms. - // When not, we draw directly to self.pixmap with the original transforms. - // - // We use a macro to avoid duplicating the entire rendering body. - // The macro receives the target pixmap (mutable ref) and the base transform to use. - macro_rules! render_all_layers { - ($target:expr, $render_transform:expr, $clip:expr, $ul_x:expr, $ul_baseline_y:expr) => {{ - let render_base = $render_transform; - - // Apply effects in order: shadow, outline, then main text. - // In libass, the shadow is a copy of the full glyph+outline - // shape shifted by the shadow offset. - let outline_width_for_shadow = data.effects.iter().find_map(|e| { - if let crate::pipeline::TextEffect::Outline { width, .. } = e { - Some(*width) - } else { - None - } - }); - for effect in &data.effects { - if let crate::pipeline::TextEffect::Shadow { - color, - x_offset, - y_offset, - } = effect - { - let mut shadow_paint = tiny_skia::Paint::default(); - shadow_paint.set_color_rgba8(color[0], color[1], color[2], color[3]); - shadow_paint.anti_alias = true; - shadow_paint.blend_mode = tiny_skia::BlendMode::SourceOver; - - let shadow_transform = render_base.pre_translate(*x_offset, *y_offset); + // Draw outline if present + for effect in &data.effects { + if let crate::pipeline::TextEffect::Outline { color, width } = effect { + let mut outline_paint = tiny_skia::Paint::default(); + outline_paint.set_color_rgba8(color[0], color[1], color[2], color[3]); + outline_paint.anti_alias = true; + outline_paint.blend_mode = tiny_skia::BlendMode::SourceOver; + + // libass uses FT_Stroker which expands outward from + // the glyph outline by the full border width (one-sided). + // tiny_skia's stroke extends width/2 on EACH side. We + // stroke at 2× width, then subtract the original glyph + // interior using EvenOdd fill to remove the inward half, + // leaving only the outward expansion. + let stroke = tiny_skia::Stroke { + width: *width * 2.0, + line_cap: tiny_skia::LineCap::Round, + line_join: tiny_skia::LineJoin::Round, + ..Default::default() + }; + + // If edge blur is needed, render outline to a separate temporary pixmap + if let Some(eb_radius) = edge_blur_radius { + if eb_radius > 0.0 { + let blur_size = (eb_radius * 2.0).ceil() as u32; + let outline_w = + (shaped.width + blur_size as f32 * 2.0 + *width * 2.0).ceil() as u32; + let outline_h = + (shaped.height + blur_size as f32 * 2.0 + *width * 2.0).ceil() as u32; + + if let Some(mut edge_temp) = Pixmap::new(outline_w, outline_h) { + edge_temp.fill(tiny_skia::Color::TRANSPARENT); + + let temp_transform = Transform::from_translate( + blur_size as f32 + *width, + blur_size as f32 + *width, + ); - for path in &paths { - // Draw the shadow outline (stroked shape) if border exists - if let Some(bord_w) = outline_width_for_shadow { - if bord_w > 0.0 { - let shadow_stroke = tiny_skia::Stroke { - width: bord_w * 2.0, - line_cap: tiny_skia::LineCap::Round, - line_join: tiny_skia::LineJoin::Round, - ..Default::default() - }; - let mut shadow_stroker = tiny_skia::PathStroker::new(); - if let Some(stroked) = shadow_stroker.stroke(path, &shadow_stroke, 1.0) { - if let Some(transformed) = stroked.transform(shadow_transform) { - $target.fill_path( - &transformed, - &shadow_paint, + let mut stroker = tiny_skia::PathStroker::new(); + for path in &paths { + if let Some(transformed) = path.clone().transform(temp_transform) { + if let Some(outlined_path) = + stroker.stroke(&transformed, &stroke, 1.0) + { + edge_temp.fill_path( + &outlined_path, + &outline_paint, tiny_skia::FillRule::Winding, Transform::identity(), - $clip, + None, ); } } } - } - // Draw the shadow fill - if let Some(transformed) = path.clone().transform(shadow_transform) { - $target.fill_path( - &transformed, - &shadow_paint, - tiny_skia::FillRule::Winding, - Transform::identity(), + + apply_be_blur(&mut edge_temp, eb_radius as u32, arena); + + let blend_transform = render_base.pre_translate( + -(blur_size as f32) - *width, + -(blur_size as f32) - *width, + ); + + let paint = tiny_skia::PixmapPaint { + blend_mode: tiny_skia::BlendMode::SourceOver, + ..Default::default() + }; + + $target.draw_pixmap( + 0, + 0, + edge_temp.as_ref(), + &paint, + blend_transform, $clip, ); } } - } - } - - // Check for edge blur effect (applies to outline only) - let edge_blur_radius = data.effects.iter().find_map(|e| { - if let crate::pipeline::TextEffect::EdgeBlur { radius } = e { - Some(*radius) } else { - None - } - }); - - // Draw outline if present - for effect in &data.effects { - if let crate::pipeline::TextEffect::Outline { color, width } = effect { - let mut outline_paint = tiny_skia::Paint::default(); - outline_paint.set_color_rgba8(color[0], color[1], color[2], color[3]); - outline_paint.anti_alias = true; - outline_paint.blend_mode = tiny_skia::BlendMode::SourceOver; - - // libass uses FT_Stroker which expands outward from - // the glyph outline by the full border width (one-sided). - // tiny_skia's stroke extends width/2 on EACH side. We - // stroke at 2× width, then subtract the original glyph - // interior using EvenOdd fill to remove the inward half, - // leaving only the outward expansion. - let stroke = tiny_skia::Stroke { - width: *width * 2.0, - line_cap: tiny_skia::LineCap::Round, - line_join: tiny_skia::LineJoin::Round, - ..Default::default() - }; - - // If edge blur is needed, render outline to a separate temporary pixmap - if let Some(eb_radius) = edge_blur_radius { - if eb_radius > 0.0 { - let blur_size = (eb_radius * 2.0).ceil() as u32; - let outline_w = - (shaped.width + blur_size as f32 * 2.0 + *width * 2.0).ceil() as u32; - let outline_h = - (shaped.height + blur_size as f32 * 2.0 + *width * 2.0).ceil() as u32; - - if let Some(mut edge_temp) = Pixmap::new(outline_w, outline_h) { - edge_temp.fill(tiny_skia::Color::TRANSPARENT); - - let temp_transform = Transform::from_translate( - blur_size as f32 + *width, - blur_size as f32 + *width, - ); - - let mut stroker = tiny_skia::PathStroker::new(); - for path in &paths { - if let Some(transformed) = path.clone().transform(temp_transform) { - if let Some(outlined_path) = - stroker.stroke(&transformed, &stroke, 1.0) - { - edge_temp.fill_path( - &outlined_path, - &outline_paint, - tiny_skia::FillRule::Winding, - Transform::identity(), - None, - ); - } - } - } - - apply_be_blur(&mut edge_temp, eb_radius as u32, arena); - - let blend_transform = render_base.pre_translate( - -(blur_size as f32) - *width, - -(blur_size as f32) - *width, - ); - - let paint = tiny_skia::PixmapPaint { - blend_mode: tiny_skia::BlendMode::SourceOver, - ..Default::default() - }; - - $target.draw_pixmap( - 0, - 0, - edge_temp.as_ref(), - &paint, - blend_transform, - $clip, - ); - } - } - } else { - // Render outline-only ring via a temp pixmap: - // 1. Draw 2× stroke (extends width in + width out) - // 2. Punch out interior with DestinationOut - // 3. Composite result onto target - // This avoids EvenOdd winding artifacts. - let bounds = render_base.map_point(&mut tiny_skia::Point { x: 0.0, y: 0.0 }); - // Compute bounding box of all transformed paths - let mut min_x = f32::MAX; - let mut min_y = f32::MAX; - let mut max_x = f32::MIN; - let mut max_y = f32::MIN; - let mut stroker = tiny_skia::PathStroker::new(); - for path in &paths { - if let Some(outlined) = stroker.stroke(path, &stroke, 1.0) { - if let Some(t) = outlined.transform(render_base) { - let b = t.bounds(); - min_x = min_x.min(b.left()); - min_y = min_y.min(b.top()); - max_x = max_x.max(b.right()); - max_y = max_y.max(b.bottom()); - } + // Render outline-only ring via a temp pixmap: + // 1. Draw 2× stroke (extends width in + width out) + // 2. Punch out interior with DestinationOut + // 3. Composite result onto target + // This avoids EvenOdd winding artifacts. + let bounds = render_base.map_point(&mut tiny_skia::Point { x: 0.0, y: 0.0 }); + // Compute bounding box of all transformed paths + let mut min_x = f32::MAX; + let mut min_y = f32::MAX; + let mut max_x = f32::MIN; + let mut max_y = f32::MIN; + let mut stroker = tiny_skia::PathStroker::new(); + for path in &paths { + if let Some(outlined) = stroker.stroke(path, &stroke, 1.0) { + if let Some(t) = outlined.transform(render_base) { + let b = t.bounds(); + min_x = min_x.min(b.left()); + min_y = min_y.min(b.top()); + max_x = max_x.max(b.right()); + max_y = max_y.max(b.bottom()); } } - let _ = bounds; - - if min_x < max_x && min_y < max_y { - let pad = 2.0; - let ox = (min_x - pad).floor(); - let oy = (min_y - pad).floor(); - let tw = ((max_x - ox) + pad * 2.0).ceil() as u32; - let th = ((max_y - oy) + pad * 2.0).ceil() as u32; - - if let Some(mut outline_temp) = Pixmap::new(tw.max(1).min(4096), th.max(1).min(4096)) { - outline_temp.fill(tiny_skia::Color::TRANSPARENT); - let local_transform = Transform::from_translate(-ox, -oy); - - let mut stroker2 = tiny_skia::PathStroker::new(); - // Draw full 2× stroke ring - for path in &paths { - if let Some(outlined_path) = stroker2.stroke(path, &stroke, 1.0) { - if let Some(transformed) = outlined_path.transform(render_base) { - if let Some(local) = transformed.transform(local_transform) { - outline_temp.fill_path( - &local, - &outline_paint, - tiny_skia::FillRule::Winding, - Transform::identity(), - None, - ); - } - } - } - } - // Punch out glyph interiors - let mut punch_paint = tiny_skia::Paint::default(); - punch_paint.set_color_rgba8(0, 0, 0, 255); - punch_paint.blend_mode = tiny_skia::BlendMode::DestinationOut; - punch_paint.anti_alias = true; - for path in &paths { - if let Some(transformed) = path.clone().transform(render_base) { + } + let _ = bounds; + + if min_x < max_x && min_y < max_y { + let pad = 2.0; + let ox = (min_x - pad).floor(); + let oy = (min_y - pad).floor(); + let tw = ((max_x - ox) + pad * 2.0).ceil() as u32; + let th = ((max_y - oy) + pad * 2.0).ceil() as u32; + + if let Some(mut outline_temp) = Pixmap::new(tw.max(1).min(4096), th.max(1).min(4096)) { + outline_temp.fill(tiny_skia::Color::TRANSPARENT); + let local_transform = Transform::from_translate(-ox, -oy); + + let mut stroker2 = tiny_skia::PathStroker::new(); + // Draw full 2× stroke ring + for path in &paths { + if let Some(outlined_path) = stroker2.stroke(path, &stroke, 1.0) { + if let Some(transformed) = outlined_path.transform(render_base) { if let Some(local) = transformed.transform(local_transform) { outline_temp.fill_path( &local, - &punch_paint, + &outline_paint, tiny_skia::FillRule::Winding, Transform::identity(), None, @@ -806,231 +793,250 @@ fn draw_text_layer( } } } - // Composite onto target - let composite_paint = tiny_skia::PixmapPaint { - blend_mode: tiny_skia::BlendMode::SourceOver, - ..Default::default() - }; - $target.draw_pixmap( - 0, 0, - outline_temp.as_ref(), - &composite_paint, - Transform::from_translate(ox, oy), - $clip, - ); } + // Punch out glyph interiors + let mut punch_paint = tiny_skia::Paint::default(); + punch_paint.set_color_rgba8(0, 0, 0, 255); + punch_paint.blend_mode = tiny_skia::BlendMode::DestinationOut; + punch_paint.anti_alias = true; + for path in &paths { + if let Some(transformed) = path.clone().transform(render_base) { + if let Some(local) = transformed.transform(local_transform) { + outline_temp.fill_path( + &local, + &punch_paint, + tiny_skia::FillRule::Winding, + Transform::identity(), + None, + ); + } + } + } + // Composite onto target + let composite_paint = tiny_skia::PixmapPaint { + blend_mode: tiny_skia::BlendMode::SourceOver, + ..Default::default() + }; + $target.draw_pixmap( + 0, 0, + outline_temp.as_ref(), + &composite_paint, + Transform::from_translate(ox, oy), + $clip, + ); } } } } + } - // Draw main text - let mut text_paint = tiny_skia::Paint::default(); - text_paint.set_color_rgba8(data.color[0], data.color[1], data.color[2], data.color[3]); - text_paint.anti_alias = true; - text_paint.blend_mode = tiny_skia::BlendMode::SourceOver; + // Draw main text + let mut text_paint = tiny_skia::Paint::default(); + text_paint.set_color_rgba8(data.color[0], data.color[1], data.color[2], data.color[3]); + text_paint.anti_alias = true; + text_paint.blend_mode = tiny_skia::BlendMode::SourceOver; + + log::trace!( + "Drawing main text with color: R={}, G={}, B={}, A={}", + data.color[0], + data.color[1], + data.color[2], + data.color[3] + ); + + // Check for karaoke effect + let karaoke_info = data.effects.iter().find_map(|e| { + if let crate::pipeline::TextEffect::Karaoke { progress, style } = e { + log::trace!("KARAOKE DETECTED: progress={}, style={}", progress, style); + Some((*progress, *style)) + } else { + None + } + }); + + let text_transform = render_base; + + if let Some((progress, karaoke_style)) = karaoke_info { + // Draw with karaoke effect based on style log::trace!( - "Drawing main text with color: R={}, G={}, B={}, A={}", + "KARAOKE RENDERING: progress={}, style={}, original color=({},{},{},{})", + progress, + karaoke_style, data.color[0], data.color[1], data.color[2], data.color[3] ); - // Check for karaoke effect - let karaoke_info = data.effects.iter().find_map(|e| { - if let crate::pipeline::TextEffect::Karaoke { progress, style } = e { - log::trace!("KARAOKE DETECTED: progress={}, style={}", progress, style); - Some((*progress, *style)) - } else { - None - } - }); - - let text_transform = render_base; - - if let Some((progress, karaoke_style)) = karaoke_info { - // Draw with karaoke effect based on style - - log::trace!( - "KARAOKE RENDERING: progress={}, style={}, original color=({},{},{},{})", - progress, - karaoke_style, - data.color[0], - data.color[1], - data.color[2], - data.color[3] - ); + let mut karaoke_paint = tiny_skia::Paint::default(); - let mut karaoke_paint = tiny_skia::Paint::default(); - - if karaoke_style == 0 { - if progress > 0.0 { - karaoke_paint.set_color_rgba8(255, 255, 0, data.color[3]); - log::trace!("KARAOKE COLOR: Sung - Yellow (255,255,0,{})", data.color[3]); - } else { - karaoke_paint.set_color_rgba8( - data.color[0], - data.color[1], - data.color[2], - data.color[3], - ); - log::trace!( - "KARAOKE COLOR: Not sung - Original ({},{},{},{})", - data.color[0], - data.color[1], - data.color[2], - data.color[3] - ); - } + if karaoke_style == 0 { + if progress > 0.0 { + karaoke_paint.set_color_rgba8(255, 255, 0, data.color[3]); + log::trace!("KARAOKE COLOR: Sung - Yellow (255,255,0,{})", data.color[3]); } else { - if progress >= 1.0 { - karaoke_paint.set_color_rgba8(255, 255, 0, data.color[3]); - } else if progress <= 0.0 { - karaoke_paint.set_color_rgba8( - data.color[0], - data.color[1], - data.color[2], - data.color[3], - ); - } else { - let r = (data.color[0] as f32 * (1.0 - progress) + 255.0 * progress) as u8; - let g = (data.color[1] as f32 * (1.0 - progress) + 255.0 * progress) as u8; - let b = (data.color[2] as f32 * (1.0 - progress) + 0.0 * progress) as u8; - karaoke_paint.set_color_rgba8(r, g, b, data.color[3]); - } - } - karaoke_paint.anti_alias = true; - karaoke_paint.blend_mode = tiny_skia::BlendMode::SourceOver; - - for path in &paths { - if let Some(transformed) = path.clone().transform(text_transform) { - $target.fill_path( - &transformed, - &karaoke_paint, - tiny_skia::FillRule::Winding, - Transform::identity(), - $clip, - ); - } + karaoke_paint.set_color_rgba8( + data.color[0], + data.color[1], + data.color[2], + data.color[3], + ); + log::trace!( + "KARAOKE COLOR: Not sung - Original ({},{},{},{})", + data.color[0], + data.color[1], + data.color[2], + data.color[3] + ); } } else { - // Draw without karaoke - log::trace!( - "Drawing {} paths for main text at transform ({}, {})", - paths.len(), - text_transform.tx, - text_transform.ty - ); - - for (i, path) in paths.iter().enumerate() { - if let Some(transformed) = path.clone().transform(text_transform) { - if i == 0 { - log::trace!( - "Drawing path 0 for main text, bounds: {:?}", - transformed.bounds() - ); - } - - $target.fill_path( - &transformed, - &text_paint, - tiny_skia::FillRule::Winding, - Transform::identity(), - $clip, - ); - } + if progress >= 1.0 { + karaoke_paint.set_color_rgba8(255, 255, 0, data.color[3]); + } else if progress <= 0.0 { + karaoke_paint.set_color_rgba8( + data.color[0], + data.color[1], + data.color[2], + data.color[3], + ); + } else { + let r = (data.color[0] as f32 * (1.0 - progress) + 255.0 * progress) as u8; + let g = (data.color[1] as f32 * (1.0 - progress) + 255.0 * progress) as u8; + let b = (data.color[2] as f32 * (1.0 - progress) + 0.0 * progress) as u8; + karaoke_paint.set_color_rgba8(r, g, b, data.color[3]); } } - - // Draw underline if present - if underline { - let underline_y = $ul_baseline_y - shaped.descent / 2.0; - let ul_x = $ul_x; - let mut builder = tiny_skia::PathBuilder::new(); - builder.move_to(ul_x, underline_y); - builder.line_to(ul_x + shaped.width, underline_y); - - if let Some(underline_path) = builder.finish() { - let stroke = tiny_skia::Stroke { - width: data.font_size * 0.08, - line_cap: tiny_skia::LineCap::Round, - ..Default::default() - }; - $target.stroke_path( - &underline_path, - &text_paint, - &stroke, + karaoke_paint.anti_alias = true; + karaoke_paint.blend_mode = tiny_skia::BlendMode::SourceOver; + + for path in &paths { + if let Some(transformed) = path.clone().transform(text_transform) { + $target.fill_path( + &transformed, + &karaoke_paint, + tiny_skia::FillRule::Winding, Transform::identity(), $clip, ); } } + } else { + // Draw without karaoke + log::trace!( + "Drawing {} paths for main text at transform ({}, {})", + paths.len(), + text_transform.tx, + text_transform.ty + ); - // Draw strikethrough if present - if strikethrough { - let strike_y = $ul_baseline_y - shaped.ascent / 3.0; - let st_x = $ul_x; - let mut builder = tiny_skia::PathBuilder::new(); - builder.move_to(st_x, strike_y); - builder.line_to(st_x + shaped.width, strike_y); - - if let Some(strike_path) = builder.finish() { - let stroke = tiny_skia::Stroke { - width: data.font_size * 0.06, - line_cap: tiny_skia::LineCap::Round, - ..Default::default() - }; - $target.stroke_path( - &strike_path, + for (i, path) in paths.iter().enumerate() { + if let Some(transformed) = path.clone().transform(text_transform) { + if i == 0 { + log::trace!( + "Drawing path 0 for main text, bounds: {:?}", + transformed.bounds() + ); + } + + $target.fill_path( + &transformed, &text_paint, - &stroke, + tiny_skia::FillRule::Winding, Transform::identity(), $clip, ); } } - }}; - } + } + + // Draw underline if present + if underline { + let underline_y = $ul_baseline_y - shaped.descent / 2.0; + let ul_x = $ul_x; + let mut builder = tiny_skia::PathBuilder::new(); + builder.move_to(ul_x, underline_y); + builder.line_to(ul_x + shaped.width, underline_y); + + if let Some(underline_path) = builder.finish() { + let stroke = tiny_skia::Stroke { + width: data.font_size * 0.08, + line_cap: tiny_skia::LineCap::Round, + ..Default::default() + }; + $target.stroke_path( + &underline_path, + &text_paint, + &stroke, + Transform::identity(), + $clip, + ); + } + } - if let Some((mut temp_pixmap, expand, radius, temp_base_transform)) = blur_state { - // Render everything to the temp pixmap (no clip mask on the temp - clip applies when compositing) - // In temp pixmap space, the text top-left is at (expand, expand), so baseline is at - // (expand, expand + shaped.baseline) and x is expand. - let scaled_baseline = shaped.baseline * scale_y_factor; + // Draw strikethrough if present + if strikethrough { + let strike_y = $ul_baseline_y - shaped.ascent / 3.0; + let st_x = $ul_x; + let mut builder = tiny_skia::PathBuilder::new(); + builder.move_to(st_x, strike_y); + builder.line_to(st_x + shaped.width, strike_y); + + if let Some(strike_path) = builder.finish() { + let stroke = tiny_skia::Stroke { + width: data.font_size * 0.06, + line_cap: tiny_skia::LineCap::Round, + ..Default::default() + }; + $target.stroke_path( + &strike_path, + &text_paint, + &stroke, + Transform::identity(), + $clip, + ); + } + } + }}; + } - render_all_layers!(temp_pixmap, temp_base_transform, None::<&tiny_skia::Mask>, expand, expand + scaled_baseline); + if let Some((mut temp_pixmap, expand, radius, temp_base_transform)) = blur_state { + // Render everything to the temp pixmap (no clip mask on the temp - clip applies when compositing) + // In temp pixmap space, the text top-left is at (expand, expand), so baseline is at + // (expand, expand + shaped.baseline) and x is expand. + let scaled_baseline = shaped.baseline * scale_y_factor; - // Apply blur to the entire composited result - apply_gaussian_blur(&mut temp_pixmap, radius, arena); + render_all_layers!(temp_pixmap, temp_base_transform, None::<&tiny_skia::Mask>, expand, expand + scaled_baseline); - // Composite blurred temp pixmap back to self.pixmap at the correct position - let composite_transform = Transform::from_translate( - data.x - expand, - data.y - expand, - ); + // Apply blur to the entire composited result + apply_gaussian_blur(&mut temp_pixmap, radius, arena); + // Composite blurred temp pixmap back to pixmap at the correct position + let composite_transform = Transform::from_translate( + data.x - expand, + data.y - expand, + ); - let paint = tiny_skia::PixmapPaint { - blend_mode: tiny_skia::BlendMode::SourceOver, - ..Default::default() - }; - pixmap.draw_pixmap( - 0, - 0, - temp_pixmap.as_ref(), - &paint, - composite_transform, - clip_mask.as_ref(), - ); - } else { - // No blur: render directly to the main pixmap - render_all_layers!(pixmap, base_transform, clip_mask.as_ref(), data.x, baseline_y); - } + let paint = tiny_skia::PixmapPaint { + blend_mode: tiny_skia::BlendMode::SourceOver, + ..Default::default() + }; - Ok(()) + pixmap.draw_pixmap( + 0, + 0, + temp_pixmap.as_ref(), + &paint, + composite_transform, + clip_mask.as_ref(), + ); + } else { + // No blur: render directly to the main pixmap + render_all_layers!(pixmap, base_transform, clip_mask.as_ref(), data.x, baseline_y); + } + + Ok(()) } /// Apply a gaussian blur to a pixmap (for `\blur` tag). @@ -1245,9 +1251,6 @@ impl RenderBackend for SoftwareBackend { } else { self.frame_buf.resize(needed, 0); } - self.frame_buf.fill(0); - self.frame_width = w; - self.frame_height = h; // Create PixmapMut view — full tiny-skia rendering API available. // We use explicit field borrows to avoid a conflict between the @@ -1309,19 +1312,16 @@ impl RenderBackend for SoftwareBackend { } // Reclaim or allocate frame buffer. + // BytesMut::zeroed() and resize(_, 0) already produce a zeroed buffer. if !self.frame_buf.try_reclaim(needed) { self.frame_buf = BytesMut::zeroed(needed); } else { self.frame_buf.resize(needed, 0); } - self.frame_width = w; - self.frame_height = h; - // Copy previous frame if dimensions match. + // Copy previous frame if dimensions match; buffer is already zeroed otherwise. if previous_frame.len() == needed { self.frame_buf.copy_from_slice(previous_frame); - } else { - self.frame_buf.fill(0); } // Same explicit field-borrow pattern as composite_layers. @@ -1374,3 +1374,112 @@ impl RenderBackend for SoftwareBackend { Some(self.metrics.clone()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::pipeline::IntermediateLayer; + use crate::renderer::RenderContext; + + /// Helper to create a small render context for tests. + fn make_context(w: u32, h: u32) -> RenderContext { + RenderContext::new(w, h) + } + + /// Render two frames back-to-back and verify both succeed with the correct + /// dimensions. After the first frame's `Bytes` is dropped the refcount on + /// the internal `BytesMut` falls to 1, so the second render must succeed + /// via `try_reclaim` rather than a fresh allocation. + #[test] + fn two_frame_render_exercises_try_reclaim() { + // Use a small resolution to keep the test fast. + let ctx = make_context(64, 64); + let mut backend = SoftwareBackend::new(&ctx).expect("failed to create backend"); + let expected_len = (64 * 64 * 4) as usize; + + // First frame: no layers — produces a fully-zeroed (transparent) frame. + let frame1 = backend + .composite_layers(&[], &ctx) + .expect("first render failed"); + assert_eq!(frame1.len(), expected_len, "first frame wrong size"); + + // Dropping frame1 reduces the BytesMut refcount back to 1, enabling try_reclaim. + drop(frame1); + + // Second frame: still no layers. The second render MUST produce a valid, + // zeroed frame even when the buffer was reclaimed from the previous frame. + let frame2 = backend + .composite_layers(&[], &ctx) + .expect("second render failed"); + assert_eq!(frame2.len(), expected_len, "second frame wrong size"); + + // All pixels should be transparent (RGBA 0,0,0,0) since no layers were drawn. + // This also verifies the reclaimed buffer was properly zeroed. + for (i, chunk) in frame2.chunks_exact(4).enumerate() { + assert_eq!( + chunk, + &[0u8, 0, 0, 0], + "pixel {i} not zero — try_reclaim path failed to zero buffer" + ); + } + } + + /// Verify that rendering a second frame with a different set of layers + /// (simulating a subtitle change between frames) produces non-identical + /// output when at least one raster layer is present. + #[test] + fn two_frame_render_with_layer_exercises_try_reclaim() { + let ctx = make_context(16, 16); + let mut backend = SoftwareBackend::new(&ctx).expect("failed to create backend"); + let expected_len = (16 * 16 * 4) as usize; + + // Frame 1: one raster layer with a non-zero pixel in the top-left. + let mut pixels = vec![0u8; 16 * 16 * 4]; + pixels[3] = 255; // alpha of first pixel = opaque + pixels[0] = 200; // R + pixels[1] = 100; // G + pixels[2] = 50; // B + let layer = IntermediateLayer::Raster(crate::pipeline::RasterData { + x: 0, + y: 0, + width: 16, + height: 16, + pixels, + opacity: 255, + }); + + let frame1 = backend + .composite_layers(&[layer], &ctx) + .expect("first raster render failed"); + assert_eq!(frame1.len(), expected_len); + + // First pixel should not be zero after drawing the raster layer. + // tiny-skia stores premultiplied RGBA; alpha=255 means colour is unchanged. + let top_left = &frame1[0..4]; + assert_ne!( + top_left, + &[0u8, 0, 0, 0], + "first frame top-left pixel should be non-zero after raster layer" + ); + + // Drop frame1 to allow try_reclaim on the second render. + drop(frame1); + + // Frame 2: no layers — should produce a fully-zeroed buffer even though + // the reclaimed buffer still contains the previous frame's data before + // the PixmapMut is re-created. + let frame2 = backend + .composite_layers(&[], &ctx) + .expect("second empty render failed"); + assert_eq!(frame2.len(), expected_len); + + // All pixels transparent: confirms the reclaimed buffer was zeroed. + for (i, chunk) in frame2.chunks_exact(4).enumerate() { + assert_eq!( + chunk, + &[0u8, 0, 0, 0], + "pixel {i} not zero on second frame — reclaimed buffer not cleared" + ); + } + } +} diff --git a/crates/ass-renderer/src/pipeline/mod.rs b/crates/ass-renderer/src/pipeline/mod.rs index 06ff722..19dd3fb 100644 --- a/crates/ass-renderer/src/pipeline/mod.rs +++ b/crates/ass-renderer/src/pipeline/mod.rs @@ -5,6 +5,7 @@ use crate::utils::{DirtyRegion, FontDb, RenderError}; #[cfg(feature = "analysis-integration")] use ass_core::analysis::ScriptAnalysis; use ass_core::parser::{Event, Script}; +use shaping::ShapedText; use smallvec::SmallVec; #[cfg(feature = "nostd")] @@ -180,6 +181,11 @@ pub struct TextData { /// Anchor point for rotation (the \pos/\move position in screen coords). /// Rotation happens around this point, matching libass behaviour. pub anchor: Option<(f32, f32)>, + /// Pre-shaped text from pipeline stage. + pub shaped: Option>, + /// Pre-rendered glyph paths from pipeline stage. + /// NOTE: Phase 7 changes this type from tiny_skia::Path to Vec. + pub glyph_paths: Option>>, } /// Text effect enumeration diff --git a/crates/ass-renderer/src/pipeline/software_pipeline.rs b/crates/ass-renderer/src/pipeline/software_pipeline.rs index 661e6e4..95d09f3 100644 --- a/crates/ass-renderer/src/pipeline/software_pipeline.rs +++ b/crates/ass-renderer/src/pipeline/software_pipeline.rs @@ -1445,6 +1445,8 @@ impl SoftwarePipeline { effects: seg_effects, spacing: spacing * scale_x, anchor: segment_anchor, + shaped: None, + glyph_paths: None, }; // Handle baseline offset diff --git a/crates/ass-renderer/src/renderer/frame.rs b/crates/ass-renderer/src/renderer/frame.rs index 292a112..717c235 100644 --- a/crates/ass-renderer/src/renderer/frame.rs +++ b/crates/ass-renderer/src/renderer/frame.rs @@ -90,13 +90,6 @@ impl Frame { self.buffer } - /// Take ownership of the buffer as Vec - /// - /// Copies if the Bytes has shared ownership; zero-copy if unique. - pub fn into_buffer(self) -> Vec { - self.buffer.to_vec() - } - /// Get frame width pub fn width(&self) -> u32 { self.width