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