Skip to main content

alpha2_model/
composition.rs

1//! Composition graphs: a clip's picture built from connected nodes.
2//!
3//! A [`Composition`] is an acyclic graph of image operations stored once in the project. A
4//! clip refers to it with a [`ClipComposition`], which also maps the clip's timeline time to
5//! composition time. Every node outputs one image and names the nodes it takes inputs from;
6//! inputs may be left unconnected while a graph is being built.
7//!
8//! A [`NodeOp::Group`] node draws another composition, feeding its [`NodeOp::GroupInput`]
9//! nodes and setting its [`CompositionControl`]s per group node. A
10//! [`crate::MediaKind::Composition`] source shows one on the timeline or in a media node.
11//! Compositions may not use themselves. A composition is inlined wherever it is drawn, so
12//! nesting is bounded by [`MAX_COMPOSITION_DEPTH`] and total drawn nodes by
13//! [`MAX_INLINED_NODES`].
14//!
15//! Saving checks structure: names resolve, nothing depends on itself, values are in range.
16//! Rendering additionally requires every input reachable from the output to be connected.
17//! Nodes that do not reach the output are kept and ignored.
18
19use crate::animation::opaque;
20use crate::{
21    Animated, ClipBlendMode, ClipEffect, CompositionId, EffectValue, Generator, MatteMode, NodeId,
22    ShapeMask, VisualProperties,
23};
24use alpha2_media::SourceId;
25use alpha2_time::MediaTime;
26use serde::{Deserialize, Serialize};
27use std::collections::BTreeMap;
28use std::collections::{HashMap, HashSet};
29
30/// Most compositions in one project.
31pub const MAX_COMPOSITIONS: usize = 10_000;
32/// Most nodes in one composition.
33pub const MAX_COMPOSITION_NODES: usize = 256;
34/// The most named inputs an effect node gives its effect, matching what an effect definition
35/// may declare.
36pub const MAX_EFFECT_INPUTS: usize = 8;
37/// Most layers in one merge node.
38pub const MAX_MERGE_LAYERS: usize = 32;
39/// Most inputs a composition takes as a group.
40pub const MAX_GROUP_INPUTS: usize = 16;
41/// Most controls a composition exposes as a group.
42pub const MAX_COMPOSITION_CONTROLS: usize = 64;
43/// Deepest nesting of compositions. A composition that draws no other has depth one; one
44/// that draws others (via group nodes or composition-source media nodes) is one deeper than
45/// the deepest of them.
46pub const MAX_COMPOSITION_DEPTH: usize = 64;
47/// Most nodes the project's compositions draw in total. Each node counts one, except a group
48/// node or composition-source media node, which counts the nodes of the composition it
49/// draws. This bounds compile cost however uses multiply.
50pub const MAX_INLINED_NODES: usize = 65_536;
51/// Most composition nodes the clips at one instant draw together, each clip counted as
52/// [`MAX_INLINED_NODES`] counts its composition and composition source. This bounds per-frame
53/// work to one composition of the largest allowed size.
54pub const MAX_FRAME_INLINED_NODES: usize = 65_536;
55
56/// An acyclic graph of image operations with one selected output.
57#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
58#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
59#[serde(deny_unknown_fields)]
60pub struct Composition {
61    /// Stable composition identity.
62    pub id: CompositionId,
63    /// The graph's nodes. Order has no meaning; a node may name a later node as its input.
64    #[serde(default)]
65    pub nodes: Vec<CompositionNode>,
66    /// The node whose image is the composition's picture. A composition without one saves but
67    /// cannot be rendered.
68    #[serde(default)]
69    pub output: Option<NodeId>,
70    /// The images a group node gives it, by name, in port order; its [`NodeOp::GroupInput`]
71    /// nodes read them.
72    #[serde(default, skip_serializing_if = "Vec::is_empty")]
73    pub inputs: Vec<String>,
74    /// The node settings a group node may set, by name.
75    #[serde(default, skip_serializing_if = "Vec::is_empty")]
76    pub controls: Vec<CompositionControl>,
77}
78
79/// A node setting a composition exposes to group nodes: an effect or transition parameter of
80/// one of its nodes.
81#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
82#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
83#[serde(deny_unknown_fields)]
84pub struct CompositionControl {
85    /// The name a group node sets it by; unique in the composition.
86    pub name: String,
87    /// The effect or transition node.
88    pub node: NodeId,
89    /// The parameter of its effect.
90    pub param: String,
91}
92
93impl Composition {
94    /// Creates an empty composition with no output.
95    #[must_use]
96    pub const fn new(id: CompositionId) -> Self {
97        Self {
98            id,
99            nodes: Vec::new(),
100            output: None,
101            inputs: Vec::new(),
102            controls: Vec::new(),
103        }
104    }
105
106    /// The node named `id`.
107    #[must_use]
108    pub fn node(&self, id: &NodeId) -> Option<&CompositionNode> {
109        self.nodes.iter().find(|node| &node.id == id)
110    }
111}
112
113/// One image operation in a [`Composition`].
114#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
115#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
116#[serde(deny_unknown_fields)]
117pub struct CompositionNode {
118    /// Identity, unique within its composition.
119    pub id: NodeId,
120    /// Whether the node passes its [`NodeOp::bypass_input`] through unchanged. A node without
121    /// inputs cannot be bypassed.
122    #[serde(default)]
123    pub bypass: bool,
124    /// What the node does, with its inputs and settings.
125    pub op: NodeOp,
126}
127
128impl CompositionNode {
129    /// Creates a node that is not bypassed.
130    #[must_use]
131    pub const fn new(id: NodeId, op: NodeOp) -> Self {
132        Self {
133            id,
134            bypass: false,
135            op,
136        }
137    }
138
139    /// The inputs drawing the node reads, in input order: only its [`NodeOp::bypass_input`] when
140    /// bypassed (none for a merge without layers), every input otherwise.
141    #[must_use]
142    pub fn needed_inputs(&self) -> Vec<NodeInput<'_>> {
143        if self.bypass {
144            return self.op.bypass_input().into_iter().collect();
145        }
146        self.op.inputs()
147    }
148}
149
150/// The operation of a [`CompositionNode`].
151///
152/// Animated settings are keyed in composition time (see [`ClipComposition`]).
153#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
154#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
155#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
156pub enum NodeOp {
157    /// The picture of the clip showing the composition: its source at the time-mapped source
158    /// time, or what its generated source draws.
159    Clip {},
160    /// Another visual source, read at `start` plus the composition time. Adds no sound to the
161    /// mix.
162    Media {
163        /// The source; a video, image, or generated source.
164        source_id: SourceId,
165        /// Source time shown at composition time zero. Non-negative.
166        #[serde(default)]
167        start: MediaTime,
168    },
169    /// A picture the engine draws.
170    Generator {
171        /// What is drawn.
172        generator: Generator,
173    },
174    /// A registered effect: its input plus any named images it samples.
175    Effect {
176        /// The image the effect processes; a bypassed node passes it through.
177        #[serde(default)]
178        input: Option<NodeId>,
179        /// The images the effect samples besides its input, by the names its definition declares
180        /// (`sample_<name>` in its shader); at most [`MAX_EFFECT_INPUTS`]. An undeclared name, or a
181        /// declared one left out, fails the frames that draw the node.
182        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
183        inputs: BTreeMap<String, Option<NodeId>>,
184        /// The versioned effect and its parameters.
185        effect: ClipEffect,
186    },
187    /// A registered two-input transition at an explicit progress.
188    Transition {
189        /// The image at progress zero.
190        #[serde(default)]
191        from: Option<NodeId>,
192        /// The image at progress one.
193        #[serde(default)]
194        to: Option<NodeId>,
195        /// The versioned transition and its parameters.
196        effect: ClipEffect,
197        /// Progress through the transition, in `0..=1`.
198        progress: Animated<f32>,
199    },
200    /// The input placed on the canvas by a transform and opacity.
201    Transform {
202        /// The image placed.
203        #[serde(default)]
204        input: Option<NodeId>,
205        /// Position, anchor, scale, rotation, and opacity.
206        #[serde(default)]
207        transform: VisualProperties,
208    },
209    /// Ordered layers composited over transparency.
210    Merge {
211        /// Layers from bottom to top.
212        #[serde(default)]
213        layers: Vec<MergeLayer>,
214    },
215    /// The input kept where a second image says.
216    Matte {
217        /// The image kept.
218        #[serde(default)]
219        input: Option<NodeId>,
220        /// The image whose alpha or luminance is coverage.
221        #[serde(default)]
222        matte: Option<NodeId>,
223        /// Which property of the matte image is coverage.
224        mode: MatteMode,
225    },
226    /// The input kept inside shapes.
227    ShapeMask {
228        /// The image masked.
229        #[serde(default)]
230        input: Option<NodeId>,
231        /// The shapes, combined in order; at most [`crate::MAX_CLIP_MASKS`]. Geometry is in the
232        /// input image's normalized space.
233        masks: Vec<ShapeMask>,
234    },
235    /// Another composition drawn in this one's time as a group: its output, with group inputs
236    /// fed from `inputs` and controls set from `controls`.
237    Group {
238        /// The composition drawn.
239        composition: CompositionId,
240        /// The image for each of its inputs, in its port order.
241        #[serde(default)]
242        inputs: Vec<Option<NodeId>>,
243        /// Values for its controls, by name; unset controls keep the composition's own value. Keyed
244        /// in composition time.
245        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
246        controls: BTreeMap<String, Animated<EffectValue>>,
247    },
248    /// In a composition used as a group, the image the group node gives its input `index`.
249    GroupInput {
250        /// The input, in the composition's port order.
251        index: u32,
252    },
253}
254
255/// One layer of a [`NodeOp::Merge`].
256#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
257#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
258#[serde(deny_unknown_fields)]
259pub struct MergeLayer {
260    /// The layer's image.
261    #[serde(default)]
262    pub input: Option<NodeId>,
263    /// How the layer blends with the layers under it.
264    #[serde(default)]
265    pub blend_mode: ClipBlendMode,
266    /// The layer's opacity, in `0..=1`.
267    #[serde(default = "opaque")]
268    pub opacity: Animated<f32>,
269}
270
271impl MergeLayer {
272    /// An opaque, normally blended layer of `input`.
273    #[must_use]
274    pub const fn new(input: Option<NodeId>) -> Self {
275        Self {
276            input,
277            blend_mode: ClipBlendMode::Normal,
278            opacity: Animated::Constant { value: 1.0 },
279        }
280    }
281}
282
283/// What each composition's output reads: the group inputs and clip nodes reachable from its
284/// output, following group nodes into their compositions. A group node needs only the ports
285/// its composition reads, so unread ports may be left unconnected.
286///
287/// Built once per project in dependency order, without recursion. A composition that uses
288/// itself (refused by validation) is taken to read every port and draw the clip.
289#[derive(Clone, Debug, Default)]
290pub struct CompositionReads<'a> {
291    read: HashMap<&'a CompositionId, Reads>,
292}
293
294/// What one composition's output reads.
295#[derive(Clone, Debug)]
296struct Reads {
297    /// By group input index.
298    ports: Vec<bool>,
299    /// Whether it draws the picture of the clip showing it.
300    clip: bool,
301}
302
303impl<'a> CompositionReads<'a> {
304    /// What each of `compositions` reads, resolving group nodes by ID.
305    #[must_use]
306    pub fn new(compositions: &HashMap<&'a CompositionId, &'a Composition>) -> Self {
307        let mut reads = Self::default();
308        let mut walking = HashSet::new();
309        let group = |node: &'a CompositionNode| match &node.op {
310            NodeOp::Group { composition, .. } => compositions.get(composition).copied(),
311            _ => None,
312        };
313        for root in compositions.values() {
314            let mut stack = vec![(*root, false)];
315            while let Some((composition, expanded)) = stack.pop() {
316                if reads.read.contains_key(&composition.id) {
317                    continue;
318                }
319                if !expanded {
320                    if !walking.insert(&composition.id) {
321                        continue;
322                    }
323                    stack.push((composition, true));
324                    stack.extend(
325                        composition
326                            .nodes
327                            .iter()
328                            .filter_map(group)
329                            .filter(|inner| {
330                                !reads.read.contains_key(&inner.id) && !walking.contains(&inner.id)
331                            })
332                            .map(|inner| (inner, false)),
333                    );
334                    continue;
335                }
336                walking.remove(&composition.id);
337                let read = reads.walk(composition);
338                reads.read.insert(&composition.id, read);
339            }
340        }
341        reads
342    }
343
344    /// Whether `composition`'s output reads its group input `port`; true for an unknown
345    /// composition.
346    #[must_use]
347    pub fn reads_port(&self, composition: &CompositionId, port: usize) -> bool {
348        self.read
349            .get(composition)
350            .is_none_or(|read| read.ports.get(port).copied().unwrap_or(false))
351    }
352
353    /// Whether `composition`, shown by a clip, draws the clip's picture (its output reaches a
354    /// clip node, directly or in a group). True for an unknown composition.
355    #[must_use]
356    pub fn draws_clip(&self, composition: &CompositionId) -> bool {
357        self.read.get(composition).is_none_or(|read| read.clip)
358    }
359
360    /// The inputs drawing `node` reads, in input order: its [`CompositionNode::needed_inputs`],
361    /// restricted for a group node to the ports its composition reads.
362    #[must_use]
363    pub fn needed_inputs<'n>(&self, node: &'n CompositionNode) -> Vec<NodeInput<'n>> {
364        let mut needed = node.needed_inputs();
365        if let (false, NodeOp::Group { composition, .. }) = (node.bypass, &node.op) {
366            needed.retain(|input| match input {
367                NodeInput::Port(port, _) => self.reads_port(composition, *port),
368                _ => true,
369            });
370        }
371        needed
372    }
373
374    /// What `composition`'s output reaches, given that its group nodes' compositions are known.
375    fn walk(&self, composition: &'a Composition) -> Reads {
376        let nodes: HashMap<&NodeId, &CompositionNode> = composition
377            .nodes
378            .iter()
379            .map(|node| (&node.id, node))
380            .collect();
381        let mut read = Reads {
382            ports: vec![false; composition.inputs.len()],
383            clip: false,
384        };
385        let mut reached = HashSet::new();
386        let mut pending: Vec<&NodeId> = composition.output.iter().collect();
387        while let Some(id) = pending.pop() {
388            let Some(node) = nodes.get(id).filter(|_| reached.insert(id)) else {
389                continue;
390            };
391            match (node.bypass, &node.op) {
392                (false, NodeOp::GroupInput { index }) => {
393                    if let Some(port) = usize::try_from(*index)
394                        .ok()
395                        .and_then(|index| read.ports.get_mut(index))
396                    {
397                        *port = true;
398                    }
399                }
400                (false, NodeOp::Clip {}) => read.clip = true,
401                (false, NodeOp::Group { composition, .. }) => {
402                    read.clip |= self.draws_clip(composition);
403                }
404                _ => {}
405            }
406            pending.extend(
407                self.needed_inputs(node)
408                    .into_iter()
409                    .filter_map(NodeInput::node),
410            );
411        }
412        read
413    }
414}
415
416/// One named input of a node, as a node's inputs are listed.
417#[derive(Clone, Copy, Debug, Eq, PartialEq)]
418pub enum NodeInput<'a> {
419    /// A named input: `input`, `from`, `to`, or `matte`.
420    Named(&'static str, Option<&'a NodeId>),
421    /// The input of a merge layer, by layer index.
422    Layer(usize, Option<&'a NodeId>),
423    /// An input of a group node, by port index.
424    Port(usize, Option<&'a NodeId>),
425    /// A named input of an effect node.
426    EffectInput(&'a str, Option<&'a NodeId>),
427}
428
429impl<'a> NodeInput<'a> {
430    /// The connected node, if any.
431    #[must_use]
432    pub const fn node(self) -> Option<&'a NodeId> {
433        match self {
434            Self::Named(_, node)
435            | Self::Layer(_, node)
436            | Self::Port(_, node)
437            | Self::EffectInput(_, node) => node,
438        }
439    }
440
441    /// The input's name as a diagnostic shows it: `input`, `from`, `to`, `matte`, `layers[i]`,
442    /// `inputs[i]`, or `inputs.<name>`.
443    #[must_use]
444    pub fn name(self) -> String {
445        match self {
446            Self::Named(name, _) => name.to_owned(),
447            Self::Layer(index, _) => format!("layers[{index}]"),
448            Self::Port(index, _) => format!("inputs[{index}]"),
449            Self::EffectInput(name, _) => format!("inputs.{name}"),
450        }
451    }
452}
453
454impl NodeOp {
455    /// Every input of the operation, connected or not, in a fixed order.
456    #[must_use]
457    pub fn inputs(&self) -> Vec<NodeInput<'_>> {
458        match self {
459            Self::Clip {}
460            | Self::Media { .. }
461            | Self::Generator { .. }
462            | Self::GroupInput { .. } => Vec::new(),
463            Self::Group { inputs, .. } => inputs
464                .iter()
465                .enumerate()
466                .map(|(index, input)| NodeInput::Port(index, input.as_ref()))
467                .collect(),
468            Self::Effect { input, inputs, .. } => {
469                std::iter::once(NodeInput::Named("input", input.as_ref()))
470                    .chain(
471                        inputs
472                            .iter()
473                            .map(|(name, node)| NodeInput::EffectInput(name, node.as_ref())),
474                    )
475                    .collect()
476            }
477            Self::Transform { input, .. } | Self::ShapeMask { input, .. } => {
478                vec![NodeInput::Named("input", input.as_ref())]
479            }
480            Self::Transition { from, to, .. } => vec![
481                NodeInput::Named("from", from.as_ref()),
482                NodeInput::Named("to", to.as_ref()),
483            ],
484            Self::Matte { input, matte, .. } => vec![
485                NodeInput::Named("input", input.as_ref()),
486                NodeInput::Named("matte", matte.as_ref()),
487            ],
488            Self::Merge { layers } => layers
489                .iter()
490                .enumerate()
491                .map(|(index, layer)| NodeInput::Layer(index, layer.input.as_ref()))
492                .collect(),
493        }
494    }
495
496    /// The input a bypassed node passes through: `input` for one-input operations and mattes,
497    /// `from` for a transition, a merge's bottom layer, and a group's first input. `None` for
498    /// operations without inputs and merges without layers.
499    #[must_use]
500    pub fn bypass_input(&self) -> Option<NodeInput<'_>> {
501        match self {
502            Self::Clip {}
503            | Self::Media { .. }
504            | Self::Generator { .. }
505            | Self::GroupInput { .. } => None,
506            Self::Merge { layers } => layers
507                .first()
508                .map(|layer| NodeInput::Layer(0, layer.input.as_ref())),
509            _ => self.inputs().into_iter().next(),
510        }
511    }
512
513    /// Whether the operation takes inputs, and so can be bypassed.
514    #[must_use]
515    pub const fn has_inputs(&self) -> bool {
516        match self {
517            Self::Clip {}
518            | Self::Media { .. }
519            | Self::Generator { .. }
520            | Self::GroupInput { .. } => false,
521            Self::Group { inputs, .. } => !inputs.is_empty(),
522            _ => true,
523        }
524    }
525
526    /// The effect or transition invocation the operation runs, if any.
527    #[must_use]
528    pub const fn effect(&self) -> Option<&ClipEffect> {
529        match self {
530            Self::Effect { effect, .. } | Self::Transition { effect, .. } => Some(effect),
531            _ => None,
532        }
533    }
534}
535
536/// A clip's use of a [`Composition`], and how its time maps onto it.
537///
538/// Composition time is `offset + (t - clip start) * scale` for timeline time `t`, rounded
539/// down to a tick. Moving a clip keeps its offset so the animation moves with it; trimming
540/// its start by `d` adds `d * scale` to the offset so the rest stays in place. A negative
541/// offset holds settings at their first keyframe and media nodes at their first frame.
542#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
543#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
544#[serde(deny_unknown_fields)]
545pub struct ClipComposition {
546    /// The composition shown.
547    pub id: CompositionId,
548    /// Composition time at the clip's start.
549    #[serde(default)]
550    pub offset: MediaTime,
551    /// Composition time per timeline time.
552    #[serde(default)]
553    pub scale: TimeScale,
554}
555
556impl ClipComposition {
557    /// Shows `id` from its time zero at the timeline's rate.
558    #[must_use]
559    pub const fn new(id: CompositionId) -> Self {
560        Self {
561            id,
562            offset: MediaTime::ZERO,
563            scale: TimeScale::ONE,
564        }
565    }
566
567    /// The composition time at timeline time `time` of a clip starting at `clip_start`. `None`
568    /// when unrepresentable or the scale has a zero denominator.
569    #[must_use]
570    pub fn local_time(&self, clip_start: MediaTime, time: MediaTime) -> Option<MediaTime> {
571        let elapsed = i128::from(time.ticks()) - i128::from(clip_start.ticks());
572        let scaled = (elapsed * i128::from(self.scale.num))
573            .checked_div_euclid(i128::from(self.scale.den))?;
574        let local = i128::from(self.offset.ticks()).checked_add(scaled)?;
575        i64::try_from(local).ok().map(MediaTime)
576    }
577}
578
579/// A positive rational rate of one time against another.
580#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)]
581#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
582#[serde(deny_unknown_fields)]
583pub struct TimeScale {
584    /// Numerator; positive.
585    pub num: u32,
586    /// Denominator; positive.
587    pub den: u32,
588}
589
590impl TimeScale {
591    /// The same rate.
592    pub const ONE: Self = Self { num: 1, den: 1 };
593
594    /// Whether both terms are positive.
595    #[must_use]
596    pub const fn is_valid(self) -> bool {
597        self.num > 0 && self.den > 0
598    }
599}
600
601impl Default for TimeScale {
602    fn default() -> Self {
603        Self::ONE
604    }
605}
606
607/// Checks every composition's identity, node settings and connections, and returns the
608/// nodes each draws once inlined. `by_source` maps source IDs for media node lookups.
609pub(crate) fn validate_compositions<'a>(
610    compositions: &'a [Composition],
611    sources: &'a [crate::MediaSource],
612    by_source: &std::collections::HashMap<&SourceId, &crate::MediaSource>,
613    effect_resources: &std::collections::HashSet<&crate::EffectResourceId>,
614    canvas: (u32, u32),
615) -> Result<InlinedNodes<'a>, crate::ModelError> {
616    use crate::ModelError;
617    use std::collections::{HashMap, HashSet};
618
619    if compositions.len() > MAX_COMPOSITIONS {
620        return Err(ModelError::TooManyCompositions {
621            found: compositions.len(),
622            maximum: MAX_COMPOSITIONS,
623        });
624    }
625    let mut ids = HashSet::with_capacity(compositions.len());
626    for composition in compositions {
627        if !ids.insert(&composition.id) {
628            return Err(ModelError::DuplicateCompositionId(composition.id.clone()));
629        }
630    }
631    let by_id: HashMap<&CompositionId, &Composition> = compositions
632        .iter()
633        .map(|composition| (&composition.id, composition))
634        .collect();
635    if let Some(source) = sources.iter().find(|source| {
636        source
637            .composition
638            .as_ref()
639            .is_some_and(|id| !by_id.contains_key(id))
640    }) {
641        return Err(ModelError::InvalidCompositionSource {
642            source: source.id.clone(),
643        });
644    }
645    for composition in compositions {
646        if composition.nodes.len() > MAX_COMPOSITION_NODES {
647            return Err(ModelError::TooManyCompositionNodes {
648                composition: composition.id.clone(),
649                found: composition.nodes.len(),
650                maximum: MAX_COMPOSITION_NODES,
651            });
652        }
653        let mut positions = HashMap::with_capacity(composition.nodes.len());
654        for (position, node) in composition.nodes.iter().enumerate() {
655            if node.id.as_str().contains('/') {
656                return Err(ModelError::InvalidNodeId {
657                    composition: composition.id.clone(),
658                    node: node.id.clone(),
659                });
660            }
661            if positions.insert(&node.id, position).is_some() {
662                return Err(ModelError::DuplicateNodeId {
663                    composition: composition.id.clone(),
664                    node: node.id.clone(),
665                });
666            }
667        }
668        validate_ports(composition, &positions)?;
669        for node in &composition.nodes {
670            validate_node(
671                composition,
672                node,
673                by_source,
674                &by_id,
675                effect_resources,
676                canvas,
677            )?;
678            for input in node.op.inputs() {
679                if let Some(target) = input.node()
680                    && !positions.contains_key(target)
681                {
682                    return Err(ModelError::UnknownNodeInput {
683                        composition: composition.id.clone(),
684                        node: node.id.clone(),
685                        input: input.name(),
686                        target: target.clone(),
687                    });
688                }
689            }
690        }
691        if let Some(output) = &composition.output
692            && !positions.contains_key(output)
693        {
694            return Err(ModelError::UnknownCompositionOutput {
695                composition: composition.id.clone(),
696                node: output.clone(),
697            });
698        }
699        if let Some(node) = cycle_member(composition, &positions) {
700            return Err(ModelError::CompositionCycle {
701                composition: composition.id.clone(),
702                node: node.clone(),
703            });
704        }
705    }
706    if let Some(composition) = reference_cycle_member(compositions, &by_id, by_source) {
707        return Err(ModelError::CompositionUsesItself {
708            composition: composition.clone(),
709        });
710    }
711    validate_expansion(compositions, &by_id, sources)
712}
713
714/// The nodes each composition draws once inlined, as [`MAX_INLINED_NODES`] counts them.
715pub(crate) type InlinedNodes<'a> = std::collections::HashMap<&'a CompositionId, u64>;
716
717/// Checks that compositions nest at most [`MAX_COMPOSITION_DEPTH`] deep and draw at most
718/// [`MAX_INLINED_NODES`] nodes, and returns the nodes each draws. One iterative walk in
719/// dependency order counts each composition once without risking stack exhaustion.
720fn validate_expansion<'a>(
721    compositions: &'a [Composition],
722    by_id: &std::collections::HashMap<&'a CompositionId, &'a Composition>,
723    sources: &'a [crate::MediaSource],
724) -> Result<InlinedNodes<'a>, crate::ModelError> {
725    use std::collections::HashMap;
726    let composition_sources: HashMap<&SourceId, &CompositionId> = sources
727        .iter()
728        .filter_map(|source| Some((&source.id, source.composition.as_ref()?)))
729        .collect();
730    // The composition a node draws in its place, if any.
731    let draws = |node: &'a CompositionNode| -> Option<&'a CompositionId> {
732        match &node.op {
733            NodeOp::Group { composition, .. } => Some(composition),
734            NodeOp::Media { source_id, .. } => composition_sources.get(source_id).copied(),
735            _ => None,
736        }
737        .filter(|id| by_id.contains_key(id))
738    };
739    // Depth and inlined node count of each composition walked.
740    let mut walked: HashMap<&CompositionId, (usize, u64)> = HashMap::new();
741    let mut total = 0_u64;
742    for root in compositions {
743        let mut stack = vec![(root, false)];
744        while let Some((composition, expanded)) = stack.pop() {
745            if walked.contains_key(&composition.id) {
746                continue;
747            }
748            if !expanded {
749                stack.push((composition, true));
750                stack.extend(
751                    composition
752                        .nodes
753                        .iter()
754                        .filter_map(draws)
755                        .filter(|id| !walked.contains_key(id))
756                        .map(|id| (by_id[id], false)),
757                );
758                continue;
759            }
760            let mut depth = 1;
761            let mut nodes = 0_u64;
762            for node in &composition.nodes {
763                let (inner, count) = draws(node).map_or((0, 1), |id| walked[id]);
764                depth = depth.max(inner + 1);
765                nodes = nodes.saturating_add(count);
766            }
767            walked.insert(&composition.id, (depth, nodes));
768        }
769        let (depth, nodes) = walked[&root.id];
770        if depth > MAX_COMPOSITION_DEPTH {
771            return Err(crate::ModelError::CompositionNestedTooDeep {
772                composition: root.id.clone(),
773                maximum: MAX_COMPOSITION_DEPTH,
774            });
775        }
776        total = total.saturating_add(nodes);
777        if total > MAX_INLINED_NODES as u64 {
778            return Err(crate::ModelError::TooManyInlinedNodes {
779                maximum: MAX_INLINED_NODES,
780            });
781        }
782    }
783    Ok(walked
784        .into_iter()
785        .map(|(id, (_, nodes))| (id, nodes))
786        .collect())
787}
788
789/// Checks that a composition's group inputs are named, distinct and few enough, and its
790/// controls name distinct settings of its effect and transition nodes.
791fn validate_ports(
792    composition: &Composition,
793    positions: &std::collections::HashMap<&NodeId, usize>,
794) -> Result<(), crate::ModelError> {
795    use std::collections::HashSet;
796    let invalid = || crate::ModelError::InvalidCompositionPorts {
797        composition: composition.id.clone(),
798    };
799    let mut names = HashSet::new();
800    if composition.inputs.len() > MAX_GROUP_INPUTS
801        || composition
802            .inputs
803            .iter()
804            .any(|name| name.is_empty() || !names.insert(name))
805    {
806        return Err(invalid());
807    }
808    let mut controls = HashSet::new();
809    let mut settings = HashSet::new();
810    if composition.controls.len() > MAX_COMPOSITION_CONTROLS {
811        return Err(invalid());
812    }
813    for control in &composition.controls {
814        let node = positions
815            .get(&control.node)
816            .map(|position| &composition.nodes[*position]);
817        if control.name.is_empty()
818            || control.param.is_empty()
819            || !controls.insert(&control.name)
820            || !settings.insert((&control.node, &control.param))
821            || node.is_none_or(|node| node.op.effect().is_none())
822        {
823            return Err(invalid());
824        }
825    }
826    Ok(())
827}
828
829/// A composition that uses itself through group nodes or composition sources: the first, in
830/// document order, that a depth-first walk finds on its own path.
831fn reference_cycle_member<'a>(
832    compositions: &'a [Composition],
833    by_id: &std::collections::HashMap<&'a CompositionId, &'a Composition>,
834    sources: &std::collections::HashMap<&SourceId, &'a crate::MediaSource>,
835) -> Option<&'a CompositionId> {
836    #[derive(Clone, Copy, PartialEq)]
837    enum Mark {
838        New,
839        Open,
840        Done,
841    }
842    let uses = |composition: &'a Composition| -> Vec<&'a CompositionId> {
843        composition
844            .nodes
845            .iter()
846            .filter_map(|node| match &node.op {
847                NodeOp::Group { composition, .. } => Some(composition),
848                NodeOp::Media { source_id, .. } => sources
849                    .get(source_id)
850                    .and_then(|source| source.composition.as_ref()),
851                _ => None,
852            })
853            .filter(|id| by_id.contains_key(id))
854            .collect()
855    };
856    let mut marks: std::collections::HashMap<&CompositionId, Mark> = compositions
857        .iter()
858        .map(|composition| (&composition.id, Mark::New))
859        .collect();
860    for root in compositions {
861        if marks[&root.id] != Mark::New {
862            continue;
863        }
864        let mut stack = vec![(&root.id, uses(root), 0_usize)];
865        marks.insert(&root.id, Mark::Open);
866        while let Some((current, next, at)) = stack.last_mut() {
867            let Some(target) = next.get(*at).copied() else {
868                marks.insert(current, Mark::Done);
869                stack.pop();
870                continue;
871            };
872            *at += 1;
873            match marks[target] {
874                Mark::Open => return Some(target),
875                Mark::New => {
876                    marks.insert(target, Mark::Open);
877                    stack.push((target, uses(by_id[target]), 0));
878                }
879                Mark::Done => {}
880            }
881        }
882    }
883    None
884}
885
886fn shape_error(
887    composition: &Composition,
888    node: &CompositionNode,
889    issue: crate::ShapeIssue,
890) -> crate::ModelError {
891    crate::ModelError::InvalidShape {
892        owner: crate::ShapeOwner::Node {
893            composition: composition.id.clone(),
894            node: node.id.clone(),
895        },
896        at: issue.at,
897        problem: issue.problem,
898    }
899}
900
901fn layout_error(
902    composition: &Composition,
903    node: &CompositionNode,
904    issue: crate::LayoutIssue,
905) -> crate::ModelError {
906    crate::ModelError::InvalidLayout {
907        owner: crate::ShapeOwner::Node {
908            composition: composition.id.clone(),
909            node: node.id.clone(),
910        },
911        at: issue.at,
912        problem: issue.problem,
913    }
914}
915
916fn validate_node(
917    composition: &Composition,
918    node: &CompositionNode,
919    sources: &std::collections::HashMap<&SourceId, &crate::MediaSource>,
920    compositions: &std::collections::HashMap<&CompositionId, &Composition>,
921    effect_resources: &std::collections::HashSet<&crate::EffectResourceId>,
922    canvas: (u32, u32),
923) -> Result<(), crate::ModelError> {
924    use crate::ModelError;
925
926    let invalid = || ModelError::InvalidNode {
927        composition: composition.id.clone(),
928        node: node.id.clone(),
929    };
930    let unit = |animation: &Animated<f32>| {
931        crate::animation::valid_scalar_values(animation, |value| (0.0..=1.0).contains(&value))
932    };
933    let shape_error = |issue| shape_error(composition, node, issue);
934    if node.bypass && !node.op.has_inputs() {
935        return Err(invalid());
936    }
937    let valid = match &node.op {
938        NodeOp::Clip {} | NodeOp::Matte { .. } => true,
939        NodeOp::Media { source_id, start } => {
940            let visual = sources.get(source_id).is_some_and(|source| {
941                matches!(
942                    source.kind,
943                    crate::MediaKind::Video
944                        | crate::MediaKind::Image
945                        | crate::MediaKind::Generated
946                        | crate::MediaKind::Composition
947                )
948            });
949            if !visual {
950                return Err(ModelError::InvalidNodeSource {
951                    composition: composition.id.clone(),
952                    node: node.id.clone(),
953                    source: source_id.clone(),
954                });
955            }
956            start.ticks() >= 0
957        }
958        NodeOp::Generator { generator } => match generator.validate_on(canvas.0, canvas.1) {
959            Ok(()) => true,
960            Err(crate::GeneratorIssue::Shape(issue)) => return Err(shape_error(issue)),
961            Err(crate::GeneratorIssue::Layout(issue)) => {
962                return Err(layout_error(composition, node, issue));
963            }
964            Err(crate::GeneratorIssue::Invalid) => false,
965        },
966        NodeOp::Effect { effect, inputs, .. } => {
967            effect.is_valid()
968                && inputs.len() <= MAX_EFFECT_INPUTS
969                && inputs
970                    .keys()
971                    .all(|name| crate::effect::valid_binding_name(name))
972        }
973        NodeOp::Transition {
974            effect, progress, ..
975        } => effect.is_valid() && unit(progress),
976        NodeOp::Transform { transform, .. } => transform.validate(),
977        NodeOp::Merge { layers } => {
978            layers.len() <= MAX_MERGE_LAYERS && layers.iter().all(|layer| unit(&layer.opacity))
979        }
980        NodeOp::ShapeMask { masks, .. } => {
981            crate::mask::validate_masks(masks).map_err(shape_error)?;
982            masks.len() <= crate::MAX_CLIP_MASKS
983        }
984        NodeOp::Group {
985            composition: target,
986            inputs,
987            controls,
988        } => {
989            let Some(target) = compositions.get(target) else {
990                return Err(ModelError::UnknownGroupComposition {
991                    composition: composition.id.clone(),
992                    node: node.id.clone(),
993                    target: target.clone(),
994                });
995            };
996            inputs.len() == target.inputs.len()
997                && controls.iter().all(|(name, value)| {
998                    target.controls.iter().any(|control| &control.name == name)
999                        && crate::effect::valid_param(value)
1000                })
1001        }
1002        NodeOp::GroupInput { index } => {
1003            usize::try_from(*index).is_ok_and(|index| index < composition.inputs.len())
1004        }
1005    };
1006    if !valid {
1007        return Err(invalid());
1008    }
1009    if let Some(effect) = node.op.effect()
1010        && let Some(resource) = effect
1011            .resources
1012            .values()
1013            .find(|resource| !effect_resources.contains(resource))
1014    {
1015        return Err(ModelError::UnknownNodeResource {
1016            composition: composition.id.clone(),
1017            node: node.id.clone(),
1018            resource: resource.clone(),
1019        });
1020    }
1021    Ok(())
1022}
1023
1024/// A node on a cycle, if any: the first, in document order, that a depth-first walk finds
1025/// on its own path.
1026fn cycle_member<'a>(
1027    composition: &'a Composition,
1028    positions: &std::collections::HashMap<&'a NodeId, usize>,
1029) -> Option<&'a NodeId> {
1030    #[derive(Clone, Copy, PartialEq)]
1031    enum Mark {
1032        New,
1033        Open,
1034        Done,
1035    }
1036    let nodes = &composition.nodes;
1037    let mut marks = vec![Mark::New; nodes.len()];
1038    for root in 0..nodes.len() {
1039        if marks[root] != Mark::New {
1040            continue;
1041        }
1042        // Each entry is a node and how many of its inputs have been walked.
1043        let mut stack = vec![(root, 0_usize)];
1044        marks[root] = Mark::Open;
1045        while let Some((current, next)) = stack.last_mut() {
1046            let inputs = nodes[*current].op.inputs();
1047            let Some(input) = inputs.get(*next) else {
1048                marks[*current] = Mark::Done;
1049                stack.pop();
1050                continue;
1051            };
1052            *next += 1;
1053            let Some(target) = input.node().and_then(|id| positions.get(id)) else {
1054                continue;
1055            };
1056            match marks[*target] {
1057                Mark::Open => return Some(&nodes[*target].id),
1058                Mark::New => {
1059                    marks[*target] = Mark::Open;
1060                    stack.push((*target, 0));
1061                }
1062                Mark::Done => {}
1063            }
1064        }
1065    }
1066    None
1067}
1068
1069#[cfg(test)]
1070#[path = "composition_tests.rs"]
1071mod tests;