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_EFFECT_INPUTS: usize = 8;
44pub const MAX_MERGE_LAYERS: usize = 32;
46pub const MAX_GROUP_INPUTS: usize = 16;
48pub const MAX_COMPOSITION_CONTROLS: usize = 64;
50pub const MAX_COMPOSITION_DEPTH: usize = 64;
55pub const MAX_INLINED_NODES: usize = 65_536;
61pub const MAX_FRAME_INLINED_NODES: usize = 65_536;
69
70#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
72#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
73#[serde(deny_unknown_fields)]
74pub struct Composition {
75 pub id: CompositionId,
77 #[serde(default)]
80 pub nodes: Vec<CompositionNode>,
81 #[serde(default)]
84 pub output: Option<NodeId>,
85 #[serde(default, skip_serializing_if = "Vec::is_empty")]
88 pub inputs: Vec<String>,
89 #[serde(default, skip_serializing_if = "Vec::is_empty")]
91 pub controls: Vec<CompositionControl>,
92}
93
94#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
97#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
98#[serde(deny_unknown_fields)]
99pub struct CompositionControl {
100 pub name: String,
102 pub node: NodeId,
104 pub param: String,
106}
107
108impl Composition {
109 #[must_use]
111 pub const fn new(id: CompositionId) -> Self {
112 Self {
113 id,
114 nodes: Vec::new(),
115 output: None,
116 inputs: Vec::new(),
117 controls: Vec::new(),
118 }
119 }
120
121 #[must_use]
123 pub fn node(&self, id: &NodeId) -> Option<&CompositionNode> {
124 self.nodes.iter().find(|node| &node.id == id)
125 }
126}
127
128#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
130#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
131#[serde(deny_unknown_fields)]
132pub struct CompositionNode {
133 pub id: NodeId,
135 #[serde(default)]
139 pub bypass: bool,
140 pub op: NodeOp,
142}
143
144impl CompositionNode {
145 #[must_use]
147 pub const fn new(id: NodeId, op: NodeOp) -> Self {
148 Self {
149 id,
150 bypass: false,
151 op,
152 }
153 }
154
155 #[must_use]
160 pub fn needed_inputs(&self) -> Vec<NodeInput<'_>> {
161 if self.bypass {
162 return self.op.bypass_input().into_iter().collect();
163 }
164 self.op.inputs()
165 }
166}
167
168#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
173#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
174#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
175pub enum NodeOp {
176 Clip {},
180 Media {
183 source_id: SourceId,
185 #[serde(default)]
187 start: MediaTime,
188 },
189 Generator {
191 generator: Generator,
193 },
194 Effect {
197 #[serde(default)]
199 input: Option<NodeId>,
200 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
206 inputs: BTreeMap<String, Option<NodeId>>,
207 effect: ClipEffect,
209 },
210 Transition {
212 #[serde(default)]
214 from: Option<NodeId>,
215 #[serde(default)]
217 to: Option<NodeId>,
218 effect: ClipEffect,
220 progress: Animated<f32>,
222 },
223 Transform {
225 #[serde(default)]
227 input: Option<NodeId>,
228 #[serde(default)]
230 transform: VisualProperties,
231 },
232 Merge {
234 #[serde(default)]
236 layers: Vec<MergeLayer>,
237 },
238 Matte {
240 #[serde(default)]
242 input: Option<NodeId>,
243 #[serde(default)]
245 matte: Option<NodeId>,
246 mode: MatteMode,
248 },
249 ShapeMask {
251 #[serde(default)]
253 input: Option<NodeId>,
254 masks: Vec<ShapeMask>,
257 },
258 Group {
262 composition: CompositionId,
264 #[serde(default)]
266 inputs: Vec<Option<NodeId>>,
267 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
270 controls: BTreeMap<String, Animated<EffectValue>>,
271 },
272 GroupInput {
275 index: u32,
277 },
278}
279
280#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
282#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
283#[serde(deny_unknown_fields)]
284pub struct MergeLayer {
285 #[serde(default)]
287 pub input: Option<NodeId>,
288 #[serde(default)]
290 pub blend_mode: ClipBlendMode,
291 #[serde(default = "opaque")]
293 pub opacity: Animated<f32>,
294}
295
296impl MergeLayer {
297 #[must_use]
299 pub const fn new(input: Option<NodeId>) -> Self {
300 Self {
301 input,
302 blend_mode: ClipBlendMode::Normal,
303 opacity: Animated::Constant { value: 1.0 },
304 }
305 }
306}
307
308const fn opaque() -> Animated<f32> {
309 Animated::Constant { value: 1.0 }
310}
311
312#[derive(Clone, Debug, Default)]
323pub struct CompositionReads<'a> {
324 read: HashMap<&'a CompositionId, Reads>,
325}
326
327#[derive(Clone, Debug)]
329struct Reads {
330 ports: Vec<bool>,
332 clip: bool,
334}
335
336impl<'a> CompositionReads<'a> {
337 #[must_use]
339 pub fn new(compositions: &HashMap<&'a CompositionId, &'a Composition>) -> Self {
340 let mut reads = Self::default();
341 let mut walking = HashSet::new();
342 let group = |node: &'a CompositionNode| match &node.op {
343 NodeOp::Group { composition, .. } => compositions.get(composition).copied(),
344 _ => None,
345 };
346 for root in compositions.values() {
347 let mut stack = vec![(*root, false)];
348 while let Some((composition, expanded)) = stack.pop() {
349 if reads.read.contains_key(&composition.id) {
350 continue;
351 }
352 if !expanded {
353 if !walking.insert(&composition.id) {
354 continue;
355 }
356 stack.push((composition, true));
357 stack.extend(
358 composition
359 .nodes
360 .iter()
361 .filter_map(group)
362 .filter(|inner| {
363 !reads.read.contains_key(&inner.id) && !walking.contains(&inner.id)
364 })
365 .map(|inner| (inner, false)),
366 );
367 continue;
368 }
369 walking.remove(&composition.id);
370 let read = reads.walk(composition);
371 reads.read.insert(&composition.id, read);
372 }
373 }
374 reads
375 }
376
377 #[must_use]
380 pub fn reads_port(&self, composition: &CompositionId, port: usize) -> bool {
381 self.read
382 .get(composition)
383 .is_none_or(|read| read.ports.get(port).copied().unwrap_or(false))
384 }
385
386 #[must_use]
390 pub fn draws_clip(&self, composition: &CompositionId) -> bool {
391 self.read.get(composition).is_none_or(|read| read.clip)
392 }
393
394 #[must_use]
398 pub fn needed_inputs<'n>(&self, node: &'n CompositionNode) -> Vec<NodeInput<'n>> {
399 let mut needed = node.needed_inputs();
400 if let (false, NodeOp::Group { composition, .. }) = (node.bypass, &node.op) {
401 needed.retain(|input| match input {
402 NodeInput::Port(port, _) => self.reads_port(composition, *port),
403 _ => true,
404 });
405 }
406 needed
407 }
408
409 fn walk(&self, composition: &'a Composition) -> Reads {
412 let nodes: HashMap<&NodeId, &CompositionNode> = composition
413 .nodes
414 .iter()
415 .map(|node| (&node.id, node))
416 .collect();
417 let mut read = Reads {
418 ports: vec![false; composition.inputs.len()],
419 clip: false,
420 };
421 let mut reached = HashSet::new();
422 let mut pending: Vec<&NodeId> = composition.output.iter().collect();
423 while let Some(id) = pending.pop() {
424 let Some(node) = nodes.get(id).filter(|_| reached.insert(id)) else {
425 continue;
426 };
427 match (node.bypass, &node.op) {
428 (false, NodeOp::GroupInput { index }) => {
429 if let Some(port) = usize::try_from(*index)
430 .ok()
431 .and_then(|index| read.ports.get_mut(index))
432 {
433 *port = true;
434 }
435 }
436 (false, NodeOp::Clip {}) => read.clip = true,
437 (false, NodeOp::Group { composition, .. }) => {
438 read.clip |= self.draws_clip(composition);
439 }
440 _ => {}
441 }
442 pending.extend(
443 self.needed_inputs(node)
444 .into_iter()
445 .filter_map(NodeInput::node),
446 );
447 }
448 read
449 }
450}
451
452#[derive(Clone, Copy, Debug, Eq, PartialEq)]
454pub enum NodeInput<'a> {
455 Named(&'static str, Option<&'a NodeId>),
457 Layer(usize, Option<&'a NodeId>),
459 Port(usize, Option<&'a NodeId>),
461 EffectInput(&'a str, Option<&'a NodeId>),
463}
464
465impl<'a> NodeInput<'a> {
466 #[must_use]
468 pub const fn node(self) -> Option<&'a NodeId> {
469 match self {
470 Self::Named(_, node)
471 | Self::Layer(_, node)
472 | Self::Port(_, node)
473 | Self::EffectInput(_, node) => node,
474 }
475 }
476
477 #[must_use]
480 pub fn name(self) -> String {
481 match self {
482 Self::Named(name, _) => name.to_owned(),
483 Self::Layer(index, _) => format!("layers[{index}]"),
484 Self::Port(index, _) => format!("inputs[{index}]"),
485 Self::EffectInput(name, _) => format!("inputs.{name}"),
486 }
487 }
488}
489
490impl NodeOp {
491 #[must_use]
493 pub fn inputs(&self) -> Vec<NodeInput<'_>> {
494 match self {
495 Self::Clip {}
496 | Self::Media { .. }
497 | Self::Generator { .. }
498 | Self::GroupInput { .. } => Vec::new(),
499 Self::Group { inputs, .. } => inputs
500 .iter()
501 .enumerate()
502 .map(|(index, input)| NodeInput::Port(index, input.as_ref()))
503 .collect(),
504 Self::Effect { input, inputs, .. } => {
505 std::iter::once(NodeInput::Named("input", input.as_ref()))
506 .chain(
507 inputs
508 .iter()
509 .map(|(name, node)| NodeInput::EffectInput(name, node.as_ref())),
510 )
511 .collect()
512 }
513 Self::Transform { input, .. } | Self::ShapeMask { input, .. } => {
514 vec![NodeInput::Named("input", input.as_ref())]
515 }
516 Self::Transition { from, to, .. } => vec![
517 NodeInput::Named("from", from.as_ref()),
518 NodeInput::Named("to", to.as_ref()),
519 ],
520 Self::Matte { input, matte, .. } => vec![
521 NodeInput::Named("input", input.as_ref()),
522 NodeInput::Named("matte", matte.as_ref()),
523 ],
524 Self::Merge { layers } => layers
525 .iter()
526 .enumerate()
527 .map(|(index, layer)| NodeInput::Layer(index, layer.input.as_ref()))
528 .collect(),
529 }
530 }
531
532 #[must_use]
537 pub fn bypass_input(&self) -> Option<NodeInput<'_>> {
538 match self {
539 Self::Clip {}
540 | Self::Media { .. }
541 | Self::Generator { .. }
542 | Self::GroupInput { .. } => None,
543 Self::Merge { layers } => layers
544 .first()
545 .map(|layer| NodeInput::Layer(0, layer.input.as_ref())),
546 _ => self.inputs().into_iter().next(),
547 }
548 }
549
550 #[must_use]
552 pub const fn has_inputs(&self) -> bool {
553 match self {
554 Self::Clip {}
555 | Self::Media { .. }
556 | Self::Generator { .. }
557 | Self::GroupInput { .. } => false,
558 Self::Group { inputs, .. } => !inputs.is_empty(),
559 _ => true,
560 }
561 }
562
563 #[must_use]
565 pub const fn effect(&self) -> Option<&ClipEffect> {
566 match self {
567 Self::Effect { effect, .. } | Self::Transition { effect, .. } => Some(effect),
568 _ => None,
569 }
570 }
571}
572
573#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
582#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
583#[serde(deny_unknown_fields)]
584pub struct ClipComposition {
585 pub id: CompositionId,
587 #[serde(default)]
589 pub offset: MediaTime,
590 #[serde(default)]
592 pub scale: TimeScale,
593}
594
595impl ClipComposition {
596 #[must_use]
598 pub const fn new(id: CompositionId) -> Self {
599 Self {
600 id,
601 offset: MediaTime::ZERO,
602 scale: TimeScale::ONE,
603 }
604 }
605
606 #[must_use]
610 pub fn local_time(&self, clip_start: MediaTime, time: MediaTime) -> Option<MediaTime> {
611 let elapsed = i128::from(time.ticks()) - i128::from(clip_start.ticks());
612 let scaled = (elapsed * i128::from(self.scale.num))
613 .checked_div_euclid(i128::from(self.scale.den))?;
614 let local = i128::from(self.offset.ticks()).checked_add(scaled)?;
615 i64::try_from(local).ok().map(MediaTime)
616 }
617}
618
619#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)]
621#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
622#[serde(deny_unknown_fields)]
623pub struct TimeScale {
624 pub num: u32,
626 pub den: u32,
628}
629
630impl TimeScale {
631 pub const ONE: Self = Self { num: 1, den: 1 };
633
634 #[must_use]
636 pub const fn is_valid(self) -> bool {
637 self.num > 0 && self.den > 0
638 }
639}
640
641impl Default for TimeScale {
642 fn default() -> Self {
643 Self::ONE
644 }
645}
646
647pub(crate) fn validate_compositions<'a>(
652 compositions: &'a [Composition],
653 sources: &'a [crate::MediaSource],
654 by_source: &std::collections::HashMap<&SourceId, &crate::MediaSource>,
655 effect_resources: &std::collections::HashSet<&crate::EffectResourceId>,
656) -> Result<InlinedNodes<'a>, crate::ModelError> {
657 use crate::ModelError;
658 use std::collections::{HashMap, HashSet};
659
660 if compositions.len() > MAX_COMPOSITIONS {
661 return Err(ModelError::TooManyCompositions {
662 found: compositions.len(),
663 maximum: MAX_COMPOSITIONS,
664 });
665 }
666 let mut ids = HashSet::with_capacity(compositions.len());
667 for composition in compositions {
668 if !ids.insert(&composition.id) {
669 return Err(ModelError::DuplicateCompositionId(composition.id.clone()));
670 }
671 }
672 let by_id: HashMap<&CompositionId, &Composition> = compositions
673 .iter()
674 .map(|composition| (&composition.id, composition))
675 .collect();
676 if let Some(source) = sources.iter().find(|source| {
677 source
678 .composition
679 .as_ref()
680 .is_some_and(|id| !by_id.contains_key(id))
681 }) {
682 return Err(ModelError::InvalidCompositionSource {
683 source: source.id.clone(),
684 });
685 }
686 for composition in compositions {
687 if composition.nodes.len() > MAX_COMPOSITION_NODES {
688 return Err(ModelError::TooManyCompositionNodes {
689 composition: composition.id.clone(),
690 found: composition.nodes.len(),
691 maximum: MAX_COMPOSITION_NODES,
692 });
693 }
694 let mut positions = HashMap::with_capacity(composition.nodes.len());
695 for (position, node) in composition.nodes.iter().enumerate() {
696 if node.id.as_str().contains('/') {
697 return Err(ModelError::InvalidNodeId {
698 composition: composition.id.clone(),
699 node: node.id.clone(),
700 });
701 }
702 if positions.insert(&node.id, position).is_some() {
703 return Err(ModelError::DuplicateNodeId {
704 composition: composition.id.clone(),
705 node: node.id.clone(),
706 });
707 }
708 }
709 validate_ports(composition, &positions)?;
710 for node in &composition.nodes {
711 validate_node(composition, node, by_source, &by_id, effect_resources)?;
712 for input in node.op.inputs() {
713 if let Some(target) = input.node()
714 && !positions.contains_key(target)
715 {
716 return Err(ModelError::UnknownNodeInput {
717 composition: composition.id.clone(),
718 node: node.id.clone(),
719 input: input.name(),
720 target: target.clone(),
721 });
722 }
723 }
724 }
725 if let Some(output) = &composition.output
726 && !positions.contains_key(output)
727 {
728 return Err(ModelError::UnknownCompositionOutput {
729 composition: composition.id.clone(),
730 node: output.clone(),
731 });
732 }
733 if let Some(node) = cycle_member(composition, &positions) {
734 return Err(ModelError::CompositionCycle {
735 composition: composition.id.clone(),
736 node: node.clone(),
737 });
738 }
739 }
740 if let Some(composition) = reference_cycle_member(compositions, &by_id, by_source) {
741 return Err(ModelError::CompositionUsesItself {
742 composition: composition.clone(),
743 });
744 }
745 validate_expansion(compositions, &by_id, sources)
746}
747
748pub(crate) type InlinedNodes<'a> = std::collections::HashMap<&'a CompositionId, u64>;
751
752fn validate_expansion<'a>(
758 compositions: &'a [Composition],
759 by_id: &std::collections::HashMap<&'a CompositionId, &'a Composition>,
760 sources: &'a [crate::MediaSource],
761) -> Result<InlinedNodes<'a>, crate::ModelError> {
762 use std::collections::HashMap;
763 let composition_sources: HashMap<&SourceId, &CompositionId> = sources
764 .iter()
765 .filter_map(|source| Some((&source.id, source.composition.as_ref()?)))
766 .collect();
767 let draws = |node: &'a CompositionNode| -> Option<&'a CompositionId> {
769 match &node.op {
770 NodeOp::Group { composition, .. } => Some(composition),
771 NodeOp::Media { source_id, .. } => composition_sources.get(source_id).copied(),
772 _ => None,
773 }
774 .filter(|id| by_id.contains_key(id))
775 };
776 let mut walked: HashMap<&CompositionId, (usize, u64)> = HashMap::new();
778 let mut total = 0_u64;
779 for root in compositions {
780 let mut stack = vec![(root, false)];
781 while let Some((composition, expanded)) = stack.pop() {
782 if walked.contains_key(&composition.id) {
783 continue;
784 }
785 if !expanded {
786 stack.push((composition, true));
787 stack.extend(
788 composition
789 .nodes
790 .iter()
791 .filter_map(draws)
792 .filter(|id| !walked.contains_key(id))
793 .map(|id| (by_id[id], false)),
794 );
795 continue;
796 }
797 let mut depth = 1;
798 let mut nodes = 0_u64;
799 for node in &composition.nodes {
800 let (inner, count) = draws(node).map_or((0, 1), |id| walked[id]);
801 depth = depth.max(inner + 1);
802 nodes = nodes.saturating_add(count);
803 }
804 walked.insert(&composition.id, (depth, nodes));
805 }
806 let (depth, nodes) = walked[&root.id];
807 if depth > MAX_COMPOSITION_DEPTH {
808 return Err(crate::ModelError::CompositionNestedTooDeep {
809 composition: root.id.clone(),
810 maximum: MAX_COMPOSITION_DEPTH,
811 });
812 }
813 total = total.saturating_add(nodes);
814 if total > MAX_INLINED_NODES as u64 {
815 return Err(crate::ModelError::TooManyInlinedNodes {
816 maximum: MAX_INLINED_NODES,
817 });
818 }
819 }
820 Ok(walked
821 .into_iter()
822 .map(|(id, (_, nodes))| (id, nodes))
823 .collect())
824}
825
826fn validate_ports(
829 composition: &Composition,
830 positions: &std::collections::HashMap<&NodeId, usize>,
831) -> Result<(), crate::ModelError> {
832 use std::collections::HashSet;
833 let invalid = || crate::ModelError::InvalidCompositionPorts {
834 composition: composition.id.clone(),
835 };
836 let mut names = HashSet::new();
837 if composition.inputs.len() > MAX_GROUP_INPUTS
838 || composition
839 .inputs
840 .iter()
841 .any(|name| name.is_empty() || !names.insert(name))
842 {
843 return Err(invalid());
844 }
845 let mut controls = HashSet::new();
846 let mut settings = HashSet::new();
847 if composition.controls.len() > MAX_COMPOSITION_CONTROLS {
848 return Err(invalid());
849 }
850 for control in &composition.controls {
851 let node = positions
852 .get(&control.node)
853 .map(|position| &composition.nodes[*position]);
854 if control.name.is_empty()
855 || control.param.is_empty()
856 || !controls.insert(&control.name)
857 || !settings.insert((&control.node, &control.param))
858 || node.is_none_or(|node| node.op.effect().is_none())
859 {
860 return Err(invalid());
861 }
862 }
863 Ok(())
864}
865
866fn reference_cycle_member<'a>(
870 compositions: &'a [Composition],
871 by_id: &std::collections::HashMap<&'a CompositionId, &'a Composition>,
872 sources: &std::collections::HashMap<&SourceId, &'a crate::MediaSource>,
873) -> Option<&'a CompositionId> {
874 #[derive(Clone, Copy, PartialEq)]
875 enum Mark {
876 New,
877 Open,
878 Done,
879 }
880 let uses = |composition: &'a Composition| -> Vec<&'a CompositionId> {
881 composition
882 .nodes
883 .iter()
884 .filter_map(|node| match &node.op {
885 NodeOp::Group { composition, .. } => Some(composition),
886 NodeOp::Media { source_id, .. } => sources
887 .get(source_id)
888 .and_then(|source| source.composition.as_ref()),
889 _ => None,
890 })
891 .filter(|id| by_id.contains_key(id))
892 .collect()
893 };
894 let mut marks: std::collections::HashMap<&CompositionId, Mark> = compositions
895 .iter()
896 .map(|composition| (&composition.id, Mark::New))
897 .collect();
898 for root in compositions {
899 if marks[&root.id] != Mark::New {
900 continue;
901 }
902 let mut stack = vec![(&root.id, uses(root), 0_usize)];
903 marks.insert(&root.id, Mark::Open);
904 while let Some((current, next, at)) = stack.last_mut() {
905 let Some(target) = next.get(*at).copied() else {
906 marks.insert(current, Mark::Done);
907 stack.pop();
908 continue;
909 };
910 *at += 1;
911 match marks[target] {
912 Mark::Open => return Some(target),
913 Mark::New => {
914 marks.insert(target, Mark::Open);
915 stack.push((target, uses(by_id[target]), 0));
916 }
917 Mark::Done => {}
918 }
919 }
920 }
921 None
922}
923
924fn shape_error(
926 composition: &Composition,
927 node: &CompositionNode,
928 issue: crate::ShapeIssue,
929) -> crate::ModelError {
930 crate::ModelError::InvalidShape {
931 owner: crate::ShapeOwner::Node {
932 composition: composition.id.clone(),
933 node: node.id.clone(),
934 },
935 at: issue.at,
936 problem: issue.problem,
937 }
938}
939
940fn validate_node(
941 composition: &Composition,
942 node: &CompositionNode,
943 sources: &std::collections::HashMap<&SourceId, &crate::MediaSource>,
944 compositions: &std::collections::HashMap<&CompositionId, &Composition>,
945 effect_resources: &std::collections::HashSet<&crate::EffectResourceId>,
946) -> Result<(), crate::ModelError> {
947 use crate::ModelError;
948
949 let invalid = || ModelError::InvalidNode {
950 composition: composition.id.clone(),
951 node: node.id.clone(),
952 };
953 let unit = |animation: &Animated<f32>| {
954 crate::animation::valid_scalar_values(animation, |value| (0.0..=1.0).contains(&value))
955 };
956 let shape_error = |issue| shape_error(composition, node, issue);
957 if node.bypass && !node.op.has_inputs() {
958 return Err(invalid());
959 }
960 let valid = match &node.op {
961 NodeOp::Clip {} | NodeOp::Matte { .. } => true,
962 NodeOp::Media { source_id, start } => {
963 let visual = sources.get(source_id).is_some_and(|source| {
964 matches!(
965 source.kind,
966 crate::MediaKind::Video
967 | crate::MediaKind::Image
968 | crate::MediaKind::Generated
969 | crate::MediaKind::Composition
970 )
971 });
972 if !visual {
973 return Err(ModelError::InvalidNodeSource {
974 composition: composition.id.clone(),
975 node: node.id.clone(),
976 source: source_id.clone(),
977 });
978 }
979 start.ticks() >= 0
980 }
981 NodeOp::Generator { generator } => match generator.validate() {
982 Ok(()) => true,
983 Err(Some(issue)) => return Err(shape_error(issue)),
984 Err(None) => false,
985 },
986 NodeOp::Effect { effect, inputs, .. } => {
987 effect.is_valid()
988 && inputs.len() <= MAX_EFFECT_INPUTS
989 && inputs
990 .keys()
991 .all(|name| crate::effect::valid_binding_name(name))
992 }
993 NodeOp::Transition {
994 effect, progress, ..
995 } => effect.is_valid() && unit(progress),
996 NodeOp::Transform { transform, .. } => transform.validate(),
997 NodeOp::Merge { layers } => {
998 layers.len() <= MAX_MERGE_LAYERS && layers.iter().all(|layer| unit(&layer.opacity))
999 }
1000 NodeOp::ShapeMask { masks, .. } => {
1001 crate::mask::validate_masks(masks).map_err(shape_error)?;
1002 masks.len() <= crate::MAX_CLIP_MASKS
1003 }
1004 NodeOp::Group {
1005 composition: target,
1006 inputs,
1007 controls,
1008 } => {
1009 let Some(target) = compositions.get(target) else {
1010 return Err(ModelError::UnknownGroupComposition {
1011 composition: composition.id.clone(),
1012 node: node.id.clone(),
1013 target: target.clone(),
1014 });
1015 };
1016 inputs.len() == target.inputs.len()
1017 && controls.iter().all(|(name, value)| {
1018 target.controls.iter().any(|control| &control.name == name)
1019 && crate::effect::valid_param(value)
1020 })
1021 }
1022 NodeOp::GroupInput { index } => {
1023 usize::try_from(*index).is_ok_and(|index| index < composition.inputs.len())
1024 }
1025 };
1026 if !valid {
1027 return Err(invalid());
1028 }
1029 if let Some(effect) = node.op.effect()
1030 && let Some(resource) = effect
1031 .resources
1032 .values()
1033 .find(|resource| !effect_resources.contains(resource))
1034 {
1035 return Err(ModelError::UnknownNodeResource {
1036 composition: composition.id.clone(),
1037 node: node.id.clone(),
1038 resource: resource.clone(),
1039 });
1040 }
1041 Ok(())
1042}
1043
1044fn cycle_member<'a>(
1047 composition: &'a Composition,
1048 positions: &std::collections::HashMap<&'a NodeId, usize>,
1049) -> Option<&'a NodeId> {
1050 #[derive(Clone, Copy, PartialEq)]
1051 enum Mark {
1052 New,
1053 Open,
1054 Done,
1055 }
1056 let nodes = &composition.nodes;
1057 let mut marks = vec![Mark::New; nodes.len()];
1058 for root in 0..nodes.len() {
1059 if marks[root] != Mark::New {
1060 continue;
1061 }
1062 let mut stack = vec![(root, 0_usize)];
1064 marks[root] = Mark::Open;
1065 while let Some((current, next)) = stack.last_mut() {
1066 let inputs = nodes[*current].op.inputs();
1067 let Some(input) = inputs.get(*next) else {
1068 marks[*current] = Mark::Done;
1069 stack.pop();
1070 continue;
1071 };
1072 *next += 1;
1073 let Some(target) = input.node().and_then(|id| positions.get(id)) else {
1074 continue;
1075 };
1076 match marks[*target] {
1077 Mark::Open => return Some(&nodes[*target].id),
1078 Mark::New => {
1079 marks[*target] = Mark::Open;
1080 stack.push((*target, 0));
1081 }
1082 Mark::Done => {}
1083 }
1084 }
1085 }
1086 None
1087}
1088
1089#[cfg(test)]
1090#[path = "composition_tests.rs"]
1091mod tests;