Merge integration track fx

This commit is contained in:
aiongg
2026-10-31 02:19:08 +00:00
55 changed files with 2535 additions and 278 deletions
+37 -6
View File
@@ -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);
+2 -3
View File
@@ -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;
}
}
+2 -2
View File
@@ -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);
+54 -22
View File
@@ -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() {
+75
View File
@@ -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");
}
+61 -7
View File
@@ -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] {
+2 -2
View File
@@ -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!({
+3 -5
View File
@@ -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);
}
}
+2 -2
View File
@@ -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(),
+19 -4
View File
@@ -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())))?;
+83 -22
View File
@@ -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
+80 -5
View File
@@ -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");
+54
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+81
View File
@@ -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");
+21
View File
@@ -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",
+13 -1
View File
@@ -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)));
+15
View File
@@ -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();
+5 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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");
}
}
+1 -1
View File
@@ -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),
+8 -17
View File
@@ -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.
+126 -25
View File
@@ -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, &region, 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, &region, 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);
}
+173
View File
@@ -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}");
}
}
}
+9 -4
View File
@@ -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"))),
+7 -1
View File
@@ -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() {
+65 -4
View File
@@ -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
-1
View File
@@ -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.
-1
View File
@@ -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.
-1
View File
@@ -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.
-1
View File
@@ -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.
-1
View File
@@ -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.
-1
View File
@@ -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.
-1
View File
@@ -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.
-1
View File
@@ -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.
-1
View File
@@ -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.
+33 -7
View File
@@ -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};
+1 -1
View File
@@ -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 } => {
+169 -29
View File
@@ -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");
}
}
}
+24
View File
@@ -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();
+128 -47
View File
@@ -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);
}
}
+2 -2
View File
@@ -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);
+25 -10
View File
@@ -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() {
+149 -13
View File
@@ -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))
+164
View File
@@ -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");
}
+105
View File
@@ -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");
}
+93
View File
@@ -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));
}
+4 -1
View File
@@ -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);
}
+29
View File
@@ -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}");
}
+2 -2
View File
@@ -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
View File
@@ -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