diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 06967d51..0a9793ef 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -47,6 +47,10 @@ procedural demo footage (`crates/media/src/generators.rs`); no external media. R | `crates/golden/goldens/transition_cross_dissolve.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `transition_cross_dissolve`) from procedurally generated media | MIT OR Apache-2.0 | | `crates/golden/goldens/transition_dip_to_black.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `transition_dip_to_black`) from procedurally generated media | MIT OR Apache-2.0 | | `crates/golden/goldens/transition_wipe.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `transition_wipe`) from procedurally generated media | MIT OR Apache-2.0 | +| `crates/golden/goldens/mask_blur.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `mask_blur`) from procedurally generated media | MIT OR Apache-2.0 | +| `crates/golden/goldens/mask_color.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `mask_color`) from procedurally generated media | MIT OR Apache-2.0 | +| `crates/golden/goldens/mask_inverted.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `mask_inverted`) from procedurally generated media | MIT OR Apache-2.0 | +| `crates/golden/goldens/mask_opacity.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `mask_opacity`) from procedurally generated media | MIT OR Apache-2.0 | ## Third-party media shown in screenshots diff --git a/Cargo.lock b/Cargo.lock index a26c5e7d..64536dd4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1531,6 +1531,7 @@ dependencies = [ "filmcraft-frame", "filmcraft-geom", "filmcraft-media", + "filmcraft-project", "filmcraft-render", "filmcraft-time", "log", diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 26e31551..af5f68a2 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1672,7 +1672,7 @@ fn build() -> Vec { "Set Effect Parameter", [], None, - r##"{"clip":id,"effect":"motion"|index,"param":str,"value":num|[x,y]|"#rrggbb"|bool,"time":ticks?}"##, + r##"{"clip":id,"effect":"motion"|index,"param":str,"mask":n?,"value":num|[x,y]|"#rrggbb"|bool|path,"time":ticks?}"##, has_seq, |s, p| { let c = clip_p(p, "clip").ok_or_else(|| bad("effects.setParam", "need `clip`"))?; @@ -1681,6 +1681,7 @@ fn build() -> Vec { let eff = p.get("effect").cloned().unwrap_or(json!("motion")); let ph = s.playhead(); let tl = time_p(s, p, "").unwrap_or(ph); + let pq = p.clone(); s.edit_sequence("Change Effect Parameter", |q, _, _| { let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?; let mt = it.source_time_at(tl.clamp(it.start, it.end() - Tick(1))); @@ -1699,7 +1700,7 @@ fn build() -> Vec { { e.params.insert(pid.clone(), filmcraft_project::Param::new(d.default.clone())); } - let prm = e.params.get_mut(&pid).ok_or_else(|| bad("effects.setParam", format!("no param `{pid}`")))?; + let prm = crate::masks::target_param(e, &pq, &pid).ok_or_else(|| bad("effects.setParam", format!("no param `{pid}`")))?; let v = json_to_param(&prm.value, &val).ok_or_else(|| bad("effects.setParam", "value has the wrong type"))?; prm.set_at(mt, v); Ok(()) @@ -1707,7 +1708,7 @@ fn build() -> Vec { Ok(Value::Null) } ), - cmd!("effects.toggleAnimation", "Toggle Animation", [], None, r#"{"clip":id,"effect":str|index,"param":str}"#, has_seq, |s, p| { + cmd!("effects.toggleAnimation", "Toggle Animation", [], None, r#"{"clip":id,"effect":str|index,"param":str,"mask":n?}"#, has_seq, |s, p| { let c = clip_p(p, "clip").ok_or_else(|| bad("effects.toggleAnimation", "need `clip`"))?; let pid = str_p(p, "param").ok_or_else(|| bad("effects.toggleAnimation", "need `param`"))?.to_string(); let eff = p.get("effect").cloned().unwrap_or(json!("motion")); @@ -1721,7 +1722,7 @@ fn build() -> Vec { _ => None, } .ok_or_else(|| bad("effects.toggleAnimation", "no such effect"))?; - e.params.get_mut(&pid).ok_or_else(|| bad("effects.toggleAnimation", "no such param"))?.toggle_animation(mt); + crate::masks::target_param(e, p, &pid).ok_or_else(|| bad("effects.toggleAnimation", "no such param"))?.toggle_animation(mt); Ok(()) })?; Ok(Value::Null) @@ -1755,9 +1756,15 @@ fn build() -> Vec { })?; Ok(Value::Null) }), - cmd!("effects.addKeyframe", "Add/Remove Keyframe", [], None, r#"{"clip":id,"effect":str|index,"param":str,"time":ticks?}"#, has_seq, |s, p| { - keyframe_op(s, p, "add") - }), + cmd!( + "effects.addKeyframe", + "Add/Remove Keyframe", + [], + None, + r#"{"clip":id,"effect":str|index,"param":str,"mask":n?,"time":ticks?}"#, + has_seq, + |s, p| { keyframe_op(s, p, "add") } + ), cmd!("effects.deleteKeyframe", "Delete Keyframe", [], None, r#"{"clip":id,"effect":str|index,"param":str,"mediaTime":ticks}"#, has_seq, |s, p| { keyframe_op(s, p, "delete") }), @@ -1876,6 +1883,7 @@ fn build() -> Vec { v.extend(crate::relink::commands()); v.extend(crate::proxies::commands()); v.extend(crate::project_manager::commands()); + v.extend(crate::masks::commands()); // Labels as individual commands (Edit ▸ Label ▸ ) for l in Label::ALL { let _ = l; @@ -1956,7 +1964,7 @@ fn keyframe_op(s: &mut Session, p: &Value, op: &str) -> Result { _ => None, } .ok_or_else(|| bad("keyframe", "no such effect"))?; - let prm = e.params.get_mut(&pid).ok_or_else(|| bad("keyframe", "no such param"))?; + let prm = crate::masks::target_param(e, p, &pid).ok_or_else(|| bad("keyframe", "no such param"))?; match op { "add" => { // toggle: remove if a keyframe sits at the playhead, else add @@ -2068,6 +2076,7 @@ pub(crate) fn json_to_param(template: &ParamValue, v: &Value) -> Option ParamValue::Bool(v.as_bool()?), ParamValue::Choice(_) => ParamValue::Choice(v.as_u64()? as u32), ParamValue::Text(_) => ParamValue::Text(v.as_str()?.to_string()), + ParamValue::Path(_) => ParamValue::Path(crate::masks::path_from_json(v)?), ParamValue::Curve(_) => { ParamValue::Curve(v.as_array()?.iter().filter_map(|p| Some([p.get(0)?.as_f64()? as f32, p.get(1)?.as_f64()? as f32])).collect()) } @@ -2273,7 +2282,7 @@ pub fn inspect_sequence(s: &Session, id: ItemId, q: &filmcraft_project::Sequence "clip": i.id.0, "item": i.item.0, "name": i.name, "start": i.start.0, "duration": i.duration.0, "startFrame": rate.frame_at(i.start), "durationFrames": rate.frame_at(i.duration), "sourceIn": i.source_in.0, "speed": i.speed, "enabled": i.enabled, "link": i.link, "label": i.label.name(), - "effects": i.effects.iter().map(|e| json!({"effect": e.effect, "enabled": e.enabled, "params": e.params.iter().map(|(k, p)| (k.clone(), json!({"value": format!("{:?}", p.value), "keyframes": p.keyframes.len()}))).collect::>()})).collect::>(), + "effects": i.effects.iter().map(|e| json!({"effect": e.effect, "enabled": e.enabled, "masks": e.masks.len(), "params": e.params.iter().map(|(k, p)| (k.clone(), json!({"value": format!("{:?}", p.value), "keyframes": p.keyframes.len()}))).collect::>()})).collect::>(), })).collect::>(), "transitions": t.transitions.iter().map(|x| json!({"id": x.id.0, "effect": x.effect.effect, "start": x.start.0, "duration": x.duration.0, "from": x.from.map(|c| c.0), "to": x.to.map(|c| c.0)})).collect::>(), }) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 391f4d00..2e8c97f5 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -16,6 +16,7 @@ pub mod demo; pub mod essential_sound; pub mod graphics; pub mod interchange; +pub mod masks; pub mod media_pool; pub mod mixer; pub mod previews; @@ -168,6 +169,9 @@ pub struct EditorState { /// Selected layers (indices among the graphic layers, 0 = back) of the selected graphic clip. #[serde(default)] pub graphic_layers: Vec, + /// The mask being edited on the Program monitor (Effect Controls selection). + #[serde(default)] + pub selected_mask: Option, } /// Events for frontends (drained each frame). @@ -506,6 +510,13 @@ impl Session { /// Drop dangling references after undo/redo/delete. pub fn fix_state(&mut self) { let p = self.project.clone(); + if let Some(m) = self.state.selected_mask { + let ok = + self.active_sequence().and_then(|q| q.find_item(m.clip)).and_then(|(_, it)| it.effects.get(m.effect)).is_some_and(|e| m.mask < e.masks.len()); + if !ok { + self.state.selected_mask = None; + } + } if let Some(s) = self.state.active_sequence && p.sequence(s).is_none() { @@ -621,6 +632,8 @@ mod essential_sound_tests; #[cfg(test)] mod file_tests; #[cfg(test)] +mod masks_tests; +#[cfg(test)] mod media_test_util; #[cfg(test)] mod mixer_tests; diff --git a/crates/engine/src/masks.rs b/crates/engine/src/masks.rs new file mode 100644 index 00000000..185dc533 --- /dev/null +++ b/crates/engine/src/masks.rs @@ -0,0 +1,499 @@ +//! Effect masks: `masks.*` commands (add / remove / edit / select / list). +//! +//! A mask lives on an effect instance of a video clip (`effect`: index or id; Opacity masks cut +//! out the clip itself). Commands address it with `clip` (default: the selected mask's clip, else +//! the first selected video clip), `effect` (default: the selected mask's effect, else `opacity`) +//! and `mask` (index, default: the selected mask). Path geometry is in clip pixels. Edits at the +//! playhead add/replace a keyframe when the parameter is animated, like every effect parameter; +//! keyframe commands (`effects.addKeyframe`, `effects.toggleAnimation`, …) take `"mask": n` to +//! address `path` / `feather` / `opacity` / `expansion`. Every edit is one undo step (`merge`: a +//! key folds a continuous drag into one step). + +use filmcraft_geom::{Affine, Vec2}; +use filmcraft_project::{ClipId, EffectInstance, Mask, MaskMode, MaskPath, MaskVertex, ParamValue, TrackKind, TrackMethod}; +use filmcraft_time::Tick; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::commands::{CommandSpec, bad, bool_p, clip_p, f64_p, has_seq, str_p, time_p, u64_p}; +use crate::{EngineError, Result, Session}; + +type Run = fn(&mut Session, &Value) -> Result; + +/// The mask selected for on-monitor editing. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct MaskSel { + pub clip: ClipId, + pub effect: usize, + pub mask: usize, +} + +fn spec(id: &'static str, label: &'static str, params: &'static str, run: Run) -> CommandSpec { + CommandSpec { id, label, menu: &[], shortcut: None, params, enabled: has_seq, run, journal: true } +} + +pub(crate) fn commands() -> Vec { + vec![ + spec( + "masks.add", + "Create Mask", + r#"{"clip":id?,"effect":index|id?="opacity","shape":"ellipse"|"polygon"|"bezier","path":[[x,y]…]|{vertices}?,"center":[x,y]?,"size":[w,h]?}"#, + add, + ), + spec("masks.remove", "Delete Mask", r#"{"clip":id?,"effect":index|id?,"mask":n?}"#, remove), + spec( + "masks.set", + "Change Mask", + r#"{"clip":id?,"effect":index|id?,"mask":n?,"name":str?,"inverted":bool?,"mode":"none|add|subtract|intersect|lighten|darken|difference"?,"trackMethod":"position|positionRotation|positionScaleRotation"?,"feather":px?,"opacity":pct?,"expansion":px?,"path":path?,"time":ticks?,"merge":key?}"#, + set, + ), + spec( + "masks.moveVertex", + "Move Mask Vertex", + r#"{"clip":id?,"effect":index|id?,"mask":n?,"vertex":n,"handle":"point"|"in"|"out"?,"to":[x,y]?|"delta":[dx,dy]?,"breakHandles":bool?,"merge":key?}"#, + move_vertex, + ), + spec("masks.translate", "Move Mask", r#"{"clip":id?,"effect":index|id?,"mask":n?,"delta":[dx,dy],"merge":key?}"#, translate), + spec("masks.addVertex", "Add Mask Vertex", r#"{"clip":id?,"effect":index|id?,"mask":n?,"after":n,"at":[x,y]}"#, add_vertex), + spec("masks.removeVertex", "Delete Mask Vertex", r#"{"clip":id?,"effect":index|id?,"mask":n?,"vertex":n}"#, remove_vertex), + spec("masks.toggleVertexSmooth", "Convert Mask Vertex", r#"{"clip":id?,"effect":index|id?,"mask":n?,"vertex":n}"#, toggle_smooth), + CommandSpec { + id: "masks.select", + label: "Select Mask", + menu: &[], + shortcut: None, + params: r#"{"clip":id,"effect":index|id,"mask":n}|{"none":true}"#, + enabled: has_seq, + run: select, + journal: false, + }, + CommandSpec { + id: "masks.list", + label: "List Masks", + menu: &[], + shortcut: None, + params: r#"{"clip":id?,"time":ticks?}"#, + enabled: has_seq, + run: list, + journal: false, + }, + ] +} + +/// A mask path from JSON: the serialized [`MaskPath`] object, an array of vertices +/// (`{"p":[x,y],"in":[x,y]?,"out":[x,y]?}`), or an array of `[x, y]` / `{"x","y"}` points. +pub fn path_from_json(v: &Value) -> Option { + if let Ok(p) = serde_json::from_value::(v.clone()) { + return Some(p); + } + let arr = v.as_array().or_else(|| v.get("vertices").and_then(Value::as_array))?; + let closed = v.get("closed").and_then(Value::as_bool).unwrap_or(true); + let mut vertices = Vec::new(); + for e in arr { + if let Some(p) = e.get("p") { + let t_in = e.get("in").or_else(|| e.get("t_in")).and_then(pt).unwrap_or_default(); + let t_out = e.get("out").or_else(|| e.get("t_out")).and_then(pt).unwrap_or_default(); + vertices.push(MaskVertex { p: pt(p)?, t_in, t_out }); + } else { + vertices.push(MaskVertex::corner(pt(e)?)); + } + } + Some(MaskPath { vertices, closed }) +} + +fn pt(v: &Value) -> Option { + if let Some(a) = v.as_array() { + return Some(Vec2::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?)); + } + Some(Vec2::new(v.get("x")?.as_f64()?, v.get("y")?.as_f64()?)) +} + +pub fn path_to_json(p: &MaskPath) -> Value { + json!({ + "closed": p.closed, + "vertices": p.vertices.iter().map(|v| json!({"p": [v.p.x, v.p.y], "in": [v.t_in.x, v.t_in.y], "out": [v.t_out.x, v.t_out.y]})).collect::>(), + }) +} + +/// Resolve the target clip (and its source size in pixels). +fn target_clip(s: &Session, p: &Value) -> Result { + if let Some(c) = clip_p(p, "clip") { + return Ok(c); + } + if let Some(sel) = s.state.selected_mask { + return Ok(sel.clip); + } + let seq = s.active_sequence().ok_or(EngineError::NoSequence)?; + s.state + .selection + .iter() + .copied() + .find(|c| seq.find_item(*c).and_then(|(t, _)| seq.track(t)).is_some_and(|t| t.kind == TrackKind::Video)) + .ok_or_else(|| bad("masks", "select a video clip (or pass `clip`)")) +} + +fn effect_index(effects: &[EffectInstance], v: Option<&Value>, sel: Option, clip: ClipId) -> Option { + match v { + Some(Value::Number(n)) => n.as_u64().map(|n| n as usize).filter(|i| *i < effects.len()), + Some(Value::String(id)) => effects.iter().position(|e| &e.effect == id), + _ => match sel { + Some(s) if s.clip == clip && s.effect < effects.len() => Some(s.effect), + _ => effects.iter().position(|e| e.effect == "opacity"), + }, + } +} + +/// Clip pixel size of a track item's source. +fn source_size(s: &Session, clip: ClipId) -> (f64, f64) { + let seq = s.active_sequence(); + let it = seq.and_then(|q| q.find_item(clip)).map(|(_, i)| i); + let dims = it.and_then(|i| s.project.item(i.item)).and_then(|pi| match &pi.kind { + filmcraft_project::ItemKind::Media(m) => m.info.video.as_ref().map(|v| (v.width, v.height)), + filmcraft_project::ItemKind::Sequence(q) => Some((q.settings.width, q.settings.height)), + filmcraft_project::ItemKind::AdjustmentLayer { width, height, .. } | filmcraft_project::ItemKind::Graphic { width, height, .. } => { + Some((*width, *height)) + } + filmcraft_project::ItemKind::Subclip { parent, .. } => { + s.project.item(*parent).and_then(|pp| pp.as_media()).and_then(|m| m.info.video.as_ref()).map(|v| (v.width, v.height)) + } + }); + let (w, h) = dims.or_else(|| seq.map(|q| (q.settings.width, q.settings.height))).unwrap_or((1920, 1080)); + (w as f64, h as f64) +} + +/// Run `f` on the addressed mask as one undoable edit. `f` gets the mask and the clip (media) time +/// of the edit. +fn edit_mask(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut Mask, Tick) -> Result) -> Result { + let clip = target_clip(s, p)?; + let sel = s.state.selected_mask; + let ph = time_p(s, p, "").unwrap_or(s.playhead()); + let eff = p.get("effect").cloned(); + let mask_i = u64_p(p, "mask").map(|m| m as usize).or(sel.filter(|x| x.clip == clip).map(|x| x.mask)); + let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?; + let body = move |pr: &mut filmcraft_project::Project, _: &mut crate::EditorState| -> Result { + 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 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`"))?; + let m = e.masks.get_mut(mi).ok_or_else(|| bad(label, format!("no mask {mi}")))?; + f(m, mt) + }; + match str_p(p, "merge") { + Some(k) => { + let key = format!("mask:{k}"); + s.edit_merged(label, &key, body) + } + None => s.edit(label, body), + } +} + +fn set_path_at(m: &mut Mask, t: Tick, path: MaskPath) { + m.path.set_at(t, ParamValue::Path(path)); +} + +fn add(s: &mut Session, p: &Value) -> Result { + let clip = target_clip(s, p)?; + let (w, h) = source_size(s, clip); + let shape = str_p(p, "shape").unwrap_or("ellipse").to_ascii_lowercase(); + let center = p.get("center").and_then(pt).unwrap_or(Vec2::new(w / 2.0, h / 2.0)); + let r = w.min(h) * 0.2; + let size = p.get("size").and_then(pt).unwrap_or(Vec2::new(r * 2.0, r * 2.0)); + let path = match p.get("path") { + Some(v) => path_from_json(v).ok_or_else(|| bad("masks.add", "bad `path`"))?, + None => match shape.as_str() { + "ellipse" => MaskPath::ellipse(center, size * 0.5), + "polygon" | "rectangle" | "rect" | "4-point" => { + MaskPath::rect(center.x - size.x / 2.0, center.y - size.y / 2.0, center.x + size.x / 2.0, center.y + size.y / 2.0) + } + _ => return Err(bad("masks.add", "a bezier mask needs `path` (pen points)")), + }, + }; + if path.len() < 3 && path.closed { + return Err(bad("masks.add", "a mask needs at least 3 vertices")); + } + let eff = p.get("effect").cloned(); + let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?; + let (ei, mi, name) = s.edit("Create Mask", move |pr, st| { + 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 ei = effect_index(&it.effects, eff.as_ref(), None, clip).ok_or_else(|| bad("masks.add", "no such effect on clip"))?; + let e = &mut it.effects[ei]; + if e.def().is_none_or(|d| d.kind != filmcraft_project::EffectKind::Video) { + return Err(bad("masks.add", "masks apply to video effects")); + } + if e.effect == "motion" { + return Err(bad("masks.add", "Motion has no masks")); + } + let n = e.masks.iter().filter_map(|m| m.name.strip_prefix("Mask (")?.strip_suffix(')')?.parse::().ok()).max().unwrap_or(0) + 1; + let name = format!("Mask ({n})"); + e.masks.push(Mask::new(name.clone(), path)); + let mi = e.masks.len() - 1; + st.selected_mask = Some(MaskSel { clip, effect: ei, mask: mi }); + Ok((ei, mi, name)) + })?; + Ok(json!({"clip": clip.0, "effect": ei, "mask": mi, "name": name})) +} + +fn remove(s: &mut Session, p: &Value) -> Result { + let clip = target_clip(s, p)?; + let sel = s.state.selected_mask; + let eff = p.get("effect").cloned(); + let mask_i = u64_p(p, "mask").map(|m| m as usize).or(sel.filter(|x| x.clip == clip).map(|x| x.mask)).ok_or_else(|| bad("masks.remove", "need `mask`"))?; + let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?; + s.edit("Delete Mask", move |pr, st| { + 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 ei = effect_index(&it.effects, eff.as_ref(), sel, clip).ok_or_else(|| bad("masks.remove", "no such effect on clip"))?; + let e = &mut it.effects[ei]; + if mask_i >= e.masks.len() { + return Err(bad("masks.remove", format!("no mask {mask_i}"))); + } + e.masks.remove(mask_i); + if st.selected_mask.is_some_and(|x| x.clip == clip && x.effect == ei) { + st.selected_mask = None; + } + Ok(()) + })?; + Ok(Value::Null) +} + +fn set(s: &mut Session, p: &Value) -> Result { + let path = match p.get("path") { + Some(v) => Some(path_from_json(v).ok_or_else(|| bad("masks.set", "bad `path`"))?), + None => None, + }; + let mode = match str_p(p, "mode") { + Some(m) => Some(MaskMode::from_name(m).ok_or_else(|| bad("masks.set", format!("unknown mode `{m}`")))?), + None => None, + }; + let method = match str_p(p, "trackMethod") { + Some(m) => Some(TrackMethod::from_name(m).ok_or_else(|| bad("masks.set", format!("unknown tracking method `{m}`")))?), + None => None, + }; + let name = str_p(p, "name").map(str::to_string); + let inverted = bool_p(p, "inverted"); + let nums: Vec<(&str, f64)> = ["feather", "opacity", "expansion"].into_iter().filter_map(|k| f64_p(p, k).map(|v| (k, v))).collect(); + edit_mask(s, p, "Change Mask", move |m, t| { + if let Some(n) = name { + m.name = n; + } + if let Some(i) = inverted { + m.inverted = i; + } + if let Some(md) = mode { + m.mode = md; + } + if let Some(me) = method { + m.track_method = me; + } + for (k, v) in nums { + let v = match k { + "feather" => v.max(0.0), + "opacity" => v.clamp(0.0, 100.0), + _ => v, + }; + if let Some(prm) = m.param_mut(k) { + prm.set_at(t, ParamValue::Float(v)); + } + } + if let Some(path) = path { + set_path_at(m, t, path); + } + Ok(()) + })?; + Ok(Value::Null) +} + +fn move_vertex(s: &mut Session, p: &Value) -> Result { + let vi = u64_p(p, "vertex").ok_or_else(|| bad("masks.moveVertex", "need `vertex`"))? as usize; + let handle = str_p(p, "handle").unwrap_or("point").to_string(); + let to = p.get("to").and_then(pt); + let delta = p.get("delta").and_then(pt); + let broken = bool_p(p, "breakHandles").unwrap_or(false); + if to.is_none() && delta.is_none() { + return Err(bad("masks.moveVertex", "need `to` or `delta`")); + } + edit_mask(s, p, "Move Mask Vertex", move |m, t| { + let mut path = m.path_at(t); + let v = path.vertices.get_mut(vi).ok_or_else(|| bad("masks.moveVertex", format!("no vertex {vi}")))?; + match handle.as_str() { + "in" | "out" => { + let cur = if handle == "in" { v.p + v.t_in } else { v.p + v.t_out }; + let target = to.unwrap_or_else(|| cur + delta.unwrap_or_default()); + let tan = target - v.p; + if handle == "in" { + v.t_in = tan; + if !broken { + v.t_out = tan * -1.0; + } + } else { + v.t_out = tan; + if !broken { + v.t_in = tan * -1.0; + } + } + } + _ => { + v.p = to.unwrap_or_else(|| v.p + delta.unwrap_or_default()); + } + } + set_path_at(m, t, path); + Ok(()) + })?; + Ok(Value::Null) +} + +fn translate(s: &mut Session, p: &Value) -> Result { + let d = p.get("delta").and_then(pt).ok_or_else(|| bad("masks.translate", "need `delta`"))?; + edit_mask(s, p, "Move Mask", move |m, t| { + let path = m.path_at(t).transformed(&Affine::translate(d.x, d.y)); + set_path_at(m, t, path); + Ok(()) + })?; + Ok(Value::Null) +} + +fn add_vertex(s: &mut Session, p: &Value) -> Result { + let after = u64_p(p, "after").ok_or_else(|| bad("masks.addVertex", "need `after`"))? as usize; + let at = p.get("at").and_then(pt).ok_or_else(|| bad("masks.addVertex", "need `at`"))?; + edit_mask(s, p, "Add Mask Vertex", move |m, t| { + if m.path.is_animated() && m.path.keyframes.len() > 1 { + // keep every keyframe interpolable: insert the vertex into all of them + for k in &mut m.path.keyframes { + if let ParamValue::Path(path) = &mut k.value { + let i = (after + 1).min(path.vertices.len()); + let a = path.vertices[after.min(path.vertices.len() - 1)].p; + let b = path.vertices[i % path.vertices.len()].p; + path.vertices.insert(i, MaskVertex::corner(a.lerp(b, 0.5))); + } + } + } + let mut path = m.path_at(t); + let i = (after + 1).min(path.vertices.len()); + if m.path.keyframes.len() > 1 { + path.vertices[i] = MaskVertex::corner(at); + } else { + path.vertices.insert(i, MaskVertex::corner(at)); + } + set_path_at(m, t, path); + Ok(()) + })?; + Ok(Value::Null) +} + +fn remove_vertex(s: &mut Session, p: &Value) -> Result { + let vi = u64_p(p, "vertex").ok_or_else(|| bad("masks.removeVertex", "need `vertex`"))? as usize; + edit_mask(s, p, "Delete Mask Vertex", move |m, t| { + if m.path_at(t).len() <= 3 { + return Err(bad("masks.removeVertex", "a mask keeps at least 3 vertices")); + } + let drop = |path: &mut MaskPath| { + if vi < path.vertices.len() { + path.vertices.remove(vi); + } + }; + drop_all(m, t, drop); + Ok(()) + })?; + Ok(Value::Null) +} + +/// Apply a topology change to the path value and every path keyframe. +fn drop_all(m: &mut Mask, _t: Tick, f: impl Fn(&mut MaskPath)) { + if let ParamValue::Path(p) = &mut m.path.value { + f(p); + } + for k in &mut m.path.keyframes { + if let ParamValue::Path(p) = &mut k.value { + f(p); + } + } +} + +fn toggle_smooth(s: &mut Session, p: &Value) -> Result { + let vi = u64_p(p, "vertex").ok_or_else(|| bad("masks.toggleVertexSmooth", "need `vertex`"))? as usize; + edit_mask(s, p, "Convert Mask Vertex", move |m, t| { + let mut path = m.path_at(t); + let n = path.vertices.len(); + if vi >= n { + return Err(bad("masks.toggleVertexSmooth", format!("no vertex {vi}"))); + } + let v = path.vertices[vi]; + if v.is_corner() { + // smooth: tangent along the neighbours' direction, a third of the way to them + let prev = path.vertices[(vi + n - 1) % n].p; + let next = path.vertices[(vi + 1) % n].p; + let dir = (next - prev) * (1.0 / 6.0); + path.vertices[vi] = MaskVertex::smooth(v.p, dir); + } else { + path.vertices[vi] = MaskVertex::corner(v.p); + } + set_path_at(m, t, path); + Ok(()) + })?; + Ok(Value::Null) +} + +fn select(s: &mut Session, p: &Value) -> Result { + if bool_p(p, "none") == Some(true) || p.is_null() || p.as_object().is_some_and(|o| o.is_empty()) { + s.state.selected_mask = None; + return Ok(Value::Null); + } + let clip = target_clip(s, p)?; + let seq = s.active_sequence().ok_or(EngineError::NoSequence)?; + let (_, it) = seq.find_item(clip).ok_or_else(|| bad("masks.select", "no such clip"))?; + let ei = effect_index(&it.effects, p.get("effect"), None, clip).ok_or_else(|| bad("masks.select", "no such effect on clip"))?; + let mi = u64_p(p, "mask").unwrap_or(0) as usize; + if mi >= it.effects[ei].masks.len() { + return Err(bad("masks.select", format!("no mask {mi}"))); + } + s.state.selected_mask = Some(MaskSel { clip, effect: ei, mask: mi }); + Ok(Value::Null) +} + +/// Masks of a clip (or every clip with masks) evaluated at the playhead. +fn list(s: &mut Session, p: &Value) -> Result { + let seq = s.active_sequence().ok_or(EngineError::NoSequence)?; + let ph = time_p(s, p, "").unwrap_or(s.playhead()); + let only = clip_p(p, "clip"); + let mut out = Vec::new(); + for tr in &seq.video_tracks { + for it in &tr.items { + if only.is_some_and(|c| c != it.id) { + continue; + } + let mt = it.source_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!({ + "clip": it.id.0, + "effect": ei, + "effectId": e.effect, + "mask": mi, + "name": m.name, + "mode": m.mode.label(), + "inverted": m.inverted, + "trackMethod": m.track_method.label(), + "feather": m.feather.f64_at(mt), + "opacity": m.opacity.f64_at(mt), + "expansion": m.expansion.f64_at(mt), + "path": path_to_json(&m.path_at(mt)), + "pathKeyframes": m.path.keyframes.iter().map(|k| k.time.0).collect::>(), + "selected": s.state.selected_mask == Some(MaskSel { clip: it.id, effect: ei, mask: mi }), + })); + } + } + } + } + Ok(json!({"masks": out})) +} + +/// The parameter a keyframe / setParam command addresses: a mask parameter when `mask` is given. +pub(crate) fn target_param<'a>(e: &'a mut EffectInstance, p: &Value, pid: &str) -> Option<&'a mut filmcraft_project::Param> { + match u64_p(p, "mask") { + Some(mi) => e.masks.get_mut(mi as usize)?.param_mut(pid), + None => e.params.get_mut(pid), + } +} diff --git a/crates/engine/src/masks_tests.rs b/crates/engine/src/masks_tests.rs new file mode 100644 index 00000000..7cea8130 --- /dev/null +++ b/crates/engine/src/masks_tests.rs @@ -0,0 +1,127 @@ +use serde_json::json; + +use crate::Session; +use filmcraft_project::ClipId; +use filmcraft_time::Tick; + +fn demo() -> (Session, ClipId) { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + let q = s.active_sequence().unwrap(); + let it = q.video_tracks[0].items[0].clone(); + // middle of the first V1 clip (past its fade from black) + s.set_playhead(it.start + Tick(it.duration.0 / 2)); + s.state.selection = vec![it.id]; + (s, it.id) +} + +fn px(img: &filmcraft_render::Image, fx: f32, fy: f32) -> [f32; 4] { + img.get(((img.w as f32 * fx) as usize).min(img.w - 1), ((img.h as f32 * fy) as usize).min(img.h - 1)) +} + +fn dist(a: [f32; 4], b: [f32; 4]) -> f32 { + (0..3).map(|k| (a[k] - b[k]).abs()).fold(0.0, f32::max) +} + +#[test] +fn masked_effect_applies_only_inside_the_mask() { + let (mut s, clip) = demo(); + let plain = s.render_program(0.25).unwrap(); + s.execute("effects.apply", json!({"effect": "invert"})).unwrap(); + let inverted = s.render_program(0.25).unwrap(); + // ellipse mask on Invert, in the left half of the frame + let r = s.execute("masks.add", json!({"effect": "invert", "shape": "ellipse", "center": [480, 540], "size": [600, 600]})).unwrap(); + assert_eq!(r["name"], "Mask (1)"); + assert_eq!(r["mask"], 0); + let masked = s.render_program(0.25).unwrap(); + // inside: inverted; outside: untouched + assert!(dist(px(&masked, 0.25, 0.5), px(&inverted, 0.25, 0.5)) < 1e-4); + assert!(dist(px(&masked, 0.8, 0.5), px(&plain, 0.8, 0.5)) < 1e-4); + assert!(dist(px(&plain, 0.25, 0.5), px(&inverted, 0.25, 0.5)) > 0.05, "invert changes the picture"); + // inverted mask swaps the regions + s.execute("masks.set", json!({"clip": clip.0, "effect": "invert", "mask": 0, "inverted": true})).unwrap(); + let inv = s.render_program(0.25).unwrap(); + assert!(dist(px(&inv, 0.25, 0.5), px(&plain, 0.25, 0.5)) < 1e-4); + assert!(dist(px(&inv, 0.8, 0.5), px(&inverted, 0.8, 0.5)) < 1e-4); + // undo ×2 → no mask + s.undo(); + s.undo(); + let back = s.render_program(0.25).unwrap(); + assert_eq!(back.px, inverted.px); + assert!(s.state.selected_mask.is_none(), "selection follows undo"); +} + +#[test] +fn opacity_mask_cuts_out_the_clip() { + let (mut s, clip) = demo(); + // V1 only: an opacity mask leaves black outside + s.execute("masks.add", json!({"clip": clip.0, "shape": "polygon", "center": [960, 540], "size": [960, 540]})).unwrap(); + s.execute("masks.set", json!({"feather": 0})).unwrap(); + let img = s.render_program(0.25).unwrap(); + assert!(px(&img, 0.5, 0.5)[3] > 0.99, "inside opaque"); + let corner = px(&img, 0.05, 0.05); + assert!(corner[0] + corner[1] + corner[2] < 1e-4, "outside cut: {corner:?}"); + let list = s.execute("masks.list", json!({})).unwrap(); + let m = &list["masks"][0]; + assert_eq!(m["effectId"], "opacity"); + assert_eq!(m["path"]["vertices"].as_array().unwrap().len(), 4); + assert_eq!(m["selected"], true); +} + +#[test] +fn keyframed_mask_path_interpolates() { + let (mut s, clip) = demo(); + let it = s.active_sequence().unwrap().find_item(clip).unwrap().1.clone(); + s.set_playhead(it.start); + s.execute("masks.add", json!({"effect": "opacity", "shape": "polygon", "center": [400, 400], "size": [200, 200]})).unwrap(); + s.execute("effects.toggleAnimation", json!({"clip": clip.0, "effect": "opacity", "mask": 0, "param": "path"})).unwrap(); + let rate = s.active_sequence().unwrap().settings.frame_rate; + let t1 = it.start + rate.tick_of(10); + s.set_playhead(t1); + s.execute("masks.translate", json!({"delta": [100, -50]})).unwrap(); + // feather keyframes too, through the generic keyframe command + s.execute("effects.toggleAnimation", json!({"clip": clip.0, "effect": "opacity", "mask": 0, "param": "feather"})).unwrap(); + s.execute("effects.setParam", json!({"clip": clip.0, "effect": "opacity", "mask": 0, "param": "feather", "value": 30.0})).unwrap(); + let mid = it.start + rate.tick_of(5); + let l = s.execute("masks.list", json!({"time": mid.0})).unwrap(); + let v0 = &l["masks"][0]["path"]["vertices"][0]["p"]; + assert!((v0[0].as_f64().unwrap() - 350.0).abs() < 1e-6, "{v0}"); + assert!((v0[1].as_f64().unwrap() - 275.0).abs() < 1e-6, "{v0}"); + assert_eq!(l["masks"][0]["pathKeyframes"].as_array().unwrap().len(), 2); + let end = s.execute("masks.list", json!({"time": t1.0})).unwrap(); + assert_eq!(end["masks"][0]["feather"], 30.0); + // vertex edit at a keyframe replaces that keyframe; handles mirror unless broken + s.execute("masks.moveVertex", json!({"vertex": 1, "handle": "out", "delta": [0, 40]})).unwrap(); + let e = s.execute("masks.list", json!({})).unwrap(); + let v1 = &e["masks"][0]["path"]["vertices"][1]; + assert_eq!(v1["out"], json!([0.0, 40.0])); + assert_eq!(v1["in"], json!([-0.0, -40.0])); + assert_eq!(e["masks"][0]["pathKeyframes"].as_array().unwrap().len(), 2); + // add / remove vertices keep all keyframes interpolable + s.execute("masks.addVertex", json!({"after": 0, "at": [500, 220]})).unwrap(); + let a = s.execute("masks.list", json!({"time": mid.0})).unwrap(); + assert_eq!(a["masks"][0]["path"]["vertices"].as_array().unwrap().len(), 5); + s.execute("masks.removeVertex", json!({"vertex": 1})).unwrap(); + let a = s.execute("masks.list", json!({"time": mid.0})).unwrap(); + assert_eq!(a["masks"][0]["path"]["vertices"].as_array().unwrap().len(), 4); +} + +#[test] +fn merged_drag_is_one_undo_step_and_project_round_trips() { + let (mut s, _) = demo(); + s.execute("effects.apply", json!({"effect": "gaussian_blur"})).unwrap(); + s.execute("masks.add", json!({"effect": "gaussian_blur", "shape": "ellipse"})).unwrap(); + let n = s.history.undo.len(); + for _ in 0..5 { + s.execute("masks.translate", json!({"delta": [3, 1], "merge": "drag-1"})).unwrap(); + } + assert_eq!(s.history.undo.len(), n + 1); + s.execute("masks.set", json!({"mode": "subtract", "trackMethod": "position", "expansion": -4})).unwrap(); + let bytes = filmcraft_format::encode(&s.project, false); + let back = filmcraft_format::decode(&bytes).unwrap().project; + assert_eq!(&back, &*s.project); + let l = s.execute("masks.list", json!({})).unwrap(); + assert_eq!(l["masks"][0]["mode"], "Subtract"); + assert_eq!(l["masks"][0]["trackMethod"], "Position"); + assert!(s.execute("masks.add", json!({"effect": "motion"})).is_err(), "Motion has no masks"); +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 3bda2295..dc18729f 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -45,7 +45,7 @@ pub type Migration = fn(Value) -> Result; /// `MIGRATIONS[i]` upgrades schema `i + 1` to `i + 2`. Append one function per schema bump; never /// edit a shipped one. -pub const MIGRATIONS: &[Migration] = &[v1_to_v2, v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6]; +pub const MIGRATIONS: &[Migration] = &[v1_to_v2, v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, v6_to_v7]; /// The schema version this build writes (and the newest it reads). pub const SCHEMA_VERSION: u32 = MIGRATIONS.len() as u32 + 1; @@ -239,13 +239,18 @@ fn v5_to_v6(doc: Value) -> Result { Ok(doc) } +/// v6 → v7: effect masks (Bézier mask paths as keyframable parameters); existing data needs no change. +fn v6_to_v7(doc: Value) -> Result { + Ok(doc) +} + #[cfg(test)] mod tests { use super::*; #[test] fn schema_version_matches_table() { - assert_eq!(SCHEMA_VERSION, 6); + assert_eq!(SCHEMA_VERSION, 7); } #[test] diff --git a/crates/golden/goldens/mask_blur.png b/crates/golden/goldens/mask_blur.png new file mode 100644 index 00000000..93065647 Binary files /dev/null and b/crates/golden/goldens/mask_blur.png differ diff --git a/crates/golden/goldens/mask_blur.png.attribution b/crates/golden/goldens/mask_blur.png.attribution new file mode 100644 index 00000000..cad4afaa --- /dev/null +++ b/crates/golden/goldens/mask_blur.png.attribution @@ -0,0 +1,8 @@ +asset: mask_blur.png +title: Masks: Gaussian Blur inside a feathered ellipse mask +author: FilmCraft contributors +source: original work (rendered by FilmCraft's own renderer from a procedurally generated project; no external media) +license: MIT OR Apache-2.0 +license-file: +added: 2026-10-01 by FilmCraft contributors (FILMCRAFT_BLESS=1) +notes: Golden reference image generated by crates/golden/tests/golden.rs (mask_blur). Regenerate with FILMCRAFT_BLESS=1. Contains no Adobe artwork. diff --git a/crates/golden/goldens/mask_color.png b/crates/golden/goldens/mask_color.png new file mode 100644 index 00000000..3c379299 Binary files /dev/null and b/crates/golden/goldens/mask_color.png differ diff --git a/crates/golden/goldens/mask_color.png.attribution b/crates/golden/goldens/mask_color.png.attribution new file mode 100644 index 00000000..70444914 --- /dev/null +++ b/crates/golden/goldens/mask_color.png.attribution @@ -0,0 +1,8 @@ +asset: mask_color.png +title: Masks: Black & White inside an expanded polygon minus a feathered Bezier mask +author: FilmCraft contributors +source: original work (rendered by FilmCraft's own renderer from a procedurally generated project; no external media) +license: MIT OR Apache-2.0 +license-file: +added: 2026-10-01 by FilmCraft contributors (FILMCRAFT_BLESS=1) +notes: Golden reference image generated by crates/golden/tests/golden.rs (mask_color). Regenerate with FILMCRAFT_BLESS=1. Contains no Adobe artwork. diff --git a/crates/golden/goldens/mask_inverted.png b/crates/golden/goldens/mask_inverted.png new file mode 100644 index 00000000..601ccdb6 Binary files /dev/null and b/crates/golden/goldens/mask_inverted.png differ diff --git a/crates/golden/goldens/mask_inverted.png.attribution b/crates/golden/goldens/mask_inverted.png.attribution new file mode 100644 index 00000000..02dbbfe1 --- /dev/null +++ b/crates/golden/goldens/mask_inverted.png.attribution @@ -0,0 +1,8 @@ +asset: mask_inverted.png +title: Masks: Mosaic outside an inverted ellipse mask at 80% opacity +author: FilmCraft contributors +source: original work (rendered by FilmCraft's own renderer from a procedurally generated project; no external media) +license: MIT OR Apache-2.0 +license-file: +added: 2026-10-01 by FilmCraft contributors (FILMCRAFT_BLESS=1) +notes: Golden reference image generated by crates/golden/tests/golden.rs (mask_inverted). Regenerate with FILMCRAFT_BLESS=1. Contains no Adobe artwork. diff --git a/crates/golden/goldens/mask_opacity.png b/crates/golden/goldens/mask_opacity.png new file mode 100644 index 00000000..fa9a0eee Binary files /dev/null and b/crates/golden/goldens/mask_opacity.png differ diff --git a/crates/golden/goldens/mask_opacity.png.attribution b/crates/golden/goldens/mask_opacity.png.attribution new file mode 100644 index 00000000..1e3c6134 --- /dev/null +++ b/crates/golden/goldens/mask_opacity.png.attribution @@ -0,0 +1,8 @@ +asset: mask_opacity.png +title: Masks: feathered ellipse opacity mask on a rotated top layer +author: FilmCraft contributors +source: original work (rendered by FilmCraft's own renderer from a procedurally generated project; no external media) +license: MIT OR Apache-2.0 +license-file: +added: 2026-10-01 by FilmCraft contributors (FILMCRAFT_BLESS=1) +notes: Golden reference image generated by crates/golden/tests/golden.rs (mask_opacity). Regenerate with FILMCRAFT_BLESS=1. Contains no Adobe artwork. diff --git a/crates/golden/tests/golden.rs b/crates/golden/tests/golden.rs index ef07af5f..a3552efe 100644 --- a/crates/golden/tests/golden.rs +++ b/crates/golden/tests/golden.rs @@ -15,8 +15,8 @@ use filmcraft_geom::Vec2; use filmcraft_media::generators::GeneratorSource; use filmcraft_media::{DemoScene, Generator, MediaSource, SharedSource}; use filmcraft_project::{ - ClipId, EffectInstance, ItemId, ItemKind, Label, MediaClip, MediaRef, ParamValue, Project, SequenceSettings, TrackItem, TrackKind, Transition, - TransitionId, find_effect, + ClipId, EffectInstance, ItemId, ItemKind, Label, Mask, MaskMode, MaskPath, MediaClip, MediaRef, ParamValue, Project, SequenceSettings, TrackItem, + TrackKind, Transition, TransitionId, find_effect, }; use filmcraft_render::{RenderOptions, SourceMap, render_sequence}; use filmcraft_testkit::golden::{Rgba8, Tolerance, assert_golden, diff}; @@ -133,6 +133,14 @@ impl Builder { self.p.sequence_mut(self.seq).unwrap().video_tracks[track].transitions.push(tr); } + /// Add a mask to the clip's effect `effect` (feather/expansion in clip pixels). + fn mask(&mut self, id: ClipId, effect: &str, path: MaskPath, feather: f64, f: impl FnOnce(&mut Mask)) { + let mut m = Mask::new("Mask", path); + m.feather.value = fl(feather); + f(&mut m); + self.clip(id).effect_mut(effect).unwrap_or_else(|| panic!("no effect {effect}")).masks.push(m); + } + fn at(self, frame: i64) -> Scene { Scene { project: self.p, seq: self.seq, sources: self.map, frame } } @@ -400,6 +408,58 @@ fn hdr_tone_map() -> Scene { b.at(12) } +/// Gaussian Blur limited to a feathered ellipse mask (media is 640×360 clip pixels). +fn mask_blur() -> Scene { + let mut b = Builder::new(1); + let bars = b.media(Generator::BarsAndTone); + let c = b.place(0, bars, 0, 48); + b.effect(c, "gaussian_blur", &[("blurriness", fl(14.0))]); + b.mask(c, "gaussian_blur", MaskPath::ellipse(Vec2::new(320.0, 170.0), Vec2::new(210.0, 120.0)), 60.0, |_| {}); + b.at(12) +} + +/// Black & White inside a hard 4-point polygon (expanded by 12 px) minus a feathered pen (Bézier) +/// mask: mask modes, expansion and smooth vertices. +fn mask_color() -> Scene { + let mut b = Builder::new(1); + let bg = b.demo(DemoScene::OceanSunset); + let c = b.place(0, bg, 0, 48); + b.effect(c, "black_white", &[]); + let quad = MaskPath::polygon(&[Vec2::new(60.0, 40.0), Vec2::new(560.0, 70.0), Vec2::new(600.0, 320.0), Vec2::new(90.0, 300.0)]); + b.mask(c, "black_white", quad, 0.0, |m| m.expansion.value = fl(12.0)); + let mut pen = MaskPath::polygon(&[Vec2::new(250.0, 110.0), Vec2::new(420.0, 160.0), Vec2::new(300.0, 270.0)]); + pen.vertices[0] = filmcraft_project::MaskVertex::smooth(Vec2::new(250.0, 110.0), Vec2::new(60.0, -30.0)); + pen.vertices[2] = filmcraft_project::MaskVertex::smooth(Vec2::new(300.0, 270.0), Vec2::new(-80.0, -10.0)); + b.mask(c, "black_white", pen, 16.0, |m| m.mode = MaskMode::Subtract); + b.at(12) +} + +/// Mosaic outside an ellipse (inverted mask), 80 % mask opacity. +fn mask_inverted() -> Scene { + let mut b = Builder::new(1); + let bg = b.demo(DemoScene::Aurora); + let c = b.place(0, bg, 0, 48); + b.effect(c, "mosaic", &[("horizontal", fl(24.0)), ("vertical", fl(14.0))]); + b.mask(c, "mosaic", MaskPath::ellipse(Vec2::new(300.0, 180.0), Vec2::new(150.0, 110.0)), 8.0, |m| { + m.inverted = true; + m.opacity.value = fl(80.0); + }); + b.at(12) +} + +/// Opacity mask: a feathered ellipse cut out of a rotated, scaled top layer over a background +/// (the mask is in clip space, so it follows Motion). +fn mask_opacity() -> Scene { + let mut b = Builder::new(2); + let bg = b.demo(DemoScene::OceanSunset); + let fg = b.demo(DemoScene::CityNight); + b.place(0, bg, 0, 48); + let top = b.place(1, fg, 0, 48); + b.fixed(top, "motion", &[("scale", fl(70.0)), ("rotation", fl(-10.0)), ("position", pt(170.0, 95.0))]); + b.mask(top, "opacity", MaskPath::ellipse(Vec2::new(320.0, 180.0), Vec2::new(250.0, 140.0)), 90.0, |_| {}); + b.at(12) +} + /// (name, title, scene). fn scenes() -> Vec<(&'static str, &'static str, fn() -> Scene)> { vec![ @@ -416,6 +476,10 @@ fn scenes() -> Vec<(&'static str, &'static str, fn() -> Scene)> { ("graphic_shapes", "Graphic clip: ellipse, polygon, path and rotated text with background", graphic_shapes), ("log_to_rec709", "Colour management: S-Log3/S-Gamut3.Cine footage to Rec. 709 (tone mapped)", log_to_rec709), ("hdr_tone_map", "Colour management: Rec. 2100 PQ footage tone mapped into a Rec. 709 sequence", hdr_tone_map), + ("mask_blur", "Masks: Gaussian Blur inside a feathered ellipse mask", mask_blur), + ("mask_color", "Masks: Black & White inside an expanded polygon minus a feathered Bezier mask", mask_color), + ("mask_inverted", "Masks: Mosaic outside an inverted ellipse mask at 80% opacity", mask_inverted), + ("mask_opacity", "Masks: feathered ellipse opacity mask on a rotated top layer", mask_opacity), ] } @@ -470,7 +534,11 @@ goldens!( graphic_title, graphic_shapes, log_to_rec709, - hdr_tone_map + hdr_tone_map, + mask_blur, + mask_color, + mask_inverted, + mask_opacity ); /// Sanity: the scenes are not trivially empty or identical to each other. diff --git a/crates/gpu/Cargo.toml b/crates/gpu/Cargo.toml index a6745234..a402c61c 100644 --- a/crates/gpu/Cargo.toml +++ b/crates/gpu/Cargo.toml @@ -22,4 +22,5 @@ rayon = { workspace = true } pollster = "0.4" wgpu = { version = "30", features = ["wgsl", "metal", "vulkan", "dx12", "gles"] } filmcraft-media = { workspace = true } +filmcraft-project = { workspace = true } filmcraft-time = { workspace = true } diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index a7223f2f..72233d57 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -23,7 +23,9 @@ use filmcraft_frame::{Chroma, PixelData, VideoFrame}; use filmcraft_render::plan::{FramePlan, PlanLayer}; pub mod lut; +pub mod mask; pub use lut::GpuLut; +pub use mask::GpuMask; /// Output texture format: gamma-encoded RGBA8 (what egui expects of native textures); the resolve /// shader applies the sRGB encoding. diff --git a/crates/gpu/src/mask.rs b/crates/gpu/src/mask.rs new file mode 100644 index 00000000..250b2bad --- /dev/null +++ b/crates/gpu/src/mask.rs @@ -0,0 +1,174 @@ +//! GPU mask coverage and masked-effect mixing (WGSL compute), the counterpart of +//! [`filmcraft_render::mask`]. Tested for parity with the CPU path. + +use filmcraft_render::mask::FlatMask; + +pub struct GpuMask { + device: wgpu::Device, + queue: wgpu::Queue, + pipeline: wgpu::ComputePipeline, + bgl: wgpu::BindGroupLayout, +} + +fn storage(binding: u32, read_only: bool) -> wgpu::BindGroupLayoutEntry { + wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only }, has_dynamic_offset: false, min_binding_size: None }, + count: None, + } +} + +fn f32_bytes(v: &[f32]) -> Vec { + v.iter().flat_map(|x| x.to_le_bytes()).collect() +} + +impl GpuMask { + pub fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Self { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("filmcraft-mask"), + source: wgpu::ShaderSource::Wgsl(include_str!("mask.wgsl").into()), + }); + let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("mask"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None }, + count: None, + }, + storage(1, true), + storage(2, true), + storage(3, true), + storage(4, true), + storage(5, false), + storage(6, false), + ], + }); + let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("mask"), bind_group_layouts: &[Some(&bgl)], immediate_size: 0 }); + let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some("mask"), + layout: Some(&pl), + module: &shader, + entry_point: Some("main"), + compilation_options: Default::default(), + cache: None, + }); + GpuMask { device: device.clone(), queue: queue.clone(), pipeline, bgl } + } + + /// Combined coverage of `masks` over `w`×`h` pixels (`None` without active masks). + pub fn coverage(&self, masks: &[FlatMask], w: usize, h: usize) -> Option> { + self.run(masks, w, h, None).map(|(cov, _)| cov) + } + + /// `lerp(original, effected, coverage)` on premultiplied RGBA f32 pixels (a masked effect). + pub fn mix(&self, masks: &[FlatMask], w: usize, h: usize, original: &[f32], effected: &[f32]) -> Option> { + self.run(masks, w, h, Some((original, effected))).map(|(_, px)| px) + } + + fn run(&self, masks: &[FlatMask], w: usize, h: usize, mix: Option<(&[f32], &[f32])>) -> Option<(Vec, Vec)> { + use wgpu::util::DeviceExt; + let active: Vec<&FlatMask> = masks.iter().filter(|m| m.mode.index() != 0).collect(); + if active.is_empty() || w == 0 || h == 0 { + return None; + } + let n = w * h; + if let Some((o, e)) = mix + && (o.len() != n * 4 || e.len() != n * 4) + { + return None; + } + let mut params = Vec::with_capacity(16); + for v in [w as u32, h as u32, active.len() as u32, u32::from(mix.is_some())] { + params.extend_from_slice(&v.to_le_bytes()); + } + let mut infos = Vec::new(); + let mut pts: Vec = Vec::new(); + for m in &active { + let start = (pts.len() / 2) as u32; + pts.extend(m.pts.iter().flatten()); + for v in [start, m.pts.len() as u32, m.mode.index(), u32::from(m.inverted)] { + infos.extend_from_slice(&v.to_le_bytes()); + } + for v in [m.feather, m.expansion, m.opacity, 0.0] { + infos.extend_from_slice(&v.to_le_bytes()); + } + } + if pts.is_empty() { + pts.extend([0.0, 0.0]); + } + let d = &self.device; + let init = |label: &str, contents: &[u8], usage| d.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some(label), contents, usage }); + let ub = init("mask-params", ¶ms, wgpu::BufferUsages::UNIFORM); + let mb = init("mask-infos", &infos, wgpu::BufferUsages::STORAGE); + let pb = init("mask-pts", &f32_bytes(&pts), wgpu::BufferUsages::STORAGE); + let dummy = [0.0f32; 4]; + let (o, e) = mix.unwrap_or((&dummy, &dummy)); + let ob = init("mask-original", &f32_bytes(o), wgpu::BufferUsages::STORAGE); + let eb = init("mask-effected", &f32_bytes(e), wgpu::BufferUsages::STORAGE); + let px_size = if mix.is_some() { (n * 16) as u64 } else { 16 }; + let cov_size = (n * 4) as u64; + let out = |label: &str, size: u64| { + d.create_buffer(&wgpu::BufferDescriptor { + label: Some(label), + size, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, + mapped_at_creation: false, + }) + }; + let px_b = out("mask-out-px", px_size); + let cov_b = out("mask-out-cov", cov_size); + let bg = d.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("mask"), + layout: &self.bgl, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: ub.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 1, resource: mb.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 2, resource: pb.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 3, resource: ob.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 4, resource: eb.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 5, resource: px_b.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 6, resource: cov_b.as_entire_binding() }, + ], + }); + let read = |size: u64| { + d.create_buffer(&wgpu::BufferDescriptor { + label: Some("mask-read"), + size, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }) + }; + let rc = read(cov_size); + let rp = read(px_size); + let mut enc = d.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("mask") }); + { + let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("mask"), timestamp_writes: None }); + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, &bg, &[]); + pass.dispatch_workgroups((w as u32).div_ceil(8), (h as u32).div_ceil(8), 1); + } + enc.copy_buffer_to_buffer(&cov_b, 0, &rc, 0, cov_size); + enc.copy_buffer_to_buffer(&px_b, 0, &rp, 0, px_size); + self.queue.submit([enc.finish()]); + let fetch = |b: &wgpu::Buffer| -> Option> { + let slice = b.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + let _ = d.poll(wgpu::PollType::wait_indefinitely()); + rx.recv().ok()?.ok()?; + let data = slice.get_mapped_range().ok()?; + let v = data.chunks_exact(4).map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect(); + drop(data); + b.unmap(); + Some(v) + }; + let cov = fetch(&rc)?; + let px = if mix.is_some() { fetch(&rp)? } else { Vec::new() }; + Some((cov, px)) + } +} diff --git a/crates/gpu/src/mask.wgsl b/crates/gpu/src/mask.wgsl new file mode 100644 index 00000000..13ab16a1 --- /dev/null +++ b/crates/gpu/src/mask.wgsl @@ -0,0 +1,113 @@ +// Mask coverage + masked-effect mix (same math as filmcraft_render::mask; see its docs). +// Masks arrive flattened to polygons in working-image pixels, active ones only (mode != None). + +struct Params { + width: u32, + height: u32, + count: u32, + do_mix: u32, +}; + +struct MaskInfo { + start: u32, + len: u32, + mode: u32, + inverted: u32, + feather: f32, + expansion: f32, + opacity: f32, + _pad: f32, +}; + +@group(0) @binding(0) var params: Params; +@group(0) @binding(1) var masks: array; +@group(0) @binding(2) var pts: array>; +@group(0) @binding(3) var original: array>; +@group(0) @binding(4) var effected: array>; +@group(0) @binding(5) var out_px: array>; +@group(0) @binding(6) var out_cov: array; + +fn falloff(s: f32, feather: f32) -> f32 { + let w = max(feather, 1.0); + let u = clamp(s / w + 0.5, 0.0, 1.0); + let smooth_u = u * u * (3.0 - 2.0 * u); + return u + (smooth_u - u) * min(feather, 1.0); +} + +// MaskMode index: None 0, Add 1, Subtract 2, Intersect 3, Lighten 4, Darken 5, Difference 6. +fn mode_start(mode: u32) -> f32 { + if (mode == 2u || mode == 3u || mode == 5u) { + return 1.0; + } + return 0.0; +} + +fn combine(mode: u32, a: f32, m: f32) -> f32 { + switch mode { + case 1u: { return a + m - a * m; } + case 2u: { return a * (1.0 - m); } + case 3u: { return a * m; } + case 4u: { return max(a, m); } + case 5u: { return min(a, m); } + case 6u: { return a + m - 2.0 * a * m; } + default: { return a; } + } +} + +fn seg_dist2(p: vec2, a: vec2, b: vec2) -> f32 { + let d = b - a; + let q = p - a; + let l2 = dot(d, d); + var t = 0.0; + if (l2 > 0.0) { + t = clamp(dot(q, d) / l2, 0.0, 1.0); + } + let e = q - d * t; + return dot(e, e); +} + +fn mask_value(m: MaskInfo, p: vec2) -> f32 { + let band = abs(m.expansion) + max(m.feather, 1.0) * 0.5 + 1.0; + var sd = -band; + if (m.len >= 3u) { + var wind = 0; + var d2 = band * band; + for (var i = 0u; i < m.len; i = i + 1u) { + let a = pts[m.start + i]; + let c = pts[m.start + (i + 1u) % m.len]; + if ((a.y <= p.y) != (c.y <= p.y)) { + let t = (p.y - a.y) / (c.y - a.y); + if (a.x + (c.x - a.x) * t < p.x) { + if (c.y > a.y) { wind = wind + 1; } else { wind = wind - 1; } + } + } + d2 = min(d2, seg_dist2(p, a, c)); + } + let d = sqrt(d2); + if (wind != 0) { sd = d; } else { sd = -d; } + } + let c = falloff(sd + m.expansion, m.feather); + if (m.inverted != 0u) { + return m.opacity * (1.0 - c); + } + return m.opacity * c; +} + +@compute @workgroup_size(8, 8) +fn main(@builtin(global_invocation_id) id: vec3) { + if (id.x >= params.width || id.y >= params.height) { + return; + } + let p = vec2(f32(id.x) + 0.5, f32(id.y) + 0.5); + var acc = mode_start(masks[0].mode); + for (var i = 0u; i < params.count; i = i + 1u) { + let m = masks[i]; + acc = clamp(combine(m.mode, acc, mask_value(m, p)), 0.0, 1.0); + } + let k = id.y * params.width + id.x; + out_cov[k] = acc; + if (params.do_mix == 1u) { + let o = original[k]; + out_px[k] = o + (effected[k] - o) * acc; + } +} diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 99935049..241cbc94 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -183,3 +183,59 @@ fn gpu_lut_matches_cpu_tetrahedral() { assert!(worst < 1e-4, "size {size}: max |cpu - gpu| = {worst}"); } } + +fn test_masks(scale: f32) -> Vec { + use filmcraft_project::{Mask, MaskMode, MaskPath, ParamValue}; + let mut a = Mask::new("a", MaskPath::ellipse(Vec2::new(150.0, 90.0), Vec2::new(90.0, 55.0))); + a.feather.value = ParamValue::Float(24.0); + a.expansion.value = ParamValue::Float(6.0); + let mut b = Mask::new("b", MaskPath::polygon(&[Vec2::new(40.0, 20.0), Vec2::new(260.0, 50.0), Vec2::new(120.0, 170.0)])); + b.mode = MaskMode::Subtract; + b.feather.value = ParamValue::Float(0.0); + b.opacity.value = ParamValue::Float(70.0); + let mut c = Mask::new("c", MaskPath::rect(200.0, 100.0, 300.0, 175.0)); + c.mode = MaskMode::Difference; + c.inverted = true; + c.feather.value = ParamValue::Float(3.5); + filmcraft_render::mask::prepare(&[a, b, c], filmcraft_time::Tick(0), scale) +} + +#[test] +fn gpu_mask_coverage_matches_cpu() { + let Some((dev, q)) = device() else { + eprintln!("no GPU adapter; skipping"); + return; + }; + let g = crate::GpuMask::new(&dev, &q); + for scale in [1.0f32, 0.5] { + let masks = test_masks(scale); + let (w, h) = ((320.0 * scale) as usize, (180.0 * scale) as usize); + let cpu = filmcraft_render::mask::coverage(&masks, w, h).unwrap(); + let gpu = g.coverage(&masks, w, h).expect("gpu coverage"); + let worst = cpu.iter().zip(&gpu).map(|(a, b)| (a - b).abs()).fold(0f32, f32::max); + eprintln!("mask coverage @{scale}: max |cpu − gpu| = {worst:e}"); + assert!(worst < 2e-4, "scale {scale}: {worst}"); + assert!(cpu.iter().any(|v| *v > 0.99) && cpu.iter().any(|v| *v < 0.01) && cpu.iter().any(|v| *v > 0.2 && *v < 0.8)); + } +} + +#[test] +fn gpu_masked_mix_matches_cpu() { + let Some((dev, q)) = device() else { + eprintln!("no GPU adapter; skipping"); + return; + }; + let g = crate::GpuMask::new(&dev, &q); + let masks = test_masks(1.0); + let (w, h) = (320usize, 180usize); + let original = filmcraft_render::Image { w, h, px: (0..w * h * 4).map(|i| ((i * 37) % 101) as f32 / 100.0).collect() }; + let mut effected = original.clone(); + effected.px.iter_mut().for_each(|v| *v = 1.0 - *v * 0.5); + let gpu = g.mix(&masks, w, h, &original.px, &effected.px).expect("gpu mix"); + let cov = filmcraft_render::mask::coverage(&masks, w, h).unwrap(); + let mut cpu = effected.clone(); + filmcraft_render::mask::mix(&mut cpu, &original, &cov); + let worst = cpu.px.iter().zip(&gpu).map(|(a, b)| (a - b).abs()).fold(0f32, f32::max); + eprintln!("masked mix: max |cpu − gpu| = {worst:e}"); + assert!(worst < 2e-4, "{worst}"); +} diff --git a/crates/interchange/src/fcp7.rs b/crates/interchange/src/fcp7.rs index 83c50856..54b70422 100644 --- a/crates/interchange/src/fcp7.rs +++ b/crates/interchange/src/fcp7.rs @@ -564,7 +564,7 @@ impl<'a, 'i> Imp<'a, 'i, '_> { ParamValue::Choice(_) => Param::new(ParamValue::Choice(child_i64(p, "value").unwrap_or(0).max(0) as u32)), ParamValue::Color(_) => Param::new(ParamValue::Color(child(p, "value").map(read_color).unwrap_or([0.0, 0.0, 0.0, 1.0]))), ParamValue::Text(_) => Param::new(ParamValue::Text(child_text(p, "value").unwrap_or("").to_string())), - ParamValue::Curve(_) => continue, + ParamValue::Curve(_) | ParamValue::Path(_) => continue, }; inst.params.insert(pd.id.to_string(), pr); } @@ -1275,7 +1275,7 @@ impl Exp<'_, '_> { write_color(&mut self.w, *c); self.w.close(); } - ParamValue::Curve(_) => { + ParamValue::Curve(_) | ParamValue::Path(_) => { self.report.warn(format!("curve parameters of \"{}\" are not supported by FCP7 XML", def.name)); } } diff --git a/crates/project/src/effect.rs b/crates/project/src/effect.rs index 3820a643..ff38f205 100644 --- a/crates/project/src/effect.rs +++ b/crates/project/src/effect.rs @@ -11,6 +11,7 @@ use filmcraft_geom::Vec2; use serde::{Deserialize, Serialize}; use crate::keyframe::{Param, ParamValue}; +use crate::mask::Mask; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum EffectKind { @@ -43,6 +44,8 @@ pub enum ParamKind { }, /// Colour wheel offset (x, y in −1..1). Wheel, + /// A mask path (edited on the Program monitor). + Path, } #[derive(Clone, Debug, PartialEq, Serialize)] @@ -91,23 +94,6 @@ impl EffectDef { } } -/// A mask on an effect (ellipse / 4-point polygon / bezier), in clip pixel space. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct Mask { - pub name: String, - pub shape: MaskShape, - pub feather: Param, - pub opacity: Param, - pub expansion: Param, - pub inverted: bool, -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub enum MaskShape { - Ellipse { center: Vec2, radius: Vec2 }, - Polygon(Vec), -} - #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct EffectInstance { pub effect: String, @@ -140,7 +126,7 @@ impl EffectInstance { self.params.get(id).map(|p| p.vec2_at(t)).unwrap_or_default() } pub fn is_animated(&self) -> bool { - self.params.values().any(Param::is_animated) + self.params.values().any(Param::is_animated) || self.masks.iter().any(Mask::is_animated) } } diff --git a/crates/project/src/keyframe.rs b/crates/project/src/keyframe.rs index 693e7926..05bfe673 100644 --- a/crates/project/src/keyframe.rs +++ b/crates/project/src/keyframe.rs @@ -18,6 +18,8 @@ pub enum ParamValue { Text(String), /// Curve control points (x, y) in 0..1, sorted by x (Lumetri curves). Curve(Vec<[f32; 2]>), + /// A mask path (Bézier vertices in clip pixels); interpolates vertex-wise. + Path(crate::mask::MaskPath), } impl ParamValue { @@ -55,13 +57,20 @@ impl ParamValue { } /// Whether values of this kind can be interpolated (others hold). pub fn interpolates(&self) -> bool { - matches!(self, ParamValue::Float(_) | ParamValue::Vec2(_) | ParamValue::Color(_)) + matches!(self, ParamValue::Float(_) | ParamValue::Vec2(_) | ParamValue::Color(_) | ParamValue::Path(_)) + } + pub fn as_path(&self) -> Option<&crate::mask::MaskPath> { + match self { + ParamValue::Path(p) => Some(p), + _ => None, + } } fn components(&self) -> Vec { match self { ParamValue::Float(v) => vec![*v], ParamValue::Vec2(v) => vec![v.x, v.y], ParamValue::Color(c) => c.iter().map(|&x| x as f64).collect(), + ParamValue::Path(p) => p.components(), _ => vec![], } } @@ -70,6 +79,7 @@ impl ParamValue { ParamValue::Float(_) => ParamValue::Float(c[0]), ParamValue::Vec2(_) => ParamValue::Vec2(Vec2::new(c[0], c[1])), ParamValue::Color(_) => ParamValue::Color([c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32]), + ParamValue::Path(p) => ParamValue::Path(p.with_components(c)), other => other.clone(), } } @@ -236,6 +246,10 @@ impl Param { let e = ease(a, b, u); let ca = a.value.components(); let cb = b.value.components(); + if ca.len() != cb.len() { + // e.g. mask paths with different vertex counts: hold + return a.value.clone(); + } let c: Vec = ca.iter().zip(&cb).map(|(x, y)| x + (y - x) * e).collect(); ParamValue::from_components(&a.value, &c) } diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index ba977630..08945953 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -12,6 +12,7 @@ pub mod effect; pub mod essential; pub mod graphic; pub mod keyframe; +pub mod mask; pub mod mixer; use std::collections::BTreeMap; @@ -25,6 +26,7 @@ pub use caption::{Caption, CaptionAlign, CaptionAnchor, CaptionFormat, CaptionSt pub use effect::{EffectDef, EffectInstance, EffectKind, ParamDef, ParamKind, effect_defs, find_effect}; pub use essential::{AudioType, EssentialSound}; pub use keyframe::{Interpolation, Keyframe, Param, ParamValue}; +pub use mask::{Mask, MaskMode, MaskPath, MaskVertex, TrackMethod}; pub use mixer::{AutomationMode, InputMap, MixerStrip, TrackSend}; macro_rules! id_type { @@ -501,6 +503,10 @@ impl TrackItem { self.effects.iter_mut().find(|e| e.effect == id) } /// Standard (non-intrinsic) effects applied, for the fx badge. + /// The Opacity effect has masks (the layer is cut out before Motion). + pub fn has_opacity_masks(&self) -> bool { + self.effect("opacity").is_some_and(|e| e.enabled && e.masks.iter().any(|m| m.mode != mask::MaskMode::None)) + } pub fn has_standard_effects(&self) -> bool { self.effects.iter().any(|e| e.def().is_some_and(|d| !d.intrinsic) && !graphic::is_layer(e)) } @@ -512,6 +518,7 @@ impl TrackItem { self.effects.iter().any(|e| { e.def().is_some_and(|d| d.intrinsic) && (e.is_animated() + || !e.masks.is_empty() || e.def().is_some_and(|d| d.params.iter().any(|p| e.params.get(p.id).is_some_and(|v| !param_eq_default(&v.value, &p.default))))) }) } diff --git a/crates/project/src/mask.rs b/crates/project/src/mask.rs new file mode 100644 index 00000000..e1531a7c --- /dev/null +++ b/crates/project/src/mask.rs @@ -0,0 +1,437 @@ +//! Effect masks (Premiere's per-effect masks: ellipse, 4-point polygon, free-draw Bézier). +//! +//! Every mask is a closed cubic Bézier path in **clip pixel space** (full-resolution source +//! pixels, the space of effect point parameters). An ellipse is four smooth vertices with circular +//! tangents and a polygon is vertices with zero-length tangents, so all three tools produce the same +//! [`MaskPath`] and the same rasterizer, keyframe interpolation and tracker apply to each. +//! +//! A mask has a keyframable *Mask Path* (vertex-wise interpolation when the vertex counts match, +//! hold otherwise), *Feather* (falloff width in pixels, centred on the edge), *Opacity* (%), +//! *Expansion* (pixels, grows/shrinks the shape by signed distance), *Inverted*, and a combine +//! [`MaskMode`] with the masks above it. Coverage is computed by `filmcraft_render::mask`. + +use filmcraft_geom::{Affine, Vec2}; +use serde::{Deserialize, Serialize}; + +use crate::effect::{ParamDef, ParamKind}; +use crate::keyframe::{Param, ParamValue}; + +/// Circle tangent length for a 4-segment cubic approximation of a quarter circle. +pub const KAPPA: f64 = 0.552_284_749_830_793_4; + +/// One path vertex with its incoming/outgoing Bézier tangents (relative to `p`; zero = corner). +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct MaskVertex { + pub p: Vec2, + #[serde(default, skip_serializing_if = "is_zero")] + pub t_in: Vec2, + #[serde(default, skip_serializing_if = "is_zero")] + pub t_out: Vec2, +} + +fn is_zero(v: &Vec2) -> bool { + v.x == 0.0 && v.y == 0.0 +} + +impl MaskVertex { + pub fn corner(p: Vec2) -> Self { + Self { p, t_in: Vec2::ZERO, t_out: Vec2::ZERO } + } + /// A smooth vertex with mirrored tangents (`t_out = t`, `t_in = -t`). + pub fn smooth(p: Vec2, t: Vec2) -> Self { + Self { p, t_in: t * -1.0, t_out: t } + } + pub fn is_corner(&self) -> bool { + is_zero(&self.t_in) && is_zero(&self.t_out) + } +} + +/// A closed cubic Bézier path (an open path is drawn but still filled as if closed). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct MaskPath { + pub vertices: Vec, + #[serde(default = "yes")] + pub closed: bool, +} + +fn yes() -> bool { + true +} + +impl MaskPath { + /// An axis-aligned ellipse (four smooth vertices: top, right, bottom, left). + pub fn ellipse(center: Vec2, radius: Vec2) -> Self { + let (rx, ry) = (radius.x, radius.y); + let k = KAPPA; + let v = vec![ + MaskVertex::smooth(center + Vec2::new(0.0, -ry), Vec2::new(rx * k, 0.0)), + MaskVertex::smooth(center + Vec2::new(rx, 0.0), Vec2::new(0.0, ry * k)), + MaskVertex::smooth(center + Vec2::new(0.0, ry), Vec2::new(-rx * k, 0.0)), + MaskVertex::smooth(center + Vec2::new(-rx, 0.0), Vec2::new(0.0, -ry * k)), + ]; + Self { vertices: v, closed: true } + } + /// A polygon through `points` (corner vertices). + pub fn polygon(points: &[Vec2]) -> Self { + Self { vertices: points.iter().map(|p| MaskVertex::corner(*p)).collect(), closed: true } + } + /// An axis-aligned rectangle (Premiere's 4-point polygon mask default). + pub fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> Self { + Self::polygon(&[Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)]) + } + + pub fn len(&self) -> usize { + self.vertices.len() + } + pub fn is_empty(&self) -> bool { + self.vertices.is_empty() + } + + /// The path with every point and tangent mapped by `m` (tangents by its linear part). + pub fn transformed(&self, m: &Affine) -> Self { + let lin = Affine { e: 0.0, f: 0.0, ..*m }; + Self { + vertices: self.vertices.iter().map(|v| MaskVertex { p: m.apply(v.p), t_in: lin.apply(v.t_in), t_out: lin.apply(v.t_out) }).collect(), + closed: self.closed, + } + } + + /// Mean of the vertex positions. + pub fn centroid(&self) -> Vec2 { + if self.vertices.is_empty() { + return Vec2::ZERO; + } + let s = self.vertices.iter().fold(Vec2::ZERO, |a, v| a + v.p); + s * (1.0 / self.vertices.len() as f64) + } + + /// Bounding box of the control polygon (contains the curve): (min, max). + pub fn bounds(&self) -> (Vec2, Vec2) { + let mut lo = Vec2::new(f64::INFINITY, f64::INFINITY); + let mut hi = Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY); + for v in &self.vertices { + for q in [v.p, v.p + v.t_in, v.p + v.t_out] { + lo = Vec2::new(lo.x.min(q.x), lo.y.min(q.y)); + hi = Vec2::new(hi.x.max(q.x), hi.y.max(q.y)); + } + } + (lo, hi) + } + + /// Cubic segments `(p0, c1, c2, p3)`; a closed path includes the last→first segment. + pub fn segments(&self) -> Vec<[Vec2; 4]> { + let n = self.vertices.len(); + if n < 2 { + return Vec::new(); + } + let count = if self.closed { n } else { n - 1 }; + (0..count) + .map(|i| { + let a = &self.vertices[i]; + let b = &self.vertices[(i + 1) % n]; + [a.p, a.p + a.t_out, b.p + b.t_in, b.p] + }) + .collect() + } + + /// Flatten to a polygon (closed implicitly) whose chords deviate from the curve by at most + /// `tol` (same units as the path). Corner-only segments emit just their end point. + pub fn flatten(&self, tol: f64) -> Vec { + let mut out = Vec::new(); + let n = self.vertices.len(); + if n == 0 { + return out; + } + out.push(self.vertices[0].p); + // an open path is filled as if closed: flatten its closing segment too + let segs = if self.closed || n < 2 { + self.segments() + } else { + let mut s = self.segments(); + let (a, b) = (&self.vertices[n - 1], &self.vertices[0]); + s.push([a.p, a.p + a.t_out, b.p + b.t_in, b.p]); + s + }; + let tol = tol.max(1e-4); + for (i, [p0, c1, c2, p3]) in segs.iter().enumerate() { + let last = i + 1 == segs.len(); + let straight = (*c1 - *p0).length() < 1e-12 && (*c2 - *p3).length() < 1e-12; + if !straight { + // Wang's bound on the number of chords for a cubic. + let d1 = (*p0 - *c1 * 2.0 + *c2).length(); + let d2 = (*c1 - *c2 * 2.0 + *p3).length(); + let m = d1.max(d2); + let steps = ((0.75 * m / tol).sqrt().ceil() as usize).clamp(1, 256); + for k in 1..steps { + let t = k as f64 / steps as f64; + out.push(cubic(*p0, *c1, *c2, *p3, t)); + } + } + if !last { + out.push(*p3); + } + } + out + } + + /// Flattened components for keyframe interpolation: `[x, y, in.x, in.y, out.x, out.y]` per vertex. + pub fn components(&self) -> Vec { + self.vertices.iter().flat_map(|v| [v.p.x, v.p.y, v.t_in.x, v.t_in.y, v.t_out.x, v.t_out.y]).collect() + } + /// Inverse of [`MaskPath::components`] (`closed` is taken from `self`). + pub fn with_components(&self, c: &[f64]) -> Self { + Self { + vertices: c.chunks_exact(6).map(|k| MaskVertex { p: Vec2::new(k[0], k[1]), t_in: Vec2::new(k[2], k[3]), t_out: Vec2::new(k[4], k[5]) }).collect(), + closed: self.closed, + } + } +} + +/// Point on a cubic Bézier at `t`. +pub fn cubic(p0: Vec2, c1: Vec2, c2: Vec2, p3: Vec2, t: f64) -> Vec2 { + let u = 1.0 - t; + p0 * (u * u * u) + c1 * (3.0 * u * u * t) + c2 * (3.0 * u * t * t) + p3 * (t * t * t) +} + +/// How a mask combines with the result of the masks above it (After Effects' mask modes). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum MaskMode { + /// Ignored (kept for the user to switch back on). + None, + /// Union: `a + m − a·m`. + #[default] + Add, + /// `a · (1 − m)`. + Subtract, + /// `a · m`. + Intersect, + /// `max(a, m)`. + Lighten, + /// `min(a, m)`. + Darken, + /// `a + m − 2·a·m`. + Difference, +} + +impl MaskMode { + pub const ALL: [MaskMode; 7] = + [MaskMode::None, MaskMode::Add, MaskMode::Subtract, MaskMode::Intersect, MaskMode::Lighten, MaskMode::Darken, MaskMode::Difference]; + pub fn label(self) -> &'static str { + match self { + MaskMode::None => "None", + MaskMode::Add => "Add", + MaskMode::Subtract => "Subtract", + MaskMode::Intersect => "Intersect", + MaskMode::Lighten => "Lighten", + MaskMode::Darken => "Darken", + MaskMode::Difference => "Difference", + } + } + pub fn from_name(s: &str) -> Option { + let s = s.to_ascii_lowercase(); + Self::ALL.into_iter().find(|m| m.label().to_ascii_lowercase() == s) + } + /// Shader / wire index. + pub fn index(self) -> u32 { + Self::ALL.iter().position(|m| *m == self).unwrap_or(1) as u32 + } + /// Combine accumulated coverage `a` with this mask's coverage `m`. + pub fn combine(self, a: f32, m: f32) -> f32 { + match self { + MaskMode::None => a, + MaskMode::Add => a + m - a * m, + MaskMode::Subtract => a * (1.0 - m), + MaskMode::Intersect => a * m, + MaskMode::Lighten => a.max(m), + MaskMode::Darken => a.min(m), + MaskMode::Difference => a + m - 2.0 * a * m, + } + } + /// Starting value when this is the first active mask: additive modes start from nothing, + /// subtractive ones from the full frame. + pub fn start(self) -> f32 { + match self { + MaskMode::Subtract | MaskMode::Intersect | MaskMode::Darken => 1.0, + _ => 0.0, + } + } +} + +/// Mask tracking method (Premiere's wrench menu next to the tracking buttons). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum TrackMethod { + Position, + PositionRotation, + #[default] + PositionScaleRotation, +} + +impl TrackMethod { + pub const ALL: [TrackMethod; 3] = [TrackMethod::Position, TrackMethod::PositionRotation, TrackMethod::PositionScaleRotation]; + pub fn label(self) -> &'static str { + match self { + TrackMethod::Position => "Position", + TrackMethod::PositionRotation => "Position & Rotation", + TrackMethod::PositionScaleRotation => "Position, Scale & Rotation", + } + } + pub fn from_name(s: &str) -> Option { + match s.to_ascii_lowercase().replace([' ', '_', '-', '&', ','], "").as_str() { + "position" => Some(TrackMethod::Position), + "positionrotation" => Some(TrackMethod::PositionRotation), + "positionscalerotation" => Some(TrackMethod::PositionScaleRotation), + _ => None, + } + } +} + +/// A mask on an effect instance. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Mask { + pub name: String, + /// `ParamValue::Path`. + pub path: Param, + /// Feather width in clip pixels (≥ 0). + pub feather: Param, + /// 0..100 %. + pub opacity: Param, + /// Clip pixels (negative shrinks). + pub expansion: Param, + #[serde(default)] + pub inverted: bool, + #[serde(default)] + pub mode: MaskMode, + #[serde(default)] + pub track_method: TrackMethod, +} + +/// Mask parameter ids (keyframe commands address them with `"mask": n`). +pub const MASK_PARAMS: [&str; 4] = ["path", "feather", "opacity", "expansion"]; + +impl Mask { + pub fn new(name: impl Into, path: MaskPath) -> Self { + Self { + name: name.into(), + path: Param::new(ParamValue::Path(path)), + feather: Param::new(ParamValue::Float(10.0)), + opacity: Param::new(ParamValue::Float(100.0)), + expansion: Param::new(ParamValue::Float(0.0)), + inverted: false, + mode: MaskMode::Add, + track_method: TrackMethod::default(), + } + } + pub fn param(&self, id: &str) -> Option<&Param> { + match id { + "path" => Some(&self.path), + "feather" => Some(&self.feather), + "opacity" => Some(&self.opacity), + "expansion" => Some(&self.expansion), + _ => None, + } + } + pub fn param_mut(&mut self, id: &str) -> Option<&mut Param> { + match id { + "path" => Some(&mut self.path), + "feather" => Some(&mut self.feather), + "opacity" => Some(&mut self.opacity), + "expansion" => Some(&mut self.expansion), + _ => None, + } + } + pub fn path_at(&self, t: filmcraft_time::Tick) -> MaskPath { + match self.path.value_at(t) { + ParamValue::Path(p) => p, + _ => MaskPath::default(), + } + } + pub fn is_animated(&self) -> bool { + MASK_PARAMS.iter().any(|p| self.param(p).is_some_and(Param::is_animated)) + } + /// Keyframe times of every mask parameter (for the keyframe lane / presets). + pub fn params_mut(&mut self) -> [&mut Param; 4] { + [&mut self.path, &mut self.feather, &mut self.opacity, &mut self.expansion] + } +} + +/// Parameter schemas of the mask rows in Effect Controls (and the MCP schema). +pub fn mask_param_defs() -> &'static [ParamDef] { + use std::sync::OnceLock; + static DEFS: OnceLock> = OnceLock::new(); + DEFS.get_or_init(|| { + let fl = |id, label, def, (min, max): (f64, f64), (smin, smax): (f64, f64), unit| ParamDef { + id, + label, + kind: ParamKind::Float { min, max, soft_min: smin, soft_max: smax, unit, decimals: 1 }, + default: ParamValue::Float(def), + animatable: true, + group: None, + }; + vec![ + ParamDef { id: "path", label: "Mask Path", kind: ParamKind::Path, default: ParamValue::Path(MaskPath::default()), animatable: true, group: None }, + fl("feather", "Mask Feather", 10.0, (0.0, 10_000.0), (0.0, 500.0), ""), + fl("opacity", "Mask Opacity", 100.0, (0.0, 100.0), (0.0, 100.0), " %"), + fl("expansion", "Mask Expansion", 0.0, (-10_000.0, 10_000.0), (-500.0, 500.0), ""), + ] + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use filmcraft_time::Tick; + + #[test] + fn ellipse_flattens_onto_the_ellipse() { + let p = MaskPath::ellipse(Vec2::new(100.0, 50.0), Vec2::new(40.0, 20.0)); + let pts = p.flatten(0.05); + assert!(pts.len() > 16, "{}", pts.len()); + for q in &pts { + let e = ((q.x - 100.0) / 40.0).powi(2) + ((q.y - 50.0) / 20.0).powi(2); + assert!((e - 1.0).abs() < 0.01, "{q:?} off the ellipse: {e}"); + } + } + + #[test] + fn polygon_flattens_to_its_corners() { + let p = MaskPath::rect(0.0, 0.0, 10.0, 5.0); + assert_eq!(p.flatten(0.1), vec![Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, 5.0), Vec2::new(0.0, 5.0)]); + } + + #[test] + fn path_keyframes_interpolate_vertexwise() { + let a = MaskPath::rect(0.0, 0.0, 10.0, 10.0); + let b = a.transformed(&Affine::translate(100.0, 20.0)); + let mut m = Mask::new("Mask (1)", a.clone()); + m.path.put_keyframe(Tick(0), ParamValue::Path(a)); + m.path.put_keyframe(Tick(100), ParamValue::Path(b)); + let mid = m.path_at(Tick(50)); + assert_eq!(mid.vertices[0].p, Vec2::new(50.0, 10.0)); + assert_eq!(mid.vertices[2].p, Vec2::new(60.0, 20.0)); + // different vertex counts hold the earlier path + m.path.put_keyframe(Tick(200), ParamValue::Path(MaskPath::ellipse(Vec2::ZERO, Vec2::new(1.0, 1.0)))); + m.path.put_keyframe(Tick(300), ParamValue::Path(MaskPath::polygon(&[Vec2::ZERO, Vec2::new(1.0, 0.0), Vec2::new(0.0, 1.0)]))); + assert_eq!(m.path_at(Tick(250)).len(), 4); + } + + #[test] + fn modes_combine() { + assert_eq!(MaskMode::Add.combine(0.0, 0.5), 0.5); + assert_eq!(MaskMode::Subtract.combine(1.0, 0.25), 0.75); + assert_eq!(MaskMode::Intersect.combine(0.5, 0.5), 0.25); + assert_eq!(MaskMode::Difference.combine(1.0, 1.0), 0.0); + assert_eq!(MaskMode::from_name("intersect"), Some(MaskMode::Intersect)); + assert_eq!(TrackMethod::from_name("Position & Rotation"), Some(TrackMethod::PositionRotation)); + } + + #[test] + fn mask_serde_round_trip() { + let mut m = Mask::new("Mask (1)", MaskPath::ellipse(Vec2::new(5.0, 5.0), Vec2::new(3.0, 2.0))); + m.inverted = true; + m.mode = MaskMode::Subtract; + m.feather.put_keyframe(Tick(10), ParamValue::Float(4.0)); + let s = serde_json::to_string(&m).unwrap(); + let back: Mask = serde_json::from_str(&s).unwrap(); + assert_eq!(back, m); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index b2312522..688b2084 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -17,6 +17,7 @@ pub mod graphic_clip; pub mod graphics; pub mod image; pub mod luts; +pub mod mask; pub mod mixer; pub mod offline; pub mod plan; @@ -134,13 +135,14 @@ fn render_seq(project: &Project, seq: &Sequence, t: Tick, opts: RenderOptions, s project: Some(project), }; for e in item.effects.iter().filter(|e| e.def().is_some_and(|d| !d.intrinsic)) { - effects::apply(&mut adjusted, e, &cx); + mask::apply_effect(&mut adjusted, e, &cx); } let (op, bl) = opacity_blend(item, mt); - // Adjustment layer opacity mixes adjusted over original. + // Adjustment layer opacity (and opacity masks, in sequence pixels) mix adjusted over original. let mut out = canvas.clone(); let mut adj = adjusted; adj.scale_alpha(op); + mask::apply_opacity_masks(&mut adj, item, mt, opts.scale); blend::composite(&mut out, &adj, 1.0, bl); canvas = out; continue; @@ -188,7 +190,7 @@ pub(crate) fn opacity_blend(item: &TrackItem, mt: Tick) -> (f32, Blend) { } /// Size of an item's source at full resolution. -pub(crate) fn source_size(project: &Project, item: ItemId) -> Option<(u32, u32)> { +pub fn source_size(project: &Project, item: ItemId) -> Option<(u32, u32)> { match &project.item(item)?.kind { ItemKind::Media(m) => m.info.video.as_ref().map(|v| (v.width, v.height)), ItemKind::Sequence(s) => Some((s.settings.width, s.settings.height)), @@ -259,7 +261,7 @@ pub(crate) fn item_layer( } ItemKind::AdjustmentLayer { .. } => return None, ItemKind::Graphic { .. } => { - if !(opts.effects && item.has_standard_effects()) { + if !(item.has_opacity_masks() || opts.effects && item.has_standard_effects()) { // vectors straight to the output: no resampling, crisp at any Motion scale let m = Affine::scale(opts.scale as f64, opts.scale as f64).then_apply(&motion); let mut canvas = Image::new(w, h); @@ -281,9 +283,10 @@ pub(crate) fn item_layer( if filmcraft_media::cancel::cancelled() { return None; } - effects::apply(&mut layer, e, &cx); + mask::apply_effect(&mut layer, e, &cx); } } + mask::apply_opacity_masks(&mut layer, item, mt, px_scale); // layer px → source px → sequence px → output px let m = Affine::scale(opts.scale as f64, opts.scale as f64).then_apply(&motion).then_apply(&Affine::scale(1.0 / px_scale as f64, 1.0 / px_scale as f64)); let placed = if layer.w == w diff --git a/crates/render/src/mask.rs b/crates/render/src/mask.rs new file mode 100644 index 00000000..2c1c86f3 --- /dev/null +++ b/crates/render/src/mask.rs @@ -0,0 +1,325 @@ +//! Mask coverage (CPU reference; `filmcraft_gpu::GpuMask` runs the same math in WGSL). +//! +//! Each [`Mask`] is flattened to a polygon in working-image pixels (chord error ≤ 0.05 px). For +//! a pixel centre `p` the signed distance `sd` to the polygon (positive inside, nonzero winding) +//! gives the coverage +//! +//! ```text +//! s = sd + expansion +//! w = max(feather, 1) +//! u = clamp(s / w + 0.5, 0, 1) +//! c = lerp(u, smoothstep(u), min(feather, 1)) +//! m = opacity · (inverted ? 1 − c : c) +//! ``` +//! +//! so a hard mask (feather 0) is the analytic box-filtered coverage of the edge (a one-pixel +//! linear ramp across the boundary: exact for straight edges, antialiased everywhere), and a +//! feathered mask falls off smoothly over `feather` pixels centred on the (expanded) edge — a true +//! distance falloff, so corners round off and the width is the same along the whole contour. +//! Masks combine top to bottom with their [`MaskMode`]. All of this runs in linear light on the +//! premultiplied working image: a masked effect is `lerp(original, effected, coverage)` per +//! channel, an opacity mask scales the layer's premultiplied pixels. + +use filmcraft_project::{EffectInstance, Mask, MaskMode}; +use filmcraft_time::Tick; +use rayon::prelude::*; + +use crate::image::Image; + +/// Flattening tolerance in working pixels. +pub const FLATTEN_TOLERANCE: f64 = 0.05; + +/// One mask ready for rasterizing, in working-image pixels. +#[derive(Clone, Debug, PartialEq)] +pub struct FlatMask { + /// Closed polygon (last vertex connects to the first). + pub pts: Vec<[f32; 2]>, + pub feather: f32, + pub expansion: f32, + /// 0..1. + pub opacity: f32, + pub inverted: bool, + pub mode: MaskMode, +} + +/// Flatten the active masks (mode ≠ None) at clip time `t`. `px_scale` = working pixels per clip pixel. +pub fn prepare(masks: &[Mask], t: Tick, px_scale: f32) -> Vec { + let s = px_scale as f64; + masks + .iter() + .filter(|m| m.mode != MaskMode::None) + .map(|m| { + let path = m.path_at(t); + let pts = path.flatten(FLATTEN_TOLERANCE / s.max(1e-6)).into_iter().map(|p| [(p.x * s) as f32, (p.y * s) as f32]).collect(); + FlatMask { + pts, + feather: (m.feather.f64_at(t).max(0.0) * s) as f32, + expansion: (m.expansion.f64_at(t) * s) as f32, + opacity: (m.opacity.f64_at(t) / 100.0).clamp(0.0, 1.0) as f32, + inverted: m.inverted, + mode: m.mode, + } + }) + .collect() +} + +/// The falloff of a signed (expanded) distance for a feather width. +#[inline] +pub fn falloff(s: f32, feather: f32) -> f32 { + let w = feather.max(1.0); + let u = (s / w + 0.5).clamp(0.0, 1.0); + let smooth = u * u * (3.0 - 2.0 * u); + u + (smooth - u) * feather.min(1.0) +} + +/// Distance band beyond which coverage is saturated (0 or 1). +fn band(m: &FlatMask) -> f32 { + m.expansion.abs() + m.feather.max(1.0) * 0.5 + 1.0 +} + +#[inline] +fn seg_dist2(px: f32, py: f32, a: [f32; 2], b: [f32; 2]) -> f32 { + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let (qx, qy) = (px - a[0], py - a[1]); + let l2 = dx * dx + dy * dy; + let t = if l2 > 0.0 { ((qx * dx + qy * dy) / l2).clamp(0.0, 1.0) } else { 0.0 }; + let (ex, ey) = (qx - dx * t, qy - dy * t); + ex * ex + ey * ey +} + +/// Coverage of one mask for row `y` into `out` (length = width). Reference per-row algorithm: +/// winding from the row's sorted edge crossings, distance only against edges within the band. +fn mask_row(m: &FlatMask, y: usize, out: &mut [f32]) { + let n = m.pts.len(); + let py = y as f32 + 0.5; + let b = band(m); + let value = |sd: f32| { + let c = falloff(sd + m.expansion, m.feather); + m.opacity * if m.inverted { 1.0 - c } else { c } + }; + if n < 3 { + out.fill(value(-b)); + return; + } + // crossings of the scanline with the polygon edges (x, direction) + let mut cross: Vec<(f32, i32)> = Vec::new(); + // edges within the band of this row: (a, b, min x, max x) + let mut near: Vec<([f32; 2], [f32; 2], f32, f32)> = Vec::new(); + for i in 0..n { + let a = m.pts[i]; + let c = m.pts[(i + 1) % n]; + if (a[1] <= py) != (c[1] <= py) { + let t = (py - a[1]) / (c[1] - a[1]); + cross.push((a[0] + (c[0] - a[0]) * t, if c[1] > a[1] { 1 } else { -1 })); + } + if py >= a[1].min(c[1]) - b && py <= a[1].max(c[1]) + b { + near.push((a, c, a[0].min(c[0]) - b, a[0].max(c[0]) + b)); + } + } + cross.sort_by(|p, q| p.0.total_cmp(&q.0)); + let mut ci = 0; + let mut wind = 0; + let b2 = b * b; + for (x, o) in out.iter_mut().enumerate() { + let px = x as f32 + 0.5; + while ci < cross.len() && cross[ci].0 < px { + wind += cross[ci].1; + ci += 1; + } + let inside = wind != 0; + let mut d2 = b2; + for (a, c, x0, x1) in &near { + if px >= *x0 && px <= *x1 { + d2 = d2.min(seg_dist2(px, py, *a, *c)); + } + } + let d = d2.sqrt(); + *o = value(if inside { d } else { -d }); + } +} + +/// Combined coverage of `masks` over a `w`×`h` image, or `None` when there is no active mask +/// (the effect applies everywhere). +pub fn coverage(masks: &[FlatMask], w: usize, h: usize) -> Option> { + let first = masks.iter().find(|m| m.mode != MaskMode::None)?; + let start = first.mode.start(); + let mut acc = vec![start; w * h]; + if w == 0 { + return Some(acc); + } + acc.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + let mut tmp = vec![0.0f32; w]; + for m in masks.iter().filter(|m| m.mode != MaskMode::None) { + mask_row(m, y, &mut tmp); + for (a, v) in row.iter_mut().zip(&tmp) { + *a = m.mode.combine(*a, *v).clamp(0.0, 1.0); + } + } + }); + Some(acc) +} + +/// Coverage of an effect's masks on a working image (`px_scale` working px per clip px). +pub fn effect_coverage(masks: &[Mask], t: Tick, px_scale: f32, w: usize, h: usize) -> Option> { + if masks.is_empty() { + return None; + } + coverage(&prepare(masks, t, px_scale), w, h) +} + +/// `img = lerp(original, img, cov)` on premultiplied pixels. +pub fn mix(img: &mut Image, original: &Image, cov: &[f32]) { + img.px.par_chunks_mut(4).zip(original.px.par_chunks(4)).zip(cov.par_iter()).for_each(|((p, o), &c)| { + for k in 0..4 { + p[k] = o[k] + (p[k] - o[k]) * c; + } + }); +} + +/// Scale premultiplied pixels by coverage (opacity masks). +pub fn scale_by(img: &mut Image, cov: &[f32]) { + img.px.par_chunks_mut(4).zip(cov.par_iter()).for_each(|(p, &c)| { + for v in p { + *v *= c; + } + }); +} + +/// Apply an effect limited to its masks (Premiere: a masked effect only applies inside the mask). +pub fn apply_effect(img: &mut Image, e: &EffectInstance, cx: &crate::effects::FxCtx) { + if !e.enabled || e.masks.is_empty() { + crate::effects::apply(img, e, cx); + return; + } + let Some(cov) = effect_coverage(&e.masks, cx.t, cx.px_scale, img.w, img.h) else { + crate::effects::apply(img, e, cx); + return; + }; + let original = img.clone(); + crate::effects::apply(img, e, cx); + mix(img, &original, &cov); +} + +/// Apply the masks of an item's Opacity effect to its layer (before Motion), if any. +pub fn apply_opacity_masks(img: &mut Image, item: &filmcraft_project::TrackItem, t: Tick, px_scale: f32) { + let Some(op) = item.effect("opacity").filter(|e| e.enabled && !e.masks.is_empty()) else { return }; + if let Some(cov) = effect_coverage(&op.masks, t, px_scale, img.w, img.h) { + scale_by(img, &cov); + } +} + +/// Brute-force coverage of one pixel (every edge; the definition the row algorithm and the WGSL +/// shader implement). For tests and documentation. +pub fn coverage_at(masks: &[FlatMask], x: f32, y: f32) -> Option { + let first = masks.iter().find(|m| m.mode != MaskMode::None)?; + let mut acc = first.mode.start(); + for m in masks.iter().filter(|m| m.mode != MaskMode::None) { + let n = m.pts.len(); + let mut wind = 0; + let mut d2 = f32::INFINITY; + for i in 0..n { + let a = m.pts[i]; + let c = m.pts[(i + 1) % n]; + if (a[1] <= y) != (c[1] <= y) { + let t = (y - a[1]) / (c[1] - a[1]); + if a[0] + (c[0] - a[0]) * t < x { + wind += if c[1] > a[1] { 1 } else { -1 }; + } + } + d2 = d2.min(seg_dist2(x, y, a, c)); + } + let d = if n < 3 { band(m) } else { d2.sqrt().min(band(m)) }; + let sd = if wind != 0 && n >= 3 { d } else { -d }; + let c = falloff(sd + m.expansion, m.feather); + let v = m.opacity * if m.inverted { 1.0 - c } else { c }; + acc = m.mode.combine(acc, v).clamp(0.0, 1.0); + } + Some(acc) +} + +#[cfg(test)] +mod tests { + use super::*; + use filmcraft_geom::Vec2; + use filmcraft_project::MaskPath; + + fn mask(path: MaskPath, feather: f64) -> Mask { + let mut m = Mask::new("m", path); + m.feather.value = filmcraft_project::ParamValue::Float(feather); + m + } + + #[test] + fn hard_rect_is_exact_box_coverage() { + // rect edges on pixel boundaries → exactly 1 inside, 0 outside; half-pixel edge → 0.5 + let m = mask(MaskPath::rect(2.0, 2.0, 6.5, 6.0), 0.0); + let c = effect_coverage(&[m], Tick(0), 1.0, 10, 10).unwrap(); + assert_eq!(c[3 * 10 + 3], 1.0); + assert_eq!(c[3 * 10 + 1], 0.0); + assert!((c[3 * 10 + 6] - 0.5).abs() < 1e-6, "{}", c[3 * 10 + 6]); + assert_eq!(c[8 * 10 + 3], 0.0); + } + + #[test] + fn ellipse_area_matches_analytic() { + let (rx, ry) = (37.3, 21.7); + let m = mask(MaskPath::ellipse(Vec2::new(50.2, 40.1), Vec2::new(rx, ry)), 0.0); + let c = effect_coverage(&[m], Tick(0), 1.0, 100, 80).unwrap(); + let area: f64 = c.iter().map(|v| *v as f64).sum(); + let exact = std::f64::consts::PI * rx * ry; + assert!((area - exact).abs() / exact < 0.002, "{area} vs {exact}"); + } + + #[test] + fn feather_is_a_symmetric_distance_falloff() { + let m = mask(MaskPath::rect(20.0, -100.0, 200.0, 200.0), 10.0); + let c = effect_coverage(&[m], Tick(0), 1.0, 60, 4).unwrap(); + // edge at x = 20: 0.5 there, saturated 5 px away, symmetric + let at = |x: usize| c[x]; + assert!((at(19) + at(20) - 1.0).abs() < 1e-5, "{} {}", at(19), at(20)); + assert!(at(13) < 1e-6 && at(26) > 1.0 - 1e-6, "{} {}", at(13), at(26)); + for x in 14..26 { + assert!(at(x + 1) >= at(x), "monotonic"); + } + } + + #[test] + fn expansion_inverted_opacity_modes() { + let mut m = mask(MaskPath::rect(10.0, 10.0, 20.0, 20.0), 0.0); + m.expansion.value = filmcraft_project::ParamValue::Float(5.0); + let c = effect_coverage(std::slice::from_ref(&m), Tick(0), 1.0, 30, 30).unwrap(); + assert_eq!(c[15 * 30 + 6], 1.0, "expanded by 5"); + assert_eq!(c[15 * 30 + 4], 0.0); + m.inverted = true; + m.opacity.value = filmcraft_project::ParamValue::Float(50.0); + let c = effect_coverage(&[m.clone()], Tick(0), 1.0, 30, 30).unwrap(); + assert_eq!(c[15 * 30 + 15], 0.0); + assert_eq!(c[2], 0.5); + // subtract a hole from a big rect + let mut big = mask(MaskPath::rect(0.0, 0.0, 30.0, 30.0), 0.0); + big.mode = MaskMode::Add; + let mut hole = mask(MaskPath::rect(10.0, 10.0, 20.0, 20.0), 0.0); + hole.mode = MaskMode::Subtract; + let c = effect_coverage(&[big, hole], Tick(0), 1.0, 30, 30).unwrap(); + assert_eq!(c[15 * 30 + 15], 0.0); + assert_eq!(c[5 * 30 + 5], 1.0); + } + + #[test] + fn row_algorithm_matches_brute_force() { + let mut a = mask(MaskPath::ellipse(Vec2::new(30.0, 25.0), Vec2::new(20.0, 12.0)), 6.0); + a.expansion.value = filmcraft_project::ParamValue::Float(-2.0); + let mut b = mask(MaskPath::polygon(&[Vec2::new(5.0, 5.0), Vec2::new(50.0, 10.0), Vec2::new(20.0, 45.0)]), 0.0); + b.mode = MaskMode::Difference; + b.inverted = true; + let flat = prepare(&[a, b], Tick(0), 0.8); + let (w, h) = (48, 40); + let c = coverage(&flat, w, h).unwrap(); + for y in 0..h { + for x in 0..w { + let r = coverage_at(&flat, x as f32 + 0.5, y as f32 + 0.5).unwrap(); + assert!((r - c[y * w + x]).abs() < 1e-5, "({x},{y}) {r} vs {}", c[y * w + x]); + } + } + } +} diff --git a/crates/render/src/plan.rs b/crates/render/src/plan.rs index 47307e8f..aba9dec8 100644 --- a/crates/render/src/plan.rs +++ b/crates/render/src/plan.rs @@ -45,7 +45,7 @@ fn simple_transition(id: &str) -> bool { /// Whether a track item can be drawn by the GPU as-is (no standard effects, Normal blend). fn gpu_simple(project: &Project, item: &TrackItem, mt: Tick) -> bool { let is_media = project.item(item.item).is_some_and(|p| matches!(p.kind, ItemKind::Media(_) | ItemKind::Subclip { .. })); - let no_fx = !item.has_standard_effects(); + let no_fx = !item.has_standard_effects() && !item.has_opacity_masks(); let normal = item.effect("opacity").is_none_or(|e| !e.enabled || e.param("blend").is_none_or(|p| matches!(p.value, filmcraft_project::ParamValue::Choice(0)))); let _ = mt; @@ -149,6 +149,7 @@ fn push_item( // image the GPU places as a layer. if bl == Blend::Normal && !(opts.effects && item.has_standard_effects()) + && !item.has_opacity_masks() && project.item(item.item).is_some_and(|p| matches!(p.kind, ItemKind::Graphic { .. })) { let Some(size) = crate::source_size(project, item.item) else { return }; diff --git a/crates/render/src/preview.rs b/crates/render/src/preview.rs index 4ffc0dfd..a7e9dfc9 100644 --- a/crates/render/src/preview.rs +++ b/crates/render/src/preview.rs @@ -442,7 +442,7 @@ fn item_fx_cost(it: &TrackItem, px: f64) -> f64 { } fn cpu_layer(project: &Project, it: &TrackItem) -> bool { - let fx = it.effects.iter().any(|e| e.enabled && e.def().is_some_and(|d| !d.intrinsic)); + let fx = it.effects.iter().any(|e| e.enabled && e.def().is_some_and(|d| !d.intrinsic)) || it.has_opacity_masks(); let blend = crate::opacity_blend(it, it.source_in).1 != crate::Blend::Normal; let nested = project.item(it.item).is_some_and(|p| matches!(p.kind, ItemKind::Sequence(_))); fx || blend || nested diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 251667a3..c947aa24 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -87,6 +87,11 @@ pub enum Icon { Proxy, /// Offline media: a broken link. Offline, + /// Mask tracking: backward continuously / one frame, forward one frame / continuously. + TrackMaskBack, + TrackMaskBackFrame, + TrackMaskFwdFrame, + TrackMaskFwd, } pub struct Pen16<'a> { @@ -508,6 +513,16 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.line(&[(3.0, 1.5), (3.5, 2.8)]); pen.line(&[(13.0, 14.5), (12.5, 13.2)]); } + TrackMaskBack => { + pen.fill(&[(8.0, 3.5), (2.0, 8.0), (8.0, 12.5)]); + pen.fill(&[(14.0, 3.5), (8.0, 8.0), (14.0, 12.5)]); + } + TrackMaskBackFrame => pen.fill(&[(11.0, 3.5), (4.0, 8.0), (11.0, 12.5)]), + TrackMaskFwdFrame => pen.fill(&[(5.0, 3.5), (12.0, 8.0), (5.0, 12.5)]), + TrackMaskFwd => { + pen.fill(&[(2.0, 3.5), (8.0, 8.0), (2.0, 12.5)]); + pen.fill(&[(8.0, 3.5), (14.0, 8.0), (8.0, 12.5)]); + } } } diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 0597566c..170d2e16 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -131,15 +131,18 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { continue; } for pd in &def.params { - param_row(app, bui, body, clip, idx, e, pd, mt_now, &mut actions, &lane, &lx, &it); + param_row(app, bui, body, clip, idx, e, None, pd, mt_now, &mut actions, &lane, &lx, &it); if app.ui.expanded_fx.contains(&graph_key(clip, idx, pd.id)) && let Some(param) = e.params.get(pd.id) && param.is_animated() && matches!(param.value, ParamValue::Float(_)) { - graph_rows(app, bui, body, clip, idx, pd, param, &lane, &it, &mut actions); + graph_rows(app, bui, body, clip, idx, None, pd, param, &lane, &it, &mut actions); } } + if crate::panels::masks::maskable(e) { + crate::panels::masks::effect_rows(app, bui, body, clip, idx, e, mt_now, &mut actions, &lane, &lx, &it); + } } }); let _ = scroll_out; @@ -161,13 +164,14 @@ fn seq_name(app: &FilmcraftApp) -> String { } #[allow(clippy::too_many_arguments)] -fn param_row( +pub(crate) fn param_row( app: &mut FilmcraftApp, ui: &mut egui::Ui, body: Rect, clip: ClipId, idx: usize, e: &EffectInstance, + mask: Option, pd: &filmcraft_project::ParamDef, mt: Tick, actions: &mut Vec<(String, Value)>, @@ -177,17 +181,33 @@ fn param_row( ) { let _ = lx; let t = app.tokens; - let Some(param) = e.params.get(pd.id) else { return }; + let param = match mask { + Some(k) => e.masks.get(k).and_then(|m| m.param(pd.id)), + None => e.params.get(pd.id), + }; + let Some(param) = param else { return }; + // mask parameters: automation ids / widget ids get a `m.` prefix, commands a `mask` field + let pkey: String = match mask { + Some(k) => format!("mask{k}.{}", pd.id), + None => pd.id.to_string(), + }; + let pkey = pkey.as_str(); + let with_mask = |mut v: Value| -> Value { + if let Some(k) = mask { + v["mask"] = json!(k); + } + v + }; let (r, _) = ui.allocate_exact_size(vec2(body.width(), ROW_H), Sense::hover()); let mut x = r.min.x + 26.0; // twirl-down for the value/velocity graphs (animated scalar params) if param.is_animated() && matches!(param.value, ParamValue::Float(_)) { - let key = graph_key(clip, idx, pd.id); + let key = graph_key(clip, idx, pkey); let open = app.ui.expanded_fx.contains(&key); let tw = Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(12.0, 12.0)); - let tresp = ui.interact(tw.expand(2.0), egui::Id::new(("twirl", clip.0, idx, pd.id)), Sense::click()).on_hover_text("Show graphs"); + let tresp = ui.interact(tw.expand(2.0), egui::Id::new(("twirl", clip.0, idx, pkey)), Sense::click()).on_hover_text("Show graphs"); icons::paint(ui.painter(), tw, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim); - app.auto.add(&format!("effectControls.{}.{}.graphs", e.effect, pd.id), tw, "Show graphs"); + app.auto.add(&format!("effectControls.{}.{}.graphs", e.effect, pkey), tw, "Show graphs"); if tresp.clicked() { if open { app.ui.expanded_fx.retain(|k| *k != key); @@ -198,12 +218,12 @@ fn param_row( } if pd.animatable { let sw = Rect::from_center_size(pos2(x, r.center().y), vec2(14.0, 14.0)); - let resp = ui.interact(sw, egui::Id::new(("sw", clip.0, idx, pd.id)), Sense::click()).on_hover_text("Toggle animation"); + let resp = ui.interact(sw, egui::Id::new(("sw", clip.0, idx, pkey)), Sense::click()).on_hover_text("Toggle animation"); icons::paint(ui.painter(), sw, Icon::Stopwatch, if param.is_animated() { t.accent } else { t.text_dim }); if resp.clicked() { - actions.push(("effects.toggleAnimation".into(), json!({"clip": clip.0, "effect": idx, "param": pd.id}))); + actions.push(("effects.toggleAnimation".into(), with_mask(json!({"clip": clip.0, "effect": idx, "param": pd.id})))); } - app.auto.add(&format!("effectControls.{}.{}.stopwatch", e.effect, pd.id), sw, "Toggle animation"); + app.auto.add(&format!("effectControls.{}.{}.stopwatch", e.effect, pkey), sw, "Toggle animation"); } x += 14.0; ui.painter().text(pos2(x, r.center().y), Align2::LEFT_CENTER, pd.label, Tokens::ui(12.0), t.text); @@ -214,7 +234,7 @@ fn param_row( .max_rect(Rect::from_min_max(pos2(vx, r.min.y + 1.0), pos2(r.max.x - 26.0, r.max.y - 1.0))) .layout(egui::Layout::left_to_right(egui::Align::Center)), ); - let id = egui::Id::new(("pv", clip.0, idx, pd.id)); + let id = egui::Id::new(("pv", clip.0, idx, pkey)); let mut set: Option = None; match (&pd.kind, &value) { (ParamKind::Float { min, max, soft_min, soft_max, unit, decimals }, ParamValue::Float(v)) => { @@ -259,12 +279,15 @@ fn param_row( set = Some(json!([rgba.r(), rgba.g(), rgba.b(), rgba.a()])); } } + (ParamKind::Path, _) => { + crate::panels::masks::path_value(app, &mut vui, clip, idx, mask, actions); + } _ => { vui.label(format!("{value:?}")); } } if let Some(v) = set { - actions.push(("effects.setParam".into(), json!({"clip": clip.0, "effect": idx, "param": pd.id, "value": v}))); + actions.push(("effects.setParam".into(), with_mask(json!({"clip": clip.0, "effect": idx, "param": pd.id, "value": v})))); } // keyframe navigator ◀ ◆ ▶ (when animated) let eff_json = json!(idx); @@ -290,20 +313,20 @@ fn param_row( arrow(ui.painter(), pr, true, prev.is_some()); arrow(ui.painter(), nr, false, next.is_some()); icons::paint(ui.painter(), kr, Icon::Keyframe, if at_key { t.hot_text } else { t.text_dim }); - if ui.interact(pr, egui::Id::new(("kprev", clip.0, idx, pd.id)), Sense::click()).clicked() + if ui.interact(pr, egui::Id::new(("kprev", clip.0, idx, pkey)), Sense::click()).clicked() && let Some(k) = prev { actions.push(("playhead.set".into(), json!({"time": to_tl(k).0}))); } - if ui.interact(nr, egui::Id::new(("knext", clip.0, idx, pd.id)), Sense::click()).clicked() + if ui.interact(nr, egui::Id::new(("knext", clip.0, idx, pkey)), Sense::click()).clicked() && let Some(k) = next { actions.push(("playhead.set".into(), json!({"time": to_tl(k).0}))); } - if ui.interact(kr, egui::Id::new(("kadd", clip.0, idx, pd.id)), Sense::click()).on_hover_text("Add/Remove Keyframe").clicked() { - actions.push(("effects.addKeyframe".into(), json!({"clip": clip.0, "effect": eff_json, "param": pd.id}))); + if ui.interact(kr, egui::Id::new(("kadd", clip.0, idx, pkey)), Sense::click()).on_hover_text("Add/Remove Keyframe").clicked() { + actions.push(("effects.addKeyframe".into(), with_mask(json!({"clip": clip.0, "effect": eff_json, "param": pd.id})))); } - app.auto.add(&format!("effectControls.{}.{}.addKeyframe", e.effect, pd.id), kr, "Add/Remove Keyframe"); + app.auto.add(&format!("effectControls.{}.{}.addKeyframe", e.effect, pkey), kr, "Add/Remove Keyframe"); } // keyframes in the lane: draggable diamonds; right-click for interpolation if param.is_animated() { @@ -316,12 +339,12 @@ fn param_row( if !(-0.01..=1.01).contains(&f) { continue; } - let id = egui::Id::new(("kf", clip.0, idx, pd.id, k.time.0)); + let id = egui::Id::new(("kf", clip.0, idx, pkey, k.time.0)); let drag_off: Option = ui.data(|d| d.get_temp(id)); let kx = lane.min.x + f * lane.width() + drag_off.unwrap_or(0.0); let kr = Rect::from_center_size(pos2(kx, y), vec2(11.0, 11.0)); let resp = ui.interact(kr.expand(2.0), id, Sense::click_and_drag()); - app.auto.add(&format!("effectControls.{}.{}.keyframe.{}", e.effect, pd.id, k.time.0), kr, "keyframe"); + app.auto.add(&format!("effectControls.{}.{}.keyframe.{}", e.effect, pkey, k.time.0), kr, "keyframe"); let sel = k.time == mt || resp.dragged(); let col = if sel { t.hot_text } else { Color32::from_rgb(0xb0, 0xb0, 0xb0) }; match k.interp { @@ -346,7 +369,7 @@ fn param_row( if new_media != k.time { actions.push(( "effects.moveKeyframe".into(), - json!({"clip": clip.0, "effect": eff_json, "param": pd.id, "mediaTime": k.time.0, "to": new_media.0}), + with_mask(json!({"clip": clip.0, "effect": eff_json, "param": pd.id, "mediaTime": k.time.0, "to": new_media.0})), )); } } @@ -367,14 +390,15 @@ fn param_row( if ui.button(label).clicked() { actions.push(( "effects.setInterpolation".into(), - json!({"clip": clip.0, "effect": eff_json, "param": pd.id, "mediaTime": k.time.0, "interpolation": key}), + with_mask(json!({"clip": clip.0, "effect": eff_json, "param": pd.id, "mediaTime": k.time.0, "interpolation": key})), )); ui.close(); } } ui.separator(); if ui.button("Clear").clicked() { - actions.push(("effects.deleteKeyframe".into(), json!({"clip": clip.0, "effect": eff_json, "param": pd.id, "mediaTime": k.time.0}))); + actions + .push(("effects.deleteKeyframe".into(), with_mask(json!({"clip": clip.0, "effect": eff_json, "param": pd.id, "mediaTime": k.time.0})))); ui.close(); } }); @@ -644,25 +668,32 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } } -fn graph_key(clip: ClipId, idx: usize, pid: &str) -> String { +pub(crate) fn graph_key(clip: ClipId, idx: usize, pid: &str) -> String { format!("graph:{}:{}:{}", clip.0, idx, pid) } /// Value and velocity graphs of an animated scalar parameter, drawn across the keyframe lane. /// Keyframes drag vertically (value); Bezier influence handles drag horizontally. #[allow(clippy::too_many_arguments)] -fn graph_rows( +pub(crate) fn graph_rows( app: &mut FilmcraftApp, ui: &mut egui::Ui, body: Rect, clip: ClipId, idx: usize, + mask: Option, pd: &filmcraft_project::ParamDef, param: &filmcraft_project::Param, lane: &Rect, it: &TrackItem, actions: &mut Vec<(String, Value)>, ) { + let with_mask = |mut v: Value| -> Value { + if let Some(k) = mask { + v["mask"] = json!(k); + } + v + }; let t = app.tokens; let (vr, _) = ui.allocate_exact_size(vec2(body.width(), 110.0), Sense::hover()); let (velr, _) = ui.allocate_exact_size(vec2(body.width(), 64.0), Sense::hover()); @@ -728,7 +759,7 @@ fn graph_rows( if !(-0.01..=1.01).contains(&f) { continue; } - let id = egui::Id::new(("kfg", clip.0, idx, pd.id, k.time.0)); + let id = egui::Id::new(("kfg", clip.0, idx, mask, pd.id, k.time.0)); let dy: f32 = ui.data(|d| d.get_temp(id)).unwrap_or(0.0); let c = pos2(x_of(f), y_of(v) + dy); let r = Rect::from_center_size(c, vec2(10.0, 10.0)); @@ -758,7 +789,7 @@ fn graph_rows( ui.data_mut(|d| d.remove::(hid)); let ni = ((infl * seg + hdx * side) / seg.max(1.0)).clamp(0.01, 1.0); let key = if side > 0.0 { "outInfluence" } else { "inInfluence" }; - actions.push(("effects.setKeyframe".into(), json!({"clip": clip.0, "effect": idx, "param": pd.id, "mediaTime": k.time.0, key: ni}))); + actions.push(("effects.setKeyframe".into(), with_mask(json!({"clip": clip.0, "effect": idx, "param": pd.id, "mediaTime": k.time.0, key: ni})))); } } p.circle_filled(c, 4.5, if resp.dragged() { t.hot_text } else { Color32::from_rgb(0xe0, 0xe0, 0xe0) }); @@ -771,7 +802,7 @@ fn graph_rows( ui.data_mut(|d| d.remove::(id)); let nv = v_of(c.y); let nv = if let ParamKind::Float { min, max, .. } = pd.kind { nv.clamp(min, max) } else { nv }; - actions.push(("effects.setKeyframe".into(), json!({"clip": clip.0, "effect": idx, "param": pd.id, "mediaTime": k.time.0, "value": nv}))); + actions.push(("effects.setKeyframe".into(), with_mask(json!({"clip": clip.0, "effect": idx, "param": pd.id, "mediaTime": k.time.0, "value": nv})))); } } } diff --git a/crates/ui-egui/src/panels/masks.rs b/crates/ui-egui/src/panels/masks.rs new file mode 100644 index 00000000..c2e62876 --- /dev/null +++ b/crates/ui-egui/src/panels/masks.rs @@ -0,0 +1,536 @@ +//! Effect masks in the UI: the mask rows of Effect Controls (create ellipse / 4-point polygon / +//! pen masks, per-mask Path / Feather / Opacity / Expansion / Inverted / mode) and on-monitor +//! editing on the Program monitor (drag vertices, Bézier handles, the whole mask, feather and +//! expansion handles; the pen places a free-draw Bézier mask). +//! +//! Automation ids: `effectControls..mask.{ellipse,polygon,pen}`, +//! `effectControls..mask` (+ `.mode`, `.inverted`, `.delete`, and the parameter rows +//! `effectControls..mask..*`), `program.mask.vertex.`, `program.mask.in.`, +//! `program.mask.out.`, `program.mask.body`, `program.mask.feather`, `program.mask.expansion`, +//! `program.maskPen`. + +use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, pos2, vec2}; +use filmcraft_geom::{Affine, Vec2}; +use filmcraft_project::{ClipId, EffectInstance, MaskMode, MaskPath, MaskVertex, TrackItem}; +use filmcraft_time::Tick; +use serde_json::{Value, json}; + +use crate::FilmcraftApp; +use crate::icons::{self, Icon}; +use crate::theme::Tokens; + +const ROW_H: f32 = 22.0; + +/// Whether an effect instance takes masks (video effects and Opacity; not Motion or graphic layers). +pub fn maskable(e: &EffectInstance) -> bool { + let Some(d) = e.def() else { return false }; + d.kind == filmcraft_project::EffectKind::Video && (!d.intrinsic || e.effect == "opacity") && !filmcraft_project::graphic::is_layer(e) +} + +/// Mask rows under an effect in Effect Controls. +#[allow(clippy::too_many_arguments)] +pub fn effect_rows( + app: &mut FilmcraftApp, + ui: &mut egui::Ui, + body: Rect, + clip: ClipId, + idx: usize, + e: &EffectInstance, + mt: Tick, + actions: &mut Vec<(String, Value)>, + lane: &Rect, + lx: &dyn Fn(Tick) -> f32, + it: &TrackItem, +) { + let t = app.tokens; + // creation tools: ellipse, 4-point polygon, free-draw Bézier + let (r, _) = ui.allocate_exact_size(vec2(body.width(), ROW_H), Sense::hover()); + let pen_on = app.ui.mask_pen.as_ref().is_some_and(|p| p.clip == clip.0 && p.effect == idx); + for (i, (icon, kind, tip)) in + [(Icon::Ellipse, "ellipse", "Create ellipse mask"), (Icon::Rectangle, "polygon", "Create 4-point polygon mask"), (Icon::Pen, "pen", "Free draw bezier")] + .into_iter() + .enumerate() + { + let br = Rect::from_center_size(pos2(r.min.x + 40.0 + i as f32 * 24.0, r.center().y), vec2(20.0, 18.0)); + let resp = ui.interact(br, egui::Id::new(("mask-new", clip.0, idx, kind)), Sense::click()).on_hover_text(tip); + if resp.hovered() { + ui.painter().rect_filled(br, 3.0, t.hover); + } + let on = kind == "pen" && pen_on; + icons::paint(ui.painter(), br.shrink(3.0), icon, if on { t.accent } else { t.icon }); + app.auto.add(&format!("effectControls.{}.mask.{kind}", e.effect), br, tip); + if resp.clicked() { + if kind == "pen" { + app.ui.mask_pen = if on { None } else { Some(crate::state::MaskPenDraft { clip: clip.0, effect: idx, points: vec![] }) }; + if !on { + app.ui.status = "Click in the Program monitor to place mask points (drag for curves); click the first point or press Enter to close".into(); + } + } else { + actions.push(("masks.add".into(), json!({"clip": clip.0, "effect": idx, "shape": kind}))); + } + } + } + let defs = filmcraft_project::mask::mask_param_defs(); + for (k, m) in e.masks.iter().enumerate() { + let key = format!("mask:{}:{}:{}", clip.0, idx, k); + let open = !app.ui.collapsed_fx.contains(&key); + let selected = app.session.state.selected_mask == Some(filmcraft_engine::masks::MaskSel { clip, effect: idx, mask: k }); + let (r, resp) = ui.allocate_exact_size(vec2(body.width(), ROW_H), Sense::click()); + if selected { + ui.painter().rect_filled(r, 0.0, t.row_selected); + } else if resp.hovered() { + ui.painter().rect_filled(r, 0.0, t.hover); + } + let tw = Rect::from_center_size(pos2(r.min.x + 26.0, r.center().y), vec2(10.0, 10.0)); + icons::paint(ui.painter(), tw, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim); + let twresp = ui.interact(tw.expand(3.0), egui::Id::new(("mask-twirl", clip.0, idx, k)), Sense::click()); + // shape glyph: ellipse for smooth paths, square for polygons + let path = m.path_at(mt); + let glyph = Rect::from_center_size(pos2(r.min.x + 42.0, r.center().y), vec2(12.0, 12.0)); + let gi = if path.vertices.iter().all(|v| v.is_corner()) { + Icon::Rectangle + } else if path.len() == 4 { + Icon::Ellipse + } else { + Icon::Pen + }; + icons::paint(ui.painter(), glyph, gi, if selected { t.accent } else { t.text_dim }); + ui.painter().text(pos2(r.min.x + 54.0, r.center().y), Align2::LEFT_CENTER, &m.name, Tokens::ui(12.0), t.text); + let base = format!("effectControls.{}.mask{k}", e.effect); + app.auto.add(&base, r, &m.name); + // mode + let mr = Rect::from_min_size(pos2(r.max.x - 118.0, r.min.y + 2.0), vec2(96.0, ROW_H - 4.0)); + let mut mui = ui.new_child(egui::UiBuilder::new().max_rect(mr).layout(egui::Layout::left_to_right(egui::Align::Center))); + let mut mode = m.mode; + egui::ComboBox::from_id_salt(("mask-mode", clip.0, idx, k)).selected_text(mode.label()).width(88.0).show_ui(&mut mui, |ui| { + for md in MaskMode::ALL { + if ui.selectable_value(&mut mode, md, md.label()).changed() { + actions.push(("masks.set".into(), json!({"clip": clip.0, "effect": idx, "mask": k, "mode": md.label()}))); + } + } + }); + app.auto.add(&format!("{base}.mode"), mr, "Mask mode"); + if twresp.clicked() { + if open { + app.ui.collapsed_fx.push(key.clone()); + } else { + app.ui.collapsed_fx.retain(|x| *x != key); + } + } else if resp.clicked() { + if selected { + actions.push(("masks.select".into(), json!({"none": true}))); + } else { + actions.push(("masks.select".into(), json!({"clip": clip.0, "effect": idx, "mask": k}))); + } + } + resp.context_menu(|ui| { + if ui.button("Delete Mask").clicked() { + actions.push(("masks.remove".into(), json!({"clip": clip.0, "effect": idx, "mask": k}))); + ui.close(); + } + }); + if !open { + continue; + } + for pd in defs { + crate::panels::effect_controls::param_row(app, ui, body, clip, idx, e, Some(k), pd, mt, actions, lane, lx, it); + if pd.id != "path" + && app.ui.expanded_fx.contains(&crate::panels::effect_controls::graph_key(clip, idx, &format!("mask{k}.{}", pd.id))) + && let Some(param) = m.param(pd.id) + && param.is_animated() + { + crate::panels::effect_controls::graph_rows(app, ui, body, clip, idx, Some(k), pd, param, lane, it, actions); + } + } + // Inverted + let (r, _) = ui.allocate_exact_size(vec2(body.width(), ROW_H), Sense::hover()); + ui.painter().text(pos2(r.min.x + 40.0, r.center().y), Align2::LEFT_CENTER, "Inverted", Tokens::ui(12.0), t.text); + let cr = Rect::from_min_size(pos2(r.min.x + (r.width() * 0.5).max(150.0), r.min.y + 2.0), vec2(20.0, ROW_H - 4.0)); + let mut cui = ui.new_child(egui::UiBuilder::new().max_rect(cr)); + let mut inv = m.inverted; + if cui.checkbox(&mut inv, "").changed() { + actions.push(("masks.set".into(), json!({"clip": clip.0, "effect": idx, "mask": k, "inverted": inv}))); + } + app.auto.add(&format!("{base}.inverted"), cr, "Inverted"); + } +} + +/// Clip pixels → screen points for the clip's current Motion and the monitor picture rect. +fn clip_to_screen(app: &FilmcraftApp, it: &TrackItem, mt: Tick, pic: Rect, frame: (u32, u32)) -> Option { + let seq = app.session.active_sequence()?; + let size = filmcraft_render::source_size(&app.session.project, it.item).unwrap_or(frame); + let motion = filmcraft_render::motion_matrix(seq, it, size, mt); + let view = Affine::translate(pic.min.x as f64, pic.min.y as f64) + .then_apply(&Affine::scale(pic.width() as f64 / frame.0 as f64, pic.height() as f64 / frame.1 as f64)); + Some(view.then_apply(&motion)) +} + +fn sp(m: &Affine, v: Vec2) -> Pos2 { + let q = m.apply(v); + pos2(q.x as f32, q.y as f32) +} + +/// Outward offset of a flattened (clip-space) polygon by `d` along averaged vertex normals. +fn offset(pts: &[Vec2], d: f64) -> Vec { + let n = pts.len(); + if n < 3 || d == 0.0 { + return pts.to_vec(); + } + // orientation: positive shoelace area in y-down = clockwise on screen + let area: f64 = (0..n).map(|i| pts[i].x * pts[(i + 1) % n].y - pts[(i + 1) % n].x * pts[i].y).sum(); + let s = if area > 0.0 { 1.0 } else { -1.0 }; + (0..n) + .map(|i| { + let a = pts[(i + n - 1) % n]; + let b = pts[(i + 1) % n]; + let t = b - a; + let len = t.length().max(1e-9); + // right-hand normal of a clockwise polygon points outward + let nrm = Vec2::new(t.y / len, -t.x / len) * s; + pts[i] + nrm * d + }) + .collect() +} + +fn inside(pts: &[Vec2], p: Vec2) -> bool { + let n = pts.len(); + let mut w = 0; + for i in 0..n { + let (a, b) = (pts[i], pts[(i + 1) % n]); + if (a.y <= p.y) != (b.y <= p.y) && a.x + (b.x - a.x) * (p.y - a.y) / (b.y - a.y) < p.x { + w += if b.y > a.y { 1 } else { -1 }; + } + } + w != 0 +} + +/// Mask editing on the Program monitor (selected mask) and the pen tool. +pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, frame: (u32, u32)) { + if app.ui.mask_pen.is_some() { + pen_overlay(app, ui, pic, frame); + return; + } + let Some(sel) = app.session.state.selected_mask else { return }; + let Some(seq) = app.session.active_sequence() else { return }; + let ph = app.session.playhead(); + let Some((_, it)) = seq.find_item(sel.clip) else { return }; + if ph < it.start || ph >= it.end() { + return; + } + 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 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 }; + let path = m.path_at(mt); + let feather = m.feather.f64_at(mt).max(0.0); + let expansion = m.expansion.f64_at(mt); + let t = app.tokens; + let col = Color32::from_rgb(0x5a, 0xb4, 0xff); + let painter = ui.painter().with_clip_rect(pic.expand(16.0)); + let flat = path.flatten(0.5); + let screen: Vec = flat.iter().map(|v| sp(&to_screen, *v)).collect(); + painter.add(egui::Shape::closed_line(screen.clone(), Stroke::new(1.5, col))); + // expansion + feather contours (dashed) + let exp_pts = offset(&flat, expansion); + let fea_pts = offset(&flat, expansion + feather / 2.0); + let dashed = |pts: &[Vec2], c: Color32| { + let mut v: Vec = pts.iter().map(|q| sp(&to_screen, *q)).collect(); + if let Some(f) = v.first().copied() { + v.push(f); + } + painter.extend(egui::Shape::dashed_line(&v, Stroke::new(1.0, c), 4.0, 3.0)); + }; + if expansion.abs() > 1e-6 { + dashed(&exp_pts, col.gamma_multiply(0.8)); + } + if feather > 1e-6 { + dashed(&fea_pts, col.gamma_multiply(0.6)); + } + let mut actions: Vec<(String, Value)> = Vec::new(); + let target = json!({"clip": sel.clip.0, "effect": sel.effect, "mask": sel.mask}); + let with = |extra: Value| -> Value { + let mut v = target.clone(); + if let (Some(o), Some(x)) = (v.as_object_mut(), extra.as_object()) { + for (k, val) in x { + o.insert(k.clone(), val.clone()); + } + } + v + }; + let gesture_id = egui::Id::new("mask-gesture"); + // one gesture (undo step) per pointer press + let mut gesture: u64 = ui.data(|d| d.get_temp(gesture_id)).unwrap_or(0); + if ui.input(|i| i.pointer.any_pressed()) { + gesture += 1; + ui.data_mut(|d| d.insert_temp(gesture_id, gesture)); + } + let clip_delta = |d: egui::Vec2| -> [f64; 2] { + let v = lin_inv.apply(Vec2::new(d.x as f64, d.y as f64)); + [v.x, v.y] + }; + // ---- whole mask (drag inside) + let bb = Rect::from_points(&screen).expand(2.0).intersect(pic); + let body = ui.interact(bb, egui::Id::new("mask-body"), Sense::click_and_drag()); + app.auto.add("program.mask.body", bb, &m.name); + let ptr_in = |p: Option| p.is_some_and(|p| inside(&flat, to_clip.apply(Vec2::new(p.x as f64, p.y as f64)))); + if body.drag_started() { + let ok = ptr_in(body.interact_pointer_pos()); + ui.data_mut(|d| d.insert_temp(gesture_id.with("body"), ok)); + } + if body.dragged() && ui.data(|d| d.get_temp::(gesture_id.with("body"))).unwrap_or(false) && body.drag_delta() != egui::Vec2::ZERO { + actions.push(("masks.translate".into(), with(json!({"delta": clip_delta(body.drag_delta()), "merge": format!("body-{gesture}")})))); + } + if body.clicked() && !ptr_in(body.interact_pointer_pos()) { + actions.push(("masks.select".into(), json!({"none": true}))); + } + if body.hovered() && ptr_in(body.hover_pos()) { + ui.ctx().set_cursor_icon(egui::CursorIcon::Move); + } + // ---- feather (top) and expansion (bottom) handles + let top = (0..flat.len()).min_by(|a, b| screen[*a].y.total_cmp(&screen[*b].y)); + let bottom = (0..flat.len()).max_by(|a, b| screen[*a].y.total_cmp(&screen[*b].y)); + if let (Some(ti), Some(bi)) = (top, bottom) { + let unit_n = |i: usize| { + let o = offset(&flat, 1.0)[i] - flat[i]; + o * (1.0 / o.length().max(1e-9)) + }; + for (which, i, dist) in [("feather", ti, expansion + feather / 2.0), ("expansion", bi, expansion)] { + let n = unit_n(i); + let anchor = sp(&to_screen, flat[i]); + let hp = sp(&to_screen, flat[i] + n * dist); + // keep the handle visible off the contour: a short stem outward + let stem = egui::Vec2::new((sp(&to_screen, flat[i] + n) - anchor).x, (sp(&to_screen, flat[i] + n) - anchor).y); + let stem = if stem.length() > 1e-6 { stem.normalized() } else { egui::Vec2::new(0.0, -1.0) }; + let hp = hp + stem * 14.0; + painter.line_segment([sp(&to_screen, flat[i] + n * dist), hp], Stroke::new(1.0, col)); + let hr = Rect::from_center_size(hp, vec2(9.0, 9.0)); + if which == "feather" { + painter.circle_filled(hp, 4.0, col); + } else { + painter.add(egui::Shape::convex_polygon( + vec![hp + vec2(0.0, -4.5), hp + vec2(4.5, 0.0), hp + vec2(0.0, 4.5), hp + vec2(-4.5, 0.0)], + col, + Stroke::NONE, + )); + } + let r = ui.interact(hr.expand(3.0), egui::Id::new(("mask-handle", which)), Sense::drag()); + app.auto.add(&format!("program.mask.{which}"), hr, if which == "feather" { "Mask Feather" } else { "Mask Expansion" }); + if r.dragged() && r.drag_delta() != egui::Vec2::ZERO { + let d = clip_delta(r.drag_delta()); + let along = d[0] * n.x + d[1] * n.y; + let (key, v) = if which == "feather" { ("feather", (feather + 2.0 * along).max(0.0)) } else { ("expansion", expansion + along) }; + actions.push(("masks.set".into(), with(json!({key: v, "merge": format!("{which}-{gesture}")})))); + painter.text(hp + vec2(10.0, -10.0), Align2::LEFT_BOTTOM, format!("{v:.1}"), Tokens::ui(10.5), t.hot_text); + } + } + } + // ---- tangent handles and vertices + let sel_v_id = egui::Id::new(("mask-sel-vertex", sel.clip.0, sel.effect, sel.mask)); + let sel_v: Option = ui.data(|d| d.get_temp(sel_v_id)); + let alt = ui.input(|i| i.modifiers.alt); + let cmd = ui.input(|i| i.modifiers.command); + for (i, v) in path.vertices.iter().enumerate() { + if v.is_corner() { + continue; + } + let p = sp(&to_screen, v.p); + for (which, tan) in [("in", v.t_in), ("out", v.t_out)] { + let h = sp(&to_screen, v.p + tan); + painter.line_segment([p, h], Stroke::new(1.0, col.gamma_multiply(0.8))); + painter.circle_filled(h, 3.5, Color32::WHITE); + painter.circle_stroke(h, 3.5, Stroke::new(1.0, col)); + let hr = Rect::from_center_size(h, vec2(8.0, 8.0)); + let r = ui.interact(hr.expand(2.0), egui::Id::new(("mask-tan", which, i)), Sense::drag()); + app.auto.add(&format!("program.mask.{which}.{i}"), hr, "Bezier handle"); + if r.dragged() && r.drag_delta() != egui::Vec2::ZERO { + actions.push(( + "masks.moveVertex".into(), + with(json!({"vertex": i, "handle": which, "delta": clip_delta(r.drag_delta()), "breakHandles": alt, "merge": format!("tan-{gesture}")})), + )); + } + } + } + for (i, v) in path.vertices.iter().enumerate() { + let p = sp(&to_screen, v.p); + let vr = Rect::from_center_size(p, vec2(7.0, 7.0)); + let selected = sel_v == Some(i); + painter.rect_filled(vr, 0.0, if selected { col } else { Color32::WHITE }); + painter.rect_stroke(vr, 0.0, Stroke::new(1.0, col), egui::StrokeKind::Middle); + let r = ui + .interact(vr.expand(3.0), egui::Id::new(("mask-vertex", i)), Sense::click_and_drag()) + .on_hover_text("Drag: move · Alt-click: smooth/corner · Cmd-click: delete"); + app.auto.add(&format!("program.mask.vertex.{i}"), vr, "Mask vertex"); + if r.drag_started() { + ui.data_mut(|d| d.insert_temp(sel_v_id, i)); + } + if r.dragged() && r.drag_delta() != egui::Vec2::ZERO { + actions.push(("masks.moveVertex".into(), with(json!({"vertex": i, "delta": clip_delta(r.drag_delta()), "merge": format!("vertex-{gesture}")})))); + } + if r.clicked() { + if alt { + actions.push(("masks.toggleVertexSmooth".into(), with(json!({"vertex": i})))); + } else if cmd { + actions.push(("masks.removeVertex".into(), with(json!({"vertex": i})))); + } else { + ui.data_mut(|d| d.insert_temp(sel_v_id, i)); + } + } + } + // Cmd-click on an edge adds a vertex there + if cmd + && body.clicked() + && let Some(pp) = body.interact_pointer_pos() + { + let n = path.vertices.len(); + let best = (0..n) + .map(|i| { + let seg = MaskPath { vertices: vec![path.vertices[i], path.vertices[(i + 1) % n]], closed: false }.flatten(0.5); + let d = seg.windows(2).map(|w| seg_dist(pp, sp(&to_screen, w[0]), sp(&to_screen, w[1]))).fold(f32::INFINITY, f32::min); + (i, d) + }) + .min_by(|a, b| a.1.total_cmp(&b.1)); + if let Some((i, d)) = best + && d < 8.0 + { + let c = to_clip.apply(Vec2::new(pp.x as f64, pp.y as f64)); + actions.retain(|(c, _)| c != "masks.select"); + actions.push(("masks.addVertex".into(), with(json!({"after": i, "at": [c.x, c.y]})))); + } + } + if ui.input(|i| i.key_pressed(egui::Key::Escape)) && ui.ctx().memory(|m| m.focused().is_none()) { + actions.push(("masks.select".into(), json!({"none": true}))); + } + for (c, p) in actions { + if let Err(e) = app.session.execute(&c, p) { + app.ui.status = e.to_string(); + } + } +} + +fn seg_dist(p: Pos2, a: Pos2, b: Pos2) -> f32 { + let ab = b - a; + let l2 = ab.length_sq(); + let t = if l2 > 0.0 { ((p - a).dot(ab) / l2).clamp(0.0, 1.0) } else { 0.0 }; + (p - (a + ab * t)).length() +} + +/// Draft vertices → a mask path. +pub fn draft_path(points: &[[f64; 4]]) -> MaskPath { + MaskPath { + vertices: points.iter().map(|q| MaskVertex { p: Vec2::new(q[0], q[1]), t_in: Vec2::new(-q[2], -q[3]), t_out: Vec2::new(q[2], q[3]) }).collect(), + closed: true, + } +} + +fn pen_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, frame: (u32, u32)) { + let Some(draft) = app.ui.mask_pen.clone() else { return }; + let ph = app.session.playhead(); + let clip = ClipId(draft.clip); + let Some(it) = app.session.active_sequence().and_then(|q| q.find_item(clip)).map(|(_, i)| i.clone()) else { + app.ui.mask_pen = None; + return; + }; + let mt = it.source_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); + let resp = ui.interact(pic, egui::Id::new("mask-pen"), Sense::click_and_drag()); + app.auto.add("program.maskPen", pic, "Pen mask"); + if resp.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + let painter = ui.painter().with_clip_rect(pic); + let to_c = |p: Pos2| to_clip.apply(Vec2::new(p.x as f64, p.y as f64)); + let mut d = draft.clone(); + let mut commit = false; + let pressed = resp.drag_started() || (resp.clicked() && !ui.data(|m| m.get_temp::(egui::Id::new("mask-pen-placed")).unwrap_or(false))); + if pressed && let Some(p) = resp.interact_pointer_pos() { + let near_first = d.points.first().is_some_and(|f| (sp(&to_screen, Vec2::new(f[0], f[1])) - p).length() < 8.0); + if d.points.len() >= 3 && near_first { + commit = true; + } else { + let c = to_c(p); + d.points.push([c.x, c.y, 0.0, 0.0]); + ui.data_mut(|m| m.insert_temp(egui::Id::new("mask-pen-placed"), resp.drag_started())); + } + } + if resp.clicked() || resp.drag_stopped() { + ui.data_mut(|m| m.insert_temp(egui::Id::new("mask-pen-placed"), false)); + } + if resp.dragged() + && !commit + && let (Some(p), Some(last)) = (resp.interact_pointer_pos(), d.points.last_mut()) + { + let c = to_c(p); + let tan = Vec2::new(c.x - last[0], c.y - last[1]); + if tan.length() > 2.0 { + last[2] = tan.x; + last[3] = tan.y; + } + } + if ui.input(|i| i.key_pressed(egui::Key::Enter)) && d.points.len() >= 3 { + commit = true; + } + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + app.ui.mask_pen = None; + return; + } + // preview + let open = MaskPath { closed: false, ..draft_path(&d.points) }; + let pts = open_polyline(&open, &to_screen); + if pts.len() >= 2 { + painter.add(egui::Shape::line(pts, Stroke::new(1.5, col))); + } + if let (Some(h), Some(l)) = (resp.hover_pos(), d.points.last()) { + painter.line_segment([sp(&to_screen, Vec2::new(l[0], l[1])), h], Stroke::new(1.0, col.gamma_multiply(0.5))); + } + for (i, q) in d.points.iter().enumerate() { + let p = sp(&to_screen, Vec2::new(q[0], q[1])); + let r = Rect::from_center_size(p, vec2(7.0, 7.0)); + painter.rect_filled(r, 0.0, if i == 0 { col } else { Color32::WHITE }); + painter.rect_stroke(r, 0.0, Stroke::new(1.0, col), egui::StrokeKind::Middle); + if q[2] != 0.0 || q[3] != 0.0 { + for s in [1.0, -1.0] { + let h = sp(&to_screen, Vec2::new(q[0] + q[2] * s, q[1] + q[3] * s)); + painter.line_segment([p, h], Stroke::new(1.0, col)); + painter.circle_filled(h, 3.0, Color32::WHITE); + } + } + } + if commit { + let path = draft_path(&d.points); + app.ui.mask_pen = None; + let r = app + .session + .execute("masks.add", json!({"clip": draft.clip, "effect": draft.effect, "shape": "bezier", "path": filmcraft_engine::masks::path_to_json(&path)})); + if let Err(e) = r { + app.ui.status = e.to_string(); + } else { + app.ui.status.clear(); + } + } else { + app.ui.mask_pen = Some(d); + } +} + +/// Screen polyline of an open path's segments (preview while drawing). +fn open_polyline(path: &MaskPath, to_screen: &Affine) -> Vec { + let mut out = Vec::new(); + if let Some(v) = path.vertices.first() { + out.push(sp(to_screen, v.p)); + } + for [p0, c1, c2, p3] in path.segments() { + for k in 1..=16 { + out.push(sp(to_screen, filmcraft_project::mask::cubic(p0, c1, c2, p3, k as f64 / 16.0))); + } + } + out +} + +/// The Mask Path row's value area (mask tracking controls arrive with the tracker). +pub fn path_value(app: &mut FilmcraftApp, ui: &mut egui::Ui, clip: ClipId, effect: usize, mask: Option, actions: &mut Vec<(String, Value)>) { + let _ = (app, ui, clip, effect, mask, actions); +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 1e8f0fde..d4686efd 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -11,6 +11,7 @@ pub mod file_dialogs; pub mod graphics; pub mod import_mode; pub mod lumetri; +pub mod masks; pub mod media_dialogs; pub mod meters; pub mod misc; diff --git a/crates/ui-egui/src/panels/monitor.rs b/crates/ui-egui/src/panels/monitor.rs index 69e3a6cf..a7584ffa 100644 --- a/crates/ui-egui/src/panels/monitor.rs +++ b/crates/ui-egui/src/panels/monitor.rs @@ -151,6 +151,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) app.auto.add(&format!("{prefix}.picture"), pic, "picture"); if which == Which::Program && has_video { crate::panels::graphics::monitor_overlay(app, ui, pic, frame_size); + crate::panels::masks::monitor_overlay(app, ui, pic, frame_size); } if pic_resp.double_clicked() && which == Which::Source { // (Premiere opens the clip's settings; we show info) diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 927395ef..48bf90b6 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -310,6 +310,31 @@ pub struct UiState { /// Open colour dialog (Interpret Footage ▸ Color Management, Sequence ▸ Color Management). #[serde(default)] pub color_dialog: Option, + /// Free-draw (pen) mask being placed on the Program monitor (Effect Controls ▸ pen icon). + #[serde(default)] + pub mask_pen: Option, + /// Effect presets: the preset being renamed / the Save Preset dialog (open when Some). + #[serde(default)] + pub save_preset: Option, +} + +/// A pen mask in progress: vertices placed so far, in clip pixels (`[x, y, tangent x, tangent y]`). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct MaskPenDraft { + pub clip: u64, + pub effect: usize, + pub points: Vec<[f64; 4]>, +} + +/// Save Preset dialog (Effect Controls ▸ right-click an effect ▸ Save Preset…). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct SavePresetDraft { + pub clip: u64, + pub effects: Vec, + pub name: String, + pub description: String, + /// "scale" | "anchorIn" | "anchorOut" | "none" + pub keyframes: String, } /// File ▸ Link Media… (shown automatically when a project opens with missing media). @@ -481,6 +506,8 @@ impl Default for UiState { project_manager: None, make_offline: None, color_dialog: None, + mask_pen: None, + save_preset: None, } } } diff --git a/crates/ui-egui/tests/masks_ui.rs b/crates/ui-egui/tests/masks_ui.rs new file mode 100644 index 00000000..4cec6bd4 --- /dev/null +++ b/crates/ui-egui/tests/masks_ui.rs @@ -0,0 +1,196 @@ +//! Effect masks in the UI, driven headless through the control channel: create masks from the +//! Effect Controls mask icons, edit them on the Program monitor (vertex, Bézier handle, feather, +//! whole-mask drags), draw a pen mask, toggle Inverted. +//! +//! With `FILMCRAFT_UI_SHOTS=` the test also renders the UI with wgpu and writes PNGs there. + +use std::sync::mpsc::{Sender, channel}; + +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_ui_egui::FilmcraftApp; +use filmcraft_ui_egui::control::ControlRequest; +use serde_json::{Value, json}; + +struct Driver { + harness: Harness<'static, FilmcraftApp>, + tx: Sender, + shots: Option, +} + +impl Driver { + fn demo() -> Self { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).expect("demo project"); + let (tx, rx) = channel(); + let app = FilmcraftApp::new(session).with_control(rx); + let shots = std::env::var_os("FILMCRAFT_UI_SHOTS").map(std::path::PathBuf::from); + let mut b = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000); + if shots.is_some() { + b = b.wgpu().with_pixels_per_point(1.0); + } + let harness = b.build_eframe(move |_cc| app); + let mut d = Driver { harness, tx, shots }; + d.frames(4); + d + } + fn frames(&mut self, n: usize) { + for _ in 0..n { + let ctx = self.harness.ctx.clone(); + let mut raw = std::mem::take(self.harness.input_mut()); + eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw); + *self.harness.input_mut() = raw; + self.harness.step(); + } + } + fn call(&mut self, method: &str, params: Value) -> Value { + let (req, reply) = ControlRequest::new(method, params.clone()); + self.tx.send(req).unwrap(); + for _ in 0..600 { + self.frames(1); + if let Ok(v) = reply.try_recv() { + return v; + } + } + panic!("no reply to {method} {params}"); + } + fn ok(&mut self, method: &str, params: Value) -> Value { + let r = self.call(method, params.clone()); + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + r["result"].clone() + } + fn exec(&mut self, command: &str, params: Value) -> Value { + self.ok("engine.execute", json!({"command": command, "params": params})) + } + fn rect(&mut self, id: &str) -> [f64; 4] { + let v = self.ok("ui.elements", json!({"prefix": id})); + let e = v.as_array().unwrap().iter().find(|e| e["id"] == id).unwrap_or_else(|| panic!("no element {id}: {v}")).clone(); + let r: Vec = e["rect"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); + [r[0], r[1], r[2], r[3]] + } + fn center(&mut self, id: &str) -> (f64, f64) { + let r = self.rect(id); + (r[0] + r[2] / 2.0, r[1] + r[3] / 2.0) + } + fn drag(&mut self, id: &str, dx: f64, dy: f64) { + let (x, y) = self.center(id); + self.ok("ui.drag", json!({"from": {"x": x, "y": y}, "to": {"x": x + dx, "y": y + dy}, "steps": 6})); + self.frames(2); + } + fn masks(&mut self) -> Vec { + self.exec("masks.list", json!({}))["masks"].as_array().unwrap().clone() + } + fn shot(&mut self, name: &str) { + let Some(dir) = self.shots.clone() else { return }; + // let the background frame workers finish the (blurred) frame + for _ in 0..40 { + self.frames(1); + std::thread::sleep(std::time::Duration::from_millis(25)); + } + let img = self.harness.render().expect("wgpu render"); + std::fs::create_dir_all(&dir).unwrap(); + img.save(dir.join(format!("{name}.png"))).unwrap(); + } +} + +fn v0(m: &Value, i: usize) -> (f64, f64) { + let p = &m["path"]["vertices"][i]["p"]; + (p[0].as_f64().unwrap(), p[1].as_f64().unwrap()) +} + +#[test] +fn create_and_edit_masks_on_the_monitor() { + let mut d = Driver::demo(); + d.ok("ui.set", json!({"workspace": "Effects"})); + let seq = d.exec("sequence.inspect", json!({})); + let clip = &seq["video"][0]["items"][0]; + let (id, start, dur) = (clip["clip"].as_u64().unwrap(), clip["start"].as_i64().unwrap(), clip["duration"].as_i64().unwrap()); + d.exec("playhead.set", json!({"time": start + dur / 2})); + d.exec("timeline.select", json!({"clips": [id]})); + d.exec("effects.apply", json!({"effect": "gaussian_blur"})); + d.exec("effects.setParam", json!({"clip": id, "effect": "gaussian_blur", "param": "blurriness", "value": 30.0})); + d.frames(4); + // ellipse mask from the Effect Controls icon + // fold Motion and Time Remapping so the Gaussian Blur rows are on screen + for fx in ["motion", "time_remap"] { + d.ok("ui.click", json!({"id": format!("effectControls.effect.{fx}")})); + d.frames(2); + } + d.ok("ui.click", json!({"id": "effectControls.gaussian_blur.mask.ellipse"})); + d.frames(3); + let m = d.masks(); + assert_eq!(m.len(), 1, "{m:?}"); + assert_eq!(m[0]["selected"], true); + assert_eq!(m[0]["name"], "Mask (1)"); + d.rect("effectControls.gaussian_blur.mask0"); + d.rect("effectControls.gaussian_blur.mask0.feather.stopwatch"); + d.shot("mask-ellipse-selected"); + + // drag the top vertex up: one undo step for the gesture + let undo0 = d.exec("history.list", json!({}))["undo"].as_array().unwrap().len(); + let before = v0(&m[0], 0); + d.drag("program.mask.vertex.0", 0.0, -30.0); + let m = d.masks(); + let after = v0(&m[0], 0); + assert!(after.1 < before.1 - 20.0, "vertex moved up: {before:?} → {after:?}"); + assert!((after.0 - before.0).abs() < 1e-6); + let undo1 = d.exec("history.list", json!({}))["undo"].as_array().unwrap().len(); + assert_eq!(undo1, undo0 + 1, "one undo step per drag"); + + // Bézier handle of vertex 1 (the right vertex of the ellipse) + let out_before = m[0]["path"]["vertices"][1]["out"].clone(); + d.drag("program.mask.out.1", 0.0, 25.0); + let m = d.masks(); + assert_ne!(m[0]["path"]["vertices"][1]["out"], out_before); + + // feather handle outward (up from the top) grows the feather + let f0 = m[0]["feather"].as_f64().unwrap(); + d.drag("program.mask.feather", 0.0, -20.0); + let m = d.masks(); + let f1 = m[0]["feather"].as_f64().unwrap(); + assert!(f1 > f0 + 10.0, "feather {f0} → {f1}"); + // expansion handle (bottom) outward = down + d.drag("program.mask.expansion", 0.0, 12.0); + let m = d.masks(); + assert!(m[0]["expansion"].as_f64().unwrap() > 5.0, "{}", m[0]["expansion"]); + + // drag inside the mask moves it + let c0 = v0(&m[0], 2); + let body = d.rect("program.mask.body"); + let (bx, by) = (body[0] + body[2] / 2.0, body[1] + body[3] / 2.0); + d.ok("ui.drag", json!({"from": {"x": bx, "y": by}, "to": {"x": bx + 40.0, "y": by}, "steps": 5})); + d.frames(2); + let m = d.masks(); + let c1 = v0(&m[0], 2); + assert!(c1.0 > c0.0 + 20.0, "{c0:?} → {c1:?}"); + d.shot("mask-edited"); + + // Inverted checkbox (fold Opacity so the row is on screen) + d.ok("ui.click", json!({"id": "effectControls.effect.opacity"})); + d.frames(2); + d.ok("ui.click", json!({"id": "effectControls.gaussian_blur.mask0.inverted"})); + d.frames(2); + assert_eq!(d.masks()[0]["inverted"], true); + d.shot("mask-inverted"); + d.ok("ui.click", json!({"id": "effectControls.effect.opacity"})); + d.frames(2); + + // pen mask on Opacity: four clicks, then click the first point to close + d.ok("ui.click", json!({"id": "effectControls.opacity.mask.pen"})); + d.frames(2); + let pic = d.rect("program.picture"); + let pts = [(0.3, 0.3), (0.6, 0.25), (0.65, 0.7), (0.35, 0.75)]; + for (fx, fy) in pts { + d.ok("ui.click", json!({"x": pic[0] + pic[2] * fx, "y": pic[1] + pic[3] * fy})); + d.frames(2); + } + d.shot("mask-pen-drawing"); + d.ok("ui.click", json!({"x": pic[0] + pic[2] * 0.3, "y": pic[1] + pic[3] * 0.3})); + d.frames(3); + let m = d.masks(); + assert_eq!(m.len(), 2, "{m:?}"); + let pen = m.iter().find(|x| x["effectId"] == "opacity").expect("opacity mask"); + assert_eq!(pen["path"]["vertices"].as_array().unwrap().len(), 4); + assert_eq!(pen["selected"], true); + d.shot("mask-pen-opacity"); +} diff --git a/docs/architecture.md b/docs/architecture.md index a421d476..6dc3393d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -125,7 +125,8 @@ TrackItem (a clip instance) ├─ link group, label, enabled └─ effects: Vec intrinsic Motion/Opacity/Volume… first, then standard effects EffectInstance -└─ effect id, enabled, params: id → constant value or keyframe track +├─ effect id, enabled, params: id → constant value or keyframe track +└─ masks: Vec path (keyframable Bézier), feather, opacity, expansion, inverted, mode ``` - **Graphic clips** ([graphics.md](graphics.md)) reference a `Graphic` canvas item; their text and @@ -314,7 +315,30 @@ essentialSound.generateDucking trigger clips' summed level (10 ms hops) → ac DeepFilterNet (MIT/Apache-2.0, Rust inference via tract) is the candidate for a future optional integration behind a trait. -### 5.3 Colour management +### 5.3 Masks + +Every video effect and the intrinsic Opacity carry `EffectInstance::masks` (`project::mask`). +A mask is a closed cubic Bézier `MaskPath` in clip pixels (ellipse = four smooth vertices with +circular tangents, 4-point polygon = corner vertices, the pen draws arbitrary vertices), stored as a +`ParamValue::Path` so the ordinary keyframe engine animates it (vertex-wise interpolation; paths +with different vertex counts hold). Feather, Opacity and Expansion are ordinary float parameters. + +- **Coverage** (`render::mask`): the path is flattened (≤ 0.05 working px chord error); the + signed distance to the polygon (nonzero winding) plus Expansion goes through a falloff of width + max(Feather, 1) centred on the edge (linear = exact box-filtered antialiasing at Feather 0, + blending into smoothstep as Feather grows). Masks combine top to bottom with Add / Subtract / + Intersect / Lighten / Darken / Difference; Inverted and Opacity apply per mask. +- **Semantics.** A masked effect is `lerp(original, effected, coverage)` per premultiplied channel + (the effect only applies inside); Opacity masks scale the clip's layer before Motion, so they + follow the clip's transform. Adjustment-layer masks are in sequence pixels. +- **GPU.** `filmcraft-gpu::GpuMask` evaluates the same coverage and mix in WGSL (compute), tested + to agree with the CPU within 3·10⁻⁶. Layers with masks are CPU-rendered images in frame plans. +- **Editing.** `masks.*` commands (add / remove / set / moveVertex / translate / addVertex / + removeVertex / toggleVertexSmooth / select / list); keyframe commands take `"mask": n`. The + Program monitor overlay drags vertices, Bézier handles, the whole mask and the feather / + expansion handles; drags merge into one undo step. + +### 5.4 Colour management ```text frame (Y'CbCr/RGB + metadata) ─► source colour space: Interpret Footage override, else VUI/colr/MKV Colour diff --git a/docs/project-files.md b/docs/project-files.md index ca9da284..700caacd 100644 --- a/docs/project-files.md +++ b/docs/project-files.md @@ -8,7 +8,7 @@ writing them is `crates/format` (`filmcraft-format`); the engine's `file.*` comm ```json { "format": "filmcraft.project", - "schema_version": 6, + "schema_version": 7, "generator": "FilmCraft 0.1.0", "project": { "name": "…", "settings": { … }, "root": { … }, "items": { … }, "next_id": 48 } } @@ -30,6 +30,7 @@ writing them is `crates/format` (`filmcraft-format`); the engine's `file.*` comm | 4 | M7.4 | Essential Sound: clip audio types and settings; no-op step | | 5 | M8.8 | colour management: working space, Interpret Footage colour space, LUT library; no-op step | | 6 | M11.7/M11.9 | media identity fingerprints, ingest settings; no-op step | +| 7 | M5.5 | effect masks: `masks` on effect instances with keyframable Bézier `Path` values; no-op step | ### Migrations