1use crate::animation::opaque;
20use crate::{
21 Animated, ClipBlendMode, ClipEffect, CompositionId, EffectValue, Generator, MatteMode, NodeId,
22 ShapeMask, VisualProperties,
23};
24use alpha2_media::SourceId;
25use alpha2_time::MediaTime;
26use serde::{Deserialize, Serialize};
27use std::collections::BTreeMap;
28use std::collections::{HashMap, HashSet};
29
30pub const MAX_COMPOSITIONS: usize = 10_000;
32pub const MAX_COMPOSITION_NODES: usize = 256;
34pub const MAX_EFFECT_INPUTS: usize = 8;
37pub const MAX_MERGE_LAYERS: usize = 32;
39pub const MAX_GROUP_INPUTS: usize = 16;
41pub const MAX_COMPOSITION_CONTROLS: usize = 64;
43pub const MAX_COMPOSITION_DEPTH: usize = 64;
47pub const MAX_INLINED_NODES: usize = 65_536;
51pub const MAX_FRAME_INLINED_NODES: usize = 65_536;
55
56#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
58#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
59#[serde(deny_unknown_fields)]
60pub struct Composition {
61 pub id: CompositionId,
63 #[serde(default)]
65 pub nodes: Vec<CompositionNode>,
66 #[serde(default)]
69 pub output: Option<NodeId>,
70 #[serde(default, skip_serializing_if = "Vec::is_empty")]
73 pub inputs: Vec<String>,
74 #[serde(default, skip_serializing_if = "Vec::is_empty")]
76 pub controls: Vec<CompositionControl>,
77}
78
79#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
82#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
83#[serde(deny_unknown_fields)]
84pub struct CompositionControl {
85 pub name: String,
87 pub node: NodeId,
89 pub param: String,
91}
92
93impl Composition {
94 #[must_use]
96 pub const fn new(id: CompositionId) -> Self {
97 Self {
98 id,
99 nodes: Vec::new(),
100 output: None,
101 inputs: Vec::new(),
102 controls: Vec::new(),
103 }
104 }
105
106 #[must_use]
108 pub fn node(&self, id: &NodeId) -> Option<&CompositionNode> {
109 self.nodes.iter().find(|node| &node.id == id)
110 }
111}
112
113#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
115#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
116#[serde(deny_unknown_fields)]
117pub struct CompositionNode {
118 pub id: NodeId,
120 #[serde(default)]
123 pub bypass: bool,
124 pub op: NodeOp,
126}
127
128impl CompositionNode {
129 #[must_use]
131 pub const fn new(id: NodeId, op: NodeOp) -> Self {
132 Self {
133 id,
134 bypass: false,
135 op,
136 }
137 }
138
139 #[must_use]
142 pub fn needed_inputs(&self) -> Vec<NodeInput<'_>> {
143 if self.bypass {
144 return self.op.bypass_input().into_iter().collect();
145 }
146 self.op.inputs()
147 }
148}
149
150#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
154#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
155#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
156pub enum NodeOp {
157 Clip {},
160 Media {
163 source_id: SourceId,
165 #[serde(default)]
167 start: MediaTime,
168 },
169 Generator {
171 generator: Generator,
173 },
174 Effect {
176 #[serde(default)]
178 input: Option<NodeId>,
179 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
183 inputs: BTreeMap<String, Option<NodeId>>,
184 effect: ClipEffect,
186 },
187 Transition {
189 #[serde(default)]
191 from: Option<NodeId>,
192 #[serde(default)]
194 to: Option<NodeId>,
195 effect: ClipEffect,
197 progress: Animated<f32>,
199 },
200 Transform {
202 #[serde(default)]
204 input: Option<NodeId>,
205 #[serde(default)]
207 transform: VisualProperties,
208 },
209 Merge {
211 #[serde(default)]
213 layers: Vec<MergeLayer>,
214 },
215 Matte {
217 #[serde(default)]
219 input: Option<NodeId>,
220 #[serde(default)]
222 matte: Option<NodeId>,
223 mode: MatteMode,
225 },
226 ShapeMask {
228 #[serde(default)]
230 input: Option<NodeId>,
231 masks: Vec<ShapeMask>,
234 },
235 Group {
238 composition: CompositionId,
240 #[serde(default)]
242 inputs: Vec<Option<NodeId>>,
243 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
246 controls: BTreeMap<String, Animated<EffectValue>>,
247 },
248 GroupInput {
250 index: u32,
252 },
253}
254
255#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
257#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
258#[serde(deny_unknown_fields)]
259pub struct MergeLayer {
260 #[serde(default)]
262 pub input: Option<NodeId>,
263 #[serde(default)]
265 pub blend_mode: ClipBlendMode,
266 #[serde(default = "opaque")]
268 pub opacity: Animated<f32>,
269}
270
271impl MergeLayer {
272 #[must_use]
274 pub const fn new(input: Option<NodeId>) -> Self {
275 Self {
276 input,
277 blend_mode: ClipBlendMode::Normal,
278 opacity: Animated::Constant { value: 1.0 },
279 }
280 }
281}
282
283#[derive(Clone, Debug, Default)]
290pub struct CompositionReads<'a> {
291 read: HashMap<&'a CompositionId, Reads>,
292}
293
294#[derive(Clone, Debug)]
296struct Reads {
297 ports: Vec<bool>,
299 clip: bool,
301}
302
303impl<'a> CompositionReads<'a> {
304 #[must_use]
306 pub fn new(compositions: &HashMap<&'a CompositionId, &'a Composition>) -> Self {
307 let mut reads = Self::default();
308 let mut walking = HashSet::new();
309 let group = |node: &'a CompositionNode| match &node.op {
310 NodeOp::Group { composition, .. } => compositions.get(composition).copied(),
311 _ => None,
312 };
313 for root in compositions.values() {
314 let mut stack = vec![(*root, false)];
315 while let Some((composition, expanded)) = stack.pop() {
316 if reads.read.contains_key(&composition.id) {
317 continue;
318 }
319 if !expanded {
320 if !walking.insert(&composition.id) {
321 continue;
322 }
323 stack.push((composition, true));
324 stack.extend(
325 composition
326 .nodes
327 .iter()
328 .filter_map(group)
329 .filter(|inner| {
330 !reads.read.contains_key(&inner.id) && !walking.contains(&inner.id)
331 })
332 .map(|inner| (inner, false)),
333 );
334 continue;
335 }
336 walking.remove(&composition.id);
337 let read = reads.walk(composition);
338 reads.read.insert(&composition.id, read);
339 }
340 }
341 reads
342 }
343
344 #[must_use]
347 pub fn reads_port(&self, composition: &CompositionId, port: usize) -> bool {
348 self.read
349 .get(composition)
350 .is_none_or(|read| read.ports.get(port).copied().unwrap_or(false))
351 }
352
353 #[must_use]
356 pub fn draws_clip(&self, composition: &CompositionId) -> bool {
357 self.read.get(composition).is_none_or(|read| read.clip)
358 }
359
360 #[must_use]
363 pub fn needed_inputs<'n>(&self, node: &'n CompositionNode) -> Vec<NodeInput<'n>> {
364 let mut needed = node.needed_inputs();
365 if let (false, NodeOp::Group { composition, .. }) = (node.bypass, &node.op) {
366 needed.retain(|input| match input {
367 NodeInput::Port(port, _) => self.reads_port(composition, *port),
368 _ => true,
369 });
370 }
371 needed
372 }
373
374 fn walk(&self, composition: &'a Composition) -> Reads {
376 let nodes: HashMap<&NodeId, &CompositionNode> = composition
377 .nodes
378 .iter()
379 .map(|node| (&node.id, node))
380 .collect();
381 let mut read = Reads {
382 ports: vec![false; composition.inputs.len()],
383 clip: false,
384 };
385 let mut reached = HashSet::new();
386 let mut pending: Vec<&NodeId> = composition.output.iter().collect();
387 while let Some(id) = pending.pop() {
388 let Some(node) = nodes.get(id).filter(|_| reached.insert(id)) else {
389 continue;
390 };
391 match (node.bypass, &node.op) {
392 (false, NodeOp::GroupInput { index }) => {
393 if let Some(port) = usize::try_from(*index)
394 .ok()
395 .and_then(|index| read.ports.get_mut(index))
396 {
397 *port = true;
398 }
399 }
400 (false, NodeOp::Clip {}) => read.clip = true,
401 (false, NodeOp::Group { composition, .. }) => {
402 read.clip |= self.draws_clip(composition);
403 }
404 _ => {}
405 }
406 pending.extend(
407 self.needed_inputs(node)
408 .into_iter()
409 .filter_map(NodeInput::node),
410 );
411 }
412 read
413 }
414}
415
416#[derive(Clone, Copy, Debug, Eq, PartialEq)]
418pub enum NodeInput<'a> {
419 Named(&'static str, Option<&'a NodeId>),
421 Layer(usize, Option<&'a NodeId>),
423 Port(usize, Option<&'a NodeId>),
425 EffectInput(&'a str, Option<&'a NodeId>),
427}
428
429impl<'a> NodeInput<'a> {
430 #[must_use]
432 pub const fn node(self) -> Option<&'a NodeId> {
433 match self {
434 Self::Named(_, node)
435 | Self::Layer(_, node)
436 | Self::Port(_, node)
437 | Self::EffectInput(_, node) => node,
438 }
439 }
440
441 #[must_use]
444 pub fn name(self) -> String {
445 match self {
446 Self::Named(name, _) => name.to_owned(),
447 Self::Layer(index, _) => format!("layers[{index}]"),
448 Self::Port(index, _) => format!("inputs[{index}]"),
449 Self::EffectInput(name, _) => format!("inputs.{name}"),
450 }
451 }
452}
453
454impl NodeOp {
455 #[must_use]
457 pub fn inputs(&self) -> Vec<NodeInput<'_>> {
458 match self {
459 Self::Clip {}
460 | Self::Media { .. }
461 | Self::Generator { .. }
462 | Self::GroupInput { .. } => Vec::new(),
463 Self::Group { inputs, .. } => inputs
464 .iter()
465 .enumerate()
466 .map(|(index, input)| NodeInput::Port(index, input.as_ref()))
467 .collect(),
468 Self::Effect { input, inputs, .. } => {
469 std::iter::once(NodeInput::Named("input", input.as_ref()))
470 .chain(
471 inputs
472 .iter()
473 .map(|(name, node)| NodeInput::EffectInput(name, node.as_ref())),
474 )
475 .collect()
476 }
477 Self::Transform { input, .. } | Self::ShapeMask { input, .. } => {
478 vec![NodeInput::Named("input", input.as_ref())]
479 }
480 Self::Transition { from, to, .. } => vec![
481 NodeInput::Named("from", from.as_ref()),
482 NodeInput::Named("to", to.as_ref()),
483 ],
484 Self::Matte { input, matte, .. } => vec![
485 NodeInput::Named("input", input.as_ref()),
486 NodeInput::Named("matte", matte.as_ref()),
487 ],
488 Self::Merge { layers } => layers
489 .iter()
490 .enumerate()
491 .map(|(index, layer)| NodeInput::Layer(index, layer.input.as_ref()))
492 .collect(),
493 }
494 }
495
496 #[must_use]
500 pub fn bypass_input(&self) -> Option<NodeInput<'_>> {
501 match self {
502 Self::Clip {}
503 | Self::Media { .. }
504 | Self::Generator { .. }
505 | Self::GroupInput { .. } => None,
506 Self::Merge { layers } => layers
507 .first()
508 .map(|layer| NodeInput::Layer(0, layer.input.as_ref())),
509 _ => self.inputs().into_iter().next(),
510 }
511 }
512
513 #[must_use]
515 pub const fn has_inputs(&self) -> bool {
516 match self {
517 Self::Clip {}
518 | Self::Media { .. }
519 | Self::Generator { .. }
520 | Self::GroupInput { .. } => false,
521 Self::Group { inputs, .. } => !inputs.is_empty(),
522 _ => true,
523 }
524 }
525
526 #[must_use]
528 pub const fn effect(&self) -> Option<&ClipEffect> {
529 match self {
530 Self::Effect { effect, .. } | Self::Transition { effect, .. } => Some(effect),
531 _ => None,
532 }
533 }
534}
535
536#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
543#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
544#[serde(deny_unknown_fields)]
545pub struct ClipComposition {
546 pub id: CompositionId,
548 #[serde(default)]
550 pub offset: MediaTime,
551 #[serde(default)]
553 pub scale: TimeScale,
554}
555
556impl ClipComposition {
557 #[must_use]
559 pub const fn new(id: CompositionId) -> Self {
560 Self {
561 id,
562 offset: MediaTime::ZERO,
563 scale: TimeScale::ONE,
564 }
565 }
566
567 #[must_use]
570 pub fn local_time(&self, clip_start: MediaTime, time: MediaTime) -> Option<MediaTime> {
571 let elapsed = i128::from(time.ticks()) - i128::from(clip_start.ticks());
572 let scaled = (elapsed * i128::from(self.scale.num))
573 .checked_div_euclid(i128::from(self.scale.den))?;
574 let local = i128::from(self.offset.ticks()).checked_add(scaled)?;
575 i64::try_from(local).ok().map(MediaTime)
576 }
577}
578
579#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)]
581#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
582#[serde(deny_unknown_fields)]
583pub struct TimeScale {
584 pub num: u32,
586 pub den: u32,
588}
589
590impl TimeScale {
591 pub const ONE: Self = Self { num: 1, den: 1 };
593
594 #[must_use]
596 pub const fn is_valid(self) -> bool {
597 self.num > 0 && self.den > 0
598 }
599}
600
601impl Default for TimeScale {
602 fn default() -> Self {
603 Self::ONE
604 }
605}
606
607pub(crate) fn validate_compositions<'a>(
610 compositions: &'a [Composition],
611 sources: &'a [crate::MediaSource],
612 by_source: &std::collections::HashMap<&SourceId, &crate::MediaSource>,
613 effect_resources: &std::collections::HashSet<&crate::EffectResourceId>,
614 canvas: (u32, u32),
615) -> Result<InlinedNodes<'a>, crate::ModelError> {
616 use crate::ModelError;
617 use std::collections::{HashMap, HashSet};
618
619 if compositions.len() > MAX_COMPOSITIONS {
620 return Err(ModelError::TooManyCompositions {
621 found: compositions.len(),
622 maximum: MAX_COMPOSITIONS,
623 });
624 }
625 let mut ids = HashSet::with_capacity(compositions.len());
626 for composition in compositions {
627 if !ids.insert(&composition.id) {
628 return Err(ModelError::DuplicateCompositionId(composition.id.clone()));
629 }
630 }
631 let by_id: HashMap<&CompositionId, &Composition> = compositions
632 .iter()
633 .map(|composition| (&composition.id, composition))
634 .collect();
635 if let Some(source) = sources.iter().find(|source| {
636 source
637 .composition
638 .as_ref()
639 .is_some_and(|id| !by_id.contains_key(id))
640 }) {
641 return Err(ModelError::InvalidCompositionSource {
642 source: source.id.clone(),
643 });
644 }
645 for composition in compositions {
646 if composition.nodes.len() > MAX_COMPOSITION_NODES {
647 return Err(ModelError::TooManyCompositionNodes {
648 composition: composition.id.clone(),
649 found: composition.nodes.len(),
650 maximum: MAX_COMPOSITION_NODES,
651 });
652 }
653 let mut positions = HashMap::with_capacity(composition.nodes.len());
654 for (position, node) in composition.nodes.iter().enumerate() {
655 if node.id.as_str().contains('/') {
656 return Err(ModelError::InvalidNodeId {
657 composition: composition.id.clone(),
658 node: node.id.clone(),
659 });
660 }
661 if positions.insert(&node.id, position).is_some() {
662 return Err(ModelError::DuplicateNodeId {
663 composition: composition.id.clone(),
664 node: node.id.clone(),
665 });
666 }
667 }
668 validate_ports(composition, &positions)?;
669 for node in &composition.nodes {
670 validate_node(
671 composition,
672 node,
673 by_source,
674 &by_id,
675 effect_resources,
676 canvas,
677 )?;
678 for input in node.op.inputs() {
679 if let Some(target) = input.node()
680 && !positions.contains_key(target)
681 {
682 return Err(ModelError::UnknownNodeInput {
683 composition: composition.id.clone(),
684 node: node.id.clone(),
685 input: input.name(),
686 target: target.clone(),
687 });
688 }
689 }
690 }
691 if let Some(output) = &composition.output
692 && !positions.contains_key(output)
693 {
694 return Err(ModelError::UnknownCompositionOutput {
695 composition: composition.id.clone(),
696 node: output.clone(),
697 });
698 }
699 if let Some(node) = cycle_member(composition, &positions) {
700 return Err(ModelError::CompositionCycle {
701 composition: composition.id.clone(),
702 node: node.clone(),
703 });
704 }
705 }
706 if let Some(composition) = reference_cycle_member(compositions, &by_id, by_source) {
707 return Err(ModelError::CompositionUsesItself {
708 composition: composition.clone(),
709 });
710 }
711 validate_expansion(compositions, &by_id, sources)
712}
713
714pub(crate) type InlinedNodes<'a> = std::collections::HashMap<&'a CompositionId, u64>;
716
717fn validate_expansion<'a>(
721 compositions: &'a [Composition],
722 by_id: &std::collections::HashMap<&'a CompositionId, &'a Composition>,
723 sources: &'a [crate::MediaSource],
724) -> Result<InlinedNodes<'a>, crate::ModelError> {
725 use std::collections::HashMap;
726 let composition_sources: HashMap<&SourceId, &CompositionId> = sources
727 .iter()
728 .filter_map(|source| Some((&source.id, source.composition.as_ref()?)))
729 .collect();
730 let draws = |node: &'a CompositionNode| -> Option<&'a CompositionId> {
732 match &node.op {
733 NodeOp::Group { composition, .. } => Some(composition),
734 NodeOp::Media { source_id, .. } => composition_sources.get(source_id).copied(),
735 _ => None,
736 }
737 .filter(|id| by_id.contains_key(id))
738 };
739 let mut walked: HashMap<&CompositionId, (usize, u64)> = HashMap::new();
741 let mut total = 0_u64;
742 for root in compositions {
743 let mut stack = vec![(root, false)];
744 while let Some((composition, expanded)) = stack.pop() {
745 if walked.contains_key(&composition.id) {
746 continue;
747 }
748 if !expanded {
749 stack.push((composition, true));
750 stack.extend(
751 composition
752 .nodes
753 .iter()
754 .filter_map(draws)
755 .filter(|id| !walked.contains_key(id))
756 .map(|id| (by_id[id], false)),
757 );
758 continue;
759 }
760 let mut depth = 1;
761 let mut nodes = 0_u64;
762 for node in &composition.nodes {
763 let (inner, count) = draws(node).map_or((0, 1), |id| walked[id]);
764 depth = depth.max(inner + 1);
765 nodes = nodes.saturating_add(count);
766 }
767 walked.insert(&composition.id, (depth, nodes));
768 }
769 let (depth, nodes) = walked[&root.id];
770 if depth > MAX_COMPOSITION_DEPTH {
771 return Err(crate::ModelError::CompositionNestedTooDeep {
772 composition: root.id.clone(),
773 maximum: MAX_COMPOSITION_DEPTH,
774 });
775 }
776 total = total.saturating_add(nodes);
777 if total > MAX_INLINED_NODES as u64 {
778 return Err(crate::ModelError::TooManyInlinedNodes {
779 maximum: MAX_INLINED_NODES,
780 });
781 }
782 }
783 Ok(walked
784 .into_iter()
785 .map(|(id, (_, nodes))| (id, nodes))
786 .collect())
787}
788
789fn validate_ports(
792 composition: &Composition,
793 positions: &std::collections::HashMap<&NodeId, usize>,
794) -> Result<(), crate::ModelError> {
795 use std::collections::HashSet;
796 let invalid = || crate::ModelError::InvalidCompositionPorts {
797 composition: composition.id.clone(),
798 };
799 let mut names = HashSet::new();
800 if composition.inputs.len() > MAX_GROUP_INPUTS
801 || composition
802 .inputs
803 .iter()
804 .any(|name| name.is_empty() || !names.insert(name))
805 {
806 return Err(invalid());
807 }
808 let mut controls = HashSet::new();
809 let mut settings = HashSet::new();
810 if composition.controls.len() > MAX_COMPOSITION_CONTROLS {
811 return Err(invalid());
812 }
813 for control in &composition.controls {
814 let node = positions
815 .get(&control.node)
816 .map(|position| &composition.nodes[*position]);
817 if control.name.is_empty()
818 || control.param.is_empty()
819 || !controls.insert(&control.name)
820 || !settings.insert((&control.node, &control.param))
821 || node.is_none_or(|node| node.op.effect().is_none())
822 {
823 return Err(invalid());
824 }
825 }
826 Ok(())
827}
828
829fn reference_cycle_member<'a>(
832 compositions: &'a [Composition],
833 by_id: &std::collections::HashMap<&'a CompositionId, &'a Composition>,
834 sources: &std::collections::HashMap<&SourceId, &'a crate::MediaSource>,
835) -> Option<&'a CompositionId> {
836 #[derive(Clone, Copy, PartialEq)]
837 enum Mark {
838 New,
839 Open,
840 Done,
841 }
842 let uses = |composition: &'a Composition| -> Vec<&'a CompositionId> {
843 composition
844 .nodes
845 .iter()
846 .filter_map(|node| match &node.op {
847 NodeOp::Group { composition, .. } => Some(composition),
848 NodeOp::Media { source_id, .. } => sources
849 .get(source_id)
850 .and_then(|source| source.composition.as_ref()),
851 _ => None,
852 })
853 .filter(|id| by_id.contains_key(id))
854 .collect()
855 };
856 let mut marks: std::collections::HashMap<&CompositionId, Mark> = compositions
857 .iter()
858 .map(|composition| (&composition.id, Mark::New))
859 .collect();
860 for root in compositions {
861 if marks[&root.id] != Mark::New {
862 continue;
863 }
864 let mut stack = vec![(&root.id, uses(root), 0_usize)];
865 marks.insert(&root.id, Mark::Open);
866 while let Some((current, next, at)) = stack.last_mut() {
867 let Some(target) = next.get(*at).copied() else {
868 marks.insert(current, Mark::Done);
869 stack.pop();
870 continue;
871 };
872 *at += 1;
873 match marks[target] {
874 Mark::Open => return Some(target),
875 Mark::New => {
876 marks.insert(target, Mark::Open);
877 stack.push((target, uses(by_id[target]), 0));
878 }
879 Mark::Done => {}
880 }
881 }
882 }
883 None
884}
885
886fn shape_error(
887 composition: &Composition,
888 node: &CompositionNode,
889 issue: crate::ShapeIssue,
890) -> crate::ModelError {
891 crate::ModelError::InvalidShape {
892 owner: crate::ShapeOwner::Node {
893 composition: composition.id.clone(),
894 node: node.id.clone(),
895 },
896 at: issue.at,
897 problem: issue.problem,
898 }
899}
900
901fn layout_error(
902 composition: &Composition,
903 node: &CompositionNode,
904 issue: crate::LayoutIssue,
905) -> crate::ModelError {
906 crate::ModelError::InvalidLayout {
907 owner: crate::ShapeOwner::Node {
908 composition: composition.id.clone(),
909 node: node.id.clone(),
910 },
911 at: issue.at,
912 problem: issue.problem,
913 }
914}
915
916fn validate_node(
917 composition: &Composition,
918 node: &CompositionNode,
919 sources: &std::collections::HashMap<&SourceId, &crate::MediaSource>,
920 compositions: &std::collections::HashMap<&CompositionId, &Composition>,
921 effect_resources: &std::collections::HashSet<&crate::EffectResourceId>,
922 canvas: (u32, u32),
923) -> Result<(), crate::ModelError> {
924 use crate::ModelError;
925
926 let invalid = || ModelError::InvalidNode {
927 composition: composition.id.clone(),
928 node: node.id.clone(),
929 };
930 let unit = |animation: &Animated<f32>| {
931 crate::animation::valid_scalar_values(animation, |value| (0.0..=1.0).contains(&value))
932 };
933 let shape_error = |issue| shape_error(composition, node, issue);
934 if node.bypass && !node.op.has_inputs() {
935 return Err(invalid());
936 }
937 let valid = match &node.op {
938 NodeOp::Clip {} | NodeOp::Matte { .. } => true,
939 NodeOp::Media { source_id, start } => {
940 let visual = sources.get(source_id).is_some_and(|source| {
941 matches!(
942 source.kind,
943 crate::MediaKind::Video
944 | crate::MediaKind::Image
945 | crate::MediaKind::Generated
946 | crate::MediaKind::Composition
947 )
948 });
949 if !visual {
950 return Err(ModelError::InvalidNodeSource {
951 composition: composition.id.clone(),
952 node: node.id.clone(),
953 source: source_id.clone(),
954 });
955 }
956 start.ticks() >= 0
957 }
958 NodeOp::Generator { generator } => match generator.validate_on(canvas.0, canvas.1) {
959 Ok(()) => true,
960 Err(crate::GeneratorIssue::Shape(issue)) => return Err(shape_error(issue)),
961 Err(crate::GeneratorIssue::Layout(issue)) => {
962 return Err(layout_error(composition, node, issue));
963 }
964 Err(crate::GeneratorIssue::Invalid) => false,
965 },
966 NodeOp::Effect { effect, inputs, .. } => {
967 effect.is_valid()
968 && inputs.len() <= MAX_EFFECT_INPUTS
969 && inputs
970 .keys()
971 .all(|name| crate::effect::valid_binding_name(name))
972 }
973 NodeOp::Transition {
974 effect, progress, ..
975 } => effect.is_valid() && unit(progress),
976 NodeOp::Transform { transform, .. } => transform.validate(),
977 NodeOp::Merge { layers } => {
978 layers.len() <= MAX_MERGE_LAYERS && layers.iter().all(|layer| unit(&layer.opacity))
979 }
980 NodeOp::ShapeMask { masks, .. } => {
981 crate::mask::validate_masks(masks).map_err(shape_error)?;
982 masks.len() <= crate::MAX_CLIP_MASKS
983 }
984 NodeOp::Group {
985 composition: target,
986 inputs,
987 controls,
988 } => {
989 let Some(target) = compositions.get(target) else {
990 return Err(ModelError::UnknownGroupComposition {
991 composition: composition.id.clone(),
992 node: node.id.clone(),
993 target: target.clone(),
994 });
995 };
996 inputs.len() == target.inputs.len()
997 && controls.iter().all(|(name, value)| {
998 target.controls.iter().any(|control| &control.name == name)
999 && crate::effect::valid_param(value)
1000 })
1001 }
1002 NodeOp::GroupInput { index } => {
1003 usize::try_from(*index).is_ok_and(|index| index < composition.inputs.len())
1004 }
1005 };
1006 if !valid {
1007 return Err(invalid());
1008 }
1009 if let Some(effect) = node.op.effect()
1010 && let Some(resource) = effect
1011 .resources
1012 .values()
1013 .find(|resource| !effect_resources.contains(resource))
1014 {
1015 return Err(ModelError::UnknownNodeResource {
1016 composition: composition.id.clone(),
1017 node: node.id.clone(),
1018 resource: resource.clone(),
1019 });
1020 }
1021 Ok(())
1022}
1023
1024fn cycle_member<'a>(
1027 composition: &'a Composition,
1028 positions: &std::collections::HashMap<&'a NodeId, usize>,
1029) -> Option<&'a NodeId> {
1030 #[derive(Clone, Copy, PartialEq)]
1031 enum Mark {
1032 New,
1033 Open,
1034 Done,
1035 }
1036 let nodes = &composition.nodes;
1037 let mut marks = vec![Mark::New; nodes.len()];
1038 for root in 0..nodes.len() {
1039 if marks[root] != Mark::New {
1040 continue;
1041 }
1042 let mut stack = vec![(root, 0_usize)];
1044 marks[root] = Mark::Open;
1045 while let Some((current, next)) = stack.last_mut() {
1046 let inputs = nodes[*current].op.inputs();
1047 let Some(input) = inputs.get(*next) else {
1048 marks[*current] = Mark::Done;
1049 stack.pop();
1050 continue;
1051 };
1052 *next += 1;
1053 let Some(target) = input.node().and_then(|id| positions.get(id)) else {
1054 continue;
1055 };
1056 match marks[*target] {
1057 Mark::Open => return Some(&nodes[*target].id),
1058 Mark::New => {
1059 marks[*target] = Mark::Open;
1060 stack.push((*target, 0));
1061 }
1062 Mark::Done => {}
1063 }
1064 }
1065 }
1066 None
1067}
1068
1069#[cfg(test)]
1070#[path = "composition_tests.rs"]
1071mod tests;