1use crate::{
2 Composition, EffectResource, EffectResourceId, Generator, MasterAudio, MediaKind, MediaSource,
3 ModelError, ProjectId, ProjectLoadError, Timeline, TrackKind,
4};
5use alpha2_time::{FrameRate, MediaTime};
6use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
7use std::collections::{HashMap, HashSet};
8use std::fmt;
9
10pub const CURRENT_SCHEMA_VERSION: u32 = 1;
12pub const MAX_PROJECT_JSON_BYTES: usize = 64 * 1024 * 1024;
14pub const MAX_CANVAS_DIMENSION: u32 = alpha2_media::MAX_FRAME_DIMENSION;
16pub const MAX_CANVAS_PIXELS: u64 = alpha2_media::MAX_FRAME_PIXELS;
18pub const SAMPLE_RATE_RANGE: std::ops::RangeInclusive<u32> = 8_000..=96_000;
23pub const MAX_TRACKS: usize = 256;
25pub const MAX_CLIPS: usize = 100_000;
27pub const MAX_CLIPS_PER_TRACK: usize = 4_096;
29pub const MAX_TRANSITIONS_PER_TRACK: usize = 4_096;
31
32#[derive(Clone, Debug, PartialEq, Serialize)]
34pub struct Project {
35 pub schema_version: u32,
37 pub id: ProjectId,
39 pub name: String,
41 pub settings: ProjectSettings,
43 pub sources: Vec<MediaSource>,
45 #[serde(default)]
47 pub effect_resources: Vec<EffectResource>,
48 #[serde(default)]
50 pub compositions: Vec<Composition>,
51 pub timeline: Timeline,
53 #[serde(default)]
55 pub audio: MasterAudio,
56}
57
58#[derive(Deserialize)]
63#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
64#[cfg_attr(feature = "schema", schemars(rename = "Project"))]
65#[serde(deny_unknown_fields)]
66pub(crate) struct ProjectWire {
67 #[cfg_attr(feature = "schema", schemars(extend("const" = CURRENT_SCHEMA_VERSION)))]
69 pub(crate) schema_version: u32,
70 pub(crate) id: ProjectId,
72 pub(crate) name: String,
74 pub(crate) settings: ProjectSettings,
76 pub(crate) sources: Vec<MediaSource>,
78 #[serde(default)]
80 pub(crate) effect_resources: Vec<EffectResource>,
81 #[serde(default)]
83 pub(crate) compositions: Vec<Composition>,
84 pub(crate) timeline: Timeline,
86 #[serde(default)]
88 pub(crate) audio: MasterAudio,
89}
90
91impl TryFrom<ProjectWire> for Project {
92 type Error = ModelError;
93
94 fn try_from(wire: ProjectWire) -> Result<Self, Self::Error> {
95 let project = Self {
96 schema_version: wire.schema_version,
97 id: wire.id,
98 name: wire.name,
99 settings: wire.settings,
100 sources: wire.sources,
101 effect_resources: wire.effect_resources,
102 compositions: wire.compositions,
103 timeline: wire.timeline,
104 audio: wire.audio,
105 };
106 project.validate()?;
107 Ok(project)
108 }
109}
110
111impl<'de> Deserialize<'de> for Project {
112 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
113 where
114 D: Deserializer<'de>,
115 {
116 let wire = ProjectWire::deserialize(deserializer)?;
117 Self::try_from(wire).map_err(D::Error::custom)
118 }
119}
120
121impl Project {
122 pub fn from_json(json: &str) -> Result<Self, ProjectLoadError> {
130 #[derive(Deserialize)]
133 struct VersionProbe {
134 schema_version: u32,
135 }
136
137 if json.len() > MAX_PROJECT_JSON_BYTES {
138 return Err(ProjectLoadError::TooLarge {
139 bytes: json.len(),
140 maximum: MAX_PROJECT_JSON_BYTES,
141 });
142 }
143 if let Ok(VersionProbe { schema_version }) = serde_json::from_str(json)
144 && schema_version != CURRENT_SCHEMA_VERSION
145 {
146 return Err(ProjectLoadError::InvalidProject(
147 ModelError::UnsupportedSchemaVersion {
148 found: schema_version,
149 },
150 ));
151 }
152 let wire = serde_json::from_str::<ProjectWire>(json).map_err(ProjectLoadError::Json)?;
153 Self::try_from(wire).map_err(ProjectLoadError::InvalidProject)
154 }
155
156 #[must_use]
165 pub fn used_sources(&self) -> Vec<&MediaSource> {
166 let sources = self
167 .sources
168 .iter()
169 .map(|source| (&source.id, source))
170 .collect::<HashMap<_, _>>();
171 let compositions = self
172 .compositions
173 .iter()
174 .map(|composition| (&composition.id, composition))
175 .collect::<HashMap<_, _>>();
176 let reads = crate::CompositionReads::new(&compositions);
177 let mut used = HashSet::new();
178 let mut walked = HashSet::new();
179 let mut pending = Vec::new();
180 for clip in self.timeline.tracks.iter().flat_map(|track| &track.clips) {
181 used.insert(&clip.source_id);
182 let drawn = clip
185 .composition
186 .as_ref()
187 .is_none_or(|reference| reads.draws_clip(&reference.id));
188 pending.extend(
189 sources
190 .get(&clip.source_id)
191 .and_then(|source| source.composition.as_ref())
192 .filter(|_| drawn),
193 );
194 pending.extend(clip.composition.as_ref().map(|reference| &reference.id));
195 }
196 while let Some(id) = pending.pop() {
199 if !walked.insert(id) {
200 continue;
201 }
202 let Some(composition) = compositions.get(id) else {
203 continue;
204 };
205 let nodes = composition
206 .nodes
207 .iter()
208 .map(|node| (&node.id, node))
209 .collect::<HashMap<_, _>>();
210 let mut reached = HashSet::new();
211 let mut frontier = composition.output.iter().collect::<Vec<_>>();
212 while let Some(node) = frontier.pop() {
213 let Some(node) = nodes.get(node).filter(|_| reached.insert(node)) else {
214 continue;
215 };
216 match &node.op {
217 crate::NodeOp::Media { source_id, .. } => {
218 used.insert(source_id);
219 pending.extend(
220 sources
221 .get(source_id)
222 .and_then(|source| source.composition.as_ref()),
223 );
224 }
225 crate::NodeOp::Group { composition, .. } => pending.push(composition),
226 _ => {}
227 }
228 frontier.extend(
229 reads
230 .needed_inputs(node)
231 .into_iter()
232 .filter_map(crate::NodeInput::node),
233 );
234 }
235 }
236 self.sources
237 .iter()
238 .filter(|source| used.contains(&source.id))
239 .collect()
240 }
241
242 #[must_use]
246 pub fn hdr_sources(&self) -> Vec<&alpha2_media::SourceId> {
247 self.sources
248 .iter()
249 .filter(|source| source.effective_color_metadata().is_hdr())
250 .map(|source| &source.id)
251 .collect()
252 }
253
254 #[must_use]
258 pub fn draws_text(&self) -> bool {
259 let is_text = |generator: &crate::Generator| {
260 matches!(
261 generator,
262 crate::Generator::Text { .. } | crate::Generator::Layout { .. }
263 )
264 };
265 self.timeline
266 .tracks
267 .iter()
268 .any(|track| track.captions.is_some())
269 || self
270 .sources
271 .iter()
272 .any(|source| source.generator.as_ref().is_some_and(is_text))
273 || self.compositions.iter().any(|composition| {
274 composition.nodes.iter().any(|node| {
275 matches!(&node.op, crate::NodeOp::Generator { generator } if is_text(generator))
276 })
277 })
278 }
279
280 #[must_use]
282 pub fn new(id: ProjectId, name: impl Into<String>, settings: ProjectSettings) -> Self {
283 Self {
284 schema_version: CURRENT_SCHEMA_VERSION,
285 id,
286 name: name.into(),
287 settings,
288 sources: Vec::new(),
289 effect_resources: Vec::new(),
290 compositions: Vec::new(),
291 timeline: Timeline { tracks: Vec::new() },
292 audio: MasterAudio::default(),
293 }
294 }
295
296 pub fn validate(&self) -> Result<(), ModelError> {
301 if self.schema_version != CURRENT_SCHEMA_VERSION {
302 return Err(ModelError::UnsupportedSchemaVersion {
303 found: self.schema_version,
304 });
305 }
306 self.settings.validate()?;
307 ensure_id(&self.id)?;
308 if !self.audio.validate() {
309 return Err(ModelError::InvalidMasterAudio);
310 }
311 if self.sources.len() > MAX_CLIPS {
312 return Err(ModelError::TooManySources {
313 found: self.sources.len(),
314 maximum: MAX_CLIPS,
315 });
316 }
317 if self.timeline.tracks.len() > MAX_TRACKS {
318 return Err(ModelError::TooManyTracks {
319 found: self.timeline.tracks.len(),
320 maximum: MAX_TRACKS,
321 });
322 }
323
324 let sources = self.validate_sources()?;
325 let effect_resource_ids = self.validate_effect_resources()?;
326 let inlined = crate::composition::validate_compositions(
327 &self.compositions,
328 &self.sources,
329 &sources,
330 &effect_resource_ids,
331 (self.settings.width, self.settings.height),
332 )?;
333 let index = DocumentIndex {
334 sources,
335 compositions: self
336 .compositions
337 .iter()
338 .map(|composition| &composition.id)
339 .collect(),
340 };
341
342 let mut track_ids = HashSet::new();
343 let mut clip_ids = HashSet::new();
344 let mut clip_count = 0_usize;
345 for track in &self.timeline.tracks {
346 ensure_id(&track.id)?;
347 if !track_ids.insert(&track.id) {
348 return Err(ModelError::DuplicateTrackId(track.id.clone()));
349 }
350 if track.clips.len() > MAX_CLIPS_PER_TRACK {
351 return Err(ModelError::TooManyClipsOnTrack {
352 track: track.id.clone(),
353 found: track.clips.len(),
354 maximum: MAX_CLIPS_PER_TRACK,
355 });
356 }
357 validate_captions(track)?;
358 if track.transitions.len() > MAX_TRANSITIONS_PER_TRACK {
359 return Err(ModelError::TooManyTransitions {
360 track: track.id.clone(),
361 found: track.transitions.len(),
362 maximum: MAX_TRANSITIONS_PER_TRACK,
363 });
364 }
365 clip_count =
366 clip_count
367 .checked_add(track.clips.len())
368 .ok_or(ModelError::TooManyClips {
369 found: usize::MAX,
370 maximum: MAX_CLIPS,
371 })?;
372 if clip_count > MAX_CLIPS {
373 return Err(ModelError::TooManyClips {
374 found: clip_count,
375 maximum: MAX_CLIPS,
376 });
377 }
378 for clip in &track.clips {
379 validate_clip(&index, track, clip, &mut clip_ids)?;
380 }
381 for transition in &track.transitions {
382 ensure_effect_resources_exist(
383 &transition.effect,
384 &effect_resource_ids,
385 &transition.to_clip,
386 )?;
387 }
388 ensure_transitions_are_supported(track)?;
389 if !track.audio.validate() {
390 return Err(ModelError::InvalidTrackAudio {
391 track: track.id.clone(),
392 });
393 }
394 ensure_overlaps_have_transitions(track)?;
395 }
396 self.ensure_audio_effects_fit()?;
397 ensure_ducking_is_acyclic(&self.timeline)?;
398 ensure_mattes_are_usable(&self.timeline)?;
399 ensure_frames_fit(&self.timeline, &index.sources, &inlined)?;
400 Ok(())
401 }
402
403 fn validate_sources(
405 &self,
406 ) -> Result<HashMap<&alpha2_media::SourceId, &MediaSource>, ModelError> {
407 let mut sources = HashMap::with_capacity(self.sources.len());
408 for source in &self.sources {
409 ensure_id(&source.id)?;
410 validate_source_content(source, (self.settings.width, self.settings.height))?;
411 if sources.insert(&source.id, source).is_some() {
412 return Err(ModelError::DuplicateSourceId(source.id.clone()));
413 }
414 }
415 Ok(sources)
416 }
417
418 fn ensure_audio_effects_fit(&self) -> Result<(), ModelError> {
421 let load = self.audio_effect_load();
422 if load.effects > crate::MAX_AUDIO_EFFECTS {
423 return Err(ModelError::TooManyAudioEffects {
424 found: load.effects,
425 maximum: crate::MAX_AUDIO_EFFECTS,
426 at_ticks: load.effects_at_ticks,
427 });
428 }
429 if load.delay_ms() > u64::from(crate::MAX_AUDIO_DELAY_MS) {
430 return Err(ModelError::TooMuchAudioDelay {
431 found_ms: load.delay_ms(),
432 maximum_ms: crate::MAX_AUDIO_DELAY_MS,
433 at_ticks: load.delay_at_ticks,
434 });
435 }
436 Ok(())
437 }
438
439 fn validate_effect_resources(&self) -> Result<HashSet<&EffectResourceId>, ModelError> {
440 let mut ids = HashSet::new();
441 for resource in &self.effect_resources {
442 ensure_id(&resource.id)?;
443 if resource.locator.is_empty() {
444 return Err(ModelError::EmptyEffectResourceLocator {
445 resource: resource.id.clone(),
446 });
447 }
448 if !ids.insert(&resource.id) {
449 return Err(ModelError::DuplicateEffectResourceId(resource.id.clone()));
450 }
451 }
452 Ok(ids)
453 }
454}
455
456fn ensure_effect_resources_exist(
457 effect: &crate::ClipEffect,
458 known: &HashSet<&EffectResourceId>,
459 clip: &crate::ClipId,
460) -> Result<(), ModelError> {
461 for resource in effect.resources.values() {
462 if !known.contains(resource) {
463 return Err(ModelError::UnknownEffectResource {
464 clip: clip.clone(),
465 resource: resource.clone(),
466 });
467 }
468 }
469 Ok(())
470}
471
472fn validate_clip<'a>(
474 index: &DocumentIndex<'a>,
475 track: &'a crate::Track,
476 clip: &'a crate::Clip,
477 clip_ids: &mut HashSet<&'a crate::ClipId>,
478) -> Result<(), ModelError> {
479 ensure_id(&clip.id)?;
480 if clip.timeline_range.is_empty() || clip.source_range.is_empty() {
481 return Err(ModelError::EmptyClipRange {
482 clip: clip.id.clone(),
483 });
484 }
485 if clip.timeline_range.start().ticks() < 0 || clip.source_range.start().ticks() < 0 {
486 return Err(ModelError::NegativeClipRange {
487 clip: clip.id.clone(),
488 });
489 }
490 if !clip_ids.insert(&clip.id) {
491 return Err(ModelError::DuplicateClipId(clip.id.clone()));
492 }
493 let Some(source) = index.sources.get(&clip.source_id) else {
494 return Err(ModelError::UnknownClipSource {
495 clip: clip.id.clone(),
496 source: clip.source_id.clone(),
497 });
498 };
499 if !track.kind.accepts(source.kind) {
500 return Err(ModelError::IncompatibleClipSource {
501 clip: clip.id.clone(),
502 track: track.id.clone(),
503 source: source.id.clone(),
504 });
505 }
506 if !clip.visual.validate() {
507 return Err(ModelError::InvalidVisualProperties {
508 clip: clip.id.clone(),
509 });
510 }
511 if let Some(composition) = &clip.composition {
512 let held = || {
515 let last = MediaTime::from_ticks(clip.timeline_range.end().ticks().saturating_sub(1));
516 composition
517 .local_time(clip.timeline_range.start(), last)
518 .is_some_and(|time| time.ticks() <= alpha2_time::MAX_SERIALIZED_TICKS)
519 };
520 if track.kind != TrackKind::Video || !composition.scale.is_valid() || !held() {
521 return Err(ModelError::InvalidClipComposition {
522 clip: clip.id.clone(),
523 });
524 }
525 if !index.compositions.contains(&composition.id) {
526 return Err(ModelError::UnknownComposition {
527 clip: clip.id.clone(),
528 composition: composition.id.clone(),
529 });
530 }
531 }
532 if !clip.audio.validate() {
533 return Err(ModelError::InvalidAudioProperties {
534 clip: clip.id.clone(),
535 });
536 }
537 let visual = track.kind == TrackKind::Video;
538 if clip.masks.len() > crate::MAX_CLIP_MASKS || (!visual && !clip.masks.is_empty()) {
539 return Err(ModelError::InvalidClipMask {
540 clip: clip.id.clone(),
541 });
542 }
543 crate::mask::validate_masks(&clip.masks).map_err(|issue| ModelError::InvalidShape {
544 owner: crate::ShapeOwner::Clip(clip.id.clone()),
545 at: issue.at,
546 problem: issue.problem,
547 })?;
548 if !visual && clip.matte.is_some() {
549 return Err(ModelError::InvalidMatte {
550 clip: clip.id.clone(),
551 });
552 }
553 if let crate::TimeMap::Freeze { source_time } = clip.time_map
554 && !clip.source_range.contains(source_time)
555 {
556 return Err(ModelError::InvalidTimeMap {
557 clip: clip.id.clone(),
558 });
559 }
560 Ok(())
561}
562
563struct DocumentIndex<'a> {
565 sources: HashMap<&'a alpha2_media::SourceId, &'a MediaSource>,
566 compositions: HashSet<&'a crate::CompositionId>,
567}
568
569fn ensure_transitions_are_supported(track: &crate::Track) -> Result<(), ModelError> {
572 let clips = track
573 .clips
574 .iter()
575 .map(|clip| (&clip.id, clip))
576 .collect::<HashMap<_, _>>();
577 let mut pairs = HashSet::with_capacity(track.transitions.len());
578 for transition in &track.transitions {
579 let covers = |id: &crate::ClipId| {
580 clips.get(id).is_some_and(|clip| {
581 clip.timeline_range.start() <= transition.timeline_range.start()
582 && clip.timeline_range.end() >= transition.timeline_range.end()
583 })
584 };
585 let valid = track.kind == TrackKind::Video
586 && transition.from_clip != transition.to_clip
587 && !transition.timeline_range.is_empty()
588 && transition.easing.is_valid()
589 && transition.effect.is_valid()
590 && covers(&transition.from_clip)
591 && covers(&transition.to_clip);
592 if !valid {
593 return Err(ModelError::InvalidClipTransition {
594 track: track.id.clone(),
595 });
596 }
597 let pair = if transition.from_clip < transition.to_clip {
600 (&transition.from_clip, &transition.to_clip)
601 } else {
602 (&transition.to_clip, &transition.from_clip)
603 };
604 if !pairs.insert(pair) {
605 return Err(ModelError::DuplicateTransition {
606 track: track.id.clone(),
607 first: transition.from_clip.clone(),
608 second: transition.to_clip.clone(),
609 });
610 }
611 }
612 Ok(())
613}
614
615fn ensure_ducking_is_acyclic(timeline: &Timeline) -> Result<(), ModelError> {
622 fn sidechain_of(track: &crate::Track) -> Option<&crate::TrackId> {
623 track
624 .audio
625 .ducking
626 .as_ref()
627 .map(|ducking| &ducking.sidechain_track)
628 }
629 let positions = timeline
631 .tracks
632 .iter()
633 .enumerate()
634 .map(|(position, track)| (&track.id, position))
635 .collect::<HashMap<_, _>>();
636 let next = timeline
637 .tracks
638 .iter()
639 .map(|track| sidechain_of(track).and_then(|sidechain| positions.get(sidechain).copied()))
640 .collect::<Vec<_>>();
641 let cyclic = tracks_on_cycles(&next);
642 for ((track, next), on_cycle) in timeline.tracks.iter().zip(next).zip(cyclic) {
643 let Some(sidechain) = sidechain_of(track) else {
644 continue;
645 };
646 if next.is_none() || on_cycle {
648 return Err(ModelError::InvalidDuckingSidechain {
649 track: track.id.clone(),
650 sidechain: sidechain.clone(),
651 });
652 }
653 }
654 Ok(())
655}
656
657fn tracks_on_cycles(next: &[Option<usize>]) -> Vec<bool> {
664 const UNVISITED: usize = usize::MAX;
665 let mut cyclic = vec![false; next.len()];
666 let mut walk_of = vec![UNVISITED; next.len()];
668 for start in 0..next.len() {
669 let mut cursor = Some(start);
670 while let Some(position) = cursor {
671 match walk_of[position] {
672 UNVISITED => {
673 walk_of[position] = start;
674 cursor = next[position];
675 }
676 walk if walk == start => {
677 let mut member = position;
679 loop {
680 cyclic[member] = true;
681 match next[member] {
682 Some(following) if following != position => member = following,
683 _ => break,
684 }
685 }
686 break;
687 }
688 _ => break,
689 }
690 }
691 }
692 cyclic
693}
694
695fn ensure_mattes_are_usable(timeline: &Timeline) -> Result<(), ModelError> {
701 let visual = timeline
702 .tracks
703 .iter()
704 .filter(|track| track.kind == TrackKind::Video)
705 .flat_map(|track| track.clips.iter().map(|clip| (&clip.id, clip)))
706 .collect::<HashMap<_, _>>();
707 let in_transition = timeline
708 .tracks
709 .iter()
710 .flat_map(|track| &track.transitions)
711 .flat_map(|transition| [&transition.from_clip, &transition.to_clip])
712 .collect::<HashSet<_>>();
713 let clips = timeline
715 .tracks
716 .iter()
717 .filter(|track| track.kind == TrackKind::Video)
718 .flat_map(|track| &track.clips);
719 for clip in clips {
720 let Some(matte) = &clip.matte else {
721 continue;
722 };
723 let usable = matte.source != clip.id
724 && visual
725 .get(&matte.source)
726 .is_some_and(|source| source.matte.is_none())
727 && !in_transition.contains(&matte.source);
728 if !usable {
729 return Err(ModelError::InvalidMatte {
730 clip: clip.id.clone(),
731 });
732 }
733 }
734 Ok(())
735}
736
737fn ensure_frames_fit(
744 timeline: &Timeline,
745 sources: &HashMap<&alpha2_media::SourceId, &MediaSource>,
746 inlined: &crate::composition::InlinedNodes<'_>,
747) -> Result<(), ModelError> {
748 let nodes = |id: Option<&crate::CompositionId>| id.and_then(|id| inlined.get(id)).copied();
749 let mut edges = Vec::new();
751 for clip in timeline.tracks.iter().flat_map(|track| &track.clips) {
752 let source = sources
753 .get(&clip.source_id)
754 .and_then(|source| source.composition.as_ref());
755 let drawn = nodes(clip.composition.as_ref().map(|reference| &reference.id))
756 .unwrap_or(0)
757 .saturating_add(nodes(source).unwrap_or(0));
758 if drawn > 0 {
759 edges.push((clip.timeline_range.start(), true, drawn, &clip.id));
760 edges.push((clip.timeline_range.end(), false, drawn, &clip.id));
761 }
762 }
763 edges.sort_by_key(|&(time, starts, ..)| (time, starts));
766 let mut drawn = 0_u64;
767 let mut edges = edges.into_iter().peekable();
768 while let Some((time, starts, nodes, clip)) = edges.next() {
769 if !starts {
770 drawn -= nodes;
771 continue;
772 }
773 drawn = drawn.saturating_add(nodes);
774 while let Some((_, _, nodes, _)) =
776 edges.next_if(|&(next, next_starts, ..)| next == time && next_starts)
777 {
778 drawn = drawn.saturating_add(nodes);
779 }
780 if drawn > crate::MAX_FRAME_INLINED_NODES as u64 {
781 return Err(ModelError::FrameDrawsTooManyNodes {
782 clip: clip.clone(),
783 found: drawn,
784 maximum: crate::MAX_FRAME_INLINED_NODES,
785 });
786 }
787 }
788 Ok(())
789}
790
791pub(crate) fn ensure_overlaps_have_transitions(track: &crate::Track) -> Result<(), ModelError> {
796 if track.kind != TrackKind::Video {
797 return Ok(());
798 }
799 let transitions = track
800 .transitions
801 .iter()
802 .flat_map(|transition| {
803 [
804 (
805 &transition.from_clip,
806 &transition.to_clip,
807 transition.timeline_range,
808 ),
809 (
810 &transition.to_clip,
811 &transition.from_clip,
812 transition.timeline_range,
813 ),
814 ]
815 })
816 .collect::<HashSet<_>>();
817 let directed = track
818 .transitions
819 .iter()
820 .map(|transition| (&transition.from_clip, &transition.to_clip))
821 .collect::<HashSet<_>>();
822 let mut ordered = track.clips.iter().collect::<Vec<_>>();
823 ordered.sort_by_key(|clip| (clip.timeline_range.start(), clip.timeline_range.end()));
824 let mut active = Vec::new();
825 for second in ordered {
826 active.retain(|first: &&crate::Clip| {
827 first.timeline_range.end() > second.timeline_range.start()
828 });
829 for first in &active {
830 let Some(intersection) = first.timeline_range.intersection(second.timeline_range)
831 else {
832 continue;
833 };
834 if !transitions.contains(&(&first.id, &second.id, intersection)) {
835 return Err(ModelError::OverlappingClips {
836 track: track.id.clone(),
837 first: first.id.clone(),
838 second: second.id.clone(),
839 });
840 }
841 }
842 active.push(second);
843 if active.len() >= 3 {
848 let mut leads = active
849 .iter()
850 .map(|from| {
851 active
852 .iter()
853 .filter(|to| directed.contains(&(&from.id, &to.id)))
854 .count()
855 })
856 .collect::<Vec<_>>();
857 leads.sort_unstable();
858 if leads.iter().enumerate().any(|(rank, lead)| rank != *lead) {
859 return Err(ModelError::TransitionCycle {
860 track: track.id.clone(),
861 at: second.timeline_range.start(),
862 });
863 }
864 }
865 }
866 Ok(())
867}
868
869fn validate_captions(track: &crate::Track) -> Result<(), ModelError> {
871 if let Some(captions) = &track.captions
872 && captions.cues.len() > MAX_CLIPS_PER_TRACK
873 {
874 return Err(ModelError::TooManyCaptionCues {
875 track: track.id.clone(),
876 found: captions.cues.len(),
877 maximum: MAX_CLIPS_PER_TRACK,
878 });
879 }
880 let valid = match (&track.kind, &track.captions) {
881 (TrackKind::Caption, Some(captions)) => {
882 if !(track.clips.is_empty() && track.transitions.is_empty()) {
883 return Err(ModelError::InvalidCaptions {
884 track: track.id.clone(),
885 });
886 }
887 match captions.validate() {
888 Ok(()) => true,
889 Err(crate::caption::CaptionIssue::Invalid) => false,
890 Err(crate::caption::CaptionIssue::UnknownStyle { cue, name }) => {
891 return Err(ModelError::UnknownCaptionStyle {
892 track: track.id.clone(),
893 cue,
894 name,
895 });
896 }
897 Err(crate::caption::CaptionIssue::CueStyle { cue }) => {
898 return Err(ModelError::InvalidCaptionCueStyle {
899 track: track.id.clone(),
900 cue,
901 });
902 }
903 }
904 }
905 (TrackKind::Caption, None) => false,
906 (_, captions) => captions.is_none(),
907 };
908 if valid {
909 Ok(())
910 } else {
911 Err(ModelError::InvalidCaptions {
912 track: track.id.clone(),
913 })
914 }
915}
916
917fn validate_source_content(source: &MediaSource, canvas: (u32, u32)) -> Result<(), ModelError> {
922 if let Some(fingerprint) = &source.fingerprint
923 && (source.kind == MediaKind::Generated || !fingerprint.is_valid())
924 {
925 return Err(ModelError::InvalidFingerprint {
926 source: source.id.clone(),
927 });
928 }
929 if source
930 .color_metadata
931 .is_some_and(|color| !color.is_consistent())
932 {
933 return Err(ModelError::ContradictoryColor {
934 source: source.id.clone(),
935 });
936 }
937 if let Some(provider) = &source.provider
938 && (provider.kind() != source.kind || source.has_audio || !provider.is_valid())
939 {
940 return Err(ModelError::InvalidProvider {
941 source: source.id.clone(),
942 });
943 }
944 if source.kind == MediaKind::Composition {
945 let valid = source.composition.is_some()
947 && source.generator.is_none()
948 && source.locator.is_empty()
949 && source.color_metadata.is_none()
950 && source.fingerprint.is_none()
951 && source.provider.is_none()
952 && !source.has_audio;
953 return if valid {
954 Ok(())
955 } else {
956 Err(ModelError::InvalidCompositionSource {
957 source: source.id.clone(),
958 })
959 };
960 }
961 if source.composition.is_some() {
962 return Err(ModelError::InvalidCompositionSource {
963 source: source.id.clone(),
964 });
965 }
966 if source.kind == MediaKind::Generated {
967 let owner = || crate::ShapeOwner::Source(source.id.clone());
968 match source
969 .generator
970 .as_ref()
971 .map(|generator| generator.validate_on(canvas.0, canvas.1))
972 {
973 Some(Err(crate::GeneratorIssue::Shape(issue))) => {
974 return Err(ModelError::InvalidShape {
975 owner: owner(),
976 at: issue.at,
977 problem: issue.problem,
978 });
979 }
980 Some(Err(crate::GeneratorIssue::Layout(issue))) => {
981 return Err(ModelError::InvalidLayout {
982 owner: owner(),
983 at: issue.at,
984 problem: issue.problem,
985 });
986 }
987 _ => {}
988 }
989 let valid = source.generator.as_ref().is_some_and(Generator::is_valid)
990 && source.locator.is_empty()
991 && source.color_metadata.is_none()
992 && !source.has_audio;
993 return if valid {
994 Ok(())
995 } else {
996 Err(ModelError::InvalidGenerator {
997 source: source.id.clone(),
998 })
999 };
1000 }
1001 if source.generator.is_some() {
1002 return Err(ModelError::InvalidGenerator {
1003 source: source.id.clone(),
1004 });
1005 }
1006 if let Some(checked) = validate_fileless_source(source) {
1007 return checked;
1008 }
1009 if source.locator.is_empty() {
1010 return Err(ModelError::EmptyLocator {
1011 source: source.id.clone(),
1012 });
1013 }
1014 if source.kind == MediaKind::Image && source.has_audio {
1015 return Err(ModelError::AudioOnImage {
1016 source: source.id.clone(),
1017 });
1018 }
1019 Ok(())
1020}
1021
1022fn validate_fileless_source(source: &MediaSource) -> Option<Result<(), ModelError>> {
1025 let provider = source.provider.as_ref()?;
1026 (!provider.reads_file()).then(|| {
1027 if source.locator.is_empty() && source.fingerprint.is_none() {
1028 Ok(())
1029 } else {
1030 Err(ModelError::InvalidProvider {
1031 source: source.id.clone(),
1032 })
1033 }
1034 })
1035}
1036
1037fn ensure_id<T: fmt::Display>(id: &T) -> Result<(), ModelError> {
1038 if id.to_string().is_empty() {
1039 Err(ModelError::EmptyIdentifier)
1040 } else {
1041 Ok(())
1042 }
1043}
1044
1045#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
1047#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1048#[serde(deny_unknown_fields)]
1049#[non_exhaustive]
1050pub struct ProjectSettings {
1051 pub width: u32,
1053 pub height: u32,
1056 pub frame_rate: FrameRate,
1058 pub sample_rate: u32,
1060 #[serde(default)]
1063 pub background: Rgb,
1064 #[serde(default)]
1066 pub blend_space: BlendSpace,
1067 #[serde(default)]
1069 pub color: ColorPipeline,
1070}
1071
1072#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1080#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1081#[serde(deny_unknown_fields)]
1082pub struct ColorPipeline {
1083 #[serde(default)]
1085 pub hdr: HdrSources,
1086 #[serde(default = "default_hdr_peak")]
1089 pub hdr_peak_nits: u32,
1090}
1091
1092pub const HDR_PEAK_NITS_RANGE: std::ops::RangeInclusive<u32> = 100..=10_000;
1094
1095const fn default_hdr_peak() -> u32 {
1096 1_000
1097}
1098
1099impl Default for ColorPipeline {
1100 fn default() -> Self {
1101 Self {
1102 hdr: HdrSources::default(),
1103 hdr_peak_nits: default_hdr_peak(),
1104 }
1105 }
1106}
1107
1108#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1110#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1111#[serde(rename_all = "snake_case")]
1112pub enum HdrSources {
1113 #[default]
1115 ToneMap,
1116 Reject,
1118}
1119
1120#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1126#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1127#[serde(rename_all = "snake_case")]
1128pub enum BlendSpace {
1129 #[default]
1131 LinearLight,
1132 SrgbEncoded,
1134}
1135
1136#[derive(Copy, Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
1138#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1139#[serde(deny_unknown_fields)]
1140pub struct Rgb {
1141 pub red: f32,
1143 pub green: f32,
1145 pub blue: f32,
1147}
1148
1149impl Rgb {
1150 pub const BLACK: Self = Self::new(0.0, 0.0, 0.0);
1152
1153 #[must_use]
1155 pub const fn new(red: f32, green: f32, blue: f32) -> Self {
1156 Self { red, green, blue }
1157 }
1158
1159 fn is_valid(self) -> bool {
1160 [self.red, self.green, self.blue]
1161 .into_iter()
1162 .all(|component| component.is_finite() && (0.0..=1.0).contains(&component))
1163 }
1164}
1165
1166impl ProjectSettings {
1167 pub fn new(
1172 width: u32,
1173 height: u32,
1174 frame_rate: FrameRate,
1175 sample_rate: u32,
1176 ) -> Result<Self, ModelError> {
1177 let settings = Self {
1178 width,
1179 height,
1180 frame_rate,
1181 sample_rate,
1182 background: Rgb::BLACK,
1183 blend_space: BlendSpace::LinearLight,
1184 color: ColorPipeline::default(),
1185 };
1186 settings.validate()?;
1187 Ok(settings)
1188 }
1189
1190 pub fn with_background(mut self, background: Rgb) -> Result<Self, ModelError> {
1195 self.background = background;
1196 self.validate()?;
1197 Ok(self)
1198 }
1199
1200 fn validate(&self) -> Result<(), ModelError> {
1201 if !HDR_PEAK_NITS_RANGE.contains(&self.color.hdr_peak_nits) {
1202 return Err(ModelError::InvalidColorPipeline);
1203 }
1204 if self.width == 0 || self.height == 0 {
1205 return Err(ModelError::ZeroCanvasDimension);
1206 }
1207 if self.width > MAX_CANVAS_DIMENSION || self.height > MAX_CANVAS_DIMENSION {
1208 return Err(ModelError::CanvasDimensionTooLarge {
1209 width: self.width,
1210 height: self.height,
1211 maximum: MAX_CANVAS_DIMENSION,
1212 });
1213 }
1214 let pixels = u64::from(self.width) * u64::from(self.height);
1215 if pixels > MAX_CANVAS_PIXELS {
1216 return Err(ModelError::CanvasPixelBudgetExceeded {
1217 pixels,
1218 maximum: MAX_CANVAS_PIXELS,
1219 });
1220 }
1221 if !self.background.is_valid() {
1222 return Err(ModelError::InvalidBackground);
1223 }
1224 if !SAMPLE_RATE_RANGE.contains(&self.sample_rate) {
1225 return Err(ModelError::UnsupportedSampleRate {
1226 found: self.sample_rate,
1227 minimum: *SAMPLE_RATE_RANGE.start(),
1228 maximum: *SAMPLE_RATE_RANGE.end(),
1229 });
1230 }
1231 Ok(())
1232 }
1233}