use super::accel::{Accelerator, Backend, CpuFx, FxStep, FxTarget}; use super::footage::{FootageSource, is_vector_footage}; use super::frames::{blend_frames, 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::{ 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, }; /// 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], } impl<'a> Renderer<'a> { pub fn new(project: &'a Project, footage: &'a dyn FootageSource, opts: RenderOpts) -> Renderer<'a> { Renderer { project, footage, expr: None, opts, cache: 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()) } /// 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, } } /// 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). pub fn collapsed(&self, ctx: &EvalCtx, layer: &Layer) -> Option { 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; } // Instance overrides (Essential Properties) need the nested comp rendered on its own. if !effectcraft_project::essential::overridden(layer).is_empty() { return None; } Some(*item) } /// 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(&self, ctx: &EvalCtx<'a>, layer: &'a Layer, item: ItemId, opacity: f32) -> Option<(Renderer<'a>, EvalCtx<'a>)> { 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, ..*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: &Layer, item: ItemId, 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, item, opacity) { sub.draw_comp(&nctx, canvas, blank); } return Region::Empty; } let Some((sub, nctx)) = self.collapse_into(ctx, layer, item, 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 } => { 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]); } } 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) } } /// 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. if self.depth == 0 && let Some(c) = self.pipe.output() { color::convert(&mut canvas, &c); self.pipe.quantize(&mut canvas); } if self.depth == 0 && let Some((lin, mode, enc)) = self.pipe.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(item) => self.draw_collapsed(ctx, visible[i], item, 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: an authored layer's buffer spans only its painted content while the stack works // on the layer's whole region (`layer_size`): give the buffer the room a sized source // already has, so tiling or displacement can place pixels outside the content instead // of losing them at its edge. Only when something will actually run, and not for an // adjustment layer, whose buffer is the comp below and already spans it. if !adjustment && fx.groups().any(|g| g.enabled && matches!(&g.kind, GroupKind::Effect { effect } if effectcraft_effects::find(effect).is_some_and(|s| !effectcraft_effects::audio_fx::is_audio_effect(s.id)))) { 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)), }; // 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 = 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 = 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 { 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. // 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_with(*item, pf, lt, None, self.pipe.float_encoding())?; 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 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.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)) } LayerSource::Text => Some(self.authored(text::render(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> { // 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 = self.footage.frame_with(item, f, f.frame_rate.tick_of(i / 2), None, self.pipe.float_curve(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 size = size.filter(|_| !(f.invert_alpha || f.linear_light_applies())); let img = self.footage_frame_raw(ctx, layer, item, f, t, size)?; if f.invert_alpha || f.linear_light_applies() { Some(Arc::new(interpret_pixels(&img, f, self.pipe.linear_footage_space(f)))) } else { Some(img) } } fn footage_frame_raw(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option> { 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); h.finish() }); if let (Some(c), Some(k)) = (self.cache, key) && let Some(b) = c.get(k) { return Some(Arc::new(b.img.clone())); } let a = self.footage.frame_with(item, f, f.frame_rate.tick_of(i), None, float); let b = self.footage.frame_with(item, f, f.frame_rate.tick_of(i + 1), None, float); if let (Some(a), Some(b)) = (a, b) { let img = blend_frames(mode, &a, &b, w as f32); if let (Some(c), Some(k)) = (self.cache_to_keep(), key) { c.insert(k, Arc::new(Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 })); } return Some(Arc::new(img)); } } } self.footage.frame_with(item, f, t, size, float) } /// Source → masks, clamped/quantised to the project depth. fn masked_source(&self, ctx: &EvalCtx, layer: &Layer) -> Option { 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> { 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> { let (b, k) = self.layer_buf_keyed(ctx, layer)?; Some(self.to_blend(b, k)) } /// A buffer converted to the blending space (cached under a key derived from `key`). pub(crate) fn to_blend(&self, b: Arc, key: Option) -> Arc { let Some(c) = self.pipe.to_blend() else { return b }; let key = key.map(|k| cache::derive(k, 0x1ea7)); if let (Some(cache), Some(k)) = (self.cache, key) && let Some(x) = cache.get(k) { return x; } let mut nb = (*b).clone(); color::convert(&mut nb.img, &c); let nb = Arc::new(nb); // The buffer the cache keeps (another worker's, if it kept one first). match (self.cache_to_keep(), key) { (Some(cache), Some(k)) => cache.insert(k, nb), _ => nb, } } fn layer_buf_keyed(&self, ctx: &EvalCtx, layer: &Layer) -> Option<(Arc, Option)> { let st = self.styled(ctx, layer, None)?; if st.passes.is_empty() { return Some((st.body, st.key)); } let key = st.key.map(|k| cache::derive(k, 0xf1a7)); if let (Some(c), Some(k)) = (self.cache, key) && let Some(b) = c.get(k) { return Some((b, key)); } let flat = Arc::new(styles::Styled { passes: st.passes.iter().map(|(b, m)| ((**b).clone(), *m)).collect(), body: (*st.body).clone() }.flatten()); // The buffer the cache keeps (another worker's, if it kept one first). let flat = match (self.cache_to_keep(), key) { (Some(c), Some(k)) => c.insert(k, flat), _ => flat, }; Some((flat, key)) } /// 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 { self.source(ctx, layer) } /// Layer pixels after source → masks → effects (no layer styles). pub fn content_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { self.content_buf_timed(ctx, layer, None).map(|(b, _)| b) } pub(crate) fn content_buf_timed(&self, ctx: &EvalCtx, layer: &Layer, mut timing: Option<&mut LayerTiming>) -> Option<(Arc, Option)> { 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(c), Some(k)) = (self.cache, key) && let Some(b) = c.get(k) { if let Some(t) = timing.as_deref_mut() { t.cached = true; } return Some((b, key)); } let buf = self.masked_source(ctx, layer)?; let buf = Arc::new(self.apply_effects_timed(ctx, layer, buf, false, usize::MAX, timing.map(|t| &mut t.effects))); // The buffer the cache keeps (another worker's, if it kept one first). let buf = match (self.cache_to_keep(), key) { (Some(c), Some(k)) => c.insert(k, buf), _ => buf, }; Some((buf, key)) } /// 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> { 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(c), Some(k)) = (self.cache, key) && let Some(b) = c.get(k) { return Some(b); } let mut buf = self.masked_source(ctx, layer)?; let origin = ctx.effect_bounds(layer).1; buf.rebase(origin); if effects > 0 { buf = self.effect_space_stack(ctx, layer, buf, EffectStack { adjustment: false, limit: effects, origin }, None); } let buf = Arc::new(buf); // The buffer the cache keeps (another worker's, if it kept one first). let buf = match (self.cache_to_keep(), key) { (Some(c), Some(k)) => c.insert(k, buf), _ => buf, }; Some(buf) } }