Merge pull request #38 from storytold/m4-nested-comps

Previews (M4): Preserve frame rate / resolution when nested, nested comps past their end, one motion blur gate
This commit is contained in:
BFlat authored and GitHub committed 2026-10-05 02:08:13 +07:00
commit 7ee6d6a301
22 files changed
+364 -57

No files matched your search

+1 -1
View File
@@ -10,7 +10,7 @@ once.
| M1 | Keyframes: temporal and spatial interpolation, Easy Ease, roving, velocity | Done |
| M2 | Compositing: 38 blend modes, track mattes, parenting, adjustment layers | Done |
| M3 | Project operations: settings dialogs, layer commands, `.ecproj`, undo, After Effects menu bar | Done (unsaved-changes prompts, Solid / Layer Settings, lock-aware layer commands, safe project files; View ▸ New Viewer still opens only the one locked viewer) |
| M4 | Preview: precomps, motion blur, cached playback | Mostly done |
| M4 | Preview: precomps, motion blur, cached playback | Mostly done (Preserve frame rate / resolution when nested, one motion blur gate, RAM preview fixes, Cache Frames When Idle; left: motion blur of collapsed precomps and of animated layer content) |
| M5 | Timeline depth: graph editor, keyframe clipboard and dialogs, time remapping, pick-whips, expression editor | Done |
| M6 | Shapes, masks and footage: shape operators, masks and the pen tool, video and image import | Done |
| M7 | 3D: 3D layers, cameras, lights, shadows, depth of field, 3D views and camera tools | In review |
+8 -2
View File
@@ -90,6 +90,12 @@ fn apply_settings(c: &mut Comp, p: &Value) {
if let Some(n) = p.get("adaptiveSampleLimit").and_then(Value::as_u64) {
c.motion_blur_adaptive_limit = (n as u32).clamp(16, 256);
}
if let Some(b) = p.get("preserveFrameRate").and_then(Value::as_bool) {
c.preserve_frame_rate = b;
}
if let Some(b) = p.get("preserveResolution").and_then(Value::as_bool) {
c.preserve_resolution = b;
}
}
fn new_comp(s: &mut Session, p: &Value) -> Result<Value> {
@@ -252,7 +258,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Composition...",
["Composition"],
Some("Cmd+N"),
"{name?, width?, height?, frameRate?, duration? (s), startTime? (s) | startTimecode?, background? [r,g,b]|#hex, pixelAspect?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), renderer? classic3D|advanced3D, anchor?, open?}",
"{name?, width?, height?, frameRate?, duration? (s), startTime? (s) | startTimecode?, background? [r,g,b]|#hex, pixelAspect?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), preserveFrameRate?: bool, preserveResolution?: bool, renderer? classic3D|advanced3D, anchor?, open?}",
always,
new_comp
),
@@ -261,7 +267,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Composition Settings...",
["Composition"],
Some("Cmd+K"),
"{comp?, name?, width?, height?, anchor? 0-8 (resize anchor, 4 = center), frameRate?, duration?, startTime? (s) | startTimecode?, background?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), pixelAspect?, renderer? classic3D|advanced3D}",
"{comp?, name?, width?, height?, anchor? 0-8 (resize anchor, 4 = center), frameRate?, duration?, startTime? (s) | startTimecode?, background?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), preserveFrameRate?: bool (nested or in the render queue it shows only its own frames), preserveResolution?: bool (nested, it renders at full size), pixelAspect?, renderer? classic3D|advanced3D}",
has_comp,
settings
),
+1
View File
@@ -79,6 +79,7 @@ fn comp_info(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({
"id": cid.0, "name": name, "width": c.width, "height": c.height, "frameRate": c.frame_rate.as_f64(), "duration": c.duration.seconds(),
"workArea": [c.work_area.0.seconds(), c.work_area.1.seconds()], "background": c.background,
"preserveFrameRate": c.preserve_frame_rate, "preserveResolution": c.preserve_resolution,
"layers": c.layers.iter().enumerate().map(|(i, l)| layer_json(l, i + 1)).collect::<Vec<_>>(),
}))
}
+21
View File
@@ -811,3 +811,24 @@ fn project_files_keep_their_format_warn_about_newer_versions_and_never_save_unre
let e = s.execute("file.open", json!({"path": junk})).unwrap_err().to_string();
assert!(e.contains("is not a project EffectCraft can open"), "{e}");
}
#[test]
fn comp_settings_preserve_frame_rate_and_resolution() {
let mut s = comp();
let info = s.execute("comp.info", json!({})).unwrap();
assert_eq!((info["preserveFrameRate"].clone(), info["preserveResolution"].clone()), (json!(false), json!(false)));
s.execute_checked("comp.settings", json!({"preserveFrameRate": true, "preserveResolution": true})).unwrap();
let info = s.execute("comp.info", json!({})).unwrap();
assert_eq!((info["preserveFrameRate"].clone(), info["preserveResolution"].clone()), (json!(true), json!(true)));
// Pre-compose keeps them; one undo step clears them.
let a = solid(&mut s, "#ff0000");
let r = s.execute("layer.precompose", json!({"layers": [a], "name": "Inner"})).unwrap();
let inner = s.project.comp(effectcraft_project::ItemId(r["comp"].as_u64().unwrap())).unwrap();
assert!(inner.preserve_frame_rate && inner.preserve_resolution);
// Undo Pre-compose and the solid; then one more step undoes the settings.
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert!(s.active_comp().unwrap().preserve_frame_rate);
s.execute("edit.undo", json!({})).unwrap();
assert!(!s.active_comp().unwrap().preserve_frame_rate && !s.active_comp().unwrap().preserve_resolution);
}
+17
View File
@@ -308,6 +308,14 @@ pub struct Comp {
pub enable_frame_blending: bool,
#[serde(default)]
pub draft_3d: bool,
/// Composition Settings ▸ Advanced ▸ Preserve frame rate when nested or in render queue:
/// nested in another comp, or rendered at another rate, it shows only its own frames.
#[serde(default)]
pub preserve_frame_rate: bool,
/// Composition Settings ▸ Advanced ▸ Preserve resolution when nested: nested in a comp
/// previewed at a lower resolution, it still renders at full size.
#[serde(default)]
pub preserve_resolution: bool,
#[serde(default)]
pub poster_time: Tick,
/// Global Light for layer styles (Layer ▸ Layer Styles ▸ Blending Options).
@@ -358,12 +366,19 @@ impl Comp {
enable_motion_blur: true,
enable_frame_blending: true,
draft_3d: false,
preserve_frame_rate: false,
preserve_resolution: false,
poster_time: Tick::ZERO,
guides: vec![],
global_light: styles::GlobalLight::default(),
essential: None,
}
}
/// Whether the composition has a frame at its time `t`: nested, it shows nothing before it
/// starts or after it ends (a precomp layer trimmed or remapped past it).
pub fn covers(&self, t: Tick) -> bool {
t >= Tick::ZERO && t < self.duration
}
/// A new, empty composition with this one's settings (frame rate, pixel aspect, background,
/// motion blur, frame blending and the 3D renderer), as Pre-compose makes.
pub fn nested_like(&self, width: u32, height: u32, duration: Tick) -> Comp {
@@ -377,6 +392,8 @@ impl Comp {
renderer: self.renderer,
enable_motion_blur: self.enable_motion_blur,
enable_frame_blending: self.enable_frame_blending,
preserve_frame_rate: self.preserve_frame_rate,
preserve_resolution: self.preserve_resolution,
..Comp::new(width, height, self.frame_rate, duration)
}
}
+3 -1
View File
@@ -418,8 +418,10 @@ impl RenderSettings {
let b = b.clamp(a, comp.duration);
(a, b)
}
/// The output frame rate: the Frame Rate override, unless the comp preserves its frame rate
/// (Composition Settings ▸ Preserve frame rate when nested or in render queue).
pub fn rate(&self, comp: &Comp) -> FrameRate {
self.frame_rate.unwrap_or(comp.frame_rate)
if comp.preserve_frame_rate { comp.frame_rate } else { self.frame_rate.unwrap_or(comp.frame_rate) }
}
/// First output-rate frame starting at or after `t`.
fn ceil_frame(r: FrameRate, t: Tick) -> i64 {
+15 -11
View File
@@ -478,21 +478,21 @@ fn hash_values(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, g: &PropGroup) {
/// Cache key for the processed (source → masks → effects) buffer of `layer` at the context
/// time, or `None` when the layer must not be cached (footage, precomps, cameras, adjustment
/// layers…).
pub fn layer_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool) -> Option<u64> {
pub fn layer_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool) -> Option<u64> {
// Time effects see property values at other times. Keyframes are part of the hashed
// structure (and the layer time is folded in), but expressions may read other layers at
// those times, which the key cannot see: such layers are not cached.
if reads_other_times(layer) && layer.props.children.iter().any(|c| matches!(c, Node::Group(g) if g.match_id != "transform" && has_expression(g))) {
return None;
}
key_with(ctx, layer, scale, draft, false)
key_with(ctx, layer, scale, draft, blur, false)
}
/// Cache key for a layer's *input* at the context time: source → masks → its first `effects`
/// effects (see `Renderer::layer_input`). Unlike [`layer_key`], footage layers are cached here
/// (keyed by item and source time), since Time effects read many neighbouring frames.
pub fn input_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, effects: usize) -> Option<u64> {
let base = key_with(ctx, layer, scale, draft, true)?;
pub fn input_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, effects: usize) -> Option<u64> {
let base = key_with(ctx, layer, scale, draft, blur, true)?;
let mut h = KeyHasher(base ^ 0x5bd1_e995_7a3c_11d3);
effects.hash(&mut h);
Some(h.finish())
@@ -515,18 +515,19 @@ fn has_expression(g: &PropGroup) -> bool {
/// Cache key for an adjustment layer's footprint (its source through its masks, see
/// `Renderer::adjustment_footprint`): the GPU compositor then uploads it once, not every frame.
pub fn footprint_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool) -> Option<u64> {
Some(derive(key_any(ctx, layer, scale, draft, false)?, 0xf007_9417))
pub fn footprint_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool) -> Option<u64> {
Some(derive(key_any(ctx, layer, scale, draft, blur, false)?, 0xf007_9417))
}
fn key_with(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, footage: bool) -> Option<u64> {
fn key_with(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, footage: bool) -> Option<u64> {
if layer.switches.adjustment {
return None;
}
key_any(ctx, layer, scale, draft, footage)
key_any(ctx, layer, scale, draft, blur, footage)
}
fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, footage: bool) -> Option<u64> {
/// `blur`: the layer is motion blurred in this render (`Renderer::mb_on`).
fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, footage: bool) -> Option<u64> {
let mut h = KeyHasher(0xcbf2_9ce4_8422_2325);
match &layer.source {
LayerSource::Solid { item } => {
@@ -558,11 +559,14 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, footage: bool)
layer.id.hash(&mut h);
hash_debug(&mut h, &layer.source);
hash_debug(&mut h, &layer.switches);
// Mask motion blur samples the comp's shutter.
if layer.masks().is_some_and(|m| !m.children.is_empty()) {
// Mask motion blur and effects that read the shutter depend on whether the layer is motion
// blurred in this render and on the comp's shutter.
if layer.masks().is_some_and(|m| !m.children.is_empty()) || layer.effects().is_some_and(|fx| !fx.children.is_empty()) {
blur.hash(&mut h);
ctx.comp.enable_motion_blur.hash(&mut h);
ctx.comp.shutter_angle.to_bits().hash(&mut h);
ctx.comp.shutter_phase.to_bits().hash(&mut h);
ctx.comp.motion_blur_samples.hash(&mut h);
}
for c in &layer.props.children {
// Transform is applied later; Layer Styles are keyed separately (see [`styles_key`]).
+13
View File
@@ -114,6 +114,19 @@ impl<'a> EvalCtx<'a> {
}
layer.layer_time(self.time)
}
/// The time a precomp layer's nested comp is shown at: [`Self::source_time`], floored to
/// the nested comp's own frames when it preserves its frame rate (Composition Settings ▸
/// Preserve frame rate when nested or in render queue).
pub fn nested_time(&self, layer: &Layer) -> Tick {
let t = self.source_time(layer);
match layer.source {
LayerSource::Comp { item } => match self.project.comp(item) {
Some(nc) if nc.preserve_frame_rate => nc.frame_rate.tick_of(crate::frame_position(t, nc.frame_rate).0),
_ => t,
},
_ => t,
}
}
pub fn layer(&self, id: LayerId) -> Option<&'a Layer> {
self.comp.layer(id)
}
+43 -19
View File
@@ -157,7 +157,7 @@ impl EffectHost for FxHost<'_, '_, '_> {
if self.r.depth > MAX_FX_DEPTH || self.r.project.comp_contains(*item, self.ctx.comp_id) {
return None;
}
self.r.nested().comp_aux(*item, self.ctx.source_time(self.layer)).map(Arc::new)
self.r.nested().comp_aux(*item, self.ctx.nested_time(self.layer)).map(Arc::new)
}
_ => None,
}
@@ -558,7 +558,11 @@ impl<'a> Renderer<'a> {
/// 3D layers use this comp's camera and lights.
pub fn collapse_into(&self, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, opacity: f32) -> Option<(Renderer<'a>, EvalCtx<'a>)> {
let nc = self.project.comp(item)?;
let nctx = EvalCtx { comp_id: item, comp: nc, time: ctx.source_time(layer), ..*ctx };
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);
@@ -837,11 +841,7 @@ impl<'a> Renderer<'a> {
effect_index: 0,
working_space: self.pipe.space,
working_linear: self.pipe.linear,
shutter: (ctx.comp.enable_motion_blur && layer.switches.motion_blur).then_some((
ctx.comp.shutter_angle,
ctx.comp.shutter_phase,
ctx.comp.motion_blur_samples,
)),
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
@@ -925,22 +925,31 @@ impl<'a> Renderer<'a> {
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(*item, pf, ctx.source_time(layer))?;
let img = self.footage.frame(*item, pf, lt)?;
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,
};
let lt = ctx.source_time(layer);
// Frame blending between the nested comp's frames (time-stretched/remapped).
let mode = frame_blend_mode(ctx, layer);
if mode != FrameBlend::Off
@@ -950,10 +959,10 @@ impl<'a> Renderer<'a> {
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: s });
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: s })
Some(Buf { img: sub.comp_frame(*item, lt), offset: [0.0; 2], scale: bs })
}
LayerSource::Footage { item } => {
let it = self.project.item(*item)?;
@@ -1072,7 +1081,7 @@ impl<'a> Renderer<'a> {
let mut buf = 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) || !solid {
if masks::apply(ctx, layer, &mut buf, self.mask_blur(ctx, layer)) || !solid {
self.pipe.quantize(&mut buf.img);
}
Some(buf)
@@ -1139,7 +1148,7 @@ impl<'a> Renderer<'a> {
}
fn content_buf_timed(&self, ctx: &EvalCtx, layer: &Layer, mut timing: Option<&mut LayerTiming>) -> Option<(Arc<Buf>, Option<u64>)> {
let key = self.cache.and_then(|_| cache::layer_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft));
let key = self.cache.and_then(|_| cache::layer_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer)));
if let (Some(c), Some(k)) = (self.cache, key)
&& let Some(b) = c.get(k)
{
@@ -1160,7 +1169,7 @@ impl<'a> Renderer<'a> {
/// (what Time effects read at neighbouring times). Served from / stored in the layer cache
/// under its own key, so scrubbing reuses frames rendered for earlier output frames.
pub fn layer_input(&self, ctx: &EvalCtx, layer: &Layer, effects: usize) -> Option<Arc<Buf>> {
let key = self.cache.and_then(|_| cache::input_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, effects));
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));
if let (Some(c), Some(k)) = (self.cache, key)
&& let Some(b) = c.get(k)
{
@@ -1272,11 +1281,22 @@ impl<'a> Renderer<'a> {
* Mat3::translate(vec2(-buf.offset[0], -buf.offset[1]))
}
/// Whether a layer is motion blurred.
fn mb_on(&self, ctx: &EvalCtx, layer: &Layer) -> bool {
/// Whether a layer is motion blurred: the render options, the comp's switch and the layer's
/// switch (limited by the precomp layers above when switches affect nested comps). Layer
/// motion, mask motion blur, effects that read the shutter and Advanced 3D all ask this.
pub(crate) fn mb_on(&self, ctx: &EvalCtx, layer: &Layer) -> bool {
self.opts.motion_blur && ctx.comp.enable_motion_blur && self.layer_motion_blur(layer)
}
/// What a layer's mask motion blur may do in this render (see [`masks::MaskBlur`]).
fn mask_blur(&self, ctx: &EvalCtx, layer: &Layer) -> masks::MaskBlur {
masks::MaskBlur {
allowed: self.opts.motion_blur && ctx.comp.enable_motion_blur && self.inherited.is_none_or(|(_, mb)| mb),
layer: self.layer_motion_blur(layer),
samples: if self.opts.draft { 4 } else { ctx.comp.motion_blur_samples as usize },
}
}
/// Motion-blur sub-samples for a layer buffer (1 = no motion blur). As in After Effects, a 2D
/// layer gets about one sample per output pixel its corners travel during the shutter,
/// at least Samples Per Frame and at most the comp's Adaptive Sample Limit; a layer that
@@ -1532,7 +1552,7 @@ impl<'a> Renderer<'a> {
/// Adjustment layer: effects applied to the comp below, limited to the layer's footprint.
fn draw_adjustment(&self, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image, opacity: f32) {
let Some(mut foot) = self.source(ctx, layer) else { return };
let _ = masks::apply(ctx, layer, &mut foot);
let _ = masks::apply(ctx, layer, &mut foot, self.mask_blur(ctx, layer));
let mut matte = Image::new(canvas.width, canvas.height);
self.place(ctx, layer, &foot, &mut matte, BlendMode::Normal, 1.0);
let o = self.roi_offset().map(|(x, y, _, _)| [-x, -y]).unwrap_or([0.0; 2]);
@@ -1695,14 +1715,14 @@ impl<'a> Renderer<'a> {
/// its effects show. `None` = the layer changes nothing.
pub fn adjustment_footprint(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Arc<Buf>> {
// Cached, so an accelerator sees the same buffer (and uploads it once) every frame.
let key = self.cache.and_then(|_| cache::footprint_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft));
let key = self.cache.and_then(|_| cache::footprint_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer)));
if let (Some(c), Some(k)) = (self.cache, key)
&& let Some(b) = c.get(k)
{
return Some(b);
}
let mut foot = self.source(ctx, layer)?;
let _ = masks::apply(ctx, layer, &mut foot);
let _ = masks::apply(ctx, layer, &mut foot, self.mask_blur(ctx, layer));
let foot = Arc::new(foot);
if let (Some(c), Some(k)) = (self.cache, key) {
c.insert(k, foot.clone());
@@ -1901,6 +1921,10 @@ mod tests_color;
#[cfg(test)]
mod tests_frame_blend;
#[cfg(test)]
mod tests_mask_blur;
#[cfg(test)]
mod tests_nested;
#[cfg(test)]
mod tests_paint;
#[cfg(test)]
mod tests_time;
+28 -17
View File
@@ -10,29 +10,40 @@ use rayon::prelude::*;
use crate::eval::EvalCtx;
/// Sub-frame samples for mask motion blur (animated mask paths only).
const MASK_BLUR_SAMPLES: usize = 8;
/// What mask motion blur may do in a render (from the renderer's motion blur gate).
#[derive(Clone, Copy, Debug, Default)]
pub struct MaskBlur {
/// Motion blur is on in this render: the render options, the comp's switch and the Motion
/// Blur switches of the precomp layers above (when switches affect nested comps).
pub allowed: bool,
/// The layer's own Motion Blur switch, as those precomp layers limit it (Same As Layer).
pub layer: bool,
/// Sub-frame samples (the comp's Samples Per Frame; fewer in Draft).
pub samples: usize,
}
/// Coverage of one mask in buffer pixels (before mode/opacity/invert). With mask motion blur
/// (Layer ▸ Mask ▸ Motion Blur: On, or Same As Layer with the layer's switch on) an animated
/// path is averaged over the comp's shutter.
fn coverage(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, buf: &Buf) -> Option<Mask> {
let mb = match g.kind {
GroupKind::Mask { motion_blur, .. } => match motion_blur {
MaskMotionBlur::On => true,
MaskMotionBlur::Off => false,
MaskMotionBlur::SameAsLayer => layer.switches.motion_blur,
},
_ => false,
};
fn coverage(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, buf: &Buf, blur: MaskBlur) -> Option<Mask> {
let mb = blur.allowed
&& match g.kind {
GroupKind::Mask { motion_blur, .. } => match motion_blur {
MaskMotionBlur::On => true,
MaskMotionBlur::Off => false,
MaskMotionBlur::SameAsLayer => blur.layer,
},
_ => false,
};
let animated = g.get("path").is_some_and(|p| p.keys.len() > 1 || p.has_expression());
if mb && animated && ctx.comp.enable_motion_blur {
if mb && animated {
let fd = ctx.comp.frame_duration().seconds();
let (angle, phase) = (ctx.comp.shutter_angle / 360.0, ctx.comp.shutter_phase / 360.0);
let mut acc: Option<Mask> = None;
let k = 1.0 / MASK_BLUR_SAMPLES as f32;
for i in 0..MASK_BLUR_SAMPLES {
let f = phase + angle * i as f64 / (MASK_BLUR_SAMPLES - 1) as f64;
let n = blur.samples.max(2);
let k = 1.0 / n as f32;
for i in 0..n {
let f = phase + angle * i as f64 / (n - 1) as f64;
let sub = ctx.at(ctx.time + effectcraft_time::Tick::from_seconds_f64(f * fd));
let Some(c) = coverage_at(&sub, layer, g, buf) else { continue };
match &mut acc {
@@ -121,7 +132,7 @@ pub fn shapes(ctx: &EvalCtx, layer: &Layer) -> Vec<effectcraft_effects::MaskShap
/// Apply the layer's masks to its buffer.
/// Returns whether any mask was applied.
pub fn apply(ctx: &EvalCtx, layer: &Layer, buf: &mut Buf) -> bool {
pub fn apply(ctx: &EvalCtx, layer: &Layer, buf: &mut Buf, blur: MaskBlur) -> bool {
let Some(masks) = layer.masks() else { return false };
let list: Vec<_> = masks.groups().filter(|g| g.enabled && matches!(g.kind, GroupKind::Mask { mode, .. } if mode != MaskMode::None)).collect();
if list.is_empty() {
@@ -133,7 +144,7 @@ pub fn apply(ctx: &EvalCtx, layer: &Layer, buf: &mut Buf) -> bool {
let mut acc = vec![if first_sub { 1.0f32 } else { 0.0 }; n];
for g in list {
let GroupKind::Mask { mode, inverted, .. } = g.kind else { continue };
let Some(mut cov) = coverage(ctx, layer, g, buf) else { continue };
let Some(mut cov) = coverage(ctx, layer, g, buf, blur) else { continue };
let op = (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32;
cov.data.par_iter_mut().for_each(|c| {
if inverted {
+52
View File
@@ -0,0 +1,52 @@
//! Mask motion blur follows the renderer's motion blur gate: the render options (Render
//! Settings ▸ Motion Blur, previews), the comp's switch and the precomp layers above.
use effectcraft_color::Label;
use effectcraft_keyframe::{Keyframe, ShapePath, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{Comp, GroupKind, ItemId, ItemKind, LayerSource, MaskMode, MaskMotionBlur, Node, Project, Solid};
use effectcraft_time::{FrameRate, Tick};
use crate::{NoFootage, RenderOpts, Renderer};
/// A white 100×100 solid whose rect mask jumps from x 10 (frame 0) to x 50 (frame 1), Mask
/// Motion Blur On, the layer's own switch off. Rendered half way, unblurred the mask covers x
/// 30..50; blurred over the shutter it reaches down to x ≈ 20.
fn project(comp_blur: bool) -> (Project, ItemId) {
let mut p = Project::default();
let mut comp = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
comp.enable_motion_blur = comp_blur;
let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color: [1.0; 3], width: 100, height: 100, pixel_aspect: 1.0 }));
let mut l = build::layer(&mut p, &comp, "S", LayerSource::Solid { item: sid }, (100, 100), None);
let mut next = p.next_id;
let mut m = build::mask(&mut Ids(&mut next), "Mask 1", ShapePath::rect([20.0, 50.0], 20.0, 100.0), MaskMode::Add, [255, 0, 0]);
p.next_id = next;
if let GroupKind::Mask { motion_blur, .. } = &mut m.kind {
*motion_blur = MaskMotionBlur::On;
}
let path = m.get_mut("path").unwrap();
path.keys = vec![
Keyframe::new(Tick::ZERO, Value::Path(ShapePath::rect([20.0, 50.0], 20.0, 100.0))),
Keyframe::new(FrameRate::FPS_30.tick_of(1), Value::Path(ShapePath::rect([60.0, 50.0], 20.0, 100.0))),
];
l.props.sub_mut("masks").unwrap().children.push(Node::Group(m));
l.switches.motion_blur = false;
let cid = p.add_item("C", Label::Sandstone, None, ItemKind::Comp(comp.into()));
p.comp_mut(cid).unwrap().layers.push(l);
(p, cid)
}
fn alpha_at_25(p: &Project, cid: ItemId, motion_blur: bool) -> f32 {
let half = Tick::from_seconds_f64(0.5 / 30.0);
Renderer::new(p, &NoFootage, RenderOpts { motion_blur, ..Default::default() }).comp_frame(cid, half).get(25, 50)[3]
}
#[test]
fn mask_motion_blur_follows_the_render_and_the_comp() {
let (p, cid) = project(true);
let a = alpha_at_25(&p, cid, true);
assert!(a > 0.05 && a < 0.95, "blurred: {a}");
assert!(alpha_at_25(&p, cid, false) < 0.01, "Render Settings ▸ Motion Blur off");
let (p, cid) = project(false);
assert!(alpha_at_25(&p, cid, true) < 0.01, "the comp's switch off");
}
+90
View File
@@ -0,0 +1,90 @@
//! Nested compositions: Preserve frame rate when nested or in render queue, Preserve resolution
//! when nested.
use effectcraft_color::Label;
use effectcraft_keyframe::{Keyframe, Value};
use effectcraft_project::build;
use effectcraft_project::render_queue::RenderSettings;
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, Solid};
use effectcraft_time::{FrameRate, Tick};
use crate::render_frame;
fn s(x: f64) -> Tick {
Tick::from_seconds_f64(x)
}
/// A 30 fps outer comp nesting a 10 fps 100×100 comp whose 10×10 solid moves from x=10 (0 s)
/// to x=90 (2 s). Returns (project, outer, inner).
fn nested(preserve_rate: bool, preserve_res: bool) -> (Project, ItemId, ItemId) {
let mut p = Project::default();
let outer = Comp::new(100, 100, FrameRate::FPS_30, s(4.0));
let mut inner = Comp::new(100, 100, FrameRate::from_f64(10.0), s(4.0));
inner.preserve_frame_rate = preserve_rate;
inner.preserve_resolution = preserve_res;
let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: 10, height: 10, pixel_aspect: 1.0 }));
let mut l = build::layer(&mut p, &inner, "S", LayerSource::Solid { item: sid }, (10, 10), None);
l.props.prop_mut("transform/position").unwrap().keys =
vec![Keyframe::new(Tick::ZERO, Value::Vec3([10.0, 50.0, 0.0])), Keyframe::new(s(2.0), Value::Vec3([90.0, 50.0, 0.0]))];
inner.layers.push(l);
let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into()));
let cid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(outer.clone().into()));
let pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (100, 100), None);
p.comp_mut(cid).unwrap().layers.push(pre);
(p, cid, iid)
}
#[test]
fn a_comp_preserving_its_frame_rate_shows_only_its_own_frames_when_nested() {
// 2/30 s falls between the nested comp's frames 0 (0 s) and 1 (0.1 s).
let between = s(2.0 / 30.0);
let (p, cid, _) = nested(false, false);
assert_ne!(render_frame(&p, cid, between, 1.0).data, render_frame(&p, cid, Tick::ZERO, 1.0).data, "follows the outer comp's frames");
let (p, cid, _) = nested(true, false);
assert_eq!(render_frame(&p, cid, between, 1.0).data, render_frame(&p, cid, Tick::ZERO, 1.0).data, "holds its own frame 0");
assert_ne!(render_frame(&p, cid, s(0.1), 1.0).data, render_frame(&p, cid, Tick::ZERO, 1.0).data, "then its frame 1");
}
#[test]
fn the_render_queue_keeps_a_preserved_frame_rate() {
let (p, _, iid) = nested(true, false);
let settings = RenderSettings { frame_rate: Some(FrameRate::FPS_30), ..Default::default() };
assert_eq!(settings.rate(p.comp(iid).unwrap()), FrameRate::from_f64(10.0));
let (p, _, iid) = nested(false, false);
assert_eq!(settings.rate(p.comp(iid).unwrap()), FrameRate::FPS_30);
}
#[test]
fn a_comp_preserving_its_resolution_renders_full_size_and_lands_in_place() {
for preserve in [false, true] {
let (p, cid, _) = nested(false, preserve);
let img = render_frame(&p, cid, Tick::ZERO, 0.5);
assert_eq!((img.width, img.height), (50, 50));
// The solid covers x 5..15, y 45..55 in the 100×100 comp: 2.5..7.5, 22.5..27.5 at half size.
assert!(img.get(5, 25)[3] > 0.99, "preserve {preserve}: inside");
assert!(img.get(20, 25)[3] < 0.01 && img.get(5, 40)[3] < 0.01, "preserve {preserve}: outside");
}
}
#[test]
fn a_nested_comp_shows_nothing_past_its_end() {
for collapse in [false, true] {
let mut p = Project::default();
let outer = Comp::new(100, 100, FrameRate::FPS_30, s(4.0));
// A 1 s comp with a solid covering it for 4 s (longer than the comp itself).
let mut inner = Comp::new(100, 100, FrameRate::FPS_30, s(1.0));
let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: 100, height: 100, pixel_aspect: 1.0 }));
let mut l = build::layer(&mut p, &inner, "S", LayerSource::Solid { item: sid }, (100, 100), None);
l.out_point = s(4.0);
inner.layers.push(l);
let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into()));
let cid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(outer.clone().into()));
// The precomp layer is trimmed out to 3 s, past the nested comp's end.
let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (100, 100), None);
pre.out_point = s(3.0);
pre.switches.collapse = collapse;
p.comp_mut(cid).unwrap().layers.push(pre);
assert!(render_frame(&p, cid, s(0.5), 1.0).get(50, 50)[3] > 0.99, "collapse {collapse}: inside its span");
assert!(render_frame(&p, cid, s(2.0), 1.0).get(50, 50)[3] < 0.01, "collapse {collapse}: past its end");
}
}
+2 -2
View File
@@ -157,8 +157,8 @@ fn wiggly_selector_is_deterministic_and_time_dependent() {
assert!(ys(&a).iter().all(|y| y.abs() <= 50.0 + 1e-9));
assert!(ys(&a).windows(2).any(|w| (w[0] - w[1]).abs() > 1e-6));
// The layer cache keys a wiggly text layer by time.
let k1 = crate::cache::layer_key(&ctx(&p, cid, 0.5), l, 1.0, false);
let k2 = crate::cache::layer_key(&ctx(&p, cid, 1.3), l, 1.0, false);
let k1 = crate::cache::layer_key(&ctx(&p, cid, 0.5), l, 1.0, false, false);
let k2 = crate::cache::layer_key(&ctx(&p, cid, 1.3), l, 1.0, false, false);
assert_ne!(k1, k2);
// Renders identically twice.
let r1 = crate::render_frame(&p, cid, Tick::from_seconds_f64(0.5), 0.5);
+3 -2
View File
@@ -882,9 +882,10 @@ pub(crate) fn build(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u32)
build_at(r, ctx, None, run, out)
}
/// Whether a layer's motion is blurred (Motion Blur switch, comp setting and render option).
/// Whether a layer's motion is blurred (the renderer's gate: Motion Blur switch, comp setting,
/// render option and the precomp layers above).
pub(crate) fn motion_blurred(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> bool {
r.opts.motion_blur && ctx.comp.enable_motion_blur && layer.switches.motion_blur
r.mb_on(ctx, layer)
}
/// [`build`] at a motion-blur sub-sample: layers with Motion Blur on, the camera and the lights
+1
View File
@@ -131,6 +131,7 @@ fn item(s: &Session, a: &J) -> R {
"draft3d": c.draft_3d, "shutterAngle": c.shutter_angle, "shutterPhase": c.shutter_phase,
"motionBlurSamplesPerFrame": c.motion_blur_samples,
"motionBlurAdaptiveSampleLimit": c.motion_blur_adaptive_limit,
"preserveNestedFrameRate": c.preserve_frame_rate, "preserveNestedResolution": c.preserve_resolution,
"renderer": match c.renderer { effectcraft_project::Renderer::Classic3D => "ADBE Classic 3D", effectcraft_project::Renderer::Advanced3D => "ADBE Advanced 3d" },
"open": s.state.open_comps.contains(&cid), "active": s.active_comp_id() == Some(cid),
"hasVideo": true, "hasAudio": c.layers.iter().any(|l| l.switches.audio && matches!(l.source, LayerSource::Footage { .. } | LayerSource::Comp { .. })),
+2
View File
@@ -1409,6 +1409,8 @@ CompItem.prototype.constructor = CompItem;
def("shutterAngle", function () { return this.__info().shutterAngle; }, function (v) { this.__settings({ shutterAngle: __num(v) }); });
def("shutterPhase", function () { return this.__info().shutterPhase; }, function (v) { this.__settings({ shutterPhase: __num(v) }); });
def("motionBlurSamplesPerFrame", function () { return this.__info().motionBlurSamplesPerFrame; }, function (v) { this.__settings({ motionBlurSamples: __num(v) }); });
def("preserveNestedFrameRate", function () { return this.__info().preserveNestedFrameRate; }, function (v) { this.__settings({ preserveFrameRate: !!v }); });
def("preserveNestedResolution", function () { return this.__info().preserveNestedResolution; }, function (v) { this.__settings({ preserveResolution: !!v }); });
def("renderer", function () { return this.__info().renderer; }, function (v) {
this.__settings({ renderer: String(v).toLowerCase().indexOf("advanced") >= 0 ? "advanced3D" : "classic3D" });
});
+18
View File
@@ -484,3 +484,21 @@ fn motion_graphics_template_hooks_add_mirrors_and_controllers() {
let eg = c.essential.as_ref().unwrap();
assert!(matches!(eg.controls[1].kind, effectcraft_engine::project::essential::EgKind::Mirror { of } if of == eg.controls[0].id));
}
#[test]
fn comp_preserves_nested_frame_rate_and_resolution() {
let mut s = session();
let o = ok(
&mut s,
r#"
var comp = app.project.items.addComp("Nested", 320, 180, 1, 2, 12);
var before = [comp.preserveNestedFrameRate, comp.preserveNestedResolution];
comp.preserveNestedFrameRate = true;
comp.preserveNestedResolution = true;
before.concat([comp.preserveNestedFrameRate, comp.preserveNestedResolution]).join(",");
"#,
);
assert_eq!(o.result, json!("false,false,true,true"));
let (_, c) = comp_named(&s, "Nested");
assert!(c.preserve_frame_rate && c.preserve_resolution);
}
@@ -88,6 +88,8 @@ pub struct CompDraft {
pub shutter_phase: f64,
pub samples: u32,
pub adaptive_limit: u32,
pub preserve_frame_rate: bool,
pub preserve_resolution: bool,
pub advanced_3d: bool,
pub tab: Tab,
/// Timecode text being edited (start, duration).
@@ -112,6 +114,8 @@ impl Default for CompDraft {
shutter_phase: -90.0,
samples: 16,
adaptive_limit: 128,
preserve_frame_rate: false,
preserve_resolution: false,
advanced_3d: false,
tab: Tab::Basic,
start_tc: None,
@@ -304,6 +308,14 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens, auto: &mut Registr
}
});
ui.end_row();
ui.label("");
let r = ui.checkbox(&mut d.preserve_frame_rate, "Preserve frame rate when nested or in render queue");
auto.add("dialog.comp.preserveFrameRate", r.rect, "Preserve frame rate when nested or in render queue");
ui.end_row();
ui.label("");
let r = ui.checkbox(&mut d.preserve_resolution, "Preserve resolution when nested");
auto.add("dialog.comp.preserveResolution", r.rect, "Preserve resolution when nested");
ui.end_row();
ui.label(egui::RichText::new("Motion Blur").strong());
ui.end_row();
ui.label("Shutter Angle:");
@@ -360,6 +372,7 @@ pub fn params(d: &CompDraft) -> serde_json::Value {
"name": d.name, "width": d.width, "height": d.height, "pixelAspect": d.pixel_aspect, "frameRate": d.fps,
"duration": d.duration, "startTime": d.start, "background": [d.bg[0], d.bg[1], d.bg[2]], "anchor": d.anchor,
"shutterAngle": d.shutter_angle, "shutterPhase": d.shutter_phase, "motionBlurSamples": d.samples, "adaptiveSampleLimit": d.adaptive_limit,
"preserveFrameRate": d.preserve_frame_rate, "preserveResolution": d.preserve_resolution,
"renderer": if d.advanced_3d { "advanced3D" } else { "classic3D" },
})
}
+3 -1
View File
@@ -83,6 +83,8 @@ pub fn open_comp_settings(app: &mut EffectcraftApp) -> Result<(), String> {
shutter_phase: c.shutter_phase,
samples: c.motion_blur_samples,
adaptive_limit: c.motion_blur_adaptive_limit,
preserve_frame_rate: c.preserve_frame_rate,
preserve_resolution: c.preserve_resolution,
advanced_3d: c.renderer == effectcraft_engine::project::Renderer::Advanced3D,
..Default::default()
};
@@ -604,7 +606,7 @@ mod tests {
}
app.dialog_state.comp.tab = super::super::comp_settings::Tab::Advanced;
frame(&mut app, &ctx);
for id in ["anchor.4", "shutterAngle", "shutterPhase", "samples", "adaptiveLimit"] {
for id in ["anchor.4", "preserveFrameRate", "preserveResolution", "shutterAngle", "shutterPhase", "samples", "adaptiveLimit"] {
assert!(app.auto.find(&format!("dialog.comp.{id}")).is_some(), "dialog.comp.{id}");
}
app.dialog_state.comp.tab = super::super::comp_settings::Tab::Basic;
+1 -1
View File
@@ -674,7 +674,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
&& let Some(nc) = app.session.project.comp(*item)
{
let (outer, _) = l2c(&ectx, l);
let nctx = EvalCtx::new(&app.session.project, *item, nc, ectx.source_time(l));
let nctx = EvalCtx::new(&app.session.project, *item, nc, ectx.nested_time(l));
for nl in nc.layers.iter().filter(|nl| nl.is_active_at(nctx.time) && !nl.is_3d()) {
if let Some((_, q, _)) = layer_quad(&nctx, nl) {
let sq: Vec<Pos2> = q
+13
View File
@@ -299,6 +299,19 @@ Continuously Rasterize: the source is rasterised at its on-screen scale. Quality
nearest-neighbour, Wireframe draws the layer bounds. Slip edit (`layer.slip`, Alt+PageUp/Down,
dragging the source bar in the timeline) moves the source under fixed in/out points.
**Nested comps** show nothing before they start or after they end (`Comp::covers`), even when
the precomp layer is trimmed or remapped past them. A comp with **Preserve frame rate when nested
or in render queue** shows only its own frames when nested (`EvalCtx::nested_time` floors the
precomp layer's source time to its frame rate) and renders at its own rate in the Render Queue;
one with **Preserve resolution when nested** renders at full size inside a comp drawn at a lower
resolution (the buffer's scale places it).
**Motion blur** has one gate, `Renderer::mb_on`: the render options (Render Settings ▸ Motion
Blur, previews), the comp's switch and the layer's switch, limited by the precomp layers above
when switches affect nested comps. Layer motion, mask motion blur (the comp's Samples Per Frame,
four in Draft), effects that read the shutter and Advanced 3D all follow it, and the layer cache
key of a layer with masks or effects records it.
**Motion tracking** (Animation ▸ Track Motion / Stabilize Motion, Window ▸ Tracker): a tracker
is a `Tracker` group under the layer's Motion Trackers group, with Track Point groups (Feature
Center, Feature Size, Search Offset, Search Size, Confidence, Attach Point, Attach Point Offset)
+16
View File
@@ -383,6 +383,22 @@ Browser in the web app (landed in M13.10: browser storage and File System Access
wrong signatures and versions, fuel exhaustion, traps and bit-identical output; script
`Socket` validates ports, and ScriptUI resource strings accept trailing array commas.
## Update: M4.9–M4.11 nested comps and motion blur
- **Composition Settings ▸ Advanced ▸ Preserve frame rate when nested or in render queue** and
**Preserve resolution when nested** (model, renderer, Render Queue, dialog, `comp.settings`,
`comp.info`, `CompItem.preserveNestedFrameRate` / `preserveNestedResolution`; Pre-compose
copies them).
- **Nested comps past their end** show nothing (flattened, through a proxy, or collapsed); they
kept drawing layers whose bars ran on.
- **Motion blur gate**: mask motion blur, effects that read the shutter and Advanced 3D follow
the render options and the precomp layers' switches like layer motion does; mask motion blur
uses the comp's Samples Per Frame.
Not yet: collapsed precomps don't blur with the precomp layer's own motion or the parent
camera's; animated content inside a layer (shape paths, text animators, nested frames) isn't
re-rendered per sub-sample; nested comp markers on the precomp layer bar.
## Update: M4.5–M4.8 cached playback
- **RAM preview keys** carry the render options (Fast Previews Draft / Fast Draft, Realtime