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