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
549 || (!visual && !clip.masks.is_empty())
550 || !clip.masks.iter().all(crate::ShapeMask::is_valid)
551 {
552 return Err(ModelError::InvalidClipMask {
553 clip: clip.id.clone(),
554 });
555 }
556 if !visual && clip.matte.is_some() {
557 return Err(ModelError::InvalidMatte {
558 clip: clip.id.clone(),
559 });
560 }
561 if let crate::TimeMap::Freeze { source_time } = clip.time_map
562 && !clip.source_range.contains(source_time)
563 {
564 return Err(ModelError::InvalidTimeMap {
565 clip: clip.id.clone(),
566 });
567 }
568 Ok(())
569}
570
571struct DocumentIndex<'a> {
574 sources: HashMap<&'a alpha2_media::SourceId, &'a MediaSource>,
575 compositions: HashSet<&'a crate::CompositionId>,
576}
577
578fn ensure_transitions_are_supported(track: &crate::Track) -> Result<(), ModelError> {
581 let clips = track
582 .clips
583 .iter()
584 .map(|clip| (&clip.id, clip))
585 .collect::<HashMap<_, _>>();
586 let mut pairs = HashSet::with_capacity(track.transitions.len());
587 for transition in &track.transitions {
588 let covers = |id: &crate::ClipId| {
589 clips.get(id).is_some_and(|clip| {
590 clip.timeline_range.start() <= transition.timeline_range.start()
591 && clip.timeline_range.end() >= transition.timeline_range.end()
592 })
593 };
594 let valid = track.kind == TrackKind::Video
595 && transition.from_clip != transition.to_clip
596 && !transition.timeline_range.is_empty()
597 && transition.easing.is_valid()
598 && transition.effect.is_valid()
599 && covers(&transition.from_clip)
600 && covers(&transition.to_clip);
601 if !valid {
602 return Err(ModelError::InvalidClipTransition {
603 track: track.id.clone(),
604 });
605 }
606 let pair = if transition.from_clip < transition.to_clip {
609 (&transition.from_clip, &transition.to_clip)
610 } else {
611 (&transition.to_clip, &transition.from_clip)
612 };
613 if !pairs.insert(pair) {
614 return Err(ModelError::DuplicateTransition {
615 track: track.id.clone(),
616 first: transition.from_clip.clone(),
617 second: transition.to_clip.clone(),
618 });
619 }
620 }
621 Ok(())
622}
623
624fn ensure_ducking_is_acyclic(timeline: &Timeline) -> Result<(), ModelError> {
633 fn sidechain_of(track: &crate::Track) -> Option<&crate::TrackId> {
634 track
635 .audio
636 .ducking
637 .as_ref()
638 .map(|ducking| &ducking.sidechain_track)
639 }
640 let positions = timeline
642 .tracks
643 .iter()
644 .enumerate()
645 .map(|(position, track)| (&track.id, position))
646 .collect::<HashMap<_, _>>();
647 let next = timeline
648 .tracks
649 .iter()
650 .map(|track| sidechain_of(track).and_then(|sidechain| positions.get(sidechain).copied()))
651 .collect::<Vec<_>>();
652 let cyclic = tracks_on_cycles(&next);
653 for ((track, next), on_cycle) in timeline.tracks.iter().zip(next).zip(cyclic) {
654 let Some(sidechain) = sidechain_of(track) else {
655 continue;
656 };
657 if next.is_none() || on_cycle {
659 return Err(ModelError::InvalidDuckingSidechain {
660 track: track.id.clone(),
661 sidechain: sidechain.clone(),
662 });
663 }
664 }
665 Ok(())
666}
667
668fn tracks_on_cycles(next: &[Option<usize>]) -> Vec<bool> {
676 const UNVISITED: usize = usize::MAX;
677 let mut cyclic = vec![false; next.len()];
678 let mut walk_of = vec![UNVISITED; next.len()];
680 for start in 0..next.len() {
681 let mut cursor = Some(start);
682 while let Some(position) = cursor {
683 match walk_of[position] {
684 UNVISITED => {
685 walk_of[position] = start;
686 cursor = next[position];
687 }
688 walk if walk == start => {
689 let mut member = position;
691 loop {
692 cyclic[member] = true;
693 match next[member] {
694 Some(following) if following != position => member = following,
695 _ => break,
696 }
697 }
698 break;
699 }
700 _ => break,
701 }
702 }
703 }
704 cyclic
705}
706
707fn ensure_mattes_are_usable(timeline: &Timeline) -> Result<(), ModelError> {
714 let visual = timeline
715 .tracks
716 .iter()
717 .filter(|track| track.kind == TrackKind::Video)
718 .flat_map(|track| track.clips.iter().map(|clip| (&clip.id, clip)))
719 .collect::<HashMap<_, _>>();
720 let in_transition = timeline
721 .tracks
722 .iter()
723 .flat_map(|track| &track.transitions)
724 .flat_map(|transition| [&transition.from_clip, &transition.to_clip])
725 .collect::<HashSet<_>>();
726 let clips = timeline
728 .tracks
729 .iter()
730 .filter(|track| track.kind == TrackKind::Video)
731 .flat_map(|track| &track.clips);
732 for clip in clips {
733 let Some(matte) = &clip.matte else {
734 continue;
735 };
736 let usable = matte.source != clip.id
737 && visual
738 .get(&matte.source)
739 .is_some_and(|source| source.matte.is_none())
740 && !in_transition.contains(&matte.source);
741 if !usable {
742 return Err(ModelError::InvalidMatte {
743 clip: clip.id.clone(),
744 });
745 }
746 }
747 Ok(())
748}
749
750fn ensure_frames_fit(
759 timeline: &Timeline,
760 sources: &HashMap<&alpha2_media::SourceId, &MediaSource>,
761 inlined: &crate::composition::InlinedNodes<'_>,
762) -> Result<(), ModelError> {
763 let nodes = |id: Option<&crate::CompositionId>| id.and_then(|id| inlined.get(id)).copied();
764 let mut edges = Vec::new();
766 for clip in timeline.tracks.iter().flat_map(|track| &track.clips) {
767 let source = sources
768 .get(&clip.source_id)
769 .and_then(|source| source.composition.as_ref());
770 let drawn = nodes(clip.composition.as_ref().map(|reference| &reference.id))
771 .unwrap_or(0)
772 .saturating_add(nodes(source).unwrap_or(0));
773 if drawn > 0 {
774 edges.push((clip.timeline_range.start(), true, drawn, &clip.id));
775 edges.push((clip.timeline_range.end(), false, drawn, &clip.id));
776 }
777 }
778 edges.sort_by_key(|&(time, starts, ..)| (time, starts));
781 let mut drawn = 0_u64;
782 let mut edges = edges.into_iter().peekable();
783 while let Some((time, starts, nodes, clip)) = edges.next() {
784 if !starts {
785 drawn -= nodes;
786 continue;
787 }
788 drawn = drawn.saturating_add(nodes);
789 while let Some((_, _, nodes, _)) =
791 edges.next_if(|&(next, next_starts, ..)| next == time && next_starts)
792 {
793 drawn = drawn.saturating_add(nodes);
794 }
795 if drawn > crate::MAX_FRAME_INLINED_NODES as u64 {
796 return Err(ModelError::FrameDrawsTooManyNodes {
797 clip: clip.clone(),
798 found: drawn,
799 maximum: crate::MAX_FRAME_INLINED_NODES,
800 });
801 }
802 }
803 Ok(())
804}
805
806pub(crate) fn ensure_overlaps_have_transitions(track: &crate::Track) -> Result<(), ModelError> {
813 if track.kind != TrackKind::Video {
814 return Ok(());
815 }
816 let transitions = track
817 .transitions
818 .iter()
819 .flat_map(|transition| {
820 [
821 (
822 &transition.from_clip,
823 &transition.to_clip,
824 transition.timeline_range,
825 ),
826 (
827 &transition.to_clip,
828 &transition.from_clip,
829 transition.timeline_range,
830 ),
831 ]
832 })
833 .collect::<HashSet<_>>();
834 let directed = track
835 .transitions
836 .iter()
837 .map(|transition| (&transition.from_clip, &transition.to_clip))
838 .collect::<HashSet<_>>();
839 let mut ordered = track.clips.iter().collect::<Vec<_>>();
840 ordered.sort_by_key(|clip| (clip.timeline_range.start(), clip.timeline_range.end()));
841 let mut active = Vec::new();
842 for second in ordered {
843 active.retain(|first: &&crate::Clip| {
844 first.timeline_range.end() > second.timeline_range.start()
845 });
846 for first in &active {
847 let Some(intersection) = first.timeline_range.intersection(second.timeline_range)
848 else {
849 continue;
850 };
851 if !transitions.contains(&(&first.id, &second.id, intersection)) {
852 return Err(ModelError::OverlappingClips {
853 track: track.id.clone(),
854 first: first.id.clone(),
855 second: second.id.clone(),
856 });
857 }
858 }
859 active.push(second);
860 if active.len() >= 3 {
865 let mut leads = active
866 .iter()
867 .map(|from| {
868 active
869 .iter()
870 .filter(|to| directed.contains(&(&from.id, &to.id)))
871 .count()
872 })
873 .collect::<Vec<_>>();
874 leads.sort_unstable();
875 if leads.iter().enumerate().any(|(rank, lead)| rank != *lead) {
876 return Err(ModelError::TransitionCycle {
877 track: track.id.clone(),
878 at: second.timeline_range.start(),
879 });
880 }
881 }
882 }
883 Ok(())
884}
885
886fn validate_captions(track: &crate::Track) -> Result<(), ModelError> {
889 if let Some(captions) = &track.captions
890 && captions.cues.len() > MAX_CLIPS_PER_TRACK
891 {
892 return Err(ModelError::TooManyCaptionCues {
893 track: track.id.clone(),
894 found: captions.cues.len(),
895 maximum: MAX_CLIPS_PER_TRACK,
896 });
897 }
898 let valid = match (&track.kind, &track.captions) {
899 (TrackKind::Caption, Some(captions)) => {
900 captions.is_valid() && track.clips.is_empty() && track.transitions.is_empty()
901 }
902 (TrackKind::Caption, None) => false,
903 (_, captions) => captions.is_none(),
904 };
905 if valid {
906 Ok(())
907 } else {
908 Err(ModelError::InvalidCaptions {
909 track: track.id.clone(),
910 })
911 }
912}
913
914fn validate_source_content(source: &MediaSource) -> Result<(), ModelError> {
918 if let Some(fingerprint) = &source.fingerprint
919 && (source.kind == MediaKind::Generated || !fingerprint.is_valid())
920 {
921 return Err(ModelError::InvalidFingerprint {
922 source: source.id.clone(),
923 });
924 }
925 if source
926 .color_metadata
927 .is_some_and(|color| !color.is_consistent())
928 {
929 return Err(ModelError::ContradictoryColor {
930 source: source.id.clone(),
931 });
932 }
933 if let Some(provider) = &source.provider
934 && (provider.kind() != source.kind || source.has_audio || !provider.is_valid())
935 {
936 return Err(ModelError::InvalidProvider {
937 source: source.id.clone(),
938 });
939 }
940 if source.kind == MediaKind::Composition {
941 let valid = source.composition.is_some()
943 && source.generator.is_none()
944 && source.locator.is_empty()
945 && source.color_metadata.is_none()
946 && source.fingerprint.is_none()
947 && source.provider.is_none()
948 && !source.has_audio;
949 return if valid {
950 Ok(())
951 } else {
952 Err(ModelError::InvalidCompositionSource {
953 source: source.id.clone(),
954 })
955 };
956 }
957 if source.composition.is_some() {
958 return Err(ModelError::InvalidCompositionSource {
959 source: source.id.clone(),
960 });
961 }
962 if source.kind == MediaKind::Generated {
963 let valid = source.generator.as_ref().is_some_and(Generator::is_valid)
964 && source.locator.is_empty()
965 && source.color_metadata.is_none()
966 && !source.has_audio;
967 return if valid {
968 Ok(())
969 } else {
970 Err(ModelError::InvalidGenerator {
971 source: source.id.clone(),
972 })
973 };
974 }
975 if source.generator.is_some() {
976 return Err(ModelError::InvalidGenerator {
977 source: source.id.clone(),
978 });
979 }
980 if source.locator.is_empty() {
981 return Err(ModelError::EmptyLocator {
982 source: source.id.clone(),
983 });
984 }
985 if source.kind == MediaKind::Image && source.has_audio {
986 return Err(ModelError::AudioOnImage {
987 source: source.id.clone(),
988 });
989 }
990 Ok(())
991}
992
993fn ensure_id<T: fmt::Display>(id: &T) -> Result<(), ModelError> {
994 if id.to_string().is_empty() {
995 Err(ModelError::EmptyIdentifier)
996 } else {
997 Ok(())
998 }
999}
1000
1001#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
1003#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1004#[serde(deny_unknown_fields)]
1005#[non_exhaustive]
1006pub struct ProjectSettings {
1007 pub width: u32,
1009 pub height: u32,
1012 pub frame_rate: FrameRate,
1014 pub sample_rate: u32,
1016 #[serde(default)]
1021 pub background: Rgb,
1022 #[serde(default)]
1025 pub blend_space: BlendSpace,
1026 #[serde(default)]
1028 pub color: ColorPipeline,
1029}
1030
1031#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1041#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1042#[serde(deny_unknown_fields)]
1043pub struct ColorPipeline {
1044 #[serde(default)]
1046 pub hdr: HdrSources,
1047 #[serde(default = "default_hdr_peak")]
1051 pub hdr_peak_nits: u32,
1052}
1053
1054pub const HDR_PEAK_NITS_RANGE: std::ops::RangeInclusive<u32> = 100..=10_000;
1056
1057const fn default_hdr_peak() -> u32 {
1058 1_000
1059}
1060
1061impl Default for ColorPipeline {
1062 fn default() -> Self {
1063 Self {
1064 hdr: HdrSources::default(),
1065 hdr_peak_nits: default_hdr_peak(),
1066 }
1067 }
1068}
1069
1070#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1072#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1073#[serde(rename_all = "snake_case")]
1074pub enum HdrSources {
1075 #[default]
1077 ToneMap,
1078 Reject,
1080}
1081
1082#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1089#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1090#[serde(rename_all = "snake_case")]
1091pub enum BlendSpace {
1092 #[default]
1094 LinearLight,
1095 SrgbEncoded,
1097}
1098
1099#[derive(Copy, Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
1102#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1103#[serde(deny_unknown_fields)]
1104pub struct Rgb {
1105 pub red: f32,
1107 pub green: f32,
1109 pub blue: f32,
1111}
1112
1113impl Rgb {
1114 pub const BLACK: Self = Self::new(0.0, 0.0, 0.0);
1116
1117 #[must_use]
1119 pub const fn new(red: f32, green: f32, blue: f32) -> Self {
1120 Self { red, green, blue }
1121 }
1122
1123 fn is_valid(self) -> bool {
1124 [self.red, self.green, self.blue]
1125 .into_iter()
1126 .all(|component| component.is_finite() && (0.0..=1.0).contains(&component))
1127 }
1128}
1129
1130impl ProjectSettings {
1131 pub fn new(
1136 width: u32,
1137 height: u32,
1138 frame_rate: FrameRate,
1139 sample_rate: u32,
1140 ) -> Result<Self, ModelError> {
1141 let settings = Self {
1142 width,
1143 height,
1144 frame_rate,
1145 sample_rate,
1146 background: Rgb::BLACK,
1147 blend_space: BlendSpace::LinearLight,
1148 color: ColorPipeline::default(),
1149 };
1150 settings.validate()?;
1151 Ok(settings)
1152 }
1153
1154 pub fn with_background(mut self, background: Rgb) -> Result<Self, ModelError> {
1160 self.background = background;
1161 self.validate()?;
1162 Ok(self)
1163 }
1164
1165 fn validate(&self) -> Result<(), ModelError> {
1167 if !HDR_PEAK_NITS_RANGE.contains(&self.color.hdr_peak_nits) {
1168 return Err(ModelError::InvalidColorPipeline);
1169 }
1170 if self.width == 0 || self.height == 0 {
1171 return Err(ModelError::ZeroCanvasDimension);
1172 }
1173 if self.width > MAX_CANVAS_DIMENSION || self.height > MAX_CANVAS_DIMENSION {
1174 return Err(ModelError::CanvasDimensionTooLarge {
1175 width: self.width,
1176 height: self.height,
1177 maximum: MAX_CANVAS_DIMENSION,
1178 });
1179 }
1180 let pixels = u64::from(self.width) * u64::from(self.height);
1181 if pixels > MAX_CANVAS_PIXELS {
1182 return Err(ModelError::CanvasPixelBudgetExceeded {
1183 pixels,
1184 maximum: MAX_CANVAS_PIXELS,
1185 });
1186 }
1187 if !self.background.is_valid() {
1188 return Err(ModelError::InvalidBackground);
1189 }
1190 if !SAMPLE_RATE_RANGE.contains(&self.sample_rate) {
1191 return Err(ModelError::UnsupportedSampleRate {
1192 found: self.sample_rate,
1193 minimum: *SAMPLE_RATE_RANGE.start(),
1194 maximum: *SAMPLE_RATE_RANGE.end(),
1195 });
1196 }
1197 Ok(())
1198 }
1199}