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:
parent
ca164bf624
commit
fbaf487237
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -1531,6 +1531,7 @@ dependencies = [
|
|||
"filmcraft-frame",
|
||||
"filmcraft-geom",
|
||||
"filmcraft-media",
|
||||
"filmcraft-project",
|
||||
"filmcraft-render",
|
||||
"filmcraft-time",
|
||||
"log",
|
||||
|
|
|
|||
|
|
@ -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<_>>(),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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", ¶ms, wgpu::BufferUsages::UNIFORM);
|
||||
let mb = init("mask-infos", &infos, wgpu::BufferUsages::STORAGE);
|
||||
let pb = init("mask-pts", &f32_bytes(&pts), wgpu::BufferUsages::STORAGE);
|
||||
let dummy = [0.0f32; 4];
|
||||
let (o, e) = mix.unwrap_or((&dummy, &dummy));
|
||||
let ob = init("mask-original", &f32_bytes(o), wgpu::BufferUsages::STORAGE);
|
||||
let eb = init("mask-effected", &f32_bytes(e), wgpu::BufferUsages::STORAGE);
|
||||
let px_size = if mix.is_some() { (n * 16) as u64 } else { 16 };
|
||||
let cov_size = (n * 4) as u64;
|
||||
let out = |label: &str, size: u64| {
|
||||
d.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some(label),
|
||||
size,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
};
|
||||
let px_b = out("mask-out-px", px_size);
|
||||
let cov_b = out("mask-out-cov", cov_size);
|
||||
let bg = d.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("mask"),
|
||||
layout: &self.bgl,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry { binding: 0, resource: ub.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 1, resource: mb.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 2, resource: pb.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 3, resource: ob.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 4, resource: eb.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 5, resource: px_b.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 6, resource: cov_b.as_entire_binding() },
|
||||
],
|
||||
});
|
||||
let read = |size: u64| {
|
||||
d.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("mask-read"),
|
||||
size,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
};
|
||||
let rc = read(cov_size);
|
||||
let rp = read(px_size);
|
||||
let mut enc = d.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("mask") });
|
||||
{
|
||||
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("mask"), timestamp_writes: None });
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &bg, &[]);
|
||||
pass.dispatch_workgroups((w as u32).div_ceil(8), (h as u32).div_ceil(8), 1);
|
||||
}
|
||||
enc.copy_buffer_to_buffer(&cov_b, 0, &rc, 0, cov_size);
|
||||
enc.copy_buffer_to_buffer(&px_b, 0, &rp, 0, px_size);
|
||||
self.queue.submit([enc.finish()]);
|
||||
let fetch = |b: &wgpu::Buffer| -> Option<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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)))))
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue