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`.
5
6use std::fmt::Write as _;
7
8use alpha2_gpu::CapabilityTier;
9
10use crate::{EffectDef, EffectInputs, EffectKind, ParamSpec, ParamType, ParamUnit, ParamValue};
11
12/// Internal identifier of the shape-mask pass.
13pub const SHAPE_MASK_EFFECT_ID: &str = "alpha2.internal.shape-mask";
14
15/// Internal identifier of the track-matte pass.
16pub const TRACK_MATTE_EFFECT_ID: &str = "alpha2.internal.track-matte";
17
18/// Masks one shape-mask pass evaluates. The project model allows as many.
19pub const MAX_SHAPE_MASKS: usize = 8;
20
21/// Enum values of a mask's `mode` parameter.
22pub const MASK_MODES: [&str; 3] = ["add", "subtract", "intersect"];
23
24/// The parameter names of one mask of the shape-mask pass, as
25/// [`shape_mask_definition`] declares them.
26#[derive(Copy, Clone, Debug, Eq, PartialEq)]
27pub struct ShapeMaskParams {
28    /// `m{i}_center`.
29    pub center: &'static str,
30    /// `m{i}_size`.
31    pub size: &'static str,
32    /// `m{i}_rotation`.
33    pub rotation: &'static str,
34    /// `m{i}_radius`.
35    pub radius: &'static str,
36    /// `m{i}_feather`.
37    pub feather: &'static str,
38    /// `m{i}_ellipse`.
39    pub ellipse: &'static str,
40    /// `m{i}_invert`.
41    pub invert: &'static str,
42    /// `m{i}_mode`.
43    pub mode: &'static str,
44}
45
46macro_rules! shape_mask_params {
47    ($($index:literal),*) => {
48        [$(ShapeMaskParams {
49            center: concat!("m", $index, "_center"),
50            size: concat!("m", $index, "_size"),
51            rotation: concat!("m", $index, "_rotation"),
52            radius: concat!("m", $index, "_radius"),
53            feather: concat!("m", $index, "_feather"),
54            ellipse: concat!("m", $index, "_ellipse"),
55            invert: concat!("m", $index, "_invert"),
56            mode: concat!("m", $index, "_mode"),
57        }),*]
58    };
59}
60
61/// Each mask's parameter names, by mask index: static, so a caller sets a
62/// frame's masks without formatting a name.
63pub const SHAPE_MASK_PARAMS: [ShapeMaskParams; MAX_SHAPE_MASKS] =
64    shape_mask_params!(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 shape-mask pass: up to [`MAX_SHAPE_MASKS`] rectangles (with rounded
88/// corners) or ellipses, each with a feathered edge, combined in order.
89///
90/// Per mask `i` the parameters are `m{i}_center`, `m{i}_size` (full extent),
91/// `m{i}_rotation` (radians, clockwise), `m{i}_radius` and `m{i}_feather` (as
92/// fractions of the shorter side of the pass), `m{i}_ellipse`,
93/// `m{i}_invert`, and `m{i}_mode`; `count` says how many are in use.
94#[must_use]
95pub fn shape_mask_definition() -> EffectDef {
96    let mut params = vec![param(
97        "count".into(),
98        ParamType::Float,
99        ParamValue::Float(0.0),
100    )];
101    for names in SHAPE_MASK_PARAMS {
102        params.extend([
103            param(
104                names.center.into(),
105                ParamType::Vec2,
106                ParamValue::Vec2([0.5, 0.5]),
107            ),
108            param(
109                names.size.into(),
110                ParamType::Vec2,
111                ParamValue::Vec2([1.0, 1.0]),
112            ),
113            param(
114                names.rotation.into(),
115                ParamType::Float,
116                ParamValue::Float(0.0),
117            ),
118            param(
119                names.radius.into(),
120                ParamType::Float,
121                ParamValue::Float(0.0),
122            ),
123            param(
124                names.feather.into(),
125                ParamType::Float,
126                ParamValue::Float(0.0),
127            ),
128            param(
129                names.ellipse.into(),
130                ParamType::Bool,
131                ParamValue::Bool(false),
132            ),
133            param(
134                names.invert.into(),
135                ParamType::Bool,
136                ParamValue::Bool(false),
137            ),
138            param(names.mode.into(), ParamType::Enum, ParamValue::Enum(0)),
139        ]);
140    }
141    EffectDef {
142        id: SHAPE_MASK_EFFECT_ID.into(),
143        version: 1,
144        kind: EffectKind::Effect,
145        inputs: EffectInputs::One,
146        params,
147        resources: Vec::new(),
148        wgsl_body: shape_mask_body(),
149        tier_min: CapabilityTier::TierB,
150        license: Some(crate::builtins::ALPHA2_EFFECT_LICENSE.into()),
151    }
152}
153
154/// WGSL shared by the passes that draw analytic shapes: the pixel offset
155/// from a shape's centre in its unrotated frame, and the signed distance from
156/// there to its outline.
157pub(crate) const SHAPE_WGSL: &str = r"// Pixel offset of `uv` from `center`, in the frame of a shape turned
158// `rotation` radians clockwise.
159fn builtin_shape_local(uv: vec2f, center: vec2f, rotation: f32) -> vec2f {
160    let offset = (uv - center) * resolution();
161    let c = cos(rotation);
162    let s = sin(rotation);
163    return vec2f(c * offset.x + s * offset.y, -s * offset.x + c * offset.y);
164}
165
166// Signed distance, in pixels, from `p` (relative to the centre, unrotated)
167// to the outline of a shape of half extent `half`: negative inside.
168fn builtin_shape_distance(p: vec2f, half: vec2f, radius: f32, ellipse: bool) -> f32 {
169    if (ellipse) {
170        // The gradient-normalized approximation, exact on the axes and
171        // within a fraction of a pixel near the outline, where it matters.
172        let h = max(half, vec2f(0.0001));
173        let k0 = length(p / h);
174        let k1 = length(p / (h * h));
175        if (k1 <= 0.0) { return -min(h.x, h.y); }
176        return k0 * (k0 - 1.0) / k1;
177    }
178    let r = min(radius, min(half.x, half.y));
179    let q = abs(p) - half + vec2f(r);
180    return length(max(q, vec2f(0.0))) + min(max(q.x, q.y), 0.0) - r;
181}
182
183// The share of its edge-ramp coverage a shape of full `extent` (pixels) keeps
184// with an antialiased edge `edge` pixels wide: all of it once each side spans
185// the edge, and in proportion to each side below that. The ramp, centred on
186// the outline, alone keeps half coverage at the centre of a shape of no size;
187// with this, a shape shrinking to nothing fades out with its area instead of
188// ending on a half-covered dot. Exactly one from `edge` up.
189fn builtin_shape_extent_weight(extent: vec2f, edge: f32) -> f32 {
190    let w = clamp(extent / max(edge, 0.0001), vec2f(0.0), vec2f(1.0));
191    return w.x * w.y;
192}
193";
194
195fn shape_mask_body() -> String {
196    let mut masks = String::new();
197    for index in 0..MAX_SHAPE_MASKS {
198        let separator = if index + 1 == MAX_SHAPE_MASKS {
199            ""
200        } else {
201            ","
202        };
203        writeln!(
204            masks,
205            "        AlphaMask(params.m{index}_center, params.m{index}_size, \
206             params.m{index}_rotation, params.m{index}_radius, params.m{index}_feather, \
207             params.m{index}_ellipse, params.m{index}_invert, params.m{index}_mode){separator}"
208        )
209        .expect("writing to a String cannot fail");
210    }
211    format!(
212        r"struct AlphaMask {{
213    center: vec2f,
214    size: vec2f,
215    rotation: f32,
216    radius: f32,
217    feather: f32,
218    ellipse: u32,
219    invert: u32,
220    mode: u32,
221}};
222
223{SHAPE_WGSL}
224fn builtin_mask_coverage(uv: vec2f, mask: AlphaMask) -> f32 {{
225    let res = resolution();
226    let unit = min(res.x, res.y);
227    let p = builtin_shape_local(uv, mask.center, mask.rotation);
228    let extent = abs(mask.size) * res;
229    let d = builtin_shape_distance(p, extent * 0.5, max(mask.radius, 0.0) * unit, mask.ellipse != 0u);
230    // A one-pixel edge at least, so a sharp mask is still antialiased.
231    let width = max(max(mask.feather, 0.0) * unit, 1.0);
232    var coverage = clamp(0.5 - d / width, 0.0, 1.0) * builtin_shape_extent_weight(extent, width);
233    if (mask.invert != 0u) {{ coverage = 1.0 - coverage; }}
234    return coverage;
235}}
236
237fn effect(uv: vec2f, src: vec4f) -> vec4f {{
238    var masks = array<AlphaMask, {MAX_SHAPE_MASKS}>(
239{masks}    );
240    let count = u32(clamp(params.count, 0.0, {MAX_SHAPE_MASKS}.0));
241    // Empty when the first mask adds, full otherwise.
242    var coverage = select(1.0, 0.0, masks[0].mode == 0u);
243    for (var index = 0u; index < {MAX_SHAPE_MASKS}u; index++) {{
244        if (index >= count) {{ break; }}
245        let mask = masks[index];
246        let m = builtin_mask_coverage(uv, mask);
247        if (mask.mode == 1u) {{
248            coverage = coverage * (1.0 - m);
249        }} else if (mask.mode == 2u) {{
250            coverage = coverage * m;
251        }} else {{
252            coverage = coverage + m - coverage * m;
253        }}
254    }}
255    if (count == 0u) {{ return src; }}
256    return src * coverage;
257}}
258"
259    )
260}
261
262/// The track-matte pass: the first input kept where the second input's
263/// alpha (or luminance over black: BT.709 weights on linear light, times
264/// alpha) says, optionally inverted.
265///
266/// It is a two-input transition and ignores progress; pass one inside
267/// `(0, 1)` so its body runs.
268#[must_use]
269pub fn track_matte_definition() -> EffectDef {
270    EffectDef {
271        id: TRACK_MATTE_EFFECT_ID.into(),
272        version: 1,
273        kind: EffectKind::Transition,
274        inputs: EffectInputs::Two,
275        params: vec![
276            ParamSpec {
277                unit: ParamUnit::Bool,
278                ..param("luma".into(), ParamType::Bool, ParamValue::Bool(false))
279            },
280            ParamSpec {
281                unit: ParamUnit::Bool,
282                ..param("inverted".into(), ParamType::Bool, ParamValue::Bool(false))
283            },
284        ],
285        resources: Vec::new(),
286        wgsl_body: r"fn builtin_matte_linear(value: f32) -> f32 {
287    if (value <= 0.04045) { return value / 12.92; }
288    return pow((value + 0.055) / 1.055, 2.4);
289}
290
291fn transition(uv: vec2f) -> vec4f {
292    let picture = from_color(uv);
293    let matte = to_color(uv);
294    var coverage = matte.a;
295    if (params.luma != 0u) {
296        let straight = clamp(matte.rgb / max(matte.a, 0.000001), vec3f(0.0), vec3f(1.0));
297        let linear = vec3f(
298            builtin_matte_linear(straight.r),
299            builtin_matte_linear(straight.g),
300            builtin_matte_linear(straight.b),
301        );
302        coverage = dot(linear, vec3f(0.2126, 0.7152, 0.0722)) * matte.a;
303    }
304    if (params.inverted != 0u) { coverage = 1.0 - coverage; }
305    return picture * clamp(coverage, 0.0, 1.0);
306}
307"
308        .into(),
309        tier_min: CapabilityTier::TierB,
310        license: Some(crate::builtins::ALPHA2_EFFECT_LICENSE.into()),
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::{MAX_SHAPE_MASKS, SHAPE_MASK_PARAMS, shape_mask_definition};
317
318    #[test]
319    fn each_masks_static_names_are_the_ones_the_pass_declares() {
320        let declared = shape_mask_definition()
321            .params
322            .into_iter()
323            .map(|spec| spec.name)
324            .collect::<Vec<_>>();
325        let mut expected = vec!["count".to_owned()];
326        for (index, names) in SHAPE_MASK_PARAMS.iter().enumerate() {
327            let fields = [
328                ("center", names.center),
329                ("size", names.size),
330                ("rotation", names.rotation),
331                ("radius", names.radius),
332                ("feather", names.feather),
333                ("ellipse", names.ellipse),
334                ("invert", names.invert),
335                ("mode", names.mode),
336            ];
337            for (field, name) in fields {
338                assert_eq!(name, format!("m{index}_{field}"));
339                expected.push(name.to_owned());
340            }
341        }
342        assert_eq!(declared, expected);
343        assert_eq!(declared.len(), 1 + 8 * MAX_SHAPE_MASKS);
344    }
345}