diff --git a/Cargo.lock b/Cargo.lock index 4f870fe..9dad18e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4610,15 +4610,6 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -[[package]] -name = "lol_alloc" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83e5106554cabc97552dcadf54f57560ae6af3276652f82ca2be06120dc4c5dc" -dependencies = [ - "spin 0.9.8", -] - [[package]] name = "longest-increasing-subsequence" version = "0.1.0" @@ -8133,7 +8124,6 @@ dependencies = [ "jacquard-common", "jacquard-derive", "js-sys", - "lol_alloc", "miette", "mime-sniffer", "mini-moka-wasm", diff --git a/Cargo.toml b/Cargo.toml index 458aaa3..7ab41a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,7 +77,6 @@ gloo-timers = "0.3" gloo-worker = { version = "0.5", features = ["futures"] } postcard = { version = "1.1", features = ["use-std"] } percent-encoding = "2" -lol_alloc = "0.4.1" mini-moka-wasm = { git = "https://tangled.org/nonbinary.computer/jacquard", branch = "main", features = ["js"] } [target.'cfg(not(all(target_family = "wasm", target_os = "unknown")))'.dependencies] @@ -91,9 +90,10 @@ mini-moka-wasm = { git = "https://tangled.org/nonbinary.computer/jacquard", bran [dev-dependencies] insta = { version = "1.40", features = ["yaml"] } -# Worker WASM build: max speed, aggressive inlining, thin LTO. +# Worker WASM build: max speed, aggressive inlining, fat LTO. # Used by build-workers.sh via `cargo build --profile worker-release`. [profile.worker-release] +panic = "abort" inherits = "release" opt-level = 3 lto = "fat" diff --git a/build-workers.sh b/build-workers.sh index bd30a03..eab0326 100755 --- a/build-workers.sh +++ b/build-workers.sh @@ -21,7 +21,7 @@ if command -v wasm-opt &>/dev/null; then echo "==> Running wasm-opt" BEFORE=$(stat -c%s public/player_worker_bg.wasm) wasm-opt public/player_worker_bg.wasm \ - -O3 \ + -O4 \ --enable-bulk-memory \ --enable-simd \ --enable-nontrapping-float-to-int \ diff --git a/crates/ass-renderer/src/pipeline/shaping/mod.rs b/crates/ass-renderer/src/pipeline/shaping/mod.rs index 2c05b71..ffa5a68 100644 --- a/crates/ass-renderer/src/pipeline/shaping/mod.rs +++ b/crates/ass-renderer/src/pipeline/shaping/mod.rs @@ -300,6 +300,7 @@ pub(crate) fn find_font( // If the requested font contains "CJK" or is a known Japanese font name, try fallbacks let is_japanese_font = family.contains("CJK") + || family.contains("M PLUS") || family.contains("Noto Sans JP") || family.contains("Hiragino") || family.contains("Yu Gothic") @@ -309,6 +310,7 @@ pub(crate) fn find_font( if is_japanese_font { // Try common Japanese font fallbacks let japanese_fallbacks = [ + "M PLUS 2", "Hiragino Sans", "Hiragino Kaku Gothic Pro", "Yu Gothic", @@ -520,6 +522,7 @@ fn find_font_for_text( ], // Japanese &[ + "M PLUS 2", "Noto Sans CJK JP", "Noto Sans JP", "Hiragino Sans", @@ -530,6 +533,8 @@ fn find_font_for_text( ], // Korean &[ + "NanumGothic", + "Nanum Gothic", "Noto Sans CJK KR", "Noto Sans KR", "Apple SD Gothic Neo", diff --git a/crates/ass-renderer/src/pipeline/software_pipeline.rs b/crates/ass-renderer/src/pipeline/software_pipeline.rs index 54572aa..2362cf2 100644 --- a/crates/ass-renderer/src/pipeline/software_pipeline.rs +++ b/crates/ass-renderer/src/pipeline/software_pipeline.rs @@ -502,7 +502,7 @@ impl SoftwarePipeline { tags.colors.alpha, tags.movement, tags.position, - &segment.text[..segment.text.len().min(30)] + &segment.text[..segment.text.char_indices().nth(30).map_or(segment.text.len(), |(i, _)| i)] ); // Calculate scaling factors @@ -958,7 +958,7 @@ impl SoftwarePipeline { let italic = tags.formatting.italic.unwrap_or(default_italic); log::trace!( "SEG: text='{}' total_advance={:.1} width={:.1} fscx={:.2} seg_advance={:.1} current_x={:.1}", - &line_text[..line_text.len().min(10)], + &line_text[..line_text.char_indices().nth(10).map_or(line_text.len(), |(i, _)| i)], shaped.total_advance().unwrap_or(-1.0), shaped.width, font_scale_x, @@ -987,7 +987,7 @@ impl SoftwarePipeline { log::trace!( "POS DEBUG: text='{}' anchor=({:.1},{:.1}) total_line_width={:.1} shaped.width={:.1} fscx={:.2} alignment={} playres={}x{} canvas={}x{}", - &line_text[..line_text.len().min(20)], + &line_text[..line_text.char_indices().nth(20).map_or(line_text.len(), |(i, _)| i)], anchor_x, anchor_y, total_line_width, shaped.width, font_scale_x, alignment, diff --git a/crates/ass-tool/src/main.rs b/crates/ass-tool/src/main.rs index 8d5792b..7a8b844 100644 --- a/crates/ass-tool/src/main.rs +++ b/crates/ass-tool/src/main.rs @@ -113,7 +113,7 @@ enum Command { /// Renders subtitle frames at given centisecond timestamps using the /// software renderer. Optionally composites onto video frames from an /// MKV/MP4 file decoded with ffmpeg. Without --video, composites onto - /// a solid black background. + /// a solid background (default #333333, configurable via --background). Render { /// Input ASS file ass_file: PathBuf, @@ -141,6 +141,10 @@ enum Command { /// Override render height (default: from ASS PlayResY or 1080) #[arg(long)] height: Option, + + /// Background colour when no --video is given (hex, e.g. "#333333") + #[arg(long, default_value = "#333333")] + background: String, }, /// Render ASS subtitles composited onto a video, producing an MP4 output @@ -344,12 +348,14 @@ fn main() -> Result<(), Box> { output_dir, width, height, + background, } => { + let bg = parse_hex_colour(&background)?; let ts = resolve_timestamps(timestamps, range.as_deref())?; if ts.is_empty() { return Err("no timestamps specified: use --timestamps or --range".into()); } - render_frames(&ass_file, &ts, video.as_deref(), &output_dir, width, height)?; + render_frames(&ass_file, &ts, video.as_deref(), &output_dir, width, height, bg)?; } Command::RenderVideo { @@ -1131,6 +1137,17 @@ fn extract_fn_tags_from_text(text: &str, fonts: &mut BTreeSet) { // ── Render subcommand ─────────────────────────────────────────────────────── /// Parse --timestamps and --range into a sorted Vec of centisecond timestamps. +fn parse_hex_colour(s: &str) -> Result<[u8; 3], Box> { + let hex = s.strip_prefix('#').unwrap_or(s); + if hex.len() != 6 || !hex.is_ascii() { + return Err(format!("invalid hex colour: {s} (expected #RRGGBB)").into()); + } + let r = u8::from_str_radix(&hex[0..2], 16)?; + let g = u8::from_str_radix(&hex[2..4], 16)?; + let b = u8::from_str_radix(&hex[4..6], 16)?; + Ok([r, g, b]) +} + fn resolve_timestamps( timestamps: Option>, range: Option<&str>, @@ -1266,6 +1283,7 @@ fn render_frames( output_dir: &Path, width_override: Option, height_override: Option, + bg_colour: [u8; 3], ) -> Result<(), Box> { let ass_text = fs::read_to_string(ass_path)?; let script = ass_core::parser::Script::parse(&ass_text) @@ -1306,11 +1324,12 @@ fn render_frames( let subtitle_rgba = frame.pixels(); - // Get background: video frame or solid black + // Get background: video frame or solid colour let mut canvas = if let Some(ref mut decoder) = video_decoder { decoder.decode_frame_at(ts)?.to_vec() } else { - vec![0u8; (width * height * 4) as usize] + let pixel = [bg_colour[0], bg_colour[1], bg_colour[2], 0xFF]; + pixel.repeat((width * height) as usize) }; // Alpha-composite subtitle RGBA onto canvas diff --git a/crates/vodplace-player-worker/src/subtitle.rs b/crates/vodplace-player-worker/src/subtitle.rs index f2b95b0..b8ae335 100644 --- a/crates/vodplace-player-worker/src/subtitle.rs +++ b/crates/vodplace-player-worker/src/subtitle.rs @@ -17,21 +17,156 @@ use web_sys::{ use crate::font_resolver; -/// Default font URLs — Noto Sans (Latin) and Noto Sans SC Regular + Bold. +/// Eagerly loaded font URLs — small Latin default, fetched immediately. /// -/// Noto Sans is the default font in converted subtitles (VTT/SRT -> ASS). -/// Noto Sans SC provides chinese fallback. URLs are from the Google Fonts CSS2 API. -const DEFAULT_FONT_URLS: &[&str] = &[ - // Noto Sans — Regular (400) - "https://fonts.gstatic.com/s/notosans/v42/o-0mIpQlx3QUlC5A4PNB6Ryti20_6n1iPHjcz6L1SoM-jCpoiyD9A99d.ttf", - // Noto Sans — Bold (700) - "https://fonts.gstatic.com/s/notosans/v42/o-0mIpQlx3QUlC5A4PNB6Ryti20_6n1iPHjcz6L1SoM-jCpoiyAaBN9d.ttf", - // Noto Sans SC — Regular (400) - "https://fonts.gstatic.com/s/notosanssc/v40/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYw.ttf", - // Noto Sans SC — Bold (700) - "https://fonts.gstatic.com/s/notosanssc/v40/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaGzjCnYw.ttf", +/// Proza Libre Medium (97KB) is the default font for converted subtitles. +/// Loaded on first subtitle use so Latin text renders without delay. +const EAGER_FONT_URLS: &[&str] = &[ + // Proza Libre — Medium (500), default subtitle font + "https://fonts.gstatic.com/s/prozalibre/v9/LYjbdGHgj0k1DIQRyUEyyELbV__f.ttf", ]; +/// Script-specific font URLs — loaded on demand when subtitle text contains +/// characters from the corresponding Unicode ranges. +/// +/// Each entry is (family_name, detect_fn_name, &[urls]). Fonts are fetched +/// only when needed, keeping initial load to just the ~97KB Latin default. +/// +/// The `detect` field name corresponds to the script detection function used +/// in `load_script_fonts_for_content`. +const SCRIPT_FONT_SETS: &[ScriptFontSet] = &[ + // ── CJK (large fonts, 2–13MB each) ────────────────────────────────────── + ScriptFontSet { + family: "M PLUS 2", + script: Script::Japanese, + urls: &[ + "https://fonts.gstatic.com/s/mplus2/v15/7Auhp_Eq3gO_OGbGGhjdwrDdpeIBxlkwOa6Vxg.ttf", + "https://fonts.gstatic.com/s/mplus2/v15/7Auhp_Eq3gO_OGbGGhjdwrDdpeIBxlkw3qmVxg.ttf", + ], + }, + ScriptFontSet { + family: "Noto Sans SC", + script: Script::ChineseSc, + urls: &[ + "https://fonts.gstatic.com/s/notosanssc/v40/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYw.ttf", + "https://fonts.gstatic.com/s/notosanssc/v40/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaGzjCnYw.ttf", + ], + }, + ScriptFontSet { + family: "Noto Sans TC", + script: Script::ChineseTc, + urls: &[ + "https://fonts.gstatic.com/s/notosanstc/v39/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_Co.ttf", + "https://fonts.gstatic.com/s/notosanstc/v39/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz70e1_Co.ttf", + ], + }, + ScriptFontSet { + family: "NanumGothic", + script: Script::Korean, + urls: &[ + "https://fonts.gstatic.com/s/nanumgothic/v26/PN_3Rfi-oW3hYwmKDpxS7F_D-djb.ttf", + "https://fonts.gstatic.com/s/nanumgothic/v26/PN_oRfi-oW3hYwmKDpxS7F_LQv3LyVsj.ttf", + ], + }, + // ── Cyrillic + Greek (Inter covers both, ~300KB) ───────────────────────── + ScriptFontSet { + family: "Inter", + script: Script::CyrillicOrGreek, + urls: &[ + "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfMZg.ttf", + ], + }, + // ── Arabic (~200KB) ────────────────────────────────────────────────────── + ScriptFontSet { + family: "Tajawal", + script: Script::Arabic, + urls: &[ + "https://fonts.gstatic.com/s/tajawal/v12/Iura6YBj_oCad4k1rzY.ttf", + ], + }, + // ── Devanagari (~150KB) ────────────────────────────────────────────────── + ScriptFontSet { + family: "Poppins", + script: Script::Devanagari, + urls: &[ + "https://fonts.gstatic.com/s/poppins/v24/pxiEyp8kv8JHgFVrFJA.ttf", + ], + }, + // ── Thai (~100KB) ──────────────────────────────────────────────────────── + ScriptFontSet { + family: "Sarabun", + script: Script::Thai, + urls: &[ + "https://fonts.gstatic.com/s/sarabun/v17/DtVjJx26TKEr37c9WBI.ttf", + ], + }, + // ── Hebrew (~100KB) ────────────────────────────────────────────────────── + ScriptFontSet { + family: "Heebo", + script: Script::Hebrew, + urls: &[ + "https://fonts.gstatic.com/s/heebo/v28/NGSpv5_NC0k9P_v6ZUCbLRAHxK1EiSyccg.ttf", + ], + }, + // ── Bengali (~200KB) ───────────────────────────────────────────────────── + ScriptFontSet { + family: "Hind Siliguri", + script: Script::Bengali, + urls: &[ + "https://fonts.gstatic.com/s/hindsiliguri/v14/ijwTs5juQtsyLLR5jN4cxBEofJs.ttf", + ], + }, + // ── Tamil (~200KB) ─────────────────────────────────────────────────────── + ScriptFontSet { + family: "Catamaran", + script: Script::Tamil, + urls: &[ + "https://fonts.gstatic.com/s/catamaran/v28/o-0bIpQoyXQa2RxT7-5B6Ryxs2E_6n1iPHjd1ak.ttf", + ], + }, + // ── Telugu (~200KB) ────────────────────────────────────────────────────── + ScriptFontSet { + family: "Hind Guntur", + script: Script::Telugu, + urls: &[ + "https://fonts.gstatic.com/s/hindguntur/v14/wXKvE3UZrok56nvamSuJd8Qq.ttf", + ], + }, + // ── Universal fallback: Noto Sans base (Latin/Greek/Cyrillic, ~570KB) ──── + // Loaded as last resort when no preferred font covers the text. + ScriptFontSet { + family: "Noto Sans", + script: Script::UniversalFallback, + urls: &[ + "https://fonts.gstatic.com/s/notosans/v42/o-0mIpQlx3QUlC5A4PNB6Ryti20_6n1iPHjcz6L1SoM-jCpoiyD9A99d.ttf", + "https://fonts.gstatic.com/s/notosans/v42/o-0mIpQlx3QUlC5A4PNB6Ryti20_6n1iPHjcz6L1SoM-jCpoiyAaBN9d.ttf", + ], + }, +]; + +struct ScriptFontSet { + family: &'static str, + script: Script, + urls: &'static [&'static str], +} + +#[derive(Clone, Copy, PartialEq)] +enum Script { + Japanese, + ChineseSc, + ChineseTc, + Korean, + CyrillicOrGreek, + Arabic, + Devanagari, + Thai, + Hebrew, + Bengali, + Tamil, + Telugu, + UniversalFallback, +} + // ── Error type ──────────────────────────────────────────────────────────────── /// Errors that can occur during subtitle state operations. @@ -231,25 +366,20 @@ impl SubtitleState { } } - /// Fetch and load the default font (Noto Sans SC) from the Google Fonts CDN. - /// - /// Sets `self.fonts_loaded = true` after the attempt (whether it succeeded - /// or failed) so that subsequent `load_script` calls do not retry. + /// Fetch and load the default Latin font (Proza Libre) from Google Fonts. /// - /// On success, rebuilds the renderer context so the new font is available - /// for shaping on the next `render_at` call. + /// Only loads the small (~97KB) Latin default eagerly. CJK fonts are loaded + /// on demand by [`resolve_script_fonts`] based on the actual script content. /// - /// On failure, logs a warning and continues — the pipeline will still - /// render (possibly with missing-glyph boxes). + /// Sets `self.fonts_loaded = true` after the attempt (whether it succeeded + /// or failed) so that subsequent calls do not retry. pub async fn load_default_font(&mut self) { - // Mark as attempted immediately so that a failure does not cause - // every subsequent load_script call to retry the fetch. self.fonts_loaded = true; let mut total_bytes = 0usize; let mut loaded = 0usize; - for url in DEFAULT_FONT_URLS { - web_sys::console::log_1(&format!("subtitle: fetching font from {url}").into()); + for url in EAGER_FONT_URLS { + web_sys::console::log_1(&format!("subtitle: fetching default font from {url}").into()); match fetch_font_bytes(url).await { Ok(font_bytes) => { total_bytes += font_bytes.len(); @@ -264,20 +394,111 @@ impl SubtitleState { } } // Register generic family aliases so fontdb's Family::SansSerif - // queries resolve to a loaded font. Without this, the fallback - // chain in find_font() can't find a generic sans-serif. - self.font_db.set_sans_serif_family("Noto Sans"); - self.font_db.set_serif_family("Noto Sans"); + // queries resolve to a loaded font. + self.font_db.set_sans_serif_family("Proza Libre"); + self.font_db.set_serif_family("Proza Libre"); - // Track loaded families so resolve_script_fonts can skip them. - font_resolver::register_font_name("Noto Sans", &mut self.loaded_families); - font_resolver::register_font_name("Noto Sans SC", &mut self.loaded_families); + font_resolver::register_font_name("Proza Libre", &mut self.loaded_families); self.publish_font_db(); log::info!( - "subtitle: {loaded}/{} default fonts loaded ({total_bytes} bytes)", - DEFAULT_FONT_URLS.len() + "subtitle: {loaded}/{} eager fonts loaded ({total_bytes} bytes)", + EAGER_FONT_URLS.len() + ); + } + + /// Load script-specific font sets needed for the current subtitle content. + /// + /// Scans all dialogue text to detect which Unicode script ranges are present, + /// then fetches only the font sets actually needed. This avoids eagerly + /// downloading large CJK fonts (~10MB+) or other script fonts when the + /// content is Latin-only. + async fn load_script_fonts_for_content(&mut self) { + let text = match self.script.as_ref() { + Some(owned) => owned.with_script(|script| { + use ass_core::parser::Section; + let mut buf = String::new(); + for section in script.sections() { + if let Section::Events(events) = section { + for event in events { + buf.push_str(event.text); + buf.push(' '); + } + } + } + buf + }), + None => return, + }; + + if text.is_empty() { + return; + } + + // Detect which scripts are present in the subtitle text. + // Each flag is set lazily — we only scan until we find a match. + let mut detected = [false; 13]; + for c in text.chars() { + if is_japanese_char(c) { detected[Script::Japanese as usize] = true; } + if is_cjk_unified(c) { + detected[Script::ChineseSc as usize] = true; + detected[Script::ChineseTc as usize] = true; + } + if is_hangul_char(c) { detected[Script::Korean as usize] = true; } + if is_cyrillic_char(c) || is_greek_char(c) { detected[Script::CyrillicOrGreek as usize] = true; } + if is_arabic_char(c) { detected[Script::Arabic as usize] = true; } + if is_devanagari_char(c) { detected[Script::Devanagari as usize] = true; } + if is_thai_char(c) { detected[Script::Thai as usize] = true; } + if is_hebrew_char(c) { detected[Script::Hebrew as usize] = true; } + if is_bengali_char(c) { detected[Script::Bengali as usize] = true; } + if is_tamil_char(c) { detected[Script::Tamil as usize] = true; } + if is_telugu_char(c) { detected[Script::Telugu as usize] = true; } + } + + let mut sets_to_load: Vec<&ScriptFontSet> = Vec::new(); + for set in SCRIPT_FONT_SETS { + if set.script == Script::UniversalFallback { + continue; // loaded separately as last resort + } + if detected[set.script as usize] + && !font_resolver::is_family_loaded(set.family, &self.loaded_families) + { + sets_to_load.push(set); + } + } + + if sets_to_load.is_empty() { + return; + } + + log::info!( + "subtitle: loading {} script font set(s): {:?}", + sets_to_load.len(), + sets_to_load.iter().map(|s| s.family).collect::>() + ); + + let mut total_bytes = 0usize; + for set in &sets_to_load { + for url in set.urls { + match fetch_font_bytes(url).await { + Ok(font_bytes) => { + total_bytes += font_bytes.len(); + self.font_db.load_font_data(font_bytes); + } + Err(e) => { + log::warn!("subtitle: failed to load font {url}: {e}"); + } + } + } + font_resolver::register_font_name(set.family, &mut self.loaded_families); + } + + self.publish_font_db(); + + log::info!( + "subtitle: loaded {} script font set(s) ({total_bytes} bytes)", + sets_to_load.len() ); } @@ -290,6 +511,9 @@ impl SubtitleState { /// Call after `load_script` and `ensure_fonts_loaded` so that default /// fonts are already in the database. pub async fn resolve_script_fonts(&mut self) { + // 0. Load script-specific font sets based on actual subtitle content + self.load_script_fonts_for_content().await; + // 1. Load embedded fonts from the [Fonts] section self.load_embedded_fonts(); @@ -1451,3 +1675,65 @@ pub async fn fetch_and_load_hls_subtitles( Ok(()) } + +// ── Script detection helpers ───────────────────────────────────────────────── + +/// Japanese kana + CJK ideographs (Hiragana, Katakana, common kanji). +fn is_japanese_char(c: char) -> bool { + matches!(c, '\u{3040}'..='\u{309F}' | '\u{30A0}'..='\u{30FF}' | '\u{4E00}'..='\u{9FFF}') +} + +/// CJK Unified Ideographs (shared across Chinese/Japanese/Korean). +fn is_cjk_unified(c: char) -> bool { + matches!(c, '\u{4E00}'..='\u{9FFF}' | '\u{3400}'..='\u{4DBF}' | '\u{20000}'..='\u{2A6DF}') +} + +/// Hangul syllables and Jamo. +fn is_hangul_char(c: char) -> bool { + matches!(c, '\u{AC00}'..='\u{D7AF}' | '\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}') +} + +/// Cyrillic (U+0400–04FF) + extended blocks. +fn is_cyrillic_char(c: char) -> bool { + matches!(c, '\u{0400}'..='\u{04FF}' | '\u{0500}'..='\u{052F}' | '\u{2DE0}'..='\u{2DFF}' | '\u{A640}'..='\u{A69F}') +} + +/// Greek and Coptic (U+0370–03FF) + extended. +fn is_greek_char(c: char) -> bool { + matches!(c, '\u{0370}'..='\u{03FF}' | '\u{1F00}'..='\u{1FFF}') +} + +/// Arabic (U+0600–06FF) + supplement + extended blocks. +fn is_arabic_char(c: char) -> bool { + matches!(c, '\u{0600}'..='\u{06FF}' | '\u{0750}'..='\u{077F}' | '\u{08A0}'..='\u{08FF}' | '\u{FB50}'..='\u{FDFF}' | '\u{FE70}'..='\u{FEFF}') +} + +/// Devanagari (U+0900–097F) + extended. +fn is_devanagari_char(c: char) -> bool { + matches!(c, '\u{0900}'..='\u{097F}' | '\u{A8E0}'..='\u{A8FF}') +} + +/// Thai (U+0E00–0E7F). +fn is_thai_char(c: char) -> bool { + matches!(c, '\u{0E00}'..='\u{0E7F}') +} + +/// Hebrew (U+0590–05FF) + presentation forms. +fn is_hebrew_char(c: char) -> bool { + matches!(c, '\u{0590}'..='\u{05FF}' | '\u{FB1D}'..='\u{FB4F}') +} + +/// Bengali (U+0980–09FF). +fn is_bengali_char(c: char) -> bool { + matches!(c, '\u{0980}'..='\u{09FF}') +} + +/// Tamil (U+0B80–0BFF). +fn is_tamil_char(c: char) -> bool { + matches!(c, '\u{0B80}'..='\u{0BFF}') +} + +/// Telugu (U+0C00–0C7F). +fn is_telugu_char(c: char) -> bool { + matches!(c, '\u{0C00}'..='\u{0C7F}') +} diff --git a/crates/vodplace-subtitle/src/ass_gen.rs b/crates/vodplace-subtitle/src/ass_gen.rs index ca4e881..6f5439b 100644 --- a/crates/vodplace-subtitle/src/ass_gen.rs +++ b/crates/vodplace-subtitle/src/ass_gen.rs @@ -17,17 +17,30 @@ PlayResY: {height} ) } -/// Generate the `[V4+ Styles]` section with a sensible default style. +/// Generate the `[V4+ Styles]` section with the standard subtitle style. /// -/// Uses Noto Sans, white text, size 46, bottom-centre alignment (\\an2), -/// with outline and semi-transparent shadow. Font size 46 at PlayRes -/// 1920x1080 matches the BBC HD broadcast standard (~4.3% of height) -/// and scales proportionally to the actual render resolution. +/// Uses Proza Libre, white text, size 72, bottom-centre alignment (\\an2), +/// with outline (3.6) and semi-transparent shadow (1.5). Style parameters +/// match the conventions of professional fansub groups at 1920×1080 +/// PlayRes and scale proportionally to the actual render resolution. pub fn default_style() -> &'static str { "\ [V4+ Styles] Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding -Style: Default,Noto Sans,46,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,100,100,0,0,1,4,1,2,10,10,120,1 +Style: Default,Proza Libre,72,&H00FFFFFF,&H00FFFFFF,&H00000000,&HA0000000,0,0,0,0,100,100,0,0,1,3.6,1.5,2,180,180,60,1 +" +} + +/// Generate the `[V4+ Styles]` section for ionosphere transcripts. +/// +/// Same font and styling as [`default_style`] but with a higher bottom +/// margin (MarginV 120) to push subtitles above the conference VOD +/// speaker bar area. +pub fn ionosphere_style() -> &'static str { + "\ +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Proza Libre,72,&H00FFFFFF,&H00FFFFFF,&H00000000,&HA0000000,0,0,0,0,100,100,0,0,1,3.6,1.5,2,180,180,120,1 " } @@ -170,7 +183,28 @@ mod tests { let style = default_style(); assert!(style.contains("[V4+ Styles]")); assert!(style.contains("Style: Default,")); - assert!(style.contains("Noto Sans")); + assert!(style.contains("Proza Libre")); + } + + #[test] + fn ionosphere_style_is_valid() { + let style = ionosphere_style(); + assert!(style.contains("[V4+ Styles]")); + assert!(style.contains("Style: Default,")); + assert!(style.contains("Proza Libre")); + assert!(style.contains(",120,1"), "ionosphere style should have MarginV 120"); + } + + #[test] + fn ionosphere_style_differs_from_default_only_in_margin() { + let default = default_style(); + let iono = ionosphere_style(); + // Both use the same font + assert!(default.contains("Proza Libre")); + assert!(iono.contains("Proza Libre")); + // Different bottom margins + assert!(default.contains(",60,1"), "default should have MarginV 60"); + assert!(iono.contains(",120,1"), "ionosphere should have MarginV 120"); } #[test] diff --git a/crates/vodplace-subtitle/src/convert_ionosphere.rs b/crates/vodplace-subtitle/src/convert_ionosphere.rs index 9f297a5..3a5fc29 100644 --- a/crates/vodplace-subtitle/src/convert_ionosphere.rs +++ b/crates/vodplace-subtitle/src/convert_ionosphere.rs @@ -66,7 +66,7 @@ pub fn ionosphere_to_ass( "Converted from ionosphere transcript", )); ass.push('\n'); - ass.push_str(&ass_gen::default_style()); + ass.push_str(&ass_gen::ionosphere_style()); ass.push('\n'); ass.push_str(&ass_gen::events_header());