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;
24pub const MAX_TRACKS: usize = 256;
26pub const MAX_CLIPS: usize = 100_000;
28pub const MAX_CLIPS_PER_TRACK: usize = 4_096;
30pub const MAX_TRANSITIONS_PER_TRACK: usize = 4_096;
32
33#[derive(Clone, Debug, PartialEq, Serialize)]
35pub struct Project {
36 pub schema_version: u32,
38 pub id: ProjectId,
40 pub name: String,
42 pub settings: ProjectSettings,
44 pub sources: Vec<MediaSource>,
46 #[serde(default)]
48 pub effect_resources: Vec<EffectResource>,
49 #[serde(default)]
51 pub compositions: Vec<Composition>,
52 pub timeline: Timeline,
54 #[serde(default)]
56 pub audio: MasterAudio,
57}
58
59#[derive(Deserialize)]
65#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
66#[cfg_attr(feature = "schema", schemars(rename = "Project"))]
67#[serde(deny_unknown_fields)]
68pub(crate) struct ProjectWire {
69 #[cfg_attr(feature = "schema", schemars(extend("const" = CURRENT_SCHEMA_VERSION)))]
72 pub(crate) schema_version: u32,
73 pub(crate) id: ProjectId,
75 pub(crate) name: String,
77 pub(crate) settings: ProjectSettings,
79 pub(crate) sources: Vec<MediaSource>,
81 #[serde(default)]
83 pub(crate) effect_resources: Vec<EffectResource>,
84 #[serde(default)]
86 pub(crate) compositions: Vec<Composition>,
87 pub(crate) timeline: Timeline,
89 #[serde(default)]
91 pub(crate) audio: MasterAudio,
92}
93
94impl TryFrom<ProjectWire> for Project {
95 type Error = ModelError;
96
97 fn try_from(wire: ProjectWire) -> Result<Self, Self::Error> {
98 let project = Self {
99 schema_version: wire.schema_version,
100 id: wire.id,
101 name: wire.name,
102 settings: wire.settings,
103 sources: wire.sources,
104 effect_resources: wire.effect_resources,
105 compositions: wire.compositions,
106 timeline: wire.timeline,
107 audio: wire.audio,
108 };
109 project.validate()?;
110 Ok(project)
111 }
112}
113
114impl<'de> Deserialize<'de> for Project {
115 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
116 where
117 D: Deserializer<'de>,
118 {
119 let wire = ProjectWire::deserialize(deserializer)?;
120 Self::try_from(wire).map_err(D::Error::custom)
121 }
122}
123
124impl Project {
125 pub fn from_json(json: &str) -> Result<Self, ProjectLoadError> {
135 #[derive(Deserialize)]
138 struct VersionProbe {
139 schema_version: u32,
140 }
141
142 if json.len() > MAX_PROJECT_JSON_BYTES {
143 return Err(ProjectLoadError::TooLarge {
144 bytes: json.len(),
145 maximum: MAX_PROJECT_JSON_BYTES,
146 });
147 }
148 if let Ok(VersionProbe { schema_version }) = serde_json::from_str(json)
149 && schema_version != CURRENT_SCHEMA_VERSION
150 {
151 return Err(ProjectLoadError::InvalidProject(
152 ModelError::UnsupportedSchemaVersion {
153 found: schema_version,
154 },
155 ));
156 }
157 let wire = serde_json::from_str::<ProjectWire>(json).map_err(ProjectLoadError::Json)?;
158 Self::try_from(wire).map_err(ProjectLoadError::InvalidProject)
159 }
160
161 #[must_use]
176 pub fn used_sources(&self) -> Vec<&MediaSource> {
177 let sources = self
178 .sources
179 .iter()
180 .map(|source| (&source.id, source))
181 .collect::<HashMap<_, _>>();
182 let compositions = self
183 .compositions
184 .iter()
185 .map(|composition| (&composition.id, composition))
186 .collect::<HashMap<_, _>>();
187 let reads = crate::CompositionReads::new(&compositions);
188 let mut used = HashSet::new();
189 let mut walked = HashSet::new();
190 let mut pending = Vec::new();
191 for clip in self.timeline.tracks.iter().flat_map(|track| &track.clips) {
192 used.insert(&clip.source_id);
193 let drawn = clip
196 .composition
197 .as_ref()
198 .is_none_or(|reference| reads.draws_clip(&reference.id));
199 pending.extend(
200 sources
201 .get(&clip.source_id)
202 .and_then(|source| source.composition.as_ref())
203 .filter(|_| drawn),
204 );
205 pending.extend(clip.composition.as_ref().map(|reference| &reference.id));
206 }
207 while let Some(id) = pending.pop() {
210 if !walked.insert(id) {
211 continue;
212 }
213 let Some(composition) = compositions.get(id) else {
214 continue;
215 };
216 let nodes = composition
217 .nodes
218 .iter()
219 .map(|node| (&node.id, node))
220 .collect::<HashMap<_, _>>();
221 let mut reached = HashSet::new();
222 let mut frontier = composition.output.iter().collect::<Vec<_>>();
223 while let Some(node) = frontier.pop() {
224 let Some(node) = nodes.get(node).filter(|_| reached.insert(node)) else {
225 continue;
226 };
227 match &node.op {
228 crate::NodeOp::Media { source_id, .. } => {
229 used.insert(source_id);
230 pending.extend(
231 sources
232 .get(source_id)
233 .and_then(|source| source.composition.as_ref()),
234 );
235 }
236 crate::NodeOp::Group { composition, .. } => pending.push(composition),
237 _ => {}
238 }
239 frontier.extend(
240 reads
241 .needed_inputs(node)
242 .into_iter()
243 .filter_map(crate::NodeInput::node),
244 );
245 }
246 }
247 self.sources
248 .iter()
249 .filter(|source| used.contains(&source.id))
250 .collect()
251 }
252
253 #[must_use]
257 pub fn hdr_sources(&self) -> Vec<&alpha2_media::SourceId> {
258 self.sources
259 .iter()
260 .filter(|source| source.effective_color_metadata().is_hdr())
261 .map(|source| &source.id)
262 .collect()
263 }
264
265 #[must_use]
269 pub fn draws_text(&self) -> bool {
270 let is_text =
271 |generator: &crate::Generator| matches!(generator, crate::Generator::Text { .. });
272 self.timeline
273 .tracks
274 .iter()
275 .any(|track| track.captions.is_some())
276 || self
277 .sources
278 .iter()
279 .any(|source| source.generator.as_ref().is_some_and(is_text))
280 || self.compositions.iter().any(|composition| {
281 composition.nodes.iter().any(|node| {
282 matches!(&node.op, crate::NodeOp::Generator { generator } if is_text(generator))
283 })
284 })
285 }
286
287 #[must_use]
289 pub fn new(id: ProjectId, name: impl Into<String>, settings: ProjectSettings) -> Self {
290 Self {
291 schema_version: CURRENT_SCHEMA_VERSION,
292 id,
293 name: name.into(),
294 settings,
295 sources: Vec::new(),
296 effect_resources: Vec::new(),
297 compositions: Vec::new(),
298 timeline: Timeline { tracks: Vec::new() },
299 audio: MasterAudio::default(),
300 }
301 }
302
303 pub fn validate(&self) -> Result<(), ModelError> {
309 if self.schema_version != CURRENT_SCHEMA_VERSION {
310 return Err(ModelError::UnsupportedSchemaVersion {
311 found: self.schema_version,
312 });
313 }
314 self.settings.validate()?;
315 ensure_id(&self.id)?;
316 if !self.audio.validate() {
317 return Err(ModelError::InvalidMasterAudio);
318 }
319 if self.sources.len() > MAX_CLIPS {
320 return Err(ModelError::TooManySources {
321 found: self.sources.len(),
322 maximum: MAX_CLIPS,
323 });
324 }
325 if self.timeline.tracks.len() > MAX_TRACKS {
326 return Err(ModelError::TooManyTracks {
327 found: self.timeline.tracks.len(),
328 maximum: MAX_TRACKS,
329 });
330 }
331
332 let sources = self.validate_sources()?;
333 let effect_resource_ids = self.validate_effect_resources()?;
334 let inlined = crate::composition::validate_compositions(
335 &self.compositions,
336 &self.sources,
337 &sources,
338 &effect_resource_ids,
339 )?;
340 let index = DocumentIndex {
341 sources,
342 compositions: self
343 .compositions
344 .iter()
345 .map(|composition| &composition.id)
346 .collect(),
347 };
348
349 let mut track_ids = HashSet::new();
350 let mut clip_ids = HashSet::new();
351 let mut clip_count = 0_usize;
352 for track in &self.timeline.tracks {
353 ensure_id(&track.id)?;
354 if !track_ids.insert(&track.id) {
355 return Err(ModelError::DuplicateTrackId(track.id.clone()));
356 }
357 if track.clips.len() > MAX_CLIPS_PER_TRACK {
358 return Err(ModelError::TooManyClipsOnTrack {
359 track: track.id.clone(),
360 found: track.clips.len(),
361 maximum: MAX_CLIPS_PER_TRACK,
362 });
363 }
364 validate_captions(track)?;
365 if track.transitions.len() > MAX_TRANSITIONS_PER_TRACK {
366 return Err(ModelError::TooManyTransitions {
367 track: track.id.clone(),
368 found: track.transitions.len(),
369 maximum: MAX_TRANSITIONS_PER_TRACK,
370 });
371 }
372 clip_count =
373 clip_count
374 .checked_add(track.clips.len())
375 .ok_or(ModelError::TooManyClips {
376 found: usize::MAX,
377 maximum: MAX_CLIPS,
378 })?;
379 if clip_count > MAX_CLIPS {
380 return Err(ModelError::TooManyClips {
381 found: clip_count,
382 maximum: MAX_CLIPS,
383 });
384 }
385 for clip in &track.clips {
386 validate_clip(&index, track, clip, &mut clip_ids)?;
387 }
388 for transition in &track.transitions {
389 ensure_effect_resources_exist(
390 &transition.effect,
391 &effect_resource_ids,
392 &transition.to_clip,
393 )?;
394 }
395 ensure_transitions_are_supported(track)?;
396 if !track.audio.validate() {
397 return Err(ModelError::InvalidTrackAudio {
398 track: track.id.clone(),
399 });
400 }
401 ensure_overlaps_have_transitions(track)?;
402 }
403 self.ensure_audio_effects_fit()?;
404 ensure_ducking_is_acyclic(&self.timeline)?;
405 ensure_mattes_are_usable(&self.timeline)?;
406 ensure_frames_fit(&self.timeline, &index.sources, &inlined)?;
407 Ok(())
408 }
409
410 fn validate_sources(
412 &self,
413 ) -> Result<HashMap<&alpha2_media::SourceId, &MediaSource>, ModelError> {
414 let mut sources = HashMap::with_capacity(self.sources.len());
415 for source in &self.sources {
416 ensure_id(&source.id)?;
417 validate_source_content(source)?;
418 if sources.insert(&source.id, source).is_some() {
419 return Err(ModelError::DuplicateSourceId(source.id.clone()));
420 }
421 }
422 Ok(sources)
423 }
424
425 fn ensure_audio_effects_fit(&self) -> Result<(), ModelError> {
429 let load = self.audio_effect_load();
430 if load.effects > crate::MAX_AUDIO_EFFECTS {
431 return Err(ModelError::TooManyAudioEffects {
432 found: load.effects,
433 maximum: crate::MAX_AUDIO_EFFECTS,
434 at_ticks: load.effects_at_ticks,
435 });
436 }
437 if load.delay_ms() > u64::from(crate::MAX_AUDIO_DELAY_MS) {
438 return Err(ModelError::TooMuchAudioDelay {
439 found_ms: load.delay_ms(),
440 maximum_ms: crate::MAX_AUDIO_DELAY_MS,
441 at_ticks: load.delay_at_ticks,
442 });
443 }
444 Ok(())
445 }
446
447 fn validate_effect_resources(&self) -> Result<HashSet<&EffectResourceId>, ModelError> {
448 let mut ids = HashSet::new();
449 for resource in &self.effect_resources {
450 ensure_id(&resource.id)?;
451 if resource.locator.is_empty() {
452 return Err(ModelError::EmptyEffectResourceLocator {
453 resource: resource.id.clone(),
454 });
455 }
456 if !ids.insert(&resource.id) {
457 return Err(ModelError::DuplicateEffectResourceId(resource.id.clone()));
458 }
459 }
460 Ok(ids)
461 }
462}
463
464fn ensure_effect_resources_exist(
465 effect: &crate::ClipEffect,
466 known: &HashSet<&EffectResourceId>,
467 clip: &crate::ClipId,
468) -> Result<(), ModelError> {
469 for resource in effect.resources.values() {
470 if !known.contains(resource) {
471 return Err(ModelError::UnknownEffectResource {
472 clip: clip.clone(),
473 resource: resource.clone(),
474 });
475 }
476 }
477 Ok(())
478}
479
480fn validate_clip<'a>(
483 index: &DocumentIndex<'a>,
484 track: &'a crate::Track,
485 clip: &'a crate::Clip,
486 clip_ids: &mut HashSet<&'a crate::ClipId>,
487) -> Result<(), ModelError> {
488 ensure_id(&clip.id)?;
489 if clip.timeline_range.is_empty() || clip.source_range.is_empty() {
490 return Err(ModelError::EmptyClipRange {
491 clip: clip.id.clone(),
492 });
493 }
494 if clip.timeline_range.start().ticks() < 0 || clip.source_range.start().ticks() < 0 {
495 return Err(ModelError::NegativeClipRange {
496 clip: clip.id.clone(),
497 });
498 }
499 if !clip_ids.insert(&clip.id) {
500 return Err(ModelError::DuplicateClipId(clip.id.clone()));
501 }
502 let Some(source) = index.sources.get(&clip.source_id) else {
503 return Err(ModelError::UnknownClipSource {
504 clip: clip.id.clone(),
505 source: clip.source_id.clone(),
506 });
507 };
508 if !track.kind.accepts(source.kind) {
509 return Err(ModelError::IncompatibleClipSource {
510 clip: clip.id.clone(),
511 track: track.id.clone(),
512 source: source.id.clone(),
513 });
514 }
515 if !clip.visual.validate() {
516 return Err(ModelError::InvalidVisualProperties {
517 clip: clip.id.clone(),
518 });
519 }
520 if let Some(composition) = &clip.composition {
521 let held = || {
525 let last = MediaTime::from_ticks(clip.timeline_range.end().ticks().saturating_sub(1));
526 composition
527 .local_time(clip.timeline_range.start(), last)
528 .is_some_and(|time| time.ticks() <= alpha2_time::MAX_SERIALIZED_TICKS)
529 };
530 if track.kind != TrackKind::Video || !composition.scale.is_valid() || !held() {
531 return Err(ModelError::InvalidClipComposition {
532 clip: clip.id.clone(),
533 });
534 }
535 if !index.compositions.contains(&composition.id) {
536 return Err(ModelError::UnknownComposition {
537 clip: clip.id.clone(),
538 composition: composition.id.clone(),
539 });
540 }
541 }
542 if !clip.audio.validate() {
543 return Err(ModelError::InvalidAudioProperties {
544 clip: clip.id.clone(),
545 });
546 }
547 let visual = track.kind == TrackKind::Video;
548 if clip.masks.len() > crate::MAX_CLIP_MASKS || (!visual && !clip.masks.is_empty()) {
549 return Err(ModelError::InvalidClipMask {
550 clip: clip.id.clone(),
551 });
552 }
553 crate::mask::validate_masks(&clip.masks).map_err(|issue| ModelError::InvalidShape {
554 owner: crate::ShapeOwner::Clip(clip.id.clone()),
555 at: issue.at,
556 problem: issue.problem,
557 })?;
558 if !visual && clip.matte.is_some() {
559 return Err(ModelError::InvalidMatte {
560 clip: clip.id.clone(),
561 });
562 }
563 if let crate::TimeMap::Freeze { source_time } = clip.time_map
564 && !clip.source_range.contains(source_time)
565 {
566 return Err(ModelError::InvalidTimeMap {
567 clip: clip.id.clone(),
568 });
569 }
570 Ok(())
571}
572
573struct DocumentIndex<'a> {
576 sources: HashMap<&'a alpha2_media::SourceId, &'a MediaSource>,
577 compositions: HashSet<&'a crate::CompositionId>,
578}
579
580fn ensure_transitions_are_supported(track: &crate::Track) -> Result<(), ModelError> {
583 let clips = track
584 .clips
585 .iter()
586 .map(|clip| (&clip.id, clip))
587 .collect::<HashMap<_, _>>();
588 let mut pairs = HashSet::with_capacity(track.transitions.len());
589 for transition in &track.transitions {
590 let covers = |id: &crate::ClipId| {
591 clips.get(id).is_some_and(|clip| {
592 clip.timeline_range.start() <= transition.timeline_range.start()
593 && clip.timeline_range.end() >= transition.timeline_range.end()
594 })
595 };
596 let valid = track.kind == TrackKind::Video
597 && transition.from_clip != transition.to_clip
598 && !transition.timeline_range.is_empty()
599 && transition.easing.is_valid()
600 && transition.effect.is_valid()
601 && covers(&transition.from_clip)
602 && covers(&transition.to_clip);
603 if !valid {
604 return Err(ModelError::InvalidClipTransition {
605 track: track.id.clone(),
606 });
607 }
608 let pair = if transition.from_clip < transition.to_clip {
611 (&transition.from_clip, &transition.to_clip)
612 } else {
613 (&transition.to_clip, &transition.from_clip)
614 };
615 if !pairs.insert(pair) {
616 return Err(ModelError::DuplicateTransition {
617 track: track.id.clone(),
618 first: transition.from_clip.clone(),
619 second: transition.to_clip.clone(),
620 });
621 }
622 }
623 Ok(())
624}
625
626fn ensure_ducking_is_acyclic(timeline: &Timeline) -> Result<(), ModelError> {
635 fn sidechain_of(track: &crate::Track) -> Option<&crate::TrackId> {
636 track
637 .audio
638 .ducking
639 .as_ref()
640 .map(|ducking| &ducking.sidechain_track)
641 }
642 let positions = timeline
644 .tracks
645 .iter()
646 .enumerate()
647 .map(|(position, track)| (&track.id, position))
648 .collect::<HashMap<_, _>>();
649 let next = timeline
650 .tracks
651 .iter()
652 .map(|track| sidechain_of(track).and_then(|sidechain| positions.get(sidechain).copied()))
653 .collect::<Vec<_>>();
654 let cyclic = tracks_on_cycles(&next);
655 for ((track, next), on_cycle) in timeline.tracks.iter().zip(next).zip(cyclic) {
656 let Some(sidechain) = sidechain_of(track) else {
657 continue;
658 };
659 if next.is_none() || on_cycle {
661 return Err(ModelError::InvalidDuckingSidechain {
662 track: track.id.clone(),
663 sidechain: sidechain.clone(),
664 });
665 }
666 }
667 Ok(())
668}
669
670fn tracks_on_cycles(next: &[Option<usize>]) -> Vec<bool> {
678 const UNVISITED: usize = usize::MAX;
679 let mut cyclic = vec![false; next.len()];
680 let mut walk_of = vec![UNVISITED; next.len()];
682 for start in 0..next.len() {
683 let mut cursor = Some(start);
684 while let Some(position) = cursor {
685 match walk_of[position] {
686 UNVISITED => {
687 walk_of[position] = start;
688 cursor = next[position];
689 }
690 walk if walk == start => {
691 let mut member = position;
693 loop {
694 cyclic[member] = true;
695 match next[member] {
696 Some(following) if following != position => member = following,
697 _ => break,
698 }
699 }
700 break;
701 }
702 _ => break,
703 }
704 }
705 }
706 cyclic
707}
708
709fn ensure_mattes_are_usable(timeline: &Timeline) -> Result<(), ModelError> {
716 let visual = timeline
717 .tracks
718 .iter()
719 .filter(|track| track.kind == TrackKind::Video)
720 .flat_map(|track| track.clips.iter().map(|clip| (&clip.id, clip)))
721 .collect::<HashMap<_, _>>();
722 let in_transition = timeline
723 .tracks
724 .iter()
725 .flat_map(|track| &track.transitions)
726 .flat_map(|transition| [&transition.from_clip, &transition.to_clip])
727 .collect::<HashSet<_>>();
728 let clips = timeline
730 .tracks
731 .iter()
732 .filter(|track| track.kind == TrackKind::Video)
733 .flat_map(|track| &track.clips);
734 for clip in clips {
735 let Some(matte) = &clip.matte else {
736 continue;
737 };
738 let usable = matte.source != clip.id
739 && visual
740 .get(&matte.source)
741 .is_some_and(|source| source.matte.is_none())
742 && !in_transition.contains(&matte.source);
743 if !usable {
744 return Err(ModelError::InvalidMatte {
745 clip: clip.id.clone(),
746 });
747 }
748 }
749 Ok(())
750}
751
752fn ensure_frames_fit(
761 timeline: &Timeline,
762 sources: &HashMap<&alpha2_media::SourceId, &MediaSource>,
763 inlined: &crate::composition::InlinedNodes<'_>,
764) -> Result<(), ModelError> {
765 let nodes = |id: Option<&crate::CompositionId>| id.and_then(|id| inlined.get(id)).copied();
766 let mut edges = Vec::new();
768 for clip in timeline.tracks.iter().flat_map(|track| &track.clips) {
769 let source = sources
770 .get(&clip.source_id)
771 .and_then(|source| source.composition.as_ref());
772 let drawn = nodes(clip.composition.as_ref().map(|reference| &reference.id))
773 .unwrap_or(0)
774 .saturating_add(nodes(source).unwrap_or(0));
775 if drawn > 0 {
776 edges.push((clip.timeline_range.start(), true, drawn, &clip.id));
777 edges.push((clip.timeline_range.end(), false, drawn, &clip.id));
778 }
779 }
780 edges.sort_by_key(|&(time, starts, ..)| (time, starts));
783 let mut drawn = 0_u64;
784 let mut edges = edges.into_iter().peekable();
785 while let Some((time, starts, nodes, clip)) = edges.next() {
786 if !starts {
787 drawn -= nodes;
788 continue;
789 }
790 drawn = drawn.saturating_add(nodes);
791 while let Some((_, _, nodes, _)) =
793 edges.next_if(|&(next, next_starts, ..)| next == time && next_starts)
794 {
795 drawn = drawn.saturating_add(nodes);
796 }
797 if drawn > crate::MAX_FRAME_INLINED_NODES as u64 {
798 return Err(ModelError::FrameDrawsTooManyNodes {
799 clip: clip.clone(),
800 found: drawn,
801 maximum: crate::MAX_FRAME_INLINED_NODES,
802 });
803 }
804 }
805 Ok(())
806}
807
808pub(crate) fn ensure_overlaps_have_transitions(track: &crate::Track) -> Result<(), ModelError> {
815 if track.kind != TrackKind::Video {
816 return Ok(());
817 }
818 let transitions = track
819 .transitions
820 .iter()
821 .flat_map(|transition| {
822 [
823 (
824 &transition.from_clip,
825 &transition.to_clip,
826 transition.timeline_range,
827 ),
828 (
829 &transition.to_clip,
830 &transition.from_clip,
831 transition.timeline_range,
832 ),
833 ]
834 })
835 .collect::<HashSet<_>>();
836 let directed = track
837 .transitions
838 .iter()
839 .map(|transition| (&transition.from_clip, &transition.to_clip))
840 .collect::<HashSet<_>>();
841 let mut ordered = track.clips.iter().collect::<Vec<_>>();
842 ordered.sort_by_key(|clip| (clip.timeline_range.start(), clip.timeline_range.end()));
843 let mut active = Vec::new();
844 for second in ordered {
845 active.retain(|first: &&crate::Clip| {
846 first.timeline_range.end() > second.timeline_range.start()
847 });
848 for first in &active {
849 let Some(intersection) = first.timeline_range.intersection(second.timeline_range)
850 else {
851 continue;
852 };
853 if !transitions.contains(&(&first.id, &second.id, intersection)) {
854 return Err(ModelError::OverlappingClips {
855 track: track.id.clone(),
856 first: first.id.clone(),
857 second: second.id.clone(),
858 });
859 }
860 }
861 active.push(second);
862 if active.len() >= 3 {
867 let mut leads = active
868 .iter()
869 .map(|from| {
870 active
871 .iter()
872 .filter(|to| directed.contains(&(&from.id, &to.id)))
873 .count()
874 })
875 .collect::<Vec<_>>();
876 leads.sort_unstable();
877 if leads.iter().enumerate().any(|(rank, lead)| rank != *lead) {
878 return Err(ModelError::TransitionCycle {
879 track: track.id.clone(),
880 at: second.timeline_range.start(),
881 });
882 }
883 }
884 }
885 Ok(())
886}
887
888fn validate_captions(track: &crate::Track) -> Result<(), ModelError> {
891 if let Some(captions) = &track.captions
892 && captions.cues.len() > MAX_CLIPS_PER_TRACK
893 {
894 return Err(ModelError::TooManyCaptionCues {
895 track: track.id.clone(),
896 found: captions.cues.len(),
897 maximum: MAX_CLIPS_PER_TRACK,
898 });
899 }
900 let valid = match (&track.kind, &track.captions) {
901 (TrackKind::Caption, Some(captions)) => {
902 captions.is_valid() && track.clips.is_empty() && track.transitions.is_empty()
903 }
904 (TrackKind::Caption, None) => false,
905 (_, captions) => captions.is_none(),
906 };
907 if valid {
908 Ok(())
909 } else {
910 Err(ModelError::InvalidCaptions {
911 track: track.id.clone(),
912 })
913 }
914}
915
916fn validate_source_content(source: &MediaSource) -> Result<(), ModelError> {
920 if let Some(fingerprint) = &source.fingerprint
921 && (source.kind == MediaKind::Generated || !fingerprint.is_valid())
922 {
923 return Err(ModelError::InvalidFingerprint {
924 source: source.id.clone(),
925 });
926 }
927 if source
928 .color_metadata
929 .is_some_and(|color| !color.is_consistent())
930 {
931 return Err(ModelError::ContradictoryColor {
932 source: source.id.clone(),
933 });
934 }
935 if let Some(provider) = &source.provider
936 && (provider.kind() != source.kind || source.has_audio || !provider.is_valid())
937 {
938 return Err(ModelError::InvalidProvider {
939 source: source.id.clone(),
940 });
941 }
942 if source.kind == MediaKind::Composition {
943 let valid = source.composition.is_some()
945 && source.generator.is_none()
946 && source.locator.is_empty()
947 && source.color_metadata.is_none()
948 && source.fingerprint.is_none()
949 && source.provider.is_none()
950 && !source.has_audio;
951 return if valid {
952 Ok(())
953 } else {
954 Err(ModelError::InvalidCompositionSource {
955 source: source.id.clone(),
956 })
957 };
958 }
959 if source.composition.is_some() {
960 return Err(ModelError::InvalidCompositionSource {
961 source: source.id.clone(),
962 });
963 }
964 if source.kind == MediaKind::Generated {
965 if let Some(Err(Some(issue))) = source.generator.as_ref().map(Generator::validate) {
966 return Err(ModelError::InvalidShape {
967 owner: crate::ShapeOwner::Source(source.id.clone()),
968 at: issue.at,
969 problem: issue.problem,
970 });
971 }
972 let valid = source.generator.as_ref().is_some_and(Generator::is_valid)
973 && source.locator.is_empty()
974 && source.color_metadata.is_none()
975 && !source.has_audio;
976 return if valid {
977 Ok(())
978 } else {
979 Err(ModelError::InvalidGenerator {
980 source: source.id.clone(),
981 })
982 };
983 }
984 if source.generator.is_some() {
985 return Err(ModelError::InvalidGenerator {
986 source: source.id.clone(),
987 });
988 }
989 if source.locator.is_empty() {
990 return Err(ModelError::EmptyLocator {
991 source: source.id.clone(),
992 });
993 }
994 if source.kind == MediaKind::Image && source.has_audio {
995 return Err(ModelError::AudioOnImage {
996 source: source.id.clone(),
997 });
998 }
999 Ok(())
1000}
1001
1002fn ensure_id<T: fmt::Display>(id: &T) -> Result<(), ModelError> {
1003 if id.to_string().is_empty() {
1004 Err(ModelError::EmptyIdentifier)
1005 } else {
1006 Ok(())
1007 }
1008}
1009
1010#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
1012#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1013#[serde(deny_unknown_fields)]
1014#[non_exhaustive]
1015pub struct ProjectSettings {
1016 pub width: u32,
1018 pub height: u32,
1021 pub frame_rate: FrameRate,
1023 pub sample_rate: u32,
1025 #[serde(default)]
1030 pub background: Rgb,
1031 #[serde(default)]
1034 pub blend_space: BlendSpace,
1035 #[serde(default)]
1037 pub color: ColorPipeline,
1038}
1039
1040#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1050#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1051#[serde(deny_unknown_fields)]
1052pub struct ColorPipeline {
1053 #[serde(default)]
1055 pub hdr: HdrSources,
1056 #[serde(default = "default_hdr_peak")]
1060 pub hdr_peak_nits: u32,
1061}
1062
1063pub const HDR_PEAK_NITS_RANGE: std::ops::RangeInclusive<u32> = 100..=10_000;
1065
1066const fn default_hdr_peak() -> u32 {
1067 1_000
1068}
1069
1070impl Default for ColorPipeline {
1071 fn default() -> Self {
1072 Self {
1073 hdr: HdrSources::default(),
1074 hdr_peak_nits: default_hdr_peak(),
1075 }
1076 }
1077}
1078
1079#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1081#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1082#[serde(rename_all = "snake_case")]
1083pub enum HdrSources {
1084 #[default]
1086 ToneMap,
1087 Reject,
1089}
1090
1091#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1098#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1099#[serde(rename_all = "snake_case")]
1100pub enum BlendSpace {
1101 #[default]
1103 LinearLight,
1104 SrgbEncoded,
1106}
1107
1108#[derive(Copy, Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
1111#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1112#[serde(deny_unknown_fields)]
1113pub struct Rgb {
1114 pub red: f32,
1116 pub green: f32,
1118 pub blue: f32,
1120}
1121
1122impl Rgb {
1123 pub const BLACK: Self = Self::new(0.0, 0.0, 0.0);
1125
1126 #[must_use]
1128 pub const fn new(red: f32, green: f32, blue: f32) -> Self {
1129 Self { red, green, blue }
1130 }
1131
1132 fn is_valid(self) -> bool {
1133 [self.red, self.green, self.blue]
1134 .into_iter()
1135 .all(|component| component.is_finite() && (0.0..=1.0).contains(&component))
1136 }
1137}
1138
1139impl ProjectSettings {
1140 pub fn new(
1145 width: u32,
1146 height: u32,
1147 frame_rate: FrameRate,
1148 sample_rate: u32,
1149 ) -> Result<Self, ModelError> {
1150 let settings = Self {
1151 width,
1152 height,
1153 frame_rate,
1154 sample_rate,
1155 background: Rgb::BLACK,
1156 blend_space: BlendSpace::LinearLight,
1157 color: ColorPipeline::default(),
1158 };
1159 settings.validate()?;
1160 Ok(settings)
1161 }
1162
1163 pub fn with_background(mut self, background: Rgb) -> Result<Self, ModelError> {
1169 self.background = background;
1170 self.validate()?;
1171 Ok(self)
1172 }
1173
1174 fn validate(&self) -> Result<(), ModelError> {
1176 if !HDR_PEAK_NITS_RANGE.contains(&self.color.hdr_peak_nits) {
1177 return Err(ModelError::InvalidColorPipeline);
1178 }
1179 if self.width == 0 || self.height == 0 {
1180 return Err(ModelError::ZeroCanvasDimension);
1181 }
1182 if self.width > MAX_CANVAS_DIMENSION || self.height > MAX_CANVAS_DIMENSION {
1183 return Err(ModelError::CanvasDimensionTooLarge {
1184 width: self.width,
1185 height: self.height,
1186 maximum: MAX_CANVAS_DIMENSION,
1187 });
1188 }
1189 let pixels = u64::from(self.width) * u64::from(self.height);
1190 if pixels > MAX_CANVAS_PIXELS {
1191 return Err(ModelError::CanvasPixelBudgetExceeded {
1192 pixels,
1193 maximum: MAX_CANVAS_PIXELS,
1194 });
1195 }
1196 if !self.background.is_valid() {
1197 return Err(ModelError::InvalidBackground);
1198 }
1199 if !SAMPLE_RATE_RANGE.contains(&self.sample_rate) {
1200 return Err(ModelError::UnsupportedSampleRate {
1201 found: self.sample_rate,
1202 minimum: *SAMPLE_RATE_RANGE.start(),
1203 maximum: *SAMPLE_RATE_RANGE.end(),
1204 });
1205 }
1206 Ok(())
1207 }
1208}