diff --git a/crates/erika/src/danmaku.rs b/crates/erika/src/danmaku.rs index 8baff36..e8efda9 100644 --- a/crates/erika/src/danmaku.rs +++ b/crates/erika/src/danmaku.rs @@ -10,16 +10,21 @@ mod outline; mod typography; use std::collections::{BTreeMap, BTreeSet, HashMap}; -use std::fs; +use std::fs::{self, File}; +use std::io::Read; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::Duration; -use ab_glyph::{Font, FontArc, FontVec, Glyph, GlyphId, ScaleFont}; +use ab_glyph::{ + CodepointIdIter, Font, FontArc, FontRef, FontVec, Glyph, GlyphId, GlyphSvg, Outline, ScaleFont, + v2, +}; use serde_json::Value; use thiserror::Error; use crate::NIPAPLAY_FALLBACK_FONT; +use crate::subtitle::SubtitleFontAttachment; use crate::text::TextShaper; use outline::{raster_radius, resolve_width_px}; use typography::{ @@ -38,6 +43,7 @@ const SCROLL_DURATION_MAX_WIDTH_SCALE: f32 = 1.3; const DEFAULT_DANMAKU_TRACK_ID: u64 = 1; const TRACK_ID_SHIFT: u64 = 48; const ITEM_ID_MASK: u64 = (1u64 << TRACK_ID_SHIFT) - 1; +const MAX_CUSTOM_DANMAKU_FONT_BYTES: u64 = 64 * 1024 * 1024; pub const DANMAKU_DEBUG_BUCKETS: usize = 16; #[derive(Debug, Error)] @@ -180,6 +186,8 @@ pub struct DanmakuLayoutConfig { pub shadow_style: DanmakuShadowStyle, pub custom_font_family: String, pub custom_font_file_path: String, + #[doc(hidden)] + pub custom_font_face_index: u32, pub merge_duplicates: bool, pub allow_stacking: bool, pub allow_scroll_overwrite: bool, @@ -236,6 +244,7 @@ impl Default for DanmakuLayoutConfig { shadow_style: DanmakuShadowStyle::Strong, custom_font_family: String::new(), custom_font_file_path: String::new(), + custom_font_face_index: 0, merge_duplicates: false, allow_stacking: false, allow_scroll_overwrite: true, @@ -1134,6 +1143,21 @@ struct DanmakuFontFace { font: Arc, } +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DanmakuFontSelection { + pub generation: u64, + pub fonts: Arc<[SubtitleFontAttachment]>, +} + +impl DanmakuFontSelection { + pub fn new(generation: u64, fonts: impl Into>) -> Self { + Self { + generation, + fonts: fonts.into(), + } + } +} + impl DanmakuFontFace { fn new(id: u32, font: FontArc) -> Self { Self { @@ -1302,6 +1326,8 @@ impl TextLayoutCacheKey { pub struct DanmakuTextRasterizer { shaper: TextShaper, primary_font: Option, + selected_fonts: Arc<[DanmakuFontFace]>, + selected_before_primary: bool, fallback_fonts: Arc>, glyph_cache: Arc>>>, text_layout_cache: Arc>>>, @@ -1319,6 +1345,8 @@ impl DanmakuTextRasterizer { Self { shaper, primary_font: load_default_font().map(|font| DanmakuFontFace::new(0, font)), + selected_fonts: Arc::from([]), + selected_before_primary: false, fallback_fonts: Arc::new(Mutex::new(SystemFontFallback::new(1))), glyph_cache: Arc::new(Mutex::new(HashMap::new())), text_layout_cache: Arc::new(Mutex::new(HashMap::new())), @@ -1327,15 +1355,30 @@ impl DanmakuTextRasterizer { } pub fn for_config(config: &DanmakuLayoutConfig) -> Self { + Self::for_config_and_selection(config, &DanmakuFontSelection::default()) + } + + pub fn for_config_and_selection( + config: &DanmakuLayoutConfig, + selection: &DanmakuFontSelection, + ) -> Self { let shaper = TextShaper::default(); + let primary_font = load_configured_font( + &config.custom_font_family, + &config.custom_font_file_path, + config.custom_font_face_index, + ) + .map(|font| DanmakuFontFace::new(0, font)); + let first_selected_id = u32::from(primary_font.is_some()); + let selected_fonts = load_selected_fonts(&selection.fonts, first_selected_id); + let next_font_id = first_selected_id.saturating_add(selected_fonts.len() as u32); Self { shaper, - primary_font: load_configured_font( - &config.custom_font_family, - &config.custom_font_file_path, - ) - .map(|font| DanmakuFontFace::new(0, font)), - fallback_fonts: Arc::new(Mutex::new(SystemFontFallback::new(1))), + primary_font, + selected_fonts: Arc::from(selected_fonts), + selected_before_primary: config.custom_font_family.is_empty() + && config.custom_font_file_path.is_empty(), + fallback_fonts: Arc::new(Mutex::new(SystemFontFallback::new(next_font_id))), glyph_cache: Arc::new(Mutex::new(HashMap::new())), text_layout_cache: Arc::new(Mutex::new(HashMap::new())), glyph_atlas: Arc::new(Mutex::new(PersistentGlyphAtlas::new())), @@ -1477,11 +1520,21 @@ impl DanmakuTextRasterizer { } fn resolve_font(&self, ch: char) -> Option { + if self.selected_before_primary { + if let Some(font) = self.selected_fonts.iter().find(|font| font.has_glyph(ch)) { + return Some(font.clone()); + } + } if let Some(font) = &self.primary_font { if font.has_glyph(ch) { return Some(font.clone()); } } + if !self.selected_before_primary { + if let Some(font) = self.selected_fonts.iter().find(|font| font.has_glyph(ch)) { + return Some(font.clone()); + } + } self.fallback_fonts .lock() .expect("danmaku system font fallback lock") @@ -1839,6 +1892,7 @@ pub struct DfmLayoutEngine { timeline: DanmakuTimeline, config: DanmakuLayoutConfig, rasterizer: DanmakuTextRasterizer, + font_selection: DanmakuFontSelection, prepared: Option, stable_tracks: HashMap, stable_viewport: Option, @@ -1852,6 +1906,7 @@ impl DfmLayoutEngine { timeline, config, rasterizer, + font_selection: DanmakuFontSelection::default(), prepared: None, stable_tracks: HashMap::new(), stable_viewport: None, @@ -1892,10 +1947,12 @@ impl DfmLayoutEngine { let re_enabling = !self.config.enabled && config.enabled; let layout_changed = re_enabling || !self.config.layout_equivalent(&config); let font_changed = self.config.custom_font_family != config.custom_font_family - || self.config.custom_font_file_path != config.custom_font_file_path; + || self.config.custom_font_file_path != config.custom_font_file_path + || self.config.custom_font_face_index != config.custom_font_face_index; self.config = config; if font_changed { - self.rasterizer = DanmakuTextRasterizer::for_config(&self.config); + self.rasterizer = + DanmakuTextRasterizer::for_config_and_selection(&self.config, &self.font_selection); } if layout_changed { self.prepared = None; @@ -1912,6 +1969,17 @@ impl DfmLayoutEngine { } } + pub fn set_font_selection(&mut self, selection: DanmakuFontSelection) -> bool { + if self.font_selection.generation == selection.generation { + return false; + } + self.rasterizer = DanmakuTextRasterizer::for_config_and_selection(&self.config, &selection); + self.font_selection = selection; + self.prepared = None; + self.invalidate_stable_tracks(); + true + } + pub fn config(&self) -> &DanmakuLayoutConfig { &self.config } @@ -2411,11 +2479,27 @@ fn sanitize_f32(value: f32, fallback: f32) -> f32 { if value.is_finite() { value } else { fallback } } -fn load_configured_font(family: &str, file_path: &str) -> Option { +fn load_configured_font(family: &str, file_path: &str, face_index: u32) -> Option { let file_path = file_path.trim(); if !file_path.is_empty() { - if let Some(font) = load_font_from_path(Path::new(file_path)) { - return Some(font); + match load_font_from_path(Path::new(file_path), face_index) { + Ok(font) => return Some(font), + Err(error) => { + crate::trace::diagnostic( + serde_json::json!({ + "event": "danmaku_custom_font", + "stage": "rejected", + "path": file_path, + "faceIndex": face_index, + "bytes": error.bytes, + "maxBytes": MAX_CUSTOM_DANMAKU_FONT_BYTES, + "reason": error.reason, + "detail": error.detail, + "fallback": if family.trim().is_empty() { "default" } else { "family_then_default" }, + }) + .to_string(), + ); + } } } @@ -2429,21 +2513,97 @@ fn load_configured_font(family: &str, file_path: &str) -> Option { load_default_font() } -fn load_font_from_path(path: &Path) -> Option { - if let Ok(bytes) = fs::read(path) { - if let Ok(font) = FontArc::try_from_vec(bytes) { - return Some(font); +fn load_selected_fonts( + attachments: &[SubtitleFontAttachment], + first_font_id: u32, +) -> Vec { + let mut fonts = Vec::new(); + for attachment in attachments { + let mut database = fontdb::Database::new(); + database.load_font_source(fontdb::Source::Binary(Arc::new(attachment.data.clone()))); + let face_indices = database.faces().map(|face| face.index).collect::>(); + for face_index in face_indices { + if let Some(font) = load_shared_font_data(attachment.data.clone(), face_index) { + let id = first_font_id.saturating_add(fonts.len() as u32); + fonts.push(DanmakuFontFace::new(id, font)); + } } } + fonts +} + +#[derive(Debug, PartialEq, Eq)] +struct CustomDanmakuFontError { + reason: &'static str, + detail: String, + bytes: u64, +} + +fn load_font_from_path( + path: &Path, + face_index: u32, +) -> std::result::Result { + let metadata = fs::metadata(path).map_err(|error| CustomDanmakuFontError { + reason: "metadata_failed", + detail: error.to_string(), + bytes: 0, + })?; + let bytes = metadata.len(); + if !metadata.is_file() { + return Err(CustomDanmakuFontError { + reason: "not_regular_file", + detail: "custom font path is not a regular file".to_string(), + bytes, + }); + } + if bytes > MAX_CUSTOM_DANMAKU_FONT_BYTES { + return Err(CustomDanmakuFontError { + reason: "file_too_large", + detail: format!( + "custom font exceeds {} byte limit", + MAX_CUSTOM_DANMAKU_FONT_BYTES + ), + bytes, + }); + } + + let file = File::open(path).map_err(|error| CustomDanmakuFontError { + reason: "read_failed", + detail: error.to_string(), + bytes, + })?; + let mut data = Vec::with_capacity(usize::try_from(bytes).unwrap_or(0)); + file.take(MAX_CUSTOM_DANMAKU_FONT_BYTES + 1) + .read_to_end(&mut data) + .map_err(|error| CustomDanmakuFontError { + reason: "read_failed", + detail: error.to_string(), + bytes, + })?; + if data.len() as u64 > MAX_CUSTOM_DANMAKU_FONT_BYTES { + return Err(CustomDanmakuFontError { + reason: "file_too_large", + detail: format!( + "custom font exceeds {} byte limit", + MAX_CUSTOM_DANMAKU_FONT_BYTES + ), + bytes: data.len() as u64, + }); + } + if let Some(font) = load_font_data(data.clone(), face_index) { + return Ok(font); + } let mut db = fontdb::Database::new(); - db.load_font_file(path).ok()?; + db.load_font_data(data); db.faces() - .next() - .and_then(|face| { - db.with_face_data(face.id, |data, _| FontArc::try_from_vec(data.to_vec()).ok()) + .find(|face| face.index == face_index) + .and_then(|face| load_fontdb_face(&db, face.id)) + .ok_or_else(|| CustomDanmakuFontError { + reason: "invalid_font", + detail: format!("font contains no loadable face at index {face_index}"), + bytes, }) - .flatten() } fn load_font_family(family: &str) -> Option { @@ -2455,9 +2615,113 @@ fn load_font_family(family: &str) -> Option { stretch: fontdb::Stretch::Normal, style: fontdb::Style::Normal, }; - db.query(&query) - .and_then(|id| db.with_face_data(id, |data, _| FontArc::try_from_vec(data.to_vec()).ok())) - .flatten() + db.query(&query).and_then(|id| load_fontdb_face(&db, id)) +} + +fn load_fontdb_face(db: &fontdb::Database, id: fontdb::ID) -> Option { + db.with_face_data(id, |data, face_index| { + load_font_data(data.to_vec(), face_index) + }) + .flatten() +} + +fn load_font_data(data: Vec, face_index: u32) -> Option { + FontVec::try_from_vec_and_index(data, face_index) + .map(FontArc::new) + .ok() +} + +#[derive(Clone)] +struct SharedFont { + // This field must be dropped before `data`, because its internal references + // point into the allocation kept alive by `data`. + font: FontRef<'static>, + data: Arc<[u8]>, +} + +impl SharedFont { + fn try_new(data: Arc<[u8]>, face_index: u32) -> Option { + // SAFETY: `bytes` points into the allocation owned by `data`. The Arc is + // stored in the same value and is declared after `font`, so it remains + // alive until all references held by FontRef have been dropped. + let bytes = unsafe { std::slice::from_raw_parts(data.as_ptr(), data.len()) }; + let font = FontRef::try_from_slice_and_index(bytes, face_index).ok()?; + Some(Self { font, data }) + } +} + +impl Font for SharedFont { + fn units_per_em(&self) -> Option { + self.font.units_per_em() + } + + fn ascent_unscaled(&self) -> f32 { + self.font.ascent_unscaled() + } + + fn descent_unscaled(&self) -> f32 { + self.font.descent_unscaled() + } + + fn line_gap_unscaled(&self) -> f32 { + self.font.line_gap_unscaled() + } + + fn italic_angle(&self) -> f32 { + self.font.italic_angle() + } + + fn glyph_id(&self, c: char) -> GlyphId { + self.font.glyph_id(c) + } + + fn h_advance_unscaled(&self, id: GlyphId) -> f32 { + self.font.h_advance_unscaled(id) + } + + fn h_side_bearing_unscaled(&self, id: GlyphId) -> f32 { + self.font.h_side_bearing_unscaled(id) + } + + fn v_advance_unscaled(&self, id: GlyphId) -> f32 { + self.font.v_advance_unscaled(id) + } + + fn v_side_bearing_unscaled(&self, id: GlyphId) -> f32 { + self.font.v_side_bearing_unscaled(id) + } + + fn kern_unscaled(&self, first: GlyphId, second: GlyphId) -> f32 { + self.font.kern_unscaled(first, second) + } + + fn outline(&self, id: GlyphId) -> Option { + self.font.outline(id) + } + + fn glyph_count(&self) -> usize { + self.font.glyph_count() + } + + fn codepoint_ids(&self) -> CodepointIdIter<'_> { + self.font.codepoint_ids() + } + + fn glyph_raster_image2(&self, id: GlyphId, pixel_size: u16) -> Option> { + self.font.glyph_raster_image2(id, pixel_size) + } + + fn glyph_svg_image(&self, id: GlyphId) -> Option> { + self.font.glyph_svg_image(id) + } + + fn font_data(&self) -> &[u8] { + &self.data + } +} + +fn load_shared_font_data(data: Arc<[u8]>, face_index: u32) -> Option { + SharedFont::try_new(data, face_index).map(FontArc::new) } fn load_default_font() -> Option { @@ -2483,13 +2747,7 @@ fn load_default_font() -> Option { stretch: fontdb::Stretch::Normal, style: fontdb::Style::Normal, }; - if let Some(font) = db - .query(&query) - .and_then(|id| { - db.with_face_data(id, |data, _| FontArc::try_from_vec(data.to_vec()).ok()) - }) - .flatten() - { + if let Some(font) = db.query(&query).and_then(|id| load_fontdb_face(&db, id)) { return Some(font); } } @@ -2509,10 +2767,7 @@ fn load_default_font() -> Option { "C:\\Windows\\Fonts\\arial.ttf", ]; for path in CANDIDATES { - let Ok(bytes) = fs::read(path) else { - continue; - }; - if let Ok(font) = FontArc::try_from_vec(bytes) { + if let Ok(font) = load_font_from_path(Path::new(path), 0) { return Some(font); } } @@ -2649,6 +2904,39 @@ fn dilate_alpha(input: &[u8], width: u32, height: u32, stride: usize, radius: i3 mod tests { use super::*; + fn test_ttc() -> Vec { + let source = NIPAPLAY_FALLBACK_FONT; + let face_count = 2u32; + let header_len = 12 + face_count as usize * 4; + let first_offset = (header_len + 3) & !3; + let second_offset = (first_offset + source.len() + 3) & !3; + let mut collection = vec![0; second_offset + source.len()]; + collection[0..4].copy_from_slice(b"ttcf"); + collection[4..8].copy_from_slice(&0x0001_0000u32.to_be_bytes()); + collection[8..12].copy_from_slice(&face_count.to_be_bytes()); + collection[12..16].copy_from_slice(&(first_offset as u32).to_be_bytes()); + collection[16..20].copy_from_slice(&(second_offset as u32).to_be_bytes()); + + for (face_index, face_offset) in [first_offset, second_offset].into_iter().enumerate() { + collection[face_offset..face_offset + source.len()].copy_from_slice(source); + let table_count = u16::from_be_bytes([source[4], source[5]]) as usize; + for table_index in 0..table_count { + let record = 12 + table_index * 16; + let source_offset = + u32::from_be_bytes(source[record + 8..record + 12].try_into().unwrap()) + as usize; + let collection_record = face_offset + record + 8; + collection[collection_record..collection_record + 4] + .copy_from_slice(&((face_offset + source_offset) as u32).to_be_bytes()); + if face_index == 1 && &source[record..record + 4] == b"head" { + collection[face_offset + source_offset + 18..face_offset + source_offset + 20] + .copy_from_slice(&512u16.to_be_bytes()); + } + } + } + collection + } + fn item(time: f64, text: &str, mode: DanmakuMode) -> DanmakuItem { DanmakuItem { id: 0, @@ -2669,6 +2957,10 @@ mod tests { ); } + fn selected_font(data: Vec) -> SubtitleFontAttachment { + SubtitleFontAttachment::new("selected", None, Vec::new(), Arc::<[u8]>::from(data)) + } + #[test] fn parses_json_long_and_short_fields() { let input = r##"{ @@ -3038,6 +3330,44 @@ mod tests { assert_eq!(retina_reference, reference); } + #[test] + fn selected_memory_fonts_fallback_in_selection_order() { + let first = selected_font(NIPAPLAY_FALLBACK_FONT.to_vec()); + let second = selected_font(test_ttc()); + let selection = DanmakuFontSelection::new(1, Arc::from(vec![first, second])); + let rasterizer = DanmakuTextRasterizer::for_config_and_selection( + &DanmakuLayoutConfig::default(), + &selection, + ); + + let resolved = rasterizer + .resolve_font('A') + .expect("selected font resolves"); + + assert_eq!(resolved.id, 1); + } + + #[test] + fn font_selection_generation_atomically_invalidates_rasterizer_caches() { + let timeline = DanmakuTimeline::new(vec![item(0.0, "cache", DanmakuMode::Top)]).unwrap(); + let mut engine = DfmLayoutEngine::new(timeline, DanmakuLayoutConfig::default()); + let viewport = DanmakuViewport::new(640, 360); + let first = engine.render_plan(Duration::from_millis(500), viewport, 1); + let first_atlas = first.atlas.expect("first atlas exists"); + let selection = DanmakuFontSelection::new( + 2, + Arc::from(vec![selected_font(NIPAPLAY_FALLBACK_FONT.to_vec())]), + ); + + assert!(engine.set_font_selection(selection.clone())); + assert!(!engine.set_font_selection(selection)); + assert!(engine.prepared.is_none()); + let second = engine.render_plan(Duration::from_millis(500), viewport, 2); + let second_atlas = second.atlas.expect("second atlas exists"); + + assert!(!Arc::ptr_eq(&first_atlas, &second_atlas)); + } + #[test] fn layout_font_size_uses_nipaplay_logical_units_across_resize() { let timeline = DanmakuTimeline::new(vec![DanmakuItem { @@ -3277,4 +3607,94 @@ mod tests { assert_eq!(prepared.items().len(), 0); } + + #[test] + fn custom_font_rejects_oversized_file_from_metadata() { + let path = std::env::temp_dir().join(format!( + "erika_danmaku_oversized_font_{}.ttf", + std::process::id() + )); + let file = File::create(&path).unwrap(); + file.set_len(MAX_CUSTOM_DANMAKU_FONT_BYTES + 1).unwrap(); + + let error = load_font_from_path(&path, 0).unwrap_err(); + + assert_eq!(error.reason, "file_too_large"); + assert_eq!(error.bytes, MAX_CUSTOM_DANMAKU_FONT_BYTES + 1); + std::fs::remove_file(path).unwrap(); + } + + #[test] + fn custom_font_rejects_non_regular_file_from_metadata() { + let error = load_font_from_path(std::env::temp_dir().as_path(), 0).unwrap_err(); + + assert_eq!(error.reason, "not_regular_file"); + } + + #[test] + fn custom_font_reports_invalid_font_structure() { + let path = std::env::temp_dir().join(format!( + "erika_danmaku_invalid_font_{}.ttf", + std::process::id() + )); + std::fs::write(&path, b"not a font").unwrap(); + + let error = load_font_from_path(&path, 0).unwrap_err(); + + assert_eq!(error.reason, "invalid_font"); + assert_eq!(error.bytes, 10); + assert!(!error.detail.is_empty()); + std::fs::remove_file(path).unwrap(); + } + + #[test] + fn invalid_custom_font_keeps_default_fallback() { + let path = std::env::temp_dir().join(format!( + "erika_danmaku_fallback_font_{}.ttf", + std::process::id() + )); + std::fs::write(&path, b"not a font").unwrap(); + + let font = load_configured_font("", path.to_str().unwrap(), 0); + + assert!(font.is_some()); + std::fs::remove_file(path).unwrap(); + } + + #[test] + fn custom_font_path_uses_configured_collection_face_index() { + let path = std::env::temp_dir().join(format!( + "erika_danmaku_collection_font_{}.ttc", + std::process::id() + )); + std::fs::write(&path, test_ttc()).unwrap(); + + let first = load_font_from_path(&path, 0).unwrap(); + let second = load_font_from_path(&path, 1).unwrap(); + + assert_ne!(first.units_per_em(), second.units_per_em()); + assert_eq!(second.units_per_em(), Some(512.0)); + std::fs::remove_file(path).unwrap(); + } + + #[test] + fn fontdb_face_loader_uses_queried_collection_face_index() { + let mut db = fontdb::Database::new(); + db.load_font_data(test_ttc()); + let second_id = db.faces().find(|face| face.index == 1).unwrap().id; + + let second = load_fontdb_face(&db, second_id).unwrap(); + + assert_eq!(second.units_per_em(), Some(512.0)); + } + + #[test] + fn selected_collection_faces_share_the_attachment_allocation() { + let attachment = selected_font(test_ttc()); + let fonts = load_selected_fonts(std::slice::from_ref(&attachment), 1); + + assert_eq!(fonts.len(), 2); + assert_eq!(fonts[0].font.font_data().as_ptr(), attachment.data.as_ptr()); + assert_eq!(fonts[1].font.font_data().as_ptr(), attachment.data.as_ptr()); + } } diff --git a/crates/erika/src/presenter.rs b/crates/erika/src/presenter.rs index 1b03eb8..a0ebc6c 100644 --- a/crates/erika/src/presenter.rs +++ b/crates/erika/src/presenter.rs @@ -1,5 +1,5 @@ use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, env, fs::OpenOptions, io::Write, @@ -35,10 +35,10 @@ use crate::core::{ VideoDecoderEvent, VideoFrameImportFailure, }; use crate::danmaku::{ - DANMAKU_DEBUG_BUCKETS, DanmakuConfigChange, DanmakuDebugBucket, DanmakuLayoutConfig, - DanmakuMode, DanmakuPreparedStats, DanmakuRenderPlan, DanmakuSession, DanmakuTextRasterizer, - DanmakuTimeline, DanmakuTrackInfo, DanmakuTrackSource, DanmakuViewport, DfmLayoutEngine, - DfmPreparedLayout, scroll_duration_for_viewport, + DANMAKU_DEBUG_BUCKETS, DanmakuConfigChange, DanmakuDebugBucket, DanmakuFontSelection, + DanmakuLayoutConfig, DanmakuMode, DanmakuPreparedStats, DanmakuRenderPlan, DanmakuSession, + DanmakuTextRasterizer, DanmakuTimeline, DanmakuTrackInfo, DanmakuTrackSource, DanmakuViewport, + DfmLayoutEngine, DfmPreparedLayout, scroll_duration_for_viewport, }; use crate::debug_hud::{DebugHud, DebugHudSnapshot}; use crate::ffmpeg::DecoderBackend; @@ -57,7 +57,8 @@ use crate::subtitle::{ decoded_subtitle_frames_to_ass_script_with_style, }; use crate::subtitle::{ - DecodedSubtitleFrame, SubtitleAssStyle, SubtitleRendererCore, SubtitleStyleConfig, + DecodedSubtitleFrame, MAX_MEMORY_SUBTITLE_FONT_BYTES, MAX_MEMORY_SUBTITLE_FONT_TOTAL_BYTES, + SubtitleAssStyle, SubtitleFontAttachment, SubtitleRendererCore, SubtitleStyleConfig, SubtitleTrackConfig, SubtitleViewport, decoded_subtitle_frames_to_timeline, }; use crate::trace; @@ -81,6 +82,47 @@ const DANMAKU_MOTION_TRACE_INTERVAL: Duration = Duration::from_millis(500); const DANMAKU_MOTION_BACKSTEP_EPSILON: f32 = 0.5; const DEFAULT_SUBTITLE_FONT_SCALE: f64 = 1.0; +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SubtitleMemoryFontStatus { + pub registered_count: usize, + pub registered_bytes: usize, + pub selected_count: usize, + pub generation: u64, + pub selected_ids: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubtitleMemoryFontFace { + pub index: u32, + pub families: Vec, + pub post_script_name: String, + pub weight: u16, + pub italic: bool, + pub monospaced: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubtitleMemoryFontInfo { + pub id: u64, + pub byte_len: usize, + pub faces: Vec, +} + +#[derive(Debug, Clone)] +struct SubtitleMemoryFontEntry { + attachment: SubtitleFontAttachment, + faces: Vec, +} + +#[derive(Debug, Default)] +struct SubtitleMemoryFonts { + next_id: u64, + registered: HashMap, + selected_ids: Vec, + generation: u64, + selection_generation: u64, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum TransitionFramePolicy { Clear, @@ -258,6 +300,7 @@ pub struct PresenterRuntime { current_danmaku_viewport: Option, subtitle_font_scale: f64, subtitle_style: SubtitleStyleConfig, + subtitle_memory_fonts: SubtitleMemoryFonts, subtitles: SubtitleFrameState, overlay: OverlayTimeline, render_test_pattern_when_idle: bool, @@ -424,6 +467,18 @@ impl AsyncDanmakuPlanner { state.config_revision = state.config_revision.saturating_add(1); } + fn set_font_selection(&mut self, font_selection: DanmakuFontSelection) { + self.last_requested = None; + let (lock, cvar) = &*self.shared; + let mut state = lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + state.rasterizer = + DanmakuTextRasterizer::for_config_and_selection(&state.config, &font_selection); + state.latest_request = None; + state.config_revision = state.config_revision.saturating_add(1); + state.revision = state.revision.saturating_add(1); + cvar.notify_one(); + } + fn invalidate_requests(&mut self) { self.last_requested = None; let (lock, _) = &*self.shared; @@ -633,6 +688,7 @@ impl PresenterRuntime { current_danmaku_viewport: None, subtitle_font_scale: DEFAULT_SUBTITLE_FONT_SCALE, subtitle_style: SubtitleStyleConfig::default(), + subtitle_memory_fonts: SubtitleMemoryFonts::default(), subtitles: SubtitleFrameState::default(), overlay: config.overlay, render_test_pattern_when_idle: config.render_test_pattern_when_idle, @@ -861,6 +917,163 @@ impl PresenterRuntime { &self.subtitle_style } + pub fn register_subtitle_font_bytes(&mut self, data: &[u8]) -> Result { + if data.is_empty() || data.len() > MAX_MEMORY_SUBTITLE_FONT_BYTES { + return Err(PlayerError::Playback( + "subtitle memory font size is invalid".to_string(), + )); + } + let registered_bytes = self + .subtitle_memory_fonts + .registered + .values() + .try_fold(0usize, |total, font| { + total.checked_add(font.attachment.byte_len()) + }); + if registered_bytes + .and_then(|total| total.checked_add(data.len())) + .is_none_or(|total| total > MAX_MEMORY_SUBTITLE_FONT_TOTAL_BYTES) + { + return Err(PlayerError::Playback( + "subtitle memory font total byte limit exceeded".to_string(), + )); + } + let mut database = fontdb::Database::new(); + database.load_font_data(data.to_vec()); + let faces = database + .faces() + .map(|face| SubtitleMemoryFontFace { + index: face.index, + families: face + .families + .iter() + .map(|(family, _)| family.clone()) + .collect(), + post_script_name: face.post_script_name.clone(), + weight: face.weight.0, + italic: face.style == fontdb::Style::Italic, + monospaced: face.monospaced, + }) + .collect::>(); + let mut families = faces + .iter() + .flat_map(|face| face.families.iter().cloned()) + .collect::>(); + families.sort(); + families.dedup(); + if families.is_empty() { + return Err(PlayerError::Playback( + "subtitle memory font contains no faces".to_string(), + )); + } + self.subtitle_memory_fonts.next_id = self.subtitle_memory_fonts.next_id.saturating_add(1); + let id = self.subtitle_memory_fonts.next_id.max(1); + self.subtitle_memory_fonts.registered.insert( + id, + SubtitleMemoryFontEntry { + attachment: SubtitleFontAttachment::new( + format!("memory-subtitle-font-{id}"), + None, + families, + Arc::<[u8]>::from(data), + ), + faces, + }, + ); + self.bump_subtitle_memory_font_registry_generation(); + Ok(id) + } + + pub fn select_subtitle_memory_fonts(&mut self, ids: &[u64]) -> Result<()> { + let mut seen = HashSet::with_capacity(ids.len()); + if ids.iter().any(|id| { + *id == 0 || !self.subtitle_memory_fonts.registered.contains_key(id) || !seen.insert(*id) + }) { + return Err(PlayerError::Playback( + "subtitle memory font selection contains an invalid id".to_string(), + )); + } + if self.subtitle_memory_fonts.selected_ids == ids { + return Ok(()); + } + self.subtitle_memory_fonts.selected_ids.clear(); + self.subtitle_memory_fonts + .selected_ids + .extend_from_slice(ids); + self.bump_subtitle_memory_font_selection_generation(); + Ok(()) + } + + pub fn clear_subtitle_memory_fonts(&mut self) { + if self.subtitle_memory_fonts.registered.is_empty() { + return; + } + self.subtitle_memory_fonts.registered.clear(); + self.subtitle_memory_fonts.selected_ids.clear(); + self.bump_subtitle_memory_font_selection_generation(); + } + + pub fn subtitle_memory_font_status(&self) -> SubtitleMemoryFontStatus { + SubtitleMemoryFontStatus { + registered_count: self.subtitle_memory_fonts.registered.len(), + registered_bytes: self + .subtitle_memory_fonts + .registered + .values() + .map(|font| font.attachment.byte_len()) + .sum(), + selected_count: self.subtitle_memory_fonts.selected_ids.len(), + generation: self.subtitle_memory_fonts.generation, + selected_ids: self.subtitle_memory_fonts.selected_ids.clone(), + } + } + + pub fn subtitle_memory_font_info(&self, id: u64) -> Option { + let font = self.subtitle_memory_fonts.registered.get(&id)?; + Some(SubtitleMemoryFontInfo { + id, + byte_len: font.attachment.byte_len(), + faces: font.faces.clone(), + }) + } + + fn bump_subtitle_memory_font_registry_generation(&mut self) { + self.subtitle_memory_fonts.generation = + self.subtitle_memory_fonts.generation.saturating_add(1); + } + + fn bump_subtitle_memory_font_selection_generation(&mut self) { + self.bump_subtitle_memory_font_registry_generation(); + self.subtitle_memory_fonts.selection_generation = self + .subtitle_memory_fonts + .selection_generation + .saturating_add(1); + let selection = self.danmaku_font_selection(); + self.danmaku.set_font_selection(selection.clone()); + self.danmaku_planner.set_font_selection(selection); + self.clear_current_danmaku_state(); + self.bump_danmaku_generation(); + self.refresh_current_overlay(); + } + + fn danmaku_font_selection(&self) -> DanmakuFontSelection { + let fonts = self + .subtitle_memory_fonts + .selected_ids + .iter() + .filter_map(|id| { + self.subtitle_memory_fonts + .registered + .get(id) + .map(|font| font.attachment.clone()) + }) + .collect::>(); + DanmakuFontSelection::new( + self.subtitle_memory_fonts.selection_generation, + Arc::from(fonts), + ) + } + fn apply_subtitle_style(&mut self, style: SubtitleStyleConfig) { let style = style.normalized(); if self.subtitle_style == style { @@ -1843,11 +2056,24 @@ impl PresenterRuntime { } fn subtitle_ass_style(&self, viewport: OverlayViewport) -> SubtitleAssStyle { + let memory_fonts = self + .subtitle_memory_fonts + .selected_ids + .iter() + .filter_map(|id| { + self.subtitle_memory_fonts + .registered + .get(id) + .map(|font| font.attachment.clone()) + }) + .collect::>(); SubtitleAssStyle { font_scale: self.subtitle_font_scale, play_res_width: viewport.width, play_res_height: viewport.height, style: self.subtitle_style.clone(), + memory_fonts: Arc::from(memory_fonts), + memory_font_revision: self.subtitle_memory_fonts.selection_generation, } } @@ -3136,10 +3362,14 @@ impl SubtitleFrameState { let mut subtitle_changed = false; #[cfg(feature = "libass")] - match self - .ass_renderer - .render(pts, overlay.viewport, style.font_scale, &style.style) - { + match self.ass_renderer.render( + pts, + overlay.viewport, + style.font_scale, + &style.style, + &style.memory_fonts, + style.memory_font_revision, + ) { Ok(Some(bitmaps)) => { subtitle_changed |= bitmaps.changed; overlay.subtitle_alpha_planes.extend(bitmaps.parts); @@ -3235,6 +3465,8 @@ struct CachedAssTrackRenderer { resources: Option>, renderer: Option, style: SubtitleStyleConfig, + memory_font_revision: u64, + chunks: Vec<(String, Duration, Option)>, } #[cfg(feature = "libass")] @@ -3247,6 +3479,8 @@ impl CachedAssTrackRenderer { self.track_id = None; self.resources = None; self.renderer = None; + self.memory_font_revision = 0; + self.chunks.clear(); } fn clear_track(&mut self, track_id: i64) { @@ -3296,6 +3530,7 @@ impl CachedAssTrackRenderer { .filter(|segment| segment.format == crate::subtitle::SubtitleTextFormat::Ass) { renderer.process_chunk(&segment.text, start, frame.end)?; + self.chunks.push((segment.text.clone(), start, frame.end)); } Ok(true) } @@ -3306,7 +3541,25 @@ impl CachedAssTrackRenderer { viewport: OverlayViewport, font_scale: f64, style: &SubtitleStyleConfig, + memory_fonts: &Arc<[SubtitleFontAttachment]>, + memory_font_revision: u64, ) -> crate::subtitle::Result> { + if self.memory_font_revision != memory_font_revision { + if let (Some(track_id), Some(resources)) = (self.track_id, self.resources.as_ref()) { + let mut renderer = LibassSubtitleRenderer::from_ass_track_with_style_and_fonts( + track_id, + resources, + LibassRenderConfig::default(), + style, + memory_fonts.clone(), + )?; + for (chunk, start, end) in &self.chunks { + renderer.process_chunk(chunk, *start, *end)?; + } + self.renderer = Some(renderer); + } + self.memory_font_revision = memory_font_revision; + } if &self.style != style { self.style = style.clone(); } @@ -3334,6 +3587,7 @@ impl CachedAssTrackRenderer { struct CachedLibassTextRenderer { script: Option, renderer: Option, + memory_font_revision: u64, } #[cfg(feature = "libass")] @@ -3358,13 +3612,19 @@ impl CachedLibassTextRenderer { self.renderer = None; return Ok(None); }; - if self.script.as_ref() != Some(&script) { - self.renderer = Some(LibassSubtitleRenderer::from_ass_script_with_style( - script.as_bytes(), - LibassRenderConfig::default(), - &style.style, - )?); + if self.script.as_ref() != Some(&script) + || self.memory_font_revision != style.memory_font_revision + { + self.renderer = Some( + LibassSubtitleRenderer::from_ass_script_with_style_and_fonts( + script.as_bytes(), + LibassRenderConfig::default(), + &style.style, + style.memory_fonts.clone(), + )?, + ); self.script = Some(script); + self.memory_font_revision = style.memory_font_revision; } let Some(renderer) = self.renderer.as_mut() else { @@ -3901,6 +4161,78 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text assert!(overlay.subtitle_alpha_planes.is_empty()); } + #[cfg(feature = "libass")] + #[test] + fn subtitle_state_resets_memory_font_revision_on_clear() { + let header = ass_test_header(); + let resources = Arc::new(AssTrackResources::new( + 2, + Arc::<[u8]>::from(header.as_bytes()), + Arc::<[crate::subtitle::SubtitleFontAttachment]>::from([]), + )); + let mut state = SubtitleFrameState::default(); + + // Simulate memory fonts having been installed at revision 5. + state.ass_renderer.memory_font_revision = 5; + + // Push an ASS frame with a different track_id to trigger clear() via + // process_frame when the track_id doesn't match. + state.push(ass_subtitle_frame( + 3, + 1, + Duration::ZERO, + Duration::from_secs(2), + 1, + resources, + )); + + // Regression: clear() must reset memory_font_revision so that the + // next render() call detects the mismatch and rebuilds the renderer + // with the current memory-font snapshot. + assert_eq!( + state.ass_renderer.memory_font_revision, 0, + "memory_font_revision should be reset to 0 after clear()" + ); + + // When render() is called with a non-zero revision, it should + // trigger a rebuild with memory fonts and update the field. + let mut overlay = empty_overlay(); + let mut style = SubtitleAssStyle::default(); + style.memory_font_revision = 5; + state.append_to_overlay(Duration::from_millis(500), &mut overlay, &style); + + assert_eq!( + state.ass_renderer.memory_font_revision, 5, + "memory_font_revision should be updated after render() rebuilds with memory fonts" + ); + } + + #[test] + fn registering_an_unselected_memory_font_does_not_invalidate_renderers() { + let mut presenter = PresenterRuntime::new(PresenterConfig::default()).unwrap(); + let danmaku_generation = presenter.danmaku_font_selection().generation; + let subtitle_revision = presenter + .subtitle_ass_style(OverlayViewport::new(640, 360)) + .memory_font_revision; + + let id = presenter + .register_subtitle_font_bytes(crate::NIPAPLAY_FALLBACK_FONT) + .unwrap(); + + assert!(id > 0); + assert_eq!(presenter.subtitle_memory_font_status().generation, 1); + assert_eq!( + presenter.danmaku_font_selection().generation, + danmaku_generation + ); + assert_eq!( + presenter + .subtitle_ass_style(OverlayViewport::new(640, 360)) + .memory_font_revision, + subtitle_revision + ); + } + #[test] fn danmaku_generation_bump_clears_stale_plans_after_seek() { let mut generation = 7; @@ -3927,6 +4259,42 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text assert_eq!(danmaku_motion_backstep(DanmakuMode::Top, 100.0, 140.0), 0.0); } + #[test] + fn async_danmaku_planner_applies_font_selection_generation() { + let engine = danmaku_engine("async font"); + let timeline = DanmakuTimeline::new(vec![danmaku_item(1, 1.0, "async font")]).unwrap(); + let mut planner = + AsyncDanmakuPlanner::new(engine, timeline, DanmakuLayoutConfig::default()); + let selection = DanmakuFontSelection::new( + 9, + Arc::from(vec![SubtitleFontAttachment::new( + "memory", + None, + Vec::new(), + Arc::<[u8]>::from(crate::NIPAPLAY_FALLBACK_FONT), + )]), + ); + planner.set_font_selection(selection); + let key = DanmakuPlanKey { + media_time: Duration::from_secs(1), + viewport: DanmakuViewport::new(640, 360), + generation: 9, + }; + planner.request_plan(key); + + let deadline = Instant::now() + Duration::from_secs(5); + let result = loop { + if let Some(result) = planner.try_recv() { + break result; + } + assert!(Instant::now() < deadline, "async planner timed out"); + thread::sleep(Duration::from_millis(10)); + }; + + assert_eq!(result.request.key, key); + assert!(!result.prepared.items().is_empty()); + } + #[test] fn presenter_config_disables_idle_test_pattern_by_default() { assert!(!PresenterConfig::default().render_test_pattern_when_idle); diff --git a/crates/erika/src/subtitle.rs b/crates/erika/src/subtitle.rs index 936baa5..b3dddba 100644 --- a/crates/erika/src/subtitle.rs +++ b/crates/erika/src/subtitle.rs @@ -1,9 +1,12 @@ #[cfg(feature = "libass")] use std::{ - collections::HashSet, + collections::{HashMap, HashSet}, ffi::{CStr, CString}, + fs::File, + io::Read, ptr::NonNull, sync::Mutex, + time::SystemTime, }; use std::{sync::Arc, time::Duration}; @@ -43,6 +46,9 @@ pub struct SubtitleFontAttachment { pub data: Arc<[u8]>, } +pub const MAX_MEMORY_SUBTITLE_FONT_BYTES: usize = 32 * 1024 * 1024; +pub const MAX_MEMORY_SUBTITLE_FONT_TOTAL_BYTES: usize = 128 * 1024 * 1024; + impl SubtitleFontAttachment { pub fn new( name: impl Into, @@ -759,6 +765,9 @@ const ASS_FONTPROVIDER_AUTODETECT: libc::c_int = 1; #[cfg(feature = "libass")] const ASS_FONTPROVIDER_CORETEXT: libc::c_int = 2; +#[cfg(feature = "libass")] +const MAX_CUSTOM_SUBTITLE_FONT_BYTES: u64 = 64 * 1024 * 1024; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct LibassRenderConfig { pub glyph_cache_limit: i32, @@ -932,6 +941,8 @@ pub struct SubtitleAssStyle { pub play_res_width: u32, pub play_res_height: u32, pub style: SubtitleStyleConfig, + pub memory_fonts: Arc<[SubtitleFontAttachment]>, + pub memory_font_revision: u64, } impl Default for SubtitleAssStyle { @@ -941,6 +952,8 @@ impl Default for SubtitleAssStyle { play_res_width: 1920, play_res_height: 1080, style: SubtitleStyleConfig::default(), + memory_fonts: Arc::from([]), + memory_font_revision: 0, } } } @@ -992,11 +1005,24 @@ impl LibassRenderPlan { pub struct LibassSubtitleRenderer { runtime: LibassRuntime, track: NonNull, + source: LibassTrackSource, + chunks: Vec<(String, Duration, Option)>, config: LibassRenderConfig, font_scale: f64, override_font_scale: f64, play_res_height: u32, style: SubtitleStyleConfig, + memory_fonts: Arc<[SubtitleFontAttachment]>, +} + +#[cfg(feature = "libass")] +#[derive(Debug, Clone)] +enum LibassTrackSource { + Script(Arc<[u8]>), + Track { + track_id: i64, + resources: AssTrackResources, + }, } /// Font selection currently installed in libass. `ass_set_fonts` rebuilds the @@ -1015,15 +1041,24 @@ struct LibassRuntime { library: NonNull, renderer: NonNull, track_id: i64, - loaded_font_files: HashSet, + loaded_font_files: HashMap, + observed_font_files: HashMap, fonts: Option, /// Counts `ass_set_fonts` calls. Only meaningful to the tests that pin how /// rarely the font selector is rebuilt. fonts_generation: u64, + custom_font_loads: u64, _log_context: Box, _log_bridge: Box, } +#[cfg(feature = "libass")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct CustomFontFileIdentity { + modified: SystemTime, + size: u64, +} + #[cfg(feature = "libass")] #[derive(Debug)] struct LibassLogContext { @@ -1036,6 +1071,7 @@ impl LibassRuntime { fn new( track_id: i64, fonts: &[SubtitleFontAttachment], + memory_fonts: &[SubtitleFontAttachment], config: LibassRenderConfig, style: &SubtitleStyleConfig, ) -> Result { @@ -1054,6 +1090,7 @@ impl LibassRuntime { libass_ffi::erika_ass_install_log_bridge(library.as_ptr(), &mut *log_bridge); libass_ffi::ass_set_extract_fonts(library.as_ptr(), 1); add_bundled_ass_fallback_font(library.as_ptr(), track_id); + add_attached_ass_fonts(library.as_ptr(), track_id, memory_fonts); add_attached_ass_fonts(library.as_ptr(), track_id, fonts); let Some(renderer) = NonNull::new(libass_ffi::ass_renderer_init(library.as_ptr())) @@ -1072,14 +1109,16 @@ impl LibassRuntime { library, renderer, track_id, - loaded_font_files: HashSet::new(), + loaded_font_files: HashMap::new(), + observed_font_files: HashMap::new(), fonts: None, fonts_generation: 0, + custom_font_loads: 0, _log_context: log_context, _log_bridge: log_bridge, } }; - runtime.configure_style(style, 1.0, 288); + runtime.configure_style(style, memory_fonts, 1.0, 288); Ok(runtime) } @@ -1094,10 +1133,11 @@ impl LibassRuntime { fn configure_style( &mut self, style: &SubtitleStyleConfig, + memory_fonts: &[SubtitleFontAttachment], font_scale: f64, play_res_height: u32, ) { - let fonts_changed = self.configure_fonts(style); + let fonts_changed = self.configure_fonts(style, memory_fonts); let override_bits = self.configure_style_override(style, font_scale, play_res_height); crate::trace::diagnostic( serde_json::json!({ @@ -1121,17 +1161,31 @@ impl LibassRuntime { /// bundled fallback, every container attachment, every custom face) and /// empties the font, metrics and bitmap caches — far too much work to repeat /// on a viewport resize. Returns whether libass was reconfigured. - fn configure_fonts(&mut self, style: &SubtitleStyleConfig) -> bool { + fn configure_fonts( + &mut self, + style: &SubtitleStyleConfig, + memory_fonts: &[SubtitleFontAttachment], + ) -> bool { if let Some(path) = style.font_file_path() { self.load_custom_font_file(path); } + let mut fallback_families = Vec::new(); + if let Some(family) = style.font_family() { + fallback_families.push(family.to_string()); + } + for font in memory_fonts { + fallback_families.extend(font.families.iter().cloned()); + } + fallback_families.push(BUNDLED_ASS_FALLBACK_FONT_FAMILY.to_string()); + let mut seen = HashSet::new(); + fallback_families.retain(|family| !family.is_empty() && seen.insert(family.clone())); + let family = CString::new(fallback_families.join("\u{1f}")) + .unwrap_or_else(|_| default_ass_font_family_cstr().to_owned()); let selection = LibassFontSelection { - family: style - .font_family() - .and_then(|family| CString::new(family).ok()) - .unwrap_or_else(|| default_ass_font_family_cstr().to_owned()), + family, default_font: style .font_file_path() + .filter(|path| self.custom_font_file_is_current(path)) .and_then(|path| CString::new(path).ok()), }; if self.fonts.as_ref() == Some(&selection) { @@ -1226,10 +1280,7 @@ impl LibassRuntime { /// Loads a user-selected font file once per runtime. libass copies the /// bytes, so the buffer does not have to outlive the call. - fn load_custom_font_file(&mut self, path: &str) { - if self.loaded_font_files.contains(path) { - return; - } + fn load_custom_font_file(&mut self, path: &str) -> bool { let reject = |reason: &str, bytes: usize| { crate::trace::diagnostic( serde_json::json!({ @@ -1243,16 +1294,82 @@ impl LibassRuntime { .to_string(), ); }; - let data = match std::fs::read(path) { - Ok(data) => data, + let mut file = match File::open(path) { + Ok(file) => file, + Err(error) => { + reject(&error.to_string(), 0); + return false; + } + }; + let metadata = match file.metadata() { + Ok(metadata) => metadata, Err(error) => { reject(&error.to_string(), 0); - return; + return false; } }; + if !metadata.is_file() { + reject("custom font path is not a regular file", 0); + return false; + } + let size = metadata.len(); + let modified = match metadata.modified() { + Ok(modified) => modified, + Err(error) => { + reject( + &error.to_string(), + usize::try_from(size).unwrap_or(usize::MAX), + ); + return false; + } + }; + let identity = CustomFontFileIdentity { modified, size }; + self.observed_font_files.insert(path.to_string(), identity); + if size == 0 { + reject("custom font file is empty", 0); + return false; + } + if size > MAX_CUSTOM_SUBTITLE_FONT_BYTES { + reject( + "custom font byte limit exceeded", + usize::try_from(size).unwrap_or(usize::MAX), + ); + return false; + } + if self.loaded_font_files.get(path) == Some(&identity) { + return false; + } + let mut data = Vec::with_capacity(size as usize); + let read_result = (&mut file) + .take(MAX_CUSTOM_SUBTITLE_FONT_BYTES + 1) + .read_to_end(&mut data); + if let Err(error) = read_result { + reject(&error.to_string(), data.len()); + return false; + } + if data.len() as u64 != size { + reject("custom font changed while being read", data.len()); + return false; + } + let current_identity = file.metadata().ok().and_then(|metadata| { + Some(CustomFontFileIdentity { + modified: metadata.modified().ok()?, + size: metadata.len(), + }) + }); + if current_identity != Some(identity) { + reject("custom font changed while being read", data.len()); + return false; + } + let mut database = fontdb::Database::new(); + database.load_font_data(data.clone()); + if database.faces().next().is_none() { + reject("font parser found no faces", data.len()); + return false; + } let Ok(data_size) = libc::c_int::try_from(data.len()) else { reject("custom font exceeds libass integer range", data.len()); - return; + return false; }; let name = std::path::Path::new(path) .file_name() @@ -1260,7 +1377,7 @@ impl LibassRuntime { .unwrap_or(path); let Ok(name) = CString::new(name) else { reject("custom font name contains an interior NUL", data.len()); - return; + return false; }; unsafe { libass_ffi::ass_add_font( @@ -1270,7 +1387,8 @@ impl LibassRuntime { data_size, ); } - self.loaded_font_files.insert(path.to_string()); + self.loaded_font_files.insert(path.to_string(), identity); + self.custom_font_loads = self.custom_font_loads.saturating_add(1); crate::trace::diagnostic( serde_json::json!({ "event": "subtitle_custom_font", @@ -1281,7 +1399,27 @@ impl LibassRuntime { }) .to_string(), ); + true } + + fn custom_font_file_is_current(&self, path: &str) -> bool { + let Some(loaded) = self.loaded_font_files.get(path) else { + return false; + }; + custom_font_file_identity(path).is_some_and(|identity| identity == *loaded) + } +} + +#[cfg(feature = "libass")] +fn custom_font_file_identity(path: &str) -> Option { + let metadata = File::open(path).ok()?.metadata().ok()?; + if !metadata.is_file() { + return None; + } + Some(CustomFontFileIdentity { + modified: metadata.modified().ok()?, + size: metadata.len(), + }) } #[cfg(feature = "libass")] @@ -1305,19 +1443,28 @@ impl LibassSubtitleRenderer { config: LibassRenderConfig, style: &SubtitleStyleConfig, ) -> Result { - let script = script.as_ref(); + Self::from_ass_script_with_style_and_fonts(script, config, style, Arc::from([])) + } + + pub fn from_ass_script_with_style_and_fonts( + script: impl AsRef<[u8]>, + config: LibassRenderConfig, + style: &SubtitleStyleConfig, + memory_fonts: Arc<[SubtitleFontAttachment]>, + ) -> Result { + let script = Arc::<[u8]>::from(script.as_ref()); if script.is_empty() { return Err(SubtitleError::Libass("ASS script is empty".to_string())); } let style = style.clone().normalized(); - let mut script = script.to_vec(); - let runtime = LibassRuntime::new(-1, &[], config, &style)?; + let mut script_buffer = script.to_vec(); + let runtime = LibassRuntime::new(-1, &[], &memory_fonts, config, &style)?; unsafe { let Some(track) = NonNull::new(libass_ffi::ass_read_memory( runtime.library.as_ptr(), - script.as_mut_ptr().cast(), - script.len(), + script_buffer.as_mut_ptr().cast(), + script_buffer.len(), std::ptr::null(), )) else { return Err(SubtitleError::Libass( @@ -1327,11 +1474,14 @@ impl LibassSubtitleRenderer { Ok(Self { runtime, track, + source: LibassTrackSource::Script(script), + chunks: Vec::new(), config, font_scale: 1.0, override_font_scale: 1.0, play_res_height: 288, style, + memory_fonts, }) } } @@ -1354,6 +1504,16 @@ impl LibassSubtitleRenderer { resources: &AssTrackResources, config: LibassRenderConfig, style: &SubtitleStyleConfig, + ) -> Result { + Self::from_ass_track_with_style_and_fonts(track_id, resources, config, style, Arc::from([])) + } + + pub fn from_ass_track_with_style_and_fonts( + track_id: i64, + resources: &AssTrackResources, + config: LibassRenderConfig, + style: &SubtitleStyleConfig, + memory_fonts: Arc<[SubtitleFontAttachment]>, ) -> Result { if resources.codec_private.is_empty() { crate::trace::diagnostic( @@ -1373,7 +1533,8 @@ impl LibassSubtitleRenderer { SubtitleError::Libass("ASS CodecPrivate exceeds libass integer range".to_string()) })?; let style = style.clone().normalized(); - let runtime = LibassRuntime::new(track_id, &resources.fonts, config, &style)?; + let runtime = + LibassRuntime::new(track_id, &resources.fonts, &memory_fonts, config, &style)?; unsafe { let track = NonNull::new(libass_ffi::ass_new_track(runtime.library.as_ptr())) .ok_or_else(|| SubtitleError::Libass("failed to allocate ASS track".to_string()))?; @@ -1397,11 +1558,17 @@ impl LibassSubtitleRenderer { Ok(Self { runtime, track, + source: LibassTrackSource::Track { + track_id, + resources: resources.clone(), + }, + chunks: Vec::new(), config, font_scale: 1.0, override_font_scale: 1.0, play_res_height: 288, style, + memory_fonts, }) } } @@ -1430,11 +1597,13 @@ impl LibassSubtitleRenderer { duration_ms, ); } + self.chunks.push((chunk.to_string(), start, end)); Ok(()) } pub fn flush_events(&mut self) { unsafe { libass_ffi::ass_flush_events(self.track.as_ptr()) }; + self.chunks.clear(); } /// Sets the scale libass applies to the whole script, including whatever the @@ -1454,8 +1623,12 @@ impl LibassSubtitleRenderer { return; } self.override_font_scale = scale; - self.runtime - .configure_style(&self.style, self.override_font_scale, self.play_res_height); + self.runtime.configure_style( + &self.style, + &self.memory_fonts, + self.override_font_scale, + self.play_res_height, + ); } /// Height the override metrics are normalized against, which must be the @@ -1469,8 +1642,12 @@ impl LibassSubtitleRenderer { return; } self.play_res_height = play_res_height; - self.runtime - .configure_style(&self.style, self.override_font_scale, self.play_res_height); + self.runtime.configure_style( + &self.style, + &self.memory_fonts, + self.override_font_scale, + self.play_res_height, + ); } /// Re-applies the user font and colours. Cheap and idempotent: an unchanged @@ -1478,14 +1655,74 @@ impl LibassSubtitleRenderer { /// every frame. pub fn set_style(&mut self, style: &SubtitleStyleConfig) { let style = style.clone().normalized(); + let font_path_changed = self.style.font_file_path() != style.font_file_path(); + let loaded_font_changed = style.font_file_path().is_some_and(|path| { + let current = custom_font_file_identity(path); + self.runtime.observed_font_files.get(path).copied() != current + || current.is_none() && self.runtime.loaded_font_files.contains_key(path) + }); + if font_path_changed || loaded_font_changed { + if let Err(error) = self.rebuild_for_style(&style) { + crate::trace::diagnostic( + serde_json::json!({ + "event": "subtitle_custom_font", + "stage": "runtime_rebuild_failed", + "trackId": self.runtime.track_id, + "path": style.font_file_path(), + "reason": error.to_string(), + }) + .to_string(), + ); + } + return; + } if self.style == style { return; } - self.runtime - .configure_style(&style, self.override_font_scale, self.play_res_height); + self.runtime.configure_style( + &style, + &self.memory_fonts, + self.override_font_scale, + self.play_res_height, + ); self.style = style; } + fn rebuild_for_style(&mut self, style: &SubtitleStyleConfig) -> Result<()> { + let mut replacement = match &self.source { + LibassTrackSource::Script(script) => Self::from_ass_script_with_style_and_fonts( + script, + self.config, + style, + self.memory_fonts.clone(), + )?, + LibassTrackSource::Track { + track_id, + resources, + } => Self::from_ass_track_with_style_and_fonts( + *track_id, + resources, + self.config, + style, + self.memory_fonts.clone(), + )?, + }; + for (chunk, start, end) in &self.chunks { + replacement.process_chunk(chunk, *start, *end)?; + } + replacement.font_scale = self.font_scale; + replacement.override_font_scale = self.override_font_scale; + replacement.play_res_height = self.play_res_height; + replacement.runtime.configure_style( + style, + &replacement.memory_fonts, + replacement.override_font_scale, + replacement.play_res_height, + ); + *self = replacement; + Ok(()) + } + pub fn style(&self) -> &SubtitleStyleConfig { &self.style } @@ -3400,6 +3637,83 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text ); } + #[cfg(feature = "libass")] + #[test] + fn libass_memory_font_family_chain_preserves_selection_order() { + let fonts = Arc::from(vec![ + SubtitleFontAttachment::new( + "first.ttf", + None, + vec!["First Family".to_string()], + crate::NIPAPLAY_FALLBACK_FONT, + ), + SubtitleFontAttachment::new( + "second.ttf", + None, + vec!["Second Family".to_string()], + crate::NIPAPLAY_FALLBACK_FONT, + ), + ]); + let renderer = LibassSubtitleRenderer::from_ass_script_with_style_and_fonts( + SIMPLE_ASS_SCRIPT, + LibassRenderConfig::default(), + &SubtitleStyleConfig::default(), + fonts, + ) + .unwrap(); + + assert_eq!( + renderer.runtime.default_family().to_bytes(), + b"First Family\x1fSecond Family\x1fDroid Sans Fallback" + ); + } + + #[cfg(feature = "libass")] + #[test] + fn libass_tries_later_default_families_when_an_earlier_one_is_missing() { + let script = SIMPLE_ASS_SCRIPT.replace("Arial", "Erika Definitely Missing"); + let fonts = Arc::from(vec![ + SubtitleFontAttachment::new( + "missing.ttf", + None, + vec!["Erika Missing Memory Family".to_string()], + Arc::<[u8]>::from(b"not a font".as_slice()), + ), + SubtitleFontAttachment::new( + "fallback.ttf", + None, + vec![BUNDLED_ASS_FALLBACK_FONT_FAMILY.to_string()], + crate::NIPAPLAY_FALLBACK_FONT, + ), + ]); + let mut renderer = LibassSubtitleRenderer::from_ass_script_with_style_and_fonts( + script, + LibassRenderConfig::default(), + &SubtitleStyleConfig::default(), + fonts, + ) + .unwrap(); + + let SubtitleRenderOutput::Alpha(bitmaps) = renderer + .render(SubtitleRenderRequest::new( + Duration::from_millis(500), + 640, + 360, + )) + .unwrap() + else { + panic!("libass renderer should produce alpha bitmap output"); + }; + assert!(!bitmaps.parts.is_empty()); + let messages = renderer.runtime._log_context.seen.lock().unwrap(); + assert!( + messages + .iter() + .any(|message| message.contains("Using default font family")), + "the patched selector must advance past the missing first family" + ); + } + #[cfg(feature = "libass")] #[test] fn libass_loads_a_custom_font_file_for_the_configured_family() { @@ -3427,7 +3741,7 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text renderer .runtime .loaded_font_files - .contains(&style.font_file_path) + .contains_key(&style.font_file_path) ); let SubtitleRenderOutput::Alpha(bitmaps) = renderer .render(SubtitleRenderRequest::new( @@ -3472,6 +3786,122 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text assert!(!bitmaps.parts.is_empty()); } + #[cfg(feature = "libass")] + #[test] + fn libass_loads_a_custom_font_that_appears_after_initial_configuration() { + let path = std::env::temp_dir().join(format!( + "erika_subtitle_late_custom_font_{}.ttf", + std::process::id() + )); + let _ = std::fs::remove_file(&path); + let style = SubtitleStyleConfig { + font_file_path: path.to_string_lossy().to_string(), + ..SubtitleStyleConfig::default() + }; + let mut renderer = LibassSubtitleRenderer::from_ass_script_with_style( + SIMPLE_ASS_SCRIPT, + LibassRenderConfig::default(), + &style, + ) + .unwrap(); + assert!(renderer.runtime.loaded_font_files.is_empty()); + + std::fs::write(&path, crate::NIPAPLAY_FALLBACK_FONT).unwrap(); + renderer.set_style(&style); + + assert!( + renderer + .runtime + .loaded_font_files + .contains_key(&style.font_file_path) + ); + let _ = std::fs::remove_file(path); + } + + #[cfg(feature = "libass")] + #[test] + fn libass_rejects_an_oversized_custom_font_before_reading_it() { + let path = std::env::temp_dir().join(format!( + "erika_subtitle_oversized_custom_font_{}.ttf", + std::process::id() + )); + let file = std::fs::File::create(&path).unwrap(); + file.set_len(MAX_CUSTOM_SUBTITLE_FONT_BYTES + 1).unwrap(); + let style = SubtitleStyleConfig { + font_file_path: path.to_string_lossy().to_string(), + ..SubtitleStyleConfig::default() + }; + + let renderer = LibassSubtitleRenderer::from_ass_script_with_style( + SIMPLE_ASS_SCRIPT, + LibassRenderConfig::default(), + &style, + ) + .unwrap(); + + assert!(renderer.runtime.loaded_font_files.is_empty()); + let _ = std::fs::remove_file(path); + } + + #[cfg(feature = "libass")] + #[test] + fn libass_rejects_a_non_font_custom_file() { + let path = std::env::temp_dir().join(format!( + "erika_subtitle_invalid_custom_font_{}.ttf", + std::process::id() + )); + std::fs::write(&path, b"not a font").unwrap(); + let style = SubtitleStyleConfig { + font_file_path: path.to_string_lossy().to_string(), + ..SubtitleStyleConfig::default() + }; + + let renderer = LibassSubtitleRenderer::from_ass_script_with_style( + SIMPLE_ASS_SCRIPT, + LibassRenderConfig::default(), + &style, + ) + .unwrap(); + + assert!(renderer.runtime.loaded_font_files.is_empty()); + let _ = std::fs::remove_file(path); + } + + #[cfg(feature = "libass")] + #[test] + fn libass_reloads_a_changed_custom_font_at_the_same_path() { + let path = std::env::temp_dir().join(format!( + "erika_subtitle_replaced_custom_font_{}.ttf", + std::process::id() + )); + std::fs::write(&path, crate::NIPAPLAY_FALLBACK_FONT).unwrap(); + let style = SubtitleStyleConfig { + font_file_path: path.to_string_lossy().to_string(), + ..SubtitleStyleConfig::default() + }; + let mut renderer = LibassSubtitleRenderer::from_ass_script_with_style( + SIMPLE_ASS_SCRIPT, + LibassRenderConfig::default(), + &style, + ) + .unwrap(); + let initial = renderer.runtime.loaded_font_files[&style.font_file_path]; + + let mut replacement = crate::NIPAPLAY_FALLBACK_FONT.to_vec(); + replacement.push(0); + std::fs::write(&path, replacement).unwrap(); + renderer.set_style(&style); + + let replaced = renderer.runtime.loaded_font_files[&style.font_file_path]; + assert_ne!(initial, replaced); + assert_eq!(replaced.size, initial.size + 1); + assert_eq!( + renderer.runtime.custom_font_loads, 1, + "same-path replacement must use a fresh libass library instead of appending another face", + ); + let _ = std::fs::remove_file(path); + } + #[cfg(feature = "libass")] #[test] fn libass_falls_back_to_bundled_font_for_unavailable_family() { diff --git a/crates/erika_capi/include/erika.h b/crates/erika_capi/include/erika.h index 5377a1f..a7424ad 100644 --- a/crates/erika_capi/include/erika.h +++ b/crates/erika_capi/include/erika.h @@ -242,6 +242,30 @@ typedef struct ErikaOutputStatus { uint64_t extended_linear_frames; } ErikaOutputStatus; +typedef struct ErikaSubtitleMemoryFontStatus { + uintptr_t registered_count; + uintptr_t registered_bytes; + uintptr_t selected_count; + uint64_t generation; + uint64_t *selected_ids; +} ErikaSubtitleMemoryFontStatus; + +typedef struct ErikaSubtitleMemoryFontFace { + uint32_t index; + char *families_json; + char *post_script_name; + uint16_t weight; + bool italic; + bool monospaced; +} ErikaSubtitleMemoryFontFace; + +typedef struct ErikaSubtitleMemoryFontInfo { + uint64_t id; + uintptr_t byte_len; + ErikaSubtitleMemoryFontFace *faces; + uintptr_t face_count; +} ErikaSubtitleMemoryFontInfo; + typedef struct ErikaDanmakuConfig { bool enabled; /* NipaPlay/Flutter logical danmaku font size. Erika uses the NipaPlay @@ -487,9 +511,29 @@ ErikaStatus erika_presenter_set_subtitle_font( ErikaPresenterHandle *handle, const char *family, const char *file_path); +ErikaStatus erika_presenter_register_subtitle_memory_font( + ErikaPresenterHandle *handle, + const uint8_t *data, + uintptr_t data_len, + uint64_t *out_font_id); +ErikaStatus erika_presenter_select_subtitle_memory_fonts( + ErikaPresenterHandle *handle, + const uint64_t *font_ids, + uintptr_t font_count); +ErikaStatus erika_presenter_clear_subtitle_memory_fonts(ErikaPresenterHandle *handle); +ErikaStatus erika_presenter_get_subtitle_memory_font_status( + ErikaPresenterHandle *handle, + ErikaSubtitleMemoryFontStatus *out_status); ErikaStatus erika_presenter_set_subtitle_style( ErikaPresenterHandle *handle, ErikaSubtitleStyle style); +void erika_subtitle_memory_font_status_free( + ErikaSubtitleMemoryFontStatus *status); +ErikaStatus erika_presenter_get_subtitle_memory_font_info( + ErikaPresenterHandle *handle, + uint64_t font_id, + ErikaSubtitleMemoryFontInfo *out_info); +void erika_subtitle_memory_font_info_free(ErikaSubtitleMemoryFontInfo *info); ErikaStatus erika_presenter_set_output_headroom( ErikaPresenterHandle *handle, float headroom, diff --git a/crates/erika_capi/src/android_jni.rs b/crates/erika_capi/src/android_jni.rs index 01ca246..f82cf88 100644 --- a/crates/erika_capi/src/android_jni.rs +++ b/crates/erika_capi/src/android_jni.rs @@ -10,7 +10,7 @@ use std::thread::{self, ThreadId}; use erika::source::{AndroidOwnedFdRegistration, register_android_owned_fd}; use jni::JNIEnv; -use jni::objects::{JClass, JObject, JString}; +use jni::objects::{JByteArray, JClass, JObject, JString}; use jni::sys::{ JNI_ERR, JNI_VERSION_1_6, JavaVM, jboolean, jbyteArray, jdouble, jfloat, jint, jlong, jstring, }; @@ -495,6 +495,33 @@ pub extern "system" fn Java_dev_aimesoft_erika_1flutter_ErikaNative_nativeInvoke new_java_string(&mut env, response.to_string()) } +#[unsafe(no_mangle)] +pub extern "system" fn Java_dev_aimesoft_erika_1flutter_ErikaNative_nativeRegisterSubtitleMemoryFont( + mut env: JNIEnv<'_>, + _class: JClass<'_>, + handle: jlong, + data: JByteArray<'_>, +) -> jstring { + let response = catch_unwind(AssertUnwindSafe(|| { + let bytes = env + .convert_byte_array(&data) + .map_err(|error| format!("invalid subtitle memory font byte array: {error}"))?; + with_registered_presenter(handle, "registerSubtitleMemoryFont", |presenter| { + let mut font_id = 0; + call_status(unsafe { + erika_presenter_register_subtitle_memory_font( + presenter.handle, + bytes.as_ptr(), + bytes.len(), + &mut font_id, + ) + })?; + Ok(json!(font_id)) + }) + })); + response_to_jstring(&mut env, response) +} + #[unsafe(no_mangle)] pub extern "system" fn Java_dev_aimesoft_erika_1flutter_ErikaNative_nativeAttachSurface( mut env: JNIEnv<'_>, @@ -791,6 +818,20 @@ unsafe fn invoke_presenter( call_status(unsafe { erika_presenter_set_subtitle_style(handle, style) })?; Ok(Value::Null) } + "selectSubtitleMemoryFonts" => { + let ids = required_u64_array(args, "fontIds")?; + status_value(unsafe { + erika_presenter_select_subtitle_memory_fonts(handle, ids.as_ptr(), ids.len()) + }) + } + "clearSubtitleMemoryFonts" => { + status_value(unsafe { erika_presenter_clear_subtitle_memory_fonts(handle) }) + } + "getSubtitleMemoryFontStatus" => unsafe { subtitle_memory_font_status_json(handle) }, + "getSubtitleMemoryFontInfo" => { + let font_id = required_u64(args, "fontId")?; + unsafe { subtitle_memory_font_info_json(handle, font_id) } + } "setOutputHeadroom" => { let headroom = required_f64(args, "headroom")? as f32; let known = @@ -1031,6 +1072,67 @@ unsafe fn presenter_track_selection_json( })) } +unsafe fn subtitle_memory_font_status_json( + handle: *mut ErikaPresenterHandle, +) -> Result { + let mut status = ErikaSubtitleMemoryFontStatus::default(); + call_status(unsafe { erika_presenter_get_subtitle_memory_font_status(handle, &mut status) })?; + let selected_ids = if status.selected_count == 0 { + Vec::new() + } else { + unsafe { std::slice::from_raw_parts(status.selected_ids, status.selected_count) }.to_vec() + }; + let value = json!({ + "registeredCount": status.registered_count, + "registeredBytes": status.registered_bytes, + "selectedCount": status.selected_count, + "generation": status.generation, + "selectedIds": selected_ids, + }); + unsafe { erika_subtitle_memory_font_status_free(&mut status) }; + Ok(value) +} + +unsafe fn subtitle_memory_font_info_json( + handle: *mut ErikaPresenterHandle, + font_id: u64, +) -> Result { + let mut info = ErikaSubtitleMemoryFontInfo::default(); + call_status(unsafe { + erika_presenter_get_subtitle_memory_font_info(handle, font_id, &mut info) + })?; + let faces = if info.face_count == 0 { + &[] + } else { + unsafe { std::slice::from_raw_parts(info.faces, info.face_count) } + }; + let faces = faces + .iter() + .map(|face| { + let families_json = unsafe { borrowed_c_string(face.families_json) }; + let families = families_json + .as_deref() + .and_then(|value| serde_json::from_str::(value).ok()) + .unwrap_or_else(|| Value::Array(Vec::new())); + json!({ + "index": face.index, + "families": families, + "postScriptName": unsafe { borrowed_c_string(face.post_script_name) }, + "weight": face.weight, + "italic": face.italic, + "monospaced": face.monospaced, + }) + }) + .collect::>(); + let value = json!({ + "id": info.id, + "byteLen": info.byte_len, + "faces": faces, + }); + unsafe { erika_subtitle_memory_font_info_free(&mut info) }; + Ok(value) +} + unsafe fn presenter_danmaku_tracks_json( handle: *mut ErikaPresenterHandle, ) -> Result { @@ -1403,6 +1505,20 @@ fn required_u64(args: &Map, name: &str) -> Result { .ok_or_else(|| format!("{name} is required")) } +fn required_u64_array(args: &Map, name: &str) -> Result, String> { + args.get(name) + .and_then(Value::as_array) + .ok_or_else(|| format!("{name} is required"))? + .iter() + .map(|value| { + value + .as_u64() + .or_else(|| value.as_i64().and_then(|value| value.try_into().ok())) + .ok_or_else(|| format!("{name} must contain unsigned integers")) + }) + .collect() +} + fn required_f64(args: &Map, name: &str) -> Result { args.get(name) .and_then(Value::as_f64) @@ -1738,4 +1854,15 @@ mod tests { assert_eq!(value["audioRecoveryCount"], 2); assert_eq!(value["videoFrameBackpressureDrops"], 5); } + + #[test] + fn parses_subtitle_memory_font_ids() { + let args = json!({"fontIds": [1, 2, 3]}); + assert_eq!( + required_u64_array(args.as_object().unwrap(), "fontIds").unwrap(), + vec![1, 2, 3] + ); + let invalid = json!({"fontIds": [1, -2]}); + assert!(required_u64_array(invalid.as_object().unwrap(), "fontIds").is_err()); + } } diff --git a/crates/erika_capi/src/lib.rs b/crates/erika_capi/src/lib.rs index 275e287..858eaf8 100644 --- a/crates/erika_capi/src/lib.rs +++ b/crates/erika_capi/src/lib.rs @@ -46,7 +46,10 @@ use erika::danmaku::{ target_os = "android", target_env = "ohos" ))] -use erika::presenter::{PresenterConfig, PresenterRuntime, PresenterRuntimeSnapshot}; +use erika::presenter::{ + PresenterConfig, PresenterRuntime, PresenterRuntimeSnapshot, SubtitleMemoryFontFace, + SubtitleMemoryFontInfo, SubtitleMemoryFontStatus, +}; #[cfg(any( target_os = "macos", target_os = "ios", @@ -300,6 +303,36 @@ impl Default for ErikaTrackInfo { } } +#[repr(C)] +#[derive(Debug, Default)] +pub struct ErikaSubtitleMemoryFontStatus { + pub registered_count: usize, + pub registered_bytes: usize, + pub selected_count: usize, + pub generation: u64, + pub selected_ids: *mut u64, +} + +#[repr(C)] +#[derive(Debug)] +pub struct ErikaSubtitleMemoryFontFace { + pub index: u32, + pub families_json: *mut c_char, + pub post_script_name: *mut c_char, + pub weight: u16, + pub italic: bool, + pub monospaced: bool, +} + +#[repr(C)] +#[derive(Debug, Default)] +pub struct ErikaSubtitleMemoryFontInfo { + pub id: u64, + pub byte_len: usize, + pub faces: *mut ErikaSubtitleMemoryFontFace, + pub face_count: usize, +} + #[repr(C)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ErikaWgpuSurfaceKind { @@ -1469,6 +1502,7 @@ fn danmaku_config_from_c( shadow_style: DanmakuShadowStyle::from_code(config.shadow_style), custom_font_family: base.custom_font_family.clone(), custom_font_file_path: base.custom_font_file_path.clone(), + custom_font_face_index: base.custom_font_face_index, } } @@ -1816,6 +1850,167 @@ pub unsafe extern "C" fn erika_presenter_set_subtitle_style( }) } +#[cfg(any( + target_os = "macos", + target_os = "ios", + target_os = "windows", + target_os = "android", + target_env = "ohos" +))] +#[unsafe(no_mangle)] +pub unsafe extern "C" fn erika_presenter_register_subtitle_memory_font( + handle: *mut ErikaPresenterHandle, + data: *const u8, + data_len: usize, + out_font_id: *mut u64, +) -> ErikaStatus { + if out_font_id.is_null() || (data_len > 0 && data.is_null()) { + return ErikaStatus::NullPointer; + } + with_presenter_mut(handle, |handle| { + let bytes = if data_len == 0 { + &[] + } else { + unsafe { std::slice::from_raw_parts(data, data_len) } + }; + match handle.presenter.register_subtitle_font_bytes(bytes) { + Ok(id) => { + unsafe { *out_font_id = id }; + ErikaStatus::Ok + } + Err(error) => player_error(error.to_string()), + } + }) +} + +#[cfg(any( + target_os = "macos", + target_os = "ios", + target_os = "windows", + target_os = "android", + target_env = "ohos" +))] +#[unsafe(no_mangle)] +pub unsafe extern "C" fn erika_presenter_select_subtitle_memory_fonts( + handle: *mut ErikaPresenterHandle, + font_ids: *const u64, + font_id_count: usize, +) -> ErikaStatus { + if font_id_count > 0 && font_ids.is_null() { + return ErikaStatus::NullPointer; + } + with_presenter_mut(handle, |handle| { + let ids = if font_id_count == 0 { + &[] + } else { + unsafe { std::slice::from_raw_parts(font_ids, font_id_count) } + }; + status_from_player_result(handle.presenter.select_subtitle_memory_fonts(ids)) + }) +} + +#[cfg(any( + target_os = "macos", + target_os = "ios", + target_os = "windows", + target_os = "android", + target_env = "ohos" +))] +#[unsafe(no_mangle)] +pub unsafe extern "C" fn erika_presenter_clear_subtitle_memory_fonts( + handle: *mut ErikaPresenterHandle, +) -> ErikaStatus { + with_presenter_mut(handle, |handle| { + handle.presenter.clear_subtitle_memory_fonts(); + ErikaStatus::Ok + }) +} + +#[cfg(any( + target_os = "macos", + target_os = "ios", + target_os = "windows", + target_os = "android", + target_env = "ohos" +))] +#[unsafe(no_mangle)] +pub unsafe extern "C" fn erika_presenter_get_subtitle_memory_font_status( + handle: *mut ErikaPresenterHandle, + out_status: *mut ErikaSubtitleMemoryFontStatus, +) -> ErikaStatus { + if out_status.is_null() { + return ErikaStatus::NullPointer; + } + with_presenter_mut(handle, |handle| { + unsafe { + *out_status = + subtitle_memory_font_status_to_c(handle.presenter.subtitle_memory_font_status()) + }; + ErikaStatus::Ok + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn erika_subtitle_memory_font_status_free( + status: *mut ErikaSubtitleMemoryFontStatus, +) { + if status.is_null() { + return; + } + let status = unsafe { &mut *status }; + if !status.selected_ids.is_null() { + let slice = std::ptr::slice_from_raw_parts_mut(status.selected_ids, status.selected_count); + unsafe { drop(Box::from_raw(slice)) }; + } + *status = ErikaSubtitleMemoryFontStatus::default(); +} + +#[cfg(any( + target_os = "macos", + target_os = "ios", + target_os = "windows", + target_os = "android", + target_env = "ohos" +))] +#[unsafe(no_mangle)] +pub unsafe extern "C" fn erika_presenter_get_subtitle_memory_font_info( + handle: *mut ErikaPresenterHandle, + font_id: u64, + out_info: *mut ErikaSubtitleMemoryFontInfo, +) -> ErikaStatus { + if out_info.is_null() { + return ErikaStatus::NullPointer; + } + with_presenter_mut(handle, |handle| { + let Some(info) = handle.presenter.subtitle_memory_font_info(font_id) else { + return player_error(format!( + "subtitle memory font ID {font_id} is not registered" + )); + }; + unsafe { *out_info = subtitle_memory_font_info_to_c(info) }; + ErikaStatus::Ok + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn erika_subtitle_memory_font_info_free( + info: *mut ErikaSubtitleMemoryFontInfo, +) { + if info.is_null() { + return; + } + let info = unsafe { &mut *info }; + if !info.faces.is_null() { + let slice = std::ptr::slice_from_raw_parts_mut(info.faces, info.face_count); + let mut faces = unsafe { Box::from_raw(slice) }; + for face in &mut faces { + free_c_string(&mut face.families_json); + free_c_string(&mut face.post_script_name); + } + } + *info = ErikaSubtitleMemoryFontInfo::default(); +} + #[cfg(any( target_os = "macos", target_os = "ios", @@ -3530,6 +3725,73 @@ fn danmaku_track_info_to_c(track: &DanmakuTrackInfo) -> ErikaDanmakuTrackInfo { } } +#[cfg(any( + target_os = "macos", + target_os = "ios", + target_os = "windows", + target_os = "android", + target_env = "ohos" +))] +fn subtitle_memory_font_status_to_c( + status: SubtitleMemoryFontStatus, +) -> ErikaSubtitleMemoryFontStatus { + let mut selected_ids = status.selected_ids.into_boxed_slice(); + let selected_ids_ptr = selected_ids.as_mut_ptr(); + std::mem::forget(selected_ids); + ErikaSubtitleMemoryFontStatus { + registered_count: status.registered_count, + registered_bytes: status.registered_bytes, + selected_count: status.selected_count, + generation: status.generation, + selected_ids: selected_ids_ptr, + } +} + +#[cfg(any( + target_os = "macos", + target_os = "ios", + target_os = "windows", + target_os = "android", + target_env = "ohos" +))] +fn subtitle_memory_font_info_to_c(info: SubtitleMemoryFontInfo) -> ErikaSubtitleMemoryFontInfo { + let mut faces = info + .faces + .into_iter() + .map(subtitle_memory_font_face_to_c) + .collect::>() + .into_boxed_slice(); + let face_count = faces.len(); + let faces_ptr = faces.as_mut_ptr(); + std::mem::forget(faces); + ErikaSubtitleMemoryFontInfo { + id: info.id, + byte_len: info.byte_len, + faces: faces_ptr, + face_count, + } +} + +#[cfg(any( + target_os = "macos", + target_os = "ios", + target_os = "windows", + target_os = "android", + target_env = "ohos" +))] +fn subtitle_memory_font_face_to_c(face: SubtitleMemoryFontFace) -> ErikaSubtitleMemoryFontFace { + ErikaSubtitleMemoryFontFace { + index: face.index, + families_json: option_string_to_c(Some( + &serde_json::to_string(&face.families).unwrap_or_else(|_| "[]".to_string()), + )), + post_script_name: option_string_to_c(Some(&face.post_script_name)), + weight: face.weight, + italic: face.italic, + monospaced: face.monospaced, + } +} + fn danmaku_timeline_from_uri(uri: &str) -> std::result::Result { let bytes = erika::source::read_uri_to_end(uri) .map_err(|error| format!("failed to read danmaku source {uri}: {error}"))?; @@ -4588,6 +4850,107 @@ mod tests { assert_eq!(style.override_mask, erika::subtitle::SUBTITLE_OVERRIDE_ALL); } + #[cfg(any( + target_os = "macos", + target_os = "ios", + target_os = "windows", + target_os = "android", + target_env = "ohos" + ))] + #[test] + fn c_presenter_memory_font_round_trip_exposes_status_and_info() { + let handle = erika_presenter_create(); + assert!(!handle.is_null()); + let font = include_bytes!("../../erika/assets/subfont.ttf"); + let mut font_id = 0; + assert_eq!( + unsafe { + erika_presenter_register_subtitle_memory_font( + handle, + font.as_ptr(), + font.len(), + &mut font_id, + ) + }, + ErikaStatus::Ok + ); + assert!(font_id > 0); + assert_eq!( + unsafe { erika_presenter_select_subtitle_memory_fonts(handle, &font_id, 1) }, + ErikaStatus::Ok + ); + + let mut status = ErikaSubtitleMemoryFontStatus::default(); + assert_eq!( + unsafe { erika_presenter_get_subtitle_memory_font_status(handle, &mut status) }, + ErikaStatus::Ok + ); + assert_eq!(status.registered_count, 1); + assert_eq!(status.registered_bytes, font.len()); + assert_eq!(status.selected_count, 1); + assert_eq!(unsafe { *status.selected_ids }, font_id); + unsafe { erika_subtitle_memory_font_status_free(&mut status) }; + assert!(status.selected_ids.is_null()); + + let mut info = ErikaSubtitleMemoryFontInfo::default(); + assert_eq!( + unsafe { erika_presenter_get_subtitle_memory_font_info(handle, font_id, &mut info) }, + ErikaStatus::Ok + ); + assert_eq!(info.id, font_id); + assert_eq!(info.byte_len, font.len()); + assert!(info.face_count > 0); + let face = unsafe { &*info.faces }; + assert!(!face.families_json.is_null()); + assert!(!face.post_script_name.is_null()); + unsafe { erika_subtitle_memory_font_info_free(&mut info) }; + assert!(info.faces.is_null()); + + assert_eq!( + unsafe { erika_presenter_clear_subtitle_memory_fonts(handle) }, + ErikaStatus::Ok + ); + unsafe { erika_presenter_destroy(handle) }; + } + + #[cfg(any( + target_os = "macos", + target_os = "ios", + target_os = "windows", + target_os = "android", + target_env = "ohos" + ))] + #[test] + fn c_presenter_memory_font_rejects_invalid_pointers_and_ids() { + assert_eq!( + unsafe { + erika_presenter_register_subtitle_memory_font( + std::ptr::null_mut(), + std::ptr::null(), + 0, + std::ptr::null_mut(), + ) + }, + ErikaStatus::NullPointer + ); + let handle = erika_presenter_create(); + assert!(!handle.is_null()); + assert_eq!( + unsafe { erika_presenter_select_subtitle_memory_fonts(handle, std::ptr::null(), 1) }, + ErikaStatus::NullPointer + ); + assert_eq!( + unsafe { erika_presenter_select_subtitle_memory_fonts(handle, &99, 1) }, + ErikaStatus::PlayerError + ); + let mut info = ErikaSubtitleMemoryFontInfo::default(); + assert_eq!( + unsafe { erika_presenter_get_subtitle_memory_font_info(handle, 99, &mut info) }, + ErikaStatus::PlayerError + ); + unsafe { erika_presenter_destroy(handle) }; + } + #[test] fn c_surface_output_capabilities_preserve_auto_headroom() { let capabilities: SurfaceOutputCapabilities = ErikaSurfaceOutputCapabilities { diff --git a/crates/erika_capi/src/presenter_json.rs b/crates/erika_capi/src/presenter_json.rs index 3b19472..01568fb 100644 --- a/crates/erika_capi/src/presenter_json.rs +++ b/crates/erika_capi/src/presenter_json.rs @@ -119,6 +119,16 @@ unsafe fn invoke( "setSubtitleScale" => status_value(unsafe { erika_presenter_set_subtitle_scale(handle, required_f64(args, "scale")?) }), + "selectSubtitleMemoryFonts" => { + let ids = required_u64_array(args, "fontIds")?; + status_value(unsafe { + erika_presenter_select_subtitle_memory_fonts(handle, ids.as_ptr(), ids.len()) + }) + } + "clearSubtitleMemoryFonts" => { + status_value(unsafe { erika_presenter_clear_subtitle_memory_fonts(handle) }) + } + "getSubtitleMemoryFontStatus" => unsafe { subtitle_memory_font_status_json(handle) }, "getUpscalerStatus" => { let mut status = ErikaUpscalerStatus::default(); call_status(unsafe { erika_presenter_get_upscaler_status(handle, &mut status) })?; @@ -347,6 +357,27 @@ unsafe fn danmaku_tracks_json(handle: *mut ErikaPresenterHandle) -> Result Result { + let mut status = ErikaSubtitleMemoryFontStatus::default(); + call_status(unsafe { erika_presenter_get_subtitle_memory_font_status(handle, &mut status) })?; + let selected_ids = if status.selected_count == 0 || status.selected_ids.is_null() { + Vec::new() + } else { + unsafe { std::slice::from_raw_parts(status.selected_ids, status.selected_count) }.to_vec() + }; + let value = json!({ + "registeredCount": status.registered_count, + "registeredBytes": status.registered_bytes, + "selectedCount": status.selected_count, + "generation": status.generation, + "selectedIds": selected_ids, + }); + unsafe { erika_subtitle_memory_font_status_free(&mut status) }; + Ok(value) +} + struct HttpHeaders { _strings: Vec, headers: Vec, @@ -529,6 +560,19 @@ fn required_u64(args: &Map, name: &str) -> Result { .ok_or_else(|| format!("{name} must be non-negative")) } +fn required_u64_array(args: &Map, name: &str) -> Result, String> { + args.get(name) + .and_then(Value::as_array) + .ok_or_else(|| format!("{name} must be an array"))? + .iter() + .map(|value| { + value + .as_u64() + .ok_or_else(|| format!("{name} must contain only unsigned integers")) + }) + .collect() +} + fn required_f64(args: &Map, name: &str) -> Result { args.get(name) .and_then(Value::as_f64) @@ -613,4 +657,29 @@ mod tests { unsafe { erika_presenter_destroy(handle) }; } + + #[test] + fn json_bridge_exposes_memory_font_selection_and_status() { + let handle = erika_presenter_create(); + assert!(!handle.is_null()); + + for (method, arguments) in [ + (c"selectSubtitleMemoryFonts", c"{\"fontIds\":[]}"), + (c"getSubtitleMemoryFontStatus", c"{}"), + (c"clearSubtitleMemoryFonts", c"{}"), + ] { + let response = + unsafe { erika_presenter_invoke_json(handle, method.as_ptr(), arguments.as_ptr()) }; + assert!(!response.is_null()); + let value: Value = unsafe { CStr::from_ptr(response) } + .to_str() + .ok() + .and_then(|response| serde_json::from_str(response).ok()) + .expect("memory font JSON method returns valid JSON"); + unsafe { erika_string_free(response) }; + assert_eq!(value.get("ok"), Some(&Value::Bool(true))); + } + + unsafe { erika_presenter_destroy(handle) }; + } } diff --git a/packages/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaFlutterPlugin.kt b/packages/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaFlutterPlugin.kt index f0cb3ee..a49389f 100644 --- a/packages/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaFlutterPlugin.kt +++ b/packages/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaFlutterPlugin.kt @@ -179,6 +179,7 @@ class ErikaFlutterPlugin : "detachOverlay" -> detachOverlay(arguments(call), result) "setOverlayFrame" -> setOverlayFrame(arguments(call), result) "screenshot" -> captureFrame(arguments(call), result) + "registerSubtitleMemoryFont" -> registerSubtitleMemoryFont(arguments(call), result) in NATIVE_METHODS -> invokePlayer(call.method, arguments(call), result) else -> result.notImplemented() } @@ -486,6 +487,17 @@ class ErikaFlutterPlugin : invokePreparedPlayer(host, method, prepared, result) } + private fun registerSubtitleMemoryFont( + arguments: Map, + result: MethodChannel.Result, + ) { + val host = player(arguments) + val data = arguments["data"] as? ByteArray + ?: throw IllegalArgumentException("Missing byte array argument 'data'") + complete(result, NativeJson.decodeResponse(ErikaNative.nativeRegisterSubtitleMemoryFont(host.handle, data))) + host.requestRender() + } + private fun invokePreparedPlayer( host: AndroidPlayerHost, method: String, @@ -1693,6 +1705,8 @@ class ErikaFlutterPlugin : "setUpscaler", "setSubtitleScale", "setSubtitleStyle", + "selectSubtitleMemoryFonts", + "clearSubtitleMemoryFonts", "addExternalSubtitle", "removeSubtitleTrack", "loadDanmakuFile", @@ -1723,6 +1737,9 @@ class ErikaFlutterPlugin : "setUpscaler", "setSubtitleScale", "setSubtitleStyle", + "selectSubtitleMemoryFonts", + "clearSubtitleMemoryFonts", + "getSubtitleMemoryFontStatus", "getUpscalerStatus", "getOutputStatus", "getPresenterStats", diff --git a/packages/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaNative.kt b/packages/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaNative.kt index f449c7b..41daba6 100644 --- a/packages/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaNative.kt +++ b/packages/erika_flutter/android/src/main/kotlin/dev/aimesoft/erika_flutter/ErikaNative.kt @@ -24,6 +24,9 @@ internal object ErikaNative { ownedFd: Int, ): String + @JvmStatic + external fun nativeRegisterSubtitleMemoryFont(handle: Long, data: ByteArray): String + @JvmStatic external fun nativeAttachSurface( handle: Long, diff --git a/packages/erika_flutter/ios/Classes/ErikaFlutterPlugin.swift b/packages/erika_flutter/ios/Classes/ErikaFlutterPlugin.swift index 228cc0e..8e635f4 100644 --- a/packages/erika_flutter/ios/Classes/ErikaFlutterPlugin.swift +++ b/packages/erika_flutter/ios/Classes/ErikaFlutterPlugin.swift @@ -280,6 +280,14 @@ private struct ErikaUpscalerStatusC { var lastGpuMicros: UInt64 = 0 } +private struct ErikaSubtitleMemoryFontStatusC { + var registeredCount: UInt = 0 + var registeredBytes: UInt = 0 + var selectedCount: UInt = 0 + var generation: UInt64 = 0 + var selectedIds: UnsafeMutablePointer? +} + // Keep field order and types aligned with `ErikaOutputStatus` in erika.h. private struct ErikaOutputStatusC { var requestedMode: Int32 = 0 @@ -392,6 +400,10 @@ private final class ErikaNativeLibrary { UnsafeMutableRawPointer?, UnsafeRawPointer? ) -> Int32 + typealias RegisterSubtitleMemoryFontFn = @convention(c) (UnsafeMutableRawPointer?, UnsafePointer?, UInt, UnsafeMutablePointer?) -> Int32 + typealias SelectSubtitleMemoryFontsFn = @convention(c) (UnsafeMutableRawPointer?, UnsafePointer?, UInt) -> Int32 + typealias GetSubtitleMemoryFontStatusFn = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) -> Int32 + typealias FreeSubtitleMemoryFontStatusFn = @convention(c) (UnsafeMutableRawPointer?) -> Void typealias GetUpscalerStatusFn = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) -> Int32 typealias GetOutputStatusFn = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) -> Int32 typealias SelectTrackFn = @convention(c) (UnsafeMutableRawPointer?, Int64) -> Int32 @@ -459,6 +471,11 @@ private final class ErikaNativeLibrary { let setSubtitleScale: SetSubtitleScaleFn? let setSubtitleFont: SetSubtitleFontFn? let setSubtitleStyle: SetSubtitleStyleFn? + let registerSubtitleMemoryFont: RegisterSubtitleMemoryFontFn? + let selectSubtitleMemoryFonts: SelectSubtitleMemoryFontsFn? + let clearSubtitleMemoryFonts: CommandFn? + let getSubtitleMemoryFontStatus: GetSubtitleMemoryFontStatusFn? + let freeSubtitleMemoryFontStatus: FreeSubtitleMemoryFontStatusFn? let getUpscalerStatus: GetUpscalerStatusFn? let getOutputStatus: GetOutputStatusFn? let selectAudioTrack: SelectTrackFn @@ -522,6 +539,11 @@ private final class ErikaNativeLibrary { setSubtitleScale = Self.loadOptional("erika_presenter_set_subtitle_scale", from: libraryHandle, as: SetSubtitleScaleFn.self) setSubtitleFont = Self.loadOptional("erika_presenter_set_subtitle_font", from: libraryHandle, as: SetSubtitleFontFn.self) setSubtitleStyle = Self.loadOptional("erika_presenter_set_subtitle_style", from: libraryHandle, as: SetSubtitleStyleFn.self) + registerSubtitleMemoryFont = Self.loadOptional("erika_presenter_register_subtitle_memory_font", from: libraryHandle, as: RegisterSubtitleMemoryFontFn.self) + selectSubtitleMemoryFonts = Self.loadOptional("erika_presenter_select_subtitle_memory_fonts", from: libraryHandle, as: SelectSubtitleMemoryFontsFn.self) + clearSubtitleMemoryFonts = Self.loadOptional("erika_presenter_clear_subtitle_memory_fonts", from: libraryHandle, as: CommandFn.self) + getSubtitleMemoryFontStatus = Self.loadOptional("erika_presenter_get_subtitle_memory_font_status", from: libraryHandle, as: GetSubtitleMemoryFontStatusFn.self) + freeSubtitleMemoryFontStatus = Self.loadOptional("erika_subtitle_memory_font_status_free", from: libraryHandle, as: FreeSubtitleMemoryFontStatusFn.self) getUpscalerStatus = Self.loadOptional("erika_presenter_get_upscaler_status", from: libraryHandle, as: GetUpscalerStatusFn.self) getOutputStatus = Self.loadOptional("erika_presenter_get_output_status", from: libraryHandle, as: GetOutputStatusFn.self) selectAudioTrack = try Self.load("erika_presenter_select_audio_track", from: libraryHandle, as: SelectTrackFn.self) @@ -814,6 +836,58 @@ private final class ErikaPlayerHost { return status.toFlutterMap() } + func registerSubtitleMemoryFont(_ data: Data) throws -> UInt64 { + guard let register = library.registerSubtitleMemoryFont else { + throw ErikaPluginError.symbolMissing("erika_presenter_register_subtitle_memory_font") + } + var fontId: UInt64 = 0 + let status = data.withUnsafeBytes { bytes in + register(handle, bytes.bindMemory(to: UInt8.self).baseAddress, UInt(data.count), &fontId) + } + try check(status, operation: "register_subtitle_memory_font") + return fontId + } + + func selectSubtitleMemoryFonts(_ fontIds: [UInt64]) throws { + guard let select = library.selectSubtitleMemoryFonts else { + throw ErikaPluginError.symbolMissing("erika_presenter_select_subtitle_memory_fonts") + } + try fontIds.withUnsafeBufferPointer { buffer in + try check(select(handle, buffer.baseAddress, UInt(buffer.count)), operation: "select_subtitle_memory_fonts") + } + } + + func clearSubtitleMemoryFonts() throws { + guard let clear = library.clearSubtitleMemoryFonts else { + throw ErikaPluginError.symbolMissing("erika_presenter_clear_subtitle_memory_fonts") + } + try check(clear(handle), operation: "clear_subtitle_memory_fonts") + } + + func subtitleMemoryFontStatus() throws -> [String: Any] { + guard let getStatus = library.getSubtitleMemoryFontStatus else { + throw ErikaPluginError.symbolMissing("erika_presenter_get_subtitle_memory_font_status") + } + var status = ErikaSubtitleMemoryFontStatusC() + defer { + if let freeStatus = library.freeSubtitleMemoryFontStatus { + withUnsafeMutablePointer(to: &status) { freeStatus(UnsafeMutableRawPointer($0)) } + } + } + try withUnsafeMutablePointer(to: &status) { pointer in + try check(getStatus(handle, UnsafeMutableRawPointer(pointer)), operation: "get_subtitle_memory_font_status") + } + return [ + "registeredCount": Int(status.registeredCount), + "registeredBytes": Int(status.registeredBytes), + "selectedCount": Int(status.selectedCount), + "generation": Int64(clamping: status.generation), + "selectedIds": status.selectedIds.map { pointer in + (0.. [String: Any] { guard let getStatus = library.getOutputStatus else { throw ErikaPluginError.symbolMissing("erika_presenter_get_output_status") @@ -1620,6 +1694,24 @@ public final class ErikaFlutterPlugin: NSObject, FlutterPlugin, FlutterStreamHan } try playerHost(from: args).setSubtitleScale(scale) result(nil) + case "registerSubtitleMemoryFont": + let args = try dictionaryArgs(call.arguments) + guard let data = args["data"] as? FlutterStandardTypedData else { + throw ErikaPluginError.invalidArguments("data is required.") + } + result(Int64(clamping: try playerHost(from: args).registerSubtitleMemoryFont(data.data))) + case "selectSubtitleMemoryFonts": + let args = try dictionaryArgs(call.arguments) + let ids = (args["fontIds"] as? [NSNumber] ?? []).map { $0.uint64Value } + try playerHost(from: args).selectSubtitleMemoryFonts(ids) + result(nil) + case "clearSubtitleMemoryFonts": + let args = try dictionaryArgs(call.arguments) + try playerHost(from: args).clearSubtitleMemoryFonts() + result(nil) + case "getSubtitleMemoryFontStatus": + let args = try dictionaryArgs(call.arguments) + result(try playerHost(from: args).subtitleMemoryFontStatus()) case "setSubtitleStyle": let args = try dictionaryArgs(call.arguments) let host = try playerHost(from: args) diff --git a/packages/erika_flutter/lib/src/erika_player.dart b/packages/erika_flutter/lib/src/erika_player.dart index 8a39daf..8ce9d66 100644 --- a/packages/erika_flutter/lib/src/erika_player.dart +++ b/packages/erika_flutter/lib/src/erika_player.dart @@ -189,6 +189,36 @@ class ErikaOutputStatus { } } +class ErikaSubtitleMemoryFontStatus { + const ErikaSubtitleMemoryFontStatus({ + required this.registeredCount, + required this.registeredBytes, + required this.selectedCount, + required this.generation, + required this.selectedIds, + }); + + final int registeredCount; + final int registeredBytes; + final int selectedCount; + final int generation; + final List selectedIds; + + factory ErikaSubtitleMemoryFontStatus.fromMap(Map map) { + return ErikaSubtitleMemoryFontStatus( + registeredCount: (map['registeredCount'] as num?)?.toInt() ?? 0, + registeredBytes: (map['registeredBytes'] as num?)?.toInt() ?? 0, + selectedCount: (map['selectedCount'] as num?)?.toInt() ?? 0, + generation: (map['generation'] as num?)?.toInt() ?? 0, + selectedIds: List.unmodifiable( + (map['selectedIds'] as List? ?? const []) + .whereType() + .map((value) => value.toInt()), + ), + ); + } +} + enum ErikaUpscalerMode { off(0), artCnnC4F16(1), @@ -828,6 +858,51 @@ class ErikaPlayer { }); } + Future registerSubtitleMemoryFont(Uint8List data) async { + if (data.isEmpty) { + throw ArgumentError.value(data, 'data', 'must not be empty'); + } + final playerId = await ensureCreated(); + final fontId = await _channel.invokeMethod( + 'registerSubtitleMemoryFont', + {'playerId': playerId, 'data': data}, + ); + if (fontId == null) { + throw StateError( + 'Erika subtitle memory font registration returned null.'); + } + return fontId; + } + + Future selectSubtitleMemoryFonts(Iterable fontIds) async { + final ids = List.unmodifiable(fontIds); + if (ids.any((id) => id <= 0) || ids.toSet().length != ids.length) { + throw ArgumentError.value( + fontIds, 'fontIds', 'must contain unique positive IDs'); + } + final playerId = await ensureCreated(); + await _invoke('selectSubtitleMemoryFonts', { + 'playerId': playerId, + 'fontIds': ids, + }); + } + + Future clearSubtitleMemoryFonts() async { + await _invokeForPlayer('clearSubtitleMemoryFonts'); + } + + Future getSubtitleMemoryFontStatus() async { + final playerId = await ensureCreated(); + final status = await _channel.invokeMethod>( + 'getSubtitleMemoryFontStatus', + {'playerId': playerId}, + ); + if (status == null) { + throw StateError('Erika subtitle memory font status returned null.'); + } + return ErikaSubtitleMemoryFontStatus.fromMap(status); + } + /// Sets the subtitle style. /// /// Values act as fallbacks: an ASS script keeps its own styling, and these diff --git a/packages/erika_flutter/macos/Classes/ErikaFlutterPlugin.swift b/packages/erika_flutter/macos/Classes/ErikaFlutterPlugin.swift index abd44cf..30bda52 100644 --- a/packages/erika_flutter/macos/Classes/ErikaFlutterPlugin.swift +++ b/packages/erika_flutter/macos/Classes/ErikaFlutterPlugin.swift @@ -256,6 +256,14 @@ private struct ErikaUpscalerStatusC { var lastGpuMicros: UInt64 = 0 } +private struct ErikaSubtitleMemoryFontStatusC { + var registeredCount: UInt = 0 + var registeredBytes: UInt = 0 + var selectedCount: UInt = 0 + var generation: UInt64 = 0 + var selectedIds: UnsafeMutablePointer? +} + // Keep field order and types aligned with `ErikaOutputStatus` in erika.h. private struct ErikaOutputStatusC { var requestedMode: Int32 = 0 @@ -372,6 +380,10 @@ private final class ErikaNativeLibrary { UnsafeMutableRawPointer?, UnsafeRawPointer? ) -> Int32 + typealias RegisterSubtitleMemoryFontFn = @convention(c) (UnsafeMutableRawPointer?, UnsafePointer?, UInt, UnsafeMutablePointer?) -> Int32 + typealias SelectSubtitleMemoryFontsFn = @convention(c) (UnsafeMutableRawPointer?, UnsafePointer?, UInt) -> Int32 + typealias GetSubtitleMemoryFontStatusFn = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) -> Int32 + typealias FreeSubtitleMemoryFontStatusFn = @convention(c) (UnsafeMutableRawPointer?) -> Void typealias GetUpscalerStatusFn = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) -> Int32 typealias GetOutputStatusFn = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) -> Int32 typealias SelectTrackFn = @convention(c) (UnsafeMutableRawPointer?, Int64) -> Int32 @@ -439,6 +451,11 @@ private final class ErikaNativeLibrary { let setSubtitleScale: SetSubtitleScaleFn? let setSubtitleFont: SetSubtitleFontFn? let setSubtitleStyle: SetSubtitleStyleFn? + let registerSubtitleMemoryFont: RegisterSubtitleMemoryFontFn? + let selectSubtitleMemoryFonts: SelectSubtitleMemoryFontsFn? + let clearSubtitleMemoryFonts: CommandFn? + let getSubtitleMemoryFontStatus: GetSubtitleMemoryFontStatusFn? + let freeSubtitleMemoryFontStatus: FreeSubtitleMemoryFontStatusFn? let getUpscalerStatus: GetUpscalerStatusFn? let getOutputStatus: GetOutputStatusFn? let selectAudioTrack: SelectTrackFn @@ -496,6 +513,11 @@ private final class ErikaNativeLibrary { setSubtitleScale = Self.loadOptional("erika_presenter_set_subtitle_scale", from: libraryHandle, as: SetSubtitleScaleFn.self) setSubtitleFont = Self.loadOptional("erika_presenter_set_subtitle_font", from: libraryHandle, as: SetSubtitleFontFn.self) setSubtitleStyle = Self.loadOptional("erika_presenter_set_subtitle_style", from: libraryHandle, as: SetSubtitleStyleFn.self) + registerSubtitleMemoryFont = Self.loadOptional("erika_presenter_register_subtitle_memory_font", from: libraryHandle, as: RegisterSubtitleMemoryFontFn.self) + selectSubtitleMemoryFonts = Self.loadOptional("erika_presenter_select_subtitle_memory_fonts", from: libraryHandle, as: SelectSubtitleMemoryFontsFn.self) + clearSubtitleMemoryFonts = Self.loadOptional("erika_presenter_clear_subtitle_memory_fonts", from: libraryHandle, as: CommandFn.self) + getSubtitleMemoryFontStatus = Self.loadOptional("erika_presenter_get_subtitle_memory_font_status", from: libraryHandle, as: GetSubtitleMemoryFontStatusFn.self) + freeSubtitleMemoryFontStatus = Self.loadOptional("erika_subtitle_memory_font_status_free", from: libraryHandle, as: FreeSubtitleMemoryFontStatusFn.self) getUpscalerStatus = Self.loadOptional("erika_presenter_get_upscaler_status", from: libraryHandle, as: GetUpscalerStatusFn.self) getOutputStatus = Self.loadOptional("erika_presenter_get_output_status", from: libraryHandle, as: GetOutputStatusFn.self) selectAudioTrack = try Self.load("erika_presenter_select_audio_track", from: libraryHandle, as: SelectTrackFn.self) @@ -842,6 +864,34 @@ private final class ErikaPlayerHost { return status.toFlutterMap() } + func registerSubtitleMemoryFont(_ data: Data) throws -> UInt64 { + guard let register = library.registerSubtitleMemoryFont else { throw ErikaPluginError.symbolMissing("erika_presenter_register_subtitle_memory_font") } + var fontId: UInt64 = 0 + let status = data.withUnsafeBytes { register(handle, $0.bindMemory(to: UInt8.self).baseAddress, UInt(data.count), &fontId) } + try check(status, operation: "register_subtitle_memory_font") + return fontId + } + + func selectSubtitleMemoryFonts(_ fontIds: [UInt64]) throws { + guard let select = library.selectSubtitleMemoryFonts else { throw ErikaPluginError.symbolMissing("erika_presenter_select_subtitle_memory_fonts") } + try fontIds.withUnsafeBufferPointer { try check(select(handle, $0.baseAddress, UInt($0.count)), operation: "select_subtitle_memory_fonts") } + } + + func clearSubtitleMemoryFonts() throws { + guard let clear = library.clearSubtitleMemoryFonts else { throw ErikaPluginError.symbolMissing("erika_presenter_clear_subtitle_memory_fonts") } + try check(clear(handle), operation: "clear_subtitle_memory_fonts") + } + + func subtitleMemoryFontStatus() throws -> [String: Any] { + guard let getStatus = library.getSubtitleMemoryFontStatus else { throw ErikaPluginError.symbolMissing("erika_presenter_get_subtitle_memory_font_status") } + var status = ErikaSubtitleMemoryFontStatusC() + defer { + if let freeStatus = library.freeSubtitleMemoryFontStatus { withUnsafeMutablePointer(to: &status) { freeStatus(UnsafeMutableRawPointer($0)) } } + } + try withUnsafeMutablePointer(to: &status) { try check(getStatus(handle, UnsafeMutableRawPointer($0)), operation: "get_subtitle_memory_font_status") } + return ["registeredCount": Int(status.registeredCount), "registeredBytes": Int(status.registeredBytes), "selectedCount": Int(status.selectedCount), "generation": Int64(clamping: status.generation), "selectedIds": status.selectedIds.map { pointer in (0.. [String: Any] { guard let getStatus = library.getOutputStatus else { throw ErikaPluginError.symbolMissing("erika_presenter_get_output_status") @@ -1936,6 +1986,21 @@ public final class ErikaFlutterPlugin: NSObject, FlutterPlugin, FlutterStreamHan } try host.setSubtitleScale(scale) result(nil) + case "registerSubtitleMemoryFont": + let args = try dictionaryArgs(call.arguments) + guard let data = args["data"] as? FlutterStandardTypedData else { throw ErikaPluginError.invalidArguments("data is required.") } + result(Int64(clamping: try playerHost(from: args).registerSubtitleMemoryFont(data.data))) + case "selectSubtitleMemoryFonts": + let args = try dictionaryArgs(call.arguments) + try playerHost(from: args).selectSubtitleMemoryFonts((args["fontIds"] as? [NSNumber] ?? []).map { $0.uint64Value }) + result(nil) + case "clearSubtitleMemoryFonts": + let args = try dictionaryArgs(call.arguments) + try playerHost(from: args).clearSubtitleMemoryFonts() + result(nil) + case "getSubtitleMemoryFontStatus": + let args = try dictionaryArgs(call.arguments) + result(try playerHost(from: args).subtitleMemoryFontStatus()) case "setSubtitleStyle": let args = try dictionaryArgs(call.arguments) let host = try playerHost(from: args) diff --git a/packages/erika_flutter/ohos/src/main/cpp/erika_flutter_plugin.cpp b/packages/erika_flutter/ohos/src/main/cpp/erika_flutter_plugin.cpp index cbbad7d..8deac70 100644 --- a/packages/erika_flutter/ohos/src/main/cpp/erika_flutter_plugin.cpp +++ b/packages/erika_flutter/ohos/src/main/cpp/erika_flutter_plugin.cpp @@ -154,6 +154,41 @@ napi_value NativeInvoke(napi_env env, napi_callback_info info) { return result; } +napi_value NativeRegisterSubtitleMemoryFont(napi_env env, napi_callback_info info) { + size_t argc = 2; + napi_value args[2] = {}; + napi_get_cb_info(env, info, &argc, args, nullptr, nullptr); + if (argc < 2) { + napi_value result = nullptr; + napi_create_array_with_length(env, 2, &result); + napi_set_element(env, result, 0, Int32(env, ErikaStatus_NullPointer)); + napi_set_element(env, result, 1, Int64(env, 0)); + return result; + } + OhosPlayer* player = FindPlayer(GetInt64(env, args[0])); + bool is_typed_array = false; + napi_is_typedarray(env, args[1], &is_typed_array); + napi_typedarray_type array_type = napi_uint8_array; + size_t byte_count = 0; + void* bytes = nullptr; + napi_value array_buffer = nullptr; + size_t byte_offset = 0; + const bool valid_bytes = is_typed_array && + napi_get_typedarray_info( + env, args[1], &array_type, &byte_count, &bytes, &array_buffer, &byte_offset) == napi_ok && + (array_type == napi_uint8_array || array_type == napi_uint8_clamped_array); + uint64_t font_id = 0; + const auto status = player == nullptr || !valid_bytes + ? ErikaStatus_NullPointer + : erika_presenter_register_subtitle_memory_font( + player->presenter, static_cast(bytes), byte_count, &font_id); + napi_value result = nullptr; + napi_create_array_with_length(env, 2, &result); + napi_set_element(env, result, 0, Int32(env, status)); + napi_set_element(env, result, 1, Int64(env, static_cast(font_id))); + return result; +} + napi_value NativeAttachSurface(napi_env env, napi_callback_info info) { size_t argc = 5; napi_value args[5] = {}; @@ -332,6 +367,7 @@ napi_value Init(napi_env env, napi_value exports) { {"nativeLastError", nullptr, NativeLastError, nullptr, nullptr, nullptr, napi_default, nullptr}, {"nativeDestroy", nullptr, NativeDestroy, nullptr, nullptr, nullptr, napi_default, nullptr}, {"nativeInvoke", nullptr, NativeInvoke, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"nativeRegisterSubtitleMemoryFont", nullptr, NativeRegisterSubtitleMemoryFont, nullptr, nullptr, nullptr, napi_default, nullptr}, {"nativeAttachSurface", nullptr, NativeAttachSurface, nullptr, nullptr, nullptr, napi_default, nullptr}, {"nativeResizeSurface", nullptr, NativeResizeSurface, nullptr, nullptr, nullptr, napi_default, nullptr}, {"nativeDetachSurface", nullptr, NativeDetachSurface, nullptr, nullptr, nullptr, napi_default, nullptr}, diff --git a/packages/erika_flutter/ohos/src/main/ets/components/plugin/ErikaFlutterPlugin.ets b/packages/erika_flutter/ohos/src/main/ets/components/plugin/ErikaFlutterPlugin.ets index 0dc4542..0e05da4 100644 --- a/packages/erika_flutter/ohos/src/main/ets/components/plugin/ErikaFlutterPlugin.ets +++ b/packages/erika_flutter/ohos/src/main/ets/components/plugin/ErikaFlutterPlugin.ets @@ -126,6 +126,10 @@ export default class ErikaFlutterPlugin implements FlutterPlugin, MethodCallHand this.captureFrame(args, result); return; } + if (call.method === 'registerSubtitleMemoryFont') { + this.registerSubtitleMemoryFont(args, result); + return; + } if (this.isNativePlayerMethod(call.method)) { this.invokePlayer(call.method, args, result); return; @@ -336,6 +340,27 @@ export default class ErikaFlutterPlugin implements FlutterPlugin, MethodCallHand this.drainEvents(playerId); } + private registerSubtitleMemoryFont( + args: Map, + result: MethodResult, + ): void { + const playerId = this.numberArg(args, 'playerId', 0); + if (!this.players.has(playerId)) { + result.error('ERIKA_ERROR', 'Unknown Erika player ' + playerId, null); + return; + } + const data = args.get('data') as Uint8Array; + const response = erikaNative.nativeRegisterSubtitleMemoryFont( + playerId, + data, + ) as Array; + const status = response[0] ?? 1; + if (!this.completeStatus(status, result, 'register subtitle memory font')) { + return; + } + result.success(response[1] ?? 0); + } + private startFrameLoop(): void { if (this.frameTimer >= 0) { return; @@ -426,6 +451,8 @@ export default class ErikaFlutterPlugin implements FlutterPlugin, MethodCallHand return [ 'open', 'play', 'pause', 'stop', 'close', 'seek', 'setPlaybackRate', 'setVolume', 'setUpscaler', 'setSubtitleScale', + 'selectSubtitleMemoryFonts', 'clearSubtitleMemoryFonts', + 'getSubtitleMemoryFontStatus', 'getUpscalerStatus', 'getOutputStatus', 'getPresenterStats', 'addExternalSubtitle', 'removeSubtitleTrack', 'loadDanmakuFile', 'loadDanmakuJson', diff --git a/packages/erika_flutter/test/android_content_source_contract_test.dart b/packages/erika_flutter/test/android_content_source_contract_test.dart index 419566f..4bb9ea2 100644 --- a/packages/erika_flutter/test/android_content_source_contract_test.dart +++ b/packages/erika_flutter/test/android_content_source_contract_test.dart @@ -32,5 +32,11 @@ void main() { expect(source, contains('temporaryFiles.toList()')); expect(source, contains('insufficient_disk_budget')); expect(source, contains('max_bytes_exceeded')); + + expect(plugin, contains('registerSubtitleMemoryFont')); + expect(plugin, contains('selectSubtitleMemoryFonts')); + expect(plugin, contains('clearSubtitleMemoryFonts')); + expect(plugin, contains('getSubtitleMemoryFontStatus')); + expect(plugin, contains('nativeRegisterSubtitleMemoryFont')); }); } diff --git a/packages/erika_flutter/test/erika_player_test.dart b/packages/erika_flutter/test/erika_player_test.dart index 147dc58..02df049 100644 --- a/packages/erika_flutter/test/erika_player_test.dart +++ b/packages/erika_flutter/test/erika_player_test.dart @@ -22,6 +22,14 @@ void main() { return switch (call.method) { 'create' => 7, 'dispose' => null, + 'registerSubtitleMemoryFont' => 41, + 'getSubtitleMemoryFontStatus' => { + 'registeredCount': 2, + 'registeredBytes': 4096, + 'selectedCount': 1, + 'generation': 3, + 'selectedIds': [41], + }, _ => null, }; }); @@ -69,6 +77,62 @@ void main() { await player.dispose(); }); + test('subtitle memory font methods preserve typed data and IDs', () async { + final player = ErikaPlayer(); + final data = Uint8List.fromList([0, 1, 2, 255]); + + expect(await player.registerSubtitleMemoryFont(data), 41); + await player.selectSubtitleMemoryFonts([41, 42]); + final status = await player.getSubtitleMemoryFontStatus(); + await player.clearSubtitleMemoryFonts(); + + final register = playerCalls.singleWhere( + (call) => call.method == 'registerSubtitleMemoryFont', + ); + expect(register.arguments, {'playerId': 7, 'data': data}); + final select = playerCalls.singleWhere( + (call) => call.method == 'selectSubtitleMemoryFonts', + ); + expect(select.arguments, { + 'playerId': 7, + 'fontIds': [41, 42], + }); + expect(status.registeredCount, 2); + expect(status.registeredBytes, 4096); + expect(status.selectedCount, 1); + expect(status.generation, 3); + expect(status.selectedIds, [41]); + expect( + playerCalls + .singleWhere( + (call) => call.method == 'clearSubtitleMemoryFonts', + ) + .arguments, + {'playerId': 7}, + ); + await player.dispose(); + }); + + test('subtitle memory font arguments are validated before channel calls', + () async { + final player = ErikaPlayer(); + + await expectLater( + player.registerSubtitleMemoryFont(Uint8List(0)), + throwsArgumentError, + ); + await expectLater( + player.selectSubtitleMemoryFonts([1, 1]), + throwsArgumentError, + ); + await expectLater( + player.selectSubtitleMemoryFonts([0]), + throwsArgumentError, + ); + expect(playerCalls, isEmpty); + await player.dispose(); + }); + test('open omits null and empty HTTP headers', () async { final player = ErikaPlayer(); diff --git a/packages/erika_flutter/test/ohos_screenshot_contract_test.dart b/packages/erika_flutter/test/ohos_screenshot_contract_test.dart index 683d7ce..bb5529b 100644 --- a/packages/erika_flutter/test/ohos_screenshot_contract_test.dart +++ b/packages/erika_flutter/test/ohos_screenshot_contract_test.dart @@ -27,4 +27,42 @@ void main() { contains('{"nativeCaptureFrame", nullptr, NativeCaptureFrame'), ); }); + + test('OpenHarmony exposes the complete subtitle memory font API', () { + final plugin = File( + 'ohos/src/main/ets/components/plugin/ErikaFlutterPlugin.ets', + ).readAsStringSync(); + final nativeBridge = File( + 'ohos/src/main/cpp/erika_flutter_plugin.cpp', + ).readAsStringSync(); + final jsonBridge = File( + '../../crates/erika_capi/src/presenter_json.rs', + ).readAsStringSync(); + + expect(plugin, contains("call.method === 'registerSubtitleMemoryFont'")); + expect(plugin, contains('nativeRegisterSubtitleMemoryFont(')); + for (final method in [ + 'selectSubtitleMemoryFonts', + 'clearSubtitleMemoryFonts', + 'getSubtitleMemoryFontStatus', + ]) { + expect(plugin, contains("'$method'")); + expect(jsonBridge, contains('"$method"')); + } + expect( + nativeBridge, + contains('napi_value NativeRegisterSubtitleMemoryFont('), + ); + expect( + nativeBridge, + contains('erika_presenter_register_subtitle_memory_font('), + ); + expect( + nativeBridge, + contains( + '{"nativeRegisterSubtitleMemoryFont", nullptr, ' + 'NativeRegisterSubtitleMemoryFont', + ), + ); + }); } diff --git a/packages/erika_flutter/windows/erika_flutter_plugin.cpp b/packages/erika_flutter/windows/erika_flutter_plugin.cpp index 4e19629..3cea9cc 100644 --- a/packages/erika_flutter/windows/erika_flutter_plugin.cpp +++ b/packages/erika_flutter/windows/erika_flutter_plugin.cpp @@ -657,6 +657,26 @@ EncodableValue OutputStatusToMap(const ErikaOutputStatus& status) { return EncodableValue(std::move(map)); } +EncodableValue SubtitleMemoryFontStatusToMap( + const ErikaSubtitleMemoryFontStatus& status) { + EncodableList selected_ids; + selected_ids.reserve(status.selected_count); + for (uintptr_t index = 0; index < status.selected_count; ++index) { + selected_ids.emplace_back(static_cast(status.selected_ids[index])); + } + return EncodableValue(EncodableMap{ + {EncodableValue("registeredCount"), + EncodableValue(static_cast(status.registered_count))}, + {EncodableValue("registeredBytes"), + EncodableValue(static_cast(status.registered_bytes))}, + {EncodableValue("selectedCount"), + EncodableValue(static_cast(status.selected_count))}, + {EncodableValue("generation"), + EncodableValue(static_cast(status.generation))}, + {EncodableValue("selectedIds"), EncodableValue(std::move(selected_ids))}, + }); +} + } // namespace struct ErikaFlutterPlugin::ErikaNativeLibrary { @@ -676,6 +696,12 @@ struct ErikaFlutterPlugin::ErikaNativeLibrary { ErikaStatus (*)(ErikaPresenterHandle*, const char*, const char*); using SetSubtitleStyleFn = ErikaStatus (*)(ErikaPresenterHandle*, ErikaSubtitleStyle); + using RegisterSubtitleMemoryFontFn = ErikaStatus (*)( + ErikaPresenterHandle*, const uint8_t*, uintptr_t, uint64_t*); + using SelectSubtitleMemoryFontsFn = ErikaStatus (*)( + ErikaPresenterHandle*, const uint64_t*, uintptr_t); + using GetSubtitleMemoryFontStatusFn = ErikaStatus (*)( + ErikaPresenterHandle*, ErikaSubtitleMemoryFontStatus*); using GetUpscalerStatusFn = ErikaStatus (*)(ErikaPresenterHandle*, ErikaUpscalerStatus*); using GetOutputStatusFn = @@ -787,6 +813,11 @@ struct ErikaFlutterPlugin::ErikaNativeLibrary { SetSubtitleScaleFn set_subtitle_scale = nullptr; SetSubtitleFontFn set_subtitle_font = nullptr; SetSubtitleStyleFn set_subtitle_style = nullptr; + RegisterSubtitleMemoryFontFn register_subtitle_memory_font = nullptr; + SelectSubtitleMemoryFontsFn select_subtitle_memory_fonts = nullptr; + CommandFn clear_subtitle_memory_fonts = nullptr; + GetSubtitleMemoryFontStatusFn get_subtitle_memory_font_status = nullptr; + void (*free_subtitle_memory_font_status)(ErikaSubtitleMemoryFontStatus*) = nullptr; GetUpscalerStatusFn get_upscaler_status = nullptr; GetOutputStatusFn get_output_status = nullptr; SelectTrackFn select_audio_track = nullptr; @@ -851,6 +882,16 @@ struct ErikaFlutterPlugin::ErikaNativeLibrary { LoadOptional("erika_presenter_set_subtitle_font"); set_subtitle_style = LoadOptional( "erika_presenter_set_subtitle_style"); + register_subtitle_memory_font = LoadOptional( + "erika_presenter_register_subtitle_memory_font"); + select_subtitle_memory_fonts = LoadOptional( + "erika_presenter_select_subtitle_memory_fonts"); + clear_subtitle_memory_fonts = LoadOptional( + "erika_presenter_clear_subtitle_memory_fonts"); + get_subtitle_memory_font_status = LoadOptional( + "erika_presenter_get_subtitle_memory_font_status"); + free_subtitle_memory_font_status = LoadOptional( + "erika_subtitle_memory_font_status_free"); get_upscaler_status = LoadOptional( "erika_presenter_get_upscaler_status"); get_output_status = LoadOptional( @@ -1263,6 +1304,56 @@ struct ErikaFlutterPlugin::PlayerHost { "set_subtitle_style"); } + uint64_t RegisterSubtitleMemoryFont(const std::vector& data) { + if (library->register_subtitle_memory_font == nullptr) { + throw PluginError("Missing Erika C ABI symbol: erika_presenter_register_subtitle_memory_font"); + } + uint64_t font_id = 0; + Check(library->register_subtitle_memory_font(handle, data.data(), data.size(), + &font_id), + "register_subtitle_memory_font"); + return font_id; + } + + void SelectSubtitleMemoryFonts(const EncodableList& values) { + if (library->select_subtitle_memory_fonts == nullptr) { + throw PluginError("Missing Erika C ABI symbol: erika_presenter_select_subtitle_memory_fonts"); + } + std::vector ids; + ids.reserve(values.size()); + for (const auto& value : values) { + const auto font_id = Int64Value(&value); + if (!font_id || *font_id <= 0) { + throw PluginError("fontIds must contain positive integers."); + } + ids.push_back(static_cast(*font_id)); + } + Check(library->select_subtitle_memory_fonts(handle, ids.data(), ids.size()), + "select_subtitle_memory_fonts"); + } + + void ClearSubtitleMemoryFonts() { + if (library->clear_subtitle_memory_fonts == nullptr) { + throw PluginError("Missing Erika C ABI symbol: erika_presenter_clear_subtitle_memory_fonts"); + } + Check(library->clear_subtitle_memory_fonts(handle), + "clear_subtitle_memory_fonts"); + } + + EncodableValue GetSubtitleMemoryFontStatus() { + if (library->get_subtitle_memory_font_status == nullptr) { + throw PluginError("Missing Erika C ABI symbol: erika_presenter_get_subtitle_memory_font_status"); + } + ErikaSubtitleMemoryFontStatus status{}; + Check(library->get_subtitle_memory_font_status(handle, &status), + "get_subtitle_memory_font_status"); + auto result = SubtitleMemoryFontStatusToMap(status); + if (library->free_subtitle_memory_font_status != nullptr) { + library->free_subtitle_memory_font_status(&status); + } + return result; + } + EncodableValue GetUpscalerStatus() { if (library->get_upscaler_status == nullptr) { throw PluginError("Missing Erika C ABI symbol: erika_presenter_get_upscaler_status"); @@ -2320,6 +2411,25 @@ void ErikaFlutterPlugin::HandleMethodCall( PlayerFromArgs(args).SetSubtitleScale( DoubleValue(FindArg(args, "scale")).value_or(1.0)); result->Success(); + } else if (method == "registerSubtitleMemoryFont") { + const auto* data = std::get_if>(FindArg(args, "data")); + if (data == nullptr || data->empty()) { + throw PluginError("data is required."); + } + result->Success(EncodableValue(static_cast( + PlayerFromArgs(args).RegisterSubtitleMemoryFont(*data)))); + } else if (method == "selectSubtitleMemoryFonts") { + const auto* ids = std::get_if(FindArg(args, "fontIds")); + if (ids == nullptr) { + throw PluginError("fontIds is required."); + } + PlayerFromArgs(args).SelectSubtitleMemoryFonts(*ids); + result->Success(); + } else if (method == "clearSubtitleMemoryFonts") { + PlayerFromArgs(args).ClearSubtitleMemoryFonts(); + result->Success(); + } else if (method == "getSubtitleMemoryFontStatus") { + result->Success(PlayerFromArgs(args).GetSubtitleMemoryFontStatus()); } else if (method == "setSubtitleStyle") { auto& host = PlayerFromArgs(args); const bool has_style = FindArg(args, "fontFamily") != nullptr || diff --git a/third_party/patches/libass-0.17.5/0001-erika-ordered-default-font-families.patch b/third_party/patches/libass-0.17.5/0001-erika-ordered-default-font-families.patch new file mode 100644 index 0000000..0336d76 --- /dev/null +++ b/third_party/patches/libass-0.17.5/0001-erika-ordered-default-font-families.patch @@ -0,0 +1,47 @@ +--- a/libass/ass_fontselect.c ++++ b/libass/ass_fontselect.c +@@ -870,6 +870,33 @@ static char *select_font(ASS_FontSelector *priv, + return result; + } + ++/* Erika passes an ASCII unit-separator-delimited list as default_family. ++ * Keep the public libass ABI unchanged while allowing application-supplied ++ * memory fonts to be tried in their selected order on every font provider. */ ++static char *select_default_font(ASS_FontSelector *priv, ++ unsigned bold, unsigned italic, ++ int *index, char **postscript_name, int *uid, ++ ASS_FontStream *stream, uint32_t code) ++{ ++ const char *start = priv->family_default; ++ while (start && *start) { ++ const char *end = strchr(start, '\x1f'); ++ size_t length = end ? (size_t) (end - start) : strlen(start); ++ char *family = strndup(start, length); ++ if (!family) ++ return NULL; ++ char *result = select_font(priv, family, false, bold, italic, ++ index, postscript_name, uid, stream, code); ++ free(family); ++ if (result) ++ return result; ++ if (!end) ++ break; ++ start = end + 1; ++ } ++ return NULL; ++} ++ + + /** + * \brief Find a font. Use default family or path if necessary. +@@ -896,8 +923,8 @@ char *ass_font_select(ASS_FontSelector *priv, + postscript_name, uid, data, code); + + if (!res && priv->family_default) { +- res = select_font(priv, priv->family_default, false, bold, +- italic, index, postscript_name, uid, data, code); ++ res = select_default_font(priv, bold, italic, index, ++ postscript_name, uid, data, code); + if (res) + ass_msg(priv->library, MSGL_WARN, "fontselect: Using default " + "font family: (%s, %d, %d) -> %s, %d, %s", diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 967308a..514d388 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -43,6 +43,10 @@ const LIBASS_URLS: &[&str] = &[ "https://github.com/libass/libass/releases/download/0.17.5/libass-0.17.5.tar.xz", "https://codeload.github.com/libass/libass/tar.gz/refs/tags/0.17.5", ]; +const LIBASS_PATCHSET_VERSION: &str = "erika-ordered-font-fallback-v1"; +const LIBASS_BUILD_VERSION: &str = "0.17.5+erika-ordered-font-fallback-v1"; +const LIBASS_PATCHES: &[&str] = + &["third_party/patches/libass-0.17.5/0001-erika-ordered-default-font-families.patch"]; const HARFBUZZ_ARCHIVE: &str = "harfbuzz-14.2.1.tar.xz"; const HARFBUZZ_DIR: &str = "harfbuzz-14.2.1"; @@ -735,6 +739,7 @@ fn fetch_dependency_sources(layout: &WorkspaceLayout, all: bool) -> Result<()> { } if all { fetch_and_extract(layout, LIBASS_URLS, LIBASS_ARCHIVE, LIBASS_DIR, None)?; + apply_libass_patches(layout)?; fetch_and_extract(layout, HARFBUZZ_URLS, HARFBUZZ_ARCHIVE, HARFBUZZ_DIR, None)?; fetch_and_extract(layout, FREETYPE_URLS, FREETYPE_ARCHIVE, FREETYPE_DIR, None)?; fetch_and_extract(layout, FRIBIDI_URLS, FRIBIDI_ARCHIVE, FRIBIDI_DIR, None)?; @@ -1350,7 +1355,7 @@ fn build_libass(layout: &WorkspaceLayout, options: DepsOptions) -> Result<()> { if marker_has_version( &layout.libass_build_marker, "libass", - LIBASS_VERSION, + LIBASS_BUILD_VERSION, options.target, ) && !options.force { @@ -1445,7 +1450,7 @@ fn build_libass(layout: &WorkspaceLayout, options: DepsOptions) -> Result<()> { write_marker( &layout.libass_build_marker, "libass", - LIBASS_VERSION, + LIBASS_BUILD_VERSION, &layout.libass_prefix, options.target, ) @@ -1952,6 +1957,40 @@ fn apply_ffmpeg_patch_files(layout: &WorkspaceLayout) -> Result Result<()> { + let mut application = PatchApplication::AlreadyApplied; + for relative_path in LIBASS_PATCHES { + let patch_path = layout.root.join(relative_path); + let patch = fs::read_to_string(&patch_path) + .with_context(|| format!("read libass patch {}", patch_path.display()))?; + if apply_unified_patch(&layout.libass_source_dir, &patch) + .with_context(|| format!("apply libass patch {}", patch_path.display()))? + == PatchApplication::Applied + { + application = PatchApplication::Applied; + } + } + fs::write( + layout.libass_source_dir.join(".erika-patchset"), + format!("{LIBASS_PATCHSET_VERSION}\n"), + ) + .with_context(|| { + format!( + "write libass patch stamp in {}", + layout.libass_source_dir.display() + ) + })?; + match application { + PatchApplication::Applied => { + println!("applied libass patch set {LIBASS_PATCHSET_VERSION}") + } + PatchApplication::AlreadyApplied => { + println!("reuse libass patch set {LIBASS_PATCHSET_VERSION}") + } + } + Ok(()) +} + fn refresh_ffmpeg_source(layout: &WorkspaceLayout) -> Result<()> { validate_generated_ffmpeg_source_path(layout)?; let archive_path = layout.cache_dir.join(FFMPEG_ARCHIVE);