use std::collections::HashMap;
use std::fmt;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use alpha2_eval::{FrameDesc, StepOp};
use alpha2_media::EmojiPolicy;
use alpha2_model::{Generator, Rgba, TextAlign, TextStyle};
use alpha2_render::{RenderCacheKey, RenderError, RenderSize, Renderer, SourceView, UploadTexture};
use alpha2_text::{TextFont, TextRasterError, TextRasterRequest, TextRasterizer};
const TEXT_CACHE_DOMAIN: u64 = 0x6361_7074_696f_6e73;
static NEXT_FONT_SET: AtomicU64 = AtomicU64::new(1);
const RETAINED_TEXTS: usize = 16;
const MAX_FONT_PIXELS: f32 = 2_048.0;
const MAX_OUTLINE_PIXELS: f32 = 64.0;
const MAX_PADDING_PIXELS: f32 = 256.0;
#[derive(Clone, Copy, Debug)]
pub struct TextTexture<'a> {
pub texture: SourceView<'a>,
pub width: u32,
pub height: u32,
pub margin: [u32; 2],
pub cache_key: Option<RenderCacheKey>,
}
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct TextBounds {
pub layout: CanvasRect,
pub content: CanvasRect,
pub ink: Option<CanvasRect>,
pub line_count: u32,
}
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct CanvasRect {
pub left: f32,
pub top: f32,
pub right: f32,
pub bottom: f32,
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum TextError {
Raster(TextRasterError),
Render(RenderError),
}
impl fmt::Display for TextError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Raster(TextRasterError::NoFonts) => {
formatter.write_str("the project shows text, but no fonts were registered")
}
Self::Raster(error) => write!(formatter, "text failed: {error}"),
Self::Render(error) => write!(formatter, "text upload failed: {error}"),
}
}
}
impl std::error::Error for TextError {}
struct TextEntry {
texture: UploadTexture,
width: u32,
height: u32,
margin: [u32; 2],
last_used: u64,
}
pub struct TextTextures {
rasterizer: TextRasterizer,
font_set: u64,
entries: HashMap<u64, TextEntry>,
frame: u64,
}
impl fmt::Debug for TextTextures {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("TextTextures")
.field("fonts", &self.rasterizer.font_count())
.field("texts", &self.entries.len())
.finish_non_exhaustive()
}
}
impl Default for TextTextures {
fn default() -> Self {
Self::new()
}
}
impl TextTextures {
#[must_use]
pub fn new() -> Self {
Self {
rasterizer: TextRasterizer::empty(),
font_set: NEXT_FONT_SET.fetch_add(1, Ordering::Relaxed),
entries: HashMap::new(),
frame: 0,
}
}
pub fn add_font(
&mut self,
family: impl Into<String>,
bytes: impl Into<Arc<[u8]>>,
face_index: u32,
) -> Result<(), TextError> {
self.rasterizer
.add_font(TextFont {
family: family.into(),
bytes: bytes.into(),
face_index,
})
.map_err(TextError::Raster)?;
self.entries.clear();
self.font_set = NEXT_FONT_SET.fetch_add(1, Ordering::Relaxed);
Ok(())
}
#[must_use]
pub fn font_count(&self) -> usize {
self.rasterizer.font_count()
}
pub fn prepare(
&mut self,
renderer: &Renderer,
frame: &FrameDesc,
size: RenderSize,
) -> Result<(), TextError> {
self.frame += 1;
for (text, style) in frame_texts(frame) {
let key = text_key(text, style, size);
if let Some(entry) = self.entries.get_mut(&key) {
entry.last_used = self.frame;
continue;
}
let request = text_request(text, style, size, key);
let raster = self
.rasterizer
.rasterize(&request)
.map_err(TextError::Raster)?;
self.rasterizer.invalidate(key);
let texture_size = &raster.descriptor.texture;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let margin = [raster.descriptor.layout_x, raster.descriptor.layout_y]
.map(|margin| (margin as u32).saturating_sub(request.decoration_margin()));
let texture = renderer
.upload_rgba8(
RenderSize::new(texture_size.width, texture_size.height)
.map_err(TextError::Render)?,
&raster.pixels.rgba8,
)
.map_err(TextError::Render)?;
self.entries.insert(
key,
TextEntry {
texture,
width: texture_size.width,
height: texture_size.height,
margin,
last_used: self.frame,
},
);
}
while self.entries.len() > RETAINED_TEXTS {
let Some(oldest) = self
.entries
.iter()
.filter(|(_, entry)| entry.last_used < self.frame)
.min_by_key(|(_, entry)| entry.last_used)
.map(|(key, _)| *key)
else {
break;
};
self.entries.remove(&oldest);
}
Ok(())
}
pub fn prepare_at(
&mut self,
renderer: &Renderer,
project: &crate::EngineProject,
time: alpha2_time::MediaTime,
size: RenderSize,
) -> Result<(), crate::EngineError> {
if !project.draws_text() {
return Ok(());
}
let frame = project.evaluate(time)?;
self.prepare(renderer, &frame, size)
.map_err(crate::EngineError::Text)
}
pub fn measure(
&self,
text: &str,
style: &TextStyle,
size: RenderSize,
) -> Result<TextBounds, TextError> {
measure_text(&self.rasterizer, text, style, size)
}
#[must_use]
pub fn get(&self, key: u64) -> Option<TextTexture<'_>> {
self.entries.get(&key).map(|entry| TextTexture {
texture: SourceView::from(&entry.texture),
width: entry.width,
height: entry.height,
margin: entry.margin,
cache_key: Some(self.cache_key(key)),
})
}
fn cache_key(&self, key: u64) -> RenderCacheKey {
let high = TEXT_CACHE_DOMAIN ^ self.font_set;
RenderCacheKey::from_value((u128::from(high) << 64) | u128::from(key))
}
}
pub(crate) fn frame_texts<'f>(
frame: &'f FrameDesc<'_>,
) -> impl Iterator<Item = (&'f str, &'f TextStyle)> {
let captions = frame
.captions
.iter()
.map(|caption| (caption.text, caption.style));
let generators = frame
.visual
.iter()
.filter(|clip| frame.draws(clip))
.flat_map(|clip| {
let steps = clip
.composition
.iter()
.chain(&clip.source_composition)
.flat_map(|composition| &composition.plan().steps)
.filter_map(|step| match &step.op {
StepOp::Generator(generator) => Some(generator),
_ => None,
});
clip.generator.into_iter().chain(steps)
})
.filter_map(|generator| match generator {
Generator::Text { text, style } => Some((text.as_str(), style)),
_ => None,
});
captions.chain(generators)
}
pub struct FramesWithText<'a> {
frames: &'a dyn crate::FrameProvider,
texts: &'a TextTextures,
}
impl<'a> FramesWithText<'a> {
#[must_use]
pub fn new(frames: &'a dyn crate::FrameProvider, texts: &'a TextTextures) -> Self {
Self { frames, texts }
}
}
impl crate::FrameProvider for FramesWithText<'_> {
fn texture_for(
&self,
source: &alpha2_media::SourceId,
time: alpha2_time::MediaTime,
) -> Option<SourceView<'_>> {
self.frames.texture_for(source, time)
}
fn pending_source_work(
&self,
source: &alpha2_media::SourceId,
time: alpha2_time::MediaTime,
) -> Option<&alpha2_render::PendingSourceWork> {
self.frames.pending_source_work(source, time)
}
fn source_key(
&self,
source: &alpha2_media::SourceId,
time: alpha2_time::MediaTime,
) -> Option<alpha2_render::RenderCacheKey> {
self.frames.source_key(source, time)
}
fn text(&self, key: u64) -> Option<TextTexture<'_>> {
self.texts.get(key)
}
}
#[must_use]
pub fn text_key(text: &str, style: &TextStyle, size: RenderSize) -> u64 {
let mut hash = 0xcbf2_9ce4_8422_2325_u64;
let mut feed = |bytes: &[u8]| {
for byte in bytes {
hash = (hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3);
}
};
feed(text.as_bytes());
feed(&[0xff]);
feed(style.font_family.as_deref().unwrap_or_default().as_bytes());
feed(&[0xff, u8::from(style.font_family.is_some())]);
for value in [
style.font_size,
style.line_height,
style.max_width,
style.position.x,
style.position.y,
style.anchor.x,
style.anchor.y,
] {
feed(&value.to_bits().to_le_bytes());
}
feed(&rgba8(style.color));
feed(&[match style.align {
TextAlign::Start => 0,
TextAlign::Center => 1,
TextAlign::End => 2,
}]);
match style.outline {
Some(outline) => {
feed(&[1]);
feed(&rgba8(outline.color));
feed(&outline.width.to_bits().to_le_bytes());
}
None => feed(&[0]),
}
match style.background {
Some(background) => {
feed(&[1]);
feed(&rgba8(background.color));
feed(&background.padding.to_bits().to_le_bytes());
}
None => feed(&[0]),
}
feed(&size.width.to_le_bytes());
feed(&size.height.to_le_bytes());
hash.max(1)
}
#[allow(clippy::cast_precision_loss)]
pub fn measure_text(
rasterizer: &TextRasterizer,
text: &str,
style: &TextStyle,
size: RenderSize,
) -> Result<TextBounds, TextError> {
let request = text_request(text, style, size, text_key(text, style, size));
let measure = rasterizer.measure(&request).map_err(TextError::Raster)?;
let (width, height) = (size.width as f32, size.height as f32);
let margin = measure.decoration_margin as f32;
let (box_width, box_height) = (measure.box_width as f32, measure.box_height as f32);
let origin = [
style.position.x * width - style.anchor.x * (box_width + 2.0 * margin) + margin,
style.position.y * height - style.anchor.y * (box_height + 2.0 * margin) + margin,
];
let rect = |[left, top, right, bottom]: [f32; 4]| CanvasRect {
left: (origin[0] + left) / width,
top: (origin[1] + top) / height,
right: (origin[0] + right) / width,
bottom: (origin[1] + bottom) / height,
};
Ok(TextBounds {
layout: rect([-margin, -margin, box_width + margin, box_height + margin]),
content: rect(measure.content),
ink: measure.ink.map(rect),
line_count: measure.line_count,
})
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn rgba8(color: Rgba) -> [u8; 4] {
[color.red, color.green, color.blue, color.alpha]
.map(|value| (value.clamp(0.0, 1.0) * 255.0).round() as u8)
}
#[allow(clippy::cast_precision_loss)]
fn text_request(text: &str, style: &TextStyle, size: RenderSize, key: u64) -> TextRasterRequest {
let font_size = (style.font_size * size.height as f32).clamp(1.0, MAX_FONT_PIXELS);
TextRasterRequest {
text: text.to_owned(),
font_size,
max_width: (style.max_width * size.width as f32).max(1.0),
line_height: style.line_height,
align: match style.align {
TextAlign::Start => alpha2_media::TextAlign::Start,
TextAlign::Center => alpha2_media::TextAlign::Center,
TextAlign::End => alpha2_media::TextAlign::End,
},
color: rgba8(style.color),
emoji_policy: EmojiPolicy::Color,
content_version: key,
outline: style.outline.map(|outline| alpha2_text::TextOutline {
color: rgba8(outline.color),
width: (outline.width * font_size).clamp(f32::MIN_POSITIVE, MAX_OUTLINE_PIXELS),
}),
background: style
.background
.map(|background| alpha2_text::TextBackground {
color: rgba8(background.color),
padding: (background.padding * font_size).clamp(0.0, MAX_PADDING_PIXELS),
}),
font_family: style.font_family.clone(),
}
}
#[cfg(test)]
#[path = "text_tests.rs"]
mod tests;