mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-13 17:29:56 -06:00
* Render: memoized audio detection, bounded stretch, preroll, waveform, cache entries, 3D bounds, solids and selector time comp_has_audio walked a precomp again for every layer using it; each comp is answered once. A tiny stored stretch made one audio block fetch a vast source range; playback speed is capped at 100x. Effect preroll is capped at 10 s. Waveforms stop where the audio ends and summarise at most a day. Disk cache entries are decompressed only when their stated size matches what their dimensions need (and at most 1 GiB). Advanced 3D triangle bounds are clamped on both sides before narrowing. Solids larger than 16 Mpx are made at a lower scale. A camera-facing layer's parent recursion is bounded. A layer's Expression Selectors share one time allowance. A disk cache folder under the temp folder is made private and may not be a link. * address review: audio detection by breadth-first search, private cache folder resolved before the temp check, oversized layers not written, solid cap after rounding, selector static amount, viewport i32 clamp * address review: private-folder check skips the temp folder itself; override value in expression fallback; assert alias cache opens * address review: refuse the shared temp folder itself as a disk cache folder * address review: shared counted selector budget, 30 s preroll, cache hardening (shared temp roots, matte discard, exact LZ4 length, render version 7), orient nesting guard
1420 lines
71 KiB
Rust
1420 lines
71 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, AutoTicket, 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 footage layer's source as the decoded picture itself (see [`Renderer::footage_picture`]):
|
||
/// its buffer in layer space is `img` at offset `[0, 0]` and `scale`; in effect space (what
|
||
/// [`Renderer::layer_input`] gives) at `effect_offset`.
|
||
#[derive(Clone, Debug)]
|
||
pub struct FootagePicture {
|
||
pub img: Arc<Image>,
|
||
pub scale: f64,
|
||
pub effect_offset: [f64; 2],
|
||
}
|
||
|
||
/// How a decoded footage frame becomes a layer buffer ([`Renderer::footage_buf`]).
|
||
enum FootageLayout {
|
||
/// Its own pixels, at this buffer scale.
|
||
Native { scale: f64 },
|
||
/// Resampled to `width`×`height` at this buffer scale.
|
||
Resample { width: u32, height: u32, scale: f64 },
|
||
}
|
||
|
||
/// 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,
|
||
}
|
||
|
||
/// The most pixels a solid layer's buffer holds (as the other layer buffers are held to).
|
||
const MAX_SOLID_PIXELS: f64 = 16.0e6;
|
||
|
||
/// The scale and whole-pixel size of a `width`×`height` solid rendered at `scale`, lowered until
|
||
/// the rounded size (each side at least one pixel) is within [`MAX_SOLID_PIXELS`].
|
||
pub(crate) fn solid_size(width: u32, height: u32, mut scale: f64) -> (f64, u32, u32) {
|
||
let size = |s: f64| (((width as f64 * s).ceil() as u32).max(1), ((height as f64 * s).ceil() as u32).max(1));
|
||
// Rounding up leaves an elongated solid over the cap after one step; each step shrinks the
|
||
// overshoot geometrically, so a few are enough.
|
||
for i in 0..64 {
|
||
let (w, h) = size(scale);
|
||
let area = w as f64 * h as f64;
|
||
if area <= MAX_SOLID_PIXELS {
|
||
break;
|
||
}
|
||
// The first step is the exact area ratio; later ones undershoot a little so rounding up
|
||
// cannot leave the size stuck just over the cap.
|
||
scale *= (MAX_SOLID_PIXELS / area).sqrt() * if i == 0 { 1.0 } else { 0.99 };
|
||
}
|
||
let (w, h) = size(scale);
|
||
(scale, w, h)
|
||
}
|
||
|
||
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,
|
||
whole_raster: 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,
|
||
// Effects and nested renders need whole layer-space pixels.
|
||
whole_raster: true,
|
||
..*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 before = ctx.at(prev);
|
||
let now = self.clipped_key(ctx, layer, cache::layer_key(ctx, layer, scale, draft, blur));
|
||
now.is_some() && now == self.clipped_key(&before, layer, cache::layer_key(&before, 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 (of the part the output shows, when
|
||
// only that is rasterised: see `raster_clip`).
|
||
if let Some(b) = content_bounds(ctx, layer) {
|
||
let b = match self.raster_clip(ctx, layer) {
|
||
Some(c) => {
|
||
let r = kurbo::Rect::new(b[0], b[1], b[2], b[3]).intersect(c);
|
||
[r.x0, r.y0, r.x1, r.y1]
|
||
}
|
||
None => b,
|
||
};
|
||
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
|
||
}
|
||
|
||
/// The layer-space part of a 3D text or shape layer the output shows, when only that part
|
||
/// needs rasterising (#1388: text close to the camera was rasterised whole at up to 16
|
||
/// million pixels, a quarter gigabyte per frame worker, though the frame showed a fraction
|
||
/// of it; behind the camera it was rasterised and not drawn). `None` rasterises it whole:
|
||
/// whenever pixels outside the frame can matter — effects, layer styles, masks, motion blur,
|
||
/// depth of field, lights (shadows and transmission), Advanced 3D, per-character 3D, a
|
||
/// collapsed 2D transform — or the visible part can't be bounded (see
|
||
/// [`three_d::compose::visible_clip`]).
|
||
pub(crate) fn raster_clip(&self, ctx: &EvalCtx, layer: &Layer) -> Option<kurbo::Rect> {
|
||
if self.whole_raster || !layer.is_3d() || !matches!(layer.source, LayerSource::Text | LayerSource::Shape) || self.outer.is_some() {
|
||
return None;
|
||
}
|
||
if (layer.switches.effects && layer.effects().is_some_and(|fx| !fx.children.is_empty()))
|
||
|| layer.masks().is_some_and(|m| !m.children.is_empty())
|
||
|| styles::active(ctx, layer)
|
||
|| ctx.comp.layers.iter().any(|l| matches!(l.source, LayerSource::Light { .. }))
|
||
|| self.is_adv3d(ctx)
|
||
|| (matches!(layer.source, LayerSource::Text) && (text::per_char_3d(ctx, layer) || text::animator_blur(ctx, layer)))
|
||
{
|
||
return None;
|
||
}
|
||
let v = three_d::compose::visible_clip(self, ctx, layer)?;
|
||
if v.area() <= 0.0 {
|
||
// Behind the camera.
|
||
return Some(v);
|
||
}
|
||
let b = content_bounds(ctx, layer).map(|b| kurbo::Rect::new(b[0], b[1], b[2], b[3]))?;
|
||
if v.x0 <= b.x0 && v.y0 <= b.y0 && v.x1 >= b.x1 && v.y1 >= b.y1 {
|
||
return None;
|
||
}
|
||
// Whole cells of a layer-space grid whose cell is about a quarter of the part that shows,
|
||
// rounded down to a power of two: a moving camera keeps one raster (and its cache entry)
|
||
// while that part stays within the same cells, and a dolly changes the grid only at each
|
||
// doubling. The cells cover at most about 2.25 times the part that shows. Worth it only
|
||
// when it leaves out at least half of the content.
|
||
let c = 2f64.powf(((v.width().max(v.height()) / 4.0).max(1e-6)).log2().floor());
|
||
let snap = |v: f64, up: bool| if up { (v / c).ceil() * c } else { (v / c).floor() * c };
|
||
// (Not cut to the content bounds: a stroke or a glyph's padding reaches past them.)
|
||
let cells = kurbo::Rect::new(snap(v.x0, false), snap(v.y0, false), snap(v.x1, true), snap(v.y1, true));
|
||
(cells.is_finite() && cells.intersect(b).area() <= b.area() * 0.5).then_some(cells)
|
||
}
|
||
|
||
/// A layer content cache key that also names the part rasterised ([`Self::raster_clip`]).
|
||
pub(crate) fn clipped_key(&self, ctx: &EvalCtx, layer: &Layer, key: Option<u64>) -> Option<u64> {
|
||
let k = key?;
|
||
Some(match self.raster_clip(ctx, layer) {
|
||
Some(c) => raster_clip_key(k, c),
|
||
None => 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 {
|
||
self.comp_frame_with(comp_id, t, false, |a| Ok::<_, std::convert::Infallible>(a.comp_frame(self, comp_id, t))).unwrap_or_else(|never| match never {})
|
||
}
|
||
|
||
/// Render a frame, validating the accelerated attempt before starting any CPU fallback.
|
||
/// The caller's scope must run only the accelerator, not this whole method: a GPU fault
|
||
/// must release failed resources before a potentially large CPU render (#3357).
|
||
/// A scope error returns immediately; the caller can then retry with a fresh CPU renderer
|
||
/// without accelerator-produced cache entries. Healthy declines and Auto timing retain
|
||
/// the same behavior as [`Self::comp_frame`]. Its fallback disables acceleration for
|
||
/// effects too: GPU work must never escape the supplied error scope.
|
||
pub fn comp_frame_scoped<E>(&self, comp_id: ItemId, t: Tick, scope: impl FnOnce(&dyn Accelerator) -> Result<Option<Image>, E>) -> Result<Image, E> {
|
||
self.comp_frame_with(comp_id, t, true, scope)
|
||
}
|
||
|
||
fn comp_frame_with<E>(&self, comp_id: ItemId, t: Tick, scoped: bool, scope: impl FnOnce(&dyn Accelerator) -> Result<Option<Image>, E>) -> Result<Image, E> {
|
||
let cpu = if scoped { self.with_accel(None) } else { self.with_accel(self.accel) };
|
||
// 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()
|
||
{
|
||
// (an error returns with the ticket, which ends the frame unmeasured)
|
||
let mut ticket = self.frame_auto(a, comp_id, false).map(|(p, key)| p.ticket(key));
|
||
if ticket.as_ref().is_none_or(AutoTicket::gpu) {
|
||
let t0 = web_time::Instant::now();
|
||
if let Some(img) = scope(a)? {
|
||
if let Some(t) = ticket {
|
||
t.record(t0.elapsed().as_secs_f64() * 1e3);
|
||
}
|
||
return Ok(img);
|
||
}
|
||
if let Some(t) = &mut ticket {
|
||
t.declined();
|
||
}
|
||
}
|
||
let t0 = web_time::Instant::now();
|
||
let img = cpu.comp_frame_cpu(comp_id, t);
|
||
if let Some(t) = ticket {
|
||
t.record(t0.elapsed().as_secs_f64() * 1e3);
|
||
}
|
||
return Ok(img);
|
||
}
|
||
Ok(cpu.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 };
|
||
// The LUT files the stack reads go to this render's files: a frame on disk made with one
|
||
// that changed since is not used (#3493).
|
||
if let Some(log) = self.reads {
|
||
for g in fx.groups().take(limit).filter(|g| g.enabled) {
|
||
for f in super::reads::lut_files(ctx, layer, g) {
|
||
log.note(f);
|
||
}
|
||
}
|
||
}
|
||
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> {
|
||
self.source_with(ctx, layer, false)
|
||
}
|
||
|
||
/// [`Renderer::source`]; with `quantized`, footage that [`Renderer::prequantized_footage`]
|
||
/// counts comes quantised to the project depth.
|
||
fn source_with(&self, ctx: &EvalCtx, layer: &Layer, quantized: bool) -> 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 };
|
||
// One colour: a solid larger than MAX_SOLID_PIXELS is made at a lower scale (the
|
||
// buffer carries it) instead of allocating gigabytes for a uniform plane.
|
||
let (s, w, h) = solid_size(sol.width, sol.height, s);
|
||
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, false));
|
||
}
|
||
// 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);
|
||
}
|
||
}
|
||
// 8 bpc plain footage: made from the footage's 8-bit levels, no float frame.
|
||
if quantized && let Some(buf) = self.footage_buf8(ctx, layer, item, self.proxy_for(item).unwrap_or(f), f, ctx.source_time(layer)) {
|
||
return Some(buf);
|
||
}
|
||
let (img, pf) = self.footage_picture_of(ctx, layer, item, f)?;
|
||
Some(self.footage_buf(&img, pf, f.width, f.height, quantized && Self::prequantized_footage(layer)))
|
||
}
|
||
// (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_in(&self.with_reads(ctx), layer, self.raster_scale(ctx, layer), self.raster_clip(ctx, layer))))
|
||
}
|
||
LayerSource::Shape => layer
|
||
.props
|
||
.sub("contents")
|
||
.map(|c| self.authored(shapes::render_in(ctx, layer, c, self.raster_scale(ctx, layer), self.raster_clip(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.
|
||
///
|
||
/// `quantized`: also clamp and quantise it to the project depth, as [`Renderer::masked_source`]
|
||
/// would (fused with the copy of the cached frame: one pass over it instead of three).
|
||
fn footage_buf(&self, img: &Image, f: &Footage, w: u32, h: u32, quantized: bool) -> Buf {
|
||
let c = self.pipe.media_in(f);
|
||
match self.footage_layout(img, w, h) {
|
||
FootageLayout::Native { scale } => {
|
||
let img = match (quantized, &c) {
|
||
(true, _) => self.pipe.copy_converted(img, c.as_ref()),
|
||
(false, Some(c)) => {
|
||
let mut img = img.clone();
|
||
color::convert(&mut img, c);
|
||
img
|
||
}
|
||
(false, None) => img.clone(),
|
||
};
|
||
Buf { img, offset: [0.0; 2], scale }
|
||
}
|
||
FootageLayout::Resample { width, height, scale } => {
|
||
let mut img = effectcraft_raster::resample(img, width, height);
|
||
if let Some(c) = &c {
|
||
color::convert(&mut img, c);
|
||
}
|
||
if quantized {
|
||
self.pipe.quantize(&mut img);
|
||
}
|
||
Buf { img, offset: [0.0; 2], scale }
|
||
}
|
||
}
|
||
}
|
||
|
||
/// [`Renderer::footage_buf`]'s quantised buffer made from the footage's 8-bit levels
|
||
/// ([`FootageSource::frame8`]), the same pixels to the bit, when nothing before the quantise
|
||
/// changes them: an 8 bpc render at Full size of plain footage (no masks, colour profile,
|
||
/// Separate Fields, Frame Blending, Cineon, Invert Alpha or linear light interpretation).
|
||
/// `None` otherwise, and when the source has no 8-bit frame.
|
||
// ponytail: the media pool keeps the 8-bit and the float frame as separate entries, so a
|
||
// movie shown by both a plain and a masked layer in one 8 bpc comp is decoded twice per
|
||
// frame; derive one from the other in the pool if a comp like that turns out common.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn footage_buf8(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, pf: &Footage, f: &Footage, t: Tick) -> Option<Buf> {
|
||
let plain = self.pipe.levels == Some(255.0)
|
||
&& Self::prequantized_footage(layer)
|
||
&& self.reduced_size(f.width, f.height).is_none()
|
||
&& self.pipe.media_in(pf).is_none()
|
||
&& pf.fields == effectcraft_project::FieldOrder::Off
|
||
&& !(pf.invert_alpha || pf.linear_light_applies() || pf.cineon_applies())
|
||
&& frame_blend_mode(ctx, layer) == FrameBlend::Off;
|
||
if !plain {
|
||
return None;
|
||
}
|
||
let fr = self.read(|| self.footage.frame8(item, pf, t, self.pipe.float_curve(pf)))?;
|
||
let n = (fr.width as usize).checked_mul(fr.height as usize)?;
|
||
let px = fr.rgba.as_chunks::<4>().0;
|
||
if px.len() != n {
|
||
return None;
|
||
}
|
||
use rayon::prelude::*;
|
||
let data = px.par_iter().map(|&p| effectcraft_raster::expand_u8(p)).collect();
|
||
let k = if fr.width > 0 { f.width.max(1) as f64 / fr.width as f64 } else { 1.0 };
|
||
Some(Buf { img: Image { width: fr.width, height: fr.height, data }, offset: [0.0; 2], scale: 1.0 / k })
|
||
}
|
||
|
||
/// The layer's source is footage that [`Renderer::source`] hands over already quantised
|
||
/// ([`Renderer::footage_buf`]): footage without masks (masks apply to unquantised pixels).
|
||
/// Collapsed layers may be vector footage, drawn elsewhere: not counted.
|
||
fn prequantized_footage(layer: &Layer) -> bool {
|
||
matches!(layer.source, LayerSource::Footage { .. }) && !layer.switches.collapse && layer.masks().is_none_or(|m| m.groups().next().is_none())
|
||
}
|
||
|
||
/// How [`Renderer::footage_buf`] lays out a decoded frame `img` of footage `w`×`h` layer
|
||
/// pixels: its own pixels, or resampled.
|
||
fn footage_layout(&self, img: &Image, w: u32, h: u32) -> FootageLayout {
|
||
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.
|
||
match self.reduced_size(w, h) {
|
||
// The source resampled it already (`FootageSource::frame_at_size`).
|
||
Some(size) if (img.width, img.height) == size => FootageLayout::Native { scale: s },
|
||
Some((width, height)) if k <= 1.0 + 1e-9 => FootageLayout::Resample { width, height, scale: s },
|
||
_ => FootageLayout::Native { scale: 1.0 / k },
|
||
}
|
||
}
|
||
|
||
/// A footage layer's decoded frame at the context time (the proxy's when one is used) and
|
||
/// the footage it was decoded from: what [`Renderer::source`] makes the layer's buffer of.
|
||
fn footage_picture_of(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &'a Footage) -> Option<(Arc<Image>, &'a Footage)> {
|
||
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((img, pf))
|
||
}
|
||
|
||
/// The decoded footage picture a layer's masked source is, bit for bit, with no copy: a
|
||
/// footage layer (not continuously rasterised vector footage) without masks whose frame
|
||
/// needs no colour conversion, no resampling and no quantisation (32 bpc). The buffer
|
||
/// [`Renderer::layer_input`] gives for no effects is this picture at `effect_offset` and
|
||
/// `scale`.
|
||
/// Layers showing the same frame get the same `Arc` (the footage source's cached frame), so
|
||
/// an accelerator uploads it once and shares the texture. `None`: the layer's buffer is
|
||
/// made the usual way (any of the above applies, or the frame is missing), and also when the
|
||
/// frame is made per call rather than served by the footage source (frame blending,
|
||
/// separate fields, Invert Alpha, Interpret As Linear Light, Cineon conversion: a new
|
||
/// picture each time, nothing to share) or when the layer cache already holds the layer's
|
||
/// buffer (served from memory as before, without asking the footage source).
|
||
pub fn footage_picture(&self, ctx: &EvalCtx, layer: &Layer) -> Option<FootagePicture> {
|
||
let LayerSource::Footage { item } = &layer.source else { return None };
|
||
if self.pipe.levels.is_some() || layer.masks().is_some_and(|m| !m.children.is_empty()) || frame_blend_mode(ctx, layer) != FrameBlend::Off {
|
||
return None;
|
||
}
|
||
let item = ctx.media_item(layer, *item);
|
||
let ItemKind::Footage(f) = &self.project.item(item)?.kind else { return None };
|
||
if !f.has_video || (layer.switches.collapse && is_vector_footage(f)) {
|
||
return None;
|
||
}
|
||
let pf = self.proxy_for(item).unwrap_or(f);
|
||
if self.pipe.media_in(pf).is_some()
|
||
|| pf.fields != effectcraft_project::FieldOrder::Off
|
||
|| pf.invert_alpha
|
||
|| pf.linear_light_applies()
|
||
|| pf.cineon_applies()
|
||
{
|
||
return None;
|
||
}
|
||
if let Some(cache) = self.cache {
|
||
let (scale, draft, blur) = (self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer));
|
||
let content = self.content_key(ctx, layer, cache::layer_key(ctx, layer, scale, draft, blur));
|
||
let input = self.content_key(ctx, layer, cache::input_key(ctx, layer, scale, draft, blur, 0));
|
||
if [content, input].into_iter().flatten().any(|k| cache.holds(k)) {
|
||
return None;
|
||
}
|
||
}
|
||
let (img, _) = self.footage_picture_of(ctx, layer, item, f)?;
|
||
let FootageLayout::Native { scale } = self.footage_layout(&img, f.width, f.height) else { return None };
|
||
// `layer_input`'s rebase into effect space (`Buf::rebase` of a buffer at the origin).
|
||
let mut at = Buf { img: Image { width: 0, height: 0, data: Vec::new() }, offset: [0.0; 2], scale };
|
||
at.rebase(ctx.effect_bounds(layer).1);
|
||
Some(FootagePicture { img, scale, effect_offset: at.offset })
|
||
}
|
||
|
||
/// 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, quantized) = match self.content_times(ctx, layer) {
|
||
Some(times) => (self.blurred_source(ctx, layer, ×)?, false),
|
||
None => (self.source_with(ctx, layer, true)?, Self::prequantized_footage(layer)),
|
||
};
|
||
// A solid is filled with a quantised colour, and plain footage comes quantised: only
|
||
// masks can take them off the grid.
|
||
let solid = matches!(layer.source, LayerSource::Solid { .. });
|
||
if masks::apply(ctx, layer, &mut buf, self.mask_blur(ctx, layer)) || !(solid || quantized) {
|
||
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> {
|
||
Renderer { whole_raster: true, ..*self }.source(ctx, layer)
|
||
}
|
||
|
||
/// Layer-space pixels after masks and effects, including content outside the viewport.
|
||
/// Expression sampleImage reads this rather than the frame-oriented clipped buffer.
|
||
pub fn layer_content(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Arc<Buf>> {
|
||
Renderer { whole_raster: true, ..*self }.content_buf(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, self.clipped_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>> {
|
||
// The Layer panel, analysis tools and effect inputs read the whole layer, not the viewport.
|
||
Renderer { whole_raster: true, ..*self }.whole_layer_input(ctx, layer, effects)
|
||
}
|
||
|
||
fn whole_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, self.clipped_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)
|
||
}
|
||
}
|
||
|
||
/// Snap cells can be finer than 1/16 unit: preserve every coordinate bit so visible
|
||
/// clips cannot collide with another cell or the empty behind-camera raster.
|
||
pub(crate) fn raster_clip_key(key: u64, clip: kurbo::Rect) -> u64 {
|
||
[clip.x0, clip.y0, clip.x1, clip.y1].iter().fold(cache::derive(key, 0xc11b), |key, coordinate| cache::derive(key, coordinate.to_bits()))
|
||
}
|