diff --git a/Cargo.lock b/Cargo.lock index cd6491a..c1ebf08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -254,7 +254,6 @@ dependencies = [ "imgref", "log", "mini-moka-wasm", - "pkg-config", "pollster 0.3.0", "pretty_assertions", "rayon", @@ -285,6 +284,7 @@ dependencies = [ "ffmpeg-next", "image 0.24.9", "log", + "tempfile", "ttf-parser 0.20.0", ] diff --git a/crates/ass-renderer/Cargo.toml b/crates/ass-renderer/Cargo.toml index cac9354..7fdb479 100644 --- a/crates/ass-renderer/Cargo.toml +++ b/crates/ass-renderer/Cargo.toml @@ -60,9 +60,6 @@ rfd = { version = "0.13", optional = true } [target.'cfg(target_arch = "wasm32")'.dependencies] getrandom = { version = "0.3", features = ["wasm_js"] } -[build-dependencies] -pkg-config = "0.3" - [dev-dependencies] pretty_assertions = "1.4" diff --git a/crates/ass-renderer/src/backends/software.rs b/crates/ass-renderer/src/backends/software.rs index 789909e..3864372 100644 --- a/crates/ass-renderer/src/backends/software.rs +++ b/crates/ass-renderer/src/backends/software.rs @@ -185,20 +185,21 @@ fn rasterise_cache_and_composite( apply_be_blur_alpha(&mut alpha_data, w, h, edge_blur_passes, arena); } - // Cache the alpha mask + // Apply colour first (borrows alpha_data immutably), then move alpha_data + // into Bytes::from — zero-copy, avoids one Vec allocation per cache miss. + let rgba = apply_colour(&alpha_data, colour); + + // Cache the alpha mask (moves alpha_data, no clone needed) cache.bitmap_cache.insert( cache_key, crate::cache::CachedBitmap { - data: bytes::Bytes::from(alpha_data.clone()), + data: bytes::Bytes::from(alpha_data), 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; @@ -310,7 +311,7 @@ fn draw_vector_layer( let cos = angle_rad.cos(); let sin = angle_rad.sin(); Some(zeno::Transform::new( - cos, -sin, sin, cos, + cos, sin, -sin, cos, cx - cos * cx + sin * cy, cy - sin * cx - cos * cy, )) @@ -384,13 +385,42 @@ fn draw_vector_layer( Ok(()) } -/// Convert a `tiny_skia::Transform` to a `zeno::Transform`. +/// Concatenate two zeno transforms: `self * other` (apply `other` first, then `self`). /// -/// Both use a 2x3 affine matrix with the same field semantics: -/// tiny_skia: `[sx, kx, ky, sy, tx, ty]` -/// zeno: `[xx, xy, yx, yy, x, y ]` -fn skia_transform_to_zeno(t: Transform) -> zeno::Transform { - zeno::Transform::new(t.sx, t.kx, t.ky, t.sy, t.tx, t.ty) +/// zeno applies: `x' = x*xx + y*yx + tx`, `y' = x*xy + y*yy + ty` +fn zeno_concat(a: zeno::Transform, b: zeno::Transform) -> zeno::Transform { + zeno::Transform::new( + a.xx * b.xx + a.yx * b.xy, + a.xy * b.xx + a.yy * b.xy, + a.xx * b.yx + a.yx * b.yy, + a.xy * b.yx + a.yy * b.yy, + a.xx * b.x + a.yx * b.y + a.x, + a.xy * b.x + a.yy * b.y + a.y, + ) +} + +/// Translate then apply `self`: equivalent to tiny_skia's `pre_translate`. +fn zeno_pre_translate(t: zeno::Transform, tx: f32, ty: f32) -> zeno::Transform { + zeno_concat(t, zeno::Transform::translation(tx, ty)) +} + +/// Rotate then apply `self`: equivalent to tiny_skia's `pre_rotate`. +fn zeno_pre_rotate(t: zeno::Transform, degrees: f32) -> zeno::Transform { + let rad = degrees * core::f32::consts::PI / 180.0; + let (sin, cos) = rad.sin_cos(); + let rot = zeno::Transform::new(cos, sin, -sin, cos, 0.0, 0.0); + zeno_concat(t, rot) +} + +/// Scale then apply `self`: equivalent to tiny_skia's `pre_scale`. +fn zeno_pre_scale(t: zeno::Transform, sx: f32, sy: f32) -> zeno::Transform { + let scale = zeno::Transform::new(sx, 0.0, 0.0, sy, 0.0, 0.0); + zeno_concat(t, scale) +} + +/// Create a skew transform. Parameters are tangent values (not angles). +fn zeno_skew(kx: f32, ky: f32) -> zeno::Transform { + zeno::Transform::new(1.0, ky, kx, 1.0, 0.0, 0.0) } /// Rasterise the outline-only alpha mask (stroke minus fill interior) using @@ -506,7 +536,7 @@ fn draw_text_layer( } // Build the base transform (positioning + rotation + scale + shear) - let mut base_transform = Transform::from_translate(data.x, baseline_y); + let mut base_transform = zeno::Transform::translation(data.x, baseline_y); 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 { @@ -525,31 +555,34 @@ fn draw_text_layer( } 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); + base_transform = zeno_pre_translate( + zeno_pre_rotate( + zeno_pre_translate(base_transform, rot_cx, rot_cy), + -*z, + ), + -rot_cx, -rot_cy, + ); } if *x != 0.0 { let angle_rad = x * core::f32::consts::PI / 180.0; let skew_y = angle_rad.sin() * 0.5; - base_transform = Transform::from_skew(0.0, skew_y).pre_concat(base_transform); + base_transform = zeno_concat(zeno_skew(0.0, skew_y), base_transform); } if *y != 0.0 { let angle_rad = y * core::f32::consts::PI / 180.0; let skew_x = angle_rad.sin() * 0.5; - base_transform = Transform::from_skew(skew_x, 0.0).pre_concat(base_transform); + base_transform = zeno_concat(zeno_skew(skew_x, 0.0), base_transform); } } crate::pipeline::TextEffect::Scale { x, y } => { let x_scale = *x / 100.0; let y_scale = *y / 100.0; 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); + base_transform = zeno_pre_scale(base_transform, x_scale, y_scale); } } crate::pipeline::TextEffect::Shear { x, y } => { - base_transform = Transform::from_skew(*x, *y).pre_concat(base_transform); + base_transform = zeno_concat(zeno_skew(*x, *y), base_transform); } _ => {} } @@ -664,13 +697,12 @@ fn draw_text_layer( outline_width + shadow_x_offset.max(shadow_y_offset) + rotation_expand + blur_padding + 2.0; // Build transform that maps glyph-local coords to temp-pixmap coords. - let temp_base_transform = { + let zeno_transform = { 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) + zeno_concat(zeno::Transform::translation(delta_x, delta_y), base_transform) }; - let zeno_transform = skia_transform_to_zeno(temp_base_transform); // Determine which sub-layers are needed let has_shadow = data.effects.iter().any(|e| { @@ -809,8 +841,7 @@ fn draw_text_layer( { // 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); + let shadow_zeno = zeno_pre_translate(zeno_transform, *x_offset, *y_offset); // Rasterise shadow shape: stroke (if border) + fill, combined into // one alpha mask. We use a temp buffer to composite both before caching. @@ -878,6 +909,10 @@ fn draw_text_layer( ); } + // Apply colour first (borrows alpha_data immutably), then move + // alpha_data into Bytes::from — avoids one Vec allocation per miss. + let rgba = super::alpha_rasteriser::apply_colour(&alpha_data, *color); + // 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 @@ -889,7 +924,7 @@ fn draw_text_layer( cache.bitmap_cache.insert( key.clone(), crate::cache::CachedBitmap { - data: bytes::Bytes::from(alpha_data.clone()), + data: bytes::Bytes::from(alpha_data), width: shadow_w, height: shadow_h, offset_x: shadow_ox - expand, @@ -897,9 +932,6 @@ fn draw_text_layer( }, ); } - - // 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, @@ -938,6 +970,10 @@ fn draw_text_layer( ); } + // Apply colour first (borrows outline_mask.data immutably), then + // move the data into Bytes::from — avoids one Vec allocation per miss. + let rgba = super::alpha_rasteriser::apply_colour(&outline_mask.data, *color); + // 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. @@ -945,7 +981,7 @@ fn draw_text_layer( cache.bitmap_cache.insert( key.clone(), crate::cache::CachedBitmap { - data: bytes::Bytes::from(outline_mask.data.clone()), + data: bytes::Bytes::from(outline_mask.data), width: outline_mask.width, height: outline_mask.height, offset_x: outline_mask.offset_x - expand, @@ -953,9 +989,6 @@ fn draw_text_layer( }, ); } - - // Apply colour and composite - 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, ) { @@ -997,21 +1030,23 @@ fn draw_text_layer( ); } + // Apply colour first (borrows alpha_data immutably), then move + // alpha_data into Bytes::from — avoids one Vec allocation per miss. + let rgba = super::alpha_rasteriser::apply_colour(&alpha_data, fill_colour); + // 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()), + data: bytes::Bytes::from(alpha_data), 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, ) { @@ -1170,10 +1205,23 @@ fn text_bitmap_key(data: &crate::pipeline::TextData, sub_layer: u8) -> BitmapCac // colour. Colour is applied at composite time via `apply_colour`. // Spacing affects glyph positions data.spacing.to_bits().hash(&mut hasher); + // Anchor shifts where rotation is applied. Hash the anchor-relative offset + // so that identical text at the same position but with different \pos anchors + // gets separate cache entries. We store the offset from the layer position + // rather than the raw anchor coords so that moving text without changing + // its anchor relationship reuses cache entries correctly. + if let Some((anchor_x, anchor_y)) = data.anchor { + 1u8.hash(&mut hasher); + (anchor_x - data.x).to_bits().hash(&mut hasher); + (anchor_y - data.y).to_bits().hash(&mut hasher); + } else { + 0u8.hash(&mut hasher); + } let content_hash = hasher.finish(); let (scale_x, scale_y) = extract_scale(&data.effects); - let rotation_z = extract_rotation_z(&data.effects); + let (rotation_x, rotation_y, rotation_z) = extract_rotations(&data.effects); + let (shear_x, shear_y) = extract_shear(&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); @@ -1189,6 +1237,10 @@ fn text_bitmap_key(data: &crate::pipeline::TextData, sub_layer: u8) -> BitmapCac 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, + rotation_x: (rotation_x * 10.0).round() as i32, + rotation_y: (rotation_y * 10.0).round() as i32, + shear_x: (shear_x * 100.0).round() as i32, + shear_y: (shear_y * 100.0).round() as i32, } } @@ -1208,7 +1260,8 @@ fn vector_bitmap_key(data: &crate::pipeline::VectorData, sub_layer: u8) -> Bitma }; let (scale_x, scale_y) = extract_scale(&data.effects); - let rotation_z = extract_rotation_z(&data.effects); + let (rotation_x, rotation_y, rotation_z) = extract_rotations(&data.effects); + let (shear_x, shear_y) = extract_shear(&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); @@ -1224,6 +1277,10 @@ fn vector_bitmap_key(data: &crate::pipeline::VectorData, sub_layer: u8) -> Bitma 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, + rotation_x: (rotation_x * 10.0).round() as i32, + rotation_y: (rotation_y * 10.0).round() as i32, + shear_x: (shear_x * 100.0).round() as i32, + shear_y: (shear_y * 100.0).round() as i32, } } @@ -1237,17 +1294,34 @@ fn extract_scale(effects: &[crate::pipeline::TextEffect]) -> (f32, f32) { }) } -fn extract_rotation_z(effects: &[crate::pipeline::TextEffect]) -> f32 { +/// Extract (x, y, z) rotation values from a `TextEffect::Rotation` entry. +/// Returns `(x, y, z)` — defaults to `(0.0, 0.0, 0.0)` if no rotation effect is present. +fn extract_rotations(effects: &[crate::pipeline::TextEffect]) -> (f32, f32, f32) { effects .iter() .find_map(|e| { - if let crate::pipeline::TextEffect::Rotation { z, .. } = e { - Some(*z) + if let crate::pipeline::TextEffect::Rotation { x, y, z } = e { + Some((*x, *y, *z)) } else { None } }) - .unwrap_or(0.0) + .unwrap_or((0.0, 0.0, 0.0)) +} + +/// Extract (x, y) shear values from a `TextEffect::Shear` entry. +/// Returns `(x, y)` — defaults to `(0.0, 0.0)` if no shear effect is present. +fn extract_shear(effects: &[crate::pipeline::TextEffect]) -> (f32, f32) { + effects + .iter() + .find_map(|e| { + if let crate::pipeline::TextEffect::Shear { x, y } = e { + Some((*x, *y)) + } else { + None + } + }) + .unwrap_or((0.0, 0.0)) } fn extract_blur(effects: &[crate::pipeline::TextEffect]) -> f32 { diff --git a/crates/ass-renderer/src/cache.rs b/crates/ass-renderer/src/cache.rs index f940a47..9345453 100644 --- a/crates/ass-renderer/src/cache.rs +++ b/crates/ass-renderer/src/cache.rs @@ -56,6 +56,16 @@ pub struct BitmapCacheKey { /// 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, + /// Rotation X * 10, rounded (0 if no X rotation). + /// X-axis rotation produces a skew transform; different values produce + /// different bitmap shapes. + pub rotation_x: i32, + /// Rotation Y * 10, rounded (0 if no Y rotation). + pub rotation_y: i32, + /// Shear X * 100, rounded (0 if no shear). + pub shear_x: i32, + /// Shear Y * 100, rounded (0 if no shear). + pub shear_y: i32, } /// Cached bitmap data — a single-channel alpha mask ready for colour application diff --git a/crates/ass-renderer/src/pipeline/font_loader.rs b/crates/ass-renderer/src/pipeline/font_loader.rs index 3ad2b2b..55188bd 100644 --- a/crates/ass-renderer/src/pipeline/font_loader.rs +++ b/crates/ass-renderer/src/pipeline/font_loader.rs @@ -37,6 +37,10 @@ pub fn load_embedded_fonts(script: &Script, font_db: &FontDb) { if let Some(&first_id) = face_ids.first() { if let Some(face_info) = font_db.face(first_id) { if let Some((canonical, _)) = face_info.families.first() { + log::info!( + "font registered: file='{}' canonical='{}' style={:?} weight={:?}", + font.filename, canonical, face_info.style, face_info.weight + ); register_name_aliases(font_db, &data_copy, canonical); } } @@ -114,8 +118,35 @@ fn register_name_aliases(font_db: &FontDb, data: &[u8], canonical: &str) { | ttf_parser::name_id::TYPOGRAPHIC_FAMILY => { if let Some(s) = name_record.to_string() { if s != canonical { + log::info!(" alias: '{}' -> '{}'", s, canonical); font_db.add_name_alias(&s, canonical); } + // Also register weight-stripped base name as an alias. + // e.g. "Alegreya Fake Medium" → strip " Medium" → "Alegreya Fake" + // This handles ASS scripts that reference the family without + // the weight suffix (common with embedded/renamed fonts). + if let Some((base, _)) = crate::utils::font::strip_weight_suffix(&s) { + if base != canonical && !base.is_empty() { + log::info!(" alias (stripped): '{}' -> '{}'", base, canonical); + font_db.add_name_alias(base, canonical); + } + } + // Also try stripping style suffixes like " Italic", " Bold Italic" + for style_suffix in [" Italic", " Bold Italic", " Bold", " Regular"] { + if let Some(without_style) = s.strip_suffix(style_suffix) { + if without_style != canonical && !without_style.is_empty() { + log::info!(" alias (style-stripped): '{}' -> '{}'", without_style, canonical); + font_db.add_name_alias(without_style, canonical); + } + // Strip weight from the style-stripped name too + if let Some((base, _)) = crate::utils::font::strip_weight_suffix(without_style) { + if base != canonical && !base.is_empty() { + log::info!(" alias (both-stripped): '{}' -> '{}'", base, canonical); + font_db.add_name_alias(base, canonical); + } + } + } + } } } _ => {} diff --git a/crates/ass-renderer/src/pipeline/shaping/mod.rs b/crates/ass-renderer/src/pipeline/shaping/mod.rs index 8635160..87706e3 100644 --- a/crates/ass-renderer/src/pipeline/shaping/mod.rs +++ b/crates/ass-renderer/src/pipeline/shaping/mod.rs @@ -77,6 +77,7 @@ pub fn shape_text( font_db: &FontDb, ) -> Result { shape_text_with_style(text, font_family, font_size, false, false, font_db) + .map(|(shaped, _)| shaped) } /// Shape text with style options @@ -87,7 +88,7 @@ pub fn shape_text_with_style( bold: bool, italic: bool, font_db: &FontDb, -) -> Result { +) -> Result<(ShapedText, FontId), RenderError> { // Find best matching font, taking the input text into account for coverage (CJK/Hangul, etc.) match find_font_for_text(font_db, font_family, bold, italic, text) { Ok(font_id) => { @@ -116,12 +117,23 @@ pub fn shape_text_with_style( fontdb::Source::SharedFile(_, data) => data, }; - shape_from_data(text, font_data.as_ref().as_ref(), index, font_size) + let shaped = shape_from_data(text, font_data.as_ref().as_ref(), index, font_size)?; + Ok((shaped, font_id)) } Err(_) => { // fontdb lookup failed entirely — try fallback fonts if let Some(font_data) = font_db.get_fallback_font(font_family) { - shape_from_data(text, &font_data, 0, font_size) + let shaped = shape_from_data(text, &font_data, 0, font_size)?; + // Use find_font as best-effort for the font ID; if that also fails, + // fall back to any face in the database. + let font_id = find_font(font_db, font_family, bold, italic) + .or_else(|_| { + let faces = font_db.faces(); + faces.first() + .map(|f| f.id) + .ok_or_else(|| RenderError::FontError("No fonts loaded".into())) + })?; + Ok((shaped, font_id)) } else { Err(RenderError::FontError( "No fonts loaded in database".into(), diff --git a/crates/ass-renderer/src/pipeline/software_pipeline.rs b/crates/ass-renderer/src/pipeline/software_pipeline.rs index 082c598..91e2c45 100644 --- a/crates/ass-renderer/src/pipeline/software_pipeline.rs +++ b/crates/ass-renderer/src/pipeline/software_pipeline.rs @@ -15,7 +15,6 @@ 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}, @@ -873,7 +872,7 @@ impl SoftwarePipeline { let entry = if let Some(entry) = self.cache.get_shaped(&cache_key) { entry } else { - let shaped_text = shape_text_with_style( + let (shaped_text, font_id) = shape_text_with_style( &segment.text, font_name, actual_font_size, @@ -882,7 +881,6 @@ impl SoftwarePipeline { &self.font_db, )?; - 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, diff --git a/crates/ass-tool/Cargo.toml b/crates/ass-tool/Cargo.toml index 5b1cdb3..7cf42c7 100644 --- a/crates/ass-tool/Cargo.toml +++ b/crates/ass-tool/Cargo.toml @@ -18,3 +18,4 @@ image = { version = "0.24", default-features = false, features = ["png"] } ttf-parser = "0.20" env_logger = "0.11" log = "0.4" +tempfile = "3" diff --git a/crates/ass-tool/src/main.rs b/crates/ass-tool/src/main.rs index de4372e..50a1153 100644 --- a/crates/ass-tool/src/main.rs +++ b/crates/ass-tool/src/main.rs @@ -174,6 +174,40 @@ enum Command { crf: u32, }, + /// Render a side-by-side comparison of libass vs our renderer + /// + /// Renders the ASS subtitles using both ffmpeg's built-in libass filter + /// (reference) and our ass-renderer, then stacks them into a single + /// comparison video with labels. + CompareVideo { + /// Input ASS file (with embedded fonts) + ass_file: PathBuf, + + /// Source video file (MKV, MP4, etc.) + #[arg(long)] + video: PathBuf, + + /// Output comparison video (default: comparison.mp4) + #[arg(long, default_value = "comparison.mp4")] + output: PathBuf, + + /// Layout: "vstack" (top/bottom, default) or "hstack" (side-by-side) + #[arg(long, default_value = "vstack")] + layout: String, + + /// Start time in centiseconds (default: 0) + #[arg(long, default_value = "0")] + start: u32, + + /// End time in centiseconds (default: end of video) + #[arg(long)] + end: Option, + + /// H.264 CRF quality (default: 18, lower = better quality) + #[arg(long, default_value = "18")] + crf: u32, + }, + /// Compare two directories of rendered PNG frames and produce diff images /// /// For each PNG in the reference directory, finds the matching filename in @@ -195,6 +229,36 @@ enum Command { amplify: u8, }, + /// Render side-by-side still frames comparing libass vs our renderer + /// + /// At each timestamp, renders the subtitle overlay using both ffmpeg's + /// libass filter (reference) and our ass-renderer, then stacks them + /// into a single comparison PNG with labels. + CompareFrame { + /// Input ASS file (with embedded fonts) + ass_file: PathBuf, + + /// Source video file (MKV, MP4, etc.) + #[arg(long)] + video: PathBuf, + + /// Comma-separated centisecond timestamps (e.g. "800,900,1000") + #[arg(long, value_delimiter = ',')] + timestamps: Option>, + + /// Render range in centiseconds: START-END:STEP (e.g. "800-1000:4") + #[arg(long)] + range: Option, + + /// Output directory (default: ./compare-frames/) + #[arg(long, default_value = "./compare-frames/")] + output_dir: PathBuf, + + /// Layout: "vstack" (top/bottom, default) or "hstack" (side-by-side) + #[arg(long, default_value = "vstack")] + layout: String, + }, + /// Dump font metrics for all TTF/OTF files in a directory /// /// Shows UPM, hhea ascender/descender, OS/2 sTypo, usWin, fsSelection @@ -264,6 +328,18 @@ fn main() -> Result<(), Box> { render_video(&ass_file, &video, &output, start, end, crf)?; } + Command::CompareVideo { + ass_file, + video, + output, + layout, + start, + end, + crf, + } => { + compare_video(&ass_file, &video, &output, &layout, start, end, crf)?; + } + Command::Compare { reference, test, @@ -272,6 +348,20 @@ fn main() -> Result<(), Box> { } => { compare_frames(&reference, &test, &output_dir, amplify)?; } + Command::CompareFrame { + ass_file, + video, + timestamps, + range, + output_dir, + layout, + } => { + let ts = resolve_timestamps(timestamps, range.as_deref())?; + if ts.is_empty() { + return Err("no timestamps specified: use --timestamps or --range".into()); + } + compare_frames_with_libass(&ass_file, &video, &ts, &output_dir, &layout)?; + } Command::FontMetrics { dir } => { dump_font_metrics(&dir)?; } @@ -1641,6 +1731,275 @@ fn render_video( Ok(()) } +// ── Compare-video subcommand ──────────────────────────────────────────────── + +/// Render a side-by-side (or top/bottom) comparison video of libass vs our renderer. +/// +/// Renders our version to a temp file using `render_video`, then runs a single +/// ffmpeg command that applies the `subtitles` filter to the source (libass reference), +/// labels both streams, and stacks them with `hstack` or `vstack`. +fn compare_video( + ass_path: &Path, + video_path: &Path, + output_path: &Path, + layout: &str, + start_cs: u32, + end_cs: Option, + crf: u32, +) -> Result<(), Box> { + if layout != "hstack" && layout != "vstack" { + return Err(format!( + "invalid layout {:?}: must be \"hstack\" or \"vstack\"", + layout + ) + .into()); + } + + // Render our version to a temp file (mp4) + let our_tmp = tempfile::Builder::new() + .prefix("ass-tool-ours-") + .suffix(".mp4") + .tempfile()?; + let our_tmp_path = our_tmp.path().to_path_buf(); + + eprintln!("--- rendering our version ---"); + render_video(ass_path, video_path, &our_tmp_path, start_cs, end_cs, crf)?; + + // Build the ffmpeg filter_complex for the comparison video. + // + // Input 0: source video (for libass reference via the `subtitles` filter) + // Input 1: our rendered temp file + // + // [0:v] subtitles=:si=0, drawtext=libass label -> [ref] + // [1:v] drawtext=ours label -> [ours] + // [ref][ours] hstack/vstack -> output + let video_path_str = video_path.to_string_lossy(); + // Escape special characters for the ffmpeg subtitles filter path. + // The subtitles filter requires escaping: \ ' : [ ] + let escaped_video = video_path_str + .replace('\\', "\\\\\\\\") + .replace(':', "\\\\:") + .replace("'", "\\\\'") + .replace('[', "\\\\[") + .replace(']', "\\\\]"); + + let drawtext_common = + "fontsize=28:fontcolor=white:borderw=2:bordercolor=black:x=10:y=10"; + + let filter_complex = format!( + "[0:v]subtitles={escaped_video}:si=0,\ + drawtext=text='libass':{drawtext_common}[ref];\ + [1:v]drawtext=text='ours':{drawtext_common}[ours];\ + [ref][ours]{layout}[out]" + ); + + // Time range flags + let ss_str = format!("{:.3}", start_cs as f64 / 100.0); + let mut ffmpeg_args: Vec = Vec::new(); + + // Input 0: source video (seek to start for libass render) + if start_cs > 0 { + ffmpeg_args.extend(["-ss".to_string(), ss_str.clone()]); + } + if let Some(end) = end_cs { + let duration_s = (end.saturating_sub(start_cs)) as f64 / 100.0; + ffmpeg_args.extend(["-t".to_string(), format!("{duration_s:.3}")]); + } + ffmpeg_args.extend(["-i".to_string(), video_path_str.to_string()]); + + // Input 1: our rendered temp file (no seek needed — already trimmed) + ffmpeg_args.extend(["-i".to_string(), our_tmp_path.to_string_lossy().to_string()]); + + // Filter graph + ffmpeg_args.extend([ + "-filter_complex".to_string(), + filter_complex, + "-map".to_string(), + "[out]".to_string(), + ]); + + // Encode options — no audio for a comparison video + let crf_str = crf.to_string(); + ffmpeg_args.extend([ + "-c:v".to_string(), + "libx264".to_string(), + "-crf".to_string(), + crf_str, + "-pix_fmt".to_string(), + "yuv420p".to_string(), + "-movflags".to_string(), + "+faststart".to_string(), + "-an".to_string(), + "-y".to_string(), + output_path.to_string_lossy().to_string(), + ]); + + eprintln!("--- building comparison video ({layout}) ---"); + eprintln!("output: {}", output_path.display()); + eprintln!("spawning ffmpeg comparison encoder..."); + + let status = std::process::Command::new("ffmpeg") + .args(&ffmpeg_args) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()) + .status() + .map_err(|e| format!("failed to spawn ffmpeg: {e} (is ffmpeg on PATH?)"))?; + + if !status.success() { + return Err(format!( + "ffmpeg comparison encoding failed (exit code: {:?})", + status.code() + ) + .into()); + } + + eprintln!("done: comparison video written to {}", output_path.display()); + + Ok(()) +} + +/// Render side-by-side comparison stills of libass vs our renderer. +fn compare_frames_with_libass( + ass_path: &Path, + video_path: &Path, + timestamps: &[u32], + output_dir: &Path, + layout: &str, +) -> Result<(), Box> { + if layout != "hstack" && layout != "vstack" { + return Err(format!( + "invalid layout {:?}: must be \"hstack\" or \"vstack\"", + layout + ) + .into()); + } + + let ass_text = fs::read_to_string(ass_path)?; + let script = ass_core::parser::Script::parse(&ass_text) + .map_err(|e| format!("failed to parse ASS: {e}"))?; + + let (play_res_x, play_res_y) = play_resolution(&script); + let font_db = setup_font_db(&script)?; + + let context = + ass_renderer::RenderContext::with_font_db(play_res_x, play_res_y, Arc::clone(&font_db)); + let mut renderer = ass_renderer::Renderer::new(context) + .map_err(|e| format!("failed to create renderer: {e}"))?; + renderer + .load_script(&script) + .map_err(|e| format!("failed to load script: {e}"))?; + + let mut video_decoder = VideoFrameDecoder::open(video_path, play_res_x, play_res_y)?; + + fs::create_dir_all(output_dir)?; + + // Temp dir for libass reference frames + let tmp_dir = tempfile::tempdir()?; + + eprintln!( + "rendering {} comparison frame(s) at {}x{}...", + timestamps.len(), + play_res_x, + play_res_y + ); + + for &ts in timestamps { + // 1. Render our version + let frame = renderer + .render_frame(&script, ts) + .map_err(|e| format!("render error at t={ts}cs: {e}"))?; + + let subtitle_rgba = frame.pixels(); + let mut our_canvas = video_decoder.decode_frame_at(ts)?.to_vec(); + alpha_composite(&mut our_canvas, subtitle_rgba); + + let our_img: image::RgbImage = image::ImageBuffer::from_raw( + play_res_x, + play_res_y, + rgba_to_rgb(&our_canvas), + ) + .ok_or_else(|| format!("failed to create our image at t={ts}cs"))?; + + // 2. Render libass reference via ffmpeg + let ref_path = tmp_dir.path().join(format!("ref_{ts}.png")); + let ss = format!("{:.4}", ts as f64 / 100.0); + let video_str = video_path.to_string_lossy(); + let escaped_video = video_str + .replace('\\', "\\\\\\\\") + .replace(':', "\\\\:") + .replace("'", "\\\\'") + .replace('[', "\\\\[") + .replace(']', "\\\\]"); + + let filter = format!("subtitles={escaped_video}:si=0"); + let ref_path_str = ref_path.to_string_lossy().to_string(); + + let status = std::process::Command::new("ffmpeg") + .args([ + "-i", + &video_str, + "-vf", &filter, + "-ss", &ss, + "-frames:v", "1", + "-y", + &ref_path_str, + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map_err(|e| format!("failed to spawn ffmpeg: {e}"))?; + + if !status.success() { + return Err(format!("ffmpeg failed rendering libass reference at t={ts}cs").into()); + } + + let ref_img = image::open(&ref_path) + .map_err(|e| format!("failed to read libass frame at t={ts}cs: {e}"))? + .into_rgb8(); + + // 3. Stack them with labels + let (out_w, out_h) = if layout == "vstack" { + (play_res_x, play_res_y * 2) + } else { + (play_res_x * 2, play_res_y) + }; + + let mut combined = image::RgbImage::new(out_w, out_h); + + if layout == "vstack" { + // libass on top, ours on bottom + image::imageops::overlay(&mut combined, &ref_img, 0, 0); + image::imageops::overlay(&mut combined, &our_img, 0, play_res_y as i64); + } else { + // libass on left, ours on right + image::imageops::overlay(&mut combined, &ref_img, 0, 0); + image::imageops::overlay(&mut combined, &our_img, play_res_x as i64, 0); + } + + let out_path = output_dir.join(format!("compare_{ts}cs.png")); + combined + .save(&out_path) + .map_err(|e| format!("failed to save {}: {e}", out_path.display()))?; + + eprintln!(" wrote {}", out_path.display()); + } + + eprintln!( + "done: {} comparison frame(s) in {}", + timestamps.len(), + output_dir.display() + ); + + Ok(()) +} + +/// Convert RGBA pixel data to RGB by dropping the alpha channel. +fn rgba_to_rgb(rgba: &[u8]) -> Vec { + rgba.chunks_exact(4) + .flat_map(|px| [px[0], px[1], px[2]]) + .collect() +} + // ── Video frame decoding ──────────────────────────────────────────────────── /// Dump font metrics for all TTF/OTF files in a directory.