M5.5: effect masks (ellipse / 4-point polygon / pen Bezier) with keyframable path, feather, opacity, expansion, inverted, modes; opacity masks; WGSL coverage parity; on-monitor editing

- project:😷 MaskPath (cubic Bezier, clip pixels) as ParamValue::Path (vertex-wise keyframe interpolation), Mask, MaskMode, TrackMethod; schema v7 (no-op migration)
- render:😷 analytic antialiased coverage via signed distance + feather falloff in linear light; masked effect = lerp(original, effected, coverage); opacity masks before Motion; adjustment layers
- gpu::GpuMask: same coverage + mix in WGSL compute (max |cpu-gpu| ~3e-6)
- engine masks.* commands (add/remove/set/moveVertex/translate/addVertex/removeVertex/toggleVertexSmooth/select/list); keyframe commands take "mask"
- UI: Effect Controls mask rows; Program monitor overlay (vertices, handles, feather/expansion handles, body drag, pen tool) with automation ids
- goldens: mask_blur, mask_color, mask_inverted, mask_opacity

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 10:48:42 -04:00
parent ca164bf624
commit fbaf487237
39 changed files with 2781 additions and 72 deletions

View File

@ -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

1
Cargo.lock generated
View File

@ -1531,6 +1531,7 @@ dependencies = [
"filmcraft-frame",
"filmcraft-geom",
"filmcraft-media",
"filmcraft-project",
"filmcraft-render",
"filmcraft-time",
"log",

View File

@ -1672,7 +1672,7 @@ fn build() -> Vec<CommandSpec> {
"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<CommandSpec> {
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<CommandSpec> {
{
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<CommandSpec> {
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<CommandSpec> {
_ => 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<CommandSpec> {
})?;
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<CommandSpec> {
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 ▸ <name>)
for l in Label::ALL {
let _ = l;
@ -1956,7 +1964,7 @@ fn keyframe_op(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
_ => 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<ParamVal
ParamValue::Bool(_) => 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::<serde_json::Map<_, _>>()})).collect::<Vec<_>>(),
"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::<serde_json::Map<_, _>>()})).collect::<Vec<_>>(),
})).collect::<Vec<_>>(),
"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::<Vec<_>>(),
})

View File

@ -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<usize>,
/// The mask being edited on the Program monitor (Effect Controls selection).
#[serde(default)]
pub selected_mask: Option<masks::MaskSel>,
}
/// 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;

499
crates/engine/src/masks.rs Normal file
View File

@ -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<Value>;
/// 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<CommandSpec> {
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<MaskPath> {
if let Ok(p) = serde_json::from_value::<MaskPath>(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<Vec2> {
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::<Vec<_>>(),
})
}
/// Resolve the target clip (and its source size in pixels).
fn target_clip(s: &Session, p: &Value) -> Result<ClipId> {
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<MaskSel>, clip: ClipId) -> Option<usize> {
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<R>(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut Mask, Tick) -> Result<R>) -> Result<R> {
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<R> {
let q = pr.sequence_mut(seq_id).ok_or(EngineError::NoSequence)?;
let (_, it) = q.find_item_mut(clip).ok_or(filmcraft_edit::EditError::NoItem(clip))?;
let mt = it.source_time_at(ph.clamp(it.start, it.end() - Tick(1)));
let 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<Value> {
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::<usize>().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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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::<Vec<_>>(),
"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),
}
}

View File

@ -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");
}

View File

@ -45,7 +45,7 @@ pub type Migration = fn(Value) -> Result<Value, String>;
/// `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<Value, String> {
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<Value, String> {
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]

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@ -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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@ -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.

View File

@ -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.

View File

@ -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 }

View File

@ -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.

174
crates/gpu/src/mask.rs Normal file
View File

@ -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<u8> {
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<Vec<f32>> {
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<Vec<f32>> {
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<f32>, Vec<f32>)> {
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<f32> = 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", &params, 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<Vec<f32>> {
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))
}
}

113
crates/gpu/src/mask.wgsl Normal file
View File

@ -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<uniform> params: Params;
@group(0) @binding(1) var<storage, read> masks: array<MaskInfo>;
@group(0) @binding(2) var<storage, read> pts: array<vec2<f32>>;
@group(0) @binding(3) var<storage, read> original: array<vec4<f32>>;
@group(0) @binding(4) var<storage, read> effected: array<vec4<f32>>;
@group(0) @binding(5) var<storage, read_write> out_px: array<vec4<f32>>;
@group(0) @binding(6) var<storage, read_write> out_cov: array<f32>;
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<f32>, a: vec2<f32>, b: vec2<f32>) -> 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>) -> 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<u32>) {
if (id.x >= params.width || id.y >= params.height) {
return;
}
let p = vec2<f32>(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;
}
}

View File

@ -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<filmcraft_render::mask::FlatMask> {
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}");
}

View File

@ -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));
}
}

View File

@ -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<Vec2>),
}
#[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)
}
}

View File

@ -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<f64> {
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<f64> = ca.iter().zip(&cb).map(|(x, y)| x + (y - x) * e).collect();
ParamValue::from_components(&a.value, &c)
}

View File

@ -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)))))
})
}

437
crates/project/src/mask.rs Normal file
View File

@ -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<MaskVertex>,
#[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<Vec2> {
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<f64> {
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<Self> {
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<Self> {
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<String>, 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<Vec<ParamDef>> = 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);
}
}

View File

@ -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

325
crates/render/src/mask.rs Normal file
View File

@ -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<FlatMask> {
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<Vec<f32>> {
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<Vec<f32>> {
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<f32> {
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]);
}
}
}
}

View File

@ -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 };

View File

@ -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

View File

@ -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)]);
}
}
}

View File

@ -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<usize>,
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<k>.` 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<Value> = 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<f32> = 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<usize>,
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::<f32>(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::<f32>(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}))));
}
}
}

View File

@ -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.<effect>.mask.{ellipse,polygon,pen}`,
//! `effectControls.<effect>.mask<k>` (+ `.mode`, `.inverted`, `.delete`, and the parameter rows
//! `effectControls.<effect>.mask<k>.<param>.*`), `program.mask.vertex.<i>`, `program.mask.in.<i>`,
//! `program.mask.out.<i>`, `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<Affine> {
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<Vec2> {
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<Pos2> = 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<Pos2> = 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<Pos2>| 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::<bool>(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<usize> = 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::<bool>(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<Pos2> {
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<usize>, actions: &mut Vec<(String, Value)>) {
let _ = (app, ui, clip, effect, mask, actions);
}

View File

@ -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;

View File

@ -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)

View File

@ -310,6 +310,31 @@ pub struct UiState {
/// Open colour dialog (Interpret Footage ▸ Color Management, Sequence ▸ Color Management).
#[serde(default)]
pub color_dialog: Option<ColorDialog>,
/// Free-draw (pen) mask being placed on the Program monitor (Effect Controls ▸ pen icon).
#[serde(default)]
pub mask_pen: Option<MaskPenDraft>,
/// Effect presets: the preset being renamed / the Save Preset dialog (open when Some).
#[serde(default)]
pub save_preset: Option<SavePresetDraft>,
}
/// 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<usize>,
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,
}
}
}

View File

@ -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=<dir>` 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<ControlRequest>,
shots: Option<std::path::PathBuf>,
}
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<f64> = 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<Value> {
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");
}

View File

@ -125,7 +125,8 @@ TrackItem (a clip instance)
├─ link group, label, enabled
└─ effects: Vec<EffectInstance> 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<Mask> 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

View File

@ -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