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(Copy, Clone, Debug, PartialEq)]
72pub struct TextBounds {
73 pub layout: CanvasRect,
77 pub content: CanvasRect,
82 pub ink: Option<CanvasRect>,
88 pub line_count: u32,
90}
91
92#[derive(Copy, Clone, Debug, PartialEq)]
94pub struct CanvasRect {
95 pub left: f32,
97 pub top: f32,
99 pub right: f32,
101 pub bottom: f32,
103}
104
105#[derive(Clone, Debug, Eq, PartialEq)]
107#[non_exhaustive]
108pub enum TextError {
109 Raster(TextRasterError),
112 Render(RenderError),
114}
115
116impl fmt::Display for TextError {
117 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
118 match self {
119 Self::Raster(TextRasterError::NoFonts) => {
120 formatter.write_str("the project shows text, but no fonts were registered")
121 }
122 Self::Raster(error) => write!(formatter, "text failed: {error}"),
123 Self::Render(error) => write!(formatter, "text upload failed: {error}"),
124 }
125 }
126}
127
128impl std::error::Error for TextError {}
129
130struct TextEntry {
131 texture: UploadTexture,
132 width: u32,
133 height: u32,
134 margin: [u32; 2],
135 last_used: u64,
136}
137
138pub struct TextTextures {
141 rasterizer: TextRasterizer,
142 font_set: u64,
145 entries: HashMap<u64, TextEntry>,
146 frame: u64,
147}
148
149impl fmt::Debug for TextTextures {
150 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
151 formatter
152 .debug_struct("TextTextures")
153 .field("fonts", &self.rasterizer.font_count())
154 .field("texts", &self.entries.len())
155 .finish_non_exhaustive()
156 }
157}
158
159impl Default for TextTextures {
160 fn default() -> Self {
161 Self::new()
162 }
163}
164
165impl TextTextures {
166 #[must_use]
168 pub fn new() -> Self {
169 Self {
170 rasterizer: TextRasterizer::empty(),
171 font_set: NEXT_FONT_SET.fetch_add(1, Ordering::Relaxed),
172 entries: HashMap::new(),
173 frame: 0,
174 }
175 }
176
177 pub fn add_font(
183 &mut self,
184 family: impl Into<String>,
185 bytes: impl Into<Arc<[u8]>>,
186 face_index: u32,
187 ) -> Result<(), TextError> {
188 self.rasterizer
189 .add_font(TextFont {
190 family: family.into(),
191 bytes: bytes.into(),
192 face_index,
193 })
194 .map_err(TextError::Raster)?;
195 self.entries.clear();
196 self.font_set = NEXT_FONT_SET.fetch_add(1, Ordering::Relaxed);
198 Ok(())
199 }
200
201 #[must_use]
203 pub fn font_count(&self) -> usize {
204 self.rasterizer.font_count()
205 }
206
207 pub fn prepare(
215 &mut self,
216 renderer: &Renderer,
217 frame: &FrameDesc,
218 size: RenderSize,
219 ) -> Result<(), TextError> {
220 self.frame += 1;
221 for (text, style) in frame_texts(frame) {
222 let key = text_key(text, style, size);
223 if let Some(entry) = self.entries.get_mut(&key) {
224 entry.last_used = self.frame;
225 continue;
226 }
227 let request = text_request(text, style, size, key);
228 let raster = self
229 .rasterizer
230 .rasterize(&request)
231 .map_err(TextError::Raster)?;
232 self.rasterizer.invalidate(key);
234 let texture_size = &raster.descriptor.texture;
235 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
238 let margin = [raster.descriptor.layout_x, raster.descriptor.layout_y]
239 .map(|margin| (margin as u32).saturating_sub(request.decoration_margin()));
240 let texture = renderer
241 .upload_rgba8(
242 RenderSize::new(texture_size.width, texture_size.height)
243 .map_err(TextError::Render)?,
244 &raster.pixels.rgba8,
245 )
246 .map_err(TextError::Render)?;
247 self.entries.insert(
248 key,
249 TextEntry {
250 texture,
251 width: texture_size.width,
252 height: texture_size.height,
253 margin,
254 last_used: self.frame,
255 },
256 );
257 }
258 while self.entries.len() > RETAINED_TEXTS {
259 let Some(oldest) = self
260 .entries
261 .iter()
262 .filter(|(_, entry)| entry.last_used < self.frame)
263 .min_by_key(|(_, entry)| entry.last_used)
264 .map(|(key, _)| *key)
265 else {
266 break;
267 };
268 self.entries.remove(&oldest);
269 }
270 Ok(())
271 }
272
273 pub fn prepare_at(
281 &mut self,
282 renderer: &Renderer,
283 project: &crate::EngineProject,
284 time: alpha2_time::MediaTime,
285 size: RenderSize,
286 ) -> Result<(), crate::EngineError> {
287 if !project.draws_text() {
288 return Ok(());
289 }
290 let frame = project.evaluate(time)?;
291 self.prepare(renderer, &frame, size)
292 .map_err(crate::EngineError::Text)
293 }
294
295 pub fn measure(
301 &self,
302 text: &str,
303 style: &TextStyle,
304 size: RenderSize,
305 ) -> Result<TextBounds, TextError> {
306 measure_text(&self.rasterizer, text, style, size)
307 }
308
309 #[must_use]
311 pub fn get(&self, key: u64) -> Option<TextTexture<'_>> {
312 self.entries.get(&key).map(|entry| TextTexture {
313 texture: SourceView::from(&entry.texture),
314 width: entry.width,
315 height: entry.height,
316 margin: entry.margin,
317 cache_key: Some(self.cache_key(key)),
318 })
319 }
320
321 fn cache_key(&self, key: u64) -> RenderCacheKey {
326 let high = TEXT_CACHE_DOMAIN ^ self.font_set;
327 RenderCacheKey::from_value((u128::from(high) << 64) | u128::from(key))
328 }
329}
330
331pub(crate) fn frame_texts<'f>(
335 frame: &'f FrameDesc<'_>,
336) -> impl Iterator<Item = (&'f str, &'f TextStyle)> {
337 let captions = frame
338 .captions
339 .iter()
340 .map(|caption| (caption.text, caption.style));
341 let generators = frame
342 .visual
343 .iter()
344 .filter(|clip| frame.draws(clip))
345 .flat_map(|clip| {
346 let steps = clip
347 .composition
348 .iter()
349 .chain(&clip.source_composition)
350 .flat_map(|composition| &composition.plan().steps)
351 .filter_map(|step| match &step.op {
352 StepOp::Generator { generator, .. } => Some(generator),
353 _ => None,
354 });
355 clip.generator.into_iter().chain(steps)
356 })
357 .filter_map(|generator| match generator {
358 Generator::Text { text, style } => Some((text.as_str(), style)),
359 _ => None,
360 });
361 captions.chain(generators)
362}
363
364pub struct FramesWithText<'a> {
367 frames: &'a dyn crate::FrameProvider,
368 texts: &'a TextTextures,
369}
370
371impl<'a> FramesWithText<'a> {
372 #[must_use]
374 pub fn new(frames: &'a dyn crate::FrameProvider, texts: &'a TextTextures) -> Self {
375 Self { frames, texts }
376 }
377}
378
379impl crate::FrameProvider for FramesWithText<'_> {
380 fn texture_for(
381 &self,
382 source: &alpha2_media::SourceId,
383 time: alpha2_time::MediaTime,
384 ) -> Option<SourceView<'_>> {
385 self.frames.texture_for(source, time)
386 }
387
388 fn pending_source_work(
389 &self,
390 source: &alpha2_media::SourceId,
391 time: alpha2_time::MediaTime,
392 ) -> Option<&alpha2_render::PendingSourceWork> {
393 self.frames.pending_source_work(source, time)
394 }
395
396 fn source_key(
397 &self,
398 source: &alpha2_media::SourceId,
399 time: alpha2_time::MediaTime,
400 ) -> Option<alpha2_render::RenderCacheKey> {
401 self.frames.source_key(source, time)
402 }
403
404 fn text(&self, key: u64) -> Option<TextTexture<'_>> {
405 self.texts.get(key)
406 }
407}
408
409#[must_use]
412pub fn text_key(text: &str, style: &TextStyle, size: RenderSize) -> u64 {
413 let mut hash = 0xcbf2_9ce4_8422_2325_u64;
414 let mut feed = |bytes: &[u8]| {
415 for byte in bytes {
416 hash = (hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3);
417 }
418 };
419 feed(text.as_bytes());
420 feed(&[0xff]);
421 feed(style.font_family.as_deref().unwrap_or_default().as_bytes());
423 feed(&[0xff, u8::from(style.font_family.is_some())]);
424 for value in [
425 style.font_size,
426 style.line_height,
427 style.max_width,
428 style.position.x,
429 style.position.y,
430 style.anchor.x,
431 style.anchor.y,
432 ] {
433 feed(&value.to_bits().to_le_bytes());
434 }
435 feed(&rgba8(style.color));
436 feed(&[match style.align {
437 TextAlign::Start => 0,
438 TextAlign::Center => 1,
439 TextAlign::End => 2,
440 }]);
441 match style.outline {
442 Some(outline) => {
443 feed(&[1]);
444 feed(&rgba8(outline.color));
445 feed(&outline.width.to_bits().to_le_bytes());
446 }
447 None => feed(&[0]),
448 }
449 match style.background {
450 Some(background) => {
451 feed(&[1]);
452 feed(&rgba8(background.color));
453 feed(&background.padding.to_bits().to_le_bytes());
454 }
455 None => feed(&[0]),
456 }
457 feed(&size.width.to_le_bytes());
458 feed(&size.height.to_le_bytes());
459 hash.max(1)
460}
461
462#[allow(clippy::cast_precision_loss)]
472pub fn measure_text(
473 rasterizer: &TextRasterizer,
474 text: &str,
475 style: &TextStyle,
476 size: RenderSize,
477) -> Result<TextBounds, TextError> {
478 let request = text_request(text, style, size, text_key(text, style, size));
479 let measure = rasterizer.measure(&request).map_err(TextError::Raster)?;
480 let (width, height) = (size.width as f32, size.height as f32);
481 let margin = measure.decoration_margin as f32;
482 let (box_width, box_height) = (measure.box_width as f32, measure.box_height as f32);
483 let origin = [
486 style.position.x * width - style.anchor.x * (box_width + 2.0 * margin) + margin,
487 style.position.y * height - style.anchor.y * (box_height + 2.0 * margin) + margin,
488 ];
489 let rect = |[left, top, right, bottom]: [f32; 4]| CanvasRect {
490 left: (origin[0] + left) / width,
491 top: (origin[1] + top) / height,
492 right: (origin[0] + right) / width,
493 bottom: (origin[1] + bottom) / height,
494 };
495 Ok(TextBounds {
496 layout: rect([-margin, -margin, box_width + margin, box_height + margin]),
497 content: rect(measure.content),
498 ink: measure.ink.map(rect),
499 line_count: measure.line_count,
500 })
501}
502
503#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
505fn rgba8(color: Rgba) -> [u8; 4] {
506 [color.red, color.green, color.blue, color.alpha]
507 .map(|value| (value.clamp(0.0, 1.0) * 255.0).round() as u8)
508}
509
510#[allow(clippy::cast_precision_loss)]
513fn text_request(text: &str, style: &TextStyle, size: RenderSize, key: u64) -> TextRasterRequest {
514 let font_size = (style.font_size * size.height as f32).clamp(1.0, MAX_FONT_PIXELS);
515 TextRasterRequest {
516 text: text.to_owned(),
517 font_size,
518 max_width: (style.max_width * size.width as f32).max(1.0),
519 line_height: style.line_height,
520 align: match style.align {
521 TextAlign::Start => alpha2_media::TextAlign::Start,
522 TextAlign::Center => alpha2_media::TextAlign::Center,
523 TextAlign::End => alpha2_media::TextAlign::End,
524 },
525 color: rgba8(style.color),
526 emoji_policy: EmojiPolicy::Color,
527 content_version: key,
528 outline: style.outline.map(|outline| alpha2_text::TextOutline {
529 color: rgba8(outline.color),
530 width: (outline.width * font_size).clamp(f32::MIN_POSITIVE, MAX_OUTLINE_PIXELS),
531 }),
532 background: style
533 .background
534 .map(|background| alpha2_text::TextBackground {
535 color: rgba8(background.color),
536 padding: (background.padding * font_size).clamp(0.0, MAX_PADDING_PIXELS),
537 }),
538 font_family: style.font_family.clone(),
539 }
540}
541
542#[cfg(test)]
543#[path = "text_tests.rs"]
544mod tests;