1use 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
37pub const MAX_COMPOSITIONS: usize = 10_000;
39pub const MAX_COMPOSITION_NODES: usize = 256;
41pub const MAX_MERGE_LAYERS: usize = 32;
43pub const MAX_GROUP_INPUTS: usize = 16;
45pub const MAX_COMPOSITION_CONTROLS: usize = 64;
47pub const MAX_COMPOSITION_DEPTH: usize = 64;
52pub const MAX_INLINED_NODES: usize = 65_536;
58pub const MAX_FRAME_INLINED_NODES: usize = 65_536;
66
67#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
69#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
70#[serde(deny_unknown_fields)]
71pub struct Composition {
72 pub id: CompositionId,
74 #[serde(default)]
77 pub nodes: Vec<CompositionNode>,
78 #[serde(default)]
81 pub output: Option<NodeId>,
82 #[serde(default, skip_serializing_if = "Vec::is_empty")]
85 pub inputs: Vec<String>,
86 #[serde(default, skip_serializing_if = "Vec::is_empty")]
88 pub controls: Vec<CompositionControl>,
89}
90
91#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
94#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
95#[serde(deny_unknown_fields)]
96pub struct CompositionControl {
97 pub name: String,
99 pub node: NodeId,
101 pub param: String,
103}
104
105impl Composition {
106 #[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 #[must_use]
120 pub fn node(&self, id: &NodeId) -> Option<&CompositionNode> {
121 self.nodes.iter().find(|node| &node.id == id)
122 }
123}
124
125#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
127#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
128#[serde(deny_unknown_fields)]
129pub struct CompositionNode {
130 pub id: NodeId,
132 #[serde(default)]
136 pub bypass: bool,
137 pub op: NodeOp,
139}
140
141impl CompositionNode {
142 #[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 #[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#[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 Clip {},
177 Media {
180 source_id: SourceId,
182 #[serde(default)]
184 start: MediaTime,
185 },
186 Generator {
188 generator: Generator,
190 },
191 Effect {
193 #[serde(default)]
195 input: Option<NodeId>,
196 effect: ClipEffect,
198 },
199 Transition {
201 #[serde(default)]
203 from: Option<NodeId>,
204 #[serde(default)]
206 to: Option<NodeId>,
207 effect: ClipEffect,
209 progress: Animated<f32>,
211 },
212 Transform {
214 #[serde(default)]
216 input: Option<NodeId>,
217 #[serde(default)]
219 transform: VisualProperties,
220 },
221 Merge {
223 #[serde(default)]
225 layers: Vec<MergeLayer>,
226 },
227 Matte {
229 #[serde(default)]
231 input: Option<NodeId>,
232 #[serde(default)]
234 matte: Option<NodeId>,
235 mode: MatteMode,
237 },
238 ShapeMask {
240 #[serde(default)]
242 input: Option<NodeId>,
243 masks: Vec<ShapeMask>,
246 },
247 Group {
251 composition: CompositionId,
253 #[serde(default)]
255 inputs: Vec<Option<NodeId>>,
256 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
259 controls: BTreeMap<String, Animated<EffectValue>>,
260 },
261 GroupInput {
264 index: u32,
266 },
267}
268
269#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
271#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
272#[serde(deny_unknown_fields)]
273pub struct MergeLayer {
274 #[serde(default)]
276 pub input: Option<NodeId>,
277 #[serde(default)]
279 pub blend_mode: ClipBlendMode,
280 #[serde(default = "opaque")]
282 pub opacity: Animated<f32>,
283}
284
285impl MergeLayer {
286 #[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#[derive(Clone, Debug, Default)]
312pub struct CompositionReads<'a> {
313 read: HashMap<&'a CompositionId, Reads>,
314}
315
316#[derive(Clone, Debug)]
318struct Reads {
319 ports: Vec<bool>,
321 clip: bool,
323}
324
325impl<'a> CompositionReads<'a> {
326 #[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 #[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 #[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 #[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 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
443pub enum NodeInput<'a> {
444 Named(&'static str, Option<&'a NodeId>),
446 Layer(usize, Option<&'a NodeId>),
448 Port(usize, Option<&'a NodeId>),
450}
451
452impl<'a> NodeInput<'a> {
453 #[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 #[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 #[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 #[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 #[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 #[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#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
556#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
557#[serde(deny_unknown_fields)]
558pub struct ClipComposition {
559 pub id: CompositionId,
561 #[serde(default)]
563 pub offset: MediaTime,
564 #[serde(default)]
566 pub scale: TimeScale,
567}
568
569impl ClipComposition {
570 #[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 #[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#[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 pub num: u32,
600 pub den: u32,
602}
603
604impl TimeScale {
605 pub const ONE: Self = Self { num: 1, den: 1 };
607
608 #[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
621pub(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
722pub(crate) type InlinedNodes<'a> = std::collections::HashMap<&'a CompositionId, u64>;
725
726fn 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 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 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
800fn 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
840fn 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
990fn 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 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;