Adjustment layers: a transition fades their effects in and out instead of cutting them in after it
During a transition on its track an adjustment layer rendered as nothing, because it has no picture of its own: Cmd+D at its start left the effects off for the whole dissolve and then cut them in. In a transition the layer is now the frame below with its effects (opacity, masks and Motion region included), so a dissolve fades the effects. A fade from or to nothing keeps the layer's blend mode. Frames where a transition touches an adjustment layer render on the CPU. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
0541d4d182
commit
6bc526b5e1
|
|
@ -276,3 +276,40 @@ fn flash_cue_markers_round_trip() {
|
|||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.active_sequence().unwrap().markers.iter().all(|m| m.kind != MarkerKind::FlashCue));
|
||||
}
|
||||
|
||||
/// Apply Video Transition (Cmd+D) at both ends of an adjustment layer with Lumetri Color fades the
|
||||
/// grade in and out, as on a clip: halfway through each dissolve the picture is about halfway
|
||||
/// between the original and the graded frame, not the original with a hard cut after it.
|
||||
#[test]
|
||||
fn cmd_d_on_an_adjustment_layer_fades_its_effects_in_and_out() {
|
||||
let mut s = demo();
|
||||
let item = s.execute("file.newAdjustmentLayer", json!({"seconds": 4})).unwrap()["item"].as_u64().unwrap();
|
||||
let layer = s.execute("timeline.place", json!({"item": item, "track": "V3", "seconds": 1.5})).unwrap()["clips"][0].as_u64().unwrap();
|
||||
s.execute("effects.apply", json!({"clips": [layer], "effect": "lumetri"})).unwrap();
|
||||
s.execute("effects.setParam", json!({"clip": layer, "effect": "lumetri", "param": "exposure", "value": 2.0})).unwrap();
|
||||
s.execute("timeline.select", json!({"clips": [layer]})).unwrap();
|
||||
for edge in ["in", "out"] {
|
||||
s.execute("sequence.applyVideoTransition", json!({"clip": layer, "edge": edge, "frames": 24})).unwrap();
|
||||
}
|
||||
let transitions: Vec<_> = s.active_sequence().unwrap().video_tracks[2].transitions.clone();
|
||||
assert_eq!(transitions.len(), 2, "a dissolve at each end");
|
||||
let mean = |s: &Session, t: Tick| {
|
||||
let img = s.render_program_at(0.25, t).unwrap();
|
||||
img.px.chunks(4).map(|c| f64::from(c[0] + c[1] + c[2])).sum::<f64>() / (img.w * img.h) as f64
|
||||
};
|
||||
for tr in &transitions {
|
||||
let mid = tr.start + Tick(tr.duration.0 / 2);
|
||||
let mf = mean(&s, mid);
|
||||
// the original frame: the layer switched off; the graded frame: without the dissolves
|
||||
s.execute("clip.enable", json!({"clips": [layer]})).unwrap();
|
||||
let m0 = mean(&s, mid);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let ids: Vec<u64> = transitions.iter().map(|t| t.id.0).collect();
|
||||
s.execute("sequence.removeTransition", json!({"transitions": ids})).unwrap();
|
||||
let m1 = mean(&s, mid);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(m1 > m0 * 1.2, "the grade brightens the frame: {m0} → {m1}");
|
||||
let k = (mf - m0) / (m1 - m0);
|
||||
assert!((0.25..0.75).contains(&k), "halfway through the dissolve the grade is about half on: {k:.3}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -184,3 +184,87 @@ fn nested_adjustment_layers_stay_in_their_sequence() {
|
|||
};
|
||||
assert_eq!(img.get(5, 5), plain.get(5, 5));
|
||||
}
|
||||
|
||||
fn dissolve(s: &mut Scene, track: usize, from: Option<ClipId>, to: Option<ClipId>, start: i64) {
|
||||
let tr = filmcraft_project::Transition {
|
||||
id: filmcraft_project::TransitionId(900 + start as u64),
|
||||
effect: find_effect("cross_dissolve").unwrap().instance(),
|
||||
start: R.tick_of(start),
|
||||
duration: R.tick_of(12),
|
||||
from,
|
||||
to,
|
||||
align: Default::default(),
|
||||
reverse: false,
|
||||
};
|
||||
s.p.sequence_mut(s.seq).unwrap().video_tracks[track].transitions.push(tr);
|
||||
}
|
||||
|
||||
fn at(s: &Scene, frame: i64) -> [f32; 4] {
|
||||
render_sequence(&s.p, s.seq, R.tick_of(frame), RenderOptions::default(), &s.map).get(160, 90)
|
||||
}
|
||||
|
||||
/// A dissolve at the start of an adjustment layer (Cmd+D) fades its effects in, and one at its
|
||||
/// end fades them out, instead of leaving them off for the transition and cutting in after it.
|
||||
#[test]
|
||||
fn a_dissolve_fades_the_adjustment_layers_effects_in_and_out() {
|
||||
let mut s = scene(2);
|
||||
let red = matte(&mut s.p, &mut s.map, [1.0, 0.0, 0.0, 1.0]);
|
||||
place(&mut s.p, s.seq, 0, red);
|
||||
let adj = adjustment(&mut s.p, s.seq, 1);
|
||||
fx(&mut s, adj, "invert");
|
||||
dissolve(&mut s, 1, None, Some(adj), 0);
|
||||
dissolve(&mut s, 1, Some(adj), None, 36);
|
||||
// fade in over frames 0..12: red → halfway → cyan
|
||||
assert!(near(at(&s, 0), [1.0, 0.0, 0.0], 1e-3), "{:?}", at(&s, 0));
|
||||
assert!(near(at(&s, 3), [0.75, 0.25, 0.25], 1e-3), "a quarter in: {:?}", at(&s, 3));
|
||||
assert!(near(at(&s, 6), [0.5, 0.5, 0.5], 1e-3), "halfway in: {:?}", at(&s, 6));
|
||||
assert!(near(at(&s, 24), [0.0, 1.0, 1.0], 1e-3), "{:?}", at(&s, 24));
|
||||
// fade out over frames 36..48
|
||||
assert!(near(at(&s, 42), [0.5, 0.5, 0.5], 1e-3), "halfway out: {:?}", at(&s, 42));
|
||||
assert!(near(at(&s, 45), [0.75, 0.25, 0.25], 1e-3), "three quarters out: {:?}", at(&s, 45));
|
||||
}
|
||||
|
||||
/// The layer's own opacity and blend mode still count during the fade.
|
||||
#[test]
|
||||
fn a_dissolve_on_an_adjustment_layer_keeps_its_opacity_and_blend_mode() {
|
||||
let mut s = scene(2);
|
||||
let red = matte(&mut s.p, &mut s.map, [1.0, 0.0, 0.0, 1.0]);
|
||||
place(&mut s.p, s.seq, 0, red);
|
||||
let adj = adjustment(&mut s.p, s.seq, 1);
|
||||
fx(&mut s, adj, "invert");
|
||||
clip(&mut s, adj).effect_mut("opacity").unwrap().params.get_mut("opacity").unwrap().value = ParamValue::Float(50.0);
|
||||
dissolve(&mut s, 1, None, Some(adj), 0);
|
||||
// halfway through the fade, at 50 % opacity: a quarter of the way to cyan
|
||||
assert!(near(at(&s, 6), [0.75, 0.25, 0.25], 1e-3), "{:?}", at(&s, 6));
|
||||
// Multiply (red × cyan = black) fades from red to black
|
||||
let op = clip(&mut s, adj).effect_mut("opacity").unwrap();
|
||||
op.params.get_mut("opacity").unwrap().value = ParamValue::Float(100.0);
|
||||
op.params.get_mut("blend").unwrap().value =
|
||||
ParamValue::Choice(filmcraft_project::effect::BLEND_MODES.iter().position(|b| *b == "Multiply").unwrap() as u32);
|
||||
assert!(near(at(&s, 6), [0.5, 0.0, 0.0], 1e-3), "{:?}", at(&s, 6));
|
||||
assert!(near(at(&s, 24), [0.0, 0.0, 0.0], 1e-3), "{:?}", at(&s, 24));
|
||||
}
|
||||
|
||||
/// A frame where a transition touches an adjustment layer is composited on the CPU, which knows
|
||||
/// how to fade one; the layered (GPU) plan does not.
|
||||
#[test]
|
||||
fn a_transition_with_an_adjustment_layer_is_not_planned_as_layers() {
|
||||
let mut s = scene(2);
|
||||
let red = matte(&mut s.p, &mut s.map, [1.0, 0.0, 0.0, 1.0]);
|
||||
let blue = matte(&mut s.p, &mut s.map, [0.0, 0.0, 1.0, 1.0]);
|
||||
place(&mut s.p, s.seq, 0, red);
|
||||
// V2: a blue clip from 0 to 24, then an adjustment layer from 24, a dissolve centred on the cut
|
||||
let b = place(&mut s.p, s.seq, 1, blue);
|
||||
clip(&mut s, b).duration = R.tick_of(24);
|
||||
let a = s.p.add_item("Adjustment Layer", Label::Lavender, ItemKind::AdjustmentLayer { width: 320, height: 180, rate: R, duration: R.tick_of(48) }, None);
|
||||
let mut ti = s.p.make_track_item(a, TrackKind::Video, R.tick_of(24), TimeRange::new(Tick::ZERO, R.tick_of(24)), R).unwrap();
|
||||
ti.effects.insert(0, find_effect("invert").unwrap().instance());
|
||||
let ac = ti.id;
|
||||
s.p.sequence_mut(s.seq).unwrap().video_tracks[1].items.push(ti);
|
||||
dissolve(&mut s, 1, Some(b), Some(ac), 18);
|
||||
let q = s.p.sequence(s.seq).unwrap();
|
||||
// frame 20 is still on the blue clip, but the dissolve into the adjustment layer shows
|
||||
assert!(!crate::plan::layered_at(&s.p, q, R.tick_of(20)));
|
||||
// halfway (frame 24): half blue, half the inverted red (cyan)
|
||||
assert!(near(at(&s, 24), [0.0, 0.5, 1.0], 1e-3), "{:?}", at(&s, 24));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -189,14 +189,25 @@ pub(crate) fn render_seq_tracks(project: &Project, seq: &Sequence, t: Tick, opts
|
|||
if let Some(tr) = track.transitions.iter().find(|tr| tr.range().contains(t)) {
|
||||
let a = tr.from.and_then(|id| track.item(id)).filter(|i| i.enabled);
|
||||
let b = tr.to.and_then(|id| track.item(id)).filter(|i| i.enabled);
|
||||
let la = a
|
||||
.and_then(|i| item_layer(project, seq, i, t, opts, sources, &tc))
|
||||
.map(|(img, op, _)| with_opacity(img, op))
|
||||
.unwrap_or_else(|| Image::new(w, h));
|
||||
let lb = b
|
||||
.and_then(|i| item_layer(project, seq, i, t, opts, sources, &tc))
|
||||
.map(|(img, op, _)| with_opacity(img, op))
|
||||
.unwrap_or_else(|| Image::new(w, h));
|
||||
// An adjustment layer has no picture of its own: in a transition it is the frame below
|
||||
// with its effects, so a dissolve fades the effects in or out.
|
||||
let layer = |i: &TrackItem, below: &Image| -> Option<(Image, Blend)> {
|
||||
if is_adjustment(project, i) {
|
||||
return opts.effects.then(|| adjustment_layer(project, seq, i, t, opts, &tc, below));
|
||||
}
|
||||
item_layer(project, seq, i, t, opts, sources, &tc).map(|(img, op, bl)| (with_opacity(img, op), bl))
|
||||
};
|
||||
let below = canvas.snapshot();
|
||||
let la = a.and_then(|i| layer(i, &below));
|
||||
let lb = b.and_then(|i| layer(i, &below));
|
||||
// a fade from or to nothing keeps the one layer's blend mode (an adjustment layer in
|
||||
// Multiply ends the fade as it carries on); between two layers they mix normally
|
||||
let bl = match (&la, &lb) {
|
||||
(Some((_, x)), None) | (None, Some((_, x))) => *x,
|
||||
_ => Blend::Normal,
|
||||
};
|
||||
let la = la.map(|l| l.0).unwrap_or_else(|| Image::new(w, h));
|
||||
let lb = lb.map(|l| l.0).unwrap_or_else(|| Image::new(w, h));
|
||||
let p = tr.progress(t) as f32;
|
||||
// Reverse plays the transition backwards (e.g. an iris closing on the outgoing clip)
|
||||
// while still going from A to B.
|
||||
|
|
@ -205,46 +216,20 @@ pub(crate) fn render_seq_tracks(project: &Project, seq: &Sequence, t: Tick, opts
|
|||
} else {
|
||||
transitions::apply_scaled(&tr.effect, &la, &lb, p, opts.scale)
|
||||
};
|
||||
blend::composite(canvas.get(), &mixed, 1.0, Blend::Normal);
|
||||
blend::composite(canvas.get(), &mixed, 1.0, bl);
|
||||
continue;
|
||||
}
|
||||
let Some(item) = track.item_at(t) else { continue };
|
||||
if !item.enabled {
|
||||
continue;
|
||||
}
|
||||
let pi = project.item(item.item);
|
||||
if let Some(pi) = pi
|
||||
&& matches!(pi.kind, ItemKind::AdjustmentLayer { .. })
|
||||
{
|
||||
if is_adjustment(project, item) {
|
||||
if !opts.effects {
|
||||
continue;
|
||||
}
|
||||
let mt = item.effect_time_at(t);
|
||||
let mut adjusted = canvas.snapshot();
|
||||
let cx = effects::FxCtx {
|
||||
t: mt,
|
||||
px_scale: opts.scale,
|
||||
seconds: (t - item.start).seconds(),
|
||||
timecode: &tc,
|
||||
clip_name: &item.name,
|
||||
project: Some(project),
|
||||
env: None,
|
||||
working: seq.settings.color.working,
|
||||
};
|
||||
for e in item.effects.iter().filter(|e| e.def().is_some_and(|d| !d.intrinsic)) {
|
||||
mask::apply_effect(&mut adjusted, e, &cx);
|
||||
}
|
||||
let (op, bl) = opacity_blend(item, mt);
|
||||
// Adjustment layer opacity (and opacity masks, in sequence pixels) mix adjusted over original.
|
||||
let mut out = canvas.snapshot();
|
||||
let mut adj = adjusted;
|
||||
adj.scale_alpha(op);
|
||||
mask::apply_opacity_masks(&mut adj, item, mt, opts.scale);
|
||||
// Motion moves / scales the adjustment layer's frame: it only applies inside it.
|
||||
if let Some(region) = adjustment_region(seq, item, project, mt, opts.scale, w, h) {
|
||||
let cov: Vec<f32> = region.px.as_chunks::<4>().0.iter().map(|p| p[3]).collect();
|
||||
mask::scale_by(&mut adj, &cov);
|
||||
}
|
||||
let below = canvas.snapshot();
|
||||
let (adj, bl) = adjustment_layer(project, seq, item, t, opts, &tc, &below);
|
||||
let mut out = below;
|
||||
blend::composite(&mut out, &adj, 1.0, bl);
|
||||
canvas.set(out);
|
||||
continue;
|
||||
|
|
@ -279,6 +264,42 @@ pub(crate) fn render_seq_tracks(project: &Project, seq: &Sequence, t: Tick, opts
|
|||
canvas
|
||||
}
|
||||
|
||||
fn is_adjustment(project: &Project, item: &TrackItem) -> bool {
|
||||
project.item(item.item).is_some_and(|pi| matches!(pi.kind, ItemKind::AdjustmentLayer { .. }))
|
||||
}
|
||||
|
||||
/// An adjustment layer as a layer over `below` (the composite of the tracks under it): `below`
|
||||
/// with the layer's effects, faded by its opacity and opacity masks and limited to its
|
||||
/// (Motion-transformed) frame, and the blend mode it mixes with.
|
||||
fn adjustment_layer(project: &Project, seq: &Sequence, item: &TrackItem, t: Tick, opts: RenderOptions, tc: &str, below: &Image) -> (Image, Blend) {
|
||||
let (w, h) = (below.w, below.h);
|
||||
let mt = item.effect_time_at(t);
|
||||
let mut adj = below.clone();
|
||||
let cx = effects::FxCtx {
|
||||
t: mt,
|
||||
px_scale: opts.scale,
|
||||
seconds: (t - item.start).seconds(),
|
||||
timecode: tc,
|
||||
clip_name: &item.name,
|
||||
project: Some(project),
|
||||
env: None,
|
||||
working: seq.settings.color.working,
|
||||
};
|
||||
for e in item.effects.iter().filter(|e| e.def().is_some_and(|d| !d.intrinsic)) {
|
||||
mask::apply_effect(&mut adj, e, &cx);
|
||||
}
|
||||
let (op, bl) = opacity_blend(item, mt);
|
||||
// Adjustment layer opacity (and opacity masks, in sequence pixels) mix adjusted over original.
|
||||
adj.scale_alpha(op);
|
||||
mask::apply_opacity_masks(&mut adj, item, mt, opts.scale);
|
||||
// Motion moves / scales the adjustment layer's frame: it only applies inside it.
|
||||
if let Some(region) = adjustment_region(seq, item, project, mt, opts.scale, w, h) {
|
||||
let cov: Vec<f32> = region.px.as_chunks::<4>().0.iter().map(|p| p[3]).collect();
|
||||
mask::scale_by(&mut adj, &cov);
|
||||
}
|
||||
(adj, bl)
|
||||
}
|
||||
|
||||
/// Coverage of an adjustment layer's (Motion-transformed) frame in output pixels, or `None` when
|
||||
/// it covers the whole output.
|
||||
fn adjustment_region(seq: &Sequence, item: &TrackItem, project: &Project, mt: Tick, scale: f32, w: usize, h: usize) -> Option<Image> {
|
||||
|
|
|
|||
|
|
@ -228,20 +228,24 @@ fn whole_frame_on_cpu(project: &Project, seq: &Sequence, t: Tick) -> bool {
|
|||
}
|
||||
|
||||
/// Whether the frame of `seq` at `t` can be planned as layers: no adjustment layer and no
|
||||
/// transition the compositor cannot mix itself is showing.
|
||||
fn layered_at(project: &Project, seq: &Sequence, t: Tick) -> bool {
|
||||
/// transition the compositor cannot mix itself is showing (a transition into or out of an
|
||||
/// adjustment layer counts, also on the side of the cut where the other clip is).
|
||||
pub(crate) fn layered_at(project: &Project, seq: &Sequence, t: Tick) -> bool {
|
||||
let adjustment = |it: &TrackItem| project.item(it.item).is_some_and(|p| matches!(p.kind, ItemKind::AdjustmentLayer { .. }));
|
||||
for tr in &seq.video_tracks {
|
||||
if !tr.enabled {
|
||||
continue;
|
||||
}
|
||||
if let Some(trn) = tr.transitions.iter().find(|x| x.range().contains(t))
|
||||
&& !simple_transition(&trn.effect.effect)
|
||||
&& trn.effect.effect != "push"
|
||||
{
|
||||
return false;
|
||||
if let Some(trn) = tr.transitions.iter().find(|x| x.range().contains(t)) {
|
||||
if !simple_transition(&trn.effect.effect) && trn.effect.effect != "push" {
|
||||
return false;
|
||||
}
|
||||
if [trn.from, trn.to].into_iter().flatten().filter_map(|c| tr.item(c)).any(adjustment) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(it) = tr.item_at(t)
|
||||
&& project.item(it.item).is_some_and(|p| matches!(p.kind, ItemKind::AdjustmentLayer { .. }))
|
||||
&& adjustment(it)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue