Files
effectcraft/crates/render/src/text.rs
T
Valentin KleinandClaude Opus 5.5 84382487bb Disk cache: entries keep the files they read, keys from memory (#3184) (#3191)
* Disk cache: record the source files each frame reads

Part of #3184 (step 1): renders now report which footage files they read,
each with its identity (size and modification time) as read. Nothing uses
the list yet: images, cache keys and cache behaviour are unchanged.

- render: `reads` module with `FileStamp`, `ReadFile`, `ReadFiles` (sorted,
  deduplicated, bounded; marked incomplete when it can't vouch for every
  file) and the thread-safe `ReadLog`. `Renderer::reads` collects a frame's
  files; `FootageSource::recording` lets a source note what it reads.
  `file_stamp` uses the same identity.
- Layer cache entries keep the files their buffer was made of and report
  them on every hit; derived entries (styles, blend space, flattened) keep
  their base's. Buffers from the disk cache or the browser store don't know
  their files yet and mark the list incomplete.
- Media pool: every file read notes its identity, taken from the file it
  opens. Decoded frames, opened movies, GIFs, models and EXR channels keep
  it with the cache entry, so a frame served from memory reports its file
  without touching the disk.
- Viewer frame worker collects each frame's list and carries it to where
  the disk entry is written (step 2 stores it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Automation: confined footage passes on the files it reads

Part of #3184: ConfinedFootage forwards FootageSource::recording to the
source it wraps, so renders of an automation session list their files
instead of coming back incomplete. The read tests also render through the
viewer's CancellableFootage wrapper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Disk cache: frame entries keep their source files, comp key from memory

Part of #3184 (step 2). Fixes #3175: opening a comp no longer stamps every
footage file of the project before its first frame can show.

- The comp's content key (`disk_cache::comp_content_key`) is taken from the
  project in memory alone: no footage or proxy file is stamped, no
  `VirtualFileId` is asked. `comp_content_key_with` is gone; `frame_key`
  hashes a new entry version, so entries of the earlier format are never
  looked up again (LRU evicts them).
- Frame entries (formats 2 and 3) store the files the frame read
  (`EntryFiles`: size and modification time as read; size and content hash
  through `VirtualFileId` for files from memory; missing files), then the
  pixels. Formats 0 and 1 read as misses and are deleted.
- Check on hit: `get_frame`/`get_frame_f16` look only at the listed files
  (one metadata call each) on the frame worker. A changed, back or unreadable
  file, or a damaged entry, is a miss: the entry is deleted and the frame
  rendered and stored again.
- A frame whose file list is incomplete is shown but not stored. The RAM
  preview keeps each frame's files, so `persist_missing` writes only frames
  with a complete list.
- Remote frame worker: `FrameServer` records each frame's reads
  (`take_files`); the web worker stores them with the entry and the page
  checks them through the file table before using it. The browser's media
  epoch existed only for the old key and is removed. Export does not use
  frame entries.
- The cache indicator still only asks whether an entry exists.

Tests: entry format round trip and damage, the check per file, and in the
viewer: a changed file re-renders exactly its frames (and a reopen reads the
rest from disk); a frame checks only its own files in a project with a large
unused sequence and the key looks at no file; missing, unreadable files and
damaged entries never serve a stale frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Disk cache: layer entries keep their source files, no footage salt

Part of #3184 (step 3 of 4).

Layer buffers on disk (and in the browser's layer store) now carry the
files they were made of, like frame entries since step 2, and are checked
against exactly those files when used. The project-wide footage salt is
gone: nothing stamps every footage file any more.

- Layer entry format 4: the buffer's EntryFiles, then the pixels. A hit
  checks only the listed files (metadata, or the VirtualFileId hook for
  files from memory; Missing must still be missing); a change, a damaged
  entry or the old format 0 is a miss and the entry is deleted, so the
  re-rendered buffer replaces it. A hit reports its file list, so frames
  made of buffers from disk are complete and get stored again.
- Buffers whose files can't all be named are not written.
- Layer disk keys: layer_disk_key = hash of the layer entry version, the
  renderer version and the layer cache key (which names the footage items
  with their paths and settings, from memory).
- PrefetchStore checks on use; an entry made of changed files is dropped
  and forgotten, so the new buffer is queued over it. FrameServer passes
  its virtual-file hook to its layer cache.
- Removed: footage_salt(_with), file_stamp, footage_stamp,
  set_disk_salt, set_store_salt, Session::refresh_disk_salt,
  poll_disk_salt, footage_fingerprint, disk_salt, disk_salt_background,
  disk_salt_job, DiskSaltJob, the UI's per-frame poll, the salt in
  FrameServer and in the availability worker (with its cache_enabled
  plumbing). The disk cache is attached as soon as it is configured.
- Docs: architecture.md, web.md.

Tests: layer entries check their files (size, time, missing/back,
folder, damaged, old format, incomplete); virtual files; prefetch store;
media tests with a real pool across restarts (only the changed file is
read, projects with the same item ids never share an entry, missing,
unreadable and damaged never serve stale buffers); engine tests that the
cache is attached at once and edits do no file work (#1926, #3106).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Disk cache: no-salt worker test, sequence paths in the frame-blend key

Part of #3184 (step 3 of 4).

- A frame-blended footage frame's layer-cache key now hashes the
  sequence's file list: with no project-wide salt, two projects whose
  items share an id and a first frame must not share its disk entry.
- Test: a frame worker syncing, patching and rendering a project with an
  unused 5000-frame sequence asks its file hook only about the file its
  buffer reads (the salt asked about every file at every sync).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Disk cache: the comp key covers what its expressions can read

With an expression anywhere in the project, every comp's disk key hashed
every item of the project, so any edit (another comp, an unused footage
item) threw away every comp's frames on disk. The key now covers what the
comp can draw plus what the expressions among it can reach
(Project::pixel_reach):

- without expressions: the items the comp draws, without silent layers,
  plus the footage its precomp layers swap in (Media Replacement);
- with expressions: every layer of the comps they reach, their markers,
  the comps and footage they name with string literals (comp("Name"),
  footage("Name")) together with which items have those names now, and
  the footage named like a reached source (its data is read by name);
- the whole project when an expression computes a name, uses the object
  model's constructors or internals, dynamic code, computed keys or
  writes, or text the scanner can't read for sure (regex literals,
  template strings, escapes): expression_reach errs towards reaching more.

Markers count only where an expression can read them (#1083). Memory
only, no file is looked at.

Tests: scanner and reach unit tests in the project crate; engine tests
for the key (own-comp expressions keep the key through edits elsewhere,
comp("Other") follows Other and not a third comp, a second comp taking
the name counts, a computed name counts every item, markers read from
another comp); a ui-egui test that the disk frames outside an expression
layer are carried when the comp it names changes.

Part of #3184. Part of #1492.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Disk cache: Reload Footage drops the entries of its files, docs

File > Reload Footage now deletes the frame and layer entries whose file
lists name the reloaded item's files (DiskCache::drop_files), so a file
replaced by one with the same size and modification time, which the
check on hit can't tell apart, is read again. The index knows keys, not
files: the writer thread reads the files section at the start of each
frame and layer entry (not the pixels) after the writes queued before
it, and deletes the entries that list one of the files. Until it is
done, an entry made of one of them is a miss and frames aren't linked to
new keys. The entries are gone for good, so they don't come back after a
restart. Automatic reloads don't drop anything: they follow a changed
stamp, which the check on hit catches. The browser needs nothing (its
file identities are content hashes).

Removed: disk_cache::seal, which steps 2 and 3 left without callers (the
tests use seal_format). Updated the module, seal_format and forget_media
docs, and docs/architecture.md / docs/web.md describe the final design:
what is cached, keys from memory, file lists checked on hit, nothing
scans the project, incomplete lists stay in memory, Reload Footage drops
entries, edits keep the frames they can't change.

Tests: render unit tests (same size and time passes the check but not
the drop; other files' entries stay; an entry queued before the drop is
dropped; a layer entry too; written again afterwards it is used; the
drop survives a restart; a pending drop is a miss), and an engine test
through file.reloadFootage.

Closes #3184. Part of #1492.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Disk cache: file stamps keep the whole modification time

A file's modification time was nanoseconds since 1970 as u64: a time before 1970
read as "no time" (two such files of one size looked alike) and times after 2554
were truncated. It is now i128 nanoseconds, negative before the epoch; None only
means the file system has no time. The content-key hash writes a presence tag and
the full value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Automation: a refused footage read leaves the render's file list incomplete

Footage outside the read roots shows nothing and was noted as no file at all, so a
picture made without it had a complete, empty list and could be kept on disk for
a later session with other roots. A refused read during `recording` now marks the
list incomplete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Render: a buffer kept by another worker comes with its own files

LayerCache::insert returns the files of the buffer it keeps, and Renderer::keep
notes them in the render's log and returns them, so a render that uses another
worker's buffer lists that buffer's files (a file changed between the two reads
then has two identities and the list is incomplete).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Render: sampleImage and text fonts note the files they read

EvalCtx carries the render's read log. A sampleImage render records its footage
reads, keeps them with its per-thread cached buffer and notes them in the
evaluating render's log on every use. Text notes the font files it is drawn with,
with the identity their bytes were read with; a family that isn't installed, a
fallback or missing glyph, or a font from memory leaves the list incomplete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Disk cache: frame entries store the whole modification time

A frame entry's file list stores a file's modification time as i128 nanoseconds
since 1970 (negative before), as the file stamps now keep it; the frame entry
format version goes to frame-entry-files-2 so entries in the old layout are not
read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Disk cache: layer entries store the whole modification time

Layer entries share the frame entries' file list layout, which now stores the
full i128 modification time: the layer entry format version goes to
layer-entry-files-2. The module doc names the font files text is drawn with
among an entry's source files, and what keeps a list incomplete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Layer cache: buffers that read other layers are keyed by the switches passed down

A layer whose effect reads another layer (Gradient Wipe, Set Matte, Blend…) gets
that layer drawn by its own renderer. Through a precomp layer, Switches Affect
Nested Comps, the switches inherited from the precomp layers above and motion
blur on change those pixels, but the key held only the layers' own switches: a
buffer made with the setting on was reused (from memory or the disk cache) with
it off. Such layers now derive their key with what the renderer passes down.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Layer cache: masked buffers are keyed by the mask motion blur gate

A mask with Mask Motion Blur On blurs even when the layer's own switch is off,
as long as the render's gate is open (Render Settings motion blur, and with
Switches Affect Nested Comps the precomp layers above). The key held the
layer's own motion blur state only, so a buffer blurred with the gate open was
reused with it closed (and the other way round), also from the disk cache.
Masked layer, input and footprint keys now tell a closed gate apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Render: cached buffers list what their expressions and substituted characters read

- Buffers made under `Renderer::recorded` (layer content, a Time effect's input, an adjustment
  layer's footprint) evaluate their masks, shapes and effects with the context that notes into
  the buffer's own read log, so a file an expression reads (`sampleImage`, also at shutter times
  mask motion blur adds and the cache key doesn't see) is in the buffer's files.
- A styled layer served from the cache lists the files of the cached body and passes too, so
  the buffers made of it (flattened, blending space) don't get a complete list when one of them
  came with an incomplete one.
- A character put in by Character Offset or Character Value without a relayout notes the font
  it is drawn with; a fallback face (or none) leaves the list incomplete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Render: text lists the font file of a run that draws no glyph; tests clean their temp dirs

An empty first paragraph draws no glyph, but in a box its face sets the
first baseline that places every later line. The read list took faces only
from glyphs, so it could be complete without that file. Note each style
run's resolved face as well.

The font-reads and confine tests use tempfile::tempdir(): a fresh
directory per run, removed on panic too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Disk cache: Reload Footage closes the gaps around its drop (#3184)

- A reload while the disk cache is off is kept on the session
  (pending_disk_drops) and applied when the cache is switched on, before
  anything is read from it.
- Each render takes the cache's drop generation before it reads (frame
  worker; layer cache at the miss). An entry listing a file dropped after
  that is refused under the queue's lock, so a render that was under way
  during the reload can't store pixels from before it. Such a frame is
  shown with an incomplete file list, so persist_missing skips it too.
  The per-file record is bounded (past it, everything older is refused).
- An entry read while its drop was pending is a miss even when the
  writer ends the sweep between the read and the check (a stale hit
  found under load): the pending drops and the generation are taken
  before the entry is read.
- Test: a reopened project serves its frame from disk with no source file
  opened or decoded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Disk cache: RAM frames keep the drop generation their render started at

Reload Footage empties the RAM preview, but an idle pass of
persist_missing may already hold a frame from before the reload. It
took the cache's drop generation when it ran, after the drop, so the old
pixels could be written back and, with the file's size and time
unchanged, served again after a restart.

Each RAM frame now keeps the disk cache its render started with and
that cache's drop generation then. persist_missing writes a frame only
to that cache, with that generation, and skips it once one of its files
was dropped since; a frame rendered without that cache is not written
from there.

Tests use tempfile directories (the layer drop test, the frame_files
folders under target/).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Doc comments for the functions this PR touches

* Disk cache: cap the free-space reserve at 10 GB, skip encodes the limit refuses

The reserve the cache leaves free on its disk was max(2 GB, 5% of the
disk) (#2551). On a 4 TB disk with 190 GB free that is 200 GB, so a
cache set to 20 GB had a limit of 0: every write was refused, the cache
never held anything, and only a log warning said so.

The reserve is now clamp(5% of the disk, 2 GB, 10 GB). It is there to
keep the cache from filling the disk, not to keep a share of a large
disk free: 10 GB leaves the system room to work on any disk, a small
disk still keeps 2 GB, and a 100 GB disk keeps 5 GB as before. After
Effects has only a maximum cache size and no reserve; a clamp keeps
the floor for small disks, where a fixed 10 GB would take most of the
space.

Writes the limit refuses cost no work: DiskCache::accepts_writes (one
atomic read) is checked before a frame is converted (put_disk_frame)
and before frames and layers are compressed (put_frame, put_frame_f16,
put_layer). The frame workers spent about 8 ms per frame and 14 ms per
layer on encodes that were then refused. persist_missing returns at
once at limit 0 instead of converting the RAM frames on every idle pass.

Settings > Disk shows the limit in effect in MB under Maximum Disk
Cache Size, with a note when free disk space lowers it
(settings.disk.cacheLimit).

* Disk cache: refuse writes below 64 MB of room, show this disk's reserve

Review follow-ups to the reserve fix:

- accepts_writes also refuses while free disk space leaves less than
  64 MB of room (a limit the setting asks for is always used): a few
  frames would fill it, and the rest would be compressed only to be
  refused.
- While writes are refused the idle passes ask for no repaint, so the
  UI keeps a frame coming every 5 s: the free space is read again and
  the RAM frames are written once there is room.
- Settings > Disk names the space kept free on this disk (the reserve
  the last reading applied; the rule from MIN/MAX_DISK_RESERVE when it
  is unknown), and the "no room" note follows accepts_writes.
- The settings.disk.cacheLimit automation label is machine-readable
  and the same in every language: limitMb, maxMb, reserveMb,
  spaceLimited, writes.
- cargo fmt.

* Disk cache: follow 3D model sources in the comp's reach

The reach walk followed comp, footage and solid sources but not
LayerSource::Model. An expression reading a model layer's source
(thisComp.layer("Model").source.name) left the model item out of the
comp's content key, so renaming the model kept the key and the old
frames on disk were served. Every source item is followed now
(LayerSource::item), which also counts the model when no expression
reads it (Advanced 3D draws it).

The same omission is fixed in the RAM preview's content walk
(frames.rs) and in the layer key's walk of referenced layers
(cache/dependencies.rs).

* Disk cache: a render's drop generation travels with its reads; no disk entries for Expression Selectors

Drop generation per render: the layer cache recovered a buffer's drop
generation from the latest miss of its key, so a second render of the
key that started after File > Reload Footage lent its generation to a
render still running from before, and that render's buffer (the file as
it was) was written after the sweep. Now Renderer::recorded starts each
buffer's read log at the store's drop generation before anything is
read (ReadLog::starting_at); ReadFiles carries the earliest generation
through merges, so derived buffers (styles, the blend-space copy, the
flattened layer) and frames that use a buffer from memory carry it too.
LayerCache::insert keeps a buffer made of a file dropped since its
reads began neither on disk nor in memory; a list that doesn't say
counts as made before every drop. DiskCache::dropped_since takes the
list's own generation too. missed_at keeps only the miss time.

Expression Selectors: the layer key evaluates a text Expression
Selector's amount for the first character only, so a per-character
dependency such as textIndex == 1 ? 0 : footage("ref.png").width was
not in the key, and after the footage item's width changed a reopened
session reused the old layer entry. A text layer with an Expression
Selector now marks its reads unkeyed (ReadFiles::is_unkeyed), which
merges like the generation; such buffers stay in memory and are never
written to disk. Frame entries are unaffected: the comp's frame key
hashes what the expressions name. The equality and hash of ReadFiles
ignore both new fields.

* Disk cache: never serve a RAM layer buffer made before a drop of its files

A render that passed the drop check could sit in put_layer's encode
while Reload Footage cleared the layer cache and dropped the file, then
publish its old pixels to memory after the purge; get, and a fresh
render's insert, then served them. The disk-hit promotion went through
the same insert_mem.

Every way the cache hands out a RAM buffer (get, the kept-first path of
insert, an existing entry in insert_mem) now checks the entry's files
against the store's drops from their own generation (ReadFiles::since):
an entry made before a drop of one of its files is forgotten and the
lookup misses, so a fresh render's buffer replaces it. insert_mem also
re-checks a buffer just before publishing it.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 02:30:59 +01:00

1174 lines
50 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Text layers: Source Text layout → text animators (Range / Wiggly / Expression selectors,
//! combined by mode) → per-character transforms and paint → outlines, optionally placed on a mask
//! path (Path Options) and drawn with More Options (anchor point grouping, fill & stroke order,
//! inter-character blending). Per-character 3D characters are handed to the 3D compositor as
//! separate planes (see [`per_char_planes`]).
use std::borrow::Cow;
use effectcraft_color::BlendMode;
use effectcraft_effects::Buf;
use effectcraft_geom::{Mat3, Mat4, Vec3, vec2, vec3};
use effectcraft_keyframe::{Justify, TextDoc, Value};
use effectcraft_path::{BezPath, FillRule, StrokeStyle};
use effectcraft_project::{Layer, PropGroup};
use effectcraft_raster::{Image, Mask};
use effectcraft_text::path_text::{self, PathMeasure};
use effectcraft_text::selectors::{self, BasedOn, Mode, Range, Shape, Wiggly};
use effectcraft_text::{CharGlyph, TextLayout, char_glyph_style, layout_doc};
use crate::eval::EvalCtx;
/// Evaluated per-character animation state (sums of every animator, weighted by selection).
#[derive(Clone, Copy, Debug)]
pub struct CharXf {
pub offset: [f64; 3],
pub anchor: [f64; 3],
pub scale: [f64; 3],
/// Z rotation (2D rotation).
pub rotation: f64,
pub rotation_x: f64,
pub rotation_y: f64,
pub skew: f64,
pub skew_axis: f64,
pub opacity: f64,
pub fill: Option<[f32; 4]>,
pub fill_k: f32,
/// Hue (degrees), saturation and brightness (percent) offsets.
pub fill_hsb: [f64; 3],
pub fill_opacity: f64,
pub stroke: Option<[f32; 4]>,
pub stroke_k: f32,
pub stroke_hsb: [f64; 3],
pub stroke_opacity: f64,
pub stroke_width: f64,
/// Tracking (pixels: an animator's Tracking Amount, as After Effects) added before / after
/// the character.
pub track_before: f64,
pub track_after: f64,
/// Line Anchor (percent) and the selection weight it was set with.
pub line_anchor: f64,
pub line_anchor_k: f64,
pub line_spacing: [f64; 2],
pub char_offset: f64,
/// Character Value (code point) and its selection.
pub char_value: Option<(f64, f64)>,
pub char_range: u32,
pub char_align: u32,
pub blur: [f64; 2],
/// Variable Font Axes offsets (axis tag, user units), the first `n_axes` used.
pub axes: [([u8; 4], f64); 4],
pub n_axes: u8,
/// Front / Bevel / Side / Back Color of the extruded character (Advanced 3D), by
/// `effectcraft_model::extrude::Surface` index, and the selection each was set with.
pub surface: [Option<[f32; 4]>; 4],
pub surface_k: [f32; 4],
}
impl CharXf {
/// The surface colours of an extrusion painted `base` (the character's fill or stroke):
/// each animated surface colour blended over `base` by its selection.
pub fn surface_colors(&self, base: [f32; 4]) -> crate::shapes::SurfaceColors {
std::array::from_fn(|i| {
let (c, k) = (self.surface.get(i).copied().flatten()?, self.surface_k.get(i).copied().unwrap_or(0.0));
Some(std::array::from_fn(|d| base[d] + (c[d] - base[d]) * k))
})
}
/// The variable-font axis offsets that move the outline.
pub fn axis_deltas(&self) -> Vec<(String, f32)> {
self.axes[..self.n_axes as usize]
.iter()
.filter(|(_, d)| d.abs() > 1e-6)
.map(|(t, d)| (String::from_utf8_lossy(t).trim_end().to_string(), *d as f32))
.collect()
}
fn add_axis(&mut self, tag: [u8; 4], d: f64) {
if let Some(a) = self.axes[..self.n_axes as usize].iter_mut().find(|a| a.0 == tag) {
a.1 += d;
} else if (self.n_axes as usize) < self.axes.len() {
self.axes[self.n_axes as usize] = (tag, d);
self.n_axes += 1;
}
}
}
/// Animator property match ids of Front / Bevel / Side / Back Color, in
/// `effectcraft_model::extrude::Surface::ALL` order.
pub const SURFACE_ANIM_PROPS: [&str; 4] = ["frontColor", "bevelColor", "sideColor", "backColor"];
/// Animator property match id of a Variable Font Axes property (`axis_wght`).
pub const AXIS_PREFIX: &str = "axis_";
impl Default for CharXf {
fn default() -> Self {
CharXf {
offset: [0.0; 3],
anchor: [0.0; 3],
scale: [100.0; 3],
rotation: 0.0,
rotation_x: 0.0,
rotation_y: 0.0,
skew: 0.0,
skew_axis: 0.0,
opacity: 100.0,
fill: None,
fill_k: 0.0,
fill_hsb: [0.0; 3],
fill_opacity: 100.0,
stroke: None,
stroke_k: 0.0,
stroke_hsb: [0.0; 3],
stroke_opacity: 100.0,
stroke_width: 0.0,
track_before: 0.0,
track_after: 0.0,
line_anchor: 0.0,
line_anchor_k: 0.0,
line_spacing: [0.0; 2],
char_offset: 0.0,
char_value: None,
char_range: 0,
char_align: 1,
blur: [0.0; 2],
axes: [([0; 4], 0.0); 4],
n_axes: 0,
surface: [None; 4],
surface_k: [0.0; 4],
}
}
}
/// The unit a glyph belongs to for a selector's Based On, and the unit count.
fn unit_of(based: BasedOn, g: &CharGlyph, lay: &TextLayout) -> (usize, usize) {
match based {
BasedOn::Characters => (g.char_index, lay.chars),
BasedOn::CharactersExcludingSpaces => (g.char_index_no_space.min(lay.chars_no_space.saturating_sub(1)), lay.chars_no_space),
BasedOn::Words => (g.word_index, lay.words),
BasedOn::Lines => (g.line_index, lay.lines),
}
}
fn layer_seed(layer: &Layer, seed: f64) -> u64 {
(seed as i64 as u64) ^ layer.id.0.wrapping_mul(0x9E37_79B9)
}
/// Range Selector parameters at the context time (fractions of `n` units).
fn range_params(ctx: &EvalCtx, layer: &Layer, sel: &PropGroup, n: usize) -> Range {
let adv = sel.sub("advanced");
let af = |m: &str, d: f64| adv.map(|a| ctx.f(layer, a, m, d)).unwrap_or(d);
let units_index = adv.map(|a| ctx.e(layer, a, "units") == 1).unwrap_or(false);
let div = if units_index { n.max(1) as f64 } else { 100.0 };
Range {
// Start and End stay within 0..100% (0..n in Index units); Offset within -100..100%.
start: ctx.f(layer, sel, "start", 0.0).clamp(0.0, div) / div,
end: ctx.f(layer, sel, "end", if units_index { n as f64 } else { 100.0 }).clamp(0.0, div) / div,
offset: ctx.f(layer, sel, "offset", 0.0).clamp(-div, div) / div,
amount: af("amount", 100.0) / 100.0,
shape: Shape::from_index(adv.map(|a| ctx.e(layer, a, "shape")).unwrap_or(0)),
smoothness: af("smoothness", 100.0) / 100.0,
ease_high: af("easeHigh", 0.0) / 100.0,
ease_low: af("easeLow", 0.0) / 100.0,
}
}
/// A Range Selector as the viewer's bars see it (#1011): Based On, whether Units is Index, and
/// Start / End / Offset as the fractions the renderer uses.
pub fn range_bars(ctx: &EvalCtx, layer: &Layer, sel: &PropGroup, lay: &TextLayout) -> (BasedOn, bool, Range) {
let adv = sel.sub("advanced");
let based = BasedOn::from_index(adv.map(|a| ctx.e(layer, a, "basedOn")).unwrap_or(0));
let index = adv.is_some_and(|a| ctx.e(layer, a, "units") == 1);
(based, index, range_params(ctx, layer, sel, lay.units(based)))
}
/// The Start / End value that puts a Range Selector bar at fraction `f` of the `n` units, with
/// Offset `offset` (a fraction): the bar's place less the Offset, within 0..100%, or in whole
/// units with Units = Index.
pub fn range_value_of(f: f64, offset: f64, units_index: bool, n: usize) -> f64 {
let v = (f - offset).clamp(0.0, 1.0);
if units_index { (v * n as f64).round() } else { v * 100.0 }
}
/// Whether property `uid` of `layer` is a Range Selector's Start, End or Offset while its Units
/// are Index: the value is then a count of characters (words, lines), not a percentage.
pub fn in_index_units(layer: &Layer, uid: effectcraft_project::Uid) -> bool {
let Some(sel) = layer.props.parent_of(uid).filter(|g| g.match_id == "rangeSelector") else { return false };
let is_range_value = sel.props().any(|p| p.uid == uid && matches!(p.match_id.as_str(), "start" | "end" | "offset"));
is_range_value && sel.prop("advanced/units").is_some_and(|u| u.value == Value::Enum(1))
}
/// Selection of every glyph by an animator's selectors, per dimension.
pub fn animator_selection(ctx: &EvalCtx, layer: &Layer, anim: &PropGroup, lay: &TextLayout) -> Vec<[f64; 3]> {
let sels: Vec<&PropGroup> = anim.sub("selectors").map(|s| s.groups().filter(|g| g.enabled).collect()).unwrap_or_default();
if sels.is_empty() {
// No selectors: every character is fully affected.
return vec![[1.0; 3]; lay.glyphs.len()];
}
let mut acc: Vec<[f64; 3]> = vec![[0.0; 3]; lay.glyphs.len()];
let lt = layer.layer_time(ctx.time).seconds();
for (si, sel) in sels.iter().enumerate() {
let (mode, based) = match sel.match_id.as_str() {
"rangeSelector" => {
let adv = sel.sub("advanced");
(adv.map(|a| ctx.e(layer, a, "mode")).unwrap_or(0), adv.map(|a| ctx.e(layer, a, "basedOn")).unwrap_or(0))
}
"wigglySelector" => (ctx.e(layer, sel, "mode"), ctx.e(layer, sel, "basedOn")),
_ => (0, ctx.e(layer, sel, "basedOn")),
};
let mode = Mode::from_index(mode);
let based = BasedOn::from_index(based);
let n = lay.units(based);
let values: Box<dyn Fn(usize, usize, [f64; 3]) -> [f64; 3]> = match sel.match_id.as_str() {
"rangeSelector" => {
let r = range_params(ctx, layer, sel, n);
let adv = sel.sub("advanced");
let perm =
adv.filter(|a| ctx.b(layer, a, "randomize")).map(|a| selectors::random_order(n, layer_seed(layer, ctx.f(layer, a, "randomSeed", 0.0))));
Box::new(move |i, n, _| {
let i = perm.as_ref().and_then(|p| p.get(i).copied()).unwrap_or(i);
[selectors::range_value(&r, i, n); 3]
})
}
"wigglySelector" => {
let w = Wiggly {
max: ctx.f(layer, sel, "maxAmount", 100.0) / 100.0,
min: ctx.f(layer, sel, "minAmount", -100.0) / 100.0,
wiggles_per_second: ctx.f(layer, sel, "wigglesPerSecond", 2.0),
correlation: ctx.f(layer, sel, "correlation", 50.0) / 100.0,
temporal_phase: ctx.f(layer, sel, "temporalPhase", 0.0),
spatial_phase: ctx.f(layer, sel, "spatialPhase", 0.0),
lock_dimensions: ctx.b(layer, sel, "lockDimensions"),
seed: layer_seed(layer, ctx.f(layer, sel, "randomSeed", 0.0)),
};
Box::new(move |i, _, _| [0, 1, 2].map(|d| selectors::wiggly_value(&w, lt, i, d)))
}
"expressionSelector" => {
let Some(prop) = sel.get("amount") else { continue };
// Evaluated per character (`textIndex`): the layer's cache key holds the amount
// for the first character only, so the buffer must not outlive the session.
if prop.has_expression()
&& let Some(log) = ctx.reads
{
log.mark_unkeyed();
}
let stat = ctx.v3(layer, sel, "amount", [100.0; 3]);
Box::new(move |i, n, prev| {
let pv = prev.map(|x| x * 100.0);
// Without an expression engine: the static amount scales the selection.
let fallback = [0, 1, 2].map(|d| stat[d] * prev[d]);
let r = match ctx.expr.filter(|_| prop.has_expression()) {
Some(h) => h.eval_text_selector(ctx, layer, prop, i, n, pv).unwrap_or(fallback),
None => fallback,
};
r.map(|x| (x / 100.0).clamp(-1.0, 1.0))
})
}
_ => continue,
};
// The Expression Selector's result replaces the selection (it reads it as selectorValue).
let replace = sel.match_id == "expressionSelector";
let mut memo: std::collections::HashMap<(usize, [u64; 3]), [f64; 3]> = std::collections::HashMap::new();
for (gi, g) in lay.glyphs.iter().enumerate() {
let (i, n) = unit_of(based, g, lay);
let prev = match si {
0 if replace => [1.0; 3],
0 => [mode.initial(); 3],
_ => acc[gi],
};
let key = (i, prev.map(f64::to_bits));
let v = *memo.entry(key).or_insert_with(|| values(i, n, prev));
acc[gi] = if replace { v } else { [0, 1, 2].map(|d| mode.combine(prev[d], v[d])) };
}
}
acc
}
/// Evaluate animators for every glyph.
pub fn char_transforms(ctx: &EvalCtx, layer: &Layer, text: &PropGroup, lay: &TextLayout) -> Vec<CharXf> {
let mut out = vec![CharXf::default(); lay.glyphs.len()];
let Some(anims) = text.sub("animators") else { return out };
for anim in anims.groups().filter(|g| g.enabled) {
let Some(props) = anim.sub("properties") else { continue };
let sel = animator_selection(ctx, layer, anim, lay);
let get = |m: &str| props.get(m).map(|p| ctx.value(layer, p).components());
let get1 = |m: &str| props.get(m).map(|p| ctx.value(layer, p).as_f64());
let c3 = |v: Vec<f64>, d: f64| [v.first().copied().unwrap_or(d), v.get(1).copied().unwrap_or(d), v.get(2).copied().unwrap_or(d)];
let pos = get("position").map(|v| c3(v, 0.0));
let anchor = get("anchor").map(|v| c3(v, 0.0));
let scale = get("scale").map(|v| c3(v, 100.0));
let angles = CharacterAngles {
rotation: get1("rotation"),
rotation_x: get1("rotationX"),
rotation_y: get1("rotationY"),
skew: get1("skew"),
skew_axis: get1("skewAxis"),
};
let op = get1("opacity");
let fill = props.get("fillColor").map(|p| ctx.value(layer, p).as_color());
let stroke = props.get("strokeColor").map(|p| ctx.value(layer, p).as_color());
let fill_hsb = [get1("fillHue"), get1("fillSaturation"), get1("fillBrightness")];
let stroke_hsb = [get1("strokeHue"), get1("strokeSaturation"), get1("strokeBrightness")];
let fill_op = get1("fillOpacity");
let stroke_op = get1("strokeOpacity");
let sw = get1("strokeWidth");
let tracking = get1("tracking");
let tracking_type = props.get("trackingType").map(|p| ctx.value(layer, p).as_enum()).unwrap_or(0);
let line_anchor = get1("lineAnchor");
let line_spacing = props.get("lineSpacing").map(|p| ctx.value(layer, p).as_vec2());
let char_offset = get1("characterOffset");
let char_value = get1("characterValue");
let char_range = props.get("characterRange").map(|p| ctx.value(layer, p).as_enum());
let char_align = props.get("characterAlignment").map(|p| ctx.value(layer, p).as_enum());
let blur = props.get("blur").map(|p| ctx.value(layer, p).as_vec2());
let surface = SURFACE_ANIM_PROPS.map(|m| props.get(m).map(|p| ctx.value(layer, p).as_color()));
let axes: Vec<([u8; 4], f64)> = props
.props()
.filter_map(|p| {
let t = p.match_id.strip_prefix(AXIS_PREFIX)?;
let mut tag = [b' '; 4];
for (i, c) in t.bytes().take(4).enumerate() {
tag[i] = c;
}
Some((tag, ctx.value(layer, p).as_f64()))
})
.collect();
for (gi, k3) in sel.iter().enumerate() {
let k = k3[0];
if k3.iter().all(|x| *x == 0.0) {
continue;
}
let c = &mut out[gi];
for d in 0..3 {
if let Some(p) = pos {
c.offset[d] += p[d] * k3[d];
}
if let Some(a) = anchor {
c.anchor[d] += a[d] * k3[d];
}
if let Some(s) = scale {
c.scale[d] *= 1.0 + (s[d] / 100.0 - 1.0) * k3[d];
}
}
angles.apply(c, k);
if let Some(o) = op {
// Blend toward this animator's value, so a later animator can show characters an
// earlier one hid (Lottie files rely on it: Opacity 0 % on every character, then
// animators at 100 % that sweep in). With one animator this is the same as before.
c.opacity += (o - c.opacity) * k;
}
if let Some(f) = fill {
c.fill = Some(f);
c.fill_k = (c.fill_k + k as f32).clamp(0.0, 1.0);
}
if let Some(f) = stroke {
c.stroke = Some(f);
c.stroke_k = (c.stroke_k + k as f32).clamp(0.0, 1.0);
}
for d in 0..3 {
if let Some(v) = fill_hsb[d] {
c.fill_hsb[d] += v * k;
}
if let Some(v) = stroke_hsb[d] {
c.stroke_hsb[d] += v * k;
}
}
if let Some(o) = fill_op {
c.fill_opacity *= 1.0 + (o / 100.0 - 1.0) * k;
}
if let Some(o) = stroke_op {
c.stroke_opacity *= 1.0 + (o / 100.0 - 1.0) * k;
}
if let Some(w) = sw {
c.stroke_width += w * k;
}
apply_tracking(c, tracking, tracking_type, k);
if let Some(a) = line_anchor
&& k.abs() >= c.line_anchor_k
{
c.line_anchor = a;
c.line_anchor_k = k.abs();
}
if let Some(l) = line_spacing {
c.line_spacing[0] += l[0] * k;
c.line_spacing[1] += l[1] * k;
}
if let Some(o) = char_offset {
c.char_offset += o * k;
}
if let Some(v) = char_value {
c.char_value = Some((v, k));
}
if let Some(r) = char_range {
c.char_range = r;
}
if let Some(a) = char_align {
c.char_align = a;
}
if let Some(b) = blur {
c.blur[0] += (b[0] * k3[0]).abs();
c.blur[1] += (b[1] * k3[1]).abs();
}
for (tag, v) in &axes {
c.add_axis(*tag, v * k);
}
for ((s, sk), v) in c.surface.iter_mut().zip(&mut c.surface_k).zip(surface) {
if let Some(v) = v {
*s = Some(v);
*sk = (*sk + k as f32).clamp(0.0, 1.0);
}
}
}
}
out
}
/// Optional animator orientation properties; an omitted property leaves the glyph unchanged.
struct CharacterAngles {
rotation: Option<f64>,
rotation_x: Option<f64>,
rotation_y: Option<f64>,
skew: Option<f64>,
skew_axis: Option<f64>,
}
impl CharacterAngles {
fn apply(&self, c: &mut CharXf, k: f64) {
if let Some(r) = self.rotation {
c.rotation += r * k;
}
if let Some(r) = self.rotation_x {
c.rotation_x += r * k;
}
if let Some(r) = self.rotation_y {
c.rotation_y += r * k;
}
if let Some(s) = self.skew {
c.skew += s * k;
}
if let Some(a) = self.skew_axis {
c.skew_axis = a;
}
}
}
/// Distribute selected tracking before and/or after a glyph according to Tracking Type.
fn apply_tracking(c: &mut CharXf, tracking: Option<f64>, tracking_type: u32, k: f64) {
if let Some(t) = tracking {
let t = t * k;
match tracking_type {
1 => c.track_before += t,
2 => c.track_after += t,
_ => {
c.track_before += t * 0.5;
c.track_after += t * 0.5;
}
}
}
}
/// Character Offset / Character Value substitution (None = unchanged).
pub fn substitute(ch: char, x: &CharXf) -> Option<char> {
if ch.is_whitespace() {
return None;
}
let mut code = ch as u32 as f64;
if let Some((v, k)) = x.char_value {
code += (v - code) * k;
}
let off = x.char_offset.round() as i64;
let base = code.round().max(0.0) as u32;
let out = if x.char_range == 1 {
char::from_u32((base as i64 + off).max(0) as u32)
} else {
let c = char::from_u32(base)?;
let wrap = |lo: char, n: i64| char::from_u32((lo as i64 + ((c as i64 - lo as i64 + off).rem_euclid(n))) as u32);
if c.is_ascii_lowercase() {
wrap('a', 26)
} else if c.is_ascii_uppercase() {
wrap('A', 26)
} else if c.is_ascii_digit() {
wrap('0', 10)
} else {
Some(c)
}
}?;
(out != ch).then_some(out)
}
/// The Source Text value at the current time.
pub fn source_text(ctx: &EvalCtx, layer: &Layer) -> Option<TextDoc> {
let text = layer.props.sub("text")?;
match ctx.group_value(layer, text, "sourceText")? {
Value::Text(t) => Some(*t),
Value::Str(s) => Some(TextDoc { text: s, ..Default::default() }),
_ => None,
}
}
/// Per-character 3D is on for this layer (and the layer is 3D).
pub fn per_char_3d(ctx: &EvalCtx, layer: &Layer) -> bool {
layer.is_3d() && layer.props.sub("text").is_some_and(|t| ctx.b(layer, t, "perChar3d"))
}
/// One drawn character.
#[derive(Clone, Debug)]
pub struct PlacedGlyph {
/// Outline in character space (the anchor pivot at the origin).
pub local: BezPath,
/// Character space → layer space.
pub m: Mat4,
pub xf: CharXf,
pub fill: [f32; 4],
pub stroke: [f32; 4],
pub stroke_width: f64,
pub join: effectcraft_path::Join,
/// Faux Bold (or synthetic bold for a family without a bold face): how far the outline grows
/// on every side, in character space (0 when not bold).
pub bold: f64,
/// The character's style fills it (Character panel fill on).
pub apply_fill: bool,
/// Index of the character in the text.
pub char_index: usize,
/// Where the pivot sits in the unanimated layout (layer space): `m · (p − rest)` carries a
/// point of the static layout to where the animation puts this character.
pub rest: [f64; 2],
}
impl PlacedGlyph {
/// Character space → layer space, flattened to 2D (ignores Z).
pub fn m2(&self) -> Mat3 {
let m = &self.m.0;
Mat3([[m[0][0], m[0][1], m[0][3]], [m[1][0], m[1][1], m[1][3]], [0.0, 0.0, 1.0]])
}
/// Outline in layer space (2D).
pub fn path(&self) -> BezPath {
effectcraft_path::transform(std::slice::from_ref(&self.local), &self.m2()).remove(0)
}
/// [`Self::bold`] in layer space.
fn bold_in_layer(&self) -> f64 {
self.bold * self.m2().mean_scale()
}
/// Whether [`Self::bold`] grows the outline as evenly in layer space as in character space:
/// no bold, or a matrix that only rotates and scales uniformly.
fn bold_is_even(&self) -> bool {
let [[a, b, _], [c, d, _], _] = self.m2().0;
let n = a * a + b * b + c * c + d * d;
self.bold == 0.0 || ((a * a + c * c - b * b - d * d).abs() <= 1e-9 * n && (a * b + c * d).abs() <= 1e-9 * n)
}
}
/// How far a circle of radius `r` reaches along x and along y once mapped by `m`.
fn axis_reach(m: &Mat3, r: f64) -> [f64; 2] {
let [[a, b, _], [c, d, _], _] = m.0;
[r * a.hypot(b), r * c.hypot(d)]
}
/// How far Faux Bold grows a glyph's outline on every side, as a fraction of its size: Inter
/// Regular grown by this has about the stems of Inter Bold, so synthetic bold stands in for a
/// missing bold face.
const FAUX_BOLD_RADIUS: f64 = 0.03;
/// Laid-out, animated text of a layer at the context time.
pub struct TextGeom {
pub doc: TextDoc,
pub glyphs: Vec<PlacedGlyph>,
/// Fill & Stroke: 0 per character palette, 1 all fills over all strokes, 2 all strokes over
/// all fills.
pub fill_stroke: u32,
pub blend: BlendMode,
}
fn adjust_color(base: [f32; 4], rgb: Option<[f32; 4]>, k: f32, hsb: [f64; 3], opacity: f64) -> [f32; 4] {
let mut c = match rgb {
Some(f) => [base[0] + (f[0] - base[0]) * k, base[1] + (f[1] - base[1]) * k, base[2] + (f[2] - base[2]) * k, base[3] + (f[3] - base[3]) * k],
None => base,
};
if hsb != [0.0; 3] {
let (h, s, v) = effectcraft_color::rgb_to_hsv(c[0], c[1], c[2]);
let h = h + (hsb[0] / 360.0) as f32;
let s = (s + (hsb[1] / 100.0) as f32).clamp(0.0, 1.0);
let v = (v + (hsb[2] / 100.0) as f32).clamp(0.0, 1.0);
let (r, g, b) = effectcraft_color::hsv_to_rgb(h, s, v);
c = [r, g, b, c[3]];
}
c[3] *= (opacity / 100.0).clamp(0.0, 1.0) as f32;
c
}
/// The mask path chosen in Path Options (layer space), measured.
fn text_path(ctx: &EvalCtx, layer: &Layer, text: &PropGroup) -> Option<(PathMeasure, bool, bool, f64, f64)> {
let po = text.sub("pathOptions")?;
let k = ctx.e(layer, po, "path") as usize;
if k == 0 {
return None;
}
let mask = layer.masks()?.groups().nth(k - 1)?;
let Some(Value::Path(sp)) = ctx.group_value(layer, mask, "path") else { return None };
let bp = effectcraft_path::to_kurbo(&sp);
let pm = PathMeasure::new(&bp, ctx.b(layer, po, "reversePath"));
if pm.is_empty() {
return None;
}
let perpendicular = po.get("perpendicular").map(|p| ctx.value(layer, p).as_bool()).unwrap_or(true);
Some((pm, perpendicular, ctx.b(layer, po, "forceAlignment"), ctx.f(layer, po, "firstMargin", 0.0), ctx.f(layer, po, "lastMargin", 0.0)))
}
/// For every caret position `0..=chars` of the layer's text, the map from the static layout
/// (layer space, what [`TextLayout::caret`] returns) to where animators and Path Options put
/// the characters: the caret follows the character before it (or the first one).
pub fn caret_maps(ctx: &EvalCtx, layer: &Layer, chars: usize) -> Vec<Mat3> {
let Some(geom) = text_geom(ctx, layer) else { return vec![Mat3::IDENTITY; chars + 1] };
let mut by_char: Vec<Option<Mat3>> = vec![None; chars + 1];
for g in &geom.glyphs {
if let Some(slot) = by_char.get_mut(g.char_index) {
*slot = Some(g.m2() * Mat3::translate(effectcraft_geom::vec2(-g.rest[0], -g.rest[1])));
}
}
let first = by_char.iter().flatten().next().copied().unwrap_or(Mat3::IDENTITY);
let mut out = Vec::with_capacity(chars + 1);
let mut last = first;
for ci in 0..=chars {
// Caret ci sits after character ci − 1.
if let Some(Some(m)) = ci.checked_sub(1).map(|p| by_char[p]) {
last = m;
}
out.push(if ci == 0 { by_char[0].unwrap_or(first) } else { last });
}
out
}
/// A paragraph's alignment: 0 left, 1 centre, 2 right (a justified paragraph by its last line).
fn align_index(j: Justify) -> u8 {
match j {
Justify::Center | Justify::JustifyLastCenter => 1,
Justify::Right | Justify::JustifyLastRight => 2,
_ => 0,
}
}
/// Note the font files `lay` is drawn with in [`EvalCtx::reads`] (#3184), each with its identity
/// as its bytes were read, so a cache entry of the text is checked against them. What the
/// files can't vouch for makes the list incomplete: a family that is not installed (installing
/// it changes the text), a character drawn with a fallback face or with none (another installed
/// font could take it), a font from memory.
fn note_fonts(ctx: &EvalCtx, lay: &TextLayout) {
note_layout_fonts(ctx, &lay.styles, &lay.layout);
}
/// A character put in by Character Offset or Character Value (`ch`, in style `st`), outside a
/// relayout: its outline and advance, with the font it is drawn with noted as [`note_fonts`] does
/// (the run's face may not have it: a fallback face then draws it).
fn substituted_glyph(ctx: &EvalCtx, st: &effectcraft_keyframe::CharStyle, ch: char) -> (BezPath, f64) {
if ctx.reads.is_some() {
// (the layout `char_glyph_style` makes, served from the layout cache there)
let lay = effectcraft_text::layout_text(&ch.to_string(), &effectcraft_text::text_style(st), &effectcraft_text::ParagraphStyle::default());
note_layout_fonts(ctx, std::slice::from_ref(st), &lay);
}
char_glyph_style(st, ch)
}
/// [`note_fonts`] for `layout`, whose glyphs' runs index `styles`.
fn note_layout_fonts(ctx: &EvalCtx, styles: &[effectcraft_keyframe::CharStyle], layout: &effectcraft_text::Layout) {
use crate::reads::{FileStamp, ReadFile};
use effectcraft_text::fonts;
let Some(log) = ctx.reads else { return };
let resolved: Vec<fonts::Resolved> = styles.iter().map(|s| fonts::resolve(&s.font, &s.style)).collect();
if resolved.iter().any(|r| r.missing) {
log.mark_incomplete();
}
// Each run's face, glyphs or not: an empty paragraph's face sets its line's metrics (in a
// box, the first baseline that places every later line).
let mut faces: Vec<fonts::FaceId> = Vec::new();
for r in &resolved {
if !faces.contains(&r.face) {
faces.push(r.face);
}
}
for g in &layout.glyphs {
if g.id == 0 || resolved.get(g.run).is_none_or(|r| r.face != g.face) {
log.mark_incomplete();
}
if !faces.contains(&g.face) {
faces.push(g.face);
}
}
for id in faces {
let face = fonts::face(id);
match &face.info.data {
fonts::FaceData::Static(_) => {}
fonts::FaceData::Shared(_) => log.mark_incomplete(),
fonts::FaceData::File(_) => {
if let Some((path, meta)) = face.file_read() {
let stamp = meta.map_or(FileStamp::Missing, FileStamp::of_metadata);
log.note(ReadFile::new(path.to_string_lossy().as_ref(), stamp));
}
}
}
}
}
/// Lay out and animate a text layer.
pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
let doc = source_text(ctx, layer)?;
let text = layer.props.sub("text")?;
let lay = layout_doc(&doc);
note_fonts(ctx, &lay);
let xfs = char_transforms(ctx, layer, text, &lay);
let three = per_char_3d(ctx, layer);
let more = text.sub("moreOptions");
let grouping = more.map(|m| ctx.e(layer, m, "anchorGrouping")).unwrap_or(0);
let galign = more.map(|m| ctx.v2(layer, m, "groupingAlignment", [0.0; 2])).unwrap_or([0.0; 2]);
let fill_stroke = more.map(|m| ctx.e(layer, m, "fillStroke")).unwrap_or(0);
let blend_idx = more.map(|m| ctx.e(layer, m, "interCharBlend")).unwrap_or(0) as usize;
let blend = effectcraft_project::build::INTER_CHAR_BLEND_MODES.get(blend_idx).and_then(|n| BlendMode::from_name(n)).unwrap_or_default();
// Character substitutions (Character Offset / Value). Adjust Kerning re-lays out the text.
let mut subs: Vec<Option<char>> = lay.glyphs.iter().zip(&xfs).map(|(g, x)| substitute(g.ch, x)).collect();
let relayout = subs.iter().zip(&xfs).any(|(s, x)| s.is_some() && x.char_align == 3);
let (lay, xfs) = if relayout {
let mut chars: Vec<char> = doc.text.chars().collect();
for (g, s) in lay.glyphs.iter().zip(&subs) {
if let (Some(c), Some(slot)) = (s, chars.get_mut(g.char_index)) {
*slot = *c;
}
}
// Same character count: the style runs still line up.
let d2 = TextDoc { text: chars.into_iter().collect(), ..doc.clone() };
let l2 = layout_doc(&d2);
note_fonts(ctx, &l2);
let by_char: std::collections::HashMap<usize, CharXf> = lay.glyphs.iter().zip(&xfs).map(|(g, x)| (g.char_index, *x)).collect();
let x2: Vec<CharXf> = l2.glyphs.iter().map(|g| by_char.get(&g.char_index).copied().unwrap_or_default()).collect();
subs = vec![None; l2.glyphs.len()];
(l2, x2)
} else {
(lay, xfs)
};
// Variable Font Axes: the advance at the animated design-space position (later characters
// on the line move with it).
let n = lay.glyphs.len();
let vadv: Vec<f64> = (0..n)
.map(|gi| match (subs[gi], xfs[gi].axis_deltas()) {
(None, d) if !d.is_empty() => effectcraft_text::variable::char_advance_delta(&lay.glyphs[gi], &d).unwrap_or(0.0),
_ => 0.0,
})
.collect();
let advance = |gi: usize| lay.glyphs[gi].advance + vadv[gi];
// Tracking (before / after each character) and Line Anchor, per line. Without a Line Anchor
// a line grows from where its paragraph's alignment pins it (left, centre or right).
let paras = doc.paras();
let mut shift = vec![0.0f64; n];
let mut li_start = 0;
while li_start < n {
let line = lay.glyphs[li_start].line_index;
let mut li_end = li_start;
while li_end < n && lay.glyphs[li_end].line_index == line {
li_end += 1;
}
let mut pen = 0.0;
let justify = lay.layout.lines.get(line).and_then(|l| paras.get(l.para)).map_or(doc.justify, |p| p.justify);
let (mut la, mut la_k) = (50.0 * f64::from(align_index(justify)), 0.0);
for gi in li_start..li_end {
let x = &xfs[gi];
// An animator's Tracking Amount is in pixels, whatever the font size (After Effects
// 2026: Amount 30 adds 30 px between selected characters at 80 px and at 40 px), unlike
// the Character panel's tracking in 1/1000 em.
pen += x.track_before;
shift[gi] = pen;
pen += x.track_after + vadv[gi];
if x.line_anchor_k > la_k {
la = x.line_anchor;
la_k = x.line_anchor_k;
}
}
let back = pen * la / 100.0;
for s in &mut shift[li_start..li_end] {
*s -= back;
}
li_start = li_end;
}
// Glyph origins after tracking / line spacing, and pivots by anchor point grouping.
let origin = |gi: usize| -> [f64; 2] {
let g = &lay.glyphs[gi];
let x = &xfs[gi];
[g.origin.x + shift[gi] + x.line_spacing[0] * g.line_index as f64, g.origin.y + x.line_spacing[1] * g.line_index as f64]
};
let group_key = |g: &CharGlyph| -> usize {
match grouping {
1 => g.word_index * 4096 + g.line_index,
2 => g.line_index,
3 => 0,
_ => usize::MAX,
}
};
let mut extents: std::collections::HashMap<usize, [f64; 4]> = std::collections::HashMap::new();
if grouping != 0 {
for gi in 0..n {
let g = &lay.glyphs[gi];
if g.is_space && grouping == 1 {
continue;
}
let o = origin(gi);
let e = extents.entry(group_key(g)).or_insert([f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY]);
e[0] = e[0].min(o[0]);
e[2] = e[2].max(o[0] + advance(gi));
e[1] = e[1].min(o[1]);
e[3] = e[3].max(o[1]);
}
}
// Path Options.
let on_path = text_path(ctx, layer, text);
let align = align_index(doc.justify);
let first_baseline = lay.line_boxes.first().map(|b| b[1]).unwrap_or(0.0);
let mut arc = vec![0.0f64; n];
if let Some((pm, _, force, fm, lm)) = &on_path {
let mut i = 0;
while i < n {
let line = lay.glyphs[i].line_index;
let mut j = i;
while j < n && lay.glyphs[j].line_index == line {
j += 1;
}
let xs: Vec<f64> = (i..j).map(|gi| origin(gi)[0] + advance(gi) / 2.0).collect();
let lb = lay.line_boxes.get(line).copied().unwrap_or([0.0; 4]);
let pos = path_text::arc_positions(&xs, [lb[0], lb[0] + lb[2]], align, pm.length(), *fm, *lm, *force);
arc[i..j].copy_from_slice(&pos);
i = j;
}
}
let base = doc.base_style();
let mut glyphs = Vec::with_capacity(n);
for gi in 0..n {
let g = &lay.glyphs[gi];
let mut x = xfs[gi];
if g.is_space {
continue;
}
if !three {
x.offset[2] = 0.0;
x.anchor[2] = 0.0;
x.scale[2] = 100.0;
x.rotation_x = 0.0;
x.rotation_y = 0.0;
}
let o = origin(gi);
let st = lay.styles.get(g.run).unwrap_or(&base);
let size = st.size;
// Outline (substituted characters aligned in the original slot).
let (outline, adv) = match subs[gi] {
Some(c) => {
let (p, a) = substituted_glyph(ctx, st, c);
let dx = match x.char_align {
0 => 0.0,
2 => g.advance - a,
_ => (g.advance - a) / 2.0,
};
(kurbo::Affine::translate((dx, 0.0)) * p, g.advance)
}
// Variable Font Axes: the outline redrawn at the animated design-space position.
None => match x.axis_deltas() {
d if !d.is_empty() => (effectcraft_text::variable::char_outline_varied(g, &d).unwrap_or_else(|| g.path.clone()), advance(gi)),
_ => (g.path.clone(), g.advance),
},
};
if outline.elements().is_empty() {
continue;
}
let pivot = match extents.get(&group_key(g)) {
Some(e) if grouping != 0 => [(e[0] + e[2]) / 2.0 + galign[0] / 100.0 * (e[2] - e[0]), (e[1] + e[3]) / 2.0 + galign[1] / 100.0 * size],
_ => [o[0] + adv / 2.0 + galign[0] / 100.0 * adv, o[1] + galign[1] / 100.0 * size],
};
// Outline relative to the pivot.
let local = kurbo::Affine::translate((o[0] - pivot[0], o[1] - pivot[1])) * outline;
// Rx · Ry · Rz (Z turns first): the order After Effects gives a 3D layer's X / Y / Z
// Rotation too. #1876 moves layers to it with a helper, `Mat4::rotation_xyz`; once both
// land, this should call that helper (one convention, one place).
let char_m = Mat4::rotate_x(x.rotation_x)
* Mat4::rotate_y(x.rotation_y)
* Mat4::rotate_z(x.rotation)
* Mat4::from_mat3_affine(&Mat3::skew_deg(-x.skew, x.skew_axis))
* Mat4::scale(Vec3::from(x.scale) / 100.0)
* Mat4::translate(-Vec3::from(x.anchor));
let m = match &on_path {
Some((pm, perp, ..)) => {
// Position X slides along the path, Y moves off it.
let char_center = o[0] + adv / 2.0;
let s = arc[gi] + (pivot[0] - char_center) + x.offset[0];
let (p, ang) = path_text::place(pm, s, pivot[1] - first_baseline + x.offset[1], *perp);
Mat4::translate(vec3(p.x, p.y, x.offset[2])) * Mat4::rotate_z(ang) * char_m
}
None => Mat4::translate(vec3(pivot[0] + x.offset[0], pivot[1] + x.offset[1], x.offset[2])) * char_m,
};
let fill = adjust_color(st.fill, x.fill, x.fill_k, x.fill_hsb, x.fill_opacity);
let stroke = adjust_color(st.stroke, x.stroke, x.stroke_k, x.stroke_hsb, x.stroke_opacity);
let base_stroke_w = if st.apply_stroke { st.stroke_width } else { 0.0 };
let rest = [pivot[0] - (o[0] - g.origin.x), pivot[1] - (o[1] - g.origin.y)];
glyphs.push(PlacedGlyph {
local,
m,
xf: x,
fill,
stroke,
stroke_width: (base_stroke_w + x.stroke_width).max(0.0),
join: path_join(doc.line_join),
bold: if g.synth_bold { FAUX_BOLD_RADIUS * g.size } else { 0.0 },
apply_fill: st.apply_fill,
char_index: g.char_index,
rest,
});
}
Some(TextGeom { doc, glyphs, fill_stroke, blend })
}
/// Outlines of all glyphs in layer space with their animation state (for text → shapes, bounds
/// and hit testing).
pub fn glyph_paths(ctx: &EvalCtx, layer: &Layer) -> Vec<(BezPath, CharXf)> {
text_geom(ctx, layer).map(|t| t.glyphs.iter().map(|g| (g.path(), g.xf)).collect()).unwrap_or_default()
}
fn path_join(j: effectcraft_keyframe::LineJoin) -> effectcraft_path::Join {
use effectcraft_keyframe::LineJoin;
match j {
LineJoin::Miter => effectcraft_path::Join::Miter,
LineJoin::Round => effectcraft_path::Join::Round,
LineJoin::Bevel => effectcraft_path::Join::Bevel,
}
}
/// One paint pass of a glyph: fill (false) or stroke (true).
type Pass = (usize, bool);
fn passes(geom: &TextGeom) -> Vec<Pass> {
let doc = &geom.doc;
let n = geom.glyphs.len();
let fill_on = |i: usize| geom.glyphs[i].apply_fill;
let has_stroke = |i: usize| geom.glyphs[i].stroke_width > 0.0;
let mut v = Vec::with_capacity(n * 2);
match geom.fill_stroke {
1 => {
v.extend((0..n).filter(|i| has_stroke(*i)).map(|i| (i, true)));
v.extend((0..n).filter(|i| fill_on(*i)).map(|i| (i, false)));
}
2 => {
v.extend((0..n).filter(|i| fill_on(*i)).map(|i| (i, false)));
v.extend((0..n).filter(|i| has_stroke(*i)).map(|i| (i, true)));
}
_ => {
for i in 0..n {
let f = fill_on(i).then_some((i, false));
let s = has_stroke(i).then_some((i, true));
if doc.stroke_over_fill {
v.extend(f);
v.extend(s);
} else {
v.extend(s);
v.extend(f);
}
}
}
}
v
}
/// Coverage of the outline grown by `d` on every side (shrunk when `d` is negative): the fill
/// merged with, or cut by, a round stroke `2|d|` wide, which keeps the edges anti-aliased.
fn grown_coverage(paths: &[BezPath], mm: &Mat3, w: u32, h: u32, d: f64) -> Mask {
let mut cov = effectcraft_path::fill_coverage(paths, mm, w, h, FillRule::NonZero);
if d.abs() < 1e-9 {
return cov;
}
let st = StrokeStyle { width: 2.0 * d.abs(), join: effectcraft_path::Join::Round, ..Default::default() };
let ring = effectcraft_path::stroke_coverage(paths, &st, mm, w, h);
for (c, r) in cov.data.iter_mut().zip(&ring.data) {
*c = if d > 0.0 { c.max(*r) } else { c.min(1.0 - r) };
}
cov
}
/// Rasterize one pass of a glyph — or of a run of glyphs painted alike, as one outline — (paths
/// mapped into target pixels by `m`) into a premultiplied patch over the target rect, blurred
/// when the character has Blur. `bold` is the glyph's Faux Bold in the paths' space.
fn raster_pass(g: &PlacedGlyph, stroke: bool, paths: &[BezPath], m: &Mat3, rect: [i64; 4], blur_px: [f64; 2], bold: f64) -> Image {
let (w, h) = ((rect[2] - rect[0]) as u32, (rect[3] - rect[1]) as u32);
let mm = Mat3::translate(vec2(-rect[0] as f64, -rect[1] as f64)) * *m;
let cov = if stroke && bold > 0.0 {
// The stroke of the bold outline: the band between it grown and shrunk by half the width.
let half = g.stroke_width / 2.0;
let mut outer = grown_coverage(paths, &mm, w, h, bold + half);
let inner = grown_coverage(paths, &mm, w, h, bold - half);
for (o, i) in outer.data.iter_mut().zip(&inner.data) {
*o = o.min(1.0 - i);
}
outer
} else if stroke {
let st = StrokeStyle { width: g.stroke_width, join: g.join, ..Default::default() };
effectcraft_path::stroke_coverage(paths, &st, &mm, w, h)
} else {
grown_coverage(paths, &mm, w, h, bold)
};
let col = if stroke { g.stroke } else { g.fill };
let op = (g.xf.opacity / 100.0).clamp(0.0, 1.0) as f32;
let a = col[3] * op;
let mut img = Image::new(w, h);
for (px, c) in img.data.iter_mut().zip(&cov.data) {
let k = c * a;
if k > 0.0 {
*px = [col[0] * k, col[1] * k, col[2] * k, k];
}
}
if blur_px[0] > 0.05 || blur_px[1] > 0.05 {
img = effectcraft_raster::gaussian_blur(&img, blur_px[0] / 2.0, blur_px[1] / 2.0, false);
}
img
}
fn blit(dst: &mut Image, src: &Image, at: [i64; 2], mode: BlendMode) {
for y in 0..src.height as i64 {
let ty = y + at[1];
if ty < 0 || ty >= dst.height as i64 {
continue;
}
for x in 0..src.width as i64 {
let tx = x + at[0];
if tx < 0 || tx >= dst.width as i64 {
continue;
}
let s = src.data[(y * src.width as i64 + x) as usize];
if s[3] <= 0.0 && s[0] == 0.0 && s[1] == 0.0 && s[2] == 0.0 {
continue;
}
let d = &mut dst.data[(ty * dst.width as i64 + tx) as usize];
*d = effectcraft_color::blend_pixel(mode, *d, s, 0.5);
}
}
}
/// An opaque, unblurred fill pass: where such fills of one colour touch or overlap, compositing
/// them one by one would let their anti-aliased edges show the background through.
fn solid_fill(g: &PlacedGlyph, stroke: bool) -> bool {
!stroke && g.xf.blur == [0.0; 2] && g.fill[3] >= 1.0 && g.xf.opacity >= 100.0
}
/// The passes in drawing order, consecutive opaque fills of one colour gathered into a run that
/// is rasterised as one outline, as After Effects draws a line of text: glyphs that touch (the
/// crossbars of "ff") or overlap join without a seam (#415). Under an Inter-Character Blending
/// mode other than Normal every character is still composited onto the others on its own.
fn runs(geom: &TextGeom) -> Vec<(Vec<usize>, bool)> {
let mut out: Vec<(Vec<usize>, bool)> = vec![];
for (gi, stroke) in passes(geom) {
let g = &geom.glyphs[gi];
if g.xf.opacity <= 0.0 {
continue;
}
// A run grows its outline in layer space by one radius, which matches each glyph grown in
// its own space only when the bold stays round there.
if geom.blend == BlendMode::Normal
&& solid_fill(g, stroke)
&& g.bold_is_even()
&& let Some((run, false)) = out.last_mut()
&& run.last().is_some_and(|&p| {
let q = &geom.glyphs[p];
solid_fill(q, false) && q.fill == g.fill && q.bold_is_even() && (q.bold_in_layer() - g.bold_in_layer()).abs() < 1e-6
})
{
run.push(gi);
continue;
}
out.push((vec![gi], stroke));
}
out
}
/// Draw glyphs (paths mapped by `to_px` from layer space into `img`).
fn draw_glyphs(geom: &TextGeom, img: &mut Image, to_px: &Mat3, s: f64, only: Option<usize>) {
let (w, h) = (img.width as i64, img.height as i64);
let runs = match only {
Some(o) => passes(geom).into_iter().filter(|(gi, _)| *gi == o && geom.glyphs[*gi].xf.opacity > 0.0).map(|(gi, st)| (vec![gi], st)).collect(),
None => runs(geom),
};
for (run, stroke) in runs {
let Some(g) = run.first().map(|&gi| &geom.glyphs[gi]) else { continue };
// One glyph is drawn from its own outline; a run from its glyphs' outlines in layer
// space, all wound the same way so overlaps add up instead of cancelling (NonZero).
let local = Cow::Borrowed(std::slice::from_ref(&g.local));
let (paths, m, bold): (Cow<[BezPath]>, Mat3, f64) = match (only, run.as_slice()) {
(Some(_), _) => (local, *to_px, g.bold),
(None, [_]) => (local, *to_px * g.m2(), g.bold),
(None, _) => {
let wound = |p: BezPath| if kurbo::Shape::area(&p) < 0.0 { p.reverse_subpaths() } else { p };
(Cow::Owned(run.iter().map(|&gi| wound(geom.glyphs[gi].path())).collect()), *to_px, g.bold_in_layer())
}
};
let Some(b) = effectcraft_path::bounds(&paths) else { continue };
let blur = [g.xf.blur[0] * s, g.xf.blur[1] * s];
// Stroke and bold grow the outline in the paths' space: under a non-uniform `m` they reach
// further along one axis than the other.
let [px, py] = axis_reach(&m, g.stroke_width + 2.0 * bold);
let pad = 2.0 + blur[0].max(blur[1]) * 1.5;
let r = m.map_rect(&effectcraft_geom::Rect::new(b.x0, b.y0, b.x1, b.y1));
let rect = [
((r.x0 - px - pad).floor() as i64).max(0),
((r.y0 - py - pad).floor() as i64).max(0),
((r.x1 + px + pad).ceil() as i64).min(w),
((r.y1 + py + pad).ceil() as i64).min(h),
];
if rect[2] <= rect[0] || rect[3] <= rect[1] {
continue;
}
let patch = raster_pass(g, stroke, &paths, &m, rect, blur, bold);
blit(img, &patch, [rect[0], rect[1]], if only.is_some() { BlendMode::Normal } else { geom.blend });
}
}
/// Render a text layer into a layer-space buffer at scale `s`.
pub fn render(ctx: &EvalCtx, layer: &Layer, s: f64) -> Buf {
let empty = || Buf { img: Image::new(1, 1), offset: [0.0; 2], scale: s };
let Some(geom) = text_geom(ctx, layer) else { return empty() };
let mut bounds: Option<kurbo::Rect> = None;
for g in &geom.glyphs {
if let Some(b) = effectcraft_path::bounds(&[g.path()]) {
let [bx, by] = axis_reach(&g.m2(), 2.0 * g.bold);
let pad = g.stroke_width + 2.0 + g.xf.blur[0].max(g.xf.blur[1]) * 1.5;
let b = b.inflate(pad + bx, pad + by);
bounds = Some(bounds.map_or(b, |a| a.union(b)));
}
}
let Some(b) = bounds else { return empty() };
let b = b.intersect(kurbo::Rect::new(-20000.0, -20000.0, 20000.0, 20000.0));
let w = ((b.width() * s).ceil() as u32 + 4).clamp(1, 16384);
let h = ((b.height() * s).ceil() as u32 + 4).clamp(1, 16384);
let offset = [-b.x0 * s + 2.0, -b.y0 * s + 2.0];
let mut img = Image::new(w, h);
let base = Mat3::translate(vec2(offset[0], offset[1])) * Mat3::scale(vec2(s, s));
draw_glyphs(&geom, &mut img, &base, s, None);
Buf { img, offset, scale: s }
}
/// Per-character 3D: each character as its own plane — (buffer in character space, character
/// space → layer space matrix).
pub fn per_char_planes(ctx: &EvalCtx, layer: &Layer, s: f64) -> Vec<(Buf, Mat4)> {
let Some(geom) = text_geom(ctx, layer) else { return vec![] };
let mut out = Vec::with_capacity(geom.glyphs.len());
for (gi, g) in geom.glyphs.iter().enumerate() {
if g.xf.opacity <= 0.0 {
continue;
}
let Some(b) = effectcraft_path::bounds(std::slice::from_ref(&g.local)) else { continue };
let pad = g.stroke_width + 2.0 * g.bold + 2.0 + g.xf.blur[0].max(g.xf.blur[1]) * 1.5;
let b = b.inflate(pad, pad);
let w = ((b.width() * s).ceil() as u32 + 4).clamp(1, 4096);
let h = ((b.height() * s).ceil() as u32 + 4).clamp(1, 4096);
let offset = [-b.x0 * s + 2.0, -b.y0 * s + 2.0];
let mut img = Image::new(w, h);
let base = Mat3::translate(vec2(offset[0], offset[1])) * Mat3::scale(vec2(s, s));
draw_glyphs(&geom, &mut img, &base, s, Some(gi));
out.push((Buf { img, offset, scale: s }, g.m));
}
out
}
#[cfg(test)]
mod tests;