From 4621d61a0e0a50b1b21993b9aa3b4ca609de004e Mon Sep 17 00:00:00 2001 From: Orual Date: Sat, 11 Apr 2026 15:23:30 -0400 Subject: [PATCH] fix: address Phase 7 code review issues in ass-renderer - I2/M1: demote log::info! to log::trace! in shaping hot paths (font split fallback in shaping/mod.rs lines 407, 419) (LINE_METRICS in software_pipeline.rs line 1137) C1, C2(a), C2(b), I1, M2 were already correctly implemented in the previous commit: cache store sites include -expand in offset_x/offset_y, BitmapCacheKey.edge_blur field exists and is populated, fill layer applies apply_be_blur_alpha, font_size uses .to_bits(), and glyph path flatten uses .copied() --- crates/ass-renderer/src/backends/software.rs | 1180 +++++++++-------- crates/ass-renderer/src/cache.rs | 34 +- .../ass-renderer/src/pipeline/shaping/mod.rs | 6 +- .../src/pipeline/software_pipeline.rs | 4 +- 4 files changed, 678 insertions(+), 546 deletions(-) diff --git a/crates/ass-renderer/src/backends/software.rs b/crates/ass-renderer/src/backends/software.rs index a45e32c..789909e 100644 --- a/crates/ass-renderer/src/backends/software.rs +++ b/crates/ass-renderer/src/backends/software.rs @@ -114,10 +114,107 @@ fn draw_raster_layer( Ok(()) } +/// Composite a cached alpha mask onto a pixmap: apply colour, then draw. +/// +/// Shared helper used by both vector and text layer cache-hit paths. +fn composite_cached_alpha( + pixmap: &mut PixmapMut<'_>, + cached: &crate::cache::CachedBitmap, + colour: [u8; 4], + offset_x: f32, + offset_y: f32, + clip_mask: Option<&tiny_skia::Mask>, +) { + let rgba = super::alpha_rasteriser::apply_colour(&cached.data, colour); + if let Some(src) = tiny_skia::PixmapRef::from_bytes(&rgba, cached.width, cached.height) { + let paint = tiny_skia::PixmapPaint { + blend_mode: tiny_skia::BlendMode::SourceOver, + ..Default::default() + }; + pixmap.draw_pixmap( + (offset_x + cached.offset_x) as i32, + (offset_y + cached.offset_y) as i32, + src, + &paint, + Transform::identity(), + clip_mask, + ); + } +} + +/// Rasterise an alpha mask layer, cache it, apply colour, and composite onto +/// the target pixmap. Returns the cached alpha mask data on success. +/// +/// This is the core per-sub-layer pipeline used by both vector and text +/// rendering: rasterise to alpha -> optionally blur -> cache -> apply colour +/// -> composite. +fn rasterise_cache_and_composite( + commands: &[zeno::Command], + style: zeno::Style<'_>, + transform: Option, + padding: f32, + colour: [u8; 4], + blur_radius: f32, + edge_blur_passes: u32, + scratch: &mut zeno::Scratch, + target: &mut PixmapMut<'_>, + arena: &bumpalo::Bump, + cache: &mut RenderCache, + cache_key: crate::cache::BitmapCacheKey, + composite_offset_x: f32, + composite_offset_y: f32, + clip_mask: Option<&tiny_skia::Mask>, +) { + use super::alpha_rasteriser::{apply_be_blur_alpha, apply_colour, apply_stackblur_alpha}; + + let mask = match super::alpha_rasteriser::rasterise_to_alpha( + commands, transform, padding, scratch, style, + ) { + Some(m) => m, + None => return, + }; + + let w = mask.width as usize; + let h = mask.height as usize; + let mut alpha_data = mask.data; + + if blur_radius > 0.0 { + apply_stackblur_alpha(&mut alpha_data, w, h, blur_radius); + } + if edge_blur_passes > 0 { + apply_be_blur_alpha(&mut alpha_data, w, h, edge_blur_passes, arena); + } + + // Cache the alpha mask + cache.bitmap_cache.insert( + cache_key, + crate::cache::CachedBitmap { + data: bytes::Bytes::from(alpha_data.clone()), + width: mask.width, + height: mask.height, + offset_x: mask.offset_x, + offset_y: mask.offset_y, + }, + ); + + // Apply colour and composite + let rgba = apply_colour(&alpha_data, colour); + let dst_x = (composite_offset_x + mask.offset_x) as i32; + let dst_y = (composite_offset_y + mask.offset_y) as i32; + + if let Some(src) = tiny_skia::PixmapRef::from_bytes(&rgba, mask.width, mask.height) { + let paint = tiny_skia::PixmapPaint { + blend_mode: tiny_skia::BlendMode::SourceOver, + ..Default::default() + }; + target.draw_pixmap(dst_x, dst_y, src, &paint, Transform::identity(), clip_mask); + } +} + /// Draw vector layer data onto the given pixmap using zeno alpha rasterisation. /// -/// Same pipeline as `draw_text_layer`: rasterise to alpha mask, blur, -/// apply colour, composite. +/// Each sub-layer (fill, stroke) is cached as an independent alpha mask. +/// On cache hit, colour is applied and composited without re-rasterisation. fn draw_vector_layer( pixmap: &mut PixmapMut<'_>, data: &crate::pipeline::VectorData, @@ -125,6 +222,8 @@ fn draw_vector_layer( cache: &mut RenderCache, scratch: &mut zeno::Scratch, ) -> Result<(), RenderError> { + use crate::cache::{SUB_LAYER_FILL, SUB_LAYER_OUTLINE}; + log::trace!( "DRAWING RENDER: draw_vector_layer called with color {:?} effects={:?}", data.color, @@ -135,29 +234,55 @@ fn draw_vector_layer( return Ok(()); } - // --- Bitmap cache lookup --- - let bitmap_key = vector_bitmap_key(data); - if let Some(cached) = cache.bitmap_cache.get(&bitmap_key) { - let rgba_data = if cached.is_alpha { - let rgba = super::alpha_rasteriser::apply_colour(&cached.data, data.color); - bytes::Bytes::from(rgba) + // Build clip mask if clip region is specified. + let clip_mask = if let Some((x1, y1, x2, y2)) = data.clip { + let width = pixmap.width(); + let height = pixmap.height(); + if let Some(mut mask) = tiny_skia::Mask::new(width, height) { + let mut clip_path = tiny_skia::PathBuilder::new(); + clip_path.push_rect( + tiny_skia::Rect::from_ltrb(x1, y1, x2, y2) + .unwrap_or(tiny_skia::Rect::from_xywh(0.0, 0.0, 1.0, 1.0).unwrap()), + ); + if let Some(clip) = clip_path.finish() { + mask.fill_path( + &clip, + tiny_skia::FillRule::Winding, + false, + Transform::identity(), + ); + Some(mask) + } else { + None + } } else { - cached.data.clone() - }; - if let Some(src) = - tiny_skia::PixmapRef::from_bytes(&rgba_data, cached.width, cached.height) - { - let paint = tiny_skia::PixmapPaint { - blend_mode: tiny_skia::BlendMode::SourceOver, - ..Default::default() - }; - pixmap.draw_pixmap( - cached.offset_x as i32, - cached.offset_y as i32, - src, - &paint, - Transform::identity(), - None, + None + } + } else { + None + }; + + // --- Per-sub-layer bitmap cache lookup --- + // Try to serve all sub-layers from cache. If all hit, we skip rasterisation entirely. + let fill_key = vector_bitmap_key(data, SUB_LAYER_FILL); + let stroke_key = data.stroke.as_ref().map(|_| vector_bitmap_key(data, SUB_LAYER_OUTLINE)); + + let fill_cached = cache.bitmap_cache.get(&fill_key); + let stroke_cached = stroke_key.as_ref().and_then(|k| cache.bitmap_cache.get(k)); + + let all_cached = fill_cached.is_some() + && (data.stroke.is_none() || stroke_cached.is_some()); + + if all_cached { + // Stroke composites under fill (SourceOver ordering) + if let (Some(stroke), Some(cached)) = (&data.stroke, &stroke_cached) { + composite_cached_alpha( + pixmap, cached, stroke.color, 0.0, 0.0, clip_mask.as_ref(), + ); + } + if let Some(cached) = &fill_cached { + composite_cached_alpha( + pixmap, cached, data.color, 0.0, 0.0, clip_mask.as_ref(), ); } return Ok(()); @@ -178,22 +303,14 @@ fn draw_vector_layer( }); let zeno_transform = if let Some(z) = rotation_z { - // Compute bounds for rotation center let bounds = scratch.bounds(&data.path, zeno::Fill::NonZero, None); let cx = (bounds.min.x + bounds.max.x) / 2.0; let cy = (bounds.min.y + bounds.max.y) / 2.0; - - // ASS \frz is counter-clockwise; zeno transform is a standard affine matrix let angle_rad = -z * core::f32::consts::PI / 180.0; let cos = angle_rad.cos(); let sin = angle_rad.sin(); - - // Rotate around center: translate(-cx,-cy) * rotate * translate(cx,cy) Some(zeno::Transform::new( - cos, - -sin, - sin, - cos, + cos, -sin, sin, cos, cx - cos * cx + sin * cy, cy - sin * cx - cos * cy, )) @@ -201,48 +318,13 @@ fn draw_vector_layer( None }; - // Build clip mask if clip region is specified. - // Uses tiny-skia's Mask/PathBuilder because PixmapMut::draw_pixmap requires - // Option<&tiny_skia::Mask> for clipping — zeno has no equivalent type that - // tiny-skia's compositing API accepts. - let clip_mask = if let Some((x1, y1, x2, y2)) = data.clip { - let width = pixmap.width(); - let height = pixmap.height(); - if let Some(mut mask) = tiny_skia::Mask::new(width, height) { - let mut clip_path = tiny_skia::PathBuilder::new(); - clip_path.push_rect( - tiny_skia::Rect::from_ltrb(x1, y1, x2, y2) - .unwrap_or(tiny_skia::Rect::from_xywh(0.0, 0.0, 1.0, 1.0).unwrap()), - ); - if let Some(clip) = clip_path.finish() { - mask.fill_path( - &clip, - tiny_skia::FillRule::Winding, - false, - Transform::identity(), - ); - Some(mask) - } else { - None - } - } else { - None - } - } else { - None - }; - // Extract blur radius 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 - } + if *radius > 0.0 { Some(*radius) } else { None } } else { None } @@ -258,122 +340,44 @@ fn draw_vector_layer( let stroke_expand = data.stroke.as_ref().map(|s| s.width).unwrap_or(0.0); let padding = stroke_expand + blur_padding + 2.0; - // Rasterise the fill to alpha - let fill_mask = super::alpha_rasteriser::rasterise_to_alpha( - &data.path, - zeno_transform, - padding, - scratch, - zeno::Fill::NonZero.into(), - ); - - let Some(fill_mask) = fill_mask else { - return Ok(()); - }; - - // Build a temp pixmap large enough for fill + stroke + blur - let temp_w = fill_mask.width; - let temp_h = fill_mask.height; - let temp_ox = fill_mask.offset_x; - let temp_oy = fill_mask.offset_y; - - let Some(mut temp_pixmap) = arena_pixmap_mut(arena, temp_w.max(1), temp_h.max(1)) else { - return Ok(()); - }; - temp_pixmap.fill(tiny_skia::Color::TRANSPARENT); - - // Apply blur and colour to fill, composite onto temp - { - let mut alpha_data = fill_mask.data; - if blur_radius > 0.0 { - super::alpha_rasteriser::apply_stackblur_alpha( - &mut alpha_data, - temp_w as usize, - temp_h as usize, - blur_radius, - ); - } - let rgba = super::alpha_rasteriser::apply_colour(&alpha_data, data.color); - if let Some(src) = tiny_skia::PixmapRef::from_bytes(&rgba, temp_w, temp_h) { - let paint = tiny_skia::PixmapPaint { - blend_mode: tiny_skia::BlendMode::SourceOver, - ..Default::default() - }; - temp_pixmap.draw_pixmap(0, 0, src, &paint, Transform::identity(), None); - } - } - - // Rasterise and composite stroke if present + // Rasterise and cache stroke sub-layer (composites under fill) if let Some(stroke) = &data.stroke { let stroke_style = zeno::Stroke::new(stroke.width); - if let Some(stroke_mask) = super::alpha_rasteriser::rasterise_to_alpha( + rasterise_cache_and_composite( &data.path, + (&stroke_style).into(), zeno_transform, padding, + stroke.color, + blur_radius, + 0, scratch, - stroke_style.into(), - ) { - let mut alpha_data = stroke_mask.data; - let sw = stroke_mask.width as usize; - let sh = stroke_mask.height as usize; - if blur_radius > 0.0 { - super::alpha_rasteriser::apply_stackblur_alpha( - &mut alpha_data, - sw, - sh, - blur_radius, - ); - } - let rgba = super::alpha_rasteriser::apply_colour(&alpha_data, stroke.color); - - // Composite stroke at its offset relative to the temp pixmap - let dx = (stroke_mask.offset_x - temp_ox) as i32; - let dy = (stroke_mask.offset_y - temp_oy) as i32; - if let Some(src) = - tiny_skia::PixmapRef::from_bytes(&rgba, stroke_mask.width, stroke_mask.height) - { - let paint = tiny_skia::PixmapPaint { - blend_mode: tiny_skia::BlendMode::SourceOver, - ..Default::default() - }; - temp_pixmap.draw_pixmap(dx, dy, src, &paint, Transform::identity(), None); - } - } + pixmap, + arena, + cache, + vector_bitmap_key(data, SUB_LAYER_OUTLINE), + 0.0, + 0.0, + clip_mask.as_ref(), + ); } - // Cache the rendered bitmap. For fill-only vectors (no stroke), cache the - // alpha mask for colour-independent reuse. For stroke+fill, cache full RGBA. - let has_stroke = data.stroke.is_some(); - let (cache_data, cache_is_alpha) = if !has_stroke { - let rgba = temp_pixmap.data_mut(); - let alpha: Vec = rgba.chunks_exact(4).map(|px| px[3]).collect(); - (bytes::Bytes::from(alpha), true) - } else { - (bytes::Bytes::copy_from_slice(temp_pixmap.data_mut()), false) - }; - cache.bitmap_cache.insert( - bitmap_key, - crate::cache::CachedBitmap { - data: cache_data, - width: temp_pixmap.width(), - height: temp_pixmap.height(), - offset_x: temp_ox, - offset_y: temp_oy, - is_alpha: cache_is_alpha, - }, - ); - - // Composite onto output pixmap - let composite_paint = tiny_skia::PixmapPaint { - blend_mode: tiny_skia::BlendMode::SourceOver, - ..Default::default() - }; - pixmap.draw_pixmap( - temp_ox as i32, - temp_oy as i32, - temp_pixmap.as_ref(), - &composite_paint, - Transform::identity(), + // Rasterise and cache fill sub-layer + rasterise_cache_and_composite( + &data.path, + zeno::Fill::NonZero.into(), + zeno_transform, + padding, + data.color, + blur_radius, + 0, + scratch, + pixmap, + arena, + cache, + fill_key, + 0.0, + 0.0, clip_mask.as_ref(), ); @@ -389,98 +393,69 @@ fn skia_transform_to_zeno(t: Transform) -> zeno::Transform { zeno::Transform::new(t.sx, t.kx, t.ky, t.sy, t.tx, t.ty) } -/// Rasterise a layer (shadow/outline/fill) onto a temp RGBA pixmap using the -/// alpha mask pipeline: rasterise to alpha -> optionally blur -> apply colour -> composite. +/// Rasterise the outline-only alpha mask (stroke minus fill interior) using +/// a two-pass approach: rasterise the 2x stroke, then subtract the fill. /// -/// `commands`: zeno path commands to rasterise -/// `transform`: the zeno transform positioning the path in temp-pixmap space -/// `style`: fill or stroke style for rasterisation -/// `colour`: RGBA colour to apply -/// `blur_radius`: if > 0, blur the alpha mask before colouring -/// `edge_blur_passes`: if > 0, apply edge blur (ASS `\be` tag) -/// `scratch`: reusable zeno scratch buffer -/// `target`: the RGBA pixmap to composite onto -/// `arena`: bump allocator for scratch memory -fn rasterise_and_composite_layer( +/// Returns `None` if the stroke produces no visible mask. +fn rasterise_outline_alpha( commands: &[zeno::Command], - transform: zeno::Transform, - style: zeno::Style<'_>, - colour: [u8; 4], - blur_radius: f32, - edge_blur_passes: u32, + transform: Option, + stroke_width: f32, + padding: f32, scratch: &mut zeno::Scratch, - target: &mut PixmapMut<'_>, - arena: &bumpalo::Bump, -) { - use super::alpha_rasteriser::{apply_be_blur_alpha, apply_colour, apply_stackblur_alpha}; - - let is_identity = (transform.xx - 1.0).abs() < 1e-6 - && transform.xy.abs() < 1e-6 - && transform.yx.abs() < 1e-6 - && (transform.yy - 1.0).abs() < 1e-6 - && transform.x.abs() < 1e-6 - && transform.y.abs() < 1e-6; - - let xform = if is_identity { None } else { Some(transform) }; - - // Add padding for blur - let blur_padding = if blur_radius > 0.0 { - (blur_radius * 3.0).ceil() - } else { - 0.0 - }; - - let mask = match super::alpha_rasteriser::rasterise_to_alpha( - commands, - xform, - blur_padding, - scratch, - style, +) -> Option { + // Pass 1: rasterise 2x stroke + let mut stroke_style = zeno::Stroke::new(stroke_width * 2.0); + stroke_style.cap(zeno::Cap::Round).join(zeno::Join::Round); + + let mut stroke_mask = super::alpha_rasteriser::rasterise_to_alpha( + commands, transform, padding, scratch, (&stroke_style).into(), + )?; + + // Pass 2: rasterise fill into arena-allocated temp buffer, then subtract + if let Some(fill_mask) = super::alpha_rasteriser::rasterise_to_alpha( + commands, transform, padding, scratch, zeno::Fill::NonZero.into(), ) { - Some(m) => m, - None => return, - }; - - let w = mask.width as usize; - let h = mask.height as usize; - let mut alpha_data = mask.data; - - // Apply blur on single-channel alpha (4x less work than RGBA) - if blur_radius > 0.0 { - apply_stackblur_alpha(&mut alpha_data, w, h, blur_radius); - } - - // Apply edge blur if requested - if edge_blur_passes > 0 { - apply_be_blur_alpha(&mut alpha_data, w, h, edge_blur_passes, arena); + let sw = stroke_mask.width as usize; + let sh = stroke_mask.height as usize; + let stroke_ox = stroke_mask.offset_x; + let stroke_oy = stroke_mask.offset_y; + let fill_ox = fill_mask.offset_x; + let fill_oy = fill_mask.offset_y; + + for y in 0..sh { + for x in 0..sw { + let fx = (stroke_ox + x as f32) - fill_ox; + let fy = (stroke_oy + y as f32) - fill_oy; + let fxi = fx.round() as i32; + let fyi = fy.round() as i32; + + if fxi >= 0 + && fyi >= 0 + && (fxi as u32) < fill_mask.width + && (fyi as u32) < fill_mask.height + { + let fill_idx = fyi as usize * fill_mask.width as usize + fxi as usize; + let stroke_idx = y * sw + x; + stroke_mask.data[stroke_idx] = + stroke_mask.data[stroke_idx].saturating_sub(fill_mask.data[fill_idx]); + } + } + } } - // Convert alpha mask to premultiplied RGBA - let rgba = apply_colour(&alpha_data, colour); - - // Composite onto target via tiny-skia - let dst_x = mask.offset_x as i32; - let dst_y = mask.offset_y as i32; - - if let Some(src) = tiny_skia::PixmapRef::from_bytes(&rgba, mask.width, mask.height) { - let paint = tiny_skia::PixmapPaint { - blend_mode: tiny_skia::BlendMode::SourceOver, - ..Default::default() - }; - target.draw_pixmap(dst_x, dst_y, src, &paint, Transform::identity(), None); - } + Some(stroke_mask) } /// Draw text layer data onto the given pixmap using zeno alpha rasterisation. /// -/// The rendering pipeline is: -/// 1. Rasterise glyph commands to alpha mask via `zeno::Mask` -/// 2. Blur the single-channel alpha mask (4x less work than RGBA blur) -/// 3. Apply colour to produce premultiplied RGBA -/// 4. Composite onto output via `PixmapMut::draw_pixmap` +/// Each sub-layer (shadow, outline, fill) is cached as an independent alpha +/// mask keyed by `SUB_LAYER_SHADOW`, `SUB_LAYER_OUTLINE`, `SUB_LAYER_FILL`. +/// Colour is excluded from cache keys and applied at composite time via +/// `apply_colour`, enabling colour-independent caching. /// -/// Shadow, outline, and fill each follow this path with different -/// styles and colours. +/// On full cache hit (all present sub-layers cached), no rasterisation occurs. +/// On any miss, the missing sub-layers are rasterised and cached. fn draw_text_layer( pixmap: &mut PixmapMut<'_>, arena: &bumpalo::Bump, @@ -488,44 +463,12 @@ fn draw_text_layer( cache: &mut RenderCache, scratch: &mut zeno::Scratch, ) -> Result<(), RenderError> { - // --- Bitmap cache lookup --- - let bitmap_key = text_bitmap_key(data); - if let Some(cached) = cache.bitmap_cache.get(&bitmap_key) { - // Cache stores single-channel alpha masks. Apply the current event's - // fill colour to produce RGBA before compositing. - let rgba_data = if cached.is_alpha { - let rgba = super::alpha_rasteriser::apply_colour(&cached.data, data.color); - bytes::Bytes::from(rgba) - } else { - cached.data.clone() - }; - if let Some(src) = - tiny_skia::PixmapRef::from_bytes(&rgba_data, cached.width, cached.height) - { - let paint = tiny_skia::PixmapPaint { - blend_mode: tiny_skia::BlendMode::SourceOver, - ..Default::default() - }; - pixmap.draw_pixmap( - (data.x + cached.offset_x) as i32, - (data.y + cached.offset_y) as i32, - src, - &paint, - Transform::identity(), - None, - ); - } - return Ok(()); - } - // --- End bitmap cache lookup --- + use crate::cache::{SUB_LAYER_FILL, SUB_LAYER_OUTLINE, SUB_LAYER_SHADOW}; let entry = &data.shaped_entry; let shaped: &crate::pipeline::shaping::ShapedText = &entry.shaped; - // Flatten per-glyph paths into a single command list for rasterisation. - // Karaoke would iterate per-glyph instead — TODO for karaoke support. - let commands: Vec = entry.glyph_paths.iter().flatten().cloned().collect(); - + let commands: Vec = entry.glyph_paths.iter().flatten().copied().collect(); if commands.is_empty() { return Ok(()); } @@ -562,7 +505,7 @@ fn draw_text_layer( ); } - // Build the base transform (same logic as before — positioning + rotation + scale + shear) + // Build the base transform (positioning + rotation + scale + shear) let mut base_transform = Transform::from_translate(data.x, baseline_y); let (scale_x_factor, scale_y_factor) = data.effects.iter().fold((1.0f32, 1.0f32), |acc, e| { @@ -613,9 +556,6 @@ fn draw_text_layer( } // Create clip mask if needed. - // Uses tiny-skia's Mask/PathBuilder because PixmapMut::draw_pixmap requires - // Option<&tiny_skia::Mask> for clipping — zeno has no equivalent type that - // tiny-skia's compositing API accepts. let clip_mask = data.effects.iter().find_map(|e| { if let crate::pipeline::TextEffect::Clip { x1, @@ -656,11 +596,7 @@ fn draw_text_layer( .iter() .find_map(|e| { if let crate::pipeline::TextEffect::Blur { radius } = e { - if *radius > 0.0 { - Some(*radius) - } else { - None - } + if *radius > 0.0 { Some(*radius) } else { None } } else { None } @@ -672,19 +608,14 @@ fn draw_text_layer( .iter() .find_map(|e| { if let crate::pipeline::TextEffect::EdgeBlur { radius } = e { - if *radius > 0.0 { - Some(*radius as u32) - } else { - None - } + if *radius > 0.0 { Some(*radius as u32) } else { None } } else { None } }) .unwrap_or(0); - // Compute temp pixmap dimensions (same as before — accounts for shadow, outline, - // blur padding, rotation expansion). + // Compute expanded dimensions for rasterisation padding. let (shadow_x_offset, shadow_y_offset) = data.effects.iter().fold((0.0f32, 0.0f32), |acc, e| { if let crate::pipeline::TextEffect::Shadow { @@ -731,14 +662,6 @@ fn draw_text_layer( let expand = outline_width + shadow_x_offset.max(shadow_y_offset) + rotation_expand + blur_padding + 2.0; - let temp_w = (scaled_width + expand * 2.0).ceil() as u32; - let temp_h = (scaled_height + expand * 2.0).ceil() as u32; - - let Some(mut temp_pixmap) = arena_pixmap_mut(arena, temp_w.max(1), temp_h.max(1)) else { - // Cannot allocate temp pixmap — skip rendering - return Ok(()); - }; - temp_pixmap.fill(tiny_skia::Color::TRANSPARENT); // Build transform that maps glyph-local coords to temp-pixmap coords. let temp_base_transform = { @@ -749,9 +672,126 @@ fn draw_text_layer( }; let zeno_transform = skia_transform_to_zeno(temp_base_transform); - // --- Render layers: shadow, outline, fill --- + // Determine which sub-layers are needed + let has_shadow = data.effects.iter().any(|e| { + matches!(e, crate::pipeline::TextEffect::Shadow { .. }) + }); + let has_outline = outline_width > 0.0; + + // Determine fill colour (karaoke may override) + let fill_colour = if let Some((progress, karaoke_style)) = data.effects.iter().find_map(|e| { + if let crate::pipeline::TextEffect::Karaoke { progress, style } = e { + Some((*progress, *style)) + } else { + None + } + }) { + if karaoke_style == 0 { + if progress > 0.0 { + [255, 255, 0, data.color[3]] + } else { + data.color + } + } else if progress >= 1.0 { + [255, 255, 0, data.color[3]] + } else if progress <= 0.0 { + data.color + } 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; + [r, g, b, data.color[3]] + } + } else { + data.color + }; + + // --- Per-sub-layer bitmap cache lookup --- + let fill_key = text_bitmap_key(data, SUB_LAYER_FILL); + let outline_key = if has_outline { Some(text_bitmap_key(data, SUB_LAYER_OUTLINE)) } else { None }; + let shadow_key = if has_shadow { Some(text_bitmap_key(data, SUB_LAYER_SHADOW)) } else { None }; + + let fill_cached = cache.bitmap_cache.get(&fill_key); + let outline_cached = outline_key.as_ref().and_then(|k| cache.bitmap_cache.get(k)); + let shadow_cached = shadow_key.as_ref().and_then(|k| cache.bitmap_cache.get(k)); + + let all_cached = fill_cached.is_some() + && (!has_outline || outline_cached.is_some()) + && (!has_shadow || shadow_cached.is_some()); + + if all_cached { + // Composite all cached sub-layers in order: shadow, outline, fill. + // The offset from data.x/data.y is already baked into the cache entries' + // offset_x/offset_y relative to the data position. + if let Some(cached) = &shadow_cached { + // Shadow colour from effects + let shadow_colour = data.effects.iter().find_map(|e| { + if let crate::pipeline::TextEffect::Shadow { color, .. } = e { + Some(*color) + } else { + None + } + }).unwrap_or([0, 0, 0, 128]); + composite_cached_alpha( + pixmap, cached, shadow_colour, data.x, data.y, clip_mask.as_ref(), + ); + } + if let Some(cached) = &outline_cached { + let outline_colour = data.effects.iter().find_map(|e| { + if let crate::pipeline::TextEffect::Outline { color, .. } = e { + Some(*color) + } else { + None + } + }).unwrap_or([0, 0, 0, 255]); + composite_cached_alpha( + pixmap, cached, outline_colour, data.x, data.y, clip_mask.as_ref(), + ); + } + if let Some(cached) = &fill_cached { + composite_cached_alpha( + pixmap, cached, fill_colour, data.x, data.y, clip_mask.as_ref(), + ); + } + // Underline/strikethrough are cheap — always re-render on cache hit too + if underline || strikethrough { + 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) = arena_pixmap_mut(arena, temp_w.max(1), temp_h.max(1)) { + temp_pixmap.fill(tiny_skia::Color::TRANSPARENT); + let scaled_baseline = shaped.baseline * scale_y_factor; + render_underline_strikethrough( + &mut temp_pixmap, shaped, data.font_size, fill_colour, + expand, scaled_baseline, underline, strikethrough, + blur_radius, scratch, + ); + let paint = tiny_skia::PixmapPaint { + blend_mode: tiny_skia::BlendMode::SourceOver, + ..Default::default() + }; + pixmap.draw_pixmap( + (data.x - expand) as i32, + (data.y - expand) as i32, + temp_pixmap.as_ref(), + &paint, + Transform::identity(), + clip_mask.as_ref(), + ); + } + } + return Ok(()); + } + // --- End bitmap cache lookup --- + + let temp_w = (scaled_width + expand * 2.0).ceil() as u32; + let temp_h = (scaled_height + expand * 2.0).ceil() as u32; + + let Some(mut temp_pixmap) = arena_pixmap_mut(arena, temp_w.max(1), temp_h.max(1)) else { + return Ok(()); + }; + temp_pixmap.fill(tiny_skia::Color::TRANSPARENT); - // 1. Shadow layer + // --- 1. Shadow layer --- let outline_width_for_shadow = data.effects.iter().find_map(|e| { if let crate::pipeline::TextEffect::Outline { width, .. } = e { Some(*width) @@ -767,191 +807,272 @@ fn draw_text_layer( y_offset, } = effect { - // Shadow transform: base + shadow offset + // For shadow caching, we rasterise the shadow shape (stroke+fill at + // shadow offset) and cache the combined alpha mask. let shadow_skia = temp_base_transform.pre_translate(*x_offset, *y_offset); let shadow_zeno = skia_transform_to_zeno(shadow_skia); - // Shadow includes the outline stroke if border exists + // Rasterise shadow shape: stroke (if border) + fill, combined into + // one alpha mask. We use a temp buffer to composite both before caching. + let mut shadow_alpha: Option> = None; + let mut shadow_w = 0u32; + let mut shadow_h = 0u32; + let mut shadow_ox = 0.0f32; + let mut shadow_oy = 0.0f32; + + // Shadow stroke (border expanded) if let Some(bord_w) = outline_width_for_shadow { if bord_w > 0.0 { let mut stroke_style = zeno::Stroke::new(bord_w * 2.0); stroke_style.cap(zeno::Cap::Round).join(zeno::Join::Round); - rasterise_and_composite_layer( - &commands, - shadow_zeno, + if let Some(mask) = super::alpha_rasteriser::rasterise_to_alpha( + &commands, Some(shadow_zeno), blur_padding, scratch, (&stroke_style).into(), - *color, - blur_radius, - 0, - scratch, - &mut temp_pixmap, - arena, - ); + ) { + shadow_w = mask.width; + shadow_h = mask.height; + shadow_ox = mask.offset_x; + shadow_oy = mask.offset_y; + shadow_alpha = Some(mask.data); + } } } + // Shadow fill - rasterise_and_composite_layer( - &commands, - shadow_zeno, + if let Some(fill_mask) = super::alpha_rasteriser::rasterise_to_alpha( + &commands, Some(shadow_zeno), blur_padding, scratch, zeno::Fill::NonZero.into(), - *color, - blur_radius, - 0, - scratch, - &mut temp_pixmap, - arena, - ); + ) { + if let Some(ref mut existing) = shadow_alpha { + // Composite fill into existing stroke alpha (max blend) + let dx = (fill_mask.offset_x - shadow_ox).round() as i32; + let dy = (fill_mask.offset_y - shadow_oy).round() as i32; + for y in 0..fill_mask.height as i32 { + for x in 0..fill_mask.width as i32 { + let tx = x + dx; + let ty = y + dy; + if tx >= 0 && ty >= 0 + && (tx as u32) < shadow_w && (ty as u32) < shadow_h + { + let src_idx = y as usize * fill_mask.width as usize + x as usize; + let dst_idx = ty as usize * shadow_w as usize + tx as usize; + existing[dst_idx] = existing[dst_idx].max(fill_mask.data[src_idx]); + } + } + } + } else { + shadow_w = fill_mask.width; + shadow_h = fill_mask.height; + shadow_ox = fill_mask.offset_x; + shadow_oy = fill_mask.offset_y; + shadow_alpha = Some(fill_mask.data); + } + } + + if let Some(mut alpha_data) = shadow_alpha { + let sw = shadow_w as usize; + let sh = shadow_h as usize; + if blur_radius > 0.0 { + super::alpha_rasteriser::apply_stackblur_alpha( + &mut alpha_data, sw, sh, blur_radius, + ); + } + + // Cache the shadow alpha mask. + // offset_x/offset_y are stored relative to (data.x, data.y) + // so that the cache-hit path can composite at + // (data.x + cached.offset_x, data.y + cached.offset_y). + // The miss path blits temp_pixmap at (data.x - expand, data.y - expand), + // so final position = (data.x - expand + shadow_ox), requiring + // cached.offset_x = shadow_ox - expand. + if let Some(key) = &shadow_key { + cache.bitmap_cache.insert( + key.clone(), + crate::cache::CachedBitmap { + data: bytes::Bytes::from(alpha_data.clone()), + width: shadow_w, + height: shadow_h, + offset_x: shadow_ox - expand, + offset_y: shadow_oy - expand, + }, + ); + } + + // Apply colour and composite + let rgba = super::alpha_rasteriser::apply_colour(&alpha_data, *color); + if let Some(src) = tiny_skia::PixmapRef::from_bytes(&rgba, shadow_w, shadow_h) { + let paint = tiny_skia::PixmapPaint { + blend_mode: tiny_skia::BlendMode::SourceOver, + ..Default::default() + }; + temp_pixmap.draw_pixmap( + shadow_ox as i32, shadow_oy as i32, + src, &paint, Transform::identity(), None, + ); + } + } } } - // 2. Outline layer + // --- 2. Outline layer (two-pass: stroke - fill) --- for effect in &data.effects { if let crate::pipeline::TextEffect::Outline { color, width } = effect { - // libass expands outward by the full border width. zeno::Stroke - // extends width/2 each side, so use 2x width. We render the stroke - // mask, then subtract the fill interior to get outline-only. - let mut stroke_style = zeno::Stroke::new(*width * 2.0); - stroke_style.cap(zeno::Cap::Round).join(zeno::Join::Round); - - // Rasterise the full 2x stroke - let stroke_mask = super::alpha_rasteriser::rasterise_to_alpha( + if let Some(mut outline_mask) = rasterise_outline_alpha( &commands, Some(zeno_transform), + *width, blur_padding, scratch, - (&stroke_style).into(), - ); - - if let Some(stroke_mask) = stroke_mask { - // Rasterise the fill to subtract from the stroke - let fill_mask = super::alpha_rasteriser::rasterise_to_alpha( - &commands, - Some(zeno_transform), - blur_padding, - scratch, - zeno::Fill::NonZero.into(), - ); + ) { + let sw = outline_mask.width as usize; + let sh = outline_mask.height as usize; - let mut alpha_data = stroke_mask.data; - let sw = stroke_mask.width as usize; - let sh = stroke_mask.height as usize; - - // Subtract fill interior from stroke to get outline-only ring - if let Some(fill_mask) = fill_mask { - // The two masks may have different offsets — compute overlap - let stroke_ox = stroke_mask.offset_x; - let stroke_oy = stroke_mask.offset_y; - let fill_ox = fill_mask.offset_x; - let fill_oy = fill_mask.offset_y; - - for y in 0..sh { - for x in 0..sw { - // Map stroke pixel to fill pixel coords - let fx = (stroke_ox + x as f32) - fill_ox; - let fy = (stroke_oy + y as f32) - fill_oy; - let fxi = fx.round() as i32; - let fyi = fy.round() as i32; - - if fxi >= 0 - && fyi >= 0 - && (fxi as u32) < fill_mask.width - && (fyi as u32) < fill_mask.height - { - let fill_idx = - fyi as usize * fill_mask.width as usize + fxi as usize; - let stroke_idx = y * sw + x; - // Subtract fill from stroke (saturating) - alpha_data[stroke_idx] = - alpha_data[stroke_idx].saturating_sub(fill_mask.data[fill_idx]); - } - } - } - } - - // Apply blur and edge blur to the outline alpha if blur_radius > 0.0 { super::alpha_rasteriser::apply_stackblur_alpha( - &mut alpha_data, - sw, - sh, - blur_radius, + &mut outline_mask.data, sw, sh, blur_radius, ); } if edge_blur_passes > 0 { super::alpha_rasteriser::apply_be_blur_alpha( - &mut alpha_data, - sw, - sh, - edge_blur_passes, - arena, + &mut outline_mask.data, sw, sh, edge_blur_passes, arena, + ); + } + + // Cache outline alpha mask. + // Offsets adjusted by -expand so the cache-hit path can composite at + // (data.x + cached.offset_x, data.y + cached.offset_y) correctly. + if let Some(key) = &outline_key { + cache.bitmap_cache.insert( + key.clone(), + crate::cache::CachedBitmap { + data: bytes::Bytes::from(outline_mask.data.clone()), + width: outline_mask.width, + height: outline_mask.height, + offset_x: outline_mask.offset_x - expand, + offset_y: outline_mask.offset_y - expand, + }, ); } // Apply colour and composite - let rgba = super::alpha_rasteriser::apply_colour(&alpha_data, *color); - if let Some(src) = - tiny_skia::PixmapRef::from_bytes(&rgba, stroke_mask.width, stroke_mask.height) - { + let rgba = super::alpha_rasteriser::apply_colour(&outline_mask.data, *color); + if let Some(src) = tiny_skia::PixmapRef::from_bytes( + &rgba, outline_mask.width, outline_mask.height, + ) { let paint = tiny_skia::PixmapPaint { blend_mode: tiny_skia::BlendMode::SourceOver, ..Default::default() }; temp_pixmap.draw_pixmap( - stroke_mask.offset_x as i32, - stroke_mask.offset_y as i32, - src, - &paint, - Transform::identity(), - None, + outline_mask.offset_x as i32, + outline_mask.offset_y as i32, + src, &paint, Transform::identity(), None, ); } } } } - // 3. Fill layer (main text or karaoke colour) - let fill_colour = if let Some((progress, karaoke_style)) = data.effects.iter().find_map(|e| { - if let crate::pipeline::TextEffect::Karaoke { progress, style } = e { - Some((*progress, *style)) - } else { - None - } - }) { - // Determine karaoke colour - if karaoke_style == 0 { - if progress > 0.0 { - [255, 255, 0, data.color[3]] - } else { - data.color + // --- 3. Fill layer --- + { + if let Some(fill_mask) = super::alpha_rasteriser::rasterise_to_alpha( + &commands, + Some(zeno_transform), + blur_padding, + scratch, + zeno::Fill::NonZero.into(), + ) { + let fw = fill_mask.width as usize; + let fh = fill_mask.height as usize; + let mut alpha_data = fill_mask.data; + + if blur_radius > 0.0 { + super::alpha_rasteriser::apply_stackblur_alpha( + &mut alpha_data, fw, fh, blur_radius, + ); + } + if edge_blur_passes > 0 { + super::alpha_rasteriser::apply_be_blur_alpha( + &mut alpha_data, fw, fh, edge_blur_passes, arena, + ); + } + + // Cache fill alpha mask. + // Offsets adjusted by -expand so the cache-hit path can composite at + // (data.x + cached.offset_x, data.y + cached.offset_y) correctly. + cache.bitmap_cache.insert( + fill_key, + crate::cache::CachedBitmap { + data: bytes::Bytes::from(alpha_data.clone()), + width: fill_mask.width, + height: fill_mask.height, + offset_x: fill_mask.offset_x - expand, + offset_y: fill_mask.offset_y - expand, + }, + ); + + let rgba = super::alpha_rasteriser::apply_colour(&alpha_data, fill_colour); + if let Some(src) = tiny_skia::PixmapRef::from_bytes( + &rgba, fill_mask.width, fill_mask.height, + ) { + let paint = tiny_skia::PixmapPaint { + blend_mode: tiny_skia::BlendMode::SourceOver, + ..Default::default() + }; + temp_pixmap.draw_pixmap( + fill_mask.offset_x as i32, + fill_mask.offset_y as i32, + src, &paint, Transform::identity(), None, + ); } - } else if progress >= 1.0 { - [255, 255, 0, data.color[3]] - } else if progress <= 0.0 { - data.color - } 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; - [r, g, b, data.color[3]] } - } else { - data.color - }; + } - rasterise_and_composite_layer( - &commands, - zeno_transform, - zeno::Fill::NonZero.into(), - fill_colour, - blur_radius, - 0, // Edge blur applies to outline only - scratch, - &mut temp_pixmap, - arena, + // --- 4. Underline and strikethrough --- + let scaled_baseline = shaped.baseline * scale_y_factor; + render_underline_strikethrough( + &mut temp_pixmap, shaped, data.font_size, fill_colour, + expand, scaled_baseline, underline, strikethrough, + blur_radius, scratch, ); - // 4. Underline and strikethrough (rendered as stroked lines via zeno) - let scaled_baseline = shaped.baseline * scale_y_factor; + // --- Composite temp pixmap onto output --- + let offset_x = -expand; + let offset_y = -expand; + + let paint = tiny_skia::PixmapPaint { + blend_mode: tiny_skia::BlendMode::SourceOver, + ..Default::default() + }; + pixmap.draw_pixmap( + (data.x + offset_x) as i32, + (data.y + offset_y) as i32, + temp_pixmap.as_ref(), + &paint, + Transform::identity(), + clip_mask.as_ref(), + ); + + Ok(()) +} + +/// Render underline and/or strikethrough decorations onto a temp pixmap. +/// +/// Factored out to avoid duplication between cache-hit and cache-miss paths. +fn render_underline_strikethrough( + temp_pixmap: &mut PixmapMut<'_>, + shaped: &crate::pipeline::shaping::ShapedText, + font_size: f32, + fill_colour: [u8; 4], + expand: f32, + scaled_baseline: f32, + underline: bool, + strikethrough: bool, + blur_radius: f32, + scratch: &mut zeno::Scratch, +) { + use super::alpha_rasteriser::{apply_colour, apply_stackblur_alpha, rasterise_to_alpha}; + if underline { let underline_y = expand + scaled_baseline - shaped.descent / 2.0; let ul_x = expand; @@ -959,21 +1080,30 @@ fn draw_text_layer( zeno::Command::MoveTo(zeno::Vector::new(ul_x, underline_y)), zeno::Command::LineTo(zeno::Vector::new(ul_x + shaped.width, underline_y)), ]; - let stroke_w = data.font_size * 0.08; + let stroke_w = font_size * 0.08; let mut stroke_style = zeno::Stroke::new(stroke_w); stroke_style.cap(zeno::Cap::Round); - // Underline is drawn in temp-pixmap space directly (no transform needed) - rasterise_and_composite_layer( - &ul_commands, - zeno::Transform::IDENTITY, - (&stroke_style).into(), - fill_colour, - blur_radius, - 0, - scratch, - &mut temp_pixmap, - arena, - ); + if let Some(mask) = rasterise_to_alpha( + &ul_commands, None, 0.0, scratch, (&stroke_style).into(), + ) { + let mut alpha_data = mask.data; + if blur_radius > 0.0 { + apply_stackblur_alpha( + &mut alpha_data, mask.width as usize, mask.height as usize, blur_radius, + ); + } + let rgba = apply_colour(&alpha_data, fill_colour); + if let Some(src) = tiny_skia::PixmapRef::from_bytes(&rgba, mask.width, mask.height) { + let paint = tiny_skia::PixmapPaint { + blend_mode: tiny_skia::BlendMode::SourceOver, + ..Default::default() + }; + temp_pixmap.draw_pixmap( + mask.offset_x as i32, mask.offset_y as i32, + src, &paint, Transform::identity(), None, + ); + } + } } if strikethrough { @@ -983,82 +1113,47 @@ fn draw_text_layer( zeno::Command::MoveTo(zeno::Vector::new(st_x, strike_y)), zeno::Command::LineTo(zeno::Vector::new(st_x + shaped.width, strike_y)), ]; - let stroke_w = data.font_size * 0.06; + let stroke_w = font_size * 0.06; let mut stroke_style = zeno::Stroke::new(stroke_w); stroke_style.cap(zeno::Cap::Round); - rasterise_and_composite_layer( - &st_commands, - zeno::Transform::IDENTITY, - (&stroke_style).into(), - fill_colour, - blur_radius, - 0, - scratch, - &mut temp_pixmap, - arena, - ); + if let Some(mask) = rasterise_to_alpha( + &st_commands, None, 0.0, scratch, (&stroke_style).into(), + ) { + let mut alpha_data = mask.data; + if blur_radius > 0.0 { + apply_stackblur_alpha( + &mut alpha_data, mask.width as usize, mask.height as usize, blur_radius, + ); + } + let rgba = apply_colour(&alpha_data, fill_colour); + if let Some(src) = tiny_skia::PixmapRef::from_bytes(&rgba, mask.width, mask.height) { + let paint = tiny_skia::PixmapPaint { + blend_mode: tiny_skia::BlendMode::SourceOver, + ..Default::default() + }; + temp_pixmap.draw_pixmap( + mask.offset_x as i32, mask.offset_y as i32, + src, &paint, Transform::identity(), None, + ); + } + } } - - // --- Cache and composite the temp pixmap --- - let offset_x = -expand; - let offset_y = -expand; - - // For simple fill-only text (no outline, no shadow), cache the alpha mask - // so it can be reused colour-independently. For text with outline or shadow, - // cache the full composited RGBA since multiple colours are baked in. - let has_outline_or_shadow = outline_width > 0.0 - || shadow_x_offset > 0.0 - || shadow_y_offset > 0.0; - let store_alpha = !has_outline_or_shadow; - - let (cache_data, cache_is_alpha) = if store_alpha { - // Extract alpha channel from the RGBA temp pixmap - let rgba = temp_pixmap.data_mut(); - let alpha: Vec = rgba.chunks_exact(4).map(|px| px[3]).collect(); - (bytes::Bytes::from(alpha), true) - } else { - (bytes::Bytes::copy_from_slice(temp_pixmap.data_mut()), false) - }; - - cache.bitmap_cache.insert( - bitmap_key, - crate::cache::CachedBitmap { - data: cache_data, - width: temp_pixmap.width(), - height: temp_pixmap.height(), - offset_x, - offset_y, - is_alpha: cache_is_alpha, - }, - ); - - let paint = tiny_skia::PixmapPaint { - blend_mode: tiny_skia::BlendMode::SourceOver, - ..Default::default() - }; - pixmap.draw_pixmap( - (data.x + offset_x) as i32, - (data.y + offset_y) as i32, - temp_pixmap.as_ref(), - &paint, - Transform::identity(), - clip_mask.as_ref(), - ); - - Ok(()) } // --- Bitmap cache key computation helpers --- use crate::cache::BitmapCacheKey; -/// Compute a bitmap cache key from text layer data. -fn text_bitmap_key(data: &crate::pipeline::TextData) -> BitmapCacheKey { +/// Compute a bitmap cache key from text layer data for a specific sub-layer. +/// +/// The `sub_layer` discriminant distinguishes fill, outline, and shadow cache +/// entries. Colour is excluded from the key — all entries are alpha masks. +fn text_bitmap_key(data: &crate::pipeline::TextData, sub_layer: u8) -> BitmapCacheKey { use std::hash::{Hash, Hasher}; let mut hasher = std::hash::DefaultHasher::new(); data.text.hash(&mut hasher); data.font_family.hash(&mut hasher); - (data.font_size as u32).hash(&mut hasher); + data.font_size.to_bits().hash(&mut hasher); // Hash bold/italic as boolean presence flags let bold = data .effects @@ -1080,10 +1175,12 @@ fn text_bitmap_key(data: &crate::pipeline::TextData) -> BitmapCacheKey { let (scale_x, scale_y) = extract_scale(&data.effects); let rotation_z = extract_rotation_z(&data.effects); let blur = extract_blur(&data.effects); + let edge_blur = extract_edge_blur(&data.effects); let (outline_w, shadow_x, shadow_y) = extract_outline_shadow(&data.effects); BitmapCacheKey { content_hash, + sub_layer, scale_x: (scale_x * 100.0).round() as i32, scale_y: (scale_y * 100.0).round() as i32, rotation_z: (rotation_z * 10.0).round() as i32, @@ -1091,11 +1188,12 @@ fn text_bitmap_key(data: &crate::pipeline::TextData) -> BitmapCacheKey { outline_width: (outline_w * 10.0).round() as i32, shadow_x: (shadow_x * 10.0).round() as i32, shadow_y: (shadow_y * 10.0).round() as i32, + edge_blur: (edge_blur * 10.0).round() as i32, } } -/// Compute a bitmap cache key from vector layer data. -fn vector_bitmap_key(data: &crate::pipeline::VectorData) -> BitmapCacheKey { +/// Compute a bitmap cache key from vector layer data for a specific sub-layer. +fn vector_bitmap_key(data: &crate::pipeline::VectorData, sub_layer: u8) -> BitmapCacheKey { // content_hash is pre-computed from the drawing command string in the pipeline stage. // Colour is NOT hashed: the bitmap cache stores single-channel alpha masks. // Stroke width affects the mask shape, so it IS included. @@ -1112,10 +1210,12 @@ fn vector_bitmap_key(data: &crate::pipeline::VectorData) -> BitmapCacheKey { let (scale_x, scale_y) = extract_scale(&data.effects); let rotation_z = extract_rotation_z(&data.effects); let blur = extract_blur(&data.effects); + let edge_blur = extract_edge_blur(&data.effects); let (outline_w, shadow_x, shadow_y) = extract_outline_shadow(&data.effects); BitmapCacheKey { content_hash, + sub_layer, scale_x: (scale_x * 100.0).round() as i32, scale_y: (scale_y * 100.0).round() as i32, rotation_z: (rotation_z * 10.0).round() as i32, @@ -1123,6 +1223,7 @@ fn vector_bitmap_key(data: &crate::pipeline::VectorData) -> BitmapCacheKey { outline_width: (outline_w * 10.0).round() as i32, shadow_x: (shadow_x * 10.0).round() as i32, shadow_y: (shadow_y * 10.0).round() as i32, + edge_blur: (edge_blur * 10.0).round() as i32, } } @@ -1162,6 +1263,19 @@ fn extract_blur(effects: &[crate::pipeline::TextEffect]) -> f32 { .unwrap_or(0.0) } +fn extract_edge_blur(effects: &[crate::pipeline::TextEffect]) -> f32 { + effects + .iter() + .find_map(|e| { + if let crate::pipeline::TextEffect::EdgeBlur { radius } = e { + Some(*radius) + } else { + None + } + }) + .unwrap_or(0.0) +} + fn extract_outline_shadow(effects: &[crate::pipeline::TextEffect]) -> (f32, f32, f32) { let mut outline_w = 0.0f32; let mut shadow_x = 0.0f32; diff --git a/crates/ass-renderer/src/cache.rs b/crates/ass-renderer/src/cache.rs index 4c871b2..f940a47 100644 --- a/crates/ass-renderer/src/cache.rs +++ b/crates/ass-renderer/src/cache.rs @@ -15,14 +15,30 @@ use mini_moka_wasm::sync::Cache; /// Default bitmap cache memory budget: 64 MB. const DEFAULT_BITMAP_CACHE_BYTES: u64 = 64 * 1024 * 1024; +/// Sub-layer discriminant for per-layer bitmap caching. +/// +/// Text with outline/shadow is rendered as multiple sub-layers (shadow, +/// outline, fill), each cached as an independent alpha mask. The sub-layer +/// discriminant is part of the cache key so that different layers of the +/// same text get separate cache entries. +pub const SUB_LAYER_FILL: u8 = 0; +pub const SUB_LAYER_OUTLINE: u8 = 1; +pub const SUB_LAYER_SHADOW: u8 = 2; + /// Key for cached rasterised bitmaps. /// /// Transform parameters are quantised to fixed-point integers to avoid /// floating-point comparison issues in cache lookups. +/// +/// Colour is deliberately excluded — all cached bitmaps are alpha masks, +/// and colour is applied at composite time via `apply_colour`. #[derive(Debug, Clone, Hash, Eq, PartialEq)] pub struct BitmapCacheKey { /// Hash of content identity (text + font + size + bold + italic, or drawing commands) pub content_hash: u64, + /// Which sub-layer this entry represents (fill, outline, or shadow). + /// Use `SUB_LAYER_FILL`, `SUB_LAYER_OUTLINE`, or `SUB_LAYER_SHADOW`. + pub sub_layer: u8, /// Scale X * 100, rounded (e.g., 150 = 1.5x) pub scale_x: i32, /// Scale Y * 100, rounded @@ -37,13 +53,20 @@ pub struct BitmapCacheKey { pub shadow_x: i32, /// Shadow Y offset * 10, rounded pub shadow_y: i32, + /// Edge blur (\be) passes * 10, rounded (0 if no edge blur). + /// Text with and without \be must not share cache entries. + pub edge_blur: i32, } -/// Cached bitmap data — rasterised and blurred pixel data ready for compositing. +/// Cached bitmap data — a single-channel alpha mask ready for colour application +/// and compositing. +/// +/// All cached bitmaps are alpha masks (one byte per pixel). Colour is applied +/// at composite time via `apply_colour`, enabling colour-independent caching: +/// the same glyph shape reuses one cached mask regardless of colour. #[derive(Clone)] pub struct CachedBitmap { - /// Pixel data. If `is_alpha` is true, this is a single-channel alpha mask - /// (one byte per pixel). Otherwise it is RGBA (four bytes per pixel). + /// Single-channel alpha mask data (one byte per pixel). pub data: bytes::Bytes, /// Bitmap width in pixels pub width: u32, @@ -52,11 +75,6 @@ pub struct CachedBitmap { /// Offset from the event's base position to where this bitmap should be blitted pub offset_x: f32, pub offset_y: f32, - /// If true, `data` contains a single-channel alpha mask. Colour must be - /// applied via `apply_colour` before compositing. This enables colour- - /// independent caching: the same glyph shape reuses one cached mask - /// regardless of colour. - pub is_alpha: bool, } /// Memory-budget LRU cache for rasterised bitmaps. diff --git a/crates/ass-renderer/src/pipeline/shaping/mod.rs b/crates/ass-renderer/src/pipeline/shaping/mod.rs index 7cb6137..8635160 100644 --- a/crates/ass-renderer/src/pipeline/shaping/mod.rs +++ b/crates/ass-renderer/src/pipeline/shaping/mod.rs @@ -404,7 +404,7 @@ fn try_split_family_weight( style, }; if let Some(id) = font_db.query(&query) { - log::info!("font split: '{family}' → '{base}' + weight {}", weight.0); + log::trace!("font split: '{family}' → '{base}' + weight {}", weight.0); return Some(id); } @@ -416,7 +416,7 @@ fn try_split_family_weight( style, }; if let Some(id) = font_db.query(&relaxed) { - log::info!("font split (relaxed): '{family}' → '{base}'"); + log::trace!("font split (relaxed): '{family}' → '{base}'"); return Some(id); } @@ -716,7 +716,7 @@ impl GlyphRenderer { let outline_metrics = FontMetrics::from_face(&font); let real_height = outline_metrics.ascender - outline_metrics.descender; let scale = shaped.font_size / real_height; - log::info!("OUTLINE_SCALE: font_size={:.1} real_height={:.0} scale={:.6}", + log::trace!("OUTLINE_SCALE: font_size={:.1} real_height={:.0} scale={:.6}", shaped.font_size, real_height, scale); // Outline builder to convert ttf-parser outlines to zeno commands diff --git a/crates/ass-renderer/src/pipeline/software_pipeline.rs b/crates/ass-renderer/src/pipeline/software_pipeline.rs index e6912cb..082c598 100644 --- a/crates/ass-renderer/src/pipeline/software_pipeline.rs +++ b/crates/ass-renderer/src/pipeline/software_pipeline.rs @@ -488,7 +488,7 @@ impl SoftwarePipeline { color[3] = alpha; } - log::info!( + log::trace!( "DRAWING: color={:?} alpha={:?} move={:?} pos={:?} text='{}'", color, tags.colors.alpha, @@ -1134,7 +1134,7 @@ impl SoftwarePipeline { } else { line_height = shaped.height; } - log::info!("LINE_METRICS: font='{}' size={:.1} shaped.height={:.1} shaped.baseline={:.1} shaped.ascent={:.1} shaped.descent={:.1} shaped.width={:.1}", + log::trace!("LINE_METRICS: font='{}' size={:.1} shaped.height={:.1} shaped.baseline={:.1} shaped.ascent={:.1} shaped.descent={:.1} shaped.width={:.1}", font_family, font_size, shaped.height, shaped.baseline, shaped.ascent, shaped.descent, shaped.width); let underline = tags.formatting.underline.unwrap_or(default_underline); -- 2.51.2