Skip to main content

alpha2_engine/
text.rs

1//! Text rendering: caption tracks' cues and text generators, rasterized by
2//! the portable text host.
3//!
4//! The engine draws text itself, from font bytes its host registers once,
5//! so a browser preview and a native export of one project, given the same
6//! fonts, draw the same text pixels. A host keeps one [`TextTextures`]
7//! beside its frame provider, calls [`TextTextures::prepare`] for each frame
8//! before rendering it, and answers [`crate::FrameProvider::text`] from it.
9//! A caption is composited as a layer between tracks; a text generator's
10//! texture is placed on the transparent picture the generator draws.
11
12use 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
23/// The high 64 bits of a text's result cache key, before its font set is
24/// mixed in: font sets are counted from 1 and stay far below this, so those
25/// bits are never zero, as a host's source keys ([`RenderCacheKey::source`])
26/// leave them, and the two never meet.
27const TEXT_CACHE_DOMAIN: u64 = 0x6361_7074_696f_6e73;
28
29/// The next font set's identity: each store, and each font a store adds,
30/// takes one, so no two font sets that might draw text differently in one
31/// process share a text cache key.
32static NEXT_FONT_SET: AtomicU64 = AtomicU64::new(1);
33
34/// How many rasterized texts a host keeps between frames; the current
35/// frame's are kept however many it shows.
36const RETAINED_TEXTS: usize = 16;
37
38/// The largest font, outline, and padding the text host draws, in pixels.
39const MAX_FONT_PIXELS: f32 = 2_048.0;
40const MAX_OUTLINE_PIXELS: f32 = 64.0;
41const MAX_PADDING_PIXELS: f32 = 256.0;
42
43/// One prepared text: its texture, pixel size, and where its layout box
44/// lies in it.
45#[derive(Clone, Copy, Debug)]
46pub struct TextTexture<'a> {
47    /// Premultiplied, sRGB-encoded RGBA, as every source texture.
48    pub texture: SourceView<'a>,
49    /// Width in pixels.
50    pub width: u32,
51    /// Height in pixels.
52    pub height: u32,
53    /// The margin, in pixels, the same on both sides of each axis, that the
54    /// texture adds around the text and its decorations for ink reaching
55    /// past them: an accent above the first line, say. Placement leaves it
56    /// out, so such ink never moves the text.
57    pub margin: [u32; 2],
58    /// The key the renderer's result cache holds results drawn from this
59    /// texture under, or `None` to keep every frame showing it out of that
60    /// cache. It is the provider's promise that the texture's pixels depend
61    /// on nothing it leaves out, as [`crate::FrameProvider::source_key`] is
62    /// for a source: [`TextTextures`] keys each text by its [`text_key`] and
63    /// the fonts it was drawn with.
64    pub cache_key: Option<RenderCacheKey>,
65}
66
67/// Why text could not be prepared.
68#[derive(Clone, Debug, Eq, PartialEq)]
69#[non_exhaustive]
70pub enum TextError {
71    /// The text host refused: no fonts registered, a malformed font, or a
72    /// text too large to rasterize.
73    Raster(TextRasterError),
74    /// The rasterized text could not be uploaded.
75    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
100/// Fonts and the texts rasterized with them, keyed by content and render
101/// size.
102pub struct TextTextures {
103    rasterizer: TextRasterizer,
104    /// The identity of the fonts the texts are drawn with, from
105    /// [`NEXT_FONT_SET`].
106    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    /// No fonts and no texts.
129    #[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    /// Appends an OpenType font to the fallback stack text draws with. Texts
140    /// already rasterized are dropped.
141    ///
142    /// # Errors
143    /// Returns [`TextError::Raster`] for malformed font bytes.
144    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        // The same text may now draw other glyphs.
159        self.font_set = NEXT_FONT_SET.fetch_add(1, Ordering::Relaxed);
160        Ok(())
161    }
162
163    /// Fonts in the fallback stack.
164    #[must_use]
165    pub fn font_count(&self) -> usize {
166        self.rasterizer.font_count()
167    }
168
169    /// Rasterizes and uploads every text `frame` shows at `size` that is not
170    /// already held: its captions and the text generators its clips draw.
171    /// Forgets the least recently shown beyond a small number.
172    ///
173    /// # Errors
174    /// Returns [`TextError`] when a text cannot be rasterized or uploaded;
175    /// with no fonts registered, only if the frame shows one.
176    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            // Each text is rasterized once per key; keep no copy.
195            self.rasterizer.invalidate(key);
196            let texture_size = &raster.descriptor.texture;
197            // The anchor places the decorated text; the margin, which holds only ink reaching past
198            // it, takes no part in placement.
199            #[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    /// [`Self::prepare`] for the frame of `project` at `time`, rendered at
236    /// `size`; nothing to do, and no evaluation, when the project draws no
237    /// text ([`crate::EngineProject::draws_text`]).
238    ///
239    /// # Errors
240    /// Returns [`crate::EngineError::Evaluate`] or
241    /// [`crate::EngineError::Text`].
242    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    /// The prepared text with `key`, as [`text_key`] names it.
258    #[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    /// The result cache key of the text with `key`, drawn with these fonts:
270    /// the font set in the high half, beside the text domain, and the text
271    /// in the low half, so two keys are equal only for the same text drawn
272    /// with the same fonts.
273    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
279/// Every text `frame` may draw, with its style: each caption, then each text
280/// generator of a clip it draws, the clip's own source or a step of its
281/// compositions. A text drawn twice is named twice.
282pub(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
312/// A host's frame provider together with its text store: frames come from
313/// the one and text from the other.
314pub struct FramesWithText<'a> {
315    frames: &'a dyn crate::FrameProvider,
316    texts: &'a TextTextures,
317}
318
319impl<'a> FramesWithText<'a> {
320    /// `frames` with the texts `texts` holds.
321    #[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/// A text's identity at a render size: the text, its style, and the size,
358/// hashed (FNV-1a). Never zero.
359#[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    // Family names cannot hold 0xff (they are UTF-8), so the text and family stay apart.
370    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/// A straight, sRGB-encoded color as bytes.
411#[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/// The text host request for `text` at `size`: sizes scaled from canvas
418/// fractions to pixels, within what the text host draws.
419#[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;