1use std::collections::HashMap;
13use std::fmt;
14use std::sync::Arc;
15use std::sync::atomic::{AtomicU64, Ordering};
16
17use alpha2_eval::{FrameDesc, StepOp};
18use alpha2_media::EmojiPolicy;
19use alpha2_model::{Generator, Rgba, TextAlign, TextStyle};
20use alpha2_render::{RenderCacheKey, RenderError, RenderSize, Renderer, SourceView, UploadTexture};
21use alpha2_text::{TextFont, TextRasterError, TextRasterRequest, TextRasterizer};
22
23const TEXT_CACHE_DOMAIN: u64 = 0x6361_7074_696f_6e73;
28
29static NEXT_FONT_SET: AtomicU64 = AtomicU64::new(1);
33
34const RETAINED_TEXTS: usize = 16;
37
38const MAX_FONT_PIXELS: f32 = 2_048.0;
40const MAX_OUTLINE_PIXELS: f32 = 64.0;
41const MAX_PADDING_PIXELS: f32 = 256.0;
42
43#[derive(Clone, Copy, Debug)]
46pub struct TextTexture<'a> {
47 pub texture: SourceView<'a>,
49 pub width: u32,
51 pub height: u32,
53 pub margin: [u32; 2],
58 pub cache_key: Option<RenderCacheKey>,
65}
66
67#[derive(Clone, Debug, Eq, PartialEq)]
69#[non_exhaustive]
70pub enum TextError {
71 Raster(TextRasterError),
74 Render(RenderError),
76}
77
78impl fmt::Display for TextError {
79 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
80 match self {
81 Self::Raster(TextRasterError::NoFonts) => {
82 formatter.write_str("the project shows text, but no fonts were registered")
83 }
84 Self::Raster(error) => write!(formatter, "text failed: {error}"),
85 Self::Render(error) => write!(formatter, "text upload failed: {error}"),
86 }
87 }
88}
89
90impl std::error::Error for TextError {}
91
92struct TextEntry {
93 texture: UploadTexture,
94 width: u32,
95 height: u32,
96 margin: [u32; 2],
97 last_used: u64,
98}
99
100pub struct TextTextures {
103 rasterizer: TextRasterizer,
104 font_set: u64,
107 entries: HashMap<u64, TextEntry>,
108 frame: u64,
109}
110
111impl fmt::Debug for TextTextures {
112 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
113 formatter
114 .debug_struct("TextTextures")
115 .field("fonts", &self.rasterizer.font_count())
116 .field("texts", &self.entries.len())
117 .finish_non_exhaustive()
118 }
119}
120
121impl Default for TextTextures {
122 fn default() -> Self {
123 Self::new()
124 }
125}
126
127impl TextTextures {
128 #[must_use]
130 pub fn new() -> Self {
131 Self {
132 rasterizer: TextRasterizer::empty(),
133 font_set: NEXT_FONT_SET.fetch_add(1, Ordering::Relaxed),
134 entries: HashMap::new(),
135 frame: 0,
136 }
137 }
138
139 pub fn add_font(
145 &mut self,
146 family: impl Into<String>,
147 bytes: impl Into<Arc<[u8]>>,
148 face_index: u32,
149 ) -> Result<(), TextError> {
150 self.rasterizer
151 .add_font(TextFont {
152 family: family.into(),
153 bytes: bytes.into(),
154 face_index,
155 })
156 .map_err(TextError::Raster)?;
157 self.entries.clear();
158 self.font_set = NEXT_FONT_SET.fetch_add(1, Ordering::Relaxed);
160 Ok(())
161 }
162
163 #[must_use]
165 pub fn font_count(&self) -> usize {
166 self.rasterizer.font_count()
167 }
168
169 pub fn prepare(
177 &mut self,
178 renderer: &Renderer,
179 frame: &FrameDesc,
180 size: RenderSize,
181 ) -> Result<(), TextError> {
182 self.frame += 1;
183 for (text, style) in frame_texts(frame) {
184 let key = text_key(text, style, size);
185 if let Some(entry) = self.entries.get_mut(&key) {
186 entry.last_used = self.frame;
187 continue;
188 }
189 let request = text_request(text, style, size, key);
190 let raster = self
191 .rasterizer
192 .rasterize(&request)
193 .map_err(TextError::Raster)?;
194 self.rasterizer.invalidate(key);
196 let texture_size = &raster.descriptor.texture;
197 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
200 let margin = [raster.descriptor.layout_x, raster.descriptor.layout_y]
201 .map(|margin| (margin as u32).saturating_sub(request.decoration_margin()));
202 let texture = renderer
203 .upload_rgba8(
204 RenderSize::new(texture_size.width, texture_size.height)
205 .map_err(TextError::Render)?,
206 &raster.pixels.rgba8,
207 )
208 .map_err(TextError::Render)?;
209 self.entries.insert(
210 key,
211 TextEntry {
212 texture,
213 width: texture_size.width,
214 height: texture_size.height,
215 margin,
216 last_used: self.frame,
217 },
218 );
219 }
220 while self.entries.len() > RETAINED_TEXTS {
221 let Some(oldest) = self
222 .entries
223 .iter()
224 .filter(|(_, entry)| entry.last_used < self.frame)
225 .min_by_key(|(_, entry)| entry.last_used)
226 .map(|(key, _)| *key)
227 else {
228 break;
229 };
230 self.entries.remove(&oldest);
231 }
232 Ok(())
233 }
234
235 pub fn prepare_at(
243 &mut self,
244 renderer: &Renderer,
245 project: &crate::EngineProject,
246 time: alpha2_time::MediaTime,
247 size: RenderSize,
248 ) -> Result<(), crate::EngineError> {
249 if !project.draws_text() {
250 return Ok(());
251 }
252 let frame = project.evaluate(time)?;
253 self.prepare(renderer, &frame, size)
254 .map_err(crate::EngineError::Text)
255 }
256
257 #[must_use]
259 pub fn get(&self, key: u64) -> Option<TextTexture<'_>> {
260 self.entries.get(&key).map(|entry| TextTexture {
261 texture: SourceView::from(&entry.texture),
262 width: entry.width,
263 height: entry.height,
264 margin: entry.margin,
265 cache_key: Some(self.cache_key(key)),
266 })
267 }
268
269 fn cache_key(&self, key: u64) -> RenderCacheKey {
274 let high = TEXT_CACHE_DOMAIN ^ self.font_set;
275 RenderCacheKey::from_value((u128::from(high) << 64) | u128::from(key))
276 }
277}
278
279pub(crate) fn frame_texts<'f>(
283 frame: &'f FrameDesc<'_>,
284) -> impl Iterator<Item = (&'f str, &'f TextStyle)> {
285 let captions = frame
286 .captions
287 .iter()
288 .map(|caption| (caption.text, caption.style));
289 let generators = frame
290 .visual
291 .iter()
292 .filter(|clip| frame.draws(clip))
293 .flat_map(|clip| {
294 let steps = clip
295 .composition
296 .iter()
297 .chain(&clip.source_composition)
298 .flat_map(|composition| &composition.plan().steps)
299 .filter_map(|step| match &step.op {
300 StepOp::Generator(generator) => Some(generator),
301 _ => None,
302 });
303 clip.generator.into_iter().chain(steps)
304 })
305 .filter_map(|generator| match generator {
306 Generator::Text { text, style } => Some((text.as_str(), style)),
307 _ => None,
308 });
309 captions.chain(generators)
310}
311
312pub struct FramesWithText<'a> {
315 frames: &'a dyn crate::FrameProvider,
316 texts: &'a TextTextures,
317}
318
319impl<'a> FramesWithText<'a> {
320 #[must_use]
322 pub fn new(frames: &'a dyn crate::FrameProvider, texts: &'a TextTextures) -> Self {
323 Self { frames, texts }
324 }
325}
326
327impl crate::FrameProvider for FramesWithText<'_> {
328 fn texture_for(
329 &self,
330 source: &alpha2_media::SourceId,
331 time: alpha2_time::MediaTime,
332 ) -> Option<SourceView<'_>> {
333 self.frames.texture_for(source, time)
334 }
335
336 fn pending_source_work(
337 &self,
338 source: &alpha2_media::SourceId,
339 time: alpha2_time::MediaTime,
340 ) -> Option<&alpha2_render::PendingSourceWork> {
341 self.frames.pending_source_work(source, time)
342 }
343
344 fn source_key(
345 &self,
346 source: &alpha2_media::SourceId,
347 time: alpha2_time::MediaTime,
348 ) -> Option<alpha2_render::RenderCacheKey> {
349 self.frames.source_key(source, time)
350 }
351
352 fn text(&self, key: u64) -> Option<TextTexture<'_>> {
353 self.texts.get(key)
354 }
355}
356
357#[must_use]
360pub fn text_key(text: &str, style: &TextStyle, size: RenderSize) -> u64 {
361 let mut hash = 0xcbf2_9ce4_8422_2325_u64;
362 let mut feed = |bytes: &[u8]| {
363 for byte in bytes {
364 hash = (hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3);
365 }
366 };
367 feed(text.as_bytes());
368 feed(&[0xff]);
369 feed(style.font_family.as_deref().unwrap_or_default().as_bytes());
371 feed(&[0xff, u8::from(style.font_family.is_some())]);
372 for value in [
373 style.font_size,
374 style.line_height,
375 style.max_width,
376 style.position.x,
377 style.position.y,
378 style.anchor.x,
379 style.anchor.y,
380 ] {
381 feed(&value.to_bits().to_le_bytes());
382 }
383 feed(&rgba8(style.color));
384 feed(&[match style.align {
385 TextAlign::Start => 0,
386 TextAlign::Center => 1,
387 TextAlign::End => 2,
388 }]);
389 match style.outline {
390 Some(outline) => {
391 feed(&[1]);
392 feed(&rgba8(outline.color));
393 feed(&outline.width.to_bits().to_le_bytes());
394 }
395 None => feed(&[0]),
396 }
397 match style.background {
398 Some(background) => {
399 feed(&[1]);
400 feed(&rgba8(background.color));
401 feed(&background.padding.to_bits().to_le_bytes());
402 }
403 None => feed(&[0]),
404 }
405 feed(&size.width.to_le_bytes());
406 feed(&size.height.to_le_bytes());
407 hash.max(1)
408}
409
410#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
412fn rgba8(color: Rgba) -> [u8; 4] {
413 [color.red, color.green, color.blue, color.alpha]
414 .map(|value| (value.clamp(0.0, 1.0) * 255.0).round() as u8)
415}
416
417#[allow(clippy::cast_precision_loss)]
420fn text_request(text: &str, style: &TextStyle, size: RenderSize, key: u64) -> TextRasterRequest {
421 let font_size = (style.font_size * size.height as f32).clamp(1.0, MAX_FONT_PIXELS);
422 TextRasterRequest {
423 text: text.to_owned(),
424 font_size,
425 max_width: (style.max_width * size.width as f32).max(1.0),
426 line_height: style.line_height,
427 align: match style.align {
428 TextAlign::Start => alpha2_media::TextAlign::Start,
429 TextAlign::Center => alpha2_media::TextAlign::Center,
430 TextAlign::End => alpha2_media::TextAlign::End,
431 },
432 color: rgba8(style.color),
433 emoji_policy: EmojiPolicy::Color,
434 content_version: key,
435 outline: style.outline.map(|outline| alpha2_text::TextOutline {
436 color: rgba8(outline.color),
437 width: (outline.width * font_size).clamp(f32::MIN_POSITIVE, MAX_OUTLINE_PIXELS),
438 }),
439 background: style
440 .background
441 .map(|background| alpha2_text::TextBackground {
442 color: rgba8(background.color),
443 padding: (background.padding * font_size).clamp(0.0, MAX_PADDING_PIXELS),
444 }),
445 font_family: style.font_family.clone(),
446 }
447}
448
449#[cfg(test)]
450#[path = "text_tests.rs"]
451mod tests;