mirror of
https://github.com/storytold/filmcraft.git
synced 2026-11-02 18:09:21 +00:00
Merge integration track fx
This commit is contained in:
@@ -207,21 +207,52 @@ fn rayon_frame_requests_while_decoding_on_rayon_never_deadlock() {
|
||||
let _ = tx.send(());
|
||||
});
|
||||
// A deadlock stops every request of a round for good. On a loaded machine the 3,840
|
||||
// requests can take minutes, so the limit applies to the time between two answered ones.
|
||||
let mut answered = 0;
|
||||
// requests can take minutes, so the limit applies to the time since the last answered one.
|
||||
if let Err(frames) = wait_while_answered(&rx, &done, Duration::from_secs(120)) {
|
||||
panic!("frame requests deadlocked after {frames} frames");
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits for `rx` while `done` keeps growing. Returns `Err` with the count in `done` once
|
||||
/// `limit` has passed since the count last grew (or since the call, before it grows).
|
||||
fn wait_while_answered(rx: &std::sync::mpsc::Receiver<()>, done: &AtomicUsize, limit: Duration) -> Result<(), usize> {
|
||||
let mut answered = done.load(Ordering::Relaxed);
|
||||
let mut last_answer = Instant::now();
|
||||
loop {
|
||||
match rx.recv_timeout(Duration::from_secs(120)) {
|
||||
Ok(()) => break,
|
||||
match rx.recv_timeout(limit / 120) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
||||
let now = done.load(Ordering::Relaxed);
|
||||
assert!(now > answered, "frame requests deadlocked after {now} frames");
|
||||
answered = now;
|
||||
if now > answered {
|
||||
answered = now;
|
||||
last_answer = Instant::now();
|
||||
} else if last_answer.elapsed() >= limit {
|
||||
return Err(now);
|
||||
}
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => panic!("a frame request failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stall_right_after_an_answer_is_reported_once_the_limit_has_passed() {
|
||||
let (_tx, rx) = std::sync::mpsc::channel();
|
||||
let done = Arc::new(AtomicUsize::new(0));
|
||||
let answering = done.clone();
|
||||
let (answer_tx, answer_rx) = std::sync::mpsc::channel();
|
||||
let limit = Duration::from_secs(2);
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
let answer = Instant::now();
|
||||
answering.fetch_add(1, Ordering::Relaxed);
|
||||
let _ = answer_tx.send(answer);
|
||||
});
|
||||
assert_eq!(wait_while_answered(&rx, &done, limit), Err(1));
|
||||
let waited = answer_rx.recv().expect("answer time").elapsed();
|
||||
assert!(waited >= limit && waited < limit * 3 / 2, "the stall was reported {waited:?} after the answer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_decoder_continues_through_a_nearby_sync_sample() {
|
||||
let s = samples(200, 30, 0, false);
|
||||
|
||||
@@ -392,7 +392,7 @@ fn list_presets(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// Lumetri Presets ▸ apply: a new Lumetri Color configured as the preset on each video clip
|
||||
/// (standard effects go before the intrinsic ones, like `effects.apply`).
|
||||
/// (added at the end of the clip's effects, like `effects.apply`).
|
||||
fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "lumetri.applyPreset";
|
||||
let name = str_p(p, "name").ok_or_else(|| bad(cmd, "need `name`"))?;
|
||||
@@ -406,8 +406,7 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut n = 0;
|
||||
for t in q.video_tracks.iter_mut() {
|
||||
for it in t.items.iter_mut().filter(|i| clips.contains(&i.id)) {
|
||||
let pos = it.effects.iter().position(|e| e.def().is_some_and(|d| d.intrinsic)).unwrap_or(it.effects.len());
|
||||
it.effects.insert(pos, inst.clone());
|
||||
it.effects.push(inst.clone());
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,14 +203,14 @@ fn lumetri_presets_list_apply_and_thumbnails() {
|
||||
assert_eq!(r["folders"], json!(["Cinematic", "Film Emulation", "Monochrome", "Technical"]));
|
||||
let mono = s.execute("lumetri.presets", json!({"folder": "monochrome"})).unwrap();
|
||||
assert!(mono["presets"].as_array().unwrap().iter().all(|p| p["folder"] == "Monochrome"));
|
||||
// apply: one undo step adding a configured Lumetri Color before the intrinsic effects
|
||||
// apply: one undo step adding a configured Lumetri Color at the end of the clip's effects
|
||||
let before = s.active_sequence().unwrap().find_item(clip).unwrap().1.effects.len();
|
||||
let r = s.execute("lumetri.applyPreset", json!({"name": "Neutral Mono"})).unwrap();
|
||||
assert_eq!(r["clips"], 1);
|
||||
let it = s.active_sequence().unwrap().find_item(clip).unwrap().1.clone();
|
||||
assert_eq!(it.effects.len(), before + 1);
|
||||
let k = it.effects.iter().position(|e| e.effect == "lumetri").unwrap();
|
||||
assert!(it.effects[k + 1..].iter().all(|e| e.def().is_some_and(|d| d.intrinsic)) || k + 1 == it.effects.len());
|
||||
assert_eq!(k + 1, it.effects.len());
|
||||
assert_eq!(it.effects[k].param("saturation").unwrap().value, ParamValue::Float(0.0));
|
||||
let img = s.render_program(0.25).unwrap();
|
||||
let grey = img.px.as_chunks::<4>().0.iter().filter(|p| p[3] > 0.5).all(|p| (p[0] - p[1]).abs() < 0.01 && (p[1] - p[2]).abs() < 0.01);
|
||||
|
||||
@@ -3369,10 +3369,11 @@ fn build() -> Vec<CommandSpec> {
|
||||
// intrinsic effects (Motion) are laid out in the sequence frame, as `reset_params` does
|
||||
let frame = if def.intrinsic { (fw, fh) } else { size };
|
||||
resolve_auto_points(&mut inst, frame, size);
|
||||
// standard effects go before the intrinsic ones (render order)
|
||||
let pos = i.effects.iter().position(|e| e.def().is_some_and(|d| d.intrinsic)).unwrap_or(i.effects.len());
|
||||
let pos = if is_audio { i.effects.len() } else { pos };
|
||||
i.effects.insert(pos, inst);
|
||||
// at the end of the stack, as in Premiere, so the indices already
|
||||
// handed out (graphics.list effectIndex…) stay valid (#1879); the
|
||||
// renderer applies the intrinsic ones after the standard ones anyway
|
||||
i.effects.push(inst);
|
||||
let pos = i.effects.len() - 1;
|
||||
applied.push(json!({"clip": i.id.0, "index": pos, "effect": def.id}));
|
||||
}
|
||||
}
|
||||
@@ -3451,7 +3452,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
}
|
||||
let (keyframes, value) = s.edit_sequence_as("Change Effect Parameter", merge.as_deref(), |q, _, _| {
|
||||
let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?;
|
||||
let mt = it.source_time_at(at.unwrap_or_else(|| ph.clamp(it.start, (it.end() - Tick(1)).max(it.start))));
|
||||
let mt = it.effect_time_at(at.unwrap_or_else(|| ph.clamp(it.start, (it.end() - Tick(1)).max(it.start))));
|
||||
let e = match &eff {
|
||||
Value::Number(n) => it.effects.get_mut(n.as_u64().unwrap_or(0) as usize),
|
||||
Value::String(sid) => it.effects.iter_mut().find(|e| &e.effect == sid),
|
||||
@@ -3532,7 +3533,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
let ph = s.playhead();
|
||||
s.edit_sequence("Toggle Animation", |q, _, _| {
|
||||
let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?;
|
||||
let mt = it.source_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start)));
|
||||
let mt = it.effect_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start)));
|
||||
let e = match &eff {
|
||||
Value::Number(n) => it.effects.get_mut(n.as_u64().unwrap_or(0) as usize),
|
||||
Value::String(sid) => it.effects.iter_mut().find(|e| &e.effect == sid),
|
||||
@@ -3572,7 +3573,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
let ph = s.playhead();
|
||||
s.edit_sequence("Reset Effect", |q, _, _| {
|
||||
let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?;
|
||||
let mt = it.source_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start)));
|
||||
let mt = it.effect_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start)));
|
||||
if let Some(e) = it.effects.get_mut(idx) {
|
||||
reset_params(e, None, frame, source, mt);
|
||||
}
|
||||
@@ -3589,7 +3590,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
let ph = s.playhead();
|
||||
s.edit_sequence("Reset Parameter", |q, _, _| {
|
||||
let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?;
|
||||
let mt = it.source_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start)));
|
||||
let mt = it.effect_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start)));
|
||||
let e = match &eff {
|
||||
Value::Number(n) => it.effects.get_mut(n.as_u64().unwrap_or(0) as usize),
|
||||
Value::String(sid) => it.effects.iter_mut().find(|e| &e.effect == sid),
|
||||
@@ -3638,9 +3639,9 @@ fn build() -> Vec<CommandSpec> {
|
||||
"Keyframe Interpolation",
|
||||
[],
|
||||
None,
|
||||
r#"{"clip":id,"effect":str|index,"param":str,"mediaTime":ticks,"interpolation":"linear|bezier|autoBezier|continuousBezier|hold|easeIn|easeOut"}"#,
|
||||
r#"{"clip":id,"effect":str|index,"param":str,"mediaTime":ticks,"interpolation":"linear|bezier|autoBezier|continuousBezier|hold|easeIn|easeOut"}|{"keyframes":[{"clip":id,"effect":index,"param":str,"mask":n?,"time":mediaTicks}],"interpolation":str}"#,
|
||||
has_seq,
|
||||
|s, p| keyframe_op(s, p, "interp")
|
||||
|s, p| if p.get("keyframes").is_some() { crate::keyframe_move::interpolate_many(s, p) } else { keyframe_op(s, p, "interp") }
|
||||
),
|
||||
cmd!(
|
||||
"effects.setKeyframe",
|
||||
@@ -3897,6 +3898,19 @@ fn reset_params(e: &mut filmcraft_project::EffectInstance, only: Option<&str>, f
|
||||
any
|
||||
}
|
||||
|
||||
/// The `interpolation` parameter; a missing or unknown name is linear.
|
||||
pub(crate) fn interpolation_p(p: &Value) -> filmcraft_project::Interpolation {
|
||||
match str_p(p, "interpolation").unwrap_or("linear").to_ascii_lowercase().as_str() {
|
||||
"bezier" => filmcraft_project::Interpolation::Bezier,
|
||||
"autobezier" => filmcraft_project::Interpolation::AutoBezier,
|
||||
"continuousbezier" => filmcraft_project::Interpolation::ContinuousBezier,
|
||||
"hold" => filmcraft_project::Interpolation::Hold,
|
||||
"easein" => filmcraft_project::Interpolation::EaseIn,
|
||||
"easeout" => filmcraft_project::Interpolation::EaseOut,
|
||||
_ => filmcraft_project::Interpolation::Linear,
|
||||
}
|
||||
}
|
||||
|
||||
/// `effects.addKeyframe` / `deleteKeyframe` / `moveKeyframe` / `setKeyframe` / `setKeyframeInterpolation`.
|
||||
/// An explicit `time` keeps its place, outside the clip too (#1055, as in `effects.setParam`); only
|
||||
/// the playhead fallback is pinned to the clip's last tick. `add` replies `{added, mediaTime}`:
|
||||
@@ -3910,7 +3924,7 @@ fn keyframe_op(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
|
||||
let playhead = s.playhead();
|
||||
let mtime = p.get("mediaTime").and_then(Value::as_i64).map(Tick);
|
||||
let to = p.get("to").and_then(Value::as_i64).map(Tick);
|
||||
let interp = str_p(p, "interpolation").map(str::to_string);
|
||||
let interp = interpolation_p(p);
|
||||
let label = match op {
|
||||
"add" => "Add Keyframe",
|
||||
"delete" => "Delete Keyframe",
|
||||
@@ -3926,7 +3940,7 @@ fn keyframe_op(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
|
||||
s.edit_sequence_as(label, merge.as_deref(), |q, _, _| {
|
||||
let mut reply = Value::Null;
|
||||
let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?;
|
||||
let mt_now = it.source_time_at(at.unwrap_or_else(|| playhead.clamp(it.start, (it.end() - Tick(1)).max(it.start))));
|
||||
let mt_now = it.effect_time_at(at.unwrap_or_else(|| playhead.clamp(it.start, (it.end() - Tick(1)).max(it.start))));
|
||||
let e = match &eff {
|
||||
Value::Number(n) => it.effects.get_mut(n.as_u64().unwrap_or(0) as usize),
|
||||
Value::String(sid) => it.effects.iter_mut().find(|e| &e.effect == sid),
|
||||
@@ -3976,17 +3990,8 @@ fn keyframe_op(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
|
||||
}
|
||||
_ => {
|
||||
let at = mtime.unwrap_or(mt_now);
|
||||
let kind = match interp.as_deref().unwrap_or("linear").to_ascii_lowercase().as_str() {
|
||||
"bezier" => filmcraft_project::Interpolation::Bezier,
|
||||
"autobezier" => filmcraft_project::Interpolation::AutoBezier,
|
||||
"continuousbezier" => filmcraft_project::Interpolation::ContinuousBezier,
|
||||
"hold" => filmcraft_project::Interpolation::Hold,
|
||||
"easein" => filmcraft_project::Interpolation::EaseIn,
|
||||
"easeout" => filmcraft_project::Interpolation::EaseOut,
|
||||
_ => filmcraft_project::Interpolation::Linear,
|
||||
};
|
||||
let k = prm.keyframes.iter_mut().find(|k| k.time == at).ok_or_else(|| bad("keyframe", "no keyframe at that time"))?;
|
||||
k.interp = kind;
|
||||
k.interp = interp;
|
||||
}
|
||||
}
|
||||
Ok(reply)
|
||||
@@ -4791,6 +4796,33 @@ pub fn preview_transition(s: &Session, p: &Value, kind: TrackKind) -> Result<(Tr
|
||||
if q.track(track).is_some_and(|tr| tr.locked) {
|
||||
return Err(edit::EditError::Locked.into());
|
||||
}
|
||||
// A transition has to fit the clips around its cut: `edit::transitions::set_span` keeps that
|
||||
// invariant when one is resized, but nothing limited the duration when a transition was
|
||||
// created, so a default longer than a short clip can hold went in unchecked (#3455). Clamp
|
||||
// the planned duration to the same limit: a centered crossfade spends half on each side of
|
||||
// the cut, a fade to/from nothing spends it all on its one side.
|
||||
let (lo, hi) = {
|
||||
let track_ref = q.track(track).ok_or_else(|| EngineError::Other("no track at the edit point".into()))?;
|
||||
let f = from.and_then(|c| track_ref.item(c));
|
||||
let n = to.and_then(|c| track_ref.item(c));
|
||||
match (f, n) {
|
||||
(Some(f), Some(n)) => (f.start, n.end()),
|
||||
(None, Some(n)) => (n.start, n.end()),
|
||||
(Some(f), None) => (f.start, f.end()),
|
||||
(None, None) => return Err(EngineError::Other("the edit point has no clip to transition on".into())),
|
||||
}
|
||||
};
|
||||
let room = if from.is_some() && to.is_some() {
|
||||
(cut - lo).min(hi - cut).mul_ratio(2, 1)
|
||||
} else if to.is_some() {
|
||||
hi - cut
|
||||
} else {
|
||||
cut - lo
|
||||
};
|
||||
let dur = dur.min(room);
|
||||
if dur < rate.tick_of(1) {
|
||||
return Err(bad("transition", "no room for a transition at this cut: the clip(s) around it are shorter than a frame"));
|
||||
}
|
||||
let start = if from.is_some() && to.is_some() {
|
||||
cut - dur.mul_ratio(1, 2)
|
||||
} else if to.is_some() {
|
||||
|
||||
@@ -871,3 +871,78 @@ fn align_puts_a_pinned_layer_where_it_asks() {
|
||||
assert!(e1.x < ellipse.x - 100.0, "the ellipse moved left: {ellipse:?} → {e1:?}");
|
||||
assert!((r1.x - rect.x).abs() < 1e-6 && (r1.y - rect.y).abs() < 1e-6, "the pinned rectangle stays where Align put it: {rect:?} → {r1:?}");
|
||||
}
|
||||
#[test]
|
||||
fn applied_effects_stack_after_the_clip_s_own_and_keep_their_indices() {
|
||||
// #1879
|
||||
let mut s = Session::default();
|
||||
s.execute("file.newSequence", json!({"name": "R", "width": 1920, "height": 1080, "fps": 30, "video": 2, "audio": 1})).unwrap();
|
||||
let r = s.execute("graphics.newEllipse", json!({"position": [960, 540], "size": [400, 400], "track": 1, "time": 0, "seconds": 2})).unwrap();
|
||||
let clip = r["clip"].as_u64().unwrap();
|
||||
let ids = |s: &Session| s.active_sequence().unwrap().find_item(ClipId(clip)).unwrap().1.effects.iter().map(|e| e.effect.clone()).collect::<Vec<_>>();
|
||||
let before = ids(&s);
|
||||
let layer = s.execute("graphics.list", json!({"clip": clip})).unwrap()["layers"][0]["effectIndex"].clone();
|
||||
s.execute("effects.apply", json!({"clips": [clip], "effect": "gaussian_blur"})).unwrap();
|
||||
s.execute("effects.apply", json!({"clips": [clip], "effect": "tint"})).unwrap();
|
||||
let after = ids(&s);
|
||||
assert_eq!(after[..before.len()], before[..], "{after:?}");
|
||||
assert_eq!(after[before.len()..], ["gaussian_blur", "tint"], "{after:?}");
|
||||
assert_eq!(s.execute("graphics.list", json!({"clip": clip})).unwrap()["layers"][0]["effectIndex"], layer);
|
||||
s.execute("effects.setParam", json!({"clip": clip, "effect": layer, "param": "position", "value": [100, 100]})).unwrap();
|
||||
}
|
||||
|
||||
/// #1879 moved applied effects from before the clip's intrinsic effects to the end of its stack.
|
||||
/// The frame must not change: the same clips render the same pixels with the effects laid out the
|
||||
/// old way (built by hand) and the new way.
|
||||
#[test]
|
||||
fn applied_effects_render_the_same_before_or_after_the_intrinsic_ones() {
|
||||
fn frame(s: &Session) -> Vec<f32> {
|
||||
s.render_program(0.25).unwrap().px
|
||||
}
|
||||
// the layout `effects.apply` gave before #1879: the applied effects (the last `n`) before the
|
||||
// clip's first intrinsic effect, in the order they were applied
|
||||
fn before_intrinsics(s: &mut Session, clip: ClipId, n: usize) {
|
||||
let seq = s.state.active_sequence.unwrap();
|
||||
let q = std::sync::Arc::make_mut(&mut s.project).sequence_mut(seq).unwrap();
|
||||
let it = q.video_tracks.iter_mut().flat_map(|t| t.items.iter_mut()).find(|i| i.id == clip).unwrap();
|
||||
let applied = it.effects.split_off(it.effects.len() - n);
|
||||
let pos = it.effects.iter().position(|e| e.def().is_some_and(|d| d.intrinsic)).unwrap();
|
||||
it.effects.splice(pos..pos, applied);
|
||||
}
|
||||
let apply = |s: &mut Session, clip: u64| {
|
||||
s.execute("effects.apply", json!({"clips": [clip], "effect": "gaussian_blur"})).unwrap();
|
||||
s.execute("effects.setParam", json!({"clip": clip, "effect": "gaussian_blur", "param": "blurriness", "value": 12.0})).unwrap();
|
||||
s.execute("effects.apply", json!({"clips": [clip], "effect": "posterize"})).unwrap();
|
||||
s.execute("effects.setParam", json!({"clip": clip, "effect": "posterize", "param": "levels", "value": 3.0})).unwrap();
|
||||
};
|
||||
let check = |mut s: Session, clip: u64, case: &str| {
|
||||
let plain = frame(&s);
|
||||
apply(&mut s, clip);
|
||||
let ids: Vec<_> = s.active_sequence().unwrap().find_item(ClipId(clip)).unwrap().1.effects.iter().map(|e| e.effect.clone()).collect();
|
||||
assert_eq!(ids[ids.len() - 2..], ["gaussian_blur", "posterize"], "{case}: {ids:?}");
|
||||
let after = frame(&s);
|
||||
assert!(after != plain, "{case}: the applied effects show in the frame");
|
||||
before_intrinsics(&mut s, ClipId(clip), 2);
|
||||
let ids: Vec<_> = s.active_sequence().unwrap().find_item(ClipId(clip)).unwrap().1.effects.iter().map(|e| e.effect.clone()).collect();
|
||||
assert!(ids.iter().position(|e| e == "posterize") < ids.iter().position(|e| e == "motion"), "{case}: {ids:?}");
|
||||
assert!(after == frame(&s), "{case}: same pixels with the applied effects before the intrinsic ones");
|
||||
};
|
||||
|
||||
// (a) a graphic clip with its layer
|
||||
let mut s = Session::default();
|
||||
s.execute("file.newSequence", json!({"name": "R", "width": 640, "height": 360, "fps": 30, "video": 2, "audio": 1})).unwrap();
|
||||
let r = s.execute("graphics.newEllipse", json!({"position": [320, 180], "size": [200, 120], "track": 1, "time": 0, "seconds": 2})).unwrap();
|
||||
let clip = r["clip"].as_u64().unwrap();
|
||||
check(s, clip, "graphic clip");
|
||||
|
||||
// (b) a video clip with Motion and Opacity off their defaults
|
||||
let mut s = demo();
|
||||
let clip = s.active_sequence().unwrap().video_tracks[0].items[0].id.0;
|
||||
// past the dip to black that opens the demo
|
||||
s.execute("playhead.set", json!({"seconds": 2.5})).unwrap();
|
||||
for (effect, param, value) in
|
||||
[("motion", "position", json!([700, 400])), ("motion", "scale", json!(70.0)), ("motion", "rotation", json!(15.0)), ("opacity", "opacity", json!(80.0))]
|
||||
{
|
||||
s.execute("effects.setParam", json!({"clip": clip, "effect": effect, "param": param, "value": value})).unwrap();
|
||||
}
|
||||
check(s, clip, "video clip");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Atomic movement of keys across effect/mask parameters; the common timeline delta keeps
|
||||
//! relative timing intact, including at the clip boundaries. Selection itself is a UI concern.
|
||||
//! Atomic edits of keys across effect/mask parameters: movement, where the common timeline delta
|
||||
//! keeps relative timing intact, including at the clip boundaries, and interpolation. Selection
|
||||
//! itself is a UI concern.
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
|
||||
use filmcraft_project::{ClipId, Keyframe, Param, Sequence, TrackItem};
|
||||
@@ -25,14 +26,14 @@ struct Key {
|
||||
|
||||
type Lane = (u64, usize, Option<usize>, String);
|
||||
|
||||
fn param_mut<'a>(sequence: &'a mut Sequence, lane: &Lane) -> Result<&'a mut Param> {
|
||||
let (_, it) = sequence.find_item_mut(ClipId(lane.0)).ok_or_else(|| bad(COMMAND, "clip does not exist"))?;
|
||||
let effect = it.effects.get_mut(lane.1).ok_or_else(|| bad(COMMAND, "effect does not exist"))?;
|
||||
fn param_mut<'a>(sequence: &'a mut Sequence, lane: &Lane, command: &str) -> Result<&'a mut Param> {
|
||||
let (_, it) = sequence.find_item_mut(ClipId(lane.0)).ok_or_else(|| bad(command, "clip does not exist"))?;
|
||||
let effect = it.effects.get_mut(lane.1).ok_or_else(|| bad(command, "effect does not exist"))?;
|
||||
match lane.2 {
|
||||
Some(mask) => effect.masks.get_mut(mask).and_then(|m| m.param_mut(&lane.3)),
|
||||
None => effect.params.get_mut(&lane.3),
|
||||
}
|
||||
.ok_or_else(|| bad(COMMAND, "parameter does not exist"))
|
||||
.ok_or_else(|| bad(command, "parameter does not exist"))
|
||||
}
|
||||
|
||||
fn direction(it: &TrackItem) -> f64 {
|
||||
@@ -94,7 +95,7 @@ pub(crate) fn move_many(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
s.edit_sequence("Move Keyframes", |sequence, _, _| {
|
||||
for (lane, moved) in lanes {
|
||||
let param = param_mut(sequence, &lane)?;
|
||||
let param = param_mut(sequence, &lane, COMMAND)?;
|
||||
let from: HashSet<_> = moved.iter().map(|(t, _)| *t).collect();
|
||||
let to: HashSet<_> = moved.iter().map(|(_, k)| k.time).collect();
|
||||
// Remove all originals before inserting any destination: selected keys can move into
|
||||
@@ -108,9 +109,32 @@ pub(crate) fn move_many(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"delta":delta,"keyframes":keys}))
|
||||
}
|
||||
|
||||
/// `effects.setInterpolation` given a `keyframes` list: every listed key changes in one undo step,
|
||||
/// so a menu applies to the whole selection (#1916).
|
||||
pub(crate) fn interpolate_many(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const SET: &str = "effects.setInterpolation";
|
||||
let raw = p.get("keyframes").and_then(Value::as_array).ok_or_else(|| bad(SET, "need keyframes"))?;
|
||||
if raw.is_empty() || raw.len() > MAX_KEYS {
|
||||
return Err(bad(SET, "keyframe count must be between 1 and 4096"));
|
||||
}
|
||||
let keys: Vec<Key> = serde_json::from_value(Value::Array(raw.clone())).map_err(|_| bad(SET, "invalid keyframe reference"))?;
|
||||
let kind = crate::commands::interpolation_p(p);
|
||||
s.edit_sequence("Keyframe Interpolation", |sequence, _, _| {
|
||||
for key in &keys {
|
||||
let lane = (key.clip, key.effect, key.mask, key.param.clone());
|
||||
let param = param_mut(sequence, &lane, SET)?;
|
||||
let k = param.keyframes.iter_mut().find(|k| k.time == key.time).ok_or_else(|| bad(SET, "keyframe does not exist"))?;
|
||||
k.interp = kind;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use filmcraft_project::Interpolation;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn fixture() -> (Session, Vec<Value>) {
|
||||
@@ -196,6 +220,36 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpolation_of_listed_keys_across_lanes_is_one_undo_step() {
|
||||
const SET: &str = "effects.setInterpolation";
|
||||
let (mut s, keys) = fixture();
|
||||
let project = s.project.clone();
|
||||
let undo = s.history.undo.len();
|
||||
s.execute(SET, json!({"keyframes":[keys[0],keys[2]],"interpolation":"hold"})).unwrap();
|
||||
let interps = |s: &Session, key: &Value| values(s, key).iter().map(|k| k.interp).collect::<Vec<_>>();
|
||||
assert_eq!(interps(&s, &keys[0]), [Interpolation::Hold, Interpolation::Linear], "the unlisted Scale key stays linear");
|
||||
assert_eq!(interps(&s, &keys[2]), [Interpolation::Hold]);
|
||||
assert_eq!(s.history.undo.len(), undo + 1);
|
||||
s.undo();
|
||||
assert_eq!(serde_json::to_value(&s.project).unwrap(), serde_json::to_value(&project).unwrap());
|
||||
|
||||
let before = s.project.clone();
|
||||
let mut invalid = keys[1].clone();
|
||||
invalid["time"] = json!(invalid["time"].as_i64().unwrap() + 1);
|
||||
for request in [
|
||||
json!({"keyframes":[keys[0],invalid],"interpolation":"hold"}),
|
||||
json!({"keyframes":[],"interpolation":"hold"}),
|
||||
json!({"keyframes":vec![keys[0].clone();MAX_KEYS+1],"interpolation":"hold"}),
|
||||
json!({"keyframes":[{"clip":keys[0]["clip"],"effect":u64::MAX,"param":"scale","time":0}],"interpolation":"hold"}),
|
||||
json!({"keyframes":[{"clip":keys[0]["clip"],"effect":0,"param":"scale","mask":3,"time":0}],"interpolation":"hold"}),
|
||||
json!({"keyframes":"all","interpolation":"hold"}),
|
||||
] {
|
||||
assert!(s.execute(SET, request).is_err());
|
||||
assert!(Arc::ptr_eq(&before, &s.project));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_times_follow_clip_speed_and_reverse() {
|
||||
for reverse in [false, true] {
|
||||
|
||||
@@ -184,7 +184,7 @@ fn edit_mask<R>(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut Mas
|
||||
let body = move |pr: &mut filmcraft_project::Project, _: &mut crate::EditorState| -> Result<R> {
|
||||
let q = pr.sequence_mut(seq_id).ok_or(EngineError::NoSequence)?;
|
||||
let (_, it) = q.find_item_mut(clip).ok_or(filmcraft_edit::EditError::NoItem(clip))?;
|
||||
let mt = it.source_time_at(ph.clamp(it.start, it.end() - Tick(1)));
|
||||
let mt = it.effect_time_at(ph.clamp(it.start, it.end() - Tick(1)));
|
||||
let ei = effect_index(&it.effects, eff.as_ref(), sel, clip).ok_or_else(|| bad(label, "no such effect on clip"))?;
|
||||
let e = &mut it.effects[ei];
|
||||
let mi = mask_i.ok_or_else(|| bad(label, "need `mask`"))?;
|
||||
@@ -557,7 +557,7 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if only.is_some_and(|c| c != it.id) {
|
||||
continue;
|
||||
}
|
||||
let mt = it.source_time_at(ph.clamp(it.start, it.end() - Tick(1)));
|
||||
let mt = it.effect_time_at(ph.clamp(it.start, it.end() - Tick(1)));
|
||||
for (ei, e) in it.effects.iter().enumerate() {
|
||||
for (mi, m) in e.masks.iter().enumerate() {
|
||||
out.push(json!({
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! - **Geometry.** Point parameters and mask paths are in clip pixels; they are scaled by the
|
||||
//! target / source frame-size ratio so a preset made on 4K media lands in the same place on HD.
|
||||
//! - **Intrinsic effects** (Motion, Opacity, Time Remapping) replace the target clip's own instance;
|
||||
//! standard effects are added before the intrinsic ones (render order), like `effects.apply`.
|
||||
//! standard effects are added at the end of the clip's effects, like `effects.apply`.
|
||||
//! - **Library.** Built-in presets are authored in code ([`builtin_presets`]); user presets persist
|
||||
//! in `<data dir>/effect-presets.json`. Preset files (export/import) are JSON:
|
||||
//! `{"format": "filmcraft.effect-presets", "version": 1, "presets": [EffectPreset…]}`.
|
||||
@@ -377,7 +377,7 @@ fn merge_keyframes(new: &mut EffectInstance, old: &EffectInstance) {
|
||||
}
|
||||
|
||||
/// Put preset effects on a clip: intrinsic ones replace the clip's instance (keeping its keyframes
|
||||
/// outside the span the preset animates), others are inserted before the intrinsic effects.
|
||||
/// outside the span the preset animates), others are added at the end, like `effects.apply`.
|
||||
pub fn put_effects(it: &mut TrackItem, effects: Vec<EffectInstance>) {
|
||||
for mut e in effects {
|
||||
let intrinsic = e.def().is_some_and(|d| d.intrinsic);
|
||||
@@ -386,9 +386,7 @@ pub fn put_effects(it: &mut TrackItem, effects: Vec<EffectInstance>) {
|
||||
*slot = e;
|
||||
continue;
|
||||
}
|
||||
let audio = e.def().is_some_and(|d| d.kind == filmcraft_project::EffectKind::Audio);
|
||||
let pos = if audio { it.effects.len() } else { it.effects.iter().position(|x| x.def().is_some_and(|d| d.intrinsic)).unwrap_or(it.effects.len()) };
|
||||
it.effects.insert(pos, e);
|
||||
it.effects.push(e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,8 +57,8 @@ fn keyframe_modes_retime_onto_the_target() {
|
||||
let it = item(&s, b);
|
||||
assert_eq!(it.effects.len(), before + 1);
|
||||
let e = it.effects.iter().rfind(|e| e.effect == "gaussian_blur").unwrap();
|
||||
// standard effects go before the intrinsic ones
|
||||
assert!(it.effects.iter().position(|x| x.effect == "gaussian_blur").unwrap() < it.effects.iter().position(|x| x.effect == "motion").unwrap());
|
||||
// standard effects go after the intrinsic ones, like effects.apply (#1879)
|
||||
assert!(it.effects.iter().position(|x| x.effect == "gaussian_blur").unwrap() > it.effects.iter().position(|x| x.effect == "motion").unwrap());
|
||||
let p = e.param("blurriness").unwrap();
|
||||
assert_eq!(
|
||||
p.keyframes.len(),
|
||||
|
||||
@@ -62,7 +62,8 @@ pub struct BarSpan {
|
||||
/// Which segments a render command picks.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum RenderMode {
|
||||
/// Red and yellow segments in In/Out (Enter).
|
||||
/// Red segments in In/Out (Enter), as in Premiere: yellow ones should play in real time, and
|
||||
/// rendering them too can take hours of encoding on a long sequence (#1220).
|
||||
EffectsInToOut,
|
||||
/// Every segment in In/Out, including no-bar ones.
|
||||
InToOut,
|
||||
@@ -70,6 +71,17 @@ pub enum RenderMode {
|
||||
Selection,
|
||||
}
|
||||
|
||||
impl RenderMode {
|
||||
/// Whether this mode renders a segment whose playback needs `need` (In/Out and the selection
|
||||
/// aside).
|
||||
pub fn renders(self, need: Need) -> bool {
|
||||
match self {
|
||||
RenderMode::EffectsInToOut => need == Need::Render,
|
||||
RenderMode::InToOut | RenderMode::Selection => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Memo = (Arc<Project>, ItemId, Arc<Vec<Segment>>);
|
||||
type AudioMemo = (Arc<Project>, ItemId, Arc<Vec<AudioSegment>>);
|
||||
|
||||
@@ -632,9 +644,8 @@ pub fn render(s: &mut Session, mode: RenderMode, p: &Value) -> Result<Value> {
|
||||
let segs = store.segments(&s.project, seq);
|
||||
let selection: HashSet<_> = s.state.selection.iter().copied().collect();
|
||||
let mut todo: Vec<Todo> = Vec::new();
|
||||
for g in segs.iter() {
|
||||
for g in segs.iter().filter(|g| mode.renders(g.need)) {
|
||||
let (a, b) = match mode {
|
||||
RenderMode::EffectsInToOut if g.need == Need::None => continue,
|
||||
RenderMode::EffectsInToOut | RenderMode::InToOut => rel_range(g, rate, &range),
|
||||
RenderMode::Selection if g.clips.iter().any(|c| selection.contains(c)) => (0, g.frames.max(0)),
|
||||
RenderMode::Selection => continue,
|
||||
@@ -644,7 +655,11 @@ pub fn render(s: &mut Session, mode: RenderMode, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
if todo.is_empty() {
|
||||
s.toast("Nothing to render: previews are up to date");
|
||||
s.toast(if mode == RenderMode::EffectsInToOut {
|
||||
"Nothing to render: no red segments without previews between In and Out"
|
||||
} else {
|
||||
"Nothing to render: previews are up to date"
|
||||
});
|
||||
return Ok(json!({"job": null, "segments": 0}));
|
||||
}
|
||||
std::fs::create_dir_all(&dir).map_err(|e| EngineError::Other(format!("preview folder {}: {e}", dir.display())))?;
|
||||
|
||||
@@ -67,13 +67,20 @@ fn job_ok(s: &Session, v: &Value) {
|
||||
assert!(j["result"].get("error").is_none(), "{j}");
|
||||
}
|
||||
|
||||
/// Render the (yellow) Tint segment: Enter leaves yellow segments live, so render the selection.
|
||||
fn render_tint(s: &mut Session, tint: ClipId) -> Value {
|
||||
s.state.selection = vec![tint];
|
||||
let v = s.execute("sequence.renderSelection", json!({"wait": true})).unwrap();
|
||||
job_ok(s, &v);
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_turns_green_edit_invalidates_and_undo_restores() {
|
||||
let (mut s, a) = session("undo");
|
||||
assert_eq!(states(&s), vec!["yellow", "none"]);
|
||||
let v = s.execute("sequence.renderEffectsInToOut", json!({"wait": true})).unwrap();
|
||||
assert_eq!(v["segments"], 1, "only the segment with an effect");
|
||||
job_ok(&s, &v);
|
||||
let v = render_tint(&mut s, a);
|
||||
assert_eq!(v["segments"], 1, "only the segment with the selected clip");
|
||||
assert_eq!(states(&s), vec!["green", "none"]);
|
||||
assert_eq!(s.previews.count(), 1);
|
||||
// playback reads preview frames for the rendered segment only
|
||||
@@ -153,16 +160,16 @@ fn spans(s: &Session) -> Vec<(i64, i64, BarState)> {
|
||||
s.previews.bar(&s.project, s.state.active_sequence.unwrap()).iter().map(|b| (r.frame_at(b.start), r.frame_at(b.end), b.state)).collect()
|
||||
}
|
||||
|
||||
/// #424: Render Effects In to Out renders the frames between In and Out, not every segment that
|
||||
/// In/Out touches; a later render fills only the gaps, and Delete Render Files In to Out removes
|
||||
/// only the files holding marked frames.
|
||||
/// #424: Render In to Out renders the frames between In and Out, not every segment that In/Out
|
||||
/// touches; a later render fills only the gaps, and Delete Render Files In to Out removes only the
|
||||
/// files holding marked frames.
|
||||
#[test]
|
||||
fn render_in_to_out_renders_only_the_marked_frames() {
|
||||
use BarState::{Green, None as NoBar, Yellow};
|
||||
let (mut s, _) = session_with("range", Generator::CountingLeader);
|
||||
let seq = s.state.active_sequence.unwrap();
|
||||
set_marks(&mut s, Some((6, 11)));
|
||||
let v = s.execute("sequence.renderEffectsInToOut", json!({"wait": true})).unwrap();
|
||||
let v = s.execute("sequence.renderInToOut", json!({"wait": true})).unwrap();
|
||||
job_ok(&s, &v);
|
||||
assert_eq!(v["frames"], 6, "frames 6..=11, not the whole 24-frame segment: {v}");
|
||||
assert_eq!(spans(&s), vec![(0, 6, Yellow), (6, 12, Green), (12, 24, Yellow), (24, 48, NoBar)]);
|
||||
@@ -189,28 +196,27 @@ fn render_in_to_out_renders_only_the_marked_frames() {
|
||||
// the files survive re-indexing the folder (as on reopening the project)
|
||||
s.previews.set_dir(s.previews.dir());
|
||||
assert_eq!(spans(&s)[1], (6, 12, Green));
|
||||
// In/Out over the whole sequence: only the two gaps around the part are rendered
|
||||
// In/Out over the whole sequence: only the two gaps around the part, and the second segment
|
||||
set_marks(&mut s, None);
|
||||
let v = s.execute("sequence.renderEffectsInToOut", json!({"wait": true})).unwrap();
|
||||
let v = s.execute("sequence.renderInToOut", json!({"wait": true})).unwrap();
|
||||
job_ok(&s, &v);
|
||||
assert_eq!((v["segments"].as_i64(), v["frames"].as_i64()), (Some(2), Some(18)), "{v}");
|
||||
assert_eq!(states(&s), vec!["green", "none"]);
|
||||
assert_eq!(s.previews.count(), 3);
|
||||
assert_eq!((v["segments"].as_i64(), v["frames"].as_i64()), (Some(3), Some(18 + 24)), "{v}");
|
||||
assert_eq!(states(&s), vec!["green", "green"]);
|
||||
assert_eq!(s.previews.count(), 4);
|
||||
let prev = s.previews.frame(&s.media, &s.project, seq, 20, 1.0).expect("frame 20 has a preview").to_rgba8();
|
||||
assert!(max_diff(&live(&s, 20), &prev) <= 4);
|
||||
// Delete Render Files In to Out keeps the parts outside In/Out
|
||||
set_marks(&mut s, Some((7, 8)));
|
||||
let v = s.execute("sequence.deleteRenderFilesInToOut", json!({})).unwrap();
|
||||
assert_eq!(v["deleted"], 1);
|
||||
assert_eq!(spans(&s), vec![(0, 6, Green), (6, 12, Yellow), (12, 24, Green), (24, 48, NoBar)]);
|
||||
assert_eq!(spans(&s), vec![(0, 6, Green), (6, 12, Yellow), (12, 48, Green)]);
|
||||
let _ = std::fs::remove_dir_all(s.previews.dir().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previews_survive_save_and_open() {
|
||||
let (mut s, _) = session("save");
|
||||
let v = s.execute("sequence.renderEffectsInToOut", json!({"wait": true})).unwrap();
|
||||
job_ok(&s, &v);
|
||||
let (mut s, a) = session("save");
|
||||
render_tint(&mut s, a);
|
||||
let folder = tmp("save-project");
|
||||
std::fs::create_dir_all(&folder).unwrap();
|
||||
let path = folder.join("Film.fcproj").to_string_lossy().to_string();
|
||||
@@ -235,11 +241,10 @@ fn saving_without_previews_creates_no_previews_folder() {
|
||||
assert_eq!(s.previews.dir().unwrap(), folder.join("FilmCraft Previews").join("Film"));
|
||||
assert!(!folder.join("FilmCraft Previews").exists());
|
||||
// the first render creates it
|
||||
let (mut r, _) = session("save-empty-render");
|
||||
let (mut r, a) = session("save-empty-render");
|
||||
r.execute("file.save", json!({"path": path})).unwrap();
|
||||
assert!(!folder.join("FilmCraft Previews").exists());
|
||||
let v = r.execute("sequence.renderEffectsInToOut", json!({"wait": true})).unwrap();
|
||||
job_ok(&r, &v);
|
||||
render_tint(&mut r, a);
|
||||
assert!(folder.join("FilmCraft Previews").join("Film").is_dir());
|
||||
assert_eq!(states(&r), vec!["green", "none"]);
|
||||
let _ = std::fs::remove_dir_all(&folder);
|
||||
@@ -277,6 +282,63 @@ fn render_selection_and_audio_previews() {
|
||||
let _ = std::fs::remove_dir_all(s.previews.dir().unwrap());
|
||||
}
|
||||
|
||||
/// #1220: Enter (Render Effects In to Out) renders red segments only, as in Premiere. Yellow
|
||||
/// segments play in real time; rendering them made Enter on a 49-minute 4K sequence of plain phone
|
||||
/// clips encode 126 000 frames of ProRes.
|
||||
#[test]
|
||||
fn enter_renders_only_red_segments() {
|
||||
use crate::previews::RenderMode;
|
||||
use filmcraft_render::preview::Need;
|
||||
assert!(RenderMode::EffectsInToOut.renders(Need::Render));
|
||||
assert!(!RenderMode::EffectsInToOut.renders(Need::Realtime));
|
||||
assert!(!RenderMode::EffectsInToOut.renders(Need::None));
|
||||
for need in [Need::None, Need::Realtime, Need::Render] {
|
||||
assert!(RenderMode::InToOut.renders(need));
|
||||
assert!(RenderMode::Selection.renders(need));
|
||||
}
|
||||
// a sequence with yellow and no-bar segments only: nothing to render, no job, no folder
|
||||
let (mut s, _) = session("enter-yellow");
|
||||
assert_eq!(states(&s), vec!["yellow", "none"]);
|
||||
let v = s.execute("sequence.renderEffectsInToOut", json!({"wait": true})).unwrap();
|
||||
assert!(v["job"].is_null(), "{v}");
|
||||
assert_eq!(states(&s), vec!["yellow", "none"]);
|
||||
assert_eq!(s.previews.count(), 0);
|
||||
assert!(!s.previews.dir().unwrap().exists(), "no previews folder for nothing rendered");
|
||||
}
|
||||
|
||||
/// #1220 end to end: on a red and a yellow segment, Enter starts a job for the red one only.
|
||||
#[test]
|
||||
fn enter_renders_the_red_segment_and_leaves_the_yellow_one() {
|
||||
let mut s = Session::default();
|
||||
s.previews.set_dir(Some(tmp("enter-red")));
|
||||
let r = FrameRate::FPS_24;
|
||||
let mut p = (*s.project).clone();
|
||||
let matte = GeneratorSource::new(Generator::ColorMatte { color: [0.2, 0.5, 0.8, 1.0] }, 1920, 1080, r, Tick(TICKS_PER_SECOND));
|
||||
let matte = crate::demo::add_generator(&mut p, &s.media, matte, "Matte", Label::Iris, None);
|
||||
// 1080p, where four heavy effects cost more than a frame lasts (red) and a Tint does not
|
||||
let seq = p.new_sequence("Seq", SequenceSettings { width: 1920, height: 1080, frame_rate: r, ..Default::default() }, 1, 1, None);
|
||||
let clip = |p: &mut filmcraft_project::Project, at: i64, fx: &[&str]| {
|
||||
let mut it = p.make_track_item(matte, TrackKind::Video, r.tick_of(at), TimeRange::new(r.tick_of(at), r.tick_of(2)), r).unwrap();
|
||||
for e in &mut it.effects {
|
||||
filmcraft_project::resolve_auto_points(e, (1920, 1080), (1920, 1080));
|
||||
}
|
||||
it.effects.extend(fx.iter().map(|f| find_effect(f).unwrap().instance()));
|
||||
it
|
||||
};
|
||||
let red = clip(&mut p, 0, &["gaussian_blur", "sharpen", "lumetri", "noise"]);
|
||||
let yellow = clip(&mut p, 2, &["tint"]);
|
||||
p.sequence_mut(seq).unwrap().video_tracks[0].items = vec![red, yellow];
|
||||
s.project = Arc::new(p);
|
||||
s.state.active_sequence = Some(seq);
|
||||
s.state.open_sequences = vec![seq];
|
||||
assert_eq!(states(&s), vec!["red", "yellow"]);
|
||||
let v = s.execute("sequence.renderEffectsInToOut", json!({"wait": true})).unwrap();
|
||||
job_ok(&s, &v);
|
||||
assert_eq!((v["segments"].as_i64(), v["frames"].as_i64()), (Some(1), Some(2)), "{v}");
|
||||
assert_eq!(states(&s), vec!["green", "yellow"]);
|
||||
let _ = std::fs::remove_dir_all(s.previews.dir().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_commands_are_registered_like_premiere() {
|
||||
let f = |id| crate::commands::find(id).unwrap();
|
||||
@@ -308,9 +370,8 @@ fn bar_state_serializes_lowercase() {
|
||||
/// files, and a file only a whole-file reader could open is refused rather than loaded.
|
||||
#[test]
|
||||
fn preview_files_are_streamed_not_read_whole() {
|
||||
let (mut s, _) = session("stream");
|
||||
let v = s.execute("sequence.renderEffectsInToOut", json!({"wait": true})).unwrap();
|
||||
job_ok(&s, &v);
|
||||
let (mut s, a) = session("stream");
|
||||
render_tint(&mut s, a);
|
||||
let seq = s.state.active_sequence.unwrap();
|
||||
assert!(s.previews.frame(&s.media, &s.project, seq, 5, 1.0).is_some(), "a rendered frame plays from its file");
|
||||
// a still image opens only from its whole bytes: as a preview file it is not opened
|
||||
|
||||
@@ -318,8 +318,9 @@ fn effects_and_keyframes() {
|
||||
s.execute("effects.setParam", json!({"clip": c, "effect": "gaussian_blur", "param": "blurriness", "value": 25.0})).unwrap();
|
||||
let q = s.active_sequence().unwrap();
|
||||
let it = q.find_item(filmcraft_project::ClipId(c)).unwrap().1;
|
||||
assert_eq!(it.effects[0].effect, "gaussian_blur", "standard effects render before intrinsics");
|
||||
assert_eq!(it.effects[0].params["blurriness"].value, filmcraft_project::ParamValue::Float(25.0));
|
||||
let blur = it.effects.last().unwrap();
|
||||
assert_eq!(blur.effect, "gaussian_blur", "applied effects go after the intrinsic ones (#1879)");
|
||||
assert_eq!(blur.params["blurriness"].value, filmcraft_project::ParamValue::Float(25.0));
|
||||
s.execute("effects.toggleAnimation", json!({"clip": c, "effect": "motion", "param": "scale"})).unwrap();
|
||||
let it = s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(c)).unwrap().1.clone();
|
||||
assert!(it.effect("motion").unwrap().params["scale"].is_animated());
|
||||
@@ -497,6 +498,72 @@ fn add_keyframe_with_a_time_at_the_out_point_is_not_moved() {
|
||||
assert_eq!(at, vec![it.source_time_at(end)], "the keyframe sits at the out point, not a tick before it");
|
||||
}
|
||||
|
||||
/// A clip with a Frame Hold (Hold Filters off) still animates its effects through the hold, so
|
||||
/// Add/Remove Keyframe at two playhead positions gives two keyframes, and a value set at the second
|
||||
/// one changes only that one (#1675: they all landed on the held frame, and the second click
|
||||
/// removed the first keyframe).
|
||||
#[test]
|
||||
fn keyframes_on_a_frame_hold_go_where_the_playhead_is() {
|
||||
let mut s = demo();
|
||||
let c = s.active_sequence().unwrap().video_tracks[0].items[0].id.0;
|
||||
s.execute("clip.frameHoldOptions", json!({"clips": [c], "holdOn": "in", "holdFilters": false})).unwrap();
|
||||
let scale = |s: &Session, t: Tick| {
|
||||
let it = s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(c)).unwrap().1;
|
||||
assert!(it.frame_hold.is_some() && !it.hold_filters);
|
||||
let p = &it.effect("motion").unwrap().params["scale"];
|
||||
(p.keyframes.len(), p.f64_at(it.effect_time_at(t)))
|
||||
};
|
||||
let key = json!({"clip": c, "effect": "motion", "param": "scale"});
|
||||
s.execute("playhead.set", json!({"seconds": 0.5})).unwrap();
|
||||
let first = s.playhead();
|
||||
s.execute("effects.addKeyframe", key.clone()).unwrap();
|
||||
s.execute("playhead.set", json!({"seconds": 2.5})).unwrap();
|
||||
let second = s.playhead();
|
||||
s.execute("effects.addKeyframe", key).unwrap();
|
||||
s.execute("effects.setParam", json!({"clip": c, "effect": "motion", "param": "scale", "value": 50.0})).unwrap();
|
||||
assert_eq!(scale(&s, first), (2, 100.0));
|
||||
assert_eq!(scale(&s, second), (2, 50.0));
|
||||
}
|
||||
|
||||
/// A mask changed at the playhead on a Frame Hold clip with Hold Filters off is keyed there, where
|
||||
/// the Program monitor shows it, not on the held frame, where the clip already has a keyframe.
|
||||
#[test]
|
||||
fn values_changed_on_a_frame_hold_are_keyed_where_the_playhead_is() {
|
||||
use filmcraft_project::{Keyframe, Param, TrackItem};
|
||||
#[derive(Debug)]
|
||||
enum At {
|
||||
MaskFeather,
|
||||
}
|
||||
fn param<'a>(it: &'a mut TrackItem, at: &At) -> &'a mut Param {
|
||||
match at {
|
||||
At::MaskFeather => &mut it.effect_mut("opacity").unwrap().masks[0].feather,
|
||||
}
|
||||
}
|
||||
let cases = [(At::MaskFeather, "masks.set", json!({"feather": 30}))];
|
||||
for (at, command, params) in cases {
|
||||
let mut s = demo();
|
||||
let c = s.active_sequence().unwrap().video_tracks[0].items[0].id;
|
||||
s.execute("timeline.select", json!({"clips": [c.0]})).unwrap();
|
||||
s.execute("masks.add", json!({"clip": c.0, "effect": "opacity", "shape": "ellipse"})).unwrap();
|
||||
s.execute("clip.frameHoldOptions", json!({"clips": [c.0], "holdOn": "in", "holdFilters": false})).unwrap();
|
||||
let seq = s.state.active_sequence.unwrap();
|
||||
let it = std::sync::Arc::make_mut(&mut s.project).sequence_mut(seq).unwrap().find_item_mut(c).unwrap().1;
|
||||
// keyed at both ends, so a value keyed at the held frame (the In) adds no keyframe
|
||||
let (a, b) = (it.moving_source_time_at(it.start), it.moving_source_time_at(it.end() - Tick(1)));
|
||||
let p = param(it, &at);
|
||||
p.keyframes = vec![Keyframe::new(a, p.value.clone()), Keyframe::new(b, p.value.clone())];
|
||||
let mid = it.start + Tick(it.duration.0 / 2);
|
||||
s.execute("playhead.set", json!({"time": mid.0})).unwrap();
|
||||
let ph = s.playhead();
|
||||
s.execute(command, params).unwrap();
|
||||
let it = std::sync::Arc::make_mut(&mut s.project).sequence_mut(seq).unwrap().find_item_mut(c).unwrap().1;
|
||||
assert!(it.frame_hold == Some(a) && !it.hold_filters, "{at:?} {command}");
|
||||
let now = it.effect_time_at(ph);
|
||||
let times: Vec<Tick> = param(it, &at).keyframes.iter().map(|k| k.time).collect();
|
||||
assert_eq!(times, vec![a, now, b], "{at:?} {command}: keyed at the playhead");
|
||||
}
|
||||
}
|
||||
|
||||
/// `effects.setParam` writes a keyframe only on an animated parameter. An agent that sends a value
|
||||
/// with a `time` to a parameter that is not animated gets a static value (the result says so:
|
||||
/// 0 keyframes); `"keyframe": true` is the stopwatch and the value in one step.
|
||||
@@ -1107,11 +1174,19 @@ fn selecting_an_effect_replaces_the_mask_selection() {
|
||||
let motion_sel = Some(EffectSel { clip: ClipId(id), effect: "motion".into(), instance: 0 });
|
||||
s.execute("effects.select", json!({"clip": id, "effect": "motion"})).unwrap();
|
||||
assert_eq!(s.state.selected_effect, motion_sel);
|
||||
// applying an effect puts it first; Motion stays the selected effect
|
||||
// applying an effect leaves Motion the selected effect
|
||||
s.execute("effects.apply", json!({"clips": [id], "effect": "gaussian_blur"})).unwrap();
|
||||
assert_eq!(s.state.selected_effect, motion_sel);
|
||||
s.execute("effects.remove", json!({"clip": id, "index": 0})).unwrap();
|
||||
assert_eq!(s.state.selected_effect, motion_sel, "and when one before it is removed");
|
||||
// and the selection follows its effect when one before it is removed
|
||||
s.execute("effects.apply", json!({"clips": [id], "effect": "tint"})).unwrap();
|
||||
s.execute("effects.select", json!({"clip": id, "effect": "tint"})).unwrap();
|
||||
let tint_sel = s.state.selected_effect.clone();
|
||||
let blur = s.active_sequence().unwrap().find_item(ClipId(id)).unwrap().1.effects.iter().position(|e| e.effect == "gaussian_blur").unwrap();
|
||||
s.execute("effects.remove", json!({"clip": id, "index": blur})).unwrap();
|
||||
assert_eq!(s.state.selected_effect, tint_sel, "and when one before it is removed");
|
||||
let tint = s.active_sequence().unwrap().find_item(ClipId(id)).unwrap().1.effects.iter().position(|e| e.effect == "tint").unwrap();
|
||||
s.execute("effects.remove", json!({"clip": id, "index": tint})).unwrap();
|
||||
s.execute("effects.select", json!({"clip": id, "effect": "motion"})).unwrap();
|
||||
let motion = s.active_sequence().unwrap().find_item(ClipId(id)).unwrap().1.effects.iter().position(|e| e.effect == "motion").unwrap();
|
||||
s.execute("masks.add", json!({"clip": id, "shape": "ellipse"})).unwrap();
|
||||
assert!(s.state.selected_effect.is_none(), "a new mask is selected instead");
|
||||
|
||||
@@ -187,3 +187,57 @@ fn set_param_refuses_values_outside_the_range() {
|
||||
s.execute("effects.setParam", json!({"clip": clip, "effect": "opacity", "param": "opacity", "value": 100})).unwrap();
|
||||
s.execute("effects.setParam", json!({"clip": clip, "effect": "opacity", "param": "opacity", "value": 0})).unwrap();
|
||||
}
|
||||
|
||||
/// #3455: a transition's requested duration can be longer than the clips around its cut can
|
||||
/// hold (a crossfade next to a few-frames clip). The preview clamps it to what the cut
|
||||
/// supports — the same invariant `edit::transitions::set_span` keeps when a transition is
|
||||
/// resized: a centered crossfade spends half its length on each side of the cut, a fade to
|
||||
/// or from nothing spends it all on its one side.
|
||||
#[test]
|
||||
fn transition_duration_is_clamped_to_what_the_cut_supports() {
|
||||
use crate::commands::preview_transition;
|
||||
use filmcraft_project::TrackKind;
|
||||
use filmcraft_time::Tick;
|
||||
|
||||
let mut s = demo();
|
||||
let sid = s.state.active_sequence.unwrap();
|
||||
let frame = s.sequence_rate().tick_of(1);
|
||||
let track = s.active_sequence().unwrap().video_tracks[0].id;
|
||||
let clips: Vec<_> = s.active_sequence().unwrap().video_tracks[0].items.iter().map(|c| c.id).collect();
|
||||
assert!(clips.len() >= 2, "the demo project needs at least two V1 clips for this test");
|
||||
let (first_start, second_end) = {
|
||||
let items = &mut std::sync::Arc::make_mut(&mut s.project).sequence_mut(sid).unwrap().track_mut(track).unwrap().items;
|
||||
items[0].duration = frame.mul_ratio(3, 1);
|
||||
let cut = items[0].end();
|
||||
items[1].start = cut;
|
||||
(items[0].start, items[1].end())
|
||||
};
|
||||
|
||||
// Crossfade at the cut between the 3-frame clip and its neighbour: the short side
|
||||
// caps a centered transition at 2 x 3 frames.
|
||||
let (_, planned) = preview_transition(&s, &json!({"effect": "cross_dissolve", "clip": clips[1].0, "edge": "in", "frames": 999}), TrackKind::Video).unwrap();
|
||||
assert_eq!(planned.duration, frame.mul_ratio(6, 1), "clamped to twice the short side of the cut");
|
||||
assert!(planned.start >= first_start && planned.start + planned.duration <= second_end, "the clamped transition stays inside the clips");
|
||||
|
||||
// The same cut with a request that fits is untouched.
|
||||
let (_, planned) = preview_transition(&s, &json!({"effect": "cross_dissolve", "clip": clips[1].0, "edge": "in", "frames": 4}), TrackKind::Video).unwrap();
|
||||
assert_eq!(planned.duration, frame.mul_ratio(4, 1));
|
||||
|
||||
// A fade to nothing on the last clip's out edge can be at most that clip's own length.
|
||||
let (last, last_duration) = {
|
||||
let items = &s.active_sequence().unwrap().track(track).unwrap().items;
|
||||
(items.last().unwrap().id, items.last().unwrap().duration)
|
||||
};
|
||||
let (_, planned) = preview_transition(&s, &json!({"effect": "cross_dissolve", "clip": last.0, "edge": "out", "frames": 9999}), TrackKind::Video).unwrap();
|
||||
assert_eq!(planned.duration, last_duration, "a one-sided fade is clamped to its clip");
|
||||
let items = s.active_sequence().unwrap().track(track).unwrap().items.clone();
|
||||
let last = items.last().unwrap();
|
||||
assert_eq!(planned.start + planned.duration, last.end());
|
||||
|
||||
// A cut whose clips are shorter than a frame is refused instead of creating a
|
||||
// zero-length transition.
|
||||
let items = &mut std::sync::Arc::make_mut(&mut s.project).sequence_mut(sid).unwrap().track_mut(track).unwrap().items;
|
||||
items[1].duration = Tick(0);
|
||||
let err = preview_transition(&s, &json!({"effect": "cross_dissolve", "clip": clips[1].0, "edge": "in", "frames": 5}), TrackKind::Video).unwrap_err().to_string();
|
||||
assert!(err.contains("no room for a transition"), "{err}");
|
||||
}
|
||||
|
||||
+37
-3
@@ -52,6 +52,11 @@ const OP_MIRROR: u32 = 28;
|
||||
const OP_OFFSET: u32 = 29;
|
||||
const OP_VIDEO_LIMITER: u32 = 30;
|
||||
const OP_LUMETRI: u32 = 31;
|
||||
const OP_BOKEH_DOWN: u32 = 32;
|
||||
const OP_BOKEH_BOOST: u32 = 33;
|
||||
const OP_BOKEH: u32 = 34;
|
||||
const OP_BOKEH_UP: u32 = 35;
|
||||
const OP_THREE_WAY: u32 = 36;
|
||||
|
||||
type Target = (wgpu::Texture, wgpu::TextureView);
|
||||
|
||||
@@ -112,8 +117,9 @@ fn boxes(radii: &[u32], vertical: bool, repeat: bool, out: &mut Vec<Step>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The passes of an op (empty: no-op). Unsharp's blur passes come first, then the combine.
|
||||
fn steps(op: &FxOp) -> Vec<Step> {
|
||||
/// The passes of an op on a `w`×`h` working image (empty: no-op). Unsharp's blur passes come
|
||||
/// first, then the combine.
|
||||
fn steps(op: &FxOp, w: u32, h: u32) -> Vec<Step> {
|
||||
let mut out = Vec::new();
|
||||
match op {
|
||||
FxOp::BrightnessContrast { br, co } => out.push(step(OP_BRIGHTNESS_CONTRAST, [0; 4], &[*br, *co])),
|
||||
@@ -125,6 +131,11 @@ fn steps(op: &FxOp) -> Vec<Step> {
|
||||
FxOp::ColorBalance { sh, md, hi, preserve } => {
|
||||
out.push(step(OP_COLOR_BALANCE, [*preserve as u32, 0, 0, 0], &[sh[0], sh[1], sh[2], 0.0, md[0], md[1], md[2], 0.0, hi[0], hi[1], hi[2]]))
|
||||
}
|
||||
FxOp::ThreeWay { sh, md, hi, range, sat } => out.push(step(
|
||||
OP_THREE_WAY,
|
||||
[op.is_identity_three_way() as u32, 0, 0, 0],
|
||||
&[sh[0], sh[1], sh[2], sh[3], md[0], md[1], md[2], md[3], hi[0], hi[1], hi[2], hi[3], range[0], range[1], range[2], range[3], *sat],
|
||||
)),
|
||||
FxOp::LeaveColor { amount, key_hue, tol, soft } => out.push(step(OP_LEAVE_COLOR, [0; 4], &[*amount, *key_hue, *tol, *soft])),
|
||||
FxOp::ChangeToColor { from_hue, to_hue, tol, soft } => out.push(step(OP_CHANGE_TO_COLOR, [0; 4], &[*from_hue, *to_hue, *tol, *soft])),
|
||||
FxOp::ColorPass { key, sim, reverse } => out.push(step(OP_COLOR_PASS, [*reverse as u32, 0, 0, 0], &[key[0], key[1], key[2], *sim])),
|
||||
@@ -143,6 +154,29 @@ fn steps(op: &FxOp) -> Vec<Step> {
|
||||
out.push(step(OP_DIRECTIONAL, [*steps, 0, 0, 0], &[*dx, *dy]));
|
||||
}
|
||||
}
|
||||
FxOp::Bokeh { levels, boost, threshold, sides, rot, rr, rings } => {
|
||||
if *rings > 0 {
|
||||
// `vfx::bokeh` on the top-left part of the working textures: each halving writes
|
||||
// the half-size image there, the boost and the gather work in place, and the
|
||||
// last pass scales it back up to the whole image.
|
||||
let (mut ww, mut wh) = (w, h);
|
||||
for _ in 0..*levels {
|
||||
out.push(step(OP_BOKEH_DOWN, [ww, wh, 0, 0], &[]));
|
||||
(ww, wh) = ((ww / 2).max(1), (wh / 2).max(1));
|
||||
}
|
||||
if *boost > 0.0 {
|
||||
out.push(step(OP_BOKEH_BOOST, [ww, wh, 0, 0], &[*boost, *threshold]));
|
||||
}
|
||||
out.push(step(OP_BOKEH, [ww, wh, *sides, *rings], &[*rr, *rot]));
|
||||
if *levels > 0 {
|
||||
// `Image::transformed` by scale(w / ww, h / wh): its inverse, in f64 as there
|
||||
let m = filmcraft_geom::Affine::scale(w as f64 / ww as f64, h as f64 / wh as f64);
|
||||
if let Some(inv) = m.inverse() {
|
||||
out.push(step(OP_BOKEH_UP, [ww, wh, 0, 0], &[inv.a as f32, inv.d as f32, inv.e as f32, inv.f as f32]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
FxOp::Unsharp { rx, ry, amount, threshold } => {
|
||||
boxes(rx, false, true, &mut out);
|
||||
boxes(ry, true, true, &mut out);
|
||||
@@ -391,7 +425,7 @@ impl FxStage {
|
||||
if w > max || h > max {
|
||||
return None;
|
||||
}
|
||||
let all: Vec<Step> = fx.ops.iter().flat_map(steps).collect();
|
||||
let all: Vec<Step> = fx.ops.iter().flat_map(|op| steps(op, w, h)).collect();
|
||||
let n = if all.iter().any(|s| s.combine) { 3 } else { 2 };
|
||||
let targets = self.targets(device, (w, h), n);
|
||||
let views: Vec<wgpu::TextureView> = targets.iter().map(|t| t.1.clone()).collect();
|
||||
|
||||
+164
-2
@@ -50,6 +50,11 @@ const OP_MIRROR: u32 = 28u;
|
||||
const OP_OFFSET: u32 = 29u;
|
||||
const OP_VIDEO_LIMITER: u32 = 30u;
|
||||
const OP_LUMETRI: u32 = 31u;
|
||||
const OP_BOKEH_DOWN: u32 = 32u;
|
||||
const OP_BOKEH_BOOST: u32 = 33u;
|
||||
const OP_BOKEH: u32 = 34u;
|
||||
const OP_BOKEH_UP: u32 = 35u;
|
||||
const OP_THREE_WAY: u32 = 36u;
|
||||
|
||||
fn size() -> vec2<i32> {
|
||||
return vec2<i32>(i32(u.i0.y), i32(u.i0.z));
|
||||
@@ -106,6 +111,134 @@ fn sample_clamped(x: f32, y: f32) -> vec4<f32> {
|
||||
return top + (bot - top) * ty;
|
||||
}
|
||||
|
||||
// ---- Bokeh Blur works on the top-left `r` pixels of the textures (the halved image)
|
||||
|
||||
// `Image::get_clamped` within `r`
|
||||
fn ld_in(p: vec2<i32>, r: vec2<i32>) -> vec4<f32> {
|
||||
return textureLoad(src, clamp(p, vec2(0), r - 1), 0);
|
||||
}
|
||||
|
||||
// `Image::sample_bilinear_clamped` within `r`
|
||||
fn sample_clamped_in(x: f32, y: f32, r: vec2<i32>) -> vec4<f32> {
|
||||
let fx = clamp(x - 0.5, 0.0, f32(r.x) - 1.0);
|
||||
let fy = clamp(y - 0.5, 0.0, f32(r.y) - 1.0);
|
||||
let x0 = i32(floor(fx));
|
||||
let y0 = i32(floor(fy));
|
||||
let x1 = min(x0 + 1, r.x - 1);
|
||||
let y1 = min(y0 + 1, r.y - 1);
|
||||
let tx = fx - f32(x0);
|
||||
let ty = fy - f32(y0);
|
||||
let a = ld(vec2(x0, y0));
|
||||
let b = ld(vec2(x1, y0));
|
||||
let c = ld(vec2(x0, y1));
|
||||
let d = ld(vec2(x1, y1));
|
||||
let top = a + (b - a) * tx;
|
||||
let bot = c + (d - c) * tx;
|
||||
return top + (bot - top) * ty;
|
||||
}
|
||||
|
||||
// `Image::get_or_clear` within `r`
|
||||
fn ld_or_clear_in(p: vec2<i32>, r: vec2<i32>) -> vec4<f32> {
|
||||
if p.x < 0 || p.y < 0 || p.x >= r.x || p.y >= r.y {
|
||||
return vec4(0.0);
|
||||
}
|
||||
return textureLoad(src, p, 0);
|
||||
}
|
||||
|
||||
// `Image::sample_bilinear` within `r`
|
||||
fn sample_bilinear_in(x: f32, y: f32, r: vec2<i32>) -> vec4<f32> {
|
||||
let fx = x - 0.5;
|
||||
let fy = y - 0.5;
|
||||
let x0 = floor(fx);
|
||||
let y0 = floor(fy);
|
||||
let tx = fx - x0;
|
||||
let ty = fy - y0;
|
||||
let i = vec2<i32>(i32(x0), i32(y0));
|
||||
let a = ld_or_clear_in(i, r);
|
||||
let b = ld_or_clear_in(i + vec2(1, 0), r);
|
||||
let c = ld_or_clear_in(i + vec2(0, 1), r);
|
||||
let d = ld_or_clear_in(i + vec2(1, 1), r);
|
||||
let top = a + (b - a) * tx;
|
||||
let bot = c + (d - c) * tx;
|
||||
return top + (bot - top) * ty;
|
||||
}
|
||||
|
||||
// `vfx::blur::poly_radius`: radius of a regular `n`-gon (circumradius 1, rotated by `rot`) in
|
||||
// direction `theta`; n = 0 is a circle.
|
||||
fn poly_radius(n: u32, theta: f32, rot: f32) -> f32 {
|
||||
if n == 0u {
|
||||
return 1.0;
|
||||
}
|
||||
let seg = 6.2831855 / f32(n);
|
||||
var m = (theta - rot) % seg;
|
||||
if m < 0.0 {
|
||||
m += seg;
|
||||
}
|
||||
let a = m - seg / 2.0;
|
||||
return cos(3.1415927 / f32(n)) / cos(a);
|
||||
}
|
||||
|
||||
// Bokeh Blur's passes (`vfx::bokeh`); i1.xy = the size of the image they read.
|
||||
fn bokeh(op: u32, p: vec2<i32>) -> vec4<f32> {
|
||||
let r = vec2<i32>(i32(u.i1.x), i32(u.i1.y));
|
||||
switch op {
|
||||
case OP_BOKEH_DOWN: {
|
||||
// `Image::downsample2`: the mean of each 2x2 block (edge-clamped)
|
||||
let o = max(r / 2, vec2(1));
|
||||
if p.x >= o.x || p.y >= o.y {
|
||||
return vec4(0.0);
|
||||
}
|
||||
let q = p * 2;
|
||||
return (ld_in(q, r) + ld_in(q + vec2(1, 0), r) + ld_in(q + vec2(0, 1), r) + ld_in(q + vec2(1, 1), r)) * 0.25;
|
||||
}
|
||||
case OP_BOKEH_BOOST: {
|
||||
if p.x >= r.x || p.y >= r.y {
|
||||
return vec4(0.0);
|
||||
}
|
||||
let o = ld(p);
|
||||
if o.a <= 1e-6 {
|
||||
return o;
|
||||
}
|
||||
let l = luma709(enc(o.rgb / o.a));
|
||||
let k = 1.0 + u.p0.x * smoothstep_fx(u.p0.y, 1.0, l);
|
||||
return vec4(o.rgb * k, o.a);
|
||||
}
|
||||
case OP_BOKEH: {
|
||||
if p.x >= r.x || p.y >= r.y {
|
||||
return vec4(0.0);
|
||||
}
|
||||
let pc = vec2<f32>(p) + 0.5;
|
||||
let sides = u.i1.z;
|
||||
let rings = u.i1.w;
|
||||
let rr = u.p0.x;
|
||||
let rot = u.p0.y;
|
||||
var acc = sample_clamped_in(pc.x, pc.y, r);
|
||||
var n_taps = 1u;
|
||||
for (var ring = 1u; ring <= rings; ring++) {
|
||||
let rad = rr * f32(ring) / f32(rings);
|
||||
let n = max(ring * 8u, 6u);
|
||||
for (var i = 0u; i < n; i++) {
|
||||
let th = 6.2831855 * (f32(i) + 0.5 * f32(ring % 2u)) / f32(n);
|
||||
let pr = rad * poly_radius(sides, th, rot);
|
||||
acc += sample_clamped_in(pc.x + cos(th) * pr, pc.y + sin(th) * pr, r);
|
||||
}
|
||||
n_taps += n;
|
||||
}
|
||||
return acc * (1.0 / f32(n_taps));
|
||||
}
|
||||
default: {
|
||||
// OP_BOKEH_UP: `Image::transformed` by the scale back to the whole image
|
||||
let pc = vec2<f32>(p) + 0.5;
|
||||
let uu = u.p0.x * pc.x + u.p0.z;
|
||||
let vv = u.p0.y * pc.y + u.p0.w;
|
||||
if uu < -1.0 || vv < -1.0 || uu > f32(r.x) + 1.0 || vv > f32(r.y) + 1.0 {
|
||||
return vec4(0.0);
|
||||
}
|
||||
return sample_bilinear_in(uu, vv, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- colour helpers (`filmcraft_color`, `effects::enc` / `dec`)
|
||||
|
||||
fn linear_to_srgb1(v: f32) -> f32 {
|
||||
@@ -343,6 +476,28 @@ fn color_op(op: u32, c: vec3<f32>) -> vec3<f32> {
|
||||
}
|
||||
return dec(o);
|
||||
}
|
||||
case OP_THREE_WAY: {
|
||||
if u.i1.x != 0u {
|
||||
return c;
|
||||
}
|
||||
let e = enc(c);
|
||||
let l = 0.2126 * e.x + 0.7152 * e.y + 0.0722 * e.z;
|
||||
let ts = clamp((l - u.p3.x) / max(u.p3.y - u.p3.x, 1e-4), 0.0, 1.0);
|
||||
let th = clamp((l - u.p3.z) / max(u.p3.w - u.p3.z, 1e-4), 0.0, 1.0);
|
||||
var ws = 1.0 - ts * ts * (3.0 - 2.0 * ts);
|
||||
var wh = th * th * (3.0 - 2.0 * th);
|
||||
var wm = max(1.0 - ws - wh, 0.0);
|
||||
let total = ws + wm + wh;
|
||||
ws = ws / total;
|
||||
wh = wh / total;
|
||||
wm = wm / total;
|
||||
let lvl = u.p0.w * ws + u.p1.w * wm + u.p2.w * wh;
|
||||
let o = e + u.p0.xyz * ws + u.p1.xyz * wm + u.p2.xyz * wh + vec3(lvl);
|
||||
let l2 = 0.2126 * o.x + 0.7152 * o.y + 0.0722 * o.z;
|
||||
// no upper clamp: HDR values keep their headroom
|
||||
let r = max(vec3(l2) + (o - vec3(l2)) * u.p4.x, vec3(0.0));
|
||||
return vec3(srgb_to_linear1(r.x), srgb_to_linear1(r.y), srgb_to_linear1(r.z));
|
||||
}
|
||||
case OP_LEAVE_COLOR: {
|
||||
let amt = u.p0.x; let kh = u.p0.y; let tol = u.p0.z; let soft = u.p0.w;
|
||||
let h = rgb_to_hsl(enc(c)).x;
|
||||
@@ -466,10 +621,14 @@ fn lumetri_op(c: vec3<f32>, p: vec2<i32>) -> vec3<f32> {
|
||||
let whl_val = clamp(l, 0.0, 1.0);
|
||||
let whl = whl_val * whl_val * whl_val;
|
||||
let nl = max(l + sh_k * ws + hl_k * whl, 0.0);
|
||||
// below a luma of 0.02 (LIFT_LUMA_FLOOR in effects.rs) the hue-preserving scale nl / l is held
|
||||
// at that luma's and the rest of the lift is added equally (lift_scale in effects.rs)
|
||||
var k = 1.0;
|
||||
if l > 1e-5 {
|
||||
let k = nl / l;
|
||||
v = v * k;
|
||||
k = min(nl / l, 1.0 + max(nl - l, 0.0) / 0.02);
|
||||
}
|
||||
let add = nl - max(l, 0.0) * k;
|
||||
v = v * k + vec3(add);
|
||||
|
||||
// contrast: smooth S-curve around mid grey
|
||||
let contrast = u.p1.w;
|
||||
@@ -534,6 +693,9 @@ fn lumetri_op(c: vec3<f32>, p: vec2<i32>) -> vec3<f32> {
|
||||
fn pixel(op: u32, p: vec2<i32>) -> vec4<f32> {
|
||||
let s = size();
|
||||
let pc = vec2<f32>(p) + 0.5;
|
||||
if op >= OP_BOKEH_DOWN && op <= OP_BOKEH_UP {
|
||||
return bokeh(op, p);
|
||||
}
|
||||
switch op {
|
||||
case OP_INVERT_ALPHA: {
|
||||
let o = ld(p);
|
||||
|
||||
@@ -114,6 +114,45 @@ fn cases() -> Vec<(&'static str, Vec<(&'static str, ParamValue)>, bool)> {
|
||||
("black_white", vec![], false),
|
||||
("color_balance", vec![("shadow_r", fl(50.0)), ("mid_g", fl(-40.0)), ("hi_b", fl(80.0)), ("shadow_b", fl(-100.0)), ("hi_r", fl(100.0))], false),
|
||||
("color_balance", vec![("mid_r", fl(70.0)), ("hi_g", fl(-60.0)), ("preserve", ParamValue::Bool(true))], false),
|
||||
(
|
||||
"three_way_color",
|
||||
vec![
|
||||
("shadow_hue", fl(220.0)),
|
||||
("shadow_balance", fl(60.0)),
|
||||
("hi_hue", fl(30.0)),
|
||||
("hi_balance", fl(80.0)),
|
||||
("mid_level", fl(-30.0)),
|
||||
("saturation", fl(130.0)),
|
||||
],
|
||||
false,
|
||||
),
|
||||
(
|
||||
"three_way_color",
|
||||
vec![
|
||||
("shadow_level", fl(50.0)),
|
||||
("hi_level", fl(-40.0)),
|
||||
("shadow_threshold", fl(50.0)),
|
||||
("shadow_softness", fl(0.0)),
|
||||
("hi_threshold", fl(50.0)),
|
||||
("hi_softness", fl(0.0)),
|
||||
("saturation", fl(0.0)),
|
||||
],
|
||||
false,
|
||||
),
|
||||
// overlapping ranges (shadow edge above the highlight edge)
|
||||
(
|
||||
"three_way_color",
|
||||
vec![
|
||||
("shadow_threshold", fl(80.0)),
|
||||
("hi_threshold", fl(20.0)),
|
||||
("shadow_balance", fl(70.0)),
|
||||
("hi_balance", fl(70.0)),
|
||||
("hi_hue", fl(200.0)),
|
||||
("shadow_level", fl(30.0)),
|
||||
("hi_level", fl(30.0)),
|
||||
],
|
||||
false,
|
||||
),
|
||||
("leave_color", vec![("amount", fl(80.0)), ("tolerance", fl(20.0)), ("softness", fl(30.0))], true),
|
||||
("leave_color", vec![("amount", fl(100.0)), ("color", col(0.1, 0.8, 0.2)), ("tolerance", fl(0.0)), ("softness", fl(0.0))], true),
|
||||
("change_to_color", vec![], true),
|
||||
@@ -157,6 +196,12 @@ fn cases() -> Vec<(&'static str, Vec<(&'static str, ParamValue)>, bool)> {
|
||||
("directional_blur", vec![("length", fl(10.0)), ("direction", fl(30.0))], false),
|
||||
("directional_blur", vec![("length", fl(0.2))], false),
|
||||
("directional_blur", vec![("length", fl(1000.0)), ("direction", fl(-100.0))], false),
|
||||
("bokeh_blur", vec![("amount", fl(0.2))], false),
|
||||
("bokeh_blur", vec![("amount", fl(3.0)), ("shape", ch(0)), ("highlight", fl(0.0))], false),
|
||||
("bokeh_blur", vec![("amount", fl(11.0)), ("shape", ch(4)), ("rotation", fl(20.0))], false),
|
||||
("bokeh_blur", vec![("amount", fl(30.0)), ("shape", ch(1)), ("rotation", fl(-75.0)), ("highlight", fl(100.0)), ("threshold", fl(40.0))], false),
|
||||
("bokeh_blur", vec![("amount", fl(60.0)), ("shape", ch(5)), ("highlight", fl(300.0)), ("threshold", fl(0.0))], false),
|
||||
("bokeh_blur", vec![("amount", fl(500.0)), ("shape", ch(2))], false),
|
||||
("sharpen", vec![("amount", fl(80.0))], false),
|
||||
("sharpen", vec![("amount", fl(4000.0))], false),
|
||||
("unsharp_mask", vec![("amount", fl(150.0)), ("radius", fl(3.0)), ("threshold", fl(10.0))], true),
|
||||
@@ -363,6 +408,38 @@ fn effects_match_cpu_exactly(dev: &wgpu::Device, q: &wgpu::Queue) {
|
||||
assert!(failures.is_empty(), "GPU effects differ from the CPU:\n{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
/// #3465 review: a neutral Three-Way Color Corrector leaves every value alone on both paths,
|
||||
/// negative and HDR channels included.
|
||||
#[test]
|
||||
fn gpu_neutral_three_way_leaves_negative_and_hdr_values() {
|
||||
let Some((dev, q)) = device() else {
|
||||
eprintln!("no GPU adapter; skipping");
|
||||
return;
|
||||
};
|
||||
let mut c = GpuCompositor::new(&dev, &q);
|
||||
let (w, h) = (16u32, 8u32);
|
||||
let mut px = picture(w, h);
|
||||
for (i, p) in px.chunks_mut(4).enumerate() {
|
||||
p[3] = 1.0;
|
||||
if i % 3 == 0 {
|
||||
p[0] = -0.1;
|
||||
}
|
||||
if i % 5 == 0 {
|
||||
p[1] = 2.0;
|
||||
}
|
||||
}
|
||||
let frame = VideoFrame::rgba_f32(w, h, px.clone());
|
||||
let e = effect("three_way_color", &[]);
|
||||
let op = FxOp::eval(&e, &cx(Tick::ZERO, 1.0), w as usize, h as usize).expect("three_way_color has a GPU op");
|
||||
assert!(op.is_identity_three_way());
|
||||
let mut cpu = filmcraft_render::Image { w: w as usize, h: h as usize, px: px.clone() };
|
||||
op.apply(&mut cpu);
|
||||
assert_eq!(cpu.px, px, "the CPU leaves a neutral corrector's input untouched");
|
||||
let (_, _, gpu) = c.effect_image(&frame, &LayerFx { size: (w, h), decimation: 1, ops: vec![op] }).expect("effect image");
|
||||
let (worst, flips) = compare(&px, &gpu);
|
||||
assert!(worst <= EXACT && flips == 0.0, "the GPU changed a neutral corrector's input: {worst:.2e}, {flips:.4} of the values");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_extract_zero_softness_has_no_fractional_edge() {
|
||||
let Some((dev, q)) = device() else {
|
||||
@@ -483,6 +560,8 @@ fn hostile_parameters_are_bounded() {
|
||||
effect("brightness_contrast", &[("brightness", fl(f64::NAN))]),
|
||||
effect("gaussian_blur", &[("blurriness", fl(f64::INFINITY))]),
|
||||
effect("directional_blur", &[("length", fl(f64::NAN))]),
|
||||
effect("bokeh_blur", &[("rotation", fl(f64::NAN))]),
|
||||
effect("bokeh_blur", &[("highlight", fl(f64::INFINITY))]),
|
||||
effect("transform", &[("rotation", fl(f64::NAN))]),
|
||||
effect("crop", &[("left", fl(f64::NAN))]),
|
||||
effect("offset", &[("shift", pt(f64::INFINITY, 0.0))]),
|
||||
@@ -532,6 +611,8 @@ fn hostile_parameters_are_bounded() {
|
||||
FxOp::eval(&effect("gaussian_blur", &[("blurriness", fl(1e30))]), &cx(Tick::ZERO, 1.0), w, h).expect("op"),
|
||||
FxOp::eval(&effect("unsharp_mask", &[("radius", fl(1e30)), ("amount", fl(1e30))]), &cx(Tick::ZERO, 1.0), w, h).expect("op"),
|
||||
FxOp::eval(&effect("directional_blur", &[("length", fl(1e30))]), &cx(Tick::ZERO, 1.0), w, h).expect("op"),
|
||||
FxOp::eval(&effect("bokeh_blur", &[("amount", fl(1e30))]), &cx(Tick::ZERO, 1.0), w, h).expect("op"),
|
||||
FxOp::eval(&effect("bokeh_blur", &[("amount", fl(f64::NAN))]), &cx(Tick::ZERO, 1.0), w, h).expect("op"),
|
||||
];
|
||||
let t0 = std::time::Instant::now();
|
||||
let (_, _, gpu) = c.effect_image(&frame, &LayerFx { size: (w as u32, h as u32), decimation: 1, ops }).expect("effect image");
|
||||
|
||||
@@ -373,6 +373,27 @@ fn build_effects() -> Vec<EffectDef> {
|
||||
b("preserve", "Preserve Luminosity", false),
|
||||
],
|
||||
),
|
||||
video(
|
||||
"three_way_color",
|
||||
"Three-Way Color Corrector",
|
||||
vfx::OBSOLETE,
|
||||
vec![
|
||||
grp(fs("shadow_hue", "Shadow Hue", 0.0, (0.0, 360.0), (0.0, 360.0), "°", 0), "Shadows"),
|
||||
grp(f("shadow_balance", "Shadow Balance", 0.0, 0.0, 100.0, ""), "Shadows"),
|
||||
grp(f("shadow_level", "Shadow Level", 0.0, -100.0, 100.0, ""), "Shadows"),
|
||||
grp(fs("mid_hue", "Midtone Hue", 0.0, (0.0, 360.0), (0.0, 360.0), "°", 0), "Midtones"),
|
||||
grp(f("mid_balance", "Midtone Balance", 0.0, 0.0, 100.0, ""), "Midtones"),
|
||||
grp(f("mid_level", "Midtone Level", 0.0, -100.0, 100.0, ""), "Midtones"),
|
||||
grp(fs("hi_hue", "Highlight Hue", 0.0, (0.0, 360.0), (0.0, 360.0), "°", 0), "Highlights"),
|
||||
grp(f("hi_balance", "Highlight Balance", 0.0, 0.0, 100.0, ""), "Highlights"),
|
||||
grp(f("hi_level", "Highlight Level", 0.0, -100.0, 100.0, ""), "Highlights"),
|
||||
grp(f("shadow_threshold", "Shadow Threshold", 33.0, 0.0, 100.0, ""), "Tonal Range"),
|
||||
grp(f("shadow_softness", "Shadow Softness", 30.0, 0.0, 100.0, ""), "Tonal Range"),
|
||||
grp(f("hi_threshold", "Highlight Threshold", 66.0, 0.0, 100.0, ""), "Tonal Range"),
|
||||
grp(f("hi_softness", "Highlight Softness", 30.0, 0.0, 100.0, ""), "Tonal Range"),
|
||||
fs("saturation", "Saturation", 100.0, (0.0, 200.0), (0.0, 200.0), "", 1),
|
||||
],
|
||||
),
|
||||
video(
|
||||
"leave_color",
|
||||
"Leave Color",
|
||||
|
||||
@@ -197,7 +197,7 @@ impl Param {
|
||||
match self.keyframes.binary_search_by_key(&t, |k| k.time) {
|
||||
Ok(i) => self.keyframes[i].value = value,
|
||||
Err(i) => {
|
||||
let interp = self.keyframes.get(i.saturating_sub(1)).map(|k| k.interp).unwrap_or_default();
|
||||
let interp = i.checked_sub(1).and_then(|j| self.keyframes.get(j)).map(|k| k.interp).unwrap_or_default();
|
||||
let mut k = Keyframe::new(t, value);
|
||||
k.interp = interp;
|
||||
self.keyframes.insert(i, k);
|
||||
@@ -417,6 +417,18 @@ mod tests {
|
||||
assert_eq!(p.f64_at(Tick(99)), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_at_before_first_keyframe_uses_default_interp() {
|
||||
let mut p = Param::new(ParamValue::Float(0.0));
|
||||
p.toggle_animation(Tick(100));
|
||||
p.keyframes[0].interp = Interpolation::Hold;
|
||||
p.set_at(Tick(0), ParamValue::Float(5.0));
|
||||
assert_eq!(p.keyframes[0].interp, Interpolation::Linear);
|
||||
assert_eq!(p.keyframes[1].interp, Interpolation::Hold);
|
||||
p.set_at(Tick(200), ParamValue::Float(7.0));
|
||||
assert_eq!(p.keyframes[2].interp, Interpolation::Hold);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bezier_eases() {
|
||||
let mut p = Param::new(ParamValue::Vec2(Vec2::new(0.0, 0.0)));
|
||||
|
||||
@@ -97,6 +97,21 @@ fn point_params_keep_auto_through_json_and_no_other_point_reads_null() {
|
||||
assert!(serde_json::from_value::<ParamValue>(serde_json::json!({ "Path": path })).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_point_default_only_matches_unset_points() {
|
||||
use super::track::param_eq_default;
|
||||
let auto = || ParamValue::Vec2(Vec2::new(f64::NAN, f64::NAN));
|
||||
// an untouched auto point is still the default
|
||||
assert!(param_eq_default(&auto(), &auto()));
|
||||
// the user moved the clip: no longer the default (this used to report `true`, so
|
||||
// `has_modified_intrinsics` never fired for motion and the fx badge, the render
|
||||
// fast path and multicam `take_intrinsics` all treated moved clips as untouched)
|
||||
assert!(!param_eq_default(&ParamValue::Vec2(Vec2::new(960.0, 540.0)), &auto()));
|
||||
// ordinary parameters are unaffected
|
||||
assert!(param_eq_default(&ParamValue::Float(1.0), &ParamValue::Float(1.0)));
|
||||
assert!(!param_eq_default(&ParamValue::Float(1.0), &ParamValue::Float(2.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_resolution_bounds_depth_and_preserves_the_outer_range() {
|
||||
let (mut p, media, _) = demo_project();
|
||||
|
||||
@@ -231,9 +231,12 @@ impl TrackItem {
|
||||
}
|
||||
}
|
||||
|
||||
fn param_eq_default(v: &ParamValue, d: &ParamValue) -> bool {
|
||||
pub(crate) fn param_eq_default(v: &ParamValue, d: &ParamValue) -> bool {
|
||||
match (v, d) {
|
||||
(ParamValue::Vec2(_), ParamValue::Vec2(dd)) if dd.x.is_nan() => true,
|
||||
// A NaN default marks an "auto" point (motion position/anchor): it only equals a value
|
||||
// that is itself still auto. (The interchange `param_modified` helper uses the same
|
||||
// both-NaN rule; the old arm ignored `v`, so a moved clip never counted as modified.)
|
||||
(ParamValue::Vec2(vv), ParamValue::Vec2(dd)) if dd.x.is_nan() => vv.x.is_nan() && vv.y.is_nan(),
|
||||
_ => v == d,
|
||||
}
|
||||
}
|
||||
|
||||
+194
-10
@@ -621,6 +621,22 @@ fn within_unit(v: [f32; 3], hdr: bool, f: impl Fn([f32; 3]) -> [f32; 3]) -> [f32
|
||||
o.map(|q| q * m)
|
||||
}
|
||||
|
||||
/// The luma (encoded, 0..1) down to which the Lumetri highlight / shadow lift scales a pixel by
|
||||
/// `new luma / luma`, preserving its hue exactly as it always has; below it the scale is held at
|
||||
/// this luma's and the rest of the lift is added equally to the channels, so a trace of chroma in
|
||||
/// a near-black pixel is not multiplied into a speck (#2094). About 5/255 in sRGB. Mirrored in
|
||||
/// `fx.wgsl` (`lumetri_op`).
|
||||
pub(crate) const LIFT_LUMA_FLOOR: f32 = 0.02;
|
||||
|
||||
/// The lift's per-channel scale and equal addition for a pixel of luma `l` lifted to `nl`: the
|
||||
/// luma lands on `nl` either way (`l * k + add == nl`); at or above [`LIFT_LUMA_FLOOR`] the
|
||||
/// addition is zero and the scale is the hue-preserving `nl / l`.
|
||||
#[inline]
|
||||
pub(crate) fn lift_scale(l: f32, nl: f32) -> (f32, f32) {
|
||||
let k = if l > 1e-5 { (nl / l).min(1.0 + (nl - l).max(0.0) / LIFT_LUMA_FLOOR) } else { 1.0 };
|
||||
(k, nl - l.max(0.0) * k)
|
||||
}
|
||||
|
||||
fn lumetri(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
|
||||
let (basic_on, creative_on, vignette_on) = (on(e, "basic_on"), on(e, "creative_on"), on(e, "vignette_on"));
|
||||
let input_lut = if basic_on { crate::luts::resolve(cx.project, text(e, "input_lut")) } else { None };
|
||||
@@ -667,10 +683,11 @@ fn lumetri(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
|
||||
let ws = (1.0 - l).clamp(0.0, 1.0).powi(3);
|
||||
let whl = l.clamp(0.0, 1.0).powi(3);
|
||||
let nl = (l + sh * 0.35 * ws + hl * 0.35 * whl).max(0.0);
|
||||
if l > 1e-5 {
|
||||
let k = nl / l;
|
||||
v = v.map(|q| q * k);
|
||||
}
|
||||
// #2094: scaling by nl / l alone multiplies a near-black pixel's chroma by the whole lift
|
||||
// (tens of times), so a trace of blue bleed beside a bright colour came out as a saturated
|
||||
// speck; below LIFT_LUMA_FLOOR the scale is held and the rest is added equally
|
||||
let (k, add) = lift_scale(l, nl);
|
||||
v = v.map(|q| q * k + add);
|
||||
// contrast: smooth S-curve around mid grey (HDR: over 0 … HDR White; speculars above it
|
||||
// keep their distance from white)
|
||||
if contrast.abs() > 1e-4 {
|
||||
@@ -979,6 +996,66 @@ mod tests {
|
||||
assert!(img.get(32, 32)[0] < 0.9 && img.get(24, 32)[0] > 0.01);
|
||||
}
|
||||
|
||||
/// #3465: Three-Way Color Corrector is the identity at its defaults, each wheel moves only its
|
||||
/// own tonal range, and Saturation 0 is grey.
|
||||
#[test]
|
||||
fn three_way_color_corrector_works_per_tonal_range() {
|
||||
let run = |set: &[(&str, f64)], c: [f32; 3]| {
|
||||
let mut img = Image::filled(2, 2, [c[0], c[1], c[2], 1.0]);
|
||||
let mut e = find_effect("three_way_color").unwrap().instance();
|
||||
for (k, v) in set {
|
||||
e.params.get_mut(*k).unwrap().value = ParamValue::Float(*v);
|
||||
}
|
||||
apply(&mut img, &e, &cx());
|
||||
let p = img.get(0, 0);
|
||||
[p[0], p[1], p[2]]
|
||||
};
|
||||
let (dark, mid, bright) = ([0.02f32; 3], [0.2f32; 3], [0.9f32; 3]);
|
||||
for c in [dark, mid, bright, [0.1, 0.4, 0.7]] {
|
||||
let o = run(&[], c);
|
||||
assert!(o.iter().zip(c).all(|(a, b)| (a - b).abs() < 1e-4), "{c:?} -> {o:?}");
|
||||
}
|
||||
// a red shadow wheel (hue 0) tints the dark pixel red, leaves the bright one alone
|
||||
let sh = [("shadow_hue", 0.0), ("shadow_balance", 80.0)];
|
||||
let d = run(&sh, dark);
|
||||
assert!(d[0] > dark[0] + 0.01 && d[2] < d[0], "{d:?}");
|
||||
let b = run(&sh, bright);
|
||||
assert!((b[0] - bright[0]).abs() < 1e-3, "{b:?}");
|
||||
// a highlight level lift brightens the bright pixel only
|
||||
let hl = [("hi_level", 50.0)];
|
||||
assert!(run(&hl, bright)[0] > bright[0] + 0.01 || bright[0] > 0.99);
|
||||
assert!((run(&hl, dark)[0] - dark[0]).abs() < 1e-3);
|
||||
// no saturation: grey
|
||||
let g = run(&[("saturation", 0.0)], [0.1, 0.4, 0.7]);
|
||||
assert!((g[0] - g[1]).abs() < 1e-4 && (g[1] - g[2]).abs() < 1e-4, "{g:?}");
|
||||
}
|
||||
|
||||
/// #3465 review: the default Three-Way Color Corrector leaves HDR values (2.0) untouched, a
|
||||
/// changed control keeps the headroom of a bright pixel, and overlapping tonal ranges (the
|
||||
/// shadow edge above the highlight edge) apply each adjustment at most once in total.
|
||||
#[test]
|
||||
fn three_way_keeps_hdr_and_normalises_overlapping_ranges() {
|
||||
let run = |set: &[(&str, f64)], c: [f32; 3]| {
|
||||
let mut img = Image::filled(1, 1, [c[0], c[1], c[2], 1.0]);
|
||||
let mut e = find_effect("three_way_color").unwrap().instance();
|
||||
for (k, v) in set {
|
||||
e.params.get_mut(*k).unwrap().value = ParamValue::Float(*v);
|
||||
}
|
||||
apply(&mut img, &e, &cx());
|
||||
let p = img.get(0, 0);
|
||||
[p[0], p[1], p[2]]
|
||||
};
|
||||
assert_eq!(run(&[], [2.0, 0.5, 1.5]), [2.0, 0.5, 1.5], "default is the identity, HDR included");
|
||||
assert!(run(&[("mid_level", 10.0)], [2.0, 2.0, 2.0])[0] > 1.5, "a changed control does not clamp HDR");
|
||||
// overlapping ranges: a mid-grey in both would take the full level twice
|
||||
let both = run(
|
||||
&[("shadow_threshold", 100.0), ("shadow_softness", 0.0), ("hi_threshold", 0.0), ("hi_softness", 0.0), ("shadow_level", 40.0), ("hi_level", 40.0)],
|
||||
[0.2, 0.2, 0.2],
|
||||
);
|
||||
let one = run(&[("shadow_level", 40.0), ("shadow_threshold", 100.0), ("shadow_softness", 0.0)], [0.2, 0.2, 0.2]);
|
||||
assert!((both[0] - one[0]).abs() < 1e-3, "weights sum to one: {both:?} vs {one:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flip_and_crop() {
|
||||
let mut img = Image::new(4, 1);
|
||||
@@ -1000,6 +1077,74 @@ mod tests {
|
||||
assert!((a - b).abs() < 2e-3, "{a} {b}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #2094: at and above the luma floor (0.02 encoded, about 5/255) the lift is exactly what it
|
||||
/// always was, the hue-preserving scale `new luma / luma`: a lit shadow pixel at Shadows 50
|
||||
/// keeps its channel ratios and lands on the same values as the plain scale; a pixel just
|
||||
/// below the floor is continuous with one just above.
|
||||
#[test]
|
||||
fn lumetri_shadow_lift_is_unchanged_at_and_above_the_luma_floor() {
|
||||
let mut e = find_effect("lumetri").unwrap().instance();
|
||||
e.params.get_mut("shadows").unwrap().value = ParamValue::Float(50.0);
|
||||
// a warm shadow pixel: sRGB about (0.12, 0.09, 0.055), luma 0.097 encoded, well above the floor
|
||||
let lit = dec([0.12, 0.09, 0.055]);
|
||||
let mut img = Image::new(1, 1);
|
||||
img.px[0..4].copy_from_slice(&[lit[0], lit[1], lit[2], 1.0]);
|
||||
apply(&mut img, &e, &cx());
|
||||
let out = img.get(0, 0);
|
||||
// the lift as it always was: the encoded pixel scaled by new luma / luma, nothing added
|
||||
let v = enc(lit);
|
||||
let l = 0.2126 * v[0] + 0.7152 * v[1] + 0.0722 * v[2];
|
||||
let nl = l + 0.5 * 0.35 * (1.0 - l).powi(3);
|
||||
let (k, add) = lift_scale(l, nl);
|
||||
assert!((k - nl / l).abs() < 1e-6 && add.abs() < 1e-6, "above the floor the lift is the plain scale: k {k} add {add}");
|
||||
let expected = dec([v[0] * k, v[1] * k, v[2] * k]);
|
||||
for c in 0..3 {
|
||||
assert!((out[c] - expected[c]).abs() < 2e-3, "channel {c}: {out:?} vs {expected:?}");
|
||||
}
|
||||
// continuity at the floor: the two sides of it give the same scale and addition
|
||||
let (k_at, add_at) = lift_scale(LIFT_LUMA_FLOOR, LIFT_LUMA_FLOOR + 0.1);
|
||||
let (k_under, add_under) = lift_scale(LIFT_LUMA_FLOOR - 1e-4, LIFT_LUMA_FLOOR - 1e-4 + 0.1);
|
||||
assert!((k_at - k_under).abs() < 0.05 && (add_at - add_under).abs() < 1e-3, "{k_at} {k_under} {add_at} {add_under}");
|
||||
}
|
||||
|
||||
/// #2094: a Shadows lift on a near-black pixel carrying a trace of blue (4:2:0 chroma bleed
|
||||
/// beside a bright orange) lifts it towards grey instead of scaling the trace into a saturated
|
||||
/// blue speck, and black lifts by the same amount as near-black (no step between them).
|
||||
#[test]
|
||||
fn lumetri_shadow_lift_does_not_amplify_near_black_chroma() {
|
||||
let mut e = find_effect("lumetri").unwrap().instance();
|
||||
e.params.get_mut("shadows").unwrap().value = ParamValue::Float(8.0);
|
||||
// linear light: about (0, 2, 6) of 255 in sRGB with the red driven slightly negative by a
|
||||
// 4:2:0 chroma sample shared with the bright neighbour
|
||||
let mut img = Image::new(3, 1);
|
||||
img.px[0..4].copy_from_slice(&[-0.001, 0.0006, 0.0018, 1.0]);
|
||||
img.px[4..8].copy_from_slice(&[0.0, 0.0, 0.0, 1.0]);
|
||||
img.px[8..12].copy_from_slice(&[0.001, 0.001, 0.001, 1.0]);
|
||||
apply(&mut img, &e, &cx());
|
||||
let (bleed, black, near) = (img.get(0, 0), img.get(1, 0), img.get(2, 0));
|
||||
// it used to come out at about (0, 5, 44) of 255; now near (0, 10, 20): a lifted near-black
|
||||
assert!(bleed[2] - bleed[0].max(bleed[1]) < 0.01, "blue speck: {bleed:?}");
|
||||
assert!(black[0] > 0.001 && (black[0] - black[1]).abs() < 1e-5, "black not lifted neutrally: {black:?}");
|
||||
assert!((near[0] - black[0]).abs() < 0.002, "step between black {black:?} and near-black {near:?}");
|
||||
}
|
||||
|
||||
/// Luma vs Sat pulling the highlights to grey leaves a saturated dark colour alone: the
|
||||
/// curve's axis runs from black to white and does not wrap around.
|
||||
#[test]
|
||||
fn luma_vs_sat_highlight_point_leaves_shadows_saturated() {
|
||||
let mut e = find_effect("lumetri").unwrap().instance();
|
||||
e.params.get_mut("luma_vs_sat").unwrap().value = filmcraft_project::ParamValue::Curve(vec![[0.5, 0.5], [0.9, 0.0]]);
|
||||
let dark_red = [0.02, 0.0005, 0.0005, 1.0];
|
||||
let mut img = Image::filled(1, 1, dark_red);
|
||||
apply(&mut img, &e, &cx());
|
||||
let s = |p: &[f32]| {
|
||||
let c = [p[0], p[1], p[2]].map(linear_to_srgb);
|
||||
rgb_to_hsl(c[0], c[1], c[2])[1]
|
||||
};
|
||||
let (before, after) = (s(&dark_red), s(&img.px));
|
||||
assert!((after - before).abs() < 1e-3, "shadow saturation {before} became {after}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Monotone cubic (Fritsch–Carlson) interpolation through sorted control points, as a LUT of `n`
|
||||
@@ -1054,19 +1199,30 @@ pub fn curve_lut(points: &[[f32; 2]], n: usize) -> Vec<f32> {
|
||||
}
|
||||
|
||||
/// Periodic (hue) curve: points around the colour wheel, neutral 0.5 where there are none.
|
||||
fn hue_lut(points: &[[f32; 2]], n: usize) -> Option<Vec<f32>> {
|
||||
/// Entry `i` of the `n` entries is the curve at hue `i / (n - 1)`, so the first and last entries
|
||||
/// are both red and equal.
|
||||
pub fn hue_lut(points: &[[f32; 2]], n: usize) -> Option<Vec<f32>> {
|
||||
if points.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// wrap: repeat points one period left and right, then sample 0..1
|
||||
// wrap: repeat points one period left and right, map -1..2 onto 0..1 and sample it with
|
||||
// 3n - 2 entries, whose spacing is exactly one n-entry step over 0..1: entries n-1 ..= 2n-2
|
||||
// are hues 0 ..= 1
|
||||
let mut ext = Vec::new();
|
||||
for off in [-1.0f32, 0.0, 1.0] {
|
||||
for p in points {
|
||||
ext.push([p[0] + off, p[1]]);
|
||||
ext.push([(p[0] + off + 1.0) / 3.0, p[1]]);
|
||||
}
|
||||
}
|
||||
let lut = curve_lut(&ext.iter().map(|p| [(p[0] + 1.0) / 3.0, p[1]]).collect::<Vec<_>>(), n * 3);
|
||||
Some(lut[n..2 * n].to_vec())
|
||||
let lut = curve_lut(&ext, 3 * n - 2);
|
||||
Some(lut[n - 1..2 * n - 1].to_vec())
|
||||
}
|
||||
|
||||
/// Lumetri's other "vs Sat" curves (Luma vs Sat, Sat vs Sat): neutral 0.5 where there are no
|
||||
/// points, like the hue curves, but over a straight 0..1 axis. Black and white are not neighbours,
|
||||
/// so the curve does not wrap; it stays flat beyond its outermost points.
|
||||
pub fn vs_sat_lut(points: &[[f32; 2]], n: usize) -> Option<Vec<f32>> {
|
||||
(!points.is_empty()).then(|| curve_lut(points, n))
|
||||
}
|
||||
|
||||
pub(crate) fn curve_param(e: &EffectInstance, id: &str) -> Option<Vec<[f32; 2]>> {
|
||||
@@ -1124,7 +1280,8 @@ fn lumetri_advanced(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
|
||||
let lut = |id: &str| curve_param(e, id).filter(|c| curves_on && !is_identity_curve(c)).map(|c| curve_lut(&c, N));
|
||||
let (luma_c, red_c, green_c, blue_c) = (lut("curve_luma"), lut("curve_red"), lut("curve_green"), lut("curve_blue"));
|
||||
let hue = |id: &str| curve_param(e, id).filter(|_| curves_on).and_then(|c| hue_lut(&c, N));
|
||||
let (hvs, hvh, hvl, lvs, svs) = (hue("hue_vs_sat"), hue("hue_vs_hue"), hue("hue_vs_luma"), hue("luma_vs_sat"), hue("sat_vs_sat"));
|
||||
let vs_sat = |id: &str| curve_param(e, id).filter(|_| curves_on).and_then(|c| vs_sat_lut(&c, N));
|
||||
let (hvs, hvh, hvl, lvs, svs) = (hue("hue_vs_sat"), hue("hue_vs_hue"), hue("hue_vs_luma"), vs_sat("luma_vs_sat"), vs_sat("sat_vs_sat"));
|
||||
// Creative ▸ Look: a LUT ("Browse…" / built-in) takes precedence over the procedural menu
|
||||
let look_lut = if creative_on { crate::luts::resolve(cx.project, text(e, "look_lut")) } else { None };
|
||||
let look = if creative_on && look_lut.is_none() { choice(e, "look") } else { 0 };
|
||||
@@ -1382,6 +1539,33 @@ mod curve_tests {
|
||||
let h = hue_lut(&[[0.0, 0.8], [0.5, 0.2]], 64).unwrap();
|
||||
assert!((h[0] - h[63]).abs() < 0.05, "periodic");
|
||||
}
|
||||
|
||||
/// A steep hue curve through red: the LUT's first and last entries are both exactly red, so
|
||||
/// the curve meets itself at 0/360 degrees without a step, and entry i is hue i/(n-1).
|
||||
#[test]
|
||||
fn hue_curve_has_no_seam_at_red() {
|
||||
let pts = [[0.02, 0.9], [0.5, 0.5], [0.98, 0.1]];
|
||||
let n = 1024;
|
||||
let h = hue_lut(&pts, n).unwrap();
|
||||
assert!((h[0] - h[n - 1]).abs() < 1e-5, "step at red: {} vs {}", h[0], h[n - 1]);
|
||||
assert!((h[0] - 0.5).abs() < 1e-4, "red is midway between the two points: {}", h[0]);
|
||||
// slope on both sides of the wrap agrees (C1 across red)
|
||||
let (s0, s1) = (h[1] - h[0], h[n - 1] - h[n - 2]);
|
||||
assert!((s0 - s1).abs() < 1e-3 * s0.abs().max(1e-3), "slopes {s0} vs {s1}");
|
||||
// a control point lands on its own grid entry
|
||||
let g = hue_lut(&[[0.25, 0.8], [0.75, 0.3]], 5).unwrap();
|
||||
assert!((g[1] - 0.8).abs() < 1e-5 && (g[3] - 0.3).abs() < 1e-5, "{g:?}");
|
||||
}
|
||||
|
||||
/// Luma vs Sat and Sat vs Sat are not periodic: a point in the highlights does not reach
|
||||
/// around into the shadows.
|
||||
#[test]
|
||||
fn vs_sat_curves_do_not_wrap() {
|
||||
let l = vs_sat_lut(&[[0.5, 0.5], [0.9, 0.0]], 256).unwrap();
|
||||
assert!((l[0] - 0.5).abs() < 1e-5, "black follows the left point: {}", l[0]);
|
||||
assert!(l[255].abs() < 1e-5, "white follows the right point: {}", l[255]);
|
||||
assert!(vs_sat_lut(&[], 256).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+153
-5
@@ -14,7 +14,7 @@ use filmcraft_geom::Affine;
|
||||
use filmcraft_project::EffectInstance;
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::effects::{FxCtx, b, choice, color, dec, enc, f, gaussian_boxes, grade_space, on, point, text};
|
||||
use crate::effects::{FxCtx, b, choice, color, dec, enc, f, gaussian_boxes, grade_space, lift_scale, on, point, text};
|
||||
use crate::image::Image;
|
||||
|
||||
/// A bilinear resample of the working image through an affine map (`Image::transformed` at a
|
||||
@@ -54,6 +54,16 @@ pub enum FxOp {
|
||||
hi: [f32; 3],
|
||||
preserve: bool,
|
||||
},
|
||||
/// Three-Way Color Corrector: per tonal range an encoded-RGB offset (the wheel) and a level
|
||||
/// shift (`[r, g, b, level]`), the range edges `[shadow lo, shadow hi, highlight lo,
|
||||
/// highlight hi]` on encoded luma, and a saturation factor.
|
||||
ThreeWay {
|
||||
sh: [f32; 4],
|
||||
md: [f32; 4],
|
||||
hi: [f32; 4],
|
||||
range: [f32; 4],
|
||||
sat: f32,
|
||||
},
|
||||
LeaveColor {
|
||||
amount: f32,
|
||||
key_hue: f32,
|
||||
@@ -106,6 +116,18 @@ pub enum FxOp {
|
||||
dy: f32,
|
||||
steps: u32,
|
||||
},
|
||||
/// Bokeh Blur: `levels` halvings of the working image, highlights boosted (`boost` × a
|
||||
/// smoothstep from `threshold`), an iris-shaped gather of `rings` rings out to radius `rr`
|
||||
/// (`sides` 0 = circle, rotated by `rot` radians), then scaled back up. `rings` 0: no-op.
|
||||
Bokeh {
|
||||
levels: u32,
|
||||
boost: f32,
|
||||
threshold: f32,
|
||||
sides: u32,
|
||||
rot: f32,
|
||||
rr: f32,
|
||||
rings: u32,
|
||||
},
|
||||
/// Unsharp mask over a repeat-edge Gaussian of the given box radii (per axis).
|
||||
Unsharp {
|
||||
rx: Vec<u32>,
|
||||
@@ -206,6 +228,7 @@ pub const GPU_EFFECTS: &[&str] = &[
|
||||
"tint",
|
||||
"black_white",
|
||||
"color_balance",
|
||||
"three_way_color",
|
||||
"leave_color",
|
||||
"change_to_color",
|
||||
"color_pass",
|
||||
@@ -219,6 +242,7 @@ pub const GPU_EFFECTS: &[&str] = &[
|
||||
"camera_blur",
|
||||
"directional_blur",
|
||||
"directional_blur_legacy",
|
||||
"bokeh_blur",
|
||||
"sharpen",
|
||||
"unsharp_mask",
|
||||
"crop",
|
||||
@@ -259,6 +283,13 @@ fn minifies(m: &Affine, w: usize, h: usize) -> bool {
|
||||
}
|
||||
|
||||
impl FxOp {
|
||||
/// A Three-Way Color Corrector with neutral controls (and any other op: false): it leaves the
|
||||
/// image untouched, HDR values included.
|
||||
pub fn is_identity_three_way(&self) -> bool {
|
||||
matches!(self, FxOp::ThreeWay { sh, md, hi, sat, .. }
|
||||
if [sh, md, hi].iter().all(|w| w.iter().all(|v| *v == 0.0)) && *sat == 1.0)
|
||||
}
|
||||
|
||||
/// Evaluate `e` at `cx.t` for a `w`×`h` working image. None for effects without a GPU
|
||||
/// implementation (and for disabled effects).
|
||||
pub fn eval(e: &EffectInstance, cx: &FxCtx, w: usize, h: usize) -> Option<FxOp> {
|
||||
@@ -287,6 +318,30 @@ impl FxOp {
|
||||
preserve: b(e, "preserve"),
|
||||
}
|
||||
}
|
||||
"three_way_color" => {
|
||||
// a wheel: its hue as an RGB direction without the grey component, scaled by Balance
|
||||
let wheel = |hue: &str, bal: &str, lvl: &str| {
|
||||
let h = f(e, hue, cx).rem_euclid(360.0) / 360.0;
|
||||
let c = filmcraft_color::hsl_to_rgb(h, 1.0, 0.5);
|
||||
let m = (c[0] + c[1] + c[2]) / 3.0;
|
||||
let k = f(e, bal, cx).clamp(0.0, 100.0) / 100.0 * 0.5;
|
||||
[(c[0] - m) * k, (c[1] - m) * k, (c[2] - m) * k, f(e, lvl, cx) / 100.0 * 0.25]
|
||||
};
|
||||
// the edges of a range: the threshold ± half its softness, on 0..1 luma
|
||||
let edges = |thr: &str, soft: &str| {
|
||||
let (t, s) = (f(e, thr, cx).clamp(0.0, 100.0) / 100.0, f(e, soft, cx).clamp(0.0, 100.0) / 100.0 * 0.5);
|
||||
((t - s).clamp(0.0, 1.0), (t + s).clamp(0.0, 1.0))
|
||||
};
|
||||
let (s0, s1) = edges("shadow_threshold", "shadow_softness");
|
||||
let (h0, h1) = edges("hi_threshold", "hi_softness");
|
||||
FxOp::ThreeWay {
|
||||
sh: wheel("shadow_hue", "shadow_balance", "shadow_level"),
|
||||
md: wheel("mid_hue", "mid_balance", "mid_level"),
|
||||
hi: wheel("hi_hue", "hi_balance", "hi_level"),
|
||||
range: [s0, s1, h0, h1],
|
||||
sat: f(e, "saturation", cx) / 100.0,
|
||||
}
|
||||
}
|
||||
"leave_color" => {
|
||||
let key = color(e, "color", cx);
|
||||
FxOp::LeaveColor {
|
||||
@@ -354,6 +409,29 @@ impl FxOp {
|
||||
FxOp::DirectionalBlur { dx: (dir.sin() as f32) * len, dy: (-dir.cos() as f32) * len, steps }
|
||||
}
|
||||
}
|
||||
"bokeh_blur" => {
|
||||
let r = f(e, "amount", cx) * cx.px_scale;
|
||||
if !r.is_finite() || r < 0.5 {
|
||||
return Some(FxOp::Bokeh { levels: 0, boost: 0.0, threshold: 0.0, sides: 0, rot: 0.0, rr: 0.0, rings: 0 });
|
||||
}
|
||||
// Large radii work on a halved (and halved again…) image: the result is smooth.
|
||||
let (mut ww, mut wh, mut down, mut levels) = (w, h, 1.0f32, 0u32);
|
||||
while r / down > 12.0 && ww >= 8 && wh >= 8 {
|
||||
(ww, wh) = ((ww / 2).max(1), (wh / 2).max(1));
|
||||
down *= 2.0;
|
||||
levels += 1;
|
||||
}
|
||||
let rr = r / down;
|
||||
FxOp::Bokeh {
|
||||
levels,
|
||||
boost: f(e, "highlight", cx) / 100.0 * 6.0,
|
||||
threshold: f(e, "threshold", cx) / 100.0,
|
||||
sides: [0u32, 3, 4, 5, 6, 8][crate::vfx::chv(e, "shape").min(5) as usize],
|
||||
rot: f(e, "rotation", cx).to_radians(),
|
||||
rr,
|
||||
rings: (rr.ceil() as usize).clamp(1, 12) as u32,
|
||||
}
|
||||
}
|
||||
"sharpen" => {
|
||||
// below a sigma of ~0.58 the three boxes all round to radius 0 and the mask is a
|
||||
// no-op, so Sharpen vanished at 1/2 playback resolution and below (#1351)
|
||||
@@ -389,6 +467,12 @@ impl FxOp {
|
||||
FxOp::crop_rect(w, h, 0.0, 0.0, 0.0, 0.0, amt)
|
||||
}
|
||||
"transform" => {
|
||||
// With its own shutter angle the CPU blurs the motion over seven samples
|
||||
// (`vfx::distort::transform_fx`, under the same condition); the GPU op places one
|
||||
// picture, so this stays on the CPU rather than losing the blur
|
||||
if crate::vfx::bv(e, "shutter_override") && crate::vfx::fv(e, "shutter_angle", cx) > 0.5 {
|
||||
return None;
|
||||
}
|
||||
let img = Image { w, h, px: Vec::new() };
|
||||
let anchor = point(e, "anchor", cx, &img);
|
||||
let pos = point(e, "position", cx, &img);
|
||||
@@ -595,6 +679,7 @@ impl FxOp {
|
||||
FxOp::Tint { black, white, amount } => fin(black) && fin(white) && amount.is_finite(),
|
||||
FxOp::BlackWhite | FxOp::HFlip | FxOp::VFlip => true,
|
||||
FxOp::ColorBalance { sh, md, hi, .. } => fin(sh) && fin(md) && fin(hi),
|
||||
FxOp::ThreeWay { sh, md, hi, range, sat } => fin(sh) && fin(md) && fin(hi) && fin(range) && sat.is_finite(),
|
||||
FxOp::LeaveColor { amount, key_hue, tol, soft } => fin(&[*amount, *key_hue, *tol, *soft]),
|
||||
FxOp::ChangeToColor { from_hue, to_hue, tol, soft } => fin(&[*from_hue, *to_hue, *tol, *soft]),
|
||||
FxOp::ColorPass { key, sim, .. } => fin(key) && sim.is_finite(),
|
||||
@@ -605,6 +690,7 @@ impl FxOp {
|
||||
FxOp::Posterize { n } => n.is_finite(),
|
||||
FxOp::Gaussian { .. } => true,
|
||||
FxOp::DirectionalBlur { dx, dy, .. } => fin(&[*dx, *dy]),
|
||||
FxOp::Bokeh { boost, threshold, sides, rot, rr, rings, .. } => fin(&[*boost, *threshold, *rot, *rr]) && *sides <= 8 && *rings <= 12,
|
||||
FxOp::Unsharp { amount, threshold, .. } => fin(&[*amount, *threshold]),
|
||||
FxOp::Crop { x0, x1, y0, y1, feather } => fin(&[*x0, *x1, *y0, *y1, *feather]),
|
||||
FxOp::Resample(r) => {
|
||||
@@ -688,6 +774,35 @@ impl FxOp {
|
||||
dec(o)
|
||||
});
|
||||
}
|
||||
FxOp::ThreeWay { sh, md, hi, range, sat } => {
|
||||
if self.is_identity_three_way() {
|
||||
return;
|
||||
}
|
||||
let (sh, md, hi, range, sat) = (*sh, *md, *hi, *range, *sat);
|
||||
let ramp = |l: f32, lo: f32, hi: f32| {
|
||||
let t = ((l - lo) / (hi - lo).max(1e-4)).clamp(0.0, 1.0);
|
||||
t * t * (3.0 - 2.0 * t)
|
||||
};
|
||||
img.map_rgb(|c, _, _| {
|
||||
let e = enc(c);
|
||||
let l = 0.2126 * e[0] + 0.7152 * e[1] + 0.0722 * e[2];
|
||||
let (mut ws, mut wh) = (1.0 - ramp(l, range[0], range[1]), ramp(l, range[2], range[3]));
|
||||
// overlapping ranges (shadow edge above the highlight edge) share the pixel: weights sum to 1
|
||||
let wm = (1.0 - ws - wh).max(0.0);
|
||||
let total = ws + wm + wh;
|
||||
ws /= total;
|
||||
wh /= total;
|
||||
let wm = wm / total;
|
||||
let lvl = sh[3] * ws + md[3] * wm + hi[3] * wh;
|
||||
let mut o = [0.0; 3];
|
||||
for k in 0..3 {
|
||||
o[k] = e[k] + sh[k] * ws + md[k] * wm + hi[k] * wh + lvl;
|
||||
}
|
||||
let l2 = 0.2126 * o[0] + 0.7152 * o[1] + 0.0722 * o[2];
|
||||
// decoded without the upper clamp: values above 1 (HDR) keep their headroom
|
||||
o.map(|v| filmcraft_color::srgb_to_linear((l2 + (v - l2) * sat).max(0.0)))
|
||||
});
|
||||
}
|
||||
FxOp::LeaveColor { amount, key_hue, tol, soft } => {
|
||||
let (amt, kh, tol, soft) = (*amount, *key_hue, *tol, *soft);
|
||||
img.map_rgb(|c, _, _| {
|
||||
@@ -778,6 +893,11 @@ impl FxOp {
|
||||
}
|
||||
FxOp::Gaussian { rx, ry, repeat } => crate::effects::box_blur(img, rx, ry, *repeat),
|
||||
FxOp::DirectionalBlur { dx, dy, steps } => crate::effects::directional_taps(img, *dx, *dy, *steps as usize),
|
||||
FxOp::Bokeh { levels, boost, threshold, sides, rot, rr, rings } => {
|
||||
if *rings > 0 {
|
||||
crate::vfx::bokeh(img, *levels, *boost, *threshold, *sides, *rot, *rr, *rings);
|
||||
}
|
||||
}
|
||||
FxOp::Unsharp { rx, ry, amount, threshold } => crate::effects::unsharp_boxes(img, rx, ry, *amount, *threshold),
|
||||
FxOp::Crop { x0, x1, y0, y1, feather } => crate::effects::crop_px(img, *x0, *x1, *y0, *y1, *feather),
|
||||
FxOp::Resample(r) => {
|
||||
@@ -962,10 +1082,9 @@ impl FxOp {
|
||||
let ws = (1.0 - l).clamp(0.0, 1.0).powi(3);
|
||||
let whl = l.clamp(0.0, 1.0).powi(3);
|
||||
let nl = (l + sh * 0.35 * ws + hl * 0.35 * whl).max(0.0);
|
||||
if l > 1e-5 {
|
||||
let k = nl / l;
|
||||
v = v.map(|q| q * k);
|
||||
}
|
||||
// #2094: below LIFT_LUMA_FLOOR the hue-preserving scale is held and the rest is added equally
|
||||
let (k, add) = lift_scale(l, nl);
|
||||
v = v.map(|q| q * k + add);
|
||||
// contrast: smooth S-curve around mid grey
|
||||
if contrast.abs() > 1e-4 {
|
||||
let k = 1.0 + contrast;
|
||||
@@ -1150,4 +1269,33 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Transform with its own shutter angle blurs the motion on the CPU
|
||||
/// (`vfx::distort::transform_fx`). The GPU op has no such blur, so that effect must leave
|
||||
/// the GPU chain instead of silently losing its blur in the Program monitor and GPU exports.
|
||||
#[test]
|
||||
fn transform_with_its_own_shutter_angle_stays_on_the_cpu() {
|
||||
let cx = FxCtx {
|
||||
t: Tick::ZERO,
|
||||
px_scale: 1.0,
|
||||
seconds: 0.0,
|
||||
timecode: "",
|
||||
clip_name: "",
|
||||
project: None,
|
||||
env: None,
|
||||
frame_rate: 24.0,
|
||||
working: filmcraft_color::WorkingSpace::Rec709,
|
||||
};
|
||||
let on_gpu = |e: &EffectInstance| FxOp::eval(e, &cx, 64, 36).is_some_and(|op| op.gpu_ok());
|
||||
let mut e = find_effect("transform").unwrap().instance();
|
||||
e.params.get_mut("position").unwrap().value = ParamValue::Vec2(filmcraft_geom::Vec2::new(40.0, 20.0));
|
||||
assert!(on_gpu(&e), "a plain transform runs on the GPU");
|
||||
e.params.get_mut("shutter_override").unwrap().value = ParamValue::Bool(true);
|
||||
assert!(!on_gpu(&e), "the default 180° shutter blurs the motion: CPU");
|
||||
e.params.get_mut("shutter_angle").unwrap().value = ParamValue::Float(0.0);
|
||||
assert!(on_gpu(&e), "a closed shutter blurs nothing");
|
||||
e.params.get_mut("shutter_override").unwrap().value = ParamValue::Bool(false);
|
||||
e.params.get_mut("shutter_angle").unwrap().value = ParamValue::Float(360.0);
|
||||
assert!(on_gpu(&e), "the angle counts only with the override");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
//! (isolated tests, adjustment layers) they leave the image unchanged.
|
||||
|
||||
mod blur;
|
||||
pub(crate) use blur::bokeh;
|
||||
mod color;
|
||||
pub(crate) use color::cdl;
|
||||
mod distort;
|
||||
@@ -467,7 +468,6 @@ pub fn apply(img: &mut Image, e: &EffectInstance, cx: &FxCtx) -> bool {
|
||||
"cineon_converter" => color::cineon(img, e, cx),
|
||||
"noise" | "noise_legacy" => color::noise(img, e, cx),
|
||||
// Blur & Sharpen
|
||||
"bokeh_blur" => blur::bokeh(img, e, cx),
|
||||
"channel_blur" => blur::channel_blur(img, e, cx),
|
||||
"compound_blur" => blur::compound(img, e, cx),
|
||||
"focus_blur" => blur::focus(img, e, cx),
|
||||
|
||||
@@ -17,25 +17,16 @@ fn poly_radius(n: u32, theta: f32, rot: f32) -> f32 {
|
||||
(std::f32::consts::PI / n as f32).cos() / a.cos()
|
||||
}
|
||||
|
||||
/// Bokeh Blur: a lens-iris shaped gather blur with highlight boost (bright points bloom into
|
||||
/// iris-shaped discs). Large radii run on a reduced image (the result is smooth).
|
||||
pub fn bokeh(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
|
||||
let r = fv(e, "amount", cx) * cx.px_scale;
|
||||
if r < 0.5 {
|
||||
return;
|
||||
}
|
||||
let sides = [0u32, 3, 4, 5, 6, 8][chv(e, "shape").min(5) as usize];
|
||||
let rot = fv(e, "rotation", cx).to_radians();
|
||||
let boost = fv(e, "highlight", cx) / 100.0 * 6.0;
|
||||
let th = fv(e, "threshold", cx) / 100.0;
|
||||
/// Bokeh Blur (`gpufx::FxOp::Bokeh`, evaluated there): a lens-iris shaped gather blur with
|
||||
/// highlight boost (bright points bloom into iris-shaped discs), on the image halved `levels`
|
||||
/// times (large radii: the result is smooth) and scaled back up.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn bokeh(img: &mut Image, levels: u32, boost: f32, th: f32, sides: u32, rot: f32, rr: f32, rings: u32) {
|
||||
let (fw, fh) = (img.w, img.h);
|
||||
let mut work = img.clone();
|
||||
let mut down = 1.0f32;
|
||||
while r / down > 12.0 && work.w >= 8 && work.h >= 8 {
|
||||
for _ in 0..levels {
|
||||
work = work.downsample2();
|
||||
down *= 2.0;
|
||||
}
|
||||
let rr = r / down;
|
||||
// boost highlights (energy, not weight: bright points stay bright after averaging)
|
||||
if boost > 0.0 {
|
||||
work.px.par_chunks_mut(4).for_each(|p| {
|
||||
@@ -49,7 +40,7 @@ pub fn bokeh(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
|
||||
}
|
||||
});
|
||||
}
|
||||
let rings = (rr.ceil() as usize).clamp(1, 12);
|
||||
let rings = rings as usize;
|
||||
let mut taps: Vec<(f32, f32)> = vec![(0.0, 0.0)];
|
||||
for ring in 1..=rings {
|
||||
let rad = rr * ring as f32 / rings as f32;
|
||||
@@ -77,7 +68,7 @@ pub fn bokeh(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
|
||||
}
|
||||
}
|
||||
});
|
||||
*img = if down > 1.0 { work.transformed(fw, fh, &Affine::scale(fw as f64 / work.w as f64, fh as f64 / work.h as f64)) } else { work };
|
||||
*img = if levels > 0 { work.transformed(fw, fh, &Affine::scale(fw as f64 / work.w as f64, fh as f64 / work.h as f64)) } else { work };
|
||||
}
|
||||
|
||||
/// Blur one channel of an image (0..3) with a Gaussian.
|
||||
|
||||
@@ -241,6 +241,58 @@ pub fn clear_stabilizer_cache() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Frames decoded, prepared and tracked together; the analysis holds this many (plus one carried
|
||||
/// over from the block before) at a time, whatever the clip's length.
|
||||
pub(super) const ANALYSIS_BLOCK: usize = 64;
|
||||
|
||||
/// The step between each pair of consecutive frames of `n`, and the size of the frames. `decode`
|
||||
/// makes frame `k` (`None` when it can't be read: its steps are the identity), `size` is a frame's
|
||||
/// (width, height), `step` the motion from one frame to the next given the size of the first frame
|
||||
/// that could be read. Frames are made `block` at a time, in parallel, and dropped once the steps
|
||||
/// they take part in are known; only the last frame of a block stays for the next one. `None` when
|
||||
/// cancelled or when no frame could be read.
|
||||
pub(super) fn chunked_steps<T: Send + Sync>(
|
||||
n: usize,
|
||||
block: usize,
|
||||
decode: impl Fn(usize) -> Option<T> + Sync,
|
||||
size: impl Fn(&T) -> (usize, usize) + Sync,
|
||||
step: impl Fn(&T, &T, (usize, usize)) -> Affine + Sync,
|
||||
cancelled: impl Fn() -> bool + Sync,
|
||||
) -> Option<(Vec<Affine>, (usize, usize))> {
|
||||
let block = block.max(1);
|
||||
let mut steps = Vec::with_capacity(n.saturating_sub(1));
|
||||
let mut dims = None;
|
||||
let mut carried: Option<Option<T>> = None;
|
||||
let mut next = 0;
|
||||
while next < n {
|
||||
if cancelled() {
|
||||
return None;
|
||||
}
|
||||
let end = (next + block).min(n);
|
||||
let fresh: Vec<Option<T>> = (next..end).into_par_iter().map(|k| if cancelled() { None } else { decode(k) }).collect();
|
||||
if cancelled() {
|
||||
return None;
|
||||
}
|
||||
let mut frames: Vec<Option<T>> = Vec::with_capacity(fresh.len() + 1);
|
||||
frames.extend(carried.take());
|
||||
frames.extend(fresh);
|
||||
if dims.is_none() {
|
||||
dims = frames.iter().flatten().next().map(&size);
|
||||
}
|
||||
let pairs: Vec<Affine> = (0..frames.len().saturating_sub(1))
|
||||
.into_par_iter()
|
||||
.map(|i| match (&frames[i], &frames[i + 1], dims) {
|
||||
(Some(a), Some(b), Some(d)) if size(a).0 == size(b).0 => step(a, b, d),
|
||||
_ => Affine::IDENTITY,
|
||||
})
|
||||
.collect();
|
||||
steps.extend(pairs);
|
||||
carried = frames.pop();
|
||||
next = end;
|
||||
}
|
||||
Some((steps, dims?))
|
||||
}
|
||||
|
||||
/// Analyse (or fetch the cached analysis of) the clip `env` describes.
|
||||
pub fn stabilizer_path(env: &dyn FxEnv, now: f64, method: TrackMethod, detailed: bool) -> Option<Arc<StabilizerPath>> {
|
||||
let key = env.clip_key() ^ (method as u64).wrapping_mul(0x9E37) ^ (detailed as u64).wrapping_mul(0x51_7CC1);
|
||||
@@ -252,28 +304,20 @@ pub fn stabilizer_path(env: &dyn FxEnv, now: f64, method: TrackMethod, detailed:
|
||||
let src_w = env.source_size().0.max(1) as f32;
|
||||
let target_w = if detailed { 960.0 } else { 480.0 };
|
||||
let scale = (target_w / src_w).min(1.0);
|
||||
let frames: Vec<Option<Prepared>> = (0..n)
|
||||
.into_par_iter()
|
||||
.map(|k| {
|
||||
if filmcraft_media::cancel::cancelled() {
|
||||
return None;
|
||||
}
|
||||
env.source_frame(k as f64 / fps - now, scale).map(|f| Prepared::new(gray_of(&f)))
|
||||
})
|
||||
.collect();
|
||||
if filmcraft_media::cancel::cancelled() {
|
||||
return None;
|
||||
}
|
||||
let (w, h) = frames.iter().flatten().next().map(|p| (p.base().w, p.base().h))?;
|
||||
let m = 8.0;
|
||||
let region = [Vec2::new(m, m), Vec2::new(w as f64 - m, m), Vec2::new(w as f64 - m, h as f64 - m), Vec2::new(m, h as f64 - m)];
|
||||
let steps: Vec<Affine> = (0..n.saturating_sub(1))
|
||||
.into_par_iter()
|
||||
.map(|k| match (&frames[k], &frames[k + 1]) {
|
||||
(Some(a), Some(b)) if a.base().w == b.base().w => track_step(a, b, ®ion, method).map(|s| s.transform).unwrap_or(Affine::IDENTITY),
|
||||
_ => Affine::IDENTITY,
|
||||
})
|
||||
.collect();
|
||||
// A few frames at a time: holding the pyramid of every frame of a long clip (a 10-minute clip
|
||||
// at 30 fps is 18,000 of them) took tens of gigabytes and ended the process (#1381).
|
||||
let (steps, (w, h)) = chunked_steps(
|
||||
n,
|
||||
ANALYSIS_BLOCK,
|
||||
|k| env.source_frame(k as f64 / fps - now, scale).map(|f| Prepared::new(gray_of(&f))),
|
||||
|p: &Prepared| (p.base().w, p.base().h),
|
||||
|a: &Prepared, b: &Prepared, (w, h)| {
|
||||
let m = 8.0;
|
||||
let region = [Vec2::new(m, m), Vec2::new(w as f64 - m, m), Vec2::new(w as f64 - m, h as f64 - m), Vec2::new(m, h as f64 - m)];
|
||||
track_step(a, b, ®ion, method).map(|s| s.transform).unwrap_or(Affine::IDENTITY)
|
||||
},
|
||||
filmcraft_media::cancel::cancelled,
|
||||
)?;
|
||||
let path = Arc::new(StabilizerPath { w, h, steps, fps });
|
||||
if let Ok(mut c) = path_cache().lock() {
|
||||
if c.len() > 64 {
|
||||
@@ -351,6 +395,62 @@ pub(crate) fn corrections(path: &StabilizerPath, smooth_frames: f64, no_motion:
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The corrections of one analysed path for one set of Smoothness / Result / Preserve Scale
|
||||
/// settings, and the zoom that hides the borders of the worst of them (per `max` zoom allowed).
|
||||
pub(crate) struct Corrected {
|
||||
pub(crate) corr: Vec<Affine>,
|
||||
/// Keeps the path the corrections came from alive, so its address can't be reused by another.
|
||||
_path: Arc<StabilizerPath>,
|
||||
worst: Mutex<Vec<(u64, f64)>>,
|
||||
}
|
||||
|
||||
impl Corrected {
|
||||
/// The largest [`cover_scale`] of any correction, with at most `max` zoom: what Crop Less
|
||||
/// Smooth Motion framing and Auto-scale need, the same for every frame of the clip.
|
||||
pub(crate) fn worst_cover(&self, w: f64, h: f64, max: f64) -> f64 {
|
||||
let key = max.to_bits();
|
||||
if let Some(v) = self.worst.lock().ok().and_then(|l| l.iter().find(|(k, _)| *k == key).map(|(_, v)| *v)) {
|
||||
return v;
|
||||
}
|
||||
let v = self.corr.iter().map(|c| cover_scale(c, w, h, max)).fold(1.0, f64::max);
|
||||
if let Ok(mut l) = self.worst.lock() {
|
||||
l.push((key, v));
|
||||
}
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
type CorrectedKey = (usize, u64, bool, bool);
|
||||
|
||||
/// Corrections already worked out, by path and settings.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct CorrectedCache(Mutex<HashMap<CorrectedKey, Arc<Corrected>>>);
|
||||
|
||||
impl CorrectedCache {
|
||||
pub(crate) fn get(&self, path: &Arc<StabilizerPath>, smooth_frames: f64, no_motion: bool, preserve_scale: bool) -> Arc<Corrected> {
|
||||
let key = (Arc::as_ptr(path) as usize, smooth_frames.to_bits(), no_motion, preserve_scale);
|
||||
if let Some(c) = self.0.lock().ok().and_then(|m| m.get(&key).cloned()) {
|
||||
return c;
|
||||
}
|
||||
let c = Arc::new(Corrected { corr: corrections(path, smooth_frames, no_motion, preserve_scale), _path: path.clone(), worst: Mutex::new(Vec::new()) });
|
||||
if let Ok(mut m) = self.0.lock() {
|
||||
// a keyframed Smoothness makes a new key for every frame: don't keep them all
|
||||
if m.len() >= 16 {
|
||||
m.clear();
|
||||
}
|
||||
m.insert(key, c.clone());
|
||||
}
|
||||
c
|
||||
}
|
||||
}
|
||||
|
||||
/// [`corrections`] for `path`, computed once per set of settings instead of for every rendered
|
||||
/// frame: smoothing the whole path and finding its worst border took 0.8 ms per frame for a 30 s
|
||||
/// clip, 7 ms for 2 minutes and 15 ms for 10 minutes, on every frame of playback and export.
|
||||
pub(crate) fn corrected(path: &Arc<StabilizerPath>, smooth_frames: f64, no_motion: bool, preserve_scale: bool) -> Arc<Corrected> {
|
||||
static CACHE: OnceLock<CorrectedCache> = OnceLock::new();
|
||||
CACHE.get_or_init(Default::default).get(path, smooth_frames, no_motion, preserve_scale)
|
||||
}
|
||||
/// Smallest zoom about the centre (≥ 1) that hides the borders a correction exposes.
|
||||
pub(crate) fn cover_scale(corr: &Affine, w: f64, h: f64, max: f64) -> f64 {
|
||||
let Some(inv) = corr.inverse() else { return 1.0 };
|
||||
@@ -388,14 +488,15 @@ pub fn warp_stabilizer(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
|
||||
let k = ((cx.seconds * path.fps).round().max(0.0) as usize).min(n - 1);
|
||||
let smooth = fv(e, "smoothness", cx) as f64 / 100.0 * path.fps;
|
||||
let crop_less = fv(e, "crop_less", cx) as f64 / 100.0;
|
||||
let corr = corrections(&path, smooth * (0.5 + crop_less), chv(e, "result") == 1, bv(e, "preserve_scale"));
|
||||
let corrected = corrected(&path, smooth * (0.5 + crop_less), chv(e, "result") == 1, bv(e, "preserve_scale"));
|
||||
let corr = &corrected.corr;
|
||||
let framing = chv(e, "framing");
|
||||
let (aw, ah) = (path.w as f64, path.h as f64);
|
||||
let max_scale = (fv(e, "max_scale", cx) as f64 / 100.0).max(1.0);
|
||||
let safe = 1.0 + fv(e, "action_safe", cx) as f64 / 100.0;
|
||||
let extra = fv(e, "additional_scale", cx) as f64 / 100.0;
|
||||
let zoom = match framing {
|
||||
2 => corr.iter().map(|c| cover_scale(c, aw, ah, max_scale)).fold(1.0, f64::max) * safe,
|
||||
2 => corrected.worst_cover(aw, ah, max_scale) * safe,
|
||||
_ => 1.0,
|
||||
} * extra;
|
||||
// analysis px → layer px
|
||||
@@ -410,7 +511,7 @@ pub fn warp_stabilizer(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
|
||||
match framing {
|
||||
1 => {
|
||||
// crop to the area every frame covers
|
||||
let worst = corr.iter().map(|c| cover_scale(c, aw, ah, 4.0)).fold(1.0, f64::max);
|
||||
let worst = corrected.worst_cover(aw, ah, 4.0);
|
||||
let (mx, my) = (lw * (1.0 - 1.0 / worst) / 2.0, lh * (1.0 - 1.0 / worst) / 2.0);
|
||||
crate::effects::crop(img, (mx / lw) as f32, (my / lh) as f32, (mx / lw) as f32, (my / lh) as f32, 0.0);
|
||||
}
|
||||
|
||||
@@ -1056,6 +1056,107 @@ fn warp_stabilizer_removes_camera_jitter() {
|
||||
assert!(stabilizer_path(&env, 0.0, filmcraft_project::TrackMethod::Position, false).is_some());
|
||||
}
|
||||
|
||||
/// #1381: the stabilizer's analysis kept the pyramid of every frame of the clip until all steps
|
||||
/// were known, so a long clip took tens of gigabytes. It now works through the clip a block at a
|
||||
/// time and gives the same steps whatever the block size.
|
||||
mod stabilizer_analysis {
|
||||
use super::super::temporal::{ANALYSIS_BLOCK, chunked_steps};
|
||||
use filmcraft_geom::Affine;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
/// A decoded frame that counts how many exist at once.
|
||||
struct Frame<'a> {
|
||||
id: usize,
|
||||
w: usize,
|
||||
live: &'a AtomicUsize,
|
||||
most: &'a AtomicUsize,
|
||||
}
|
||||
impl<'a> Frame<'a> {
|
||||
fn new(id: usize, w: usize, live: &'a AtomicUsize, most: &'a AtomicUsize) -> Self {
|
||||
most.fetch_max(live.fetch_add(1, Ordering::SeqCst) + 1, Ordering::SeqCst);
|
||||
Frame { id, w, live, most }
|
||||
}
|
||||
}
|
||||
impl Drop for Frame<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.live.fetch_sub(1, Ordering::SeqCst);
|
||||
let _ = self.most;
|
||||
}
|
||||
}
|
||||
|
||||
/// Steps of an `n`-frame clip where frames in `missing` can't be read and frames from
|
||||
/// `narrow_from` on are narrower. Returns the steps, the size, and the most frames alive at once.
|
||||
fn run(n: usize, block: usize, missing: &[usize], narrow_from: usize) -> (Option<(Vec<Affine>, (usize, usize))>, usize) {
|
||||
let (live, most) = (AtomicUsize::new(0), AtomicUsize::new(0));
|
||||
let r = chunked_steps(
|
||||
n,
|
||||
block,
|
||||
|k| (!missing.contains(&k)).then(|| Frame::new(k, if k >= narrow_from { 30 } else { 40 }, &live, &most)),
|
||||
|f: &Frame| (f.w, 10 + f.w),
|
||||
// a step that tells which two frames it joins
|
||||
|a: &Frame, b: &Frame, d| Affine::translate((a.id * 1000 + b.id) as f64, (d.0 * 7 + d.1) as f64),
|
||||
|| false,
|
||||
);
|
||||
assert_eq!(live.load(Ordering::SeqCst), 0, "every frame is dropped");
|
||||
(r, most.load(Ordering::SeqCst))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_steps_do_not_depend_on_the_block_size() {
|
||||
let (reference, _) = run(37, 1000, &[], 1000);
|
||||
let (steps, size) = reference.unwrap();
|
||||
assert_eq!((steps.len(), size), (36, (40, 50)));
|
||||
for block in [1, 2, 3, 5, 8, 36, 37, 38] {
|
||||
let (r, _) = run(37, block, &[], 1000);
|
||||
let (s, d) = r.unwrap();
|
||||
assert_eq!(d, size, "block {block}");
|
||||
assert_eq!(s.len(), steps.len(), "block {block}");
|
||||
assert!(s.iter().zip(&steps).all(|(a, b)| (a.e - b.e).abs() < 1e-12 && (a.f - b.f).abs() < 1e-12), "block {block}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_and_resized_frames_give_identity_steps_and_the_first_size_is_used() {
|
||||
// frames 0..=2 can't be read, so the size comes from frame 3; 6 is unreadable too; from
|
||||
// frame 8 on the frames are narrower than their neighbour
|
||||
let missing = [0, 1, 2, 6];
|
||||
for block in [1, 3, 4, 100] {
|
||||
let (r, _) = run(12, block, &missing, 8);
|
||||
let (steps, size) = r.unwrap();
|
||||
assert_eq!(size, (40, 50), "block {block}");
|
||||
assert_eq!(steps.len(), 11);
|
||||
let identity: Vec<bool> = steps.iter().map(|s| s.e == 0.0 && s.f == 0.0).collect();
|
||||
// steps 0..=2 touch an unreadable frame, 3 and 4 are real, 5 and 6 touch frame 6, 7 joins
|
||||
// frames of different widths, 8..=10 are real again
|
||||
assert_eq!(identity, [true, true, true, false, false, true, true, true, false, false, false], "block {block}: {identity:?}");
|
||||
assert_eq!(steps[3].e, 3004.0, "block {block}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clip_with_no_readable_frame_has_no_analysis() {
|
||||
let (r, _) = run(5, 2, &[0, 1, 2, 3, 4], 100);
|
||||
assert!(r.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_block_of_frames_is_alive_at_once() {
|
||||
let (r, most) = run(2_000, ANALYSIS_BLOCK, &[], 100_000);
|
||||
assert_eq!(r.unwrap().0.len(), 1_999);
|
||||
assert!(most <= ANALYSIS_BLOCK + 1, "{most} frames alive at once for a block of {ANALYSIS_BLOCK}");
|
||||
// reading every frame first, as the analysis did, would have held all of them
|
||||
let (_, all) = run(300, 300, &[], 100_000);
|
||||
assert!(all > 250, "{all}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_stops_the_analysis() {
|
||||
let calls = AtomicUsize::new(0);
|
||||
let r = chunked_steps(1_000, 10, Some, |_: &usize| (1, 1), |_: &usize, _: &usize, _| Affine::IDENTITY, || calls.fetch_add(1, Ordering::SeqCst) >= 12);
|
||||
assert!(r.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_reframe_follows_the_subject() {
|
||||
fn subject(x: f64, y: f64, _t: f64) -> [f32; 3] {
|
||||
@@ -1191,3 +1292,75 @@ fn transform_shutter_uses_the_sequence_frame_rate_without_env() {
|
||||
assert!(delta(frame) > 1e-3, "frame {frame} moves but is sharp");
|
||||
}
|
||||
}
|
||||
|
||||
/// The corrections of an analysed path are worked out once per set of settings, not for every
|
||||
/// rendered frame: smoothing the whole path took 0.8 ms per frame for a 30 s clip, 7 ms for 2
|
||||
/// minutes and 15 ms for 10 minutes.
|
||||
mod stabilizer_corrections_cache {
|
||||
use super::super::temporal::{CorrectedCache, StabilizerPath, corrections, cover_scale};
|
||||
use filmcraft_geom::Affine;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn path(n: usize, seed: usize) -> Arc<StabilizerPath> {
|
||||
// a shaky path: small steps with a drift and some turning, so the borders do get exposed
|
||||
let steps = (0..n - 1)
|
||||
.map(|k| {
|
||||
let j = (k * 7919 + seed) % 13;
|
||||
Affine::translate(j as f64 * 0.4 - 2.0, ((k * 104_729 + seed) % 11) as f64 * 0.3 - 1.4)
|
||||
})
|
||||
.collect();
|
||||
Arc::new(StabilizerPath { w: 480, h: 270, steps, fps: 30.0 })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_path_and_settings_are_worked_out_once_and_match_the_direct_result() {
|
||||
let p = path(400, 1);
|
||||
let cache = CorrectedCache::default();
|
||||
let a = cache.get(&p, 15.0, false, false);
|
||||
let b = cache.get(&p, 15.0, false, false);
|
||||
assert!(Arc::ptr_eq(&a, &b), "a second frame reuses the first frame's corrections");
|
||||
let direct = corrections(&p, 15.0, false, false);
|
||||
assert_eq!(a.corr.len(), direct.len());
|
||||
assert!(a.corr.iter().zip(&direct).all(|(x, y)| (x.a, x.b, x.c, x.d, x.e, x.f) == (y.a, y.b, y.c, y.d, y.e, y.f)), "same numbers as `corrections`");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_settings_or_another_path_get_their_own_corrections() {
|
||||
let p = path(300, 2);
|
||||
let cache = CorrectedCache::default();
|
||||
let base = cache.get(&p, 15.0, false, false);
|
||||
for other in [
|
||||
cache.get(&p, 16.0, false, false),
|
||||
cache.get(&p, 15.0, true, false),
|
||||
cache.get(&p, 15.0, false, true),
|
||||
cache.get(&path(300, 2), 15.0, false, false),
|
||||
] {
|
||||
assert!(!Arc::ptr_eq(&base, &other));
|
||||
}
|
||||
assert!(Arc::ptr_eq(&base, &cache.get(&p, 15.0, false, false)), "and the first set is still there");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_worst_border_matches_a_direct_scan_for_each_zoom_limit() {
|
||||
let p = path(500, 3);
|
||||
let c = CorrectedCache::default().get(&p, 4.0, false, false);
|
||||
for max in [1.0, 1.2, 1.5, 4.0] {
|
||||
let direct = c.corr.iter().map(|x| cover_scale(x, 480.0, 270.0, max)).fold(1.0, f64::max);
|
||||
assert_eq!(c.worst_cover(480.0, 270.0, max), direct, "max {max}");
|
||||
assert_eq!(c.worst_cover(480.0, 270.0, max), direct, "max {max}, again");
|
||||
}
|
||||
assert!(c.worst_cover(480.0, 270.0, 4.0) > 1.0, "the test path does expose borders");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_animated_smoothness_does_not_grow_the_cache_without_bound() {
|
||||
let p = path(120, 4);
|
||||
let cache = CorrectedCache::default();
|
||||
for k in 0..100 {
|
||||
let smooth = 5.0 + k as f64 * 0.1;
|
||||
let c = cache.get(&p, smooth, false, false);
|
||||
let direct = corrections(&p, smooth, false, false);
|
||||
assert_eq!(c.corr[60].e, direct[60].e, "smooth {smooth}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -813,10 +813,15 @@ pub fn cli_main() {
|
||||
// Live play (effects rendered per frame), render previews, then play twice.
|
||||
reports.push(bench.play(&s, seq, &mut display, *scale, Tick::ZERO, &tag("live")));
|
||||
let t0 = Instant::now();
|
||||
s.execute("sequence.renderEffectsInToOut", json!({"wait": true})).expect("render previews");
|
||||
eprintln!(" rendered previews in {:.1}s (load {})", t0.elapsed().as_secs_f64(), load_avg());
|
||||
reports.push(bench.play(&s, seq, &mut display, *scale, Tick::ZERO, &tag("1st after")));
|
||||
reports.push(bench.play(&s, seq, &mut display, *scale, Tick::ZERO, &tag("2nd after")));
|
||||
// every segment, not only red ones: the bench compares playing previews with playing live
|
||||
match s.execute("sequence.renderInToOut", json!({"wait": true})) {
|
||||
Ok(_) => {
|
||||
eprintln!(" rendered previews in {:.1}s (load {})", t0.elapsed().as_secs_f64(), load_avg());
|
||||
reports.push(bench.play(&s, seq, &mut display, *scale, Tick::ZERO, &tag("1st after")));
|
||||
reports.push(bench.play(&s, seq, &mut display, *scale, Tick::ZERO, &tag("2nd after")));
|
||||
}
|
||||
Err(e) => eprintln!(" rendering previews failed, so no runs after it: {e}"),
|
||||
}
|
||||
s.previews.delete(None);
|
||||
}
|
||||
_ => reports.push(bench.play(&s, seq, &mut display, *scale, Tick::ZERO, &tag("play"))),
|
||||
|
||||
@@ -271,6 +271,9 @@ impl FilmcraftApp {
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs(1));
|
||||
}
|
||||
self.sync_pool();
|
||||
// a frame server rebuilt by sync_pool (another project opened) gets its wake-up now: no
|
||||
// poll asks for the next frame, so its finished frames would wait for the mouse (#2438 review)
|
||||
self.frames.set_context(ctx);
|
||||
updates_ui::tick(self, ctx);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.tick_media_refresh(ctx, now);
|
||||
@@ -396,7 +399,10 @@ impl FilmcraftApp {
|
||||
if !self.synthetic.is_empty() || self.synthetic_pending_step.is_some() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
if self.frames.queue_len() > 0 {
|
||||
// Only the web renders queued frames in the UI pass (`pump`): poll for them there. Native
|
||||
// workers wake the UI when a frame is done, so a backlog (thumbnails after launch, slow
|
||||
// effects) doesn't run empty passes at 60 Hz beside them (#1142, #1606).
|
||||
if cfg!(target_arch = "wasm32") && self.frames.queue_len() > 0 {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(16));
|
||||
}
|
||||
if !ctx.will_discard() {
|
||||
|
||||
@@ -441,6 +441,20 @@ pub const PREROLL_FRAMES: i64 = 6;
|
||||
pub const SCRUB_KEEP_S: f64 = 2.0;
|
||||
pub const PREROLL_TIMEOUT_S: f64 = 0.5;
|
||||
|
||||
/// How many revisions behind the project a playing monitor's picture may be while the frame due
|
||||
/// is not ready at the current one (#1673). A value dragged during playback makes a revision per
|
||||
/// UI pass, so no frame was ever ready at the current revision and the picture froze until the
|
||||
/// mouse rested; frames of the last few changes are what the monitor shows meanwhile.
|
||||
pub const PLAYING_REVISION_LAG: u64 = 64;
|
||||
|
||||
/// The frames [`FrameServer::recent`] looks for, in order: nearest frame first (`key.frame`, then
|
||||
/// the ones already played: lower frames, or higher ones when `dir` < 0), newest revision first.
|
||||
fn recent_keys(key: FrameKey, dir: i64, max_back: i64, lag: u64) -> impl Iterator<Item = FrameKey> {
|
||||
let back = if dir < 0 { -1 } else { 1 };
|
||||
(0..=max_back.max(0))
|
||||
.flat_map(move |d| (0..=lag.min(key.revision)).map(move |r| FrameKey { frame: key.frame - d * back, revision: key.revision - r, ..key }))
|
||||
}
|
||||
|
||||
/// Which frames playback asks for, given the per-frame render cost (s), the displayed frame rate
|
||||
/// and the worker count: `(lead, stride)`. While the workers keep up, every frame from the
|
||||
/// playhead on (`(1, 1)`). When they cannot (CPU effects slower than real time), `lead` is the
|
||||
@@ -517,6 +531,11 @@ impl FrameServer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether finished jobs wake the UI ([`Self::set_context`] has run).
|
||||
pub fn wakes_ui(&self) -> bool {
|
||||
self.repaint.lock().unwrap_or_else(|e| e.into_inner()).is_some()
|
||||
}
|
||||
|
||||
/// Collect a [`JobRecord`] per finished job (benchmarks); `take_records` drains them.
|
||||
pub fn set_profiling(&self, on: bool) {
|
||||
self.shared.profiling.store(on, Ordering::Relaxed);
|
||||
@@ -577,6 +596,24 @@ impl FrameServer {
|
||||
})
|
||||
}
|
||||
|
||||
/// While playing: the cached frame closest to `key.frame` on the side already played (within
|
||||
/// `max_back` frames; `dir` -1 plays in reverse, so higher frames lie behind) at the newest
|
||||
/// revision from `key.revision` back to `lag` revisions before it, image or plan as
|
||||
/// `key.target` renders (#1673). `None` when there is none.
|
||||
pub fn recent(&self, key: FrameKey, dir: i64, max_back: i64, lag: u64) -> Option<FrameKey> {
|
||||
let keys = recent_keys(key, dir, max_back, lag);
|
||||
match key.target {
|
||||
Target::SequencePlan(_) => {
|
||||
let g = self.shared.plans.lock().unwrap_or_else(|e| e.into_inner());
|
||||
keys.into_iter().find(|k| g.map.contains_key(k))
|
||||
}
|
||||
_ => {
|
||||
let c = self.shared.done.lock().unwrap_or_else(|e| e.into_inner());
|
||||
keys.into_iter().find(|k| c.map.contains_key(k))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue a job unless it is cached, queued or in flight.
|
||||
///
|
||||
/// The frame on screen (`prio` 0: scrubbing, a seek) treats frames more than
|
||||
@@ -681,7 +718,14 @@ impl FrameServer {
|
||||
}
|
||||
let (target, rev, cur) = (key.target, key.revision, key.frame);
|
||||
let span = (lead + ahead * stride) * step + 26;
|
||||
self.retain_queue(|k| k.target != target || (k.revision == rev && (k.frame - cur) * dir >= 0 && (k.frame - cur).abs() < span));
|
||||
let ahead_of = |k: &FrameKey| (k.frame - cur) * dir >= 0 && (k.frame - cur).abs() < span;
|
||||
// Queued jobs of an older revision are dropped, but one already rendering a frame still
|
||||
// ahead is left to finish (#1673): a value dragged during playback asks for a new
|
||||
// revision on every pass, and cancelling those jobs each time left nothing ever ready.
|
||||
self.retain_jobs(
|
||||
|k| k.target != target || (k.revision == rev && ahead_of(k)),
|
||||
|k| k.target != target || (k.revision.saturating_add(PLAYING_REVISION_LAG) >= rev && k.revision <= rev && ahead_of(k)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether playback can start at `key`: the next [`PREROLL_FRAMES`] frames (up to `last`)
|
||||
@@ -700,9 +744,14 @@ impl FrameServer {
|
||||
/// Jobs already running for such frames are cancelled: a worker blocked behind a decoder
|
||||
/// would otherwise go on to decode a frame playback has passed, possibly from its keyframe.
|
||||
pub fn retain_queue(&self, keep: impl Fn(&FrameKey) -> bool) {
|
||||
self.shared.queue.lock().unwrap_or_else(PoisonError::into_inner).retain(|j| keep(&j.key));
|
||||
self.retain_jobs(&keep, &keep);
|
||||
}
|
||||
|
||||
/// [`Self::retain_queue`] with one test for queued jobs and another for running ones.
|
||||
fn retain_jobs(&self, keep_queued: impl Fn(&FrameKey) -> bool, keep_running: impl Fn(&FrameKey) -> bool) {
|
||||
self.shared.queue.lock().unwrap_or_else(PoisonError::into_inner).retain(|j| keep_queued(&j.key));
|
||||
for (k, c, _) in self.shared.in_flight.lock().unwrap_or_else(PoisonError::into_inner).iter() {
|
||||
if !keep(k) {
|
||||
if !keep_running(k) {
|
||||
c.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
@@ -1087,7 +1136,7 @@ fn render_job(job: &Job, pool: &Arc<MediaPool>, services: &Arc<dyn Services>, pr
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{PlaybackMeter, Target, background_work, playback_plan};
|
||||
use super::{FrameKey, PlaybackMeter, Target, background_work, playback_plan, recent_keys};
|
||||
use filmcraft_project::ItemId;
|
||||
|
||||
#[test]
|
||||
@@ -1134,6 +1183,18 @@ mod tests {
|
||||
assert!(!filmcraft_engine::autosave::Preferences::default().playback.draft_decode, "off by default");
|
||||
}
|
||||
|
||||
/// #1673: the recent-frame fallback looks at frames already played, newest revision first.
|
||||
#[test]
|
||||
fn recent_frames_lie_on_the_played_side() {
|
||||
let key = FrameKey { target: Target::Sequence(ItemId(1)), frame: 10, size: 1000, revision: 5, draft: false };
|
||||
let fwd: Vec<(i64, u64)> = recent_keys(key, 1, 2, 1).map(|k| (k.frame, k.revision)).collect();
|
||||
assert_eq!(fwd, [(10, 5), (10, 4), (9, 5), (9, 4), (8, 5), (8, 4)]);
|
||||
let rev: Vec<(i64, u64)> = recent_keys(key, -1, 2, 1).map(|k| (k.frame, k.revision)).collect();
|
||||
assert_eq!(rev, [(10, 5), (10, 4), (11, 5), (11, 4), (12, 5), (12, 4)]);
|
||||
// no revision before the first
|
||||
assert_eq!(recent_keys(FrameKey { revision: 0, ..key }, 1, 0, 64).count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_plan_skips_frames_only_when_workers_cannot_keep_up() {
|
||||
// decode-only / light frames: every frame from the playhead on
|
||||
|
||||
@@ -1026,7 +1026,6 @@
|
||||
Eyedropper (pick a color from the Program monitor) Pipette (Farbe aus dem Programmmonitor aufnehmen)
|
||||
Eyedropper: no picture to sample Pipette: kein Bild zum Aufnehmen
|
||||
Add/Remove Keyframe Keyframe hinzufügen/entfernen
|
||||
Temporal Interpolation Zeitliche Interpolation
|
||||
Linear Linear
|
||||
Bezier Bézier
|
||||
Auto Bezier Automatische Bézier
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 45.
|
@@ -1002,7 +1002,6 @@
|
||||
Eyedropper (pick a color from the Program monitor) Cuentagotas (tomar un color del monitor de programa)
|
||||
Eyedropper: no picture to sample Cuentagotas: no hay imagen de la que tomar el color
|
||||
Add/Remove Keyframe Añadir/quitar fotograma clave
|
||||
Temporal Interpolation Interpolación temporal
|
||||
Linear Lineal
|
||||
Bezier Bézier
|
||||
Auto Bezier Bézier automático
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 3 and column 95.
|
@@ -994,7 +994,6 @@
|
||||
Eyedropper (pick a color from the Program monitor) Pipette (prélever une couleur dans le moniteur programme)
|
||||
Eyedropper: no picture to sample Pipette : aucune image à échantillonner
|
||||
Add/Remove Keyframe Ajouter/supprimer une image clé
|
||||
Temporal Interpolation Interpolation temporelle
|
||||
Linear Linéaire
|
||||
Bezier Bézier
|
||||
Auto Bezier Bézier auto
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 83.
|
@@ -999,7 +999,6 @@
|
||||
Eyedropper (pick a color from the Program monitor) Pipet (ambil warna dari monitor Program)
|
||||
Eyedropper: no picture to sample Pipet: tidak ada gambar untuk diambil sampelnya
|
||||
Add/Remove Keyframe Tambah/Hapus Bingkai Kunci
|
||||
Temporal Interpolation Interpolasi Temporal
|
||||
Linear Linier
|
||||
Bezier Bezier
|
||||
Auto Bezier Bezier Otomatis
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 14.
|
@@ -991,7 +991,6 @@
|
||||
Eyedropper (pick a color from the Program monitor) Contagocce (preleva un colore dal monitor programma)
|
||||
Eyedropper: no picture to sample Contagocce: nessuna immagine da campionare
|
||||
Add/Remove Keyframe Aggiungi/rimuovi fotogramma chiave
|
||||
Temporal Interpolation Interpolazione temporale
|
||||
Linear Lineare
|
||||
Bezier Bezier
|
||||
Auto Bezier Bezier automatica
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 3 and column 95.
|
@@ -1001,7 +1001,6 @@
|
||||
Show graphs グラフを表示
|
||||
Toggle animation アニメーションのオン / オフを切り替え
|
||||
Add/Remove Keyframe キーフレームを追加/削除
|
||||
Temporal Interpolation 時間補間
|
||||
Linear リニア
|
||||
Bezier ベジェ
|
||||
Auto Bezier 自動ベジェ
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 59.
|
@@ -3122,7 +3122,6 @@
|
||||
Temperature 색온도
|
||||
Template Name: 템플릿 이름:
|
||||
Template Properties 템플릿 속성
|
||||
Temporal Interpolation 시간 보간
|
||||
Text 텍스트
|
||||
Text (graphics and captions) 텍스트(그래픽 및 자막)
|
||||
Text Animator 텍스트 애니메이터
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2818 and column 7.
|
@@ -996,7 +996,6 @@
|
||||
Eyedropper (pick a color from the Program monitor) Conta-gotas (capturar uma cor do monitor do programa)
|
||||
Eyedropper: no picture to sample Conta-gotas: nenhuma imagem para amostrar
|
||||
Add/Remove Keyframe Adicionar/remover quadro-chave
|
||||
Temporal Interpolation Interpolação temporal
|
||||
Linear Linear
|
||||
Bezier Bézier
|
||||
Auto Bezier Bézier automático
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 4.
|
@@ -990,7 +990,6 @@
|
||||
Show graphs 显示图表
|
||||
Toggle animation 切换动画
|
||||
Add/Remove Keyframe 添加/移除关键帧
|
||||
Temporal Interpolation 时间插值
|
||||
Linear 线性
|
||||
Bezier 贝塞尔
|
||||
Auto Bezier 自动贝塞尔
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 3 and column 95.
|
@@ -1154,6 +1154,12 @@ pub fn label_color(labels: &filmcraft_engine::settings::LabelPrefs, l: filmcraft
|
||||
egui::Color32::from_rgb(r, g, b)
|
||||
}
|
||||
|
||||
/// The left gutter of a menu row that can carry a checkmark or a label swatch: the label is pushed
|
||||
/// right by these spaces and the mark is painted in the room they leave, centred 10 points from the
|
||||
/// row's left edge. The two agree at the default font and size; one constant keeps every gutter
|
||||
/// (menu bar, right-click menus) the same width.
|
||||
const GUTTER: &str = " ";
|
||||
|
||||
/// A label choice in a right-click Label menu (Project panel items, Timeline clips; #786), drawn
|
||||
/// as Edit ▸ Label draws it: the user's name for the label with a swatch of its colour in the
|
||||
/// left gutter. Registered for automation as `<prefix>.<Label>` (the label's built-in name).
|
||||
@@ -1166,12 +1172,20 @@ pub fn label_choice(
|
||||
) -> egui::Response {
|
||||
let name = labels.name(l);
|
||||
let text = crate::i18n::t(&name);
|
||||
let r = ui.button(format!(" {text}"));
|
||||
let r = ui.button(format!("{GUTTER}{text}"));
|
||||
auto.add(&format!("{prefix}.{}", l.name()), r.rect, text);
|
||||
paint_swatch(ui, r.rect, label_color(labels, l));
|
||||
r
|
||||
}
|
||||
|
||||
/// A checkmark in the left gutter of the menu row `row`.
|
||||
fn paint_check(ui: &egui::Ui, row: egui::Rect) {
|
||||
let c = row.left_center() + egui::vec2(10.0, 0.0);
|
||||
let st = egui::Stroke::new(1.5, ui.visuals().text_color());
|
||||
ui.painter().line_segment([c + egui::vec2(-4.0, 0.0), c + egui::vec2(-1.0, 3.0)], st);
|
||||
ui.painter().line_segment([c + egui::vec2(-1.0, 3.0), c + egui::vec2(4.5, -3.5)], st);
|
||||
}
|
||||
|
||||
/// A label swatch in the left gutter of the menu row `row`.
|
||||
fn paint_swatch(ui: &egui::Ui, row: egui::Rect, color: egui::Color32) {
|
||||
let rect = egui::Rect::from_center_size(row.left_center() + egui::vec2(10.0, 0.0), egui::vec2(12.0, 12.0));
|
||||
@@ -1182,7 +1196,7 @@ fn paint_swatch(ui: &egui::Ui, row: egui::Rect, color: egui::Color32) {
|
||||
fn menu_entry(ui: &mut egui::Ui, it: &MenuItem, auto: &mut crate::automation::Registry, labels: &filmcraft_engine::settings::LabelPrefs) -> bool {
|
||||
// Checkmarks and label swatches each occupy the same left gutter.
|
||||
let swatch = label_menu_swatch(&it.id, labels);
|
||||
let label = if it.checked.is_some() || swatch.is_some() { format!(" {}", it.label) } else { it.label.clone() };
|
||||
let label = if it.checked.is_some() || swatch.is_some() { format!("{GUTTER}{}", it.label) } else { it.label.clone() };
|
||||
let mut b = egui::Button::new(label);
|
||||
if let Some(s) = &it.shortcut {
|
||||
b = b.shortcut_text(shortcut_text(s));
|
||||
@@ -1197,15 +1211,27 @@ fn menu_entry(ui: &mut egui::Ui, it: &MenuItem, auto: &mut crate::automation::Re
|
||||
paint_swatch(ui, r.rect, color);
|
||||
}
|
||||
if it.checked == Some(true) {
|
||||
let c = r.rect.left_center() + egui::vec2(10.0, 0.0);
|
||||
let col = ui.visuals().text_color();
|
||||
let st = egui::Stroke::new(1.5, col);
|
||||
ui.painter().line_segment([c + egui::vec2(-4.0, 0.0), c + egui::vec2(-1.0, 3.0)], st);
|
||||
ui.painter().line_segment([c + egui::vec2(-1.0, 3.0), c + egui::vec2(4.5, -3.5)], st);
|
||||
paint_check(ui, r.rect);
|
||||
}
|
||||
r.clicked()
|
||||
}
|
||||
|
||||
/// A row of a right-click menu with the menu bar's checkmark gutter, so it lines up with the
|
||||
/// checkable rows around it. `checked` is as in [`MenuItem::checked`]: `None` is a command,
|
||||
/// reported to accessibility as a plain button; `Some` is a checkable row, reported with its state
|
||||
/// and showing the checkmark when true.
|
||||
pub fn checked_entry(ui: &mut egui::Ui, label: &str, enabled: bool, checked: Option<bool>) -> egui::Response {
|
||||
let r = ui.add_enabled(enabled, egui::Button::new(format!("{GUTTER}{label}")));
|
||||
r.widget_info(|| match checked {
|
||||
Some(checked) => egui::WidgetInfo::selected(egui::WidgetType::Button, enabled, checked, label),
|
||||
None => egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, label),
|
||||
});
|
||||
if checked == Some(true) {
|
||||
paint_check(ui, r.rect);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod parse_shortcut_tests {
|
||||
use super::{label_menu_swatch, parse_shortcut};
|
||||
|
||||
@@ -69,7 +69,7 @@ fn resolve(app: &FilmcraftApp, target: &FxTarget) -> Option<(EffectInstance, Tic
|
||||
let (tid, it) = seq.find_item(ClipId(*clip))?;
|
||||
let e = it.effects.get(*index)?.clone();
|
||||
let track = seq.track(tid).map(|t| t.name.clone()).unwrap_or_default();
|
||||
let mt = it.source_time_at(ph.clamp(it.start, it.end() - Tick(1)));
|
||||
let mt = it.effect_time_at(ph.clamp(it.start, it.end() - Tick(1)));
|
||||
Some((e, mt, format!("{track}, {}", it.name)))
|
||||
}
|
||||
FxTarget::Insert { strip, slot } => {
|
||||
|
||||
@@ -250,7 +250,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
app.auto.add("effectControls.graphicTimeHint", ir, hint);
|
||||
}
|
||||
let mt_now = it.source_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start)));
|
||||
let mt_now = it.effect_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start)));
|
||||
// Premiere lists the fixed effects (Motion, Opacity, Time Remapping / Volume…) first.
|
||||
let mut order: Vec<usize> = (0..it.effects.len()).collect();
|
||||
order.sort_by_key(|i| !it.effects[*i].def().is_some_and(|d| d.intrinsic));
|
||||
@@ -702,7 +702,7 @@ pub(crate) fn param_row(
|
||||
actions.push(("playhead.set".into(), json!({"time": tl.0})));
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
keyframe_menu(app, ui, KeyframeRef { clip: clip.0, effect: idx, param: pd.id.to_string(), mask, time: k.time }, actions);
|
||||
keyframe_menu(app, ui, KeyframeRef { clip: clip.0, effect: idx, param: pd.id.to_string(), mask, time: k.time }, k.interp, actions);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -860,6 +860,35 @@ fn param_value_widget(
|
||||
vui.data_mut(|d| d.remove::<String>(id));
|
||||
}
|
||||
}
|
||||
// Lumetri's Input LUT / Look LUT: the Lumetri Color panel's menu (it showed only the name, #1123)
|
||||
(ParamKind::Text, ParamValue::Text(cur)) => {
|
||||
let input = pd.id == "input_lut";
|
||||
let auto_id = format!("effectControls.{}.{}", e.effect, pkey);
|
||||
let (rect, pick) = crate::panels::lumetri::lut_menu(vui, app, ("fx-lut", clip.0, idx, pkey), cur, input, 160.0, &auto_id);
|
||||
app.auto.add(&auto_id, rect, pd.label);
|
||||
match pick {
|
||||
Some(crate::panels::lumetri::LutPick::Lut(r)) => set = Some(json!(r)),
|
||||
Some(crate::panels::lumetri::LutPick::Browse) => {
|
||||
let (param, clip, mask, label) = (pd.id.to_string(), clip.0, mask, pd.label);
|
||||
app.pick_ui(crate::native_dialogs::FileDialog::open_file(tl!("LUT"), &["cube", "3dl"]), move |app, paths| {
|
||||
let Some(path) = paths.into_iter().next() else { return };
|
||||
// importing into the LUT library and setting the parameter undo as one step
|
||||
let r = app.session.edit_group(label, |s| {
|
||||
let r = s.execute("lut.import", json!({"path": path}))?["ref"].clone();
|
||||
let mut p = json!({"clip": clip, "effect": idx, "param": param, "value": r});
|
||||
if let Some(k) = mask {
|
||||
p["mask"] = json!(k);
|
||||
}
|
||||
s.execute("effects.setParam", p)
|
||||
});
|
||||
if let Err(err) = r {
|
||||
app.ui.status = err.to_string();
|
||||
}
|
||||
});
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
vui.label(param_text(&app.session.project, pd.id, value));
|
||||
}
|
||||
@@ -868,37 +897,51 @@ fn param_value_widget(
|
||||
}
|
||||
|
||||
/// Shared by the lane diamonds and graph points: commands use media time and preserve the mask.
|
||||
fn keyframe_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, target: KeyframeRef, actions: &mut Vec<(String, Value)>) {
|
||||
/// The rows and their order are Premiere's (Cut, Copy and Paste have no keyframe command yet, and
|
||||
/// Select All waits for Clear to act on the whole selection); `current` is the keyframe's
|
||||
/// interpolation, the checked row.
|
||||
fn keyframe_menu(
|
||||
app: &mut FilmcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
target: KeyframeRef,
|
||||
current: filmcraft_project::Interpolation,
|
||||
actions: &mut Vec<(String, Value)>,
|
||||
) {
|
||||
use filmcraft_project::Interpolation;
|
||||
let mut params = json!({"clip": target.clip, "effect": target.effect, "param": target.param, "mediaTime": target.time.0});
|
||||
if let Some(mask) = target.mask {
|
||||
params["mask"] = json!(mask);
|
||||
}
|
||||
ui.label(egui::RichText::new(tl!("Temporal Interpolation")).color(app.tokens.text_dim));
|
||||
for (label, key) in [
|
||||
(tl!("Linear"), "linear"),
|
||||
(tl!("Bezier"), "bezier"),
|
||||
(tl!("Auto Bezier"), "autoBezier"),
|
||||
(tl!("Continuous Bezier"), "continuousBezier"),
|
||||
(tl!("Hold"), "hold"),
|
||||
(tl!("Ease In"), "easeIn"),
|
||||
(tl!("Ease Out"), "easeOut"),
|
||||
] {
|
||||
let response = ui.button(label);
|
||||
app.auto.add(&format!("effectControls.keyframeMenu.{key}"), response.rect, label);
|
||||
if response.clicked() {
|
||||
let mut params = params.clone();
|
||||
params["interpolation"] = json!(key);
|
||||
actions.push(("effects.setInterpolation".into(), params));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
let response = ui.button(tl!("Clear"));
|
||||
// A selected keyframe stands for the whole selection, as when dragging it (#1916).
|
||||
let interpolated = if app.ui.keyframe_selection.contains(&target) { json!({"keyframes": app.ui.keyframe_selection}) } else { params.clone() };
|
||||
let response = crate::menus::checked_entry(ui, tl!("Clear"), true, None);
|
||||
app.auto.add("effectControls.keyframeMenu.clear", response.rect, "Clear");
|
||||
if response.clicked() {
|
||||
actions.push(("effects.deleteKeyframe".into(), params));
|
||||
actions.push(("effects.deleteKeyframe".into(), params.clone()));
|
||||
ui.close();
|
||||
}
|
||||
for group in [
|
||||
&[
|
||||
(tl!("Linear"), "linear", Interpolation::Linear),
|
||||
(tl!("Bezier"), "bezier", Interpolation::Bezier),
|
||||
(tl!("Auto Bezier"), "autoBezier", Interpolation::AutoBezier),
|
||||
(tl!("Continuous Bezier"), "continuousBezier", Interpolation::ContinuousBezier),
|
||||
(tl!("Hold"), "hold", Interpolation::Hold),
|
||||
][..],
|
||||
&[(tl!("Ease In"), "easeIn", Interpolation::EaseIn), (tl!("Ease Out"), "easeOut", Interpolation::EaseOut)],
|
||||
] {
|
||||
ui.separator();
|
||||
for &(label, key, interpolation) in group {
|
||||
let response = crate::menus::checked_entry(ui, label, true, Some(current == interpolation));
|
||||
app.auto.add(&format!("effectControls.keyframeMenu.{key}"), response.rect, label);
|
||||
if response.clicked() {
|
||||
let mut params = interpolated.clone();
|
||||
params["interpolation"] = json!(key);
|
||||
actions.push(("effects.setInterpolation".into(), params));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lumetri Color panel: edits (or adds) the Lumetri effect on the selected clip, grouped by section.
|
||||
@@ -920,7 +963,7 @@ pub fn lumetri_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let e = it.effects[idx].clone();
|
||||
let Some(def) = e.def() else { return };
|
||||
let ph = app.session.playhead();
|
||||
let mt = it.source_time_at(ph.clamp(it.start, it.end() - Tick(1)));
|
||||
let mt = it.effect_time_at(ph.clamp(it.start, it.end() - Tick(1)));
|
||||
let mut actions = Vec::new();
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(&mut bui, |ui| {
|
||||
let mut groups: Vec<&str> = Vec::new();
|
||||
@@ -991,7 +1034,7 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
return;
|
||||
};
|
||||
let ph = app.session.playhead();
|
||||
let mt = it.source_time_at(ph.clamp(it.start, it.end() - Tick(1)));
|
||||
let mt = it.effect_time_at(ph.clamp(it.start, it.end() - Tick(1)));
|
||||
let mut actions: Vec<(String, Value)> = Vec::new();
|
||||
let mut bui = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(14.0, 8.0))).id_salt("props"));
|
||||
// header: clip name + menu
|
||||
@@ -1145,7 +1188,7 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
&& section(bui, app, "Audio", it.effects.iter().position(|e| e.effect == "volume").map(|i| (i, clip.0)), &mut actions)
|
||||
{
|
||||
let clip = *clip;
|
||||
let mt = it.source_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start)));
|
||||
let mt = it.effect_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start)));
|
||||
let val = |eid: &str, p: &str| it.effect(eid).and_then(|e| e.param(p)).map(|p| p.value_at(mt));
|
||||
let r = row(bui, tl!("Level"));
|
||||
let mut vui = bui.new_child(
|
||||
@@ -1411,7 +1454,7 @@ fn draw_graph_keyframes(
|
||||
let r = Rect::from_center_size(c, vec2(10.0, 10.0));
|
||||
let resp = ui.interact(r.expand(2.0), id, Sense::click_and_drag());
|
||||
resp.context_menu(|ui| {
|
||||
keyframe_menu(app, ui, KeyframeRef { clip: clip.0, effect: idx, param: pd.id.to_string(), mask, time: k.time }, actions);
|
||||
keyframe_menu(app, ui, KeyframeRef { clip: clip.0, effect: idx, param: pd.id.to_string(), mask, time: k.time }, k.interp, actions);
|
||||
});
|
||||
let auto_id = match (comps.len(), ci) {
|
||||
(1, _) => format!("effectControls.{}.graph.keyframe.{}", pd.id, k.time.0),
|
||||
@@ -1865,3 +1908,100 @@ mod graph_drag_tests {
|
||||
assert_eq!(changed.keyframes[1], before.keyframes[1]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod frame_hold_tests {
|
||||
//! #1675: on a Frame Hold clip with Hold Filters off the effects keep animating through the
|
||||
//! hold, so the panels that show effect values read them where the render does, as Effect
|
||||
//! Controls does, and not on the held frame.
|
||||
use super::*;
|
||||
use crate::panels::audio_fx_editor::FxTarget;
|
||||
use filmcraft_geom::Vec2;
|
||||
use filmcraft_project::{Keyframe, Param};
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Panel {
|
||||
Lumetri,
|
||||
Masks,
|
||||
TransformHandles,
|
||||
AudioFxEditor,
|
||||
}
|
||||
|
||||
fn animate(it: &mut TrackItem, effect: &str, param: &str, from: ParamValue, to: ParamValue) {
|
||||
let (a, b) = (it.moving_source_time_at(it.start), it.moving_source_time_at(it.end() - Tick(1)));
|
||||
let e = it.effects.iter_mut().find(|e| e.effect == effect).unwrap();
|
||||
e.params.insert(param.into(), Param { value: from.clone(), keyframes: vec![Keyframe::new(a, from), Keyframe::new(b, to)] });
|
||||
}
|
||||
|
||||
/// A V1 clip and an A1 clip under the playhead, their effects animated from
|
||||
/// their In to their Out, each held on its In (`Some(hold_filters)`) or not held.
|
||||
fn app(hold: Option<bool>) -> FilmcraftApp {
|
||||
let mut s = filmcraft_engine::Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
let v = s.active_sequence().unwrap().video_tracks[0].items[0].clone();
|
||||
// away from the In, where the held and the moving times meet
|
||||
let ph = v.start + Tick(v.duration.0 / 2);
|
||||
s.execute("playhead.set", json!({"time": ph.0})).unwrap();
|
||||
let a = s.active_sequence().unwrap().audio_tracks.iter().find_map(|t| t.item_at(ph)).unwrap().id;
|
||||
s.execute("effects.apply", json!({"clips": [v.id.0], "effect": "lumetri"})).unwrap();
|
||||
s.execute("effects.apply", json!({"clips": [a.0], "effect": "parametric_eq"})).unwrap();
|
||||
s.execute("timeline.select", json!({"clips": [v.id.0]})).unwrap();
|
||||
s.execute("masks.add", json!({"clip": v.id.0, "effect": "opacity", "shape": "ellipse"})).unwrap();
|
||||
s.execute("effects.select", json!({"clip": v.id.0, "effect": "motion"})).unwrap();
|
||||
if let Some(hold_filters) = hold {
|
||||
s.execute("clip.frameHoldOptions", json!({"clips": [v.id.0], "holdOn": "in", "holdFilters": hold_filters})).unwrap();
|
||||
}
|
||||
let seq = s.state.active_sequence.unwrap();
|
||||
let q = std::sync::Arc::make_mut(&mut s.project).sequence_mut(seq).unwrap();
|
||||
let it = q.find_item_mut(v.id).unwrap().1;
|
||||
animate(it, "motion", "position", ParamValue::Vec2(Vec2::new(400.0, 300.0)), ParamValue::Vec2(Vec2::new(800.0, 500.0)));
|
||||
animate(it, "lumetri", "saturation", ParamValue::Float(50.0), ParamValue::Float(150.0));
|
||||
let it = q.find_item_mut(a).unwrap().1;
|
||||
animate(it, "parametric_eq", "b1_gain", ParamValue::Float(-12.0), ParamValue::Float(12.0));
|
||||
// Frame Hold Options takes video clips; an imported edit list can hold an audio clip
|
||||
if let Some(hold_filters) = hold {
|
||||
(it.frame_hold, it.hold_filters) = (Some(it.source_in), hold_filters);
|
||||
}
|
||||
let index = it.effects.iter().position(|e| e.effect == "parametric_eq").unwrap();
|
||||
let mut app = FilmcraftApp::new(s);
|
||||
app.ui.audio_fx_editors.push(FxTarget::Clip { clip: a.0, index });
|
||||
app
|
||||
}
|
||||
|
||||
/// What `panel` draws, after a few frames (the editor window sizes itself on the first one).
|
||||
fn observe(mut app: FilmcraftApp, panel: Panel) -> String {
|
||||
let q = app.session.active_sequence().unwrap();
|
||||
let frame = (q.settings.width, q.settings.height);
|
||||
let pic = Rect::from_min_size(pos2(0.0, 0.0), vec2(640.0, 360.0));
|
||||
// a selected mask and a selected effect exclude each other
|
||||
if let Panel::Masks = panel {
|
||||
let clip = app.session.state.selection[0];
|
||||
app.session.execute("masks.select", json!({"clip": clip.0, "effect": "opacity", "mask": 0})).unwrap();
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install(&ctx, &app.tokens);
|
||||
let mut shapes = String::new();
|
||||
for i in 0..3 {
|
||||
app.auto.begin_frame();
|
||||
let mut out = ctx.run_ui(egui::RawInput { time: Some(f64::from(i) * 0.1), ..Default::default() }, |ui| match panel {
|
||||
Panel::Lumetri => crate::panels::lumetri::show(&mut app, ui, Rect::from_min_size(pos2(0.0, 0.0), vec2(360.0, 900.0))),
|
||||
Panel::Masks => crate::panels::masks::monitor_overlay(&mut app, ui, pic, frame),
|
||||
Panel::TransformHandles => crate::panels::transform_handles::monitor_overlay(&mut app, ui, pic, frame),
|
||||
Panel::AudioFxEditor => crate::panels::audio_fx_editor::show(&mut app, ui.ctx()),
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
shapes = format!("{:?}", out.shapes);
|
||||
}
|
||||
shapes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panels_show_effect_values_where_the_render_takes_them_on_a_frame_hold() {
|
||||
for panel in [Panel::Lumetri, Panel::Masks, Panel::TransformHandles, Panel::AudioFxEditor] {
|
||||
let moving = observe(app(None), panel);
|
||||
let held = observe(app(Some(true)), panel);
|
||||
assert!(moving != held, "{panel:?}: with Hold Filters on it shows the held frame's values");
|
||||
assert!(observe(app(Some(false)), panel) == moving, "{panel:?}: with Hold Filters off it shows the values it shows without the hold");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let tops = filmcraft_project::vtransition::EFFECT_TOP_FOLDERS;
|
||||
let mut apply: Option<String> = None;
|
||||
let mut lumetri_apply: Option<String> = None;
|
||||
let mut set_default: Option<String> = None;
|
||||
let mut preset_action: Option<(String, String)> = None;
|
||||
egui::ScrollArea::vertical().id_salt("fx-scroll").auto_shrink([false, false]).show(&mut bui, |ui| {
|
||||
for &top in tops {
|
||||
@@ -115,6 +116,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if resp.double_clicked() {
|
||||
apply = Some(d.id.to_string());
|
||||
}
|
||||
if is_tr && transition_menu(app, &resp) {
|
||||
set_default = Some(d.id.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,6 +136,11 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if let Some((cmd, name)) = preset_action {
|
||||
crate::panels::presets::run(app, &cmd, &name);
|
||||
}
|
||||
if let Some(id) = set_default
|
||||
&& let Err(e) = app.session.execute("effects.setDefaultTransition", json!({"effect": id}))
|
||||
{
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
if let Some(id) = apply {
|
||||
let r = app.session.execute("effects.apply", json!({"effect": id}));
|
||||
if let Err(e) = r {
|
||||
@@ -177,6 +186,21 @@ fn folder_row(app: &mut FilmcraftApp, ui: &mut egui::Ui, key: &str, depth: usize
|
||||
open
|
||||
}
|
||||
|
||||
/// Right-click menu of a video or audio transition: Set as Default Transition (automation id
|
||||
/// `effects.itemMenu.setDefaultTransition`). True when chosen (#3573).
|
||||
fn transition_menu(app: &mut FilmcraftApp, resp: &egui::Response) -> bool {
|
||||
let mut chosen = false;
|
||||
resp.context_menu(|ui| {
|
||||
let b = ui.button(tl!("Set as Default Transition"));
|
||||
app.auto.add("effects.itemMenu.setDefaultTransition", b.rect, "Set as Default Transition");
|
||||
if b.clicked() {
|
||||
chosen = true;
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
chosen
|
||||
}
|
||||
|
||||
/// Right-click menu of a Lumetri preset: Apply to Selected Clips (automation id
|
||||
/// `effects.presetMenu.apply`). True when chosen.
|
||||
fn apply_menu(app: &mut FilmcraftApp, resp: &egui::Response) -> bool {
|
||||
|
||||
@@ -510,7 +510,7 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
return;
|
||||
};
|
||||
let ph = app.session.playhead();
|
||||
let mt = it.source_time_at(ph.clamp(it.start, it.end() - Tick(1)));
|
||||
let mt = it.effect_time_at(ph.clamp(it.start, it.end() - Tick(1)));
|
||||
let idx = layer_indices(&it.effects);
|
||||
let sel = app.session.state.graphic_layers.first().copied().filter(|l| *l < idx.len());
|
||||
let mut actions: Vec<(String, Value)> = Vec::new();
|
||||
|
||||
@@ -50,7 +50,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let hdr = app.session.active_sequence().is_some_and(|q| q.settings.color.working.is_hdr());
|
||||
let mut auto_rows: Vec<(String, Rect, &'static str)> = Vec::new();
|
||||
let ph = app.session.playhead();
|
||||
let mt = it.source_time_at(ph.clamp(it.start, it.end() - Tick(1)));
|
||||
let mt = it.effect_time_at(ph.clamp(it.start, it.end() - Tick(1)));
|
||||
let mut actions: Actions = Vec::new();
|
||||
// header: clip context + fx toggle
|
||||
// the fx toggle is laid out first so a long clip name is truncated instead of widening the panel
|
||||
@@ -151,7 +151,8 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
("curve_green", Color32::from_rgb(80, 200, 90)),
|
||||
("curve_blue", Color32::from_rgb(80, 130, 250)),
|
||||
][ch];
|
||||
curve_editor(ui, &e, id, false, col, &t, &mut actions, clip, idx);
|
||||
let r = curve_editor(ui, &e, id, false, col, &t, &mut actions, clip, idx);
|
||||
auto_rows.push((format!("lumetri.curve.{id}"), r, "Curve"));
|
||||
if hdr {
|
||||
let r = slider(ui, &e, mt, "curves_hdr_range", tl!("HDR Range"), None, &t, &mut actions, clip, idx);
|
||||
auto_rows.push(("lumetri.param.curves_hdr_range".into(), r, "HDR Range"));
|
||||
@@ -165,7 +166,8 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
("sat_vs_sat", tl!("Sat vs Sat")),
|
||||
] {
|
||||
ui.label(egui::RichText::new(label).color(t.text_dim));
|
||||
curve_editor(ui, &e, id, true, Color32::WHITE, &t, &mut actions, clip, idx);
|
||||
let r = curve_editor(ui, &e, id, true, Color32::WHITE, &t, &mut actions, clip, idx);
|
||||
auto_rows.push((format!("lumetri.curve.{id}"), r, label));
|
||||
}
|
||||
}
|
||||
if open_section(ui, app, &e, "Color Wheels & Match", "wheels_on", "wheels", &mut sections) {
|
||||
@@ -298,7 +300,42 @@ fn lut_combo(ui: &mut egui::Ui, app: &mut FilmcraftApp, e: &EffectInstance, para
|
||||
_ => String::new(),
|
||||
};
|
||||
let cmd: &'static str = if input { "lumetri.setInputLut" } else { "lumetri.setLook" };
|
||||
let shown = filmcraft_render::luts::label(Some(&app.session.project), &cur);
|
||||
let mut rect = Rect::NOTHING;
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(vec2(110.0, 20.0), egui::Label::new(egui::RichText::new(label).color(t.text_dim)));
|
||||
let width = (ui.available_width() - 40.0).max(60.0);
|
||||
let (r, pick) = lut_menu(ui, app, ("lumetri-lut", param), &cur, input, width, &format!("lumetri.{param}"));
|
||||
match pick {
|
||||
Some(LutPick::Lut(r)) => out.push((cmd, json!({"lut": r}))),
|
||||
Some(LutPick::Browse) => out.push((cmd, json!({"browse": true}))),
|
||||
None => {}
|
||||
}
|
||||
rect = r;
|
||||
});
|
||||
app.auto.add(&format!("lumetri.{param}"), rect, label);
|
||||
}
|
||||
|
||||
/// What was chosen in a [`lut_menu`].
|
||||
pub(crate) enum LutPick {
|
||||
/// A LUT reference (`builtin:<id>`, `lib:<id>`), or "" for None.
|
||||
Lut(String),
|
||||
/// Browse…: pick a .cube / .3dl file.
|
||||
Browse,
|
||||
}
|
||||
|
||||
/// The LUT combo box for the LUT reference `cur`: None, the built-in input LUTs (`input`) or looks,
|
||||
/// the project's LUT library, Browse…. Shared by the Lumetri Color panel and Effect Controls (#1123).
|
||||
/// The entries get automation ids `<auto_id>.lut.<reference>` (`.lut.none`) and `<auto_id>.browse`.
|
||||
pub(crate) fn lut_menu(
|
||||
ui: &mut egui::Ui,
|
||||
app: &mut FilmcraftApp,
|
||||
salt: impl std::hash::Hash + std::fmt::Debug,
|
||||
cur: &str,
|
||||
input: bool,
|
||||
width: f32,
|
||||
auto_id: &str,
|
||||
) -> (Rect, Option<LutPick>) {
|
||||
let shown = filmcraft_render::luts::label(Some(&app.session.project), cur);
|
||||
let mut entries: Vec<(String, String)> = vec![(String::new(), tl!("None").into())];
|
||||
let builtins: Vec<&filmcraft_render::luts::Builtin> = if input {
|
||||
filmcraft_render::luts::input_builtins().collect()
|
||||
@@ -307,27 +344,23 @@ fn lut_combo(ui: &mut egui::Ui, app: &mut FilmcraftApp, e: &EffectInstance, para
|
||||
};
|
||||
entries.extend(builtins.into_iter().map(|b| (format!("builtin:{}", b.id), b.label.clone())));
|
||||
entries.extend(app.session.project.luts.iter().map(|l| (format!("lib:{}", l.id), l.name.clone())));
|
||||
let mut rect = Rect::NOTHING;
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(vec2(110.0, 20.0), egui::Label::new(egui::RichText::new(label).color(t.text_dim)));
|
||||
let r = craft_ui::ComboBox::from_id_salt(("lumetri-lut", param))
|
||||
.selected_text(shown)
|
||||
.truncate()
|
||||
.width((ui.available_width() - 40.0).max(60.0))
|
||||
.show_ui(ui, |ui| {
|
||||
for (r, name) in &entries {
|
||||
if ui.selectable_label(*r == cur, name).clicked() && *r != cur {
|
||||
out.push((cmd, json!({"lut": r})));
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if ui.selectable_label(false, tl!("Browse…")).clicked() {
|
||||
out.push((cmd, json!({"browse": true})));
|
||||
}
|
||||
});
|
||||
rect = r.response.rect;
|
||||
let mut pick = None;
|
||||
let r = craft_ui::ComboBox::from_id_salt(salt).selected_text(shown).truncate().width(width).show_ui(ui, |ui| {
|
||||
for (r, name) in &entries {
|
||||
let b = ui.selectable_label(r == cur, name);
|
||||
app.auto.add(&format!("{auto_id}.lut.{}", if r.is_empty() { "none" } else { r }), b.rect, name);
|
||||
if b.clicked() && r != cur {
|
||||
pick = Some(LutPick::Lut(r.clone()));
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
let b = ui.selectable_label(false, tl!("Browse…"));
|
||||
app.auto.add(&format!("{auto_id}.browse"), b.rect, tl!("Browse…"));
|
||||
if b.clicked() {
|
||||
pick = Some(LutPick::Browse);
|
||||
}
|
||||
});
|
||||
app.auto.add(&format!("lumetri.{param}"), rect, label);
|
||||
(r.response.rect, pick)
|
||||
}
|
||||
|
||||
/// A section header; returns (expanded, switch clicked). `on` is the section's enable switch.
|
||||
@@ -463,12 +496,14 @@ fn lerp_col(a: Color32, b: Color32, f: f32) -> Color32 {
|
||||
Color32::from_rgb(l(a.r(), b.r()), l(a.g(), b.g()), l(a.b(), b.b()))
|
||||
}
|
||||
|
||||
/// Curve editor: drag points, click the curve to add, double-click a point to remove.
|
||||
/// Curve editor: drag points, click the curve to add, double-click a point to remove. Returns its
|
||||
/// rectangle (its automation element, `lumetri.curve.<param>`).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn curve_editor(ui: &mut egui::Ui, e: &EffectInstance, id: &str, hue: bool, col: Color32, t: &Tokens, actions: &mut Actions, clip: ClipId, idx: usize) {
|
||||
fn curve_editor(ui: &mut egui::Ui, e: &EffectInstance, id: &str, hue: bool, col: Color32, t: &Tokens, actions: &mut Actions, clip: ClipId, idx: usize) -> Rect {
|
||||
let pts: Vec<[f32; 2]> = e.param(id).and_then(|p| p.value.as_curve().map(|c| c.to_vec())).unwrap_or_default();
|
||||
let w = ui.available_width().min(260.0);
|
||||
let h = if hue { w * 0.45 } else { w };
|
||||
// as wide as the panel (it stayed 260 px wide in a wide panel, #1658), not taller than 420 px
|
||||
let w = ui.available_width().max(1.0);
|
||||
let h = if hue { w * 0.45 } else { w }.min(420.0);
|
||||
let (area, resp) = ui.allocate_exact_size(vec2(w, h), Sense::click_and_drag());
|
||||
let p = ui.painter();
|
||||
p.rect_filled(area, 2.0, Color32::from_rgb(0x16, 0x16, 0x16));
|
||||
@@ -503,20 +538,19 @@ fn curve_editor(ui: &mut egui::Ui, e: &EffectInstance, id: &str, hue: bool, col:
|
||||
let from_screen = |s: Pos2| [((s.x - area.min.x) / area.width()).clamp(0.0, 1.0), ((area.max.y - s.y) / area.height()).clamp(0.0, 1.0)];
|
||||
// curve polyline
|
||||
let draw_pts: Vec<[f32; 2]> = if hue && pts.is_empty() { vec![[0.0, 0.5], [1.0, 0.5]] } else { pts.clone() };
|
||||
let lut = if hue && !pts.is_empty() {
|
||||
let mut ext = Vec::new();
|
||||
for off in [-1.0f32, 0.0, 1.0] {
|
||||
for q in &pts {
|
||||
ext.push([(q[0] + off + 1.0) / 3.0, q[1]]);
|
||||
}
|
||||
}
|
||||
let l = filmcraft_render::effects::curve_lut(&ext, 3 * 128);
|
||||
l[128..256].to_vec()
|
||||
} else {
|
||||
filmcraft_render::effects::curve_lut(&draw_pts, 128)
|
||||
};
|
||||
// the hue curves wrap around red; Luma vs Sat and Sat vs Sat do not (same LUTs as the render)
|
||||
let lut = match (hue, id.starts_with("hue_vs_")) {
|
||||
(true, true) => filmcraft_render::effects::hue_lut(&pts, 128),
|
||||
(true, false) => filmcraft_render::effects::vs_sat_lut(&pts, 128),
|
||||
_ => None,
|
||||
}
|
||||
.unwrap_or_else(|| filmcraft_render::effects::curve_lut(&draw_pts, 128));
|
||||
let line: Vec<Pos2> = lut.iter().enumerate().map(|(i, y)| to_screen([i as f32 / 127.0, y.clamp(0.0, 1.0)])).collect();
|
||||
p.add(egui::Shape::line(line, Stroke::new(1.5, col)));
|
||||
p.add(egui::Shape::line(line.clone(), Stroke::new(1.5, col)));
|
||||
// the point nearest `pos` within reach (drag and double-click pick the same one)
|
||||
let nearest = |pos: Pos2| {
|
||||
pts.iter().enumerate().map(|(i, q)| (i, to_screen(*q).distance(pos))).filter(|(_, d)| *d < 9.0).min_by(|a, b| a.1.total_cmp(&b.1)).map(|x| x.0)
|
||||
};
|
||||
let drag_key = egui::Id::unique(("curve-drag", clip.0, id));
|
||||
let mut dragging: Option<usize> = ui.data(|d| d.get_temp(drag_key));
|
||||
let mut new_pts = pts.clone();
|
||||
@@ -528,8 +562,7 @@ fn curve_editor(ui: &mut egui::Ui, e: &EffectInstance, id: &str, hue: bool, col:
|
||||
if resp.drag_started()
|
||||
&& let Some(pos) = resp.interact_pointer_pos()
|
||||
{
|
||||
let near =
|
||||
pts.iter().enumerate().map(|(i, q)| (i, to_screen(*q).distance(pos))).filter(|(_, d)| *d < 9.0).min_by(|a, b| a.1.total_cmp(&b.1)).map(|x| x.0);
|
||||
let near = nearest(pos);
|
||||
dragging = near.or_else(|| {
|
||||
new_pts.push(from_screen(pos));
|
||||
changed = true;
|
||||
@@ -546,13 +579,29 @@ fn curve_editor(ui: &mut egui::Ui, e: &EffectInstance, id: &str, hue: bool, col:
|
||||
if resp.drag_stopped() {
|
||||
dragging = None;
|
||||
}
|
||||
// A plain click on the curve places a point where it landed (#3431): before, only a drag
|
||||
// created points, so the curve ignored the click the interaction promises. A click on a
|
||||
// point selects it (as a drag start does); a click off the curve adds nothing, so the RGB
|
||||
// double-click reset and the hue double-click remove keep working.
|
||||
if resp.clicked()
|
||||
&& let Some(pos) = resp.interact_pointer_pos()
|
||||
{
|
||||
match nearest(pos) {
|
||||
Some(i) => dragging = Some(i),
|
||||
None if line.iter().any(|p| p.distance(pos) < 9.0) => {
|
||||
dragging = None;
|
||||
new_pts.push(from_screen(pos));
|
||||
changed = true;
|
||||
}
|
||||
_ => dragging = None,
|
||||
}
|
||||
}
|
||||
if resp.double_clicked()
|
||||
&& let Some(pos) = resp.interact_pointer_pos()
|
||||
&& let Some(i) = pts.iter().position(|q| to_screen(*q).distance(pos) < 9.0)
|
||||
&& let Some(i) = nearest(pos)
|
||||
{
|
||||
let endpoint = !hue && (i == 0 || i == pts.len() - 1);
|
||||
if !endpoint {
|
||||
new_pts.remove(i);
|
||||
if let Some(rest) = without_point(&pts, i, hue) {
|
||||
new_pts = rest;
|
||||
changed = true;
|
||||
}
|
||||
} else if resp.double_clicked() && !hue {
|
||||
@@ -571,6 +620,21 @@ fn curve_editor(ui: &mut egui::Ui, e: &EffectInstance, id: &str, hue: bool, col:
|
||||
actions.push(json!({"clip": clip.0, "effect": idx, "param": id, "value": v}));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
area
|
||||
}
|
||||
|
||||
/// `pts` without point `i` (double-click), or `None` for a point that stays: the leftmost and
|
||||
/// rightmost points of an RGB curve. Points are stored in the order they were added, not by x, so
|
||||
/// list position says nothing (the newest point could never be removed, #1658).
|
||||
fn without_point(pts: &[[f32; 2]], i: usize, hue: bool) -> Option<Vec<[f32; 2]>> {
|
||||
let q = pts.get(i)?;
|
||||
let (lo, hi) = pts.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), p| (lo.min(p[0]), hi.max(p[0])));
|
||||
if !hue && (q[0] <= lo || q[0] >= hi) {
|
||||
return None;
|
||||
}
|
||||
let mut rest = pts.to_vec();
|
||||
rest.remove(i);
|
||||
Some(rest)
|
||||
}
|
||||
|
||||
/// Colour wheel: hue ring, draggable puck (offset), lightness slider on the left.
|
||||
@@ -671,3 +735,20 @@ fn match_controls(ui: &mut egui::Ui, app: &mut FilmcraftApp, out: &mut Vec<(&'st
|
||||
d.insert_temp(fd_key, face);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::without_point;
|
||||
|
||||
#[test]
|
||||
fn a_curve_point_is_removed_by_position_not_by_list_order() {
|
||||
// #1658: added points are appended, so the newest one is last in the list
|
||||
let pts = [[0.0, 0.0], [1.0, 1.0], [0.5, 0.7]];
|
||||
assert_eq!(without_point(&pts, 2, false), Some(vec![[0.0, 0.0], [1.0, 1.0]]), "the newest point can go");
|
||||
assert_eq!(without_point(&pts, 1, false), None, "the right end stays");
|
||||
assert_eq!(without_point(&pts, 0, false), None, "the left end stays");
|
||||
// hue curves wrap around: any point can go
|
||||
assert_eq!(without_point(&pts, 0, true), Some(vec![[1.0, 1.0], [0.5, 0.7]]));
|
||||
assert_eq!(without_point(&pts, 7, false), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,7 +249,7 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra
|
||||
let it = it.clone();
|
||||
let Some(e) = it.effects.get(sel.effect) else { return };
|
||||
let Some(m) = e.masks.get(sel.mask) else { return };
|
||||
let mt = it.source_time_at(ph);
|
||||
let mt = it.effect_time_at(ph);
|
||||
let Some(to_screen) = clip_to_screen(app, &it, mt, pic, frame) else { return };
|
||||
let Some(to_clip) = to_screen.inverse() else { return };
|
||||
let lin_inv = Affine { e: 0.0, f: 0.0, ..to_clip };
|
||||
@@ -790,7 +790,7 @@ fn pen_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, frame: (u32
|
||||
app.ui.mask_pen = None;
|
||||
return;
|
||||
};
|
||||
let mt = it.source_time_at(ph.clamp(it.start, it.end() - Tick(1)));
|
||||
let mt = it.effect_time_at(ph.clamp(it.start, it.end() - Tick(1)));
|
||||
let Some(to_screen) = clip_to_screen(app, &it, mt, pic, frame) else { return };
|
||||
let Some(to_clip) = to_screen.inverse() else { return };
|
||||
let col = Color32::from_rgb(0x5a, 0xb4, 0xff);
|
||||
|
||||
@@ -81,9 +81,11 @@ fn draw_picture(app: &mut FilmcraftApp, ui: &mut egui::Ui, which: Which, mv: &cr
|
||||
let draft = crate::frames::draft_playback(playing, scale, app.session.prefs.playback.draft_decode);
|
||||
let key = FrameKey { target, frame, size: size_key, revision: rev, draft };
|
||||
let project = app.session.project.clone();
|
||||
let speed = if which == Which::Program { app.playback.speed } else { app.source_playback.clock.speed };
|
||||
// while playing, which way: frames on the other side of the playhead are not due yet
|
||||
let dir = playing.then_some(if speed < 0.0 { -1 } else { 1 });
|
||||
if playing {
|
||||
let preroll = which == Which::Program && app.playback.preroll.is_some();
|
||||
let speed = if which == Which::Program { app.playback.speed } else { app.source_playback.clock.speed };
|
||||
app.frames.schedule_playback(key, rate, scale, &project, speed, preroll);
|
||||
if preroll {
|
||||
app.playback.preroll_ready = app.frames.preroll_ready(key, app.playback.speed, rate.frame_at(duration) - 1);
|
||||
@@ -105,15 +107,18 @@ fn draw_picture(app: &mut FilmcraftApp, ui: &mut egui::Ui, which: Which, mv: &cr
|
||||
.get_plan(&key)
|
||||
.map(|p| (key, p))
|
||||
.or_else(|| app.frames.nearest_plan(key, 6))
|
||||
.or_else(|| asked.iter().find_map(|k| app.frames.get_plan(k).map(|p| (*k, p))));
|
||||
.or_else(|| asked.iter().find_map(|k| app.frames.get_plan(k).map(|p| (*k, p))))
|
||||
.or_else(|| playing_recent(app, key, dir).and_then(|k| app.frames.get_plan(&k).map(|p| (k, p))));
|
||||
match found {
|
||||
Some((k, plan)) => (app.gpu_present(k, &plan).map(|(id, _)| id), Some(k)),
|
||||
None => (app.gpu.as_ref().and_then(|g| g.texture), app.gpu.as_ref().and_then(|g| g.last_key)),
|
||||
}
|
||||
} else if grid {
|
||||
cpu_texture_as(app, &ctx, &tex_name, key, &asked, display, true)
|
||||
let recent = playing_recent(app, key, dir);
|
||||
cpu_texture_as(app, &ctx, &tex_name, key, &asked, recent, display, true)
|
||||
} else {
|
||||
cpu_texture(app, &ctx, &tex_name, key, &asked, display)
|
||||
let recent = playing_recent(app, key, dir);
|
||||
cpu_texture(app, &ctx, &tex_name, key, &asked, recent, display)
|
||||
};
|
||||
let exact = on_screen == Some(key);
|
||||
if which == Which::Program {
|
||||
@@ -149,9 +154,9 @@ fn draw_picture(app: &mut FilmcraftApp, ui: &mut egui::Ui, which: Which, mv: &cr
|
||||
ui.painter().rect_filled(rpic, 0.0, t.monitor_bg);
|
||||
}
|
||||
let (reference, _) = if mv.transparency_grid {
|
||||
cpu_texture_as(app, &ctx, &format!("monitor-{prefix}-ref-grid"), rkey, &[], DisplayMode::Composite, true)
|
||||
cpu_texture_as(app, &ctx, &format!("monitor-{prefix}-ref-grid"), rkey, &[], None, DisplayMode::Composite, true)
|
||||
} else {
|
||||
cpu_texture(app, &ctx, &format!("monitor-{prefix}-ref"), rkey, &[], DisplayMode::Composite)
|
||||
cpu_texture(app, &ctx, &format!("monitor-{prefix}-ref"), rkey, &[], None, DisplayMode::Composite)
|
||||
};
|
||||
if let Some(tex) = reference {
|
||||
ui.painter().image(tex, rpic, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
@@ -561,27 +566,36 @@ fn asked_before(app: &mut FilmcraftApp, prefix: &str, key: FrameKey, playing: bo
|
||||
before
|
||||
}
|
||||
|
||||
/// While playing, a frame to show when the one due is not ready at the project's revision: the
|
||||
/// same frame (or one just played) rendered for one of the last few revisions (#1673).
|
||||
fn playing_recent(app: &FilmcraftApp, key: FrameKey, dir: Option<i64>) -> Option<FrameKey> {
|
||||
dir.and_then(|dir| app.frames.recent(key, dir, 6, crate::frames::PLAYING_REVISION_LAG))
|
||||
}
|
||||
|
||||
/// A monitor texture from the CPU frame path: the exact frame, else the nearest cached one before
|
||||
/// it, else the newest ready one of `asked` (see [`asked_before`]), with the display mode's
|
||||
/// channel mapping. Returns the texture and the frame it shows.
|
||||
/// it, else the newest ready one of `asked` (see [`asked_before`]), else `recent` (while playing),
|
||||
/// with the display mode's channel mapping. Returns the texture and the frame it shows.
|
||||
fn cpu_texture(
|
||||
app: &mut FilmcraftApp,
|
||||
ctx: &egui::Context,
|
||||
name: &str,
|
||||
key: FrameKey,
|
||||
asked: &[FrameKey],
|
||||
recent: Option<FrameKey>,
|
||||
mode: DisplayMode,
|
||||
) -> (Option<egui::TextureId>, Option<FrameKey>) {
|
||||
cpu_texture_as(app, ctx, name, key, asked, mode, false)
|
||||
cpu_texture_as(app, ctx, name, key, asked, recent, mode, false)
|
||||
}
|
||||
|
||||
/// [`cpu_texture`], keeping the frame's alpha when `alpha` is set (the Transparency Grid).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn cpu_texture_as(
|
||||
app: &mut FilmcraftApp,
|
||||
ctx: &egui::Context,
|
||||
name: &str,
|
||||
key: FrameKey,
|
||||
asked: &[FrameKey],
|
||||
recent: Option<FrameKey>,
|
||||
mode: DisplayMode,
|
||||
alpha: bool,
|
||||
) -> (Option<egui::TextureId>, Option<FrameKey>) {
|
||||
@@ -590,7 +604,8 @@ fn cpu_texture_as(
|
||||
.get(&key)
|
||||
.map(|img| (key, img))
|
||||
.or_else(|| (1..=6).map(|d| FrameKey { frame: key.frame.saturating_sub(d), ..key }).find_map(|k| app.frames.get(&k).map(|img| (k, img))))
|
||||
.or_else(|| asked.iter().find_map(|k| app.frames.get(k).map(|img| (*k, img))));
|
||||
.or_else(|| asked.iter().find_map(|k| app.frames.get(k).map(|img| (*k, img))))
|
||||
.or_else(|| recent.and_then(|k| app.frames.get(&k).map(|img| (k, img))));
|
||||
match found {
|
||||
Some((k, img)) => {
|
||||
let tex = if mode.is_channel() {
|
||||
|
||||
@@ -10,9 +10,15 @@
|
||||
//! the playhead while the drag goes on, as dragging them in Effect Controls does, so a property
|
||||
//! with its stopwatch on gets a keyframe there, and one drag is one undo step.
|
||||
//!
|
||||
//! Corner Pin gets a handle on each of its four corners while it is the selected effect (#776):
|
||||
//! dragging one moves that corner, and dragging inside the outline through them moves all four,
|
||||
//! through `effects.setParam` in the same way.
|
||||
//!
|
||||
//! Automation ids: `program.transform.box`, `program.transform.handle.<n>` (0–3 the corners TL,
|
||||
//! TR, BR, BL, 4–7 the middles of the top, right, bottom and left edges, as for graphic layers),
|
||||
//! `program.transform.rotate.<n>` (just outside corner `n`), `program.transform.anchor`.
|
||||
//! `program.transform.rotate.<n>` (just outside corner `n`), `program.transform.anchor`;
|
||||
//! `program.cornerPin.box`, `program.cornerPin.handle.<n>` (0–3 Upper Left, Upper Right, Lower
|
||||
//! Right, Lower Left).
|
||||
|
||||
use egui::{Color32, CursorIcon, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use filmcraft_geom::{Affine, Vec2};
|
||||
@@ -43,6 +49,10 @@ const BADGE_ARM: f32 = 5.0;
|
||||
const ROTATE_REACH: f32 = 24.0;
|
||||
/// The `merge` key of a drag's changes: one undo step, whichever parameters the drag changes.
|
||||
const MERGE: &str = "programTransform";
|
||||
/// The same for a Corner Pin corner.
|
||||
const PIN_MERGE: &str = "programCornerPin";
|
||||
/// Corner Pin's corners, in the order of the box's (TL, TR, BR, BL).
|
||||
const PIN_CORNERS: [&str; 4] = ["upper_left", "upper_right", "lower_right", "lower_left"];
|
||||
/// How far inside the monitor's clip (which reaches 12 points past the picture area) a handle
|
||||
/// that would be off the monitor stops, so that all of it shows and can be grabbed.
|
||||
const REACH_INSET: f32 = 12.0 + CORNER;
|
||||
@@ -126,7 +136,7 @@ fn inside(quad: &[Pos2; 4], p: Pos2) -> bool {
|
||||
/// The handles at `points` pulled into `view`, the part of the monitor on screen: a picture moved
|
||||
/// past the monitor's edge keeps every handle in reach (#1393). The picture stays where it is;
|
||||
/// only the squares stop at the edge. A `view` with no room leaves them where they are.
|
||||
fn within(points: [Pos2; 8], view: Rect) -> [Pos2; 8] {
|
||||
fn within<const N: usize>(points: [Pos2; N], view: Rect) -> [Pos2; N] {
|
||||
if !(view.is_finite() && view.is_positive()) {
|
||||
return points;
|
||||
}
|
||||
@@ -183,24 +193,147 @@ fn placement(app: &FilmcraftApp, it: &TrackItem, e: &EffectInstance, mt: Tick, p
|
||||
})
|
||||
}
|
||||
|
||||
/// The selected effect (its clip and index) and its box, while it is Motion or Transform of a
|
||||
/// selected clip under the playhead.
|
||||
fn selected_placement(app: &FilmcraftApp, pic: Rect, reach: Rect, frame: (u32, u32)) -> Option<(Target, &'static EffectDef, Placement)> {
|
||||
/// The selected effect (its clip and index), its clip and its definition, while it is an enabled
|
||||
/// effect of a selected clip under the playhead.
|
||||
fn selected_effect(app: &FilmcraftApp) -> Option<(Target, &TrackItem, &EffectInstance, &'static EffectDef)> {
|
||||
let sel = app.session.state.selected_effect.as_ref()?;
|
||||
if !app.session.state.selection.contains(&sel.clip) {
|
||||
return None;
|
||||
}
|
||||
let seq = app.session.active_sequence()?;
|
||||
let ph = app.session.playhead();
|
||||
let (_, it) = seq.find_item(sel.clip)?;
|
||||
if !it.range().contains(ph) {
|
||||
if !it.range().contains(app.session.playhead()) {
|
||||
return None;
|
||||
}
|
||||
let index = sel.index(&it.effects)?;
|
||||
let e = it.effects.get(index).filter(|e| e.enabled)?;
|
||||
let def = e.def().filter(|d| d.id == "motion" || d.id == "transform")?;
|
||||
let pl = placement(app, it, e, it.source_time_at(ph), pic, reach, frame)?;
|
||||
Some(((sel.clip, index), def, pl))
|
||||
Some(((sel.clip, index), it, e, e.def()?))
|
||||
}
|
||||
|
||||
/// The selected effect (its clip and index) and its box, while it is Motion or Transform of a
|
||||
/// selected clip under the playhead.
|
||||
fn selected_placement(app: &FilmcraftApp, pic: Rect, reach: Rect, frame: (u32, u32)) -> Option<(Target, &'static EffectDef, Placement)> {
|
||||
let (sel, it, e, def) = selected_effect(app).filter(|s| s.3.id == "motion" || s.3.id == "transform")?;
|
||||
let pl = placement(app, it, e, it.effect_time_at(app.session.playhead()), pic, reach, frame)?;
|
||||
Some((sel, def, pl))
|
||||
}
|
||||
|
||||
/// Corner Pin's four corners on the monitor and the values they are drawn from.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Pins {
|
||||
/// Clip pixels (where Corner Pin's points are measured) → screen.
|
||||
to_screen: Affine,
|
||||
/// The corners in clip pixels, in the order of [`PIN_CORNERS`].
|
||||
points: [Vec2; 4],
|
||||
/// Where they are on screen, and where their handles are drawn and grabbed (kept in reach).
|
||||
at: [Pos2; 4],
|
||||
handles: [Pos2; 4],
|
||||
}
|
||||
|
||||
/// The selected effect (its clip and index) and its corners, while it is Corner Pin of a selected
|
||||
/// clip under the playhead.
|
||||
fn selected_pins(app: &FilmcraftApp, pic: Rect, reach: Rect, frame: (u32, u32)) -> Option<(Target, Pins)> {
|
||||
let (sel, it, e, _) = selected_effect(app).filter(|s| s.3.id == "corner_pin")?;
|
||||
let seq = app.session.active_sequence()?;
|
||||
let project = &app.session.project;
|
||||
let mt = it.effect_time_at(app.session.playhead());
|
||||
let size = filmcraft_render::source_size(project, it.item).unwrap_or(frame);
|
||||
// Corner Pin warps the clip's own pixels before Motion places them (`filmcraft_render::vfx::corner_pin`)
|
||||
let to_screen = frame_to_screen(pic, frame).then_apply(&filmcraft_render::motion_matrix(seq, it, size, filmcraft_render::source_par(project, it.item), mt));
|
||||
let (w, h) = (f64::from(size.0), f64::from(size.1));
|
||||
let points = PIN_CORNERS.map(|id| {
|
||||
let v = e.param(id).map_or(Vec2::new(f64::NAN, f64::NAN), |p| p.vec2_at(mt));
|
||||
// "auto" points (NaN) are the clip's corners, where the render puts them
|
||||
let (fx, fy) = filmcraft_project::effect::auto_point("corner_pin", id).unwrap_or((0.5, 0.5));
|
||||
Vec2::new(if v.x.is_nan() { w * fx } else { v.x }, if v.y.is_nan() { h * fy } else { v.y })
|
||||
});
|
||||
let at = points.map(|p| sp(&to_screen, p));
|
||||
at.iter().all(|p| p.x.is_finite() && p.y.is_finite()).then_some((sel, Pins { to_screen, points, at, handles: within(at, reach) }))
|
||||
}
|
||||
|
||||
/// A drag of Corner Pin's corner `n`, or of all four (`None`, a drag inside the outline).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct PinDrag {
|
||||
sel: Target,
|
||||
n: Option<usize>,
|
||||
start: Pos2,
|
||||
from: Pins,
|
||||
}
|
||||
|
||||
/// Corner Pin's corners on the Program monitor: the outline through them and a handle on each.
|
||||
/// Dragging a handle moves its corner; dragging inside the outline moves all four together.
|
||||
fn pin_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, sel: Target, pins: &Pins, actions: &mut Vec<(String, Value)>) {
|
||||
let accent = app.tokens.accent;
|
||||
let id = egui::Id::unique("program-corner-pin");
|
||||
let grab = vec2(GRAB, GRAB) * 2.0;
|
||||
ui.painter().add(egui::Shape::closed_line(pins.at.to_vec(), Stroke::new(1.0, accent)));
|
||||
// the outline first, so the handles (on top) take a press on them
|
||||
let outline = Rect::from_points(&pins.at);
|
||||
app.auto.add("program.cornerPin.box", outline, "Corner Pin");
|
||||
let body = ui.interact(outline, id.with("box"), Sense::drag());
|
||||
let inside_at = |p: Option<Pos2>| p.is_some_and(|p| inside(&pins.at, p));
|
||||
pin_drag(ui, &body, sel, None, pins, inside_at(ui.input(|i| i.pointer.press_origin())), CursorIcon::Move, actions);
|
||||
if body.hovered() && inside_at(body.hover_pos()) {
|
||||
ui.ctx().set_cursor_icon(CursorIcon::Move);
|
||||
}
|
||||
// each corner resizes outward from the middle of the outline, as the Motion box's corners do
|
||||
let mid = pins.at.iter().fold(Pos2::ZERO, |m, p| m + p.to_vec2() / 4.0);
|
||||
for (n, c) in pins.handles.iter().enumerate() {
|
||||
let hr = Rect::from_center_size(*c, vec2(CORNER, CORNER) * 1.4);
|
||||
ui.painter().rect_filled(hr, 0.0, Color32::WHITE);
|
||||
ui.painter().rect_stroke(hr, 0.0, Stroke::new(1.0, accent), StrokeKind::Middle);
|
||||
let name = PIN_CORNERS.get(n).copied().unwrap_or_default();
|
||||
app.auto.add(&format!("program.cornerPin.handle.{n}"), Rect::from_center_size(*c, grab), name);
|
||||
let resp = ui.interact(Rect::from_center_size(*c, grab), id.with(n), Sense::drag());
|
||||
let cursor = pins.at.get(n).map_or(CursorIcon::Move, |p| resize_cursor(*p - mid));
|
||||
pin_drag(ui, &resp, sel, Some(n), pins, true, cursor, actions);
|
||||
if resp.hovered() {
|
||||
ui.ctx().set_cursor_icon(cursor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The left-button drag of `resp` on corner `n` (all four for `None`): the corners it moves, as
|
||||
/// `effects.setParam` actions merged into one undo step, with `cursor` shown while it lasts. A
|
||||
/// drag starts only when `grabbed`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn pin_drag(
|
||||
ui: &egui::Ui,
|
||||
resp: &egui::Response,
|
||||
sel: Target,
|
||||
n: Option<usize>,
|
||||
pins: &Pins,
|
||||
grabbed: bool,
|
||||
cursor: CursorIcon,
|
||||
actions: &mut Vec<(String, Value)>,
|
||||
) {
|
||||
let drag_id = egui::Id::unique("program-corner-pin").with("drag");
|
||||
let left = egui::PointerButton::Primary;
|
||||
if resp.drag_started_by(left) && grabbed {
|
||||
let start = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()).unwrap_or_default();
|
||||
ui.data_mut(|d| d.insert_temp(drag_id, PinDrag { sel, n, start, from: *pins }));
|
||||
}
|
||||
let drag: Option<PinDrag> = ui.data(|d| d.get_temp(drag_id)).filter(|d: &PinDrag| d.sel == sel && d.n == n);
|
||||
if let Some(d) = drag
|
||||
&& resp.dragged_by(left)
|
||||
{
|
||||
if resp.drag_started_by(left) || resp.drag_delta() != egui::Vec2::ZERO {
|
||||
let cur = resp.interact_pointer_pos().unwrap_or(d.start);
|
||||
let off = unscale(&d.from.to_screen, cur - d.start);
|
||||
for (k, (name, from)) in PIN_CORNERS.iter().zip(d.from.points).enumerate() {
|
||||
let v = from + off;
|
||||
// the range Effect Controls' fields take
|
||||
if n.is_none_or(|n| n == k) && v.x.is_finite() && v.y.is_finite() {
|
||||
let value = json!([v.x.clamp(-100_000.0, 100_000.0), v.y.clamp(-100_000.0, 100_000.0)]);
|
||||
actions.push(("effects.setParam".into(), json!({"clip": sel.0.0, "effect": sel.1, "param": name, "value": value, "merge": PIN_MERGE})));
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.ctx().set_cursor_icon(cursor);
|
||||
}
|
||||
if resp.drag_stopped() && drag.is_some() {
|
||||
ui.data_mut(|d| d.remove::<PinDrag>(drag_id));
|
||||
}
|
||||
}
|
||||
|
||||
fn grab_at(pl: &Placement, p: Pos2) -> Option<Grab> {
|
||||
@@ -386,13 +519,13 @@ fn clip_under(app: &FilmcraftApp, pic: Rect, frame: (u32, u32), p: Pos2) -> Opti
|
||||
let project = &app.session.project;
|
||||
seq.video_tracks.iter().rev().filter(|tr| tr.enabled).filter_map(|tr| tr.item_at(t)).filter(|it| it.enabled).find_map(|it| {
|
||||
let size = filmcraft_render::source_size(project, it.item).unwrap_or(frame);
|
||||
let m = view.then_apply(&filmcraft_render::motion_matrix(seq, it, size, filmcraft_render::source_par(project, it.item), it.source_time_at(t)));
|
||||
let m = view.then_apply(&filmcraft_render::motion_matrix(seq, it, size, filmcraft_render::source_par(project, it.item), it.effect_time_at(t)));
|
||||
inside(&quad_of(&m, size), p).then_some(it.id)
|
||||
})
|
||||
}
|
||||
|
||||
/// The selected effect's box on the Program monitor picture `pic`, and double-clicking a clip's
|
||||
/// picture to select its Motion.
|
||||
/// The selected effect's box (or Corner Pin's corners) on the Program monitor picture `pic`, and
|
||||
/// double-clicking a clip's picture to select its Motion.
|
||||
pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, frame: (u32, u32)) {
|
||||
if app.ui.tool != Tool::Selection || app.ui.mask_pen.is_some() {
|
||||
return;
|
||||
@@ -467,6 +600,9 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra
|
||||
actions.push(("effects.select".into(), json!({"none": true})));
|
||||
}
|
||||
}
|
||||
if let Some((sel, pins)) = selected_pins(app, pic, reach, frame) {
|
||||
pin_overlay(app, ui, sel, &pins, &mut actions);
|
||||
}
|
||||
if double
|
||||
&& app.ui.gfx_edit.is_none()
|
||||
&& let Some(p) = ui.input(|i| i.pointer.interact_pos()).filter(|p| pic.contains(*p) && ui.rect_contains_pointer(pic))
|
||||
|
||||
@@ -166,6 +166,99 @@ fn lumetri_lut_menus_and_section_switches() {
|
||||
assert_eq!(e["params"]["creative_on"]["value"], json!("Bool(true)"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_controls_lut_rows_pick_a_lut() {
|
||||
// #1123: Input LUT / Look LUT in Effect Controls were a read-only label
|
||||
let mut d = Driver::demo();
|
||||
let clip = select_first_clip(&mut d);
|
||||
d.exec("effects.apply", json!({"clips": [clip], "effect": "lumetri"}));
|
||||
d.ok("ui.panel.show", json!({"panel": "EffectControls"}));
|
||||
// maximized, so the Lumetri rows fit
|
||||
d.ok("ui.set", json!({"focused": "EffectControls"}));
|
||||
d.ok("ui.menu.invoke", json!({"id": "window.maximizeFrame"}));
|
||||
d.frames(4);
|
||||
let ids = d.ids("effectControls.lumetri.input_lut");
|
||||
assert!(ids.iter().any(|i| i == "effectControls.lumetri.input_lut"), "{ids:?}");
|
||||
d.click("effectControls.lumetri.input_lut");
|
||||
d.frames(2);
|
||||
d.click("effectControls.lumetri.input_lut.lut.builtin:slog3-sgamut3cine-to-rec709");
|
||||
d.frames(2);
|
||||
let e = lumetri(&mut d, clip);
|
||||
assert_eq!(e["params"]["input_lut"]["value"], json!("Text(\"builtin:slog3-sgamut3cine-to-rec709\")"), "{e}");
|
||||
// one undo step
|
||||
d.exec("edit.undo", json!({}));
|
||||
let e = lumetri(&mut d, clip);
|
||||
assert_eq!(e["params"]["input_lut"]["value"], json!("Text(\"\")"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_controls_lut_browse_imports_and_sets_in_one_undo_step() {
|
||||
// Browse… imports the picked file into the LUT library and sets the row; one undo takes both back
|
||||
let dir = std::env::temp_dir().join(format!("fc-lut-browse-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("swap.cube");
|
||||
let mut cube = String::from("LUT_3D_SIZE 2\n");
|
||||
for b in 0..2 {
|
||||
for g in 0..2 {
|
||||
for r in 0..2 {
|
||||
cube.push_str(&format!("{b} {g} {r}\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
std::fs::write(&path, cube).unwrap();
|
||||
let mut d = Driver::demo();
|
||||
let picked = path.to_string_lossy().into_owned();
|
||||
d.harness.state_mut().hooks.run_dialog = Some(Box::new(move |_dialog| {
|
||||
let (tx, rx) = channel();
|
||||
tx.send(vec![picked.clone()]).unwrap();
|
||||
rx
|
||||
}));
|
||||
let clip = select_first_clip(&mut d);
|
||||
d.exec("effects.apply", json!({"clips": [clip], "effect": "lumetri"}));
|
||||
d.ok("ui.panel.show", json!({"panel": "EffectControls"}));
|
||||
d.ok("ui.set", json!({"focused": "EffectControls"}));
|
||||
d.ok("ui.menu.invoke", json!({"id": "window.maximizeFrame"}));
|
||||
d.frames(4);
|
||||
let luts = |d: &mut Driver| d.harness.state().session.project.luts.len();
|
||||
let before = luts(&mut d);
|
||||
d.click("effectControls.lumetri.input_lut");
|
||||
d.frames(2);
|
||||
// Browse… is at the bottom of a long list: scroll it into view
|
||||
let first = d.ids("effectControls.lumetri.input_lut.lut.").into_iter().next().expect("the LUT list is open");
|
||||
for _ in 0..10 {
|
||||
d.ok("ui.scroll", json!({"id": first, "dy": -400.0}));
|
||||
d.frames(1);
|
||||
}
|
||||
d.click("effectControls.lumetri.input_lut.browse");
|
||||
d.frames(6);
|
||||
let status = d.harness.state().ui.status.clone();
|
||||
assert_eq!(luts(&mut d), before + 1, "imported into the LUT library: {status}");
|
||||
let e = lumetri(&mut d, clip);
|
||||
assert!(e["params"]["input_lut"]["value"].as_str().is_some_and(|v| v.contains("lib:")), "{e}");
|
||||
d.exec("edit.undo", json!({}));
|
||||
assert_eq!(luts(&mut d), before, "one undo removes the import too");
|
||||
let e = lumetri(&mut d, clip);
|
||||
assert_eq!(e["params"]["input_lut"]["value"], json!("Text(\"\")"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lumetri_curves_grow_with_the_panel() {
|
||||
// #1658: the RGB curve kept 260 px in a wide panel
|
||||
let mut d = Driver::demo();
|
||||
d.ok("ui.set", json!({"workspace": "Color"}));
|
||||
d.frames(3);
|
||||
let clip = select_first_clip(&mut d);
|
||||
d.exec("effects.apply", json!({"clips": [clip], "effect": "lumetri"}));
|
||||
d.ok("ui.set", json!({"focused": "LumetriColor"}));
|
||||
d.ok("ui.menu.invoke", json!({"id": "window.maximizeFrame"}));
|
||||
d.frames(3);
|
||||
d.click("lumetri.section.Curves");
|
||||
d.frames(3);
|
||||
let r = d.element("lumetri.curve.curve_luma").expect("the luma curve is shown");
|
||||
assert!(r[2] > 400.0, "curve width {r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpret_footage_and_sequence_color_dialogs_and_hdr_scopes() {
|
||||
let mut d = Driver::demo();
|
||||
@@ -406,3 +499,74 @@ fn interpret_footage_from_the_item_menu_takes_the_whole_selection() {
|
||||
assert_eq!(over(&mut d, c), json!("slog3-sgamut3cine"));
|
||||
assert_eq!(over(&mut d, a), before[0]);
|
||||
}
|
||||
/// #2682: a curve point dragged in Lumetri Color is one undo step: Cmd+Z puts the curve back, and
|
||||
/// Cmd+Shift+Z brings the edit back.
|
||||
#[test]
|
||||
fn a_dragged_lumetri_curve_point_undoes_and_redoes() {
|
||||
let mut d = Driver::demo();
|
||||
d.ok("ui.set", json!({"workspace": "Color"}));
|
||||
d.frames(3);
|
||||
let clip = select_first_clip(&mut d);
|
||||
d.exec("effects.apply", json!({"clips": [clip], "effect": "lumetri"}));
|
||||
d.frames(3);
|
||||
d.click("lumetri.section.Curves");
|
||||
d.frames(2);
|
||||
let curve = |d: &mut Driver| {
|
||||
let q = d.app().session.active_sequence().unwrap().clone();
|
||||
let (_, it) = q.find_item(filmcraft_project::ClipId(clip)).unwrap();
|
||||
let e = it.effects.iter().find(|e| e.effect == "lumetri").unwrap();
|
||||
e.param("curve_luma").and_then(|p| p.value.as_curve().map(|c| c.to_vec())).unwrap_or_default()
|
||||
};
|
||||
let before = curve(&mut d);
|
||||
let r = d.element("lumetri.curve.curve_luma").expect("the luma curve has an automation element");
|
||||
// from the middle of the curve up a quarter of its height: a new point, dragged
|
||||
let (x, y) = (r[0] + r[2] * 0.5, r[1] + r[3] * 0.5);
|
||||
d.ok("ui.drag", json!({"from": {"x": x, "y": y}, "to": {"x": x, "y": y - r[3] * 0.25}, "steps": 6}));
|
||||
d.frames(3);
|
||||
let edited = curve(&mut d);
|
||||
assert_ne!(edited, before, "the drag changed the curve");
|
||||
d.ok("ui.key", json!({"key": "Cmd+Z"}));
|
||||
d.frames(3);
|
||||
assert_eq!(curve(&mut d), before, "Cmd+Z puts the curve back: {}", d.app().ui.status);
|
||||
d.ok("ui.key", json!({"key": "Cmd+Shift+Z"}));
|
||||
d.frames(3);
|
||||
assert_eq!(curve(&mut d), edited, "Cmd+Shift+Z brings the edit back");
|
||||
// a second drag is a step of its own: one Cmd+Z takes back that one only
|
||||
let (x2, y2) = (r[0] + r[2] * 0.25, r[1] + r[3] * 0.75);
|
||||
d.ok("ui.drag", json!({"from": {"x": x2, "y": y2}, "to": {"x": x2, "y": y2 + r[3] * 0.1}, "steps": 6}));
|
||||
d.frames(3);
|
||||
let twice = curve(&mut d);
|
||||
assert_ne!(twice, edited);
|
||||
d.ok("ui.key", json!({"key": "Cmd+Z"}));
|
||||
d.frames(3);
|
||||
assert_eq!(curve(&mut d), edited, "one Cmd+Z undoes the second drag only");
|
||||
}
|
||||
|
||||
/// #3431: a plain click on the Lumetri luma curve places a point where it landed; a click away
|
||||
/// from the curve adds nothing.
|
||||
#[test]
|
||||
fn a_click_on_the_lumetri_curve_places_a_point() {
|
||||
let mut d = Driver::demo();
|
||||
d.ok("ui.set", json!({"workspace": "Color"}));
|
||||
d.frames(3);
|
||||
let clip = select_first_clip(&mut d);
|
||||
d.exec("effects.apply", json!({"clips": [clip], "effect": "lumetri"}));
|
||||
d.frames(3);
|
||||
d.click("lumetri.section.Curves");
|
||||
d.frames(2);
|
||||
let points = |d: &mut Driver| {
|
||||
let q = d.app().session.active_sequence().unwrap().clone();
|
||||
let (_, it) = q.find_item(filmcraft_project::ClipId(clip)).unwrap();
|
||||
let e = it.effects.iter().find(|e| e.effect == "lumetri").unwrap();
|
||||
e.param("curve_luma").and_then(|p| p.value.as_curve().map(|c| c.len())).unwrap_or_default()
|
||||
};
|
||||
let before = points(&mut d);
|
||||
// the middle of the identity diagonal
|
||||
d.ok("ui.click", json!({"id": "lumetri.curve.curve_luma", "fx": 0.5, "fy": 0.5}));
|
||||
d.frames(3);
|
||||
assert_eq!(points(&mut d), before + 1, "a click on the curve adds a point");
|
||||
// the top-left corner of the curve box is far from the diagonal
|
||||
d.ok("ui.click", json!({"id": "lumetri.curve.curve_luma", "fx": 0.1, "fy": 0.1}));
|
||||
d.frames(3);
|
||||
assert_eq!(points(&mut d), before + 1, "a click off the curve adds none");
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
use std::sync::mpsc::{Sender, channel};
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
use filmcraft_engine::Session;
|
||||
use filmcraft_project::{ClipId, Interpolation, Param};
|
||||
use filmcraft_time::Tick;
|
||||
@@ -492,6 +493,32 @@ fn selected_keyframes_move_together_and_undo_as_one_edit() {
|
||||
assert_eq!(serde_json::to_value(&d.harness.state().session.project).unwrap(), after);
|
||||
}
|
||||
|
||||
/// #1916: with three Scale keyframes selected, the interpolation chosen in the right-click menu
|
||||
/// applies to all three as one undo step, not only to the keyframe under the pointer. A fourth,
|
||||
/// unselected keyframe is left alone, and right-clicking it changes only that one.
|
||||
#[test]
|
||||
fn keyframe_menu_interpolation_applies_to_every_selected_keyframe() {
|
||||
let (mut d, clip) = Driver::demo();
|
||||
let keys = lane_keys(&mut d, &clip, &[("scale", 0.5), ("scale", 1.5), ("scale", 2.5), ("scale", 3.5)]);
|
||||
select_keys(&mut d, &keys[..3]);
|
||||
let before = d.parameter(&clip, "motion", "scale");
|
||||
assert!(before.keyframes.iter().all(|k| k.interp == Interpolation::Linear));
|
||||
let undo = d.undo_len();
|
||||
d.right_click(&key_id(&d, &keys[1]));
|
||||
d.click("effectControls.keyframeMenu.bezier");
|
||||
let changed = d.parameter(&clip, "motion", "scale");
|
||||
let interps: Vec<_> = changed.keyframes.iter().map(|k| k.interp).collect();
|
||||
assert_eq!(interps, [Interpolation::Bezier, Interpolation::Bezier, Interpolation::Bezier, Interpolation::Linear]);
|
||||
assert_eq!(d.undo_len(), undo + 1);
|
||||
d.exec("edit.undo", json!({}));
|
||||
assert_eq!(d.parameter(&clip, "motion", "scale"), before, "one undo reverts all three");
|
||||
|
||||
d.right_click(&key_id(&d, &keys[3]));
|
||||
d.click("effectControls.keyframeMenu.hold");
|
||||
let interps: Vec<_> = d.parameter(&clip, "motion", "scale").keyframes.iter().map(|k| k.interp).collect();
|
||||
assert_eq!(interps, [Interpolation::Linear, Interpolation::Linear, Interpolation::Linear, Interpolation::Hold]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_snaps_the_group_to_other_rows_at_different_lane_widths() {
|
||||
for (width, theme) in [(280.0, "dark"), (480.0, "light")] {
|
||||
@@ -749,3 +776,81 @@ fn effect_dropped_on_effect_controls_applies_to_the_selected_clip() {
|
||||
assert!(effects_of(&d, clip.id).contains(&"gaussian_blur".to_string()), "applied to the selected clip");
|
||||
assert_eq!(d.undo_len(), undo + 1, "one undo step");
|
||||
}
|
||||
|
||||
/// Scale keyframes at 1 s and 2 s and a Position keyframe at 1 s; the first Scale keyframe eases
|
||||
/// in. Returns the lane diamond of that first Scale keyframe and its media time.
|
||||
fn three_keyframes_first_easing_in(d: &mut Driver, clip: &Clip) -> (String, i64) {
|
||||
d.seek(1.0);
|
||||
for param in ["scale", "position"] {
|
||||
d.exec("effects.addKeyframe", json!({"clip": clip.id, "effect": "motion", "param": param}));
|
||||
}
|
||||
d.seek(2.0);
|
||||
d.exec("effects.addKeyframe", json!({"clip": clip.id, "effect": "motion", "param": "scale"}));
|
||||
let time = d.scale(clip).0[0];
|
||||
d.exec("effects.setInterpolation", json!({"clip": clip.id, "effect": "motion", "param": "scale", "mediaTime": time, "interpolation": "easeIn"}));
|
||||
(format!("effectControls.motion.scale.keyframe.{time}"), time)
|
||||
}
|
||||
|
||||
/// The rows of the keyframe menu, as accessibility reports them: each row's label and whether it
|
||||
/// is checked (`None` for a command, which is never checked).
|
||||
fn row_checks(d: &mut Driver) -> Vec<(&'static str, Option<egui::accesskit::Toggled>)> {
|
||||
d.frames(2);
|
||||
["Clear", "Linear", "Bezier", "Auto Bezier", "Continuous Bezier", "Hold", "Ease In", "Ease Out"]
|
||||
.into_iter()
|
||||
.map(|label| (label, d.harness.get_by_label(label).accesskit_node().toggled()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// #265: right-clicking a keyframe shows Premiere's rows in Premiere's order (Clear, the five
|
||||
/// temporal interpolations, then Ease In and Ease Out), with no heading, and the keyframe's own
|
||||
/// interpolation checked, on the lane's diamonds and on the graph's points alike. Clear is a
|
||||
/// plain command: accessibility does not report it as an unchecked toggle.
|
||||
#[test]
|
||||
fn keyframe_menu_has_premieres_rows_and_checks_the_current_interpolation() {
|
||||
use egui::accesskit::Toggled;
|
||||
let (mut d, clip) = Driver::demo();
|
||||
let (diamond, time) = three_keyframes_first_easing_in(&mut d, &clip);
|
||||
d.right_click(&diamond);
|
||||
d.shot("keyframe-menu");
|
||||
let expected = ["clear", "linear", "bezier", "autoBezier", "continuousBezier", "hold", "easeIn", "easeOut"];
|
||||
let mut shown: Vec<(f64, &str)> = expected.iter().filter_map(|k| Some((d.find(&format!("effectControls.keyframeMenu.{k}"))?[1], *k))).collect();
|
||||
shown.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
assert_eq!(shown.iter().map(|r| r.1).collect::<Vec<_>>(), expected, "the rows top to bottom");
|
||||
assert!(d.harness.query_by_label_contains("Temporal Interpolation").is_none(), "Premiere's menu has no heading");
|
||||
|
||||
let eased = |label: &str| match label {
|
||||
"Clear" => None,
|
||||
"Ease In" => Some(Toggled::True),
|
||||
_ => Some(Toggled::False),
|
||||
};
|
||||
for (label, toggled) in row_checks(&mut d) {
|
||||
assert_eq!(toggled, eased(label), "lane menu: {label}");
|
||||
}
|
||||
|
||||
d.click("effectControls.motion.scale.graphs");
|
||||
assert!(d.find("effectControls.keyframeMenu.clear").is_none(), "the click closed the lane's menu");
|
||||
d.right_click(&format!("effectControls.scale.graph.keyframe.{time}"));
|
||||
for (label, toggled) in row_checks(&mut d) {
|
||||
assert_eq!(toggled, eased(label), "graph menu: {label}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #265: with every keyframe selected (⌘A in Effect Controls), Clear from a keyframe's menu still
|
||||
/// deletes only the right-clicked keyframe. This pins today's behaviour; Clear acting on the whole
|
||||
/// selection, as in Premiere, comes with Select All back in the menu.
|
||||
#[test]
|
||||
fn keyframe_menu_clear_deletes_only_the_clicked_keyframe_of_a_selection() {
|
||||
let (mut d, clip) = Driver::demo();
|
||||
let (diamond, time) = three_keyframes_first_easing_in(&mut d, &clip);
|
||||
let selected = |d: &mut Driver| d.ok("ui.inspect", json!({}))["ui"]["keyframe_selection"].as_array().map_or(0, Vec::len);
|
||||
d.ok("ui.set", json!({"focused": "EffectControls"}));
|
||||
d.ok("ui.key", json!({"key": "Cmd+A"}));
|
||||
d.frames(2);
|
||||
d.right_click(&diamond);
|
||||
assert_eq!(selected(&mut d), 3, "the right-clicked keyframe is part of the whole selection");
|
||||
d.click("effectControls.keyframeMenu.clear");
|
||||
let scale = d.scale(&clip).0;
|
||||
let position = d.parameter(&clip, "motion", "position").keyframes.len();
|
||||
assert_eq!((scale.len(), position), (1, 1), "2 of 3 keyframes remain: {scale:?}");
|
||||
assert!(!scale.contains(&time), "the right-clicked Scale keyframe is the one deleted");
|
||||
}
|
||||
|
||||
@@ -213,6 +213,42 @@ fn a_newer_revision_keeps_the_frame_being_rendered() {
|
||||
d.ready(key(9001));
|
||||
}
|
||||
|
||||
/// #1142, #1606: a backlog of frames to render does not make the UI repaint at 60 Hz on native
|
||||
/// builds (the workers wake it when a frame is done; only the web pumps frames in the UI pass).
|
||||
#[test]
|
||||
fn queued_frames_do_not_poll_the_ui() {
|
||||
let mut d = Driver::demo();
|
||||
// a frame server with no workers keeps its queue: what a slow backlog looks like to the UI
|
||||
let (pool, services, previews) = {
|
||||
let s = &d.harness.state().session;
|
||||
(s.media.clone(), s.services.clone(), s.previews.clone())
|
||||
};
|
||||
d.harness.state_mut().frames = std::sync::Arc::new(filmcraft_ui_egui::frames::FrameServer::new(pool, services, previews, 0));
|
||||
d.exec("playhead.set", json!({"frame": 7}));
|
||||
d.frames(3);
|
||||
assert!(d.harness.state().frames.queue_len() > 0, "frames are queued");
|
||||
// with nothing else going on the UI goes idle (no repaint asked for) instead of polling
|
||||
let idle = (0..5).any(|_| {
|
||||
d.frames(1);
|
||||
d.harness.output().viewport_output[&egui::ViewportId::ROOT].repaint_delay > std::time::Duration::from_secs(1)
|
||||
});
|
||||
assert!(idle, "the UI kept repainting while frames were queued");
|
||||
}
|
||||
|
||||
/// #2438 review: when another project's media pool replaces the session's, the frame server is
|
||||
/// rebuilt in the middle of a frame; it must be able to wake the UI at once, since nothing polls
|
||||
/// for its queued frames any more.
|
||||
#[test]
|
||||
fn a_rebuilt_frame_server_wakes_the_ui_from_its_first_frame() {
|
||||
let mut d = Driver::demo();
|
||||
let before = d.harness.state().frames.clone();
|
||||
d.harness.state_mut().session.media = std::sync::Arc::new(filmcraft_engine::media_pool::MediaPool::default());
|
||||
d.harness.step();
|
||||
let after = d.harness.state().frames.clone();
|
||||
assert!(!std::sync::Arc::ptr_eq(&before, &after), "the frame server was rebuilt");
|
||||
assert!(after.wakes_ui(), "its finished frames wake the UI");
|
||||
}
|
||||
|
||||
/// #1682: thumbnail textures (timeline tiles, hover scrubs) are capped instead of one being kept
|
||||
/// for every frame ever shown.
|
||||
#[test]
|
||||
@@ -262,3 +298,60 @@ fn effect_controls_hint_that_clip_effects_are_not_remapped() {
|
||||
let label = "Responsive Design – Time remaps the graphic's layer keyframes; clip effects like Opacity and Motion keep media time";
|
||||
let _ = d.harness.get_by_role_and_label(egui::accesskit::Role::Button, label);
|
||||
}
|
||||
/// #1673: a value dragged in Effect Controls while playing makes a revision per UI pass, so the
|
||||
/// frame due is never ready at the project's revision. The Program monitor shows the frames the
|
||||
/// workers finish for the last few revisions and keeps playing; it used to hold the picture from
|
||||
/// before the drag until the mouse rested.
|
||||
#[test]
|
||||
fn program_monitor_plays_on_while_a_value_is_dragged() {
|
||||
let mut d = Driver::demo();
|
||||
let clip = d.exec("sequence.inspect", json!({}))["video"][0]["items"][0]["clip"].as_u64().unwrap();
|
||||
d.exec("effects.apply", json!({"clips": [clip], "effect": "crop"}));
|
||||
d.exec("playhead.set", json!({"seconds": 1.0}));
|
||||
let at_rest = d.settled();
|
||||
d.harness.state_mut().play(1.0);
|
||||
let mut frames = std::collections::BTreeSet::new();
|
||||
let mut newest = at_rest.revision;
|
||||
for i in 0..90 {
|
||||
// one change and one UI pass, as a mouse move while dragging the value
|
||||
d.exec("effects.setParam", json!({"clip": clip, "effect": "crop", "param": "left", "value": (i % 30) as f64}));
|
||||
let shown = d.harness.state().program_picture().unwrap();
|
||||
frames.insert(shown.frame);
|
||||
newest = newest.max(shown.revision);
|
||||
std::thread::sleep(std::time::Duration::from_millis(16));
|
||||
}
|
||||
assert!(frames.len() >= 5, "the picture kept playing during the drag: frames shown {frames:?}");
|
||||
assert!(newest > at_rest.revision, "the picture showed the dragged value, not only the one from before");
|
||||
}
|
||||
|
||||
/// The same for playback scheduling: a frame being rendered ahead of the playhead is kept when
|
||||
/// playback asks for frames at a newer revision (CPU effects take longer than a pass).
|
||||
#[test]
|
||||
fn playback_at_a_newer_revision_keeps_the_frame_being_rendered() {
|
||||
let d = Driver::demo();
|
||||
let app = d.harness.state();
|
||||
let idle = || {
|
||||
for _ in 0..200_000 {
|
||||
if app.frames.queue_len() == 0 {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_micros(50));
|
||||
}
|
||||
panic!("the frame queue never emptied");
|
||||
};
|
||||
// the clip with Lumetri Color at full resolution: tens of milliseconds on the CPU
|
||||
let seq = app.session.state.active_sequence.unwrap();
|
||||
let rate = app.session.active_sequence().unwrap().settings.frame_rate;
|
||||
let frame = rate.frame_at(filmcraft_time::Tick::from_seconds_f64(15.0));
|
||||
let key = |frame, revision| FrameKey { target: Target::Sequence(seq), frame, size: 1000, revision, draft: false };
|
||||
let project = app.session.project.clone();
|
||||
idle();
|
||||
// starting to play from the frame before it (preroll: every frame, none skipped as too
|
||||
// expensive): that one, then `frame` and the next ones as prefetch
|
||||
app.frames.schedule_playback(key(frame - 1, 9001), rate, 1.0, &project, 1.0, true);
|
||||
idle(); // the workers have taken them
|
||||
// one change later, still playing from there
|
||||
app.frames.schedule_playback(key(frame - 1, 9002), rate, 1.0, &project, 1.0, true);
|
||||
d.ready(key(frame, 9001));
|
||||
d.ready(key(frame, 9002));
|
||||
}
|
||||
|
||||
@@ -598,7 +598,10 @@ fn eyedropper_picks_a_key_color_on_a_selected_clip() {
|
||||
d.exec("effects.apply", json!({"effect": "Color Key"}));
|
||||
d.ok("ui.panel.show", json!({"panel": "EffectControls"}));
|
||||
d.frames(4);
|
||||
let color = |d: &mut Driver| d.sequence()["video"][0]["items"][0]["effects"][0]["params"]["color"]["value"].clone();
|
||||
let color = |d: &mut Driver| {
|
||||
let seq = d.sequence();
|
||||
seq["video"][0]["items"][0]["effects"].as_array().unwrap().iter().find(|e| e["effect"] == "color_key").unwrap()["params"]["color"]["value"].clone()
|
||||
};
|
||||
let before = color(&mut d);
|
||||
d.ok("ui.click", json!({"id": "effectControls.color_key.color.eyedropper"}));
|
||||
d.frames(2);
|
||||
|
||||
@@ -478,3 +478,62 @@ fn rotating_from_beside_an_edge() {
|
||||
let r = d.num(clip, "motion", "rotation");
|
||||
assert!(r > 5.0, "up from beside the left edge turns clockwise: {r}");
|
||||
}
|
||||
|
||||
/// Corner Pin gets a handle on each corner while it is the selected effect, on the corners of the
|
||||
/// picture; dragging one moves only that corner, and dragging inside the outline moves all four,
|
||||
/// each drag in one undo step (#776).
|
||||
#[test]
|
||||
fn corner_pin_handles_move_its_corners() {
|
||||
let mut d = Driver::demo();
|
||||
let (clip, _) = d.first_clip();
|
||||
// half size, so every corner is on the monitor
|
||||
d.exec("effects.setParam", json!({"clip": clip, "effect": "motion", "param": "scale", "value": 50.0}));
|
||||
d.exec("effects.apply", json!({"clips": [clip], "effect": "corner_pin"}));
|
||||
d.frames(2);
|
||||
d.click("effectControls.effect.motion");
|
||||
let box_corners: Vec<(f64, f64)> = (0..4).map(|n| d.center(&format!("program.transform.handle.{n}"))).collect();
|
||||
assert!(d.find("program.cornerPin.handle.0").is_none(), "no corner handles while Motion is selected");
|
||||
d.exec("effects.select", json!({"clip": clip, "effect": "corner_pin"}));
|
||||
d.frames(2);
|
||||
assert!(d.find("program.transform.box").is_none(), "Corner Pin has no transform box");
|
||||
for (n, c) in box_corners.iter().enumerate() {
|
||||
let at = d.center(&format!("program.cornerPin.handle.{n}"));
|
||||
assert!(near(at, *c), "corner {n} starts on the picture's corner: {at:?} vs {c:?}");
|
||||
}
|
||||
d.shot("corner-pin-selected");
|
||||
|
||||
let others: Vec<(f64, f64)> = ["upper_right", "lower_right", "lower_left"].iter().map(|p| d.point(clip, "corner_pin", p)).collect();
|
||||
let undo = d.undo_len();
|
||||
let at = d.center("program.cornerPin.handle.0");
|
||||
d.drag("program.cornerPin.handle.0", 40.0, 20.0);
|
||||
assert!(near(d.center("program.cornerPin.handle.0"), (at.0 + 40.0, at.1 + 20.0)), "the handle follows the pointer");
|
||||
let ul = d.point(clip, "corner_pin", "upper_left");
|
||||
assert!(ul.0 > 1.0 && ul.1 > 1.0, "the upper-left corner moves in: {ul:?}");
|
||||
let now: Vec<(f64, f64)> = ["upper_right", "lower_right", "lower_left"].iter().map(|p| d.point(clip, "corner_pin", p)).collect();
|
||||
// (auto points are NaN, which never equal themselves: compare their bits)
|
||||
let bits = |v: &[(f64, f64)]| v.iter().map(|(x, y)| (x.to_bits(), y.to_bits())).collect::<Vec<_>>();
|
||||
assert_eq!(bits(&now), bits(&others), "the other corners stay put");
|
||||
assert_eq!(d.undo_len(), undo + 1, "one undo step per drag");
|
||||
d.shot("corner-pin-dragged");
|
||||
d.exec("edit.undo", json!({}));
|
||||
d.frames(2);
|
||||
assert!(near(d.center("program.cornerPin.handle.0"), at), "undo puts the corner back");
|
||||
|
||||
// a drag inside the outline (away from the handles) moves every corner by the same amount
|
||||
let before: Vec<(f64, f64)> = (0..4).map(|n| d.center(&format!("program.cornerPin.handle.{n}"))).collect();
|
||||
let undo = d.undo_len();
|
||||
let b = d.rect("program.cornerPin.box");
|
||||
d.drag_from((b[0] + b[2] * 0.4, b[1] + b[3] * 0.4), 30.0, -15.0);
|
||||
for (n, c) in before.iter().enumerate() {
|
||||
let now = d.center(&format!("program.cornerPin.handle.{n}"));
|
||||
assert!(near(now, (c.0 + 30.0, c.1 - 15.0)), "corner {n} moves with the drag: {c:?} → {now:?}");
|
||||
}
|
||||
assert_eq!(d.undo_len(), undo + 1, "the four corners move in one undo step");
|
||||
d.shot("corner-pin-moved");
|
||||
// a drag beside the outline (outside the picture) moves nothing
|
||||
let pins: Vec<(f64, f64)> = ["upper_left", "upper_right", "lower_right", "lower_left"].iter().map(|p| d.point(clip, "corner_pin", p)).collect();
|
||||
let b = d.rect("program.cornerPin.box");
|
||||
d.drag_from((b[0] - 40.0, b[1] + b[3] / 2.0), 20.0, 0.0);
|
||||
let after: Vec<(f64, f64)> = ["upper_left", "upper_right", "lower_right", "lower_left"].iter().map(|p| d.point(clip, "corner_pin", p)).collect();
|
||||
assert_eq!(after, pins);
|
||||
}
|
||||
|
||||
@@ -422,3 +422,32 @@ fn the_cut_is_grabbed_over_the_clips_and_stops_at_the_rolls_limit() {
|
||||
drag_cut(&mut d, limit.0 as f64 / moved as f64 * 20.0 + 200.0);
|
||||
assert_eq!(cut_of(&mut d), cut1 + limit.0, "stopped at the limit");
|
||||
}
|
||||
|
||||
/// #3573: right-clicking a video transition in the Effects panel offers Set as Default Transition,
|
||||
/// and Cmd+D then applies that transition.
|
||||
#[test]
|
||||
fn set_as_default_transition_from_the_effects_panel_feeds_cmd_d() {
|
||||
let mut d = Driver::demo();
|
||||
d.ok("ui.panel.show", json!({"panel": "Effects"}));
|
||||
d.ok("ui.set", json!({"effectsSearch": "Iris Round"}));
|
||||
d.frames(3);
|
||||
d.ok("ui.click", json!({"id": "effects.item.iris_round", "button": "right"}));
|
||||
d.frames(3);
|
||||
d.click("effects.itemMenu.setDefaultTransition");
|
||||
assert_eq!(d.harness.state().session.state.default_video_transition, "iris_round");
|
||||
// a plain effect has no such item
|
||||
d.ok("ui.set", json!({"effectsSearch": "Gaussian Blur"}));
|
||||
d.frames(3);
|
||||
d.ok("ui.click", json!({"id": "effects.item.gaussian_blur", "button": "right"}));
|
||||
d.frames(3);
|
||||
assert!(d.find("effects.itemMenu.setDefaultTransition").is_none());
|
||||
d.ok("ui.key", json!({"key": "Escape"}));
|
||||
// Cmd+D on a selected clip uses it
|
||||
let q = d.exec("sequence.inspect", json!({}));
|
||||
let clip = q["video"][0]["items"][2]["clip"].clone();
|
||||
d.exec("timeline.select", json!({"clips": [clip]}));
|
||||
d.ok("ui.key", json!({"key": "Cmd+D"}));
|
||||
d.frames(2);
|
||||
let q = d.exec("sequence.inspect", json!({}));
|
||||
assert!(q["video"][0]["transitions"].as_array().unwrap().iter().any(|t| t["to"] == clip && t["effect"] == "iris_round"), "{q}");
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ against 793 s with our software encoder (SSIM 0.990). NVENC HEVC round trip 48.9
|
||||
| Feature | Premiere (Mercury Playback Engine: Metal, CUDA, OpenCL) | Ours (wgpu: Metal, D3D12, Vulkan) | % |
|
||||
|---|---|---|---|
|
||||
| Compositing, blend modes, transforms | GPU | **GPU** (#32), CPU compositor is the oracle | 90% |
|
||||
| Accelerated video effects | ~82 of ~92 effects marked Accelerated (reference list `plan/premiere/10-effects-list.md`) | **34 effect ids** (`render::gpufx::GPU_EFFECTS`): colour, levels, blurs, sharpen, crop, transform, flips, vignette, video limiter, ASC CDL, channel mix, Lumetri basic / creative / vignette | ~40% |
|
||||
| Accelerated video effects | ~82 of ~92 effects marked Accelerated (reference list `plan/premiere/10-effects-list.md`) | **35 effect ids** (`render::gpufx::GPU_EFFECTS`): colour, levels, blurs (Bokeh Blur included), sharpen, crop, transform, flips, vignette, video limiter, ASC CDL, channel mix, Lumetri basic / creative / vignette | ~40% |
|
||||
| Lumetri curves, wheels, HSL secondary | GPU | CPU | 0% |
|
||||
| Keys (Ultra Key), masks, distortions, transitions | GPU | CPU (masks have WGSL parity tests, not in the live path) | 10% |
|
||||
| Export rendering on the GPU | yes | **yes** (#421; Export ▸ GPU Rendering, Auto): 2.4× (1080p) to 3.2× (4K) faster on effects-heavy timelines | 80% |
|
||||
@@ -74,7 +74,7 @@ against 793 s with our software encoder (SSIM 0.990). NVENC HEVC round trip 48.9
|
||||
## Summary
|
||||
|
||||
**Hardware dimension: ~45%, 120–200 h.** Ahead of Premiere: hardware decode on Linux (VA-API,
|
||||
NVDEC) and FreeBSD builds. Behind, by user impact: zero-copy upload; GPU effects beyond 34
|
||||
NVDEC) and FreeBSD builds. Behind, by user impact: zero-copy upload; GPU effects beyond 35
|
||||
(Lumetri curves / wheels / HSL, keys, masks, transitions); Quick Sync and AMD encoders on Windows;
|
||||
VideoToolbox Main 10 HDR encode; 10-bit / 4:2:2 decode off macOS; HDR display; external video
|
||||
I/O and control surfaces (need hardware we don't own). Calibration: one decode backend ~3–5 h,
|
||||
|
||||
+1
-1
@@ -353,7 +353,7 @@ cargo xtask bench-playback --json target/bench-playback.json # machine-readable
|
||||
| `stack3-blend` | `stack3` with V2 in Screen and V3 in Overlay (blend modes on the GPU) |
|
||||
| `h264-2160` | one 2160p23.976 H.264 clip |
|
||||
| `demo` | the built-in demo project (procedural footage, transitions, effects) |
|
||||
| `after-preview` | 1080p H.264 + Lumetri/Sharpen/Levels/Tint: a live play, Render Effects In to Out, then two plays of the green segment |
|
||||
| `after-preview` | 1080p H.264 + Lumetri/Sharpen/Levels/Tint: a live play, Render In to Out, then two plays of the green segment |
|
||||
| `seek-storm` | 40 jumps to random frames 150 ms apart (scrubbing), time until the exact frame shows |
|
||||
|
||||
Options: `--res full,half,quarter`, `--cpu` (CPU compositor + texture conversion instead of the
|
||||
|
||||
Reference in New Issue
Block a user