Transform shutter: use the sequence frame rate on adjustment layers (#1088)

Adjustment layers render their effects without an FxEnv, so Transform's
shutter fell back to 24 fps. In a 60 fps sequence a 360° shutter then
spanned 2.5 frames, blurring one frame before the move starts and one
after it ends. FxCtx now carries the sequence frame rate, set wherever a
sequence is known, and Transform uses it.
This commit is contained in:
NetterSilver
2026-10-12 12:11:03 +02:00
parent 0541d4d182
commit eb3afad326
15 changed files with 83 additions and 3 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ const EXACT: f32 = 1e-4;
const FLIPS: f64 = 0.01;
fn cx(t: Tick, px_scale: f32) -> FxCtx<'static> {
FxCtx { t, px_scale, seconds: 0.0, timecode: "", clip_name: "", project: None, env: None, working: filmcraft_color::WorkingSpace::Rec709 }
FxCtx { t, px_scale, seconds: 0.0, timecode: "", clip_name: "", project: None, env: None, frame_rate: 24.0, working: filmcraft_color::WorkingSpace::Rec709 }
}
fn effect(id: &str, params: &[(&str, ParamValue)]) -> EffectInstance {
+34
View File
@@ -184,3 +184,37 @@ fn nested_adjustment_layers_stay_in_their_sequence() {
};
assert_eq!(img.get(5, 5), plain.get(5, 5));
}
#[test]
fn transform_shutter_on_an_adjustment_layer_spans_one_sequence_frame() {
// A 60 fps sequence: at 360° the shutter is one 60 fps frame, so the frames just outside
// the move stay sharp (they were blurred with a 24 fps shutter, #1088).
use filmcraft_project::{Interpolation, Keyframe};
let r60 = FrameRate::FPS_60;
let mut s = scene(2);
s.p.sequence_mut(s.seq).unwrap().settings.frame_rate = r60;
let red = matte(&mut s.p, &mut s.map, [1.0, 0.0, 0.0, 1.0]);
let below = place(&mut s.p, s.seq, 0, red);
clip(&mut s, below).effect_mut("motion").unwrap().params.get_mut("scale").unwrap().value = ParamValue::Float(25.0);
let adj = adjustment(&mut s.p, s.seq, 1);
fx(&mut s, adj, "transform");
let key = |sec: f64, x: f64| {
let mut k = Keyframe::new(Tick::from_seconds_f64(sec), ParamValue::Vec2(filmcraft_geom::Vec2::new(x, 90.0)));
k.interp = Interpolation::Linear;
k
};
let e = clip(&mut s, adj).effect_mut("transform").unwrap();
e.params.get_mut("shutter_angle").unwrap().value = ParamValue::Float(360.0);
e.params.get_mut("position").unwrap().keyframes = vec![key(0.5, 160.0), key(1.0, 260.0)];
let mut diff = |frame: i64| {
let mut at = |shutter: bool| {
clip(&mut s, adj).effect_mut("transform").unwrap().params.get_mut("shutter_override").unwrap().value = ParamValue::Bool(shutter);
render_sequence(&s.p, s.seq, r60.tick_of(frame), RenderOptions::default(), &s.map)
};
let (sharp, blurred) = (at(false), at(true));
sharp.px.iter().zip(&blurred.px).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max)
};
assert!(diff(29) < 1e-4, "frame before the move is blurred");
assert!(diff(61) < 1e-4, "frame after the move is blurred");
assert!(diff(45) > 1e-2, "moving frame is sharp");
}
+2
View File
@@ -192,6 +192,7 @@ fn graded_stats(img: &Image, base: &EffectInstance, x: &[f32; 10], skin_protect:
clip_name: "",
project: None,
env: None,
frame_rate: 24.0,
working: filmcraft_color::WorkingSpace::Rec709,
};
apply(&mut g, &set_params(base, x), &cx);
@@ -361,6 +362,7 @@ mod tests {
clip_name: "",
project: None,
env: None,
frame_rate: 24.0,
working: filmcraft_color::WorkingSpace::Rec709,
};
apply(&mut g, &set_params(base, &x), &cx);
+5
View File
@@ -29,6 +29,8 @@ pub struct FxCtx<'a> {
/// The clip's surroundings (other frames, other tracks, sequence geometry) for temporal,
/// track-reading and reframing effects; `None` in isolated tests and adjustment layers.
pub env: Option<&'a dyn crate::vfx::FxEnv>,
/// Sequence frame rate (shutter-angle motion blur); also known where `env` is `None`.
pub frame_rate: f64,
/// The sequence's working colour space: Lumetri grades HDR (PQ / HLG) working spaces in their
/// own signal, normalised to HDR White ([`filmcraft_color::GradeSpace`]).
pub working: filmcraft_color::WorkingSpace,
@@ -934,6 +936,7 @@ mod tests {
clip_name: "x",
project: None,
env: None,
frame_rate: 24.0,
working: filmcraft_color::WorkingSpace::Rec709,
}
}
@@ -1529,6 +1532,7 @@ mod lumetri_cpu_parity_tests {
clip_name: "",
project: None,
env: None,
frame_rate: 24.0,
working: filmcraft_color::WorkingSpace::Rec709,
};
@@ -1568,6 +1572,7 @@ mod lumetri_cpu_parity_tests {
clip_name: "",
project: None,
env: None,
frame_rate: 24.0,
working: filmcraft_color::WorkingSpace::Rec709,
};
let render = |roundness: f64| {
+1
View File
@@ -1113,6 +1113,7 @@ mod tests {
clip_name: "",
project: None,
env: None,
frame_rate: 24.0,
working: filmcraft_color::WorkingSpace::Rec709,
};
// Both sides of each cutoff, including samples inside the former artificial fade.
+2
View File
@@ -229,6 +229,7 @@ pub(crate) fn render_seq_tracks(project: &Project, seq: &Sequence, t: Tick, opts
clip_name: &item.name,
project: Some(project),
env: None,
frame_rate: seq.settings.frame_rate.as_f64(),
working: seq.settings.color.working,
};
for e in item.effects.iter().filter(|e| e.def().is_some_and(|d| !d.intrinsic)) {
@@ -483,6 +484,7 @@ pub(crate) fn item_layer_ex(
clip_name: &item.name,
project: Some(project),
env: Some(&env),
frame_rate: seq.settings.frame_rate.as_f64(),
working: seq.settings.color.working,
};
for e in item.effects.iter().filter(|e| e.def().is_some_and(|d| !d.intrinsic) && !filmcraft_project::graphic::is_layer(e)) {
+1 -1
View File
@@ -11,7 +11,7 @@ use crate::effects::{FxCtx, apply};
const RW: f32 = REFERENCE_WHITE_NITS as f32;
fn cx(working: WorkingSpace) -> FxCtx<'static> {
FxCtx { t: Tick::ZERO, px_scale: 1.0, seconds: 0.0, timecode: "", clip_name: "", project: None, env: None, working }
FxCtx { t: Tick::ZERO, px_scale: 1.0, seconds: 0.0, timecode: "", clip_name: "", project: None, env: None, frame_rate: 24.0, working }
}
fn lumetri(params: &[(&str, ParamValue)]) -> EffectInstance {
+1
View File
@@ -228,6 +228,7 @@ pub fn thumbnail(preset: &LumetriPreset, w: usize, h: usize) -> Image {
clip_name: "",
project: None,
env: None,
frame_rate: 24.0,
working: filmcraft_color::WorkingSpace::Rec709,
};
apply(&mut img, &preset.instance(), &cx);
+1
View File
@@ -434,6 +434,7 @@ fn push_item(
clip_name: &item.name,
project: Some(project),
env: None,
frame_rate: seq.settings.frame_rate.as_f64(),
working: seq.settings.color.working,
};
let ops: Option<Vec<FxOp>> = chain.iter().map(|e| FxOp::eval(e, &cx, lw, lh).filter(FxOp::gpu_ok)).collect();
+1
View File
@@ -234,6 +234,7 @@ fn effect_costs() {
clip_name: "x",
project: None,
env: None,
frame_rate: 24.0,
working: filmcraft_color::WorkingSpace::Rec709,
};
apply(&mut img, &e, &cx);
+2 -1
View File
@@ -233,7 +233,7 @@ pub fn rotate_3d(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
/// Transform: affine place, with optional shutter-angle motion blur when the override is on.
pub fn transform_fx(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let fps = cx.env.map_or(24.0, |env| env.frame_rate()).max(1.0);
let fps = cx.frame_rate.max(1.0);
let angle = fv(e, "shutter_angle", cx);
let n = if bv(e, "shutter_override") && angle > 0.5 { 7 } else { 1 };
let span = (angle as f64 / 360.0) / fps;
@@ -247,6 +247,7 @@ pub fn transform_fx(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
clip_name: cx.clip_name,
project: cx.project,
env: cx.env,
frame_rate: cx.frame_rate,
working: cx.working,
};
let anchor = crate::effects::point(e, "anchor", &cx2, im);
+1
View File
@@ -103,6 +103,7 @@ impl FxEnv for ItemEnv<'_> {
clip_name: &self.item.name,
project: Some(self.project),
env: None,
frame_rate: self.seq.settings.frame_rate.as_f64(),
working: self.seq.settings.color.working,
};
for e in &self.item.effects {
+29
View File
@@ -17,6 +17,7 @@ fn cx() -> FxCtx<'static> {
clip_name: "clip",
project: None,
env: None,
frame_rate: 24.0,
working: filmcraft_color::WorkingSpace::Rec709,
}
}
@@ -1154,3 +1155,31 @@ mod pipeline {
assert!(e.px.iter().all(|v| v.is_finite()));
}
}
#[test]
fn transform_shutter_uses_the_sequence_frame_rate_without_env() {
// An adjustment layer has no env: the shutter must still span one 60 fps frame at 360°, so
// the frames just before the motion starts and just after it ends stay sharp (#1088).
let mut e = inst("transform");
set(&mut e, "shutter_override", ParamValue::Bool(true));
setf(&mut e, "shutter_angle", 360.0);
let mut a = Keyframe::new(Tick::from_seconds_f64(1.0), ParamValue::Vec2(filmcraft_geom::Vec2::new(8.0, 16.0)));
a.interp = Interpolation::Linear;
let mut b = Keyframe::new(Tick::from_seconds_f64(2.0), ParamValue::Vec2(filmcraft_geom::Vec2::new(40.0, 16.0)));
b.interp = Interpolation::Linear;
e.params.insert("position".into(), Param { value: ParamValue::Vec2(filmcraft_geom::Vec2::new(8.0, 16.0)), keyframes: vec![a, b] });
let img = picture(32, 32);
let mut off = e.clone();
set(&mut off, "shutter_override", ParamValue::Bool(false));
let delta = |frame: i32| {
let s = f64::from(frame) / 60.0;
let cx = FxCtx { t: Tick::from_seconds_f64(s), seconds: s, frame_rate: 60.0, ..cx() };
max_diff(&run("transform", &off, &img, &cx), &run("transform", &e, &img, &cx))
};
for frame in [59, 121] {
assert!(delta(frame) < 1e-4, "frame {frame} is blurred outside the motion ({})", delta(frame));
}
for frame in [60, 90, 120] {
assert!(delta(frame) > 1e-3, "frame {frame} moves but is sharp");
}
}
+1
View File
@@ -17,6 +17,7 @@ fn cx() -> FxCtx<'static> {
clip_name: "x",
project: None,
env: None,
frame_rate: 24.0,
working: filmcraft_color::WorkingSpace::Rec709,
}
}
@@ -18,6 +18,7 @@ fn render_noise(effect_id: &str, rate: FrameRate, frame: i64, amount: f64) -> Ve
clip_name: "generated matte",
project: None,
env: None,
frame_rate: 24.0,
working: filmcraft_color::WorkingSpace::Rec709,
};
apply(&mut image, &effect, &context);