diff --git a/crates/ass-renderer/src/backends/software.rs b/crates/ass-renderer/src/backends/software.rs index 1729bd3..30b1827 100644 --- a/crates/ass-renderer/src/backends/software.rs +++ b/crates/ass-renderer/src/backends/software.rs @@ -19,7 +19,7 @@ use std::{boxed::Box, sync::Arc, vec::Vec}; use bytes::{Bytes, BytesMut}; use crate::backends::{BackendFeature, BackendType, RenderBackend}; -use crate::cache::{RenderCache, TextCacheKey}; +use crate::cache::RenderCache; use crate::pipeline::{IntermediateLayer, Pipeline, SoftwarePipeline}; use crate::renderer::RenderContext; use crate::utils::{DirtyRegion, FontDb, RenderError}; @@ -232,15 +232,7 @@ fn draw_text_layer( ) -> 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)); + // Extract style flags from effects let underline = data .effects .iter() @@ -250,46 +242,62 @@ fn draw_text_layer( .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, - }; + // Use pre-shaped data from the pipeline when available. + // The pipeline shapes text once per segment and passes the results through + // TextData, eliminating redundant shaping in the backend. + let (shaped, paths) = match (&data.shaped, &data.glyph_paths) { + (Some(s), Some(p)) => (Arc::clone(s), Arc::clone(p)), + _ => { + // Fallback for backward compat — should not happen after phase 3 + 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 cache_key = crate::cache::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) + if let Some(cached) = cache.get_shaped(&cache_key) { + (Arc::new(cached.0.clone()), Arc::new(cached.1.clone())) + } else { + let shaped_text = shape_text_with_style( + &data.text, + &data.font_family, + data.font_size, + bold, + italic, + font_db, + )?; + let font_id = crate::pipeline::shaping::find_font( + font_db, + &data.font_family, + bold, + italic, + )?; + let rendered_paths = glyph_renderer.render_shaped_text( + &shaped_text, + font_id, + font_db, + data.spacing, + )?; + let cached = cache.store_shaped(cache_key, shaped_text, rendered_paths); + (Arc::new(cached.0.clone()), Arc::new(cached.1.clone())) + } + } }; - // 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, - )?; + // Dereference Arc wrappers so the rest of the function works with plain references. + let shaped: &crate::pipeline::shaping::ShapedText = &shaped; + let paths: &Vec = &paths; log::trace!(" Rendered {} glyph paths", paths.len()); @@ -616,7 +624,7 @@ fn draw_text_layer( let shadow_transform = render_base.pre_translate(*x_offset, *y_offset); - for path in &paths { + for path in paths.iter() { // Draw the shadow outline (stroked shape) if border exists if let Some(bord_w) = outline_width_for_shadow { if bord_w > 0.0 { @@ -702,7 +710,7 @@ fn draw_text_layer( ); let mut stroker = tiny_skia::PathStroker::new(); - for path in &paths { + for path in paths.iter() { if let Some(transformed) = path.clone().transform(temp_transform) { if let Some(outlined_path) = stroker.stroke(&transformed, &stroke, 1.0) @@ -753,7 +761,7 @@ fn draw_text_layer( let mut max_x = f32::MIN; let mut max_y = f32::MIN; let mut stroker = tiny_skia::PathStroker::new(); - for path in &paths { + for path in paths.iter() { if let Some(outlined) = stroker.stroke(path, &stroke, 1.0) { if let Some(t) = outlined.transform(render_base) { let b = t.bounds(); @@ -779,7 +787,7 @@ fn draw_text_layer( let mut stroker2 = tiny_skia::PathStroker::new(); // Draw full 2× stroke ring - for path in &paths { + for path in paths.iter() { 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) { @@ -799,7 +807,7 @@ fn draw_text_layer( 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 { + for path in paths.iter() { if let Some(transformed) = path.clone().transform(render_base) { if let Some(local) = transformed.transform(local_transform) { outline_temp.fill_path( @@ -910,7 +918,7 @@ fn draw_text_layer( karaoke_paint.anti_alias = true; karaoke_paint.blend_mode = tiny_skia::BlendMode::SourceOver; - for path in &paths { + for path in paths.iter() { if let Some(transformed) = path.clone().transform(text_transform) { $target.fill_path( &transformed, diff --git a/crates/ass-renderer/src/cache/mod.rs b/crates/ass-renderer/src/cache/mod.rs index 71dd429..6e49fb6 100644 --- a/crates/ass-renderer/src/cache/mod.rs +++ b/crates/ass-renderer/src/cache/mod.rs @@ -1,19 +1,19 @@ -//! Caching system for expensive operations +//! Caching system for expensive operations using mini-moka-wasm LRU caches. use crate::pipeline::shaping::ShapedText; use tiny_skia::Path; -#[cfg(not(feature = "nostd"))] -use std::collections::HashMap; #[cfg(not(feature = "nostd"))] use std::sync::Arc; #[cfg(feature = "nostd")] -use alloc::collections::BTreeMap as HashMap; -#[cfg(feature = "nostd")] -use alloc::{string::String, sync::Arc}; +use alloc::{string::String, sync::Arc, vec::Vec}; +#[cfg(not(feature = "nostd"))] +use std::{string::String, vec::Vec}; -/// Cache key for shaped text +use mini_moka_wasm::sync::Cache; + +/// Cache key for shaped text (used by pipeline shaping cache). #[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd)] pub struct TextCacheKey { /// Text content to be shaped @@ -35,15 +35,18 @@ pub struct DrawingCacheKey { pub commands: String, } -/// Render cache for expensive operations +/// Render cache for expensive operations. +/// +/// Uses mini-moka-wasm bounded LRU caches instead of hand-rolled HashMap + +/// random eviction. Both `SoftwarePipeline` and `SoftwareBackend` hold their +/// own `RenderCache` instance, using different subsets of the caches within. pub struct RenderCache { - /// Cache for shaped text - shaped_text_cache: HashMap>, - max_shaped_entries: usize, + /// Cache for drawing paths (used by pipeline for \p drawing commands) + drawing_path_cache: Cache>, - /// Cache for drawing paths - drawing_path_cache: HashMap>, - max_drawing_entries: usize, + /// Cache for shaped text + glyph paths (used by pipeline for cross-frame reuse). + /// Key: (text, font, size, bold, italic) -> (ShapedText, Vec) + shaping_cache: Cache)>>, /// Cache statistics pub stats: CacheStats, @@ -52,73 +55,62 @@ pub struct RenderCache { /// Cache statistics for monitoring #[derive(Debug, Default, Clone)] pub struct CacheStats { - /// Number of text cache hits - pub text_hits: usize, - /// Number of text cache misses - pub text_misses: usize, + /// Number of shaping cache hits + pub shaping_hits: usize, + /// Number of shaping cache misses + pub shaping_misses: usize, /// Number of drawing cache hits pub drawing_hits: usize, /// Number of drawing cache misses pub drawing_misses: usize, - /// Number of cache evictions - pub evictions: usize, } impl RenderCache { - /// Create a new render cache + /// Create a new render cache with default limits. pub fn new() -> Self { - Self { - shaped_text_cache: HashMap::new(), - max_shaped_entries: 1000, - drawing_path_cache: HashMap::new(), - max_drawing_entries: 500, - stats: CacheStats::default(), - } + Self::with_limits(1000, 500) } - /// Create with custom limits - pub fn with_limits(max_shaped: usize, max_drawing: usize) -> Self { + /// Create with custom limits. + /// + /// `max_shaping`: maximum entries in the shaping (text+paths) cache. + /// `max_drawing`: maximum entries in the drawing path cache. + pub fn with_limits(max_shaping: usize, max_drawing: usize) -> Self { Self { - shaped_text_cache: HashMap::new(), - max_shaped_entries: max_shaped, - drawing_path_cache: HashMap::new(), - max_drawing_entries: max_drawing, + drawing_path_cache: Cache::new(max_drawing as u64), + shaping_cache: Cache::new(max_shaping as u64), stats: CacheStats::default(), } } - /// Get shaped text from cache - pub fn get_shaped_text(&mut self, key: &TextCacheKey) -> Option> { - if let Some(shaped) = self.shaped_text_cache.get(key) { - self.stats.text_hits += 1; - Some(Arc::clone(shaped)) + /// Look up pre-shaped text and glyph paths in the shaping cache. + pub fn get_shaped(&mut self, key: &TextCacheKey) -> Option)>> { + if let Some(entry) = self.shaping_cache.get(key) { + self.stats.shaping_hits += 1; + Some(entry) } else { - self.stats.text_misses += 1; + self.stats.shaping_misses += 1; None } } - /// Store shaped text in cache - pub fn store_shaped_text(&mut self, key: TextCacheKey, shaped: ShapedText) -> Arc { - // Evict if at capacity - if self.shaped_text_cache.len() >= self.max_shaped_entries { - // Simple LRU: remove first item (not ideal but simple) - if let Some(first_key) = self.shaped_text_cache.keys().next().cloned() { - self.shaped_text_cache.remove(&first_key); - self.stats.evictions += 1; - } - } - - let arc_shaped = Arc::new(shaped); - self.shaped_text_cache.insert(key, Arc::clone(&arc_shaped)); - arc_shaped + /// Store pre-shaped text and glyph paths in the shaping cache. + pub fn store_shaped( + &mut self, + key: TextCacheKey, + shaped: ShapedText, + paths: Vec, + ) -> Arc<(ShapedText, Vec)> { + let arc = Arc::new((shaped, paths)); + self.shaping_cache.insert(key, Arc::clone(&arc)); + arc } /// Get drawing path from cache pub fn get_drawing_path(&mut self, key: &DrawingCacheKey) -> Option> { if let Some(path) = self.drawing_path_cache.get(key) { self.stats.drawing_hits += 1; - Some(path.clone()) + Some(path) } else { self.stats.drawing_misses += 1; None @@ -127,21 +119,13 @@ impl RenderCache { /// Store drawing path in cache pub fn store_drawing_path(&mut self, key: DrawingCacheKey, path: Option) { - // Evict if at capacity - if self.drawing_path_cache.len() >= self.max_drawing_entries { - if let Some(first_key) = self.drawing_path_cache.keys().next().cloned() { - self.drawing_path_cache.remove(&first_key); - self.stats.evictions += 1; - } - } - self.drawing_path_cache.insert(key, path); } /// Clear all caches pub fn clear(&mut self) { - self.shaped_text_cache.clear(); - self.drawing_path_cache.clear(); + self.drawing_path_cache.invalidate_all(); + self.shaping_cache.invalidate_all(); self.stats = CacheStats::default(); } @@ -153,40 +137,35 @@ impl RenderCache { /// Print cache statistics pub fn print_stats(&self) { #[cfg(not(feature = "nostd"))] - let text_ratio = if self.stats.text_hits + self.stats.text_misses > 0 { - self.stats.text_hits as f64 / (self.stats.text_hits + self.stats.text_misses) as f64 - } else { - 0.0 - }; - - #[cfg(not(feature = "nostd"))] - let drawing_ratio = if self.stats.drawing_hits + self.stats.drawing_misses > 0 { - self.stats.drawing_hits as f64 - / (self.stats.drawing_hits + self.stats.drawing_misses) as f64 - } else { - 0.0 - }; - - #[cfg(not(feature = "nostd"))] - eprintln!("=== Cache Statistics ==="); - #[cfg(not(feature = "nostd"))] - eprintln!( - "Text Cache: {} entries, {:.1}% hit rate ({}/{} hits)", - self.shaped_text_cache.len(), - text_ratio * 100.0, - self.stats.text_hits, - self.stats.text_hits + self.stats.text_misses - ); - #[cfg(not(feature = "nostd"))] - eprintln!( - "Drawing Cache: {} entries, {:.1}% hit rate ({}/{} hits)", - self.drawing_path_cache.len(), - drawing_ratio * 100.0, - self.stats.drawing_hits, - self.stats.drawing_hits + self.stats.drawing_misses - ); - #[cfg(not(feature = "nostd"))] - eprintln!("Total Evictions: {}", self.stats.evictions); + { + let shaping_total = self.stats.shaping_hits + self.stats.shaping_misses; + let shaping_ratio = if shaping_total > 0 { + self.stats.shaping_hits as f64 / shaping_total as f64 + } else { + 0.0 + }; + + let drawing_total = self.stats.drawing_hits + self.stats.drawing_misses; + let drawing_ratio = if drawing_total > 0 { + self.stats.drawing_hits as f64 / drawing_total as f64 + } else { + 0.0 + }; + + eprintln!("=== Cache Statistics ==="); + eprintln!( + "Shaping Cache: {:.1}% hit rate ({}/{} hits)", + shaping_ratio * 100.0, + self.stats.shaping_hits, + shaping_total, + ); + eprintln!( + "Drawing Cache: {:.1}% hit rate ({}/{} hits)", + drawing_ratio * 100.0, + self.stats.drawing_hits, + drawing_total, + ); + } } } diff --git a/crates/ass-renderer/src/pipeline/software_pipeline.rs b/crates/ass-renderer/src/pipeline/software_pipeline.rs index 95d09f3..b48e30f 100644 --- a/crates/ass-renderer/src/pipeline/software_pipeline.rs +++ b/crates/ass-renderer/src/pipeline/software_pipeline.rs @@ -15,6 +15,7 @@ use std::{ use crate::pipeline::{ animation::calculate_move_progress, drawing::process_drawing_commands, + shaping, shaping::{shape_text_with_style, GlyphRenderer}, tag_processor::{KaraokeStyle, ProcessedTags}, text_segmenter::{segment_text_with_tags, TextSegment}, @@ -825,57 +826,102 @@ impl SoftwarePipeline { default_back_color, ); - // ── Pre-compute total line width ──────────────────────────── + // ── Shape pass: shape each segment once, compute line width ── // - // Shape each segment independently (matching libass: each style - // change = new shaping run) and sum advances to get the total - // line width needed for \pos + alignment calculations. + // Shape each non-drawing segment, render glyph paths, and store + // results. Accumulate total_line_width for alignment. This + // replaces the previous two-pass approach that shaped twice. + struct ShapedSegment { + shaped: Arc, + glyph_paths: Arc>, + seg_advance: f32, + } + let mut total_line_width: f32 = 0.0; + let mut shaped_segments: Vec> = Vec::with_capacity(line_segments.len()); + for segment in &line_segments { let mut tags = segment.tags.clone(); if tags.drawing_mode.map_or(false, |m| m > 0) { + shaped_segments.push(None); continue; } self.apply_transform_animations(&mut tags, event_start_cs, time_cs, default_colors); let base_font_size = tags.font.size.unwrap_or(default_font_size_base); let font_scale_x = tags.font.scale_x.unwrap_or(default_scale_x) / 100.0; - let _font_scale_y = tags.font.scale_y.unwrap_or(default_scale_y) / 100.0; - // Do NOT bake fscy into the font size — libass applies fscx/fscy - // as glyph transforms after rasterization, not as font size multipliers. let actual_font_size = base_font_size * scale_y; let font_name = tags.font.name.as_deref().unwrap_or(default_font_name); let bold = tags.formatting.bold.unwrap_or(default_bold); let italic = tags.formatting.italic.unwrap_or(default_italic); + let spacing = tags.font.spacing.unwrap_or(default_spacing); + let spacing_scaled = spacing * scale_x; - let shaped = shape_text_with_style( - &segment.text, - font_name, - actual_font_size, + // Cache key for cross-frame reuse + let cache_key = crate::cache::TextCacheKey { + text: segment.text.clone(), + font_family: font_name.to_string(), + font_size: actual_font_size as u32, bold, italic, - &self.font_db, - )?; - let spacing = tags.font.spacing.unwrap_or(default_spacing); + }; + + let (shaped, glyph_paths) = if let Some(cached) = self.cache.get_shaped(&cache_key) { + (Arc::new(cached.0.clone()), Arc::new(cached.1.clone())) + } else { + let shaped_text = shape_text_with_style( + &segment.text, + font_name, + actual_font_size, + bold, + italic, + &self.font_db, + )?; + + // Render glyph paths + let font_id = shaping::find_font( + &self.font_db, + font_name, + bold, + italic, + )?; + let paths = self.glyph_renderer.render_shaped_text( + &shaped_text, + font_id, + &self.font_db, + spacing_scaled, + )?; + + let cached = self.cache.store_shaped(cache_key, shaped_text, paths); + (Arc::new(cached.0.clone()), Arc::new(cached.1.clone())) + }; + let char_count = segment.text.chars().count(); let spacing_extra = if char_count > 1 { - spacing * scale_x * (char_count - 1) as f32 + spacing_scaled * (char_count - 1) as f32 } else { 0.0 }; - total_line_width += shaped.total_advance().unwrap_or(0.0) * font_scale_x + spacing_extra; + let seg_advance = shaped.total_advance().unwrap_or(shaped.width) * font_scale_x + spacing_extra; + total_line_width += seg_advance; + + shaped_segments.push(Some(ShapedSegment { + shaped, + glyph_paths, + seg_advance, + })); } - // ── Process each segment ──────────────────────────────────── + // ── Emit pass: build TextData layers from pre-shaped data ─── // - // Each segment is shaped independently and emitted as its own - // TextData layer, matching libass's per-segment shaping model. + // Each segment is emitted as its own TextData layer with + // pre-shaped data attached, matching libass's per-segment model. let mut current_x = 0.0; let mut current_y: Option = None; let mut needs_initial_position = true; let mut karaoke_accumulated_time = 0u32; - for segment in line_segments.into_iter() { + for (seg_idx, segment) in line_segments.into_iter().enumerate() { let mut tags = segment.tags.clone(); let line_text = &segment.text; @@ -891,37 +937,22 @@ impl SoftwarePipeline { continue; } + // Retrieve pre-shaped data (guaranteed Some for non-drawing segments) + let shaped_seg = shaped_segments[seg_idx].take() + .expect("shaped segment missing for non-drawing segment"); + let shaped = shaped_seg.shaped; + let glyph_paths = shaped_seg.glyph_paths; + let seg_advance = shaped_seg.seg_advance; + // Apply transform animations self.apply_transform_animations(&mut tags, event_start_cs, time_cs, default_colors); let base_font_size = tags.font.size.unwrap_or(default_font_size_base); let font_scale_x = tags.font.scale_x.unwrap_or(default_scale_x) / 100.0; let font_scale_y = tags.font.scale_y.unwrap_or(default_scale_y) / 100.0; - // Do NOT bake fscy into the font size — libass applies fscx/fscy - // as glyph transforms after rasterization, not as font size multipliers. let actual_font_size = base_font_size * scale_y; - let font_name = tags.font.name.as_deref().unwrap_or(default_font_name); let bold = tags.formatting.bold.unwrap_or(default_bold); let italic = tags.formatting.italic.unwrap_or(default_italic); - - // Shape this segment independently - let shaped = shape_text_with_style( - line_text, - font_name, - actual_font_size, - bold, - italic, - &self.font_db, - )?; - - let spacing = tags.font.spacing.unwrap_or(default_spacing); - let char_count = line_text.chars().count(); - let spacing_extra = if char_count > 1 { - spacing * scale_x * (char_count - 1) as f32 - } else { - 0.0 - }; - let seg_advance = shaped.total_advance().unwrap_or(shaped.width) * font_scale_x + spacing_extra; log::trace!( "SEG: text='{}' total_advance={:.1} width={:.1} fscx={:.2} seg_advance={:.1} current_x={:.1}", &line_text[..line_text.len().min(10)], @@ -1445,8 +1476,8 @@ impl SoftwarePipeline { effects: seg_effects, spacing: spacing * scale_x, anchor: segment_anchor, - shaped: None, - glyph_paths: None, + shaped: Some(Arc::clone(&shaped)), + glyph_paths: Some(Arc::clone(&glyph_paths)), }; // Handle baseline offset