mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-14 22:33:09 +00:00
* 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>
1135 lines
55 KiB
Rust
1135 lines
55 KiB
Rust
use super::accel::{Accelerator, Backend, CpuFx, FxStep, FxTarget};
|
||
use super::footage::{FootageSource, is_vector_footage};
|
||
use super::frames::{blend_frames, cineon_frame, content_bounds, field_frame, frame_blend_mode, frame_position, frame_size, interpret_pixels, roi_inside};
|
||
use super::fx_host::{FxHost, essential_overrides};
|
||
use super::opts::{Collapse3d, LayerTiming, MAX_THROUGH, RenderOpts, Through};
|
||
use super::reads::{ReadFiles, ReadLog};
|
||
use super::{
|
||
Arc, AutoKey, AutoPick, BlendMode, Buf, Comp, EffectCtx, EffectEnv, EvalCtx, Footage, FootageKind, FrameBlend, FrameRate, GroupKind, Image, ItemId,
|
||
ItemKind, Layer, LayerCache, LayerSource, Params, Project, Quality, Region, Renderer, Tick, cache, color, masks, shapes, styles, text, three_d, vec2,
|
||
};
|
||
use effectcraft_keyframe::Value;
|
||
use effectcraft_project::LayerId;
|
||
|
||
/// Coordinate space and stack range shared by CPU and accelerated effect evaluation.
|
||
#[derive(Clone, Copy)]
|
||
pub(crate) struct EffectStack {
|
||
pub adjustment: bool,
|
||
pub limit: usize,
|
||
pub origin: [f64; 2],
|
||
}
|
||
|
||
/// A property value an Essential Properties override gives a layer of a collapsed precomp.
|
||
#[derive(Clone, Debug)]
|
||
pub struct PropertyOverride {
|
||
pub(crate) layer: LayerId,
|
||
pub(crate) prop: u64,
|
||
pub(crate) value: Value,
|
||
/// A Font control changes only the font of the target text, not its text.
|
||
pub(crate) font: bool,
|
||
}
|
||
|
||
/// A Media Replacement an Essential Properties override gives a footage layer of a collapsed
|
||
/// precomp.
|
||
#[derive(Clone, Copy, Debug)]
|
||
pub struct MediaOverride {
|
||
pub(crate) layer: LayerId,
|
||
pub(crate) item: ItemId,
|
||
}
|
||
|
||
impl MediaOverride {
|
||
/// A Media Replacement of `layer`'s footage by `item`.
|
||
pub fn new(layer: LayerId, item: ItemId) -> Self {
|
||
MediaOverride { layer, item }
|
||
}
|
||
}
|
||
|
||
/// A collapsed precomp layer: the nested comp and the instance overrides that apply to it.
|
||
pub struct Collapsed {
|
||
pub item: ItemId,
|
||
pub(crate) property_overrides: Vec<PropertyOverride>,
|
||
pub(crate) media_overrides: Vec<MediaOverride>,
|
||
}
|
||
|
||
/// The property and media overrides of a collapsed precomp layer. `None` when its Essential
|
||
/// Properties group or the source comp's definitions are missing (the flattened path then
|
||
/// preserves the old behaviour).
|
||
fn collapsed_instance(ctx: &EvalCtx, layer: &Layer, item: ItemId) -> Option<(Vec<PropertyOverride>, Vec<MediaOverride>)> {
|
||
use effectcraft_project::essential::{self, ControlType, EgKind};
|
||
let over = essential::overridden(layer);
|
||
if over.is_empty() {
|
||
return Some((vec![], vec![]));
|
||
}
|
||
let g = essential::group(layer)?;
|
||
let eg = ctx.project.comp(item)?.essential.as_ref()?;
|
||
let mut property = vec![];
|
||
let mut fonts: Vec<PropertyOverride> = vec![];
|
||
let mut media = vec![];
|
||
g.walk("", &mut |_, p| {
|
||
if !over.contains(&p.uid) {
|
||
return;
|
||
}
|
||
let Some(control) = essential::control_of(&p.match_id) else { return };
|
||
let Some(ctl) = eg.resolve(control) else { return };
|
||
let value = ctx.value(layer, p);
|
||
match &ctl.kind {
|
||
EgKind::Property { .. } => {
|
||
let font = ctl.as_type == Some(ControlType::Font);
|
||
for (layer, prop) in ctl.kind.targets() {
|
||
let o = PropertyOverride { layer, prop, value: value.clone(), font };
|
||
if font { fonts.push(o) } else { property.push(o) }
|
||
}
|
||
}
|
||
EgKind::Media { layer: target } => {
|
||
let Value::Scalar(id) = value else { return };
|
||
let item = ItemId(id.max(0.0) as u64);
|
||
if matches!(ctx.project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(_))) {
|
||
media.push(MediaOverride { layer: *target, item });
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
});
|
||
// A Font control merges into the text its instance shows, after a whole-value (Text) override
|
||
// of the same property, as `essential::with_overrides` does: keep one entry per property.
|
||
for f in fonts {
|
||
match property.iter_mut().rev().find(|o| o.layer == f.layer && o.prop == f.prop) {
|
||
Some(o) => effectcraft_project::essential::merge_font(&mut o.value, &f.value),
|
||
None => property.push(f),
|
||
}
|
||
}
|
||
Some((property, media))
|
||
}
|
||
|
||
impl<'a> Renderer<'a> {
|
||
/// A renderer over `project` and `footage` with the given options.
|
||
pub fn new(project: &'a Project, footage: &'a dyn FootageSource, opts: RenderOpts) -> Renderer<'a> {
|
||
Renderer {
|
||
project,
|
||
footage,
|
||
expr: None,
|
||
opts,
|
||
cache: None,
|
||
reads: None,
|
||
profile: None,
|
||
accel: None,
|
||
depth: 0,
|
||
pipe: color::Pipe::of(&project.settings),
|
||
outer: None,
|
||
opacity_mul: 1.0,
|
||
collapse3d: None,
|
||
inherited: None,
|
||
defer_dof: false,
|
||
top: true,
|
||
through: [None; MAX_THROUGH],
|
||
through_lost: false,
|
||
}
|
||
}
|
||
|
||
/// The layer cache to keep this render's buffers in: none once its footage reads were
|
||
/// called off, as they may have come back empty ([`FootageSource::called_off`], #329).
|
||
pub(crate) fn cache_to_keep(&self) -> Option<&'a LayerCache> {
|
||
self.cache.filter(|_| !self.footage.called_off())
|
||
}
|
||
|
||
/// `read`, a read of this render's footage source, with the files it uses noted in
|
||
/// [`Renderer::reads`] (see [`FootageSource::recording`]).
|
||
pub(crate) fn read<R>(&self, read: impl FnOnce() -> Option<R>) -> Option<R> {
|
||
let Some(log) = self.reads else { return read() };
|
||
let (mut read, mut out) = (Some(read), None);
|
||
self.footage.recording(log, &mut || out = read.take().and_then(|f| f()));
|
||
out
|
||
}
|
||
|
||
/// `ctx` noting what it reads (fonts, `sampleImage`) in this render's [`Renderer::reads`]:
|
||
/// a context made by an outer render carries that render's log, not the one of the buffer
|
||
/// [`Renderer::recorded`] makes.
|
||
pub(crate) fn with_reads<'c>(&'c self, ctx: &EvalCtx<'c>) -> EvalCtx<'c> {
|
||
EvalCtx { reads: self.reads, ..*ctx }
|
||
}
|
||
|
||
/// The buffer the layer cache keeps under `key`, with the files it was made of noted in
|
||
/// [`Renderer::reads`].
|
||
pub(crate) fn cached(&self, key: Option<u64>) -> Option<(Arc<Buf>, Arc<ReadFiles>)> {
|
||
let (buf, files) = self.cache?.get(key?)?;
|
||
if let Some(log) = self.reads {
|
||
log.note_all(&files);
|
||
}
|
||
Some((buf, files))
|
||
}
|
||
|
||
/// Keep `buf`, made of `files`, under `key` (see [`Renderer::recorded`]): the buffer the cache
|
||
/// keeps (another worker's, if it kept one first), or `buf` when nothing is kept, with the
|
||
/// files that buffer was made of. Another worker's files are noted in [`Renderer::reads`]
|
||
/// too: its pixels are the ones this render uses (a file that changed between the two reads
|
||
/// then has two identities, and the list is incomplete).
|
||
pub(crate) fn keep(&self, key: Option<u64>, buf: Arc<Buf>, files: &Arc<ReadFiles>) -> (Arc<Buf>, Arc<ReadFiles>) {
|
||
let (Some(c), Some(k)) = (self.cache_to_keep(), key) else { return (buf, files.clone()) };
|
||
let (kept, kept_files) = c.insert(k, buf, files.clone());
|
||
if !Arc::ptr_eq(&kept_files, files)
|
||
&& let Some(log) = self.reads
|
||
{
|
||
log.note_all(&kept_files);
|
||
}
|
||
(kept, kept_files)
|
||
}
|
||
|
||
/// Run `make`, which makes a buffer to keep under `key` in the layer cache, on this renderer
|
||
/// with its reads noted apart: what `make` returns and the files it read (from the footage
|
||
/// source, or the files of cached buffers it used), which go to [`Renderer::reads`] too. A
|
||
/// cached buffer then reports the files it was made of whichever render uses it. Without a
|
||
/// cache or a key, `make` runs on this renderer as it is (no files: nothing is kept).
|
||
pub(crate) fn recorded<T>(&self, key: Option<u64>, make: impl FnOnce(&Renderer<'_>) -> T) -> (T, Arc<ReadFiles>) {
|
||
if key.is_none() || self.cache.is_none() {
|
||
return (make(self), Arc::default());
|
||
}
|
||
// (the generation before any read: a file dropped while `make` runs makes it stale)
|
||
let log = self.cache.and_then(LayerCache::drop_generation).map_or_else(ReadLog::new, ReadLog::starting_at);
|
||
let out = make(&Renderer { reads: Some(&log), ..*self });
|
||
let files = log.into_files();
|
||
if let Some(own) = self.reads {
|
||
own.note_all(&files);
|
||
}
|
||
(out, Arc::new(files))
|
||
}
|
||
|
||
/// A layer's Quality switch, limited by the precomp layers above it when
|
||
/// [`RenderOpts::nested_switches`] is on.
|
||
pub fn quality(&self, layer: &Layer) -> Quality {
|
||
let rank = |q: Quality| match q {
|
||
Quality::Best => 0,
|
||
Quality::Draft => 1,
|
||
Quality::Wireframe => 2,
|
||
};
|
||
match self.inherited {
|
||
Some((q, _)) if rank(q) > rank(layer.switches.quality) => q,
|
||
_ => layer.switches.quality,
|
||
}
|
||
}
|
||
|
||
/// What this renderer passes down to the layers it draws beyond their own switches
|
||
/// ([`RenderOpts::nested_switches`], the switches inherited from the precomp layers above,
|
||
/// motion blur on): part of the cache key of a buffer that reads other layers.
|
||
pub(crate) fn nested_look(&self) -> u64 {
|
||
let inherited = match self.inherited {
|
||
None => 0,
|
||
Some((q, mb)) => {
|
||
let q = match q {
|
||
Quality::Best => 0,
|
||
Quality::Draft => 1,
|
||
Quality::Wireframe => 2,
|
||
};
|
||
1 + q * 2 + u64::from(mb)
|
||
}
|
||
};
|
||
0x6e65_7374_0000_0000 | inherited << 2 | u64::from(self.opts.nested_switches) << 1 | u64::from(self.opts.motion_blur)
|
||
}
|
||
|
||
/// A layer's Motion Blur switch, limited by the precomp layers above it.
|
||
pub(crate) fn layer_motion_blur(&self, layer: &Layer) -> bool {
|
||
layer.switches.motion_blur && self.inherited.is_none_or(|(_, mb)| mb)
|
||
}
|
||
|
||
/// The renderer for the nested comp of precomp layer `layer`: its switches pass down when
|
||
/// [`RenderOpts::nested_switches`] is on.
|
||
fn nested_through(&self, layer: &Layer) -> Renderer<'a> {
|
||
let mut sub = self.nested();
|
||
if self.opts.nested_switches {
|
||
sub.inherited = Some((self.quality(layer), self.layer_motion_blur(layer)));
|
||
}
|
||
sub
|
||
}
|
||
|
||
/// The render options one level down (precomps, collapsed precomps, comps read by effects):
|
||
/// guide layers render only in their own comp, never when it is nested.
|
||
fn nested_opts(&self) -> RenderOpts {
|
||
RenderOpts { guides: false, ..self.opts }
|
||
}
|
||
|
||
/// A renderer for nested work (precomps, layers read by effects): one level deeper.
|
||
pub(crate) fn nested(&self) -> Renderer<'a> {
|
||
Renderer {
|
||
opts: self.nested_opts(),
|
||
depth: self.depth + 1,
|
||
outer: None,
|
||
opacity_mul: 1.0,
|
||
collapse3d: None,
|
||
defer_dof: false,
|
||
top: false,
|
||
through: [None; MAX_THROUGH],
|
||
through_lost: false,
|
||
..*self
|
||
}
|
||
}
|
||
|
||
/// A precomp layer's nested comp for an accelerator to render resident (no CPU render and
|
||
/// upload of the nested frame): the renderer one level down, the comp, its time and the
|
||
/// buffer scale, exactly as [`Self::layer_source`]'s comp branch would render it. `None`
|
||
/// when that branch does more (composition proxy, Essential Properties overrides, frame
|
||
/// blending, recursion) or draws nothing (outside the nested comp's span): use the CPU path.
|
||
pub fn precomp_source(&self, ctx: &EvalCtx, layer: &Layer) -> Option<(Renderer<'a>, ItemId, Tick, f64)> {
|
||
let LayerSource::Comp { item } = layer.source else { return None };
|
||
if layer.switches.adjustment || self.project.comp_contains(item, ctx.comp_id) {
|
||
return None;
|
||
}
|
||
let nc = self.project.comp(item)?;
|
||
let lt = ctx.nested_time(layer);
|
||
if self.content_times(ctx, layer).is_some() {
|
||
return None;
|
||
}
|
||
if !nc.covers(lt) || self.proxy_for(item).is_some() || !essential_overrides(ctx, layer).is_empty() || frame_blend_mode(ctx, layer) != FrameBlend::Off {
|
||
return None;
|
||
}
|
||
let s = self.opts.scale;
|
||
let bs = if s < 1.0 && nc.preserve_resolution { 1.0 } else { s };
|
||
let base = self.nested_through(layer);
|
||
Some((Renderer { opts: RenderOpts { scale: bs, ..base.opts }, ..base }, item, lt, bs))
|
||
}
|
||
|
||
/// The nested comp of a precomp layer whose transformations collapse into this comp: the
|
||
/// Collapse Transformations switch is on and the layer has no masks, effects or layer
|
||
/// styles (those force a flattened render of the precomp, as in After Effects). Essential
|
||
/// Properties overrides travel with it and are applied as the nested layers are evaluated,
|
||
/// so the nested comp is not rasterised at its frame.
|
||
pub fn collapsed(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Collapsed> {
|
||
if !layer.switches.collapse || layer.switches.adjustment || self.depth > 12 {
|
||
return None;
|
||
}
|
||
let LayerSource::Comp { item } = &layer.source else { return None };
|
||
if self.project.comp_contains(*item, ctx.comp_id) || self.project.comp(*item).is_none() {
|
||
return None;
|
||
}
|
||
if layer.masks().is_some_and(|m| !m.children.is_empty()) {
|
||
return None;
|
||
}
|
||
if layer.switches.effects && layer.effects().is_some_and(|fx| fx.groups().any(|g| g.enabled)) {
|
||
return None;
|
||
}
|
||
if styles::active(ctx, layer) {
|
||
return None;
|
||
}
|
||
// A composition proxy or frame blending is drawn by the flattened path (`precomp_source`);
|
||
// collapsing renders the nested comp directly and would ignore both.
|
||
if self.proxy_for(*item).is_some() || frame_blend_mode(ctx, layer) != FrameBlend::Off {
|
||
return None;
|
||
}
|
||
let (property_overrides, media_overrides) = collapsed_instance(ctx, layer, *item)?;
|
||
Some(Collapsed { item: *item, property_overrides, media_overrides })
|
||
}
|
||
|
||
/// A renderer and context that draw a collapsed precomp's layers straight into this comp:
|
||
/// their transforms are concatenated with the precomp layer's (one resample, no
|
||
/// rasterisation at the precomp's bounds), their opacity multiplied by `opacity`, and their
|
||
/// 3D layers use this comp's camera and lights.
|
||
pub fn collapse_into<'p>(&self, ctx: &EvalCtx<'a>, layer: &'a Layer, collapsed: &'p Collapsed, opacity: f32) -> Option<(Renderer<'p>, EvalCtx<'p>)>
|
||
where
|
||
'a: 'p,
|
||
{
|
||
let item = collapsed.item;
|
||
let nc = self.project.comp(item)?;
|
||
let t = ctx.nested_time(layer);
|
||
if !nc.covers(t) {
|
||
return None;
|
||
}
|
||
let nctx = EvalCtx {
|
||
comp_id: item,
|
||
comp: nc,
|
||
time: t,
|
||
property_overrides: &collapsed.property_overrides,
|
||
media_overrides: &collapsed.media_overrides,
|
||
..*ctx
|
||
};
|
||
let (l2c, _) = ctx.layer_to_comp(layer);
|
||
let outer = Some(self.outer.map_or(l2c, |o| o * l2c));
|
||
let world = ctx.world_matrix(layer);
|
||
let c3 = Collapse3d {
|
||
world: self.collapse3d.map_or(world, |c| c.world * world),
|
||
cam: three_d::compose::camera_for(self, ctx),
|
||
parent: self.collapse3d.map_or(*ctx, |c| c.parent),
|
||
};
|
||
// Motion blur follows the precomp layer's own motion too.
|
||
let link = Through { comp_id: ctx.comp_id, comp: ctx.comp, time: ctx.time, layer, blur: self.mb_on(ctx, layer) };
|
||
let mut through = self.through;
|
||
let slot = through.iter_mut().find(|t| t.is_none());
|
||
let through_lost = self.through_lost || slot.is_none();
|
||
if let Some(slot) = slot {
|
||
*slot = Some(link);
|
||
}
|
||
Some((
|
||
Renderer { opts: self.nested_opts(), depth: self.depth + 1, outer, opacity_mul: opacity, collapse3d: Some(c3), through, through_lost, ..*self },
|
||
nctx,
|
||
))
|
||
}
|
||
|
||
pub(crate) fn opacity_mul(&self) -> f32 {
|
||
self.opacity_mul
|
||
}
|
||
|
||
/// Draw a collapsed 2D precomp layer: nested layers blend straight into `canvas` with their
|
||
/// own modes (Normal precomp, no matte); otherwise they are drawn into an isolated buffer
|
||
/// that then takes the precomp layer's track matte, Preserve Transparency and blend mode.
|
||
/// Returns the region left to re-quantise (nested layers drawn straight into `canvas`
|
||
/// quantise their own footprints).
|
||
pub(crate) fn draw_collapsed(&self, ctx: &EvalCtx<'a>, layer: &'a Layer, collapsed: &Collapsed, canvas: &mut Image, blank: bool) -> Region {
|
||
let opacity = ctx.opacity(layer) as f32 * self.opacity_mul;
|
||
if opacity <= 0.0 {
|
||
return Region::Empty;
|
||
}
|
||
let matte = layer.track_matte.is_some_and(|tm| tm.layer != layer.id && ctx.comp.layer(tm.layer).is_some());
|
||
if layer.blend_mode == BlendMode::Normal && !matte && !layer.preserve_transparency {
|
||
if let Some((sub, nctx)) = self.collapse_into(ctx, layer, collapsed, opacity) {
|
||
sub.draw_comp(&nctx, canvas, blank);
|
||
}
|
||
return Region::Empty;
|
||
}
|
||
let Some((sub, nctx)) = self.collapse_into(ctx, layer, collapsed, 1.0) else { return Region::Empty };
|
||
let mut iso = Image::new(canvas.width, canvas.height);
|
||
sub.draw_comp(&nctx, &mut iso, true);
|
||
self.composite_iso(ctx, layer, iso, canvas, opacity);
|
||
Region::Full
|
||
}
|
||
|
||
/// Whether `layer`'s processed buffer (source → masks → effects) is cached and the same as
|
||
/// one frame earlier: drawing it from the layer cache (an accelerator uploads it once) beats
|
||
/// re-running its effects every frame.
|
||
pub fn layer_is_static(&self, ctx: &EvalCtx, layer: &Layer) -> bool {
|
||
if self.cache.is_none() {
|
||
return false;
|
||
}
|
||
let (scale, draft, blur) = (self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer));
|
||
let prev = ctx.time - ctx.comp.frame_rate.frame_duration();
|
||
let now = cache::layer_key(ctx, layer, scale, draft, blur);
|
||
now.is_some() && now == cache::layer_key(&ctx.at(prev), layer, scale, draft, blur)
|
||
}
|
||
|
||
/// Rasterisation scale of a layer's source: the output scale, or — for text and shape layers,
|
||
/// which are always continuously rasterised (#390, #751), and Continuously Rasterize vector
|
||
/// footage — the output scale times the layer's on-screen scale (quarter-octave steps, rounded
|
||
/// up, so vector content is drawn at its final size and stays sharp when scaled up).
|
||
pub fn raster_scale(&self, ctx: &EvalCtx, layer: &Layer) -> f64 {
|
||
let s = self.opts.scale;
|
||
let continuous = match layer.source {
|
||
LayerSource::Text | LayerSource::Shape => true,
|
||
LayerSource::Footage { item } => {
|
||
let item = ctx.media_item(layer, item);
|
||
layer.switches.collapse && matches!(self.project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if is_vector_footage(f))
|
||
}
|
||
_ => false,
|
||
};
|
||
if !continuous {
|
||
return s;
|
||
}
|
||
let (l2c, _) = ctx.layer_to_comp(layer);
|
||
let m = self.outer.map_or(l2c, |o| o * l2c);
|
||
let a = layer.transform().map(|tr| ctx.v3(layer, tr, "anchor", [0.0; 3])).unwrap_or([0.0; 3]);
|
||
let p0 = m.apply(vec2(a[0], a[1]));
|
||
let px = m.apply(vec2(a[0] + 1.0, a[1]));
|
||
let py = m.apply(vec2(a[0], a[1] + 1.0));
|
||
let k = ((px.x - p0.x).hypot(px.y - p0.y)).max((py.x - p0.x).hypot(py.y - p0.y));
|
||
if !k.is_finite() || k <= 1e-6 {
|
||
return s;
|
||
}
|
||
// The step is rounded up past rounding noise only: an unscaled layer whose position isn't
|
||
// a whole number maps the unit vectors to lengths a hair over 1, which must stay 1 (#390:
|
||
// rounded up to 2^¼, unscaled text was drawn larger and resampled, softer).
|
||
let mut k = 2f64.powf((k.log2() * 4.0 - 1e-6).ceil() / 4.0).clamp(1.0 / 16.0, 64.0);
|
||
// Keep the buffer within about 16 million pixels.
|
||
if let Some(b) = content_bounds(ctx, layer) {
|
||
let area = ((b[2] - b[0]) * (b[3] - b[1])).max(1.0) * s * s;
|
||
k = k.min((16.0e6 / area).sqrt().max(1.0));
|
||
}
|
||
s * k
|
||
}
|
||
|
||
/// Draw a Wireframe-quality layer: its bounds as a one-pixel outline.
|
||
pub(crate) fn draw_wireframe(&self, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image) {
|
||
for (x, y) in self.wireframe_pixels(ctx, layer, (canvas.width, canvas.height)) {
|
||
canvas.set(x, y, [1.0, 1.0, 1.0, 1.0]);
|
||
}
|
||
}
|
||
|
||
/// The evaluation context of `comp` at `t`, carrying this renderer's expressions, footage and read log.
|
||
pub(crate) fn ctx(&self, comp_id: ItemId, comp: &'a Comp, t: Tick) -> EvalCtx<'a> {
|
||
EvalCtx {
|
||
project: self.project,
|
||
comp_id,
|
||
comp,
|
||
time: t,
|
||
expr: self.expr,
|
||
footage: Some(self.footage),
|
||
property_overrides: &[],
|
||
media_overrides: &[],
|
||
reads: self.reads,
|
||
}
|
||
}
|
||
|
||
/// This renderer with another accelerator (or none): the passes of
|
||
/// [`passes::in_passes`] render with the accelerator, its CPU fallback without.
|
||
pub fn with_accel(&self, accel: Option<&'a dyn Accelerator>) -> Renderer<'a> {
|
||
Renderer { accel, ..*self }
|
||
}
|
||
|
||
/// The accelerator to use for this render, if any (see [`Backend`]).
|
||
pub fn active_accel(&self) -> Option<&'a dyn Accelerator> {
|
||
let a = self.accel?;
|
||
match self.opts.backend {
|
||
Backend::Cpu => None,
|
||
Backend::Gpu => Some(a),
|
||
Backend::Auto => self.project.settings.gpu_acceleration.then_some(a),
|
||
}
|
||
}
|
||
|
||
/// Render a composition at comp time `t` (transparent background, comp size × scale).
|
||
/// Top-level frames go to the GPU when [`Self::active_accel`] is set and handles them.
|
||
pub fn comp_frame(&self, comp_id: ItemId, t: Tick) -> Image {
|
||
// Classic 3D runs composite on the GPU too (M12.7), so Auto sends 3D comps there as well;
|
||
// Auto renders each comp on whichever compositor measured faster ([`AutoPick`]).
|
||
if self.depth == 0
|
||
&& let Some(a) = self.active_accel()
|
||
{
|
||
let auto = self.frame_auto(a, comp_id, false);
|
||
if auto.is_none_or(|(p, key)| p.choose(key)) {
|
||
let t0 = web_time::Instant::now();
|
||
if let Some(img) = a.comp_frame(self, comp_id, t) {
|
||
if let Some((p, key)) = auto {
|
||
p.record(key, true, t0.elapsed().as_secs_f64() * 1e3);
|
||
}
|
||
return img;
|
||
}
|
||
if let Some((p, key)) = auto {
|
||
p.declined(key);
|
||
}
|
||
}
|
||
let t0 = web_time::Instant::now();
|
||
let img = self.comp_frame_cpu(comp_id, t);
|
||
if let Some((p, key)) = auto {
|
||
p.record(key, false, t0.elapsed().as_secs_f64() * 1e3);
|
||
}
|
||
return img;
|
||
}
|
||
self.comp_frame_cpu(comp_id, t)
|
||
}
|
||
|
||
/// The Auto timing history and key for a top-level frame of `comp` (`None` unless the
|
||
/// backend is [`Backend::Auto`] and the accelerator keeps history).
|
||
pub fn frame_auto(&self, a: &'a dyn Accelerator, comp: ItemId, display: bool) -> Option<(&'a AutoPick, AutoKey)> {
|
||
if self.opts.backend != Backend::Auto || self.opts.roi.is_some() {
|
||
return None;
|
||
}
|
||
Some((a.auto_pick()?, AutoKey::new(comp, self.opts.scale, display)))
|
||
}
|
||
|
||
/// [`Self::comp_frame`] on the CPU compositor only.
|
||
pub fn comp_frame_cpu(&self, comp_id: ItemId, t: Tick) -> Image {
|
||
let Some(comp) = self.project.comp(comp_id) else { return Image::new(1, 1) };
|
||
let s = self.opts.scale;
|
||
let (w, h) = frame_size(comp.width, comp.height, s);
|
||
// Region of interest on a comp with 3D layers: render the frame and crop it. A region
|
||
// reaching past the comp frame (the Extended Viewer) renders Classic 3D natively — the
|
||
// planes project through the offset camera — and Advanced 3D as the frame, padded.
|
||
if self.depth == 0
|
||
&& let Some(r) = self.opts.roi
|
||
&& comp.has_3d()
|
||
&& (roi_inside(r, comp.width, comp.height) || comp.renderer == effectcraft_project::Renderer::Advanced3D)
|
||
{
|
||
let full = Renderer { opts: RenderOpts { roi: None, ..self.opts }, ..*self }.comp_frame(comp_id, t);
|
||
let (x0, y0) = ((r[0] * s).round() as i64, (r[1] * s).round() as i64);
|
||
let (rw, rh) = (((r[2] * s).round() as u32).max(1), ((r[3] * s).round() as u32).max(1));
|
||
let mut out = Image::new(rw, rh);
|
||
for y in 0..rh as i64 {
|
||
let sy = y + y0;
|
||
if sy < 0 || sy >= full.height as i64 {
|
||
continue;
|
||
}
|
||
for x in 0..rw as i64 {
|
||
let sx = x + x0;
|
||
if sx >= 0 && sx < full.width as i64 {
|
||
out.data[(y * rw as i64 + x) as usize] = full.data[(sy * full.width as i64 + sx) as usize];
|
||
}
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
let (w, h) = match self.roi_offset() {
|
||
Some((_, _, rw, rh)) => (rw, rh),
|
||
None => (w, h),
|
||
};
|
||
let mut canvas = Image::new(w, h);
|
||
if self.depth > 16 {
|
||
return canvas;
|
||
}
|
||
let ctx = self.ctx(comp_id, comp, t);
|
||
self.draw_comp(&ctx, &mut canvas, true);
|
||
// The canvas is already quantised after every layer; only re-encoding changes it.
|
||
if let Some(c) = self.pipe.from_blend() {
|
||
color::convert(&mut canvas, &c);
|
||
self.pipe.quantize(&mut canvas);
|
||
}
|
||
// The top-level comp leaves the working space for the (sRGB) display / output.
|
||
let out = self.output_pipe();
|
||
if self.depth == 0
|
||
&& let Some(c) = out.output()
|
||
{
|
||
color::convert(&mut canvas, &c);
|
||
self.pipe.quantize(&mut canvas);
|
||
}
|
||
if self.depth == 0
|
||
&& let Some((lin, mode, enc)) = out.output_hdr()
|
||
{
|
||
color::output_hdr(&mut canvas, lin, mode, enc);
|
||
self.pipe.quantize(&mut canvas);
|
||
}
|
||
canvas
|
||
}
|
||
|
||
/// Draw a comp's layers (bottom to top) into `canvas` (blending space).
|
||
/// `blank`: the canvas is fully transparent (lets the first layer skip re-quantising).
|
||
fn draw_comp(&self, ctx: &EvalCtx<'a>, canvas: &mut Image, mut blank: bool) {
|
||
// Bottom-to-top, with runs of consecutive 3D layers depth-sorted (farthest first).
|
||
let visible = self.visible_layers(ctx);
|
||
let mut i = 0;
|
||
while i < visible.len() {
|
||
if visible[i].is_3d() {
|
||
let mut j = i;
|
||
while j < visible.len() && visible[j].is_3d() {
|
||
j += 1;
|
||
}
|
||
three_d::compose::draw_run(self, ctx, &visible[i..j], canvas);
|
||
i = j;
|
||
self.pipe.quantize(canvas);
|
||
blank = false;
|
||
} else {
|
||
let changed = match self.collapsed(ctx, visible[i]) {
|
||
Some(collapsed) => self.draw_collapsed(ctx, visible[i], &collapsed, canvas, blank),
|
||
None => self.draw_layer(ctx, visible[i], canvas, blank),
|
||
};
|
||
i += 1;
|
||
// 8/16 bpc: the comp is an integer buffer after every layer (only the pixels the
|
||
// layer touched need re-quantising).
|
||
self.pipe.quantize_region(canvas, changed);
|
||
blank = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Evaluate an effect instance's parameters.
|
||
fn effect_params(&self, ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup) -> Params {
|
||
// Nested groups (Paint strokes, Puppet meshes and pins) are flattened too.
|
||
effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr))
|
||
}
|
||
|
||
pub(crate) fn apply_effects(&self, ctx: &EvalCtx, layer: &Layer, buf: Buf, adjustment: bool) -> Buf {
|
||
self.apply_effects_timed(ctx, layer, buf, adjustment, usize::MAX, None)
|
||
}
|
||
|
||
/// Run the first `limit` effects of the layer's stack (by position; disabled ones count) on
|
||
/// `buf` (layer space, or the comp below for an adjustment layer).
|
||
fn apply_effects_timed(&self, ctx: &EvalCtx, layer: &Layer, mut buf: Buf, adjustment: bool, limit: usize, timing: Option<&mut Vec<(String, f64)>>) -> Buf {
|
||
// The comp below an adjustment layer is not in its layer space: it stays as it is.
|
||
let origin = if adjustment { [0.0; 2] } else { ctx.effect_bounds(layer).1 };
|
||
buf.rebase(origin);
|
||
let mut out = self.effect_space_stack(ctx, layer, buf, EffectStack { adjustment, limit, origin }, timing);
|
||
out.rebase([-origin[0], -origin[1]]);
|
||
out
|
||
}
|
||
|
||
/// [`Renderer::apply_effects_timed`] on a buffer already in effect space (measured from
|
||
/// `origin`, in layer coordinates); the result stays in effect space.
|
||
fn effect_space_stack(&self, ctx: &EvalCtx, layer: &Layer, buf: Buf, stack: EffectStack, timing: Option<&mut Vec<(String, f64)>>) -> Buf {
|
||
let mut t = CpuFx { accel: self.active_accel(), levels: self.pipe.levels, buf: Some(buf) };
|
||
self.run_effect_stack(ctx, layer, stack, timing, &mut t);
|
||
t.buf.unwrap_or_else(|| Buf { img: Image::new(1, 1), offset: [0.0; 2], scale: self.opts.scale })
|
||
}
|
||
|
||
/// Run the layer's video effect stack on `target` (in effect space, measured from `origin`
|
||
/// in layer coordinates): runs of GPU-capable effects go to [`FxTarget::gpu`] (when an
|
||
/// accelerator is active and supports them), the rest to [`FxTarget::cpu`] one by one
|
||
/// (quantised to the bit depth after each).
|
||
pub(crate) fn run_effect_stack(
|
||
&self,
|
||
ctx: &EvalCtx,
|
||
layer: &Layer,
|
||
stack: EffectStack,
|
||
mut timing: Option<&mut Vec<(String, f64)>>,
|
||
target: &mut dyn FxTarget,
|
||
) {
|
||
let EffectStack { adjustment, limit, origin } = stack;
|
||
if !layer.switches.effects {
|
||
return;
|
||
}
|
||
let Some(fx) = layer.effects() else { return };
|
||
let lt = layer.layer_time(ctx.time);
|
||
let layer_size = ctx.effect_bounds(layer).0;
|
||
// #659 / #1205: an authored layer's buffer spans only its painted content. Effects that
|
||
// place pixels outside it grow the buffer themselves, by exactly what they need
|
||
// (`layer_cover_pad`: CC Tiler, CC Smear, CC Mosaic sharp) instead of a blanket cover of
|
||
// the whole layer rectangle on every stack — which paid a comp-sized texture per layer,
|
||
// per frame in flight, for glow and blur that never use the extra room. The buffer still
|
||
// takes the cover's pixel-grid phase: a downscaled layer resamples along that grid.
|
||
// Effects not known to stay inside their own margins (Twirl, Bulge, Transform, ...) still
|
||
// get the cover: they move content past the painted bounds without growing the buffer.
|
||
let video = || {
|
||
fx.groups().filter(|g| g.enabled).filter_map(|g| match &g.kind {
|
||
GroupKind::Effect { effect } => effectcraft_effects::find(effect).filter(|s| !effectcraft_effects::audio_fx::is_audio_effect(s.id)),
|
||
_ => None,
|
||
})
|
||
};
|
||
if !adjustment && video().next().is_some() {
|
||
if video().all(|s| s.skips_cover()) {
|
||
target.align(layer_size);
|
||
} else {
|
||
target.cover(layer_size);
|
||
}
|
||
}
|
||
let mut mask_shapes = masks::shapes(ctx, layer);
|
||
if origin != [0.0; 2] {
|
||
for q in mask_shapes.iter_mut().flat_map(|m| m.points.iter_mut()) {
|
||
*q = [q[0] - origin[0], q[1] - origin[1]];
|
||
}
|
||
}
|
||
// Capture once, before preceding effects can expand or replace the input. An
|
||
// adjustment stack's original is the comp below, not its solid source.
|
||
let needs_original =
|
||
fx.groups().take(limit).any(|g| g.enabled && matches!(&g.kind, GroupKind::Effect { effect } if effect == "ec.channel.cccomposite"));
|
||
let mut original = None;
|
||
if needs_original {
|
||
target.cpu(&mut |buf| {
|
||
original = Some(buf.clone());
|
||
buf
|
||
});
|
||
}
|
||
let host = FxHost { r: self, ctx, layer, origin, index: 0, original: original.as_ref() };
|
||
let env = EffectEnv {
|
||
masks: &mask_shapes,
|
||
host: Some(&host),
|
||
comp_time: ctx.time.seconds(),
|
||
frame_rate: ctx.comp.frame_rate.as_f64(),
|
||
effect_index: 0,
|
||
working_space: self.pipe.space,
|
||
working_linear: self.pipe.linear,
|
||
float_depth: self.pipe.levels.is_none(),
|
||
shutter: self.mb_on(ctx, layer).then_some((ctx.comp.shutter_angle, ctx.comp.shutter_phase, ctx.comp.motion_blur_samples)),
|
||
layer_span: crate::eval::layer_span(self.project, layer),
|
||
};
|
||
// Video effects in stack order (index, group, spec); disabled and audio effects skipped.
|
||
let stack: Vec<(usize, &effectcraft_project::PropGroup, &'static effectcraft_effects::EffectSpec)> = fx
|
||
.groups()
|
||
.enumerate()
|
||
.take_while(|(i, _)| *i < limit)
|
||
.filter(|(_, g)| g.enabled)
|
||
.filter_map(|(i, g)| match &g.kind {
|
||
GroupKind::Effect { effect } => effectcraft_effects::find(effect).map(|s| (i, g, s)),
|
||
_ => None,
|
||
})
|
||
.filter(|(_, _, s)| !effectcraft_effects::audio_fx::is_audio_effect(s.id))
|
||
.collect();
|
||
let accel = self.active_accel();
|
||
let mut k = 0;
|
||
while k < stack.len() {
|
||
let (i, g, spec) = stack[k];
|
||
// A run of GPU effects goes to the accelerator in one go.
|
||
if let Some(a) = accel
|
||
&& spec.gpu
|
||
&& a.supports_effect(spec.id)
|
||
{
|
||
let run: Vec<_> = stack[k..].iter().take_while(|(_, _, s)| s.gpu && a.supports_effect(s.id)).collect();
|
||
let params: Vec<Params> = run.iter().map(|(_, g, _)| self.effect_params(ctx, layer, g)).collect();
|
||
let hosts: Vec<_> = run.iter().map(|(i, _, _)| FxHost { index: *i, ..host }).collect();
|
||
let steps: Vec<FxStep> = run
|
||
.iter()
|
||
.zip(¶ms)
|
||
.zip(&hosts)
|
||
.map(|(((i, g, s), params), host)| FxStep {
|
||
spec: s,
|
||
ctx: EffectCtx {
|
||
params,
|
||
time: lt.seconds(),
|
||
layer_size,
|
||
seed: g.uid as u32,
|
||
adjustment,
|
||
env: EffectEnv { host: Some(host), effect_index: *i, ..env },
|
||
},
|
||
})
|
||
.collect();
|
||
let t0 = web_time::Instant::now();
|
||
if target.gpu(&steps) {
|
||
if let Some(v) = timing.as_deref_mut() {
|
||
let ms = t0.elapsed().as_secs_f64() * 1e3 / run.len() as f64;
|
||
v.extend(run.iter().map(|(_, _, s)| (format!("{} (gpu)", s.id), ms)));
|
||
}
|
||
k += run.len();
|
||
continue;
|
||
}
|
||
}
|
||
k += 1;
|
||
let params = self.effect_params(ctx, layer, g);
|
||
let host = FxHost { index: i, ..host };
|
||
let env = EffectEnv { host: Some(&host), effect_index: i, ..env };
|
||
let ectx = EffectCtx { params: ¶ms, time: lt.seconds(), layer_size, seed: g.uid as u32, adjustment, env };
|
||
let t0 = web_time::Instant::now();
|
||
target.cpu(&mut |buf| {
|
||
let mut buf = effectcraft_effects::apply(spec, &ectx, buf);
|
||
// 8/16 bpc effects write integer pixels.
|
||
self.pipe.quantize(&mut buf.img);
|
||
buf
|
||
});
|
||
if let Some(v) = timing.as_deref_mut() {
|
||
v.push((spec.id.to_string(), t0.elapsed().as_secs_f64() * 1e3));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Source pixels of a layer in layer space (before masks/effects).
|
||
pub(crate) fn source(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Buf> {
|
||
let s = self.opts.scale;
|
||
match &layer.source {
|
||
LayerSource::Solid { item } => {
|
||
let ItemKind::Solid(sol) = &self.project.item(*item)?.kind else { return None };
|
||
let w = ((sol.width as f64 * s).ceil() as u32).max(1);
|
||
let h = ((sol.height as f64 * s).ceil() as u32).max(1);
|
||
if layer.switches.adjustment {
|
||
return Some(Buf { img: Image::filled(w, h, [1.0; 4]), offset: [0.0; 2], scale: s });
|
||
}
|
||
let mut c = sol.color;
|
||
if let Some(conv) = self.pipe.authored_in() {
|
||
c = conv.apply(c);
|
||
}
|
||
let px = self.pipe.quantize_px([c[0], c[1], c[2], 1.0]);
|
||
Some(Buf { img: Image::filled(w, h, px), offset: [0.0; 2], scale: s })
|
||
}
|
||
LayerSource::Comp { item } => {
|
||
if self.project.comp_contains(*item, ctx.comp_id) {
|
||
return None;
|
||
}
|
||
// A composition proxy (a rendered still or movie) stands in for the comp. It is
|
||
// decoded like footage: its Separate Fields, Invert Alpha and Interpret As Linear
|
||
// Light settings and the layer's Frame Blending apply. Nothing before the nested
|
||
// comp starts or after it ends.
|
||
let lt = ctx.nested_time(layer);
|
||
if !self.project.comp(*item).is_some_and(|nc| nc.covers(lt)) {
|
||
return None;
|
||
}
|
||
if let Some(pf) = self.proxy_for(*item)
|
||
&& let Some(nc) = self.project.comp(*item)
|
||
{
|
||
let img = self.footage_frame(ctx, layer, *item, pf, lt, None)?;
|
||
return Some(self.footage_buf(&img, pf, nc.width, nc.height));
|
||
}
|
||
// Essential Properties overrides render the nested comp with this instance's
|
||
// values.
|
||
let ov = essential_overrides(ctx, layer);
|
||
let tmp = if ov.is_empty() { None } else { effectcraft_project::essential::with_overrides(self.project, *item, &ov) };
|
||
// Preserve resolution when nested: drawn at full size even when this comp renders
|
||
// smaller (the buffer's scale places it).
|
||
let full = s < 1.0 && self.project.comp(*item).is_some_and(|nc| nc.preserve_resolution);
|
||
let bs = if full { 1.0 } else { s };
|
||
let base = self.nested_through(layer);
|
||
let base = Renderer { opts: RenderOpts { scale: bs, ..base.opts }, ..base };
|
||
let sub = match &tmp {
|
||
Some(p) => Renderer { project: p, ..base },
|
||
None => base,
|
||
};
|
||
// Frame blending between the nested comp's frames (time-stretched/remapped).
|
||
let mode = frame_blend_mode(ctx, layer);
|
||
if mode != FrameBlend::Off
|
||
&& let Some(nc) = self.project.comp(*item)
|
||
{
|
||
let (i, w) = frame_position(lt, nc.frame_rate);
|
||
if w > 1e-6 {
|
||
let a = sub.comp_frame(*item, nc.frame_rate.tick_of(i));
|
||
let b = sub.comp_frame(*item, nc.frame_rate.tick_of(i + 1));
|
||
return Some(Buf { img: blend_frames(mode, &a, &b, w as f32), offset: [0.0; 2], scale: bs });
|
||
}
|
||
}
|
||
Some(Buf { img: sub.comp_frame(*item, lt), offset: [0.0; 2], scale: bs })
|
||
}
|
||
LayerSource::Footage { item } => {
|
||
let item = ctx.media_item(layer, *item);
|
||
let it = self.project.item(item)?;
|
||
let ItemKind::Footage(f) = &it.kind else { return None };
|
||
if !f.has_video {
|
||
return None;
|
||
}
|
||
// Continuously rasterised vector footage: drawn at the on-screen scale.
|
||
if layer.switches.collapse && is_vector_footage(f) {
|
||
let k = self.raster_scale(ctx, layer);
|
||
if let Some(img) = self.read(|| self.footage.vector_frame(item, f, k)) {
|
||
let mut buf = Buf { img: (*img).clone(), offset: [0.0; 2], scale: k };
|
||
if let Some(c) = self.pipe.media_in(f) {
|
||
color::convert(&mut buf.img, &c);
|
||
}
|
||
return Some(buf);
|
||
}
|
||
}
|
||
let lt = ctx.source_time(layer);
|
||
// The proxy is decoded instead, at its own size, and fills the footage's frame.
|
||
let proxy = self.proxy_for(item);
|
||
let pf = proxy.unwrap_or(f);
|
||
let size = self.reduced_size(f.width, f.height).filter(|_| proxy.is_none());
|
||
let img = self.footage_frame(ctx, layer, item, pf, lt, size)?;
|
||
Some(self.footage_buf(&img, pf, f.width, f.height))
|
||
}
|
||
// (the font files go to this render's log, which `ctx` may not carry: see
|
||
// [`Renderer::with_reads`])
|
||
LayerSource::Text => {
|
||
// An Expression Selector evaluates per character: its results at other characters
|
||
// than the first are not in the layer's cache key (an amount reading a footage
|
||
// item's size by name, `textIndex == 1 ? 0 : footage("ref.png").width`), so the
|
||
// buffer is kept in memory only, never on disk (the frame's key covers them).
|
||
if cache::per_character_expressions(layer)
|
||
&& let Some(log) = self.reads
|
||
{
|
||
log.mark_unkeyed();
|
||
}
|
||
Some(self.authored(text::render(&self.with_reads(ctx), layer, self.raster_scale(ctx, layer))))
|
||
}
|
||
LayerSource::Shape => layer.props.sub("contents").map(|c| self.authored(shapes::render(ctx, layer, c, self.raster_scale(ctx, layer)))),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// The proxy footage used for `item` under this render's Proxy Use, if any.
|
||
pub fn proxy_for(&self, item: ItemId) -> Option<&'a Footage> {
|
||
let it = self.project.item(item)?;
|
||
let px = it.proxy.as_deref()?;
|
||
(self.opts.proxy.uses(matches!(it.kind, ItemKind::Comp(_)), px.enabled) && px.footage.has_video).then_some(&px.footage)
|
||
}
|
||
|
||
/// A decoded frame of `f` as a layer buffer covering `w`×`h` layer pixels (the footage's
|
||
/// nominal size; proxies are scaled up to it), converted from its colour profile.
|
||
fn footage_buf(&self, img: &Image, f: &Footage, w: u32, h: u32) -> Buf {
|
||
let s = self.opts.scale;
|
||
let k = if img.width > 0 { w.max(1) as f64 / img.width as f64 } else { 1.0 };
|
||
// Keep native pixels when downsampling is small; resample otherwise.
|
||
let mut buf = match self.reduced_size(w, h) {
|
||
// The source resampled it already (`FootageSource::frame_at_size`).
|
||
Some(size) if (img.width, img.height) == size => Buf { img: img.clone(), offset: [0.0; 2], scale: s },
|
||
Some((rw, rh)) if k <= 1.0 + 1e-9 => Buf { img: effectcraft_raster::resample(img, rw, rh), offset: [0.0; 2], scale: s },
|
||
_ => Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 / k },
|
||
};
|
||
if let Some(c) = self.pipe.media_in(f) {
|
||
color::convert(&mut buf.img, &c);
|
||
}
|
||
buf
|
||
}
|
||
|
||
/// The size of `w` × `h` layer pixels in this render when it is reduced enough that footage
|
||
/// is resampled to it (below 3/4 scale).
|
||
fn reduced_size(&self, w: u32, h: u32) -> Option<(u32, u32)> {
|
||
let s = self.opts.scale;
|
||
(s < 0.75).then(|| (((w as f64 * s).round() as u32).max(1), ((h as f64 * s).round() as u32).max(1)))
|
||
}
|
||
|
||
/// Authored colours into the working space (linear working spaces).
|
||
fn authored(&self, mut buf: Buf) -> Buf {
|
||
if let Some(c) = self.pipe.authored_in() {
|
||
color::convert(&mut buf.img, &c);
|
||
}
|
||
buf
|
||
}
|
||
|
||
/// A footage frame at source time `t`, frame-blended between the two nearest source frames
|
||
/// when the layer's Frame Blending switch (and the comp's Enable Frame Blending) is on and
|
||
/// `t` falls between frames (footage rate ≠ comp rate, time stretch, time remapping).
|
||
/// `size`: a plain frame may come resampled to it ([`FootageSource::frame_at_size`]).
|
||
fn footage_frame(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option<Arc<Image>> {
|
||
// Interpret Footage ▸ Separate Fields: each field is a frame at twice the rate.
|
||
if f.fields != effectcraft_project::FieldOrder::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) {
|
||
let field_rate = FrameRate::new(f.frame_rate.num * 2, f.frame_rate.den);
|
||
let i = field_rate.frame_at(t).max(0);
|
||
let img = cineon_frame(self.read(|| self.footage.frame_with(item, f, f.frame_rate.tick_of(i / 2), None, self.pipe.float_curve(f)))?, f);
|
||
let dominant_upper = f.fields == effectcraft_project::FieldOrder::UpperFirst;
|
||
// Upper field = even lines (0, 2, …).
|
||
let parity = if (i % 2 == 0) == dominant_upper { 0 } else { 1 };
|
||
return Some(Arc::new(interpret_pixels(&field_frame(&img, parity), f, self.pipe.linear_footage_space(f))));
|
||
}
|
||
// Interpretation is per pixel but not linear: it runs on the frame before resampling.
|
||
let per_pixel = f.invert_alpha || f.linear_light_applies();
|
||
let size = size.filter(|_| !(per_pixel || f.cineon_applies()));
|
||
let img = self.footage_frame_raw(ctx, layer, item, f, t, size)?;
|
||
if per_pixel { Some(Arc::new(interpret_pixels(&img, f, self.pipe.linear_footage_space(f)))) } else { Some(img) }
|
||
}
|
||
|
||
/// The footage frame at source time `t` after frame blending: the average of the source frames one comp frame spans, else a blend of the two neighbouring frames, else the plain frame.
|
||
/// Log scans are converted first, and blended frames are cached.
|
||
fn footage_frame_raw(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option<Arc<Image>> {
|
||
let mode = frame_blend_mode(ctx, layer);
|
||
let float = self.pipe.float_curve(f);
|
||
if mode != FrameBlend::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) {
|
||
let (i, w) = frame_position(t, f.frame_rate);
|
||
if w > 1e-6 {
|
||
// Blended frames are cached (Pixel Motion is expensive).
|
||
let key = self.cache.map(|_| {
|
||
use std::hash::{Hash, Hasher};
|
||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||
(0x00f8_a3e5_u64, item, i, (w as f32).to_bits(), mode as u8, f.frame_rate.num, f.frame_rate.den, &f.path, float).hash(&mut h);
|
||
// Every file of a sequence: the key must tell two projects apart whose items
|
||
// share an id and a first frame (on disk, no project-wide salt does, #3184).
|
||
f.sequence.hash(&mut h);
|
||
(f.alpha as u8).hash(&mut h);
|
||
if f.alpha == effectcraft_project::AlphaMode::Premultiplied {
|
||
f.premul_color.map(f32::to_bits).hash(&mut h);
|
||
}
|
||
if f.cineon_applies() {
|
||
let c = &f.cineon;
|
||
[c.converted_black, c.converted_white, c.black, c.white, c.gamma, c.rolloff].map(f32::to_bits).hash(&mut h);
|
||
}
|
||
h.finish()
|
||
});
|
||
if let Some((b, _)) = self.cached(key) {
|
||
return Some(Arc::new(b.img.clone()));
|
||
}
|
||
// Log scans are converted before they blend: the conversion is not linear.
|
||
let ((a, b), files) = self.recorded(key, |r| {
|
||
let frame = |i| r.read(|| r.footage.frame_with(item, f, f.frame_rate.tick_of(i), None, float)).map(|a| cineon_frame(a, f));
|
||
(frame(i), frame(i + 1))
|
||
});
|
||
if let (Some(a), Some(b)) = (a, b) {
|
||
let img = blend_frames(mode, &a, &b, w as f32);
|
||
let _kept = self.keep(key, Arc::new(Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 }), &files);
|
||
return Some(Arc::new(img));
|
||
}
|
||
}
|
||
}
|
||
self.read(|| self.footage.frame_with(item, f, t, size, float)).map(|img| cineon_frame(img, f))
|
||
}
|
||
|
||
/// Source → masks, clamped/quantised to the project depth.
|
||
fn masked_source(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Buf> {
|
||
let mut buf = match self.content_times(ctx, layer) {
|
||
Some(times) => self.blurred_source(ctx, layer, ×)?,
|
||
None => self.source(ctx, layer)?,
|
||
};
|
||
// A solid is filled with a quantised colour: only masks can take it off the grid.
|
||
let solid = matches!(layer.source, LayerSource::Solid { .. });
|
||
if masks::apply(ctx, layer, &mut buf, self.mask_blur(ctx, layer)) || !solid {
|
||
self.pipe.quantize(&mut buf.img);
|
||
}
|
||
Some(buf)
|
||
}
|
||
|
||
/// Fully processed layer buffer (source → masks → effects → layer styles, flattened).
|
||
/// Served from the layer cache when the layer's content key matches.
|
||
pub fn layer_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Arc<Buf>> {
|
||
self.layer_buf_keyed(ctx, layer).map(|(b, _, _)| b)
|
||
}
|
||
|
||
/// [`Self::layer_buf`] in the blending space (linear with Blend Colors Using 1.0 Gamma).
|
||
pub fn blend_layer_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Arc<Buf>> {
|
||
let (b, k, files) = self.layer_buf_keyed(ctx, layer)?;
|
||
Some(self.to_blend(b, k, &files))
|
||
}
|
||
|
||
/// A buffer converted to the blending space (cached under a key derived from `key`, with
|
||
/// `files`, the source files `b` was made of).
|
||
pub(crate) fn to_blend(&self, b: Arc<Buf>, key: Option<u64>, files: &Arc<ReadFiles>) -> Arc<Buf> {
|
||
let Some(c) = self.pipe.to_blend() else { return b };
|
||
let key = key.map(|k| cache::derive(k, 0x1ea7));
|
||
if let Some((x, _)) = self.cached(key) {
|
||
return x;
|
||
}
|
||
let mut nb = (*b).clone();
|
||
color::convert(&mut nb.img, &c);
|
||
self.keep(key, Arc::new(nb), files).0
|
||
}
|
||
|
||
/// The flattened layer buffer, its cache key and the source files it was made of.
|
||
fn layer_buf_keyed(&self, ctx: &EvalCtx, layer: &Layer) -> Option<(Arc<Buf>, Option<u64>, Arc<ReadFiles>)> {
|
||
let st = self.styled(ctx, layer, None)?;
|
||
if st.passes.is_empty() {
|
||
return Some((st.body, st.key, st.files));
|
||
}
|
||
let key = st.key.map(|k| cache::derive(k, 0xf1a7));
|
||
if let Some((b, files)) = self.cached(key) {
|
||
return Some((b, key, files));
|
||
}
|
||
let flat = Arc::new(styles::Styled { passes: st.passes.iter().map(|(b, m)| ((**b).clone(), *m)).collect(), body: (*st.body).clone() }.flatten());
|
||
let (flat, files) = self.keep(key, flat, &st.files);
|
||
Some((flat, key, files))
|
||
}
|
||
|
||
/// A layer's source pixels at the context time, before masks, effects and the transform
|
||
/// (what the motion tracker analyses, like After Effects' Layer panel). `None` for layers
|
||
/// without pixels.
|
||
pub fn layer_source(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Buf> {
|
||
self.source(ctx, layer)
|
||
}
|
||
|
||
/// A 3D model or primitive layer rendered on its own, through the comp's camera: what an
|
||
/// effect layer parameter reads when it references one, as After Effects, which renders 3D
|
||
/// layers through the active camera. The comp's pixels at this render's scale; the layer's
|
||
/// blend mode, track matte and video switch don't apply (a source read, not a composite —
|
||
/// the solo clone of a 3D track matte, see `three_d::adv`).
|
||
pub(crate) fn solo_3d(&self, ctx: &EvalCtx, layer: &Layer) -> Buf {
|
||
let s = self.opts.scale;
|
||
let w = ((ctx.comp.width as f64 * s).ceil() as u32).max(1);
|
||
let h = ((ctx.comp.height as f64 * s).ceil() as u32).max(1);
|
||
let mut canvas = Image::new(w, h);
|
||
// Outside the layer's in/out points there is nothing to read: the copy is transparent
|
||
// there, like any other layer reference (the 3D pipelines don't gate on the range).
|
||
if !layer.is_active_at(ctx.time) {
|
||
return Buf { img: canvas, offset: [0.0; 2], scale: s };
|
||
}
|
||
let mut solo = layer.clone();
|
||
solo.track_matte = None;
|
||
solo.blend_mode = effectcraft_color::BlendMode::Normal;
|
||
solo.preserve_transparency = false;
|
||
solo.switches.video = true;
|
||
three_d::compose::draw_run(self, ctx, &[&solo], &mut canvas);
|
||
Buf { img: canvas, offset: [0.0; 2], scale: s }
|
||
}
|
||
|
||
/// Layer pixels after source → masks → effects (no layer styles).
|
||
pub fn content_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Arc<Buf>> {
|
||
self.content_buf_timed(ctx, layer, None).map(|(b, _, _)| b)
|
||
}
|
||
|
||
/// [`Self::content_buf`], its cache key and the source files it was made of.
|
||
pub(crate) fn content_buf_timed(
|
||
&self,
|
||
ctx: &EvalCtx,
|
||
layer: &Layer,
|
||
mut timing: Option<&mut LayerTiming>,
|
||
) -> Option<(Arc<Buf>, Option<u64>, Arc<ReadFiles>)> {
|
||
let key = self.cache.and_then(|_| cache::layer_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer)));
|
||
let key = self.content_key(ctx, layer, key);
|
||
if let Some((b, files)) = self.cached(key) {
|
||
if let Some(t) = timing.as_deref_mut() {
|
||
t.cached = true;
|
||
}
|
||
return Some((b, key, files));
|
||
}
|
||
let (buf, files) = self.recorded(key, |r| {
|
||
// (what expressions read while it is made, at shutter times too, goes to its files)
|
||
let ctx = &r.with_reads(ctx);
|
||
let buf = r.masked_source(ctx, layer)?;
|
||
Some(r.apply_effects_timed(ctx, layer, buf, false, usize::MAX, timing.map(|t| &mut t.effects)))
|
||
});
|
||
let (buf, files) = self.keep(key, Arc::new(buf?), &files);
|
||
Some((buf, key, files))
|
||
}
|
||
|
||
/// A layer's input at the context time: source → masks → its first `effects` effects
|
||
/// (what Time effects read at neighbouring times), in effect space (see [`effect_bounds`]):
|
||
/// what effect `effects` sees. Served from / stored in the layer cache under its own key,
|
||
/// so scrubbing reuses frames rendered for earlier output frames.
|
||
pub fn layer_input(&self, ctx: &EvalCtx, layer: &Layer, effects: usize) -> Option<Arc<Buf>> {
|
||
let key = self.cache.and_then(|_| cache::input_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer), effects));
|
||
let key = self.content_key(ctx, layer, key);
|
||
if let Some((b, _)) = self.cached(key) {
|
||
return Some(b);
|
||
}
|
||
let (buf, files) = self.recorded(key, |r| {
|
||
let ctx = &r.with_reads(ctx);
|
||
let mut buf = r.masked_source(ctx, layer)?;
|
||
let origin = ctx.effect_bounds(layer).1;
|
||
buf.rebase(origin);
|
||
if effects > 0 {
|
||
buf = r.effect_space_stack(ctx, layer, buf, EffectStack { adjustment: false, limit: effects, origin }, None);
|
||
}
|
||
Some(buf)
|
||
});
|
||
Some(self.keep(key, Arc::new(buf?), &files).0)
|
||
}
|
||
}
|