mirror of
https://github.com/storytold/filmcraft.git
synced 2026-10-24 10:10:05 -06:00
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:
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user