Skip to main content

alpha2_render/
masking.rs

1//! Internal effects behind clip shape masks and track mattes.
2//!
3//! Neither is visible to projects as an effect: the timeline engine inserts
4//! them from a clip's `masks` and `matte`. A mask's outline is drawn by the
5//! vector pass as coverage and feathered by the Gaussian blur; the passes
6//! here combine those coverages and keep the clip by them.
7
8use std::fmt::Write as _;
9
10use alpha2_gpu::CapabilityTier;
11
12use crate::{EffectDef, EffectKind, InputSpec, ParamSpec, ParamType, ParamUnit, ParamValue};
13
14/// Internal identifier of the track-matte pass.
15pub const TRACK_MATTE_EFFECT_ID: &str = "alpha2.internal.track-matte";
16
17/// Masks one mask-combine pass combines at most. The project model allows as
18/// many, and each is one of its named inputs.
19pub const MAX_SHAPE_MASKS: usize = 8;
20
21const _: () = assert!(MAX_SHAPE_MASKS <= crate::MAX_EFFECT_INPUTS);
22
23/// Enum values of a mask's `mode{i}` parameter.
24pub const MASK_MODES: [&str; 3] = ["add", "subtract", "intersect"];
25
26/// Internal identifiers of the mask-combine passes, by mask count minus one:
27/// the pass for `n` masks is `MASK_COMBINE_EFFECT_IDS[n - 1]`.
28pub const MASK_COMBINE_EFFECT_IDS: [&str; MAX_SHAPE_MASKS] = [
29    "alpha2.internal.mask-combine-1",
30    "alpha2.internal.mask-combine-2",
31    "alpha2.internal.mask-combine-3",
32    "alpha2.internal.mask-combine-4",
33    "alpha2.internal.mask-combine-5",
34    "alpha2.internal.mask-combine-6",
35    "alpha2.internal.mask-combine-7",
36    "alpha2.internal.mask-combine-8",
37];
38
39/// The names of one mask of a mask-combine pass: its coverage input and
40/// its parameters, as [`mask_combine_definition`] declares them.
41#[derive(Copy, Clone, Debug, Eq, PartialEq)]
42pub struct MaskCombineNames {
43    /// The named input holding the mask's coverage in its alpha: `m{i}`.
44    pub input: &'static str,
45    /// `invert{i}`.
46    pub invert: &'static str,
47    /// `mode{i}`.
48    pub mode: &'static str,
49}
50
51macro_rules! mask_combine_names {
52    ($($index:literal),*) => {
53        [$(MaskCombineNames {
54            input: concat!("m", $index),
55            invert: concat!("invert", $index),
56            mode: concat!("mode", $index),
57        }),*]
58    };
59}
60
61/// Each mask's names, by mask index: static, so a caller sets a frame's
62/// masks without formatting a name.
63pub const MASK_COMBINE_NAMES: [MaskCombineNames; MAX_SHAPE_MASKS] =
64    mask_combine_names!(0, 1, 2, 3, 4, 5, 6, 7);
65
66fn param(name: String, param_type: ParamType, default: ParamValue) -> ParamSpec {
67    ParamSpec {
68        name,
69        param_type,
70        default,
71        min: None,
72        max: None,
73        step: None,
74        unit: match param_type {
75            ParamType::Bool => ParamUnit::Bool,
76            ParamType::Enum => ParamUnit::Enum,
77            _ => ParamUnit::Normalized,
78        },
79        enum_values: if param_type == ParamType::Enum {
80            MASK_MODES.map(str::to_owned).to_vec()
81        } else {
82            Vec::new()
83        },
84    }
85}
86
87/// The pass combining `count` masks (1 to [`MAX_SHAPE_MASKS`]) into its
88/// source's coverage. Mask `i` is the named input `m{i}`, whose alpha is
89/// the mask's coverage (drawn by the vector pass, feathered by a blur),
90/// inverted when `invert{i}` is set, and combined with the masks before it
91/// by `mode{i}`: coverage starts empty when the first mask adds and full
92/// otherwise; add is `c + m - c m`, subtract `c (1 - m)`, intersect `c m`.
93/// The source is kept by the result.
94///
95/// # Panics
96/// When `count` is zero or more than [`MAX_SHAPE_MASKS`].
97#[must_use]
98pub fn mask_combine_definition(count: usize) -> EffectDef {
99    assert!(
100        (1..=MAX_SHAPE_MASKS).contains(&count),
101        "a mask-combine pass takes 1 to {MAX_SHAPE_MASKS} masks"
102    );
103    let names = &MASK_COMBINE_NAMES[..count];
104    let mut params = Vec::with_capacity(count * 2);
105    let mut steps = String::new();
106    for names in names {
107        params.push(param(
108            names.invert.into(),
109            ParamType::Bool,
110            ParamValue::Bool(false),
111        ));
112        params.push(param(
113            names.mode.into(),
114            ParamType::Enum,
115            ParamValue::Enum(0),
116        ));
117        writeln!(
118            steps,
119            "    coverage = builtin_mask_step(coverage, sample_{input}(uv).a, params.{invert}, params.{mode});",
120            input = names.input,
121            invert = names.invert,
122            mode = names.mode,
123        )
124        .expect("writing to a String cannot fail");
125    }
126    EffectDef {
127        id: MASK_COMBINE_EFFECT_IDS[count - 1].into(),
128        version: 1,
129        kind: EffectKind::Effect,
130        inputs: names
131            .iter()
132            .map(|names| InputSpec {
133                name: names.input.into(),
134            })
135            .collect(),
136        params,
137        resources: Vec::new(),
138        wgsl_body: format!(
139            r"fn builtin_mask_step(coverage: f32, mask: f32, invert: u32, mode: u32) -> f32 {{
140    var m = clamp(mask, 0.0, 1.0);
141    if (invert != 0u) {{ m = 1.0 - m; }}
142    if (mode == 1u) {{ return coverage * (1.0 - m); }}
143    if (mode == 2u) {{ return coverage * m; }}
144    return coverage + m - coverage * m;
145}}
146
147fn effect(uv: vec2f, src: vec4f) -> vec4f {{
148    // Empty when the first mask adds, full otherwise.
149    var coverage = select(1.0, 0.0, params.mode0 == 0u);
150{steps}    return src * clamp(coverage, 0.0, 1.0);
151}}
152"
153        ),
154        tier_min: CapabilityTier::TierB,
155        license: Some(crate::builtins::ALPHA2_EFFECT_LICENSE.into()),
156    }
157}
158
159/// Every mask-combine pass, for one to [`MAX_SHAPE_MASKS`] masks.
160#[must_use]
161pub fn mask_combine_definitions() -> Vec<EffectDef> {
162    (1..=MAX_SHAPE_MASKS).map(mask_combine_definition).collect()
163}
164
165/// The track-matte pass: the first input kept where the second input's
166/// alpha (or luminance over black: BT.709 weights on linear light, times
167/// alpha) says, optionally inverted.
168///
169/// It is a two-input transition and ignores progress; pass one inside
170/// `(0, 1)` so its body runs.
171#[must_use]
172pub fn track_matte_definition() -> EffectDef {
173    EffectDef {
174        id: TRACK_MATTE_EFFECT_ID.into(),
175        version: 1,
176        kind: EffectKind::Transition,
177        inputs: Vec::new(),
178        params: vec![
179            ParamSpec {
180                unit: ParamUnit::Bool,
181                ..param("luma".into(), ParamType::Bool, ParamValue::Bool(false))
182            },
183            ParamSpec {
184                unit: ParamUnit::Bool,
185                ..param("inverted".into(), ParamType::Bool, ParamValue::Bool(false))
186            },
187        ],
188        resources: Vec::new(),
189        wgsl_body: r"fn builtin_matte_linear(value: f32) -> f32 {
190    if (value <= 0.04045) { return value / 12.92; }
191    return pow((value + 0.055) / 1.055, 2.4);
192}
193
194fn transition(uv: vec2f) -> vec4f {
195    let picture = from_color(uv);
196    let matte = to_color(uv);
197    var coverage = matte.a;
198    if (params.luma != 0u) {
199        let straight = clamp(matte.rgb / max(matte.a, 0.000001), vec3f(0.0), vec3f(1.0));
200        let linear = vec3f(
201            builtin_matte_linear(straight.r),
202            builtin_matte_linear(straight.g),
203            builtin_matte_linear(straight.b),
204        );
205        coverage = dot(linear, vec3f(0.2126, 0.7152, 0.0722)) * matte.a;
206    }
207    if (params.inverted != 0u) { coverage = 1.0 - coverage; }
208    return picture * clamp(coverage, 0.0, 1.0);
209}
210"
211        .into(),
212        tier_min: CapabilityTier::TierB,
213        license: Some(crate::builtins::ALPHA2_EFFECT_LICENSE.into()),
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::{
220        MASK_COMBINE_EFFECT_IDS, MASK_COMBINE_NAMES, MAX_SHAPE_MASKS, mask_combine_definition,
221    };
222
223    #[test]
224    fn each_masks_static_names_are_the_ones_the_pass_declares() {
225        for count in 1..=MAX_SHAPE_MASKS {
226            let definition = mask_combine_definition(count);
227            assert_eq!(definition.id, MASK_COMBINE_EFFECT_IDS[count - 1]);
228            let params = definition
229                .params
230                .iter()
231                .map(|spec| spec.name.clone())
232                .collect::<Vec<_>>();
233            let inputs = definition
234                .inputs
235                .iter()
236                .map(|input| input.name.clone())
237                .collect::<Vec<_>>();
238            let mut expected_params = Vec::new();
239            let mut expected_inputs = Vec::new();
240            for (index, names) in MASK_COMBINE_NAMES[..count].iter().enumerate() {
241                assert_eq!(names.input, format!("m{index}"));
242                assert_eq!(names.invert, format!("invert{index}"));
243                assert_eq!(names.mode, format!("mode{index}"));
244                expected_inputs.push(names.input.to_owned());
245                expected_params.push(names.invert.to_owned());
246                expected_params.push(names.mode.to_owned());
247            }
248            assert_eq!(inputs, expected_inputs);
249            assert_eq!(params, expected_params);
250        }
251    }
252}