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]
270 pub fn draws_text(&self) -> bool {
271 let is_text = |generator: &crate::Generator| {
272 matches!(
273 generator,
274 crate::Generator::Text { .. } | crate::Generator::Layout { .. }
275 )
276 };
277 self.timeline
278 .tracks
279 .iter()
280 .any(|track| track.captions.is_some())
281 || self
282 .sources
283 .iter()
284 .any(|source| source.generator.as_ref().is_some_and(is_text))
285 || self.compositions.iter().any(|composition| {
286 composition.nodes.iter().any(|node| {
287 matches!(&node.op, crate::NodeOp::Generator { generator } if is_text(generator))
288 })
289 })
290 }
291
292 #[must_use]
294 pub fn new(id: ProjectId, name: impl Into<String>, settings: ProjectSettings) -> Self {
295 Self {
296 schema_version: CURRENT_SCHEMA_VERSION,
297 id,
298 name: name.into(),
299 settings,
300 sources: Vec::new(),
301 effect_resources: Vec::new(),
302 compositions: Vec::new(),
303 timeline: Timeline { tracks: Vec::new() },
304 audio: MasterAudio::default(),
305 }
306 }
307
308 pub fn validate(&self) -> Result<(), ModelError> {
314 if self.schema_version != CURRENT_SCHEMA_VERSION {
315 return Err(ModelError::UnsupportedSchemaVersion {
316 found: self.schema_version,
317 });
318 }
319 self.settings.validate()?;
320 ensure_id(&self.id)?;
321 if !self.audio.validate() {
322 return Err(ModelError::InvalidMasterAudio);
323 }
324 if self.sources.len() > MAX_CLIPS {
325 return Err(ModelError::TooManySources {
326 found: self.sources.len(),
327 maximum: MAX_CLIPS,
328 });
329 }
330 if self.timeline.tracks.len() > MAX_TRACKS {
331 return Err(ModelError::TooManyTracks {
332 found: self.timeline.tracks.len(),
333 maximum: MAX_TRACKS,
334 });
335 }
336
337 let sources = self.validate_sources()?;
338 let effect_resource_ids = self.validate_effect_resources()?;
339 let inlined = crate::composition::validate_compositions(
340 &self.compositions,
341 &self.sources,
342 &sources,
343 &effect_resource_ids,
344 (self.settings.width, self.settings.height),
345 )?;
346 let index = DocumentIndex {
347 sources,
348 compositions: self
349 .compositions
350 .iter()
351 .map(|composition| &composition.id)
352 .collect(),
353 };
354
355 let mut track_ids = HashSet::new();
356 let mut clip_ids = HashSet::new();
357 let mut clip_count = 0_usize;
358 for track in &self.timeline.tracks {
359 ensure_id(&track.id)?;
360 if !track_ids.insert(&track.id) {
361 return Err(ModelError::DuplicateTrackId(track.id.clone()));
362 }
363 if track.clips.len() > MAX_CLIPS_PER_TRACK {
364 return Err(ModelError::TooManyClipsOnTrack {
365 track: track.id.clone(),
366 found: track.clips.len(),
367 maximum: MAX_CLIPS_PER_TRACK,
368 });
369 }
370 validate_captions(track)?;
371 if track.transitions.len() > MAX_TRANSITIONS_PER_TRACK {
372 return Err(ModelError::TooManyTransitions {
373 track: track.id.clone(),
374 found: track.transitions.len(),
375 maximum: MAX_TRANSITIONS_PER_TRACK,
376 });
377 }
378 clip_count =
379 clip_count
380 .checked_add(track.clips.len())
381 .ok_or(ModelError::TooManyClips {
382 found: usize::MAX,
383 maximum: MAX_CLIPS,
384 })?;
385 if clip_count > MAX_CLIPS {
386 return Err(ModelError::TooManyClips {
387 found: clip_count,
388 maximum: MAX_CLIPS,
389 });
390 }
391 for clip in &track.clips {
392 validate_clip(&index, track, clip, &mut clip_ids)?;
393 }
394 for transition in &track.transitions {
395 ensure_effect_resources_exist(
396 &transition.effect,
397 &effect_resource_ids,
398 &transition.to_clip,
399 )?;
400 }
401 ensure_transitions_are_supported(track)?;
402 if !track.audio.validate() {
403 return Err(ModelError::InvalidTrackAudio {
404 track: track.id.clone(),
405 });
406 }
407 ensure_overlaps_have_transitions(track)?;
408 }
409 self.ensure_audio_effects_fit()?;
410 ensure_ducking_is_acyclic(&self.timeline)?;
411 ensure_mattes_are_usable(&self.timeline)?;
412 ensure_frames_fit(&self.timeline, &index.sources, &inlined)?;
413 Ok(())
414 }
415
416 fn validate_sources(
418 &self,
419 ) -> Result<HashMap<&alpha2_media::SourceId, &MediaSource>, ModelError> {
420 let mut sources = HashMap::with_capacity(self.sources.len());
421 for source in &self.sources {
422 ensure_id(&source.id)?;
423 validate_source_content(source, (self.settings.width, self.settings.height))?;
424 if sources.insert(&source.id, source).is_some() {
425 return Err(ModelError::DuplicateSourceId(source.id.clone()));
426 }
427 }
428 Ok(sources)
429 }
430
431 fn ensure_audio_effects_fit(&self) -> Result<(), ModelError> {
435 let load = self.audio_effect_load();
436 if load.effects > crate::MAX_AUDIO_EFFECTS {
437 return Err(ModelError::TooManyAudioEffects {
438 found: load.effects,
439 maximum: crate::MAX_AUDIO_EFFECTS,
440 at_ticks: load.effects_at_ticks,
441 });
442 }
443 if load.delay_ms() > u64::from(crate::MAX_AUDIO_DELAY_MS) {
444 return Err(ModelError::TooMuchAudioDelay {
445 found_ms: load.delay_ms(),
446 maximum_ms: crate::MAX_AUDIO_DELAY_MS,
447 at_ticks: load.delay_at_ticks,
448 });
449 }
450 Ok(())
451 }
452
453 fn validate_effect_resources(&self) -> Result<HashSet<&EffectResourceId>, ModelError> {
454 let mut ids = HashSet::new();
455 for resource in &self.effect_resources {
456 ensure_id(&resource.id)?;
457 if resource.locator.is_empty() {
458 return Err(ModelError::EmptyEffectResourceLocator {
459 resource: resource.id.clone(),
460 });
461 }
462 if !ids.insert(&resource.id) {
463 return Err(ModelError::DuplicateEffectResourceId(resource.id.clone()));
464 }
465 }
466 Ok(ids)
467 }
468}
469
470fn ensure_effect_resources_exist(
471 effect: &crate::ClipEffect,
472 known: &HashSet<&EffectResourceId>,
473 clip: &crate::ClipId,
474) -> Result<(), ModelError> {
475 for resource in effect.resources.values() {
476 if !known.contains(resource) {
477 return Err(ModelError::UnknownEffectResource {
478 clip: clip.clone(),
479 resource: resource.clone(),
480 });
481 }
482 }
483 Ok(())
484}
485
486fn validate_clip<'a>(
489 index: &DocumentIndex<'a>,
490 track: &'a crate::Track,
491 clip: &'a crate::Clip,
492 clip_ids: &mut HashSet<&'a crate::ClipId>,
493) -> Result<(), ModelError> {
494 ensure_id(&clip.id)?;
495 if clip.timeline_range.is_empty() || clip.source_range.is_empty() {
496 return Err(ModelError::EmptyClipRange {
497 clip: clip.id.clone(),
498 });
499 }
500 if clip.timeline_range.start().ticks() < 0 || clip.source_range.start().ticks() < 0 {
501 return Err(ModelError::NegativeClipRange {
502 clip: clip.id.clone(),
503 });
504 }
505 if !clip_ids.insert(&clip.id) {
506 return Err(ModelError::DuplicateClipId(clip.id.clone()));
507 }
508 let Some(source) = index.sources.get(&clip.source_id) else {
509 return Err(ModelError::UnknownClipSource {
510 clip: clip.id.clone(),
511 source: clip.source_id.clone(),
512 });
513 };
514 if !track.kind.accepts(source.kind) {
515 return Err(ModelError::IncompatibleClipSource {
516 clip: clip.id.clone(),
517 track: track.id.clone(),
518 source: source.id.clone(),
519 });
520 }
521 if !clip.visual.validate() {
522 return Err(ModelError::InvalidVisualProperties {
523 clip: clip.id.clone(),
524 });
525 }
526 if let Some(composition) = &clip.composition {
527 let held = || {
531 let last = MediaTime::from_ticks(clip.timeline_range.end().ticks().saturating_sub(1));
532 composition
533 .local_time(clip.timeline_range.start(), last)
534 .is_some_and(|time| time.ticks() <= alpha2_time::MAX_SERIALIZED_TICKS)
535 };
536 if track.kind != TrackKind::Video || !composition.scale.is_valid() || !held() {
537 return Err(ModelError::InvalidClipComposition {
538 clip: clip.id.clone(),
539 });
540 }
541 if !index.compositions.contains(&composition.id) {
542 return Err(ModelError::UnknownComposition {
543 clip: clip.id.clone(),
544 composition: composition.id.clone(),
545 });
546 }
547 }
548 if !clip.audio.validate() {
549 return Err(ModelError::InvalidAudioProperties {
550 clip: clip.id.clone(),
551 });
552 }
553 let visual = track.kind == TrackKind::Video;
554 if clip.masks.len() > crate::MAX_CLIP_MASKS || (!visual && !clip.masks.is_empty()) {
555 return Err(ModelError::InvalidClipMask {
556 clip: clip.id.clone(),
557 });
558 }
559 crate::mask::validate_masks(&clip.masks).map_err(|issue| ModelError::InvalidShape {
560 owner: crate::ShapeOwner::Clip(clip.id.clone()),
561 at: issue.at,
562 problem: issue.problem,
563 })?;
564 if !visual && clip.matte.is_some() {
565 return Err(ModelError::InvalidMatte {
566 clip: clip.id.clone(),
567 });
568 }
569 if let crate::TimeMap::Freeze { source_time } = clip.time_map
570 && !clip.source_range.contains(source_time)
571 {
572 return Err(ModelError::InvalidTimeMap {
573 clip: clip.id.clone(),
574 });
575 }
576 Ok(())
577}
578
579struct DocumentIndex<'a> {
582 sources: HashMap<&'a alpha2_media::SourceId, &'a MediaSource>,
583 compositions: HashSet<&'a crate::CompositionId>,
584}
585
586fn ensure_transitions_are_supported(track: &crate::Track) -> Result<(), ModelError> {
589 let clips = track
590 .clips
591 .iter()
592 .map(|clip| (&clip.id, clip))
593 .collect::<HashMap<_, _>>();
594 let mut pairs = HashSet::with_capacity(track.transitions.len());
595 for transition in &track.transitions {
596 let covers = |id: &crate::ClipId| {
597 clips.get(id).is_some_and(|clip| {
598 clip.timeline_range.start() <= transition.timeline_range.start()
599 && clip.timeline_range.end() >= transition.timeline_range.end()
600 })
601 };
602 let valid = track.kind == TrackKind::Video
603 && transition.from_clip != transition.to_clip
604 && !transition.timeline_range.is_empty()
605 && transition.easing.is_valid()
606 && transition.effect.is_valid()
607 && covers(&transition.from_clip)
608 && covers(&transition.to_clip);
609 if !valid {
610 return Err(ModelError::InvalidClipTransition {
611 track: track.id.clone(),
612 });
613 }
614 let pair = if transition.from_clip < transition.to_clip {
617 (&transition.from_clip, &transition.to_clip)
618 } else {
619 (&transition.to_clip, &transition.from_clip)
620 };
621 if !pairs.insert(pair) {
622 return Err(ModelError::DuplicateTransition {
623 track: track.id.clone(),
624 first: transition.from_clip.clone(),
625 second: transition.to_clip.clone(),
626 });
627 }
628 }
629 Ok(())
630}
631
632fn ensure_ducking_is_acyclic(timeline: &Timeline) -> Result<(), ModelError> {
641 fn sidechain_of(track: &crate::Track) -> Option<&crate::TrackId> {
642 track
643 .audio
644 .ducking
645 .as_ref()
646 .map(|ducking| &ducking.sidechain_track)
647 }
648 let positions = timeline
650 .tracks
651 .iter()
652 .enumerate()
653 .map(|(position, track)| (&track.id, position))
654 .collect::<HashMap<_, _>>();
655 let next = timeline
656 .tracks
657 .iter()
658 .map(|track| sidechain_of(track).and_then(|sidechain| positions.get(sidechain).copied()))
659 .collect::<Vec<_>>();
660 let cyclic = tracks_on_cycles(&next);
661 for ((track, next), on_cycle) in timeline.tracks.iter().zip(next).zip(cyclic) {
662 let Some(sidechain) = sidechain_of(track) else {
663 continue;
664 };
665 if next.is_none() || on_cycle {
667 return Err(ModelError::InvalidDuckingSidechain {
668 track: track.id.clone(),
669 sidechain: sidechain.clone(),
670 });
671 }
672 }
673 Ok(())
674}
675
676fn tracks_on_cycles(next: &[Option<usize>]) -> Vec<bool> {
684 const UNVISITED: usize = usize::MAX;
685 let mut cyclic = vec![false; next.len()];
686 let mut walk_of = vec![UNVISITED; next.len()];
688 for start in 0..next.len() {
689 let mut cursor = Some(start);
690 while let Some(position) = cursor {
691 match walk_of[position] {
692 UNVISITED => {
693 walk_of[position] = start;
694 cursor = next[position];
695 }
696 walk if walk == start => {
697 let mut member = position;
699 loop {
700 cyclic[member] = true;
701 match next[member] {
702 Some(following) if following != position => member = following,
703 _ => break,
704 }
705 }
706 break;
707 }
708 _ => break,
709 }
710 }
711 }
712 cyclic
713}
714
715fn ensure_mattes_are_usable(timeline: &Timeline) -> Result<(), ModelError> {
722 let visual = timeline
723 .tracks
724 .iter()
725 .filter(|track| track.kind == TrackKind::Video)
726 .flat_map(|track| track.clips.iter().map(|clip| (&clip.id, clip)))
727 .collect::<HashMap<_, _>>();
728 let in_transition = timeline
729 .tracks
730 .iter()
731 .flat_map(|track| &track.transitions)
732 .flat_map(|transition| [&transition.from_clip, &transition.to_clip])
733 .collect::<HashSet<_>>();
734 let clips = timeline
736 .tracks
737 .iter()
738 .filter(|track| track.kind == TrackKind::Video)
739 .flat_map(|track| &track.clips);
740 for clip in clips {
741 let Some(matte) = &clip.matte else {
742 continue;
743 };
744 let usable = matte.source != clip.id
745 && visual
746 .get(&matte.source)
747 .is_some_and(|source| source.matte.is_none())
748 && !in_transition.contains(&matte.source);
749 if !usable {
750 return Err(ModelError::InvalidMatte {
751 clip: clip.id.clone(),
752 });
753 }
754 }
755 Ok(())
756}
757
758fn ensure_frames_fit(
767 timeline: &Timeline,
768 sources: &HashMap<&alpha2_media::SourceId, &MediaSource>,
769 inlined: &crate::composition::InlinedNodes<'_>,
770) -> Result<(), ModelError> {
771 let nodes = |id: Option<&crate::CompositionId>| id.and_then(|id| inlined.get(id)).copied();
772 let mut edges = Vec::new();
774 for clip in timeline.tracks.iter().flat_map(|track| &track.clips) {
775 let source = sources
776 .get(&clip.source_id)
777 .and_then(|source| source.composition.as_ref());
778 let drawn = nodes(clip.composition.as_ref().map(|reference| &reference.id))
779 .unwrap_or(0)
780 .saturating_add(nodes(source).unwrap_or(0));
781 if drawn > 0 {
782 edges.push((clip.timeline_range.start(), true, drawn, &clip.id));
783 edges.push((clip.timeline_range.end(), false, drawn, &clip.id));
784 }
785 }
786 edges.sort_by_key(|&(time, starts, ..)| (time, starts));
789 let mut drawn = 0_u64;
790 let mut edges = edges.into_iter().peekable();
791 while let Some((time, starts, nodes, clip)) = edges.next() {
792 if !starts {
793 drawn -= nodes;
794 continue;
795 }
796 drawn = drawn.saturating_add(nodes);
797 while let Some((_, _, nodes, _)) =
799 edges.next_if(|&(next, next_starts, ..)| next == time && next_starts)
800 {
801 drawn = drawn.saturating_add(nodes);
802 }
803 if drawn > crate::MAX_FRAME_INLINED_NODES as u64 {
804 return Err(ModelError::FrameDrawsTooManyNodes {
805 clip: clip.clone(),
806 found: drawn,
807 maximum: crate::MAX_FRAME_INLINED_NODES,
808 });
809 }
810 }
811 Ok(())
812}
813
814pub(crate) fn ensure_overlaps_have_transitions(track: &crate::Track) -> Result<(), ModelError> {
821 if track.kind != TrackKind::Video {
822 return Ok(());
823 }
824 let transitions = track
825 .transitions
826 .iter()
827 .flat_map(|transition| {
828 [
829 (
830 &transition.from_clip,
831 &transition.to_clip,
832 transition.timeline_range,
833 ),
834 (
835 &transition.to_clip,
836 &transition.from_clip,
837 transition.timeline_range,
838 ),
839 ]
840 })
841 .collect::<HashSet<_>>();
842 let directed = track
843 .transitions
844 .iter()
845 .map(|transition| (&transition.from_clip, &transition.to_clip))
846 .collect::<HashSet<_>>();
847 let mut ordered = track.clips.iter().collect::<Vec<_>>();
848 ordered.sort_by_key(|clip| (clip.timeline_range.start(), clip.timeline_range.end()));
849 let mut active = Vec::new();
850 for second in ordered {
851 active.retain(|first: &&crate::Clip| {
852 first.timeline_range.end() > second.timeline_range.start()
853 });
854 for first in &active {
855 let Some(intersection) = first.timeline_range.intersection(second.timeline_range)
856 else {
857 continue;
858 };
859 if !transitions.contains(&(&first.id, &second.id, intersection)) {
860 return Err(ModelError::OverlappingClips {
861 track: track.id.clone(),
862 first: first.id.clone(),
863 second: second.id.clone(),
864 });
865 }
866 }
867 active.push(second);
868 if active.len() >= 3 {
873 let mut leads = active
874 .iter()
875 .map(|from| {
876 active
877 .iter()
878 .filter(|to| directed.contains(&(&from.id, &to.id)))
879 .count()
880 })
881 .collect::<Vec<_>>();
882 leads.sort_unstable();
883 if leads.iter().enumerate().any(|(rank, lead)| rank != *lead) {
884 return Err(ModelError::TransitionCycle {
885 track: track.id.clone(),
886 at: second.timeline_range.start(),
887 });
888 }
889 }
890 }
891 Ok(())
892}
893
894fn validate_captions(track: &crate::Track) -> Result<(), ModelError> {
897 if let Some(captions) = &track.captions
898 && captions.cues.len() > MAX_CLIPS_PER_TRACK
899 {
900 return Err(ModelError::TooManyCaptionCues {
901 track: track.id.clone(),
902 found: captions.cues.len(),
903 maximum: MAX_CLIPS_PER_TRACK,
904 });
905 }
906 let valid = match (&track.kind, &track.captions) {
907 (TrackKind::Caption, Some(captions)) => {
908 captions.is_valid() && track.clips.is_empty() && track.transitions.is_empty()
909 }
910 (TrackKind::Caption, None) => false,
911 (_, captions) => captions.is_none(),
912 };
913 if valid {
914 Ok(())
915 } else {
916 Err(ModelError::InvalidCaptions {
917 track: track.id.clone(),
918 })
919 }
920}
921
922fn validate_source_content(source: &MediaSource, canvas: (u32, u32)) -> Result<(), ModelError> {
928 if let Some(fingerprint) = &source.fingerprint
929 && (source.kind == MediaKind::Generated || !fingerprint.is_valid())
930 {
931 return Err(ModelError::InvalidFingerprint {
932 source: source.id.clone(),
933 });
934 }
935 if source
936 .color_metadata
937 .is_some_and(|color| !color.is_consistent())
938 {
939 return Err(ModelError::ContradictoryColor {
940 source: source.id.clone(),
941 });
942 }
943 if let Some(provider) = &source.provider
944 && (provider.kind() != source.kind || source.has_audio || !provider.is_valid())
945 {
946 return Err(ModelError::InvalidProvider {
947 source: source.id.clone(),
948 });
949 }
950 if source.kind == MediaKind::Composition {
951 let valid = source.composition.is_some()
953 && source.generator.is_none()
954 && source.locator.is_empty()
955 && source.color_metadata.is_none()
956 && source.fingerprint.is_none()
957 && source.provider.is_none()
958 && !source.has_audio;
959 return if valid {
960 Ok(())
961 } else {
962 Err(ModelError::InvalidCompositionSource {
963 source: source.id.clone(),
964 })
965 };
966 }
967 if source.composition.is_some() {
968 return Err(ModelError::InvalidCompositionSource {
969 source: source.id.clone(),
970 });
971 }
972 if source.kind == MediaKind::Generated {
973 let owner = || crate::ShapeOwner::Source(source.id.clone());
974 match source
975 .generator
976 .as_ref()
977 .map(|generator| generator.validate_on(canvas.0, canvas.1))
978 {
979 Some(Err(crate::GeneratorIssue::Shape(issue))) => {
980 return Err(ModelError::InvalidShape {
981 owner: owner(),
982 at: issue.at,
983 problem: issue.problem,
984 });
985 }
986 Some(Err(crate::GeneratorIssue::Layout(issue))) => {
987 return Err(ModelError::InvalidLayout {
988 owner: owner(),
989 at: issue.at,
990 problem: issue.problem,
991 });
992 }
993 _ => {}
994 }
995 let valid = source.generator.as_ref().is_some_and(Generator::is_valid)
996 && source.locator.is_empty()
997 && source.color_metadata.is_none()
998 && !source.has_audio;
999 return if valid {
1000 Ok(())
1001 } else {
1002 Err(ModelError::InvalidGenerator {
1003 source: source.id.clone(),
1004 })
1005 };
1006 }
1007 if source.generator.is_some() {
1008 return Err(ModelError::InvalidGenerator {
1009 source: source.id.clone(),
1010 });
1011 }
1012 if source.locator.is_empty() {
1013 return Err(ModelError::EmptyLocator {
1014 source: source.id.clone(),
1015 });
1016 }
1017 if source.kind == MediaKind::Image && source.has_audio {
1018 return Err(ModelError::AudioOnImage {
1019 source: source.id.clone(),
1020 });
1021 }
1022 Ok(())
1023}
1024
1025fn ensure_id<T: fmt::Display>(id: &T) -> Result<(), ModelError> {
1026 if id.to_string().is_empty() {
1027 Err(ModelError::EmptyIdentifier)
1028 } else {
1029 Ok(())
1030 }
1031}
1032
1033#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
1035#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1036#[serde(deny_unknown_fields)]
1037#[non_exhaustive]
1038pub struct ProjectSettings {
1039 pub width: u32,
1041 pub height: u32,
1044 pub frame_rate: FrameRate,
1046 pub sample_rate: u32,
1048 #[serde(default)]
1053 pub background: Rgb,
1054 #[serde(default)]
1057 pub blend_space: BlendSpace,
1058 #[serde(default)]
1060 pub color: ColorPipeline,
1061}
1062
1063#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1073#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1074#[serde(deny_unknown_fields)]
1075pub struct ColorPipeline {
1076 #[serde(default)]
1078 pub hdr: HdrSources,
1079 #[serde(default = "default_hdr_peak")]
1083 pub hdr_peak_nits: u32,
1084}
1085
1086pub const HDR_PEAK_NITS_RANGE: std::ops::RangeInclusive<u32> = 100..=10_000;
1088
1089const fn default_hdr_peak() -> u32 {
1090 1_000
1091}
1092
1093impl Default for ColorPipeline {
1094 fn default() -> Self {
1095 Self {
1096 hdr: HdrSources::default(),
1097 hdr_peak_nits: default_hdr_peak(),
1098 }
1099 }
1100}
1101
1102#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1104#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1105#[serde(rename_all = "snake_case")]
1106pub enum HdrSources {
1107 #[default]
1109 ToneMap,
1110 Reject,
1112}
1113
1114#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1121#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1122#[serde(rename_all = "snake_case")]
1123pub enum BlendSpace {
1124 #[default]
1126 LinearLight,
1127 SrgbEncoded,
1129}
1130
1131#[derive(Copy, Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
1134#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1135#[serde(deny_unknown_fields)]
1136pub struct Rgb {
1137 pub red: f32,
1139 pub green: f32,
1141 pub blue: f32,
1143}
1144
1145impl Rgb {
1146 pub const BLACK: Self = Self::new(0.0, 0.0, 0.0);
1148
1149 #[must_use]
1151 pub const fn new(red: f32, green: f32, blue: f32) -> Self {
1152 Self { red, green, blue }
1153 }
1154
1155 fn is_valid(self) -> bool {
1156 [self.red, self.green, self.blue]
1157 .into_iter()
1158 .all(|component| component.is_finite() && (0.0..=1.0).contains(&component))
1159 }
1160}
1161
1162impl ProjectSettings {
1163 pub fn new(
1168 width: u32,
1169 height: u32,
1170 frame_rate: FrameRate,
1171 sample_rate: u32,
1172 ) -> Result<Self, ModelError> {
1173 let settings = Self {
1174 width,
1175 height,
1176 frame_rate,
1177 sample_rate,
1178 background: Rgb::BLACK,
1179 blend_space: BlendSpace::LinearLight,
1180 color: ColorPipeline::default(),
1181 };
1182 settings.validate()?;
1183 Ok(settings)
1184 }
1185
1186 pub fn with_background(mut self, background: Rgb) -> Result<Self, ModelError> {
1192 self.background = background;
1193 self.validate()?;
1194 Ok(self)
1195 }
1196
1197 fn validate(&self) -> Result<(), ModelError> {
1199 if !HDR_PEAK_NITS_RANGE.contains(&self.color.hdr_peak_nits) {
1200 return Err(ModelError::InvalidColorPipeline);
1201 }
1202 if self.width == 0 || self.height == 0 {
1203 return Err(ModelError::ZeroCanvasDimension);
1204 }
1205 if self.width > MAX_CANVAS_DIMENSION || self.height > MAX_CANVAS_DIMENSION {
1206 return Err(ModelError::CanvasDimensionTooLarge {
1207 width: self.width,
1208 height: self.height,
1209 maximum: MAX_CANVAS_DIMENSION,
1210 });
1211 }
1212 let pixels = u64::from(self.width) * u64::from(self.height);
1213 if pixels > MAX_CANVAS_PIXELS {
1214 return Err(ModelError::CanvasPixelBudgetExceeded {
1215 pixels,
1216 maximum: MAX_CANVAS_PIXELS,
1217 });
1218 }
1219 if !self.background.is_valid() {
1220 return Err(ModelError::InvalidBackground);
1221 }
1222 if !SAMPLE_RATE_RANGE.contains(&self.sample_rate) {
1223 return Err(ModelError::UnsupportedSampleRate {
1224 found: self.sample_rate,
1225 minimum: *SAMPLE_RATE_RANGE.start(),
1226 maximum: *SAMPLE_RATE_RANGE.end(),
1227 });
1228 }
1229 Ok(())
1230 }
1231}