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 canvas: (u32, u32),
657) -> Result<InlinedNodes<'a>, crate::ModelError> {
658 use crate::ModelError;
659 use std::collections::{HashMap, HashSet};
660
661 if compositions.len() > MAX_COMPOSITIONS {
662 return Err(ModelError::TooManyCompositions {
663 found: compositions.len(),
664 maximum: MAX_COMPOSITIONS,
665 });
666 }
667 let mut ids = HashSet::with_capacity(compositions.len());
668 for composition in compositions {
669 if !ids.insert(&composition.id) {
670 return Err(ModelError::DuplicateCompositionId(composition.id.clone()));
671 }
672 }
673 let by_id: HashMap<&CompositionId, &Composition> = compositions
674 .iter()
675 .map(|composition| (&composition.id, composition))
676 .collect();
677 if let Some(source) = sources.iter().find(|source| {
678 source
679 .composition
680 .as_ref()
681 .is_some_and(|id| !by_id.contains_key(id))
682 }) {
683 return Err(ModelError::InvalidCompositionSource {
684 source: source.id.clone(),
685 });
686 }
687 for composition in compositions {
688 if composition.nodes.len() > MAX_COMPOSITION_NODES {
689 return Err(ModelError::TooManyCompositionNodes {
690 composition: composition.id.clone(),
691 found: composition.nodes.len(),
692 maximum: MAX_COMPOSITION_NODES,
693 });
694 }
695 let mut positions = HashMap::with_capacity(composition.nodes.len());
696 for (position, node) in composition.nodes.iter().enumerate() {
697 if node.id.as_str().contains('/') {
698 return Err(ModelError::InvalidNodeId {
699 composition: composition.id.clone(),
700 node: node.id.clone(),
701 });
702 }
703 if positions.insert(&node.id, position).is_some() {
704 return Err(ModelError::DuplicateNodeId {
705 composition: composition.id.clone(),
706 node: node.id.clone(),
707 });
708 }
709 }
710 validate_ports(composition, &positions)?;
711 for node in &composition.nodes {
712 validate_node(
713 composition,
714 node,
715 by_source,
716 &by_id,
717 effect_resources,
718 canvas,
719 )?;
720 for input in node.op.inputs() {
721 if let Some(target) = input.node()
722 && !positions.contains_key(target)
723 {
724 return Err(ModelError::UnknownNodeInput {
725 composition: composition.id.clone(),
726 node: node.id.clone(),
727 input: input.name(),
728 target: target.clone(),
729 });
730 }
731 }
732 }
733 if let Some(output) = &composition.output
734 && !positions.contains_key(output)
735 {
736 return Err(ModelError::UnknownCompositionOutput {
737 composition: composition.id.clone(),
738 node: output.clone(),
739 });
740 }
741 if let Some(node) = cycle_member(composition, &positions) {
742 return Err(ModelError::CompositionCycle {
743 composition: composition.id.clone(),
744 node: node.clone(),
745 });
746 }
747 }
748 if let Some(composition) = reference_cycle_member(compositions, &by_id, by_source) {
749 return Err(ModelError::CompositionUsesItself {
750 composition: composition.clone(),
751 });
752 }
753 validate_expansion(compositions, &by_id, sources)
754}
755
756pub(crate) type InlinedNodes<'a> = std::collections::HashMap<&'a CompositionId, u64>;
759
760fn validate_expansion<'a>(
766 compositions: &'a [Composition],
767 by_id: &std::collections::HashMap<&'a CompositionId, &'a Composition>,
768 sources: &'a [crate::MediaSource],
769) -> Result<InlinedNodes<'a>, crate::ModelError> {
770 use std::collections::HashMap;
771 let composition_sources: HashMap<&SourceId, &CompositionId> = sources
772 .iter()
773 .filter_map(|source| Some((&source.id, source.composition.as_ref()?)))
774 .collect();
775 let draws = |node: &'a CompositionNode| -> Option<&'a CompositionId> {
777 match &node.op {
778 NodeOp::Group { composition, .. } => Some(composition),
779 NodeOp::Media { source_id, .. } => composition_sources.get(source_id).copied(),
780 _ => None,
781 }
782 .filter(|id| by_id.contains_key(id))
783 };
784 let mut walked: HashMap<&CompositionId, (usize, u64)> = HashMap::new();
786 let mut total = 0_u64;
787 for root in compositions {
788 let mut stack = vec![(root, false)];
789 while let Some((composition, expanded)) = stack.pop() {
790 if walked.contains_key(&composition.id) {
791 continue;
792 }
793 if !expanded {
794 stack.push((composition, true));
795 stack.extend(
796 composition
797 .nodes
798 .iter()
799 .filter_map(draws)
800 .filter(|id| !walked.contains_key(id))
801 .map(|id| (by_id[id], false)),
802 );
803 continue;
804 }
805 let mut depth = 1;
806 let mut nodes = 0_u64;
807 for node in &composition.nodes {
808 let (inner, count) = draws(node).map_or((0, 1), |id| walked[id]);
809 depth = depth.max(inner + 1);
810 nodes = nodes.saturating_add(count);
811 }
812 walked.insert(&composition.id, (depth, nodes));
813 }
814 let (depth, nodes) = walked[&root.id];
815 if depth > MAX_COMPOSITION_DEPTH {
816 return Err(crate::ModelError::CompositionNestedTooDeep {
817 composition: root.id.clone(),
818 maximum: MAX_COMPOSITION_DEPTH,
819 });
820 }
821 total = total.saturating_add(nodes);
822 if total > MAX_INLINED_NODES as u64 {
823 return Err(crate::ModelError::TooManyInlinedNodes {
824 maximum: MAX_INLINED_NODES,
825 });
826 }
827 }
828 Ok(walked
829 .into_iter()
830 .map(|(id, (_, nodes))| (id, nodes))
831 .collect())
832}
833
834fn validate_ports(
837 composition: &Composition,
838 positions: &std::collections::HashMap<&NodeId, usize>,
839) -> Result<(), crate::ModelError> {
840 use std::collections::HashSet;
841 let invalid = || crate::ModelError::InvalidCompositionPorts {
842 composition: composition.id.clone(),
843 };
844 let mut names = HashSet::new();
845 if composition.inputs.len() > MAX_GROUP_INPUTS
846 || composition
847 .inputs
848 .iter()
849 .any(|name| name.is_empty() || !names.insert(name))
850 {
851 return Err(invalid());
852 }
853 let mut controls = HashSet::new();
854 let mut settings = HashSet::new();
855 if composition.controls.len() > MAX_COMPOSITION_CONTROLS {
856 return Err(invalid());
857 }
858 for control in &composition.controls {
859 let node = positions
860 .get(&control.node)
861 .map(|position| &composition.nodes[*position]);
862 if control.name.is_empty()
863 || control.param.is_empty()
864 || !controls.insert(&control.name)
865 || !settings.insert((&control.node, &control.param))
866 || node.is_none_or(|node| node.op.effect().is_none())
867 {
868 return Err(invalid());
869 }
870 }
871 Ok(())
872}
873
874fn reference_cycle_member<'a>(
878 compositions: &'a [Composition],
879 by_id: &std::collections::HashMap<&'a CompositionId, &'a Composition>,
880 sources: &std::collections::HashMap<&SourceId, &'a crate::MediaSource>,
881) -> Option<&'a CompositionId> {
882 #[derive(Clone, Copy, PartialEq)]
883 enum Mark {
884 New,
885 Open,
886 Done,
887 }
888 let uses = |composition: &'a Composition| -> Vec<&'a CompositionId> {
889 composition
890 .nodes
891 .iter()
892 .filter_map(|node| match &node.op {
893 NodeOp::Group { composition, .. } => Some(composition),
894 NodeOp::Media { source_id, .. } => sources
895 .get(source_id)
896 .and_then(|source| source.composition.as_ref()),
897 _ => None,
898 })
899 .filter(|id| by_id.contains_key(id))
900 .collect()
901 };
902 let mut marks: std::collections::HashMap<&CompositionId, Mark> = compositions
903 .iter()
904 .map(|composition| (&composition.id, Mark::New))
905 .collect();
906 for root in compositions {
907 if marks[&root.id] != Mark::New {
908 continue;
909 }
910 let mut stack = vec![(&root.id, uses(root), 0_usize)];
911 marks.insert(&root.id, Mark::Open);
912 while let Some((current, next, at)) = stack.last_mut() {
913 let Some(target) = next.get(*at).copied() else {
914 marks.insert(current, Mark::Done);
915 stack.pop();
916 continue;
917 };
918 *at += 1;
919 match marks[target] {
920 Mark::Open => return Some(target),
921 Mark::New => {
922 marks.insert(target, Mark::Open);
923 stack.push((target, uses(by_id[target]), 0));
924 }
925 Mark::Done => {}
926 }
927 }
928 }
929 None
930}
931
932fn shape_error(
934 composition: &Composition,
935 node: &CompositionNode,
936 issue: crate::ShapeIssue,
937) -> crate::ModelError {
938 crate::ModelError::InvalidShape {
939 owner: crate::ShapeOwner::Node {
940 composition: composition.id.clone(),
941 node: node.id.clone(),
942 },
943 at: issue.at,
944 problem: issue.problem,
945 }
946}
947
948fn layout_error(
950 composition: &Composition,
951 node: &CompositionNode,
952 issue: crate::LayoutIssue,
953) -> crate::ModelError {
954 crate::ModelError::InvalidLayout {
955 owner: crate::ShapeOwner::Node {
956 composition: composition.id.clone(),
957 node: node.id.clone(),
958 },
959 at: issue.at,
960 problem: issue.problem,
961 }
962}
963
964fn validate_node(
965 composition: &Composition,
966 node: &CompositionNode,
967 sources: &std::collections::HashMap<&SourceId, &crate::MediaSource>,
968 compositions: &std::collections::HashMap<&CompositionId, &Composition>,
969 effect_resources: &std::collections::HashSet<&crate::EffectResourceId>,
970 canvas: (u32, u32),
971) -> Result<(), crate::ModelError> {
972 use crate::ModelError;
973
974 let invalid = || ModelError::InvalidNode {
975 composition: composition.id.clone(),
976 node: node.id.clone(),
977 };
978 let unit = |animation: &Animated<f32>| {
979 crate::animation::valid_scalar_values(animation, |value| (0.0..=1.0).contains(&value))
980 };
981 let shape_error = |issue| shape_error(composition, node, issue);
982 if node.bypass && !node.op.has_inputs() {
983 return Err(invalid());
984 }
985 let valid = match &node.op {
986 NodeOp::Clip {} | NodeOp::Matte { .. } => true,
987 NodeOp::Media { source_id, start } => {
988 let visual = sources.get(source_id).is_some_and(|source| {
989 matches!(
990 source.kind,
991 crate::MediaKind::Video
992 | crate::MediaKind::Image
993 | crate::MediaKind::Generated
994 | crate::MediaKind::Composition
995 )
996 });
997 if !visual {
998 return Err(ModelError::InvalidNodeSource {
999 composition: composition.id.clone(),
1000 node: node.id.clone(),
1001 source: source_id.clone(),
1002 });
1003 }
1004 start.ticks() >= 0
1005 }
1006 NodeOp::Generator { generator } => match generator.validate_on(canvas.0, canvas.1) {
1007 Ok(()) => true,
1008 Err(crate::GeneratorIssue::Shape(issue)) => return Err(shape_error(issue)),
1009 Err(crate::GeneratorIssue::Layout(issue)) => {
1010 return Err(layout_error(composition, node, issue));
1011 }
1012 Err(crate::GeneratorIssue::Invalid) => false,
1013 },
1014 NodeOp::Effect { effect, inputs, .. } => {
1015 effect.is_valid()
1016 && inputs.len() <= MAX_EFFECT_INPUTS
1017 && inputs
1018 .keys()
1019 .all(|name| crate::effect::valid_binding_name(name))
1020 }
1021 NodeOp::Transition {
1022 effect, progress, ..
1023 } => effect.is_valid() && unit(progress),
1024 NodeOp::Transform { transform, .. } => transform.validate(),
1025 NodeOp::Merge { layers } => {
1026 layers.len() <= MAX_MERGE_LAYERS && layers.iter().all(|layer| unit(&layer.opacity))
1027 }
1028 NodeOp::ShapeMask { masks, .. } => {
1029 crate::mask::validate_masks(masks).map_err(shape_error)?;
1030 masks.len() <= crate::MAX_CLIP_MASKS
1031 }
1032 NodeOp::Group {
1033 composition: target,
1034 inputs,
1035 controls,
1036 } => {
1037 let Some(target) = compositions.get(target) else {
1038 return Err(ModelError::UnknownGroupComposition {
1039 composition: composition.id.clone(),
1040 node: node.id.clone(),
1041 target: target.clone(),
1042 });
1043 };
1044 inputs.len() == target.inputs.len()
1045 && controls.iter().all(|(name, value)| {
1046 target.controls.iter().any(|control| &control.name == name)
1047 && crate::effect::valid_param(value)
1048 })
1049 }
1050 NodeOp::GroupInput { index } => {
1051 usize::try_from(*index).is_ok_and(|index| index < composition.inputs.len())
1052 }
1053 };
1054 if !valid {
1055 return Err(invalid());
1056 }
1057 if let Some(effect) = node.op.effect()
1058 && let Some(resource) = effect
1059 .resources
1060 .values()
1061 .find(|resource| !effect_resources.contains(resource))
1062 {
1063 return Err(ModelError::UnknownNodeResource {
1064 composition: composition.id.clone(),
1065 node: node.id.clone(),
1066 resource: resource.clone(),
1067 });
1068 }
1069 Ok(())
1070}
1071
1072fn cycle_member<'a>(
1075 composition: &'a Composition,
1076 positions: &std::collections::HashMap<&'a NodeId, usize>,
1077) -> Option<&'a NodeId> {
1078 #[derive(Clone, Copy, PartialEq)]
1079 enum Mark {
1080 New,
1081 Open,
1082 Done,
1083 }
1084 let nodes = &composition.nodes;
1085 let mut marks = vec![Mark::New; nodes.len()];
1086 for root in 0..nodes.len() {
1087 if marks[root] != Mark::New {
1088 continue;
1089 }
1090 let mut stack = vec![(root, 0_usize)];
1092 marks[root] = Mark::Open;
1093 while let Some((current, next)) = stack.last_mut() {
1094 let inputs = nodes[*current].op.inputs();
1095 let Some(input) = inputs.get(*next) else {
1096 marks[*current] = Mark::Done;
1097 stack.pop();
1098 continue;
1099 };
1100 *next += 1;
1101 let Some(target) = input.node().and_then(|id| positions.get(id)) else {
1102 continue;
1103 };
1104 match marks[*target] {
1105 Mark::Open => return Some(&nodes[*target].id),
1106 Mark::New => {
1107 marks[*target] = Mark::Open;
1108 stack.push((*target, 0));
1109 }
1110 Mark::Done => {}
1111 }
1112 }
1113 }
1114 None
1115}
1116
1117#[cfg(test)]
1118#[path = "composition_tests.rs"]
1119mod tests;