mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
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:
22 files changed
+364
-57
No files matched your search
+1
-1
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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`]).
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 { .. })),
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
|
||||
@@ -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" },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user