Skip to main content

alpha2_model/
project.rs

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
10/// The newest project-document schema understood by this build.
11pub const CURRENT_SCHEMA_VERSION: u32 = 1;
12/// Largest project document accepted by [`Project::from_json`].
13pub const MAX_PROJECT_JSON_BYTES: usize = 64 * 1024 * 1024;
14/// Largest canvas edge accepted by the portable project model.
15pub const MAX_CANVAS_DIMENSION: u32 = alpha2_media::MAX_FRAME_DIMENSION;
16/// Largest total canvas area accepted before quality scaling.
17pub const MAX_CANVAS_PIXELS: u64 = alpha2_media::MAX_FRAME_PIXELS;
18/// Supported project audio sample-rate interval.
19///
20/// 96 kHz is the highest rate an export codec here takes (AAC-LC), and staying below the
21/// 120 kHz tick rate keeps every sample on a distinct tick. Faster sources are resampled.
22pub const SAMPLE_RATE_RANGE: std::ops::RangeInclusive<u32> = 8_000..=96_000;
23/// Maximum number of tracks in one project.
24pub const MAX_TRACKS: usize = 256;
25/// Maximum number of clips across one project, and of sources in it.
26pub const MAX_CLIPS: usize = 100_000;
27/// Maximum number of clips on one track.
28pub const MAX_CLIPS_PER_TRACK: usize = 4_096;
29/// Maximum number of transitions on one track.
30pub const MAX_TRANSITIONS_PER_TRACK: usize = 4_096;
31
32/// A complete serializable project document.
33#[derive(Clone, Debug, PartialEq, Serialize)]
34pub struct Project {
35    /// Version used to serialize this document.
36    pub schema_version: u32,
37    /// Stable identity of this project.
38    pub id: ProjectId,
39    /// Human-readable project name. It has no semantic meaning.
40    pub name: String,
41    /// Output-wide timeline settings.
42    pub settings: ProjectSettings,
43    /// Media resources referenced by clips.
44    pub sources: Vec<MediaSource>,
45    /// Non-media resources referenced by effect invocations.
46    #[serde(default)]
47    pub effect_resources: Vec<EffectResource>,
48    /// Composition graphs that clips show.
49    #[serde(default)]
50    pub compositions: Vec<Composition>,
51    /// Ordered visual and audio tracks.
52    pub timeline: Timeline,
53    /// Master bus applied to the summed track audio.
54    #[serde(default)]
55    pub audio: MasterAudio,
56}
57
58/// The deserialized shape of a [`Project`] before its invariants are checked.
59///
60/// This is the wire contract the published JSON Schema is derived from; [`Project::validate`]
61/// then decides whether a well-shaped document is valid.
62#[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    /// Document version. This build reads and writes only [`CURRENT_SCHEMA_VERSION`].
68    #[cfg_attr(feature = "schema", schemars(extend("const" = CURRENT_SCHEMA_VERSION)))]
69    pub(crate) schema_version: u32,
70    /// Stable identity of this project.
71    pub(crate) id: ProjectId,
72    /// Human-readable project name. It has no semantic meaning.
73    pub(crate) name: String,
74    /// Output-wide timeline settings.
75    pub(crate) settings: ProjectSettings,
76    /// Media resources referenced by clips.
77    pub(crate) sources: Vec<MediaSource>,
78    /// Non-media resources referenced by effect invocations.
79    #[serde(default)]
80    pub(crate) effect_resources: Vec<EffectResource>,
81    /// Composition graphs that clips show.
82    #[serde(default)]
83    pub(crate) compositions: Vec<Composition>,
84    /// Ordered visual and audio tracks.
85    pub(crate) timeline: Timeline,
86    /// Master bus applied to the summed track audio.
87    #[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    /// Parses and validates a serialized project document.
123    ///
124    /// `schema_version` is read first, so a document of another version reports
125    /// [`ModelError::UnsupportedSchemaVersion`] rather than a shape error.
126    ///
127    /// # Errors
128    /// Returns [`ProjectLoadError`] for malformed, unsupported, or structurally invalid
129    /// documents.
130    pub fn from_json(json: &str) -> Result<Self, ProjectLoadError> {
131        // Reads only the version and skips every other field. A failed probe leaves the error to
132        // the full parse below.
133        #[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    /// The sources a render of this project can read, in source order: each clip's own, and
158    /// those read by media nodes its compositions draw.
159    ///
160    /// From a composition's output the walk follows the inputs each node reads
161    /// ([`crate::CompositionReads::needed_inputs`], as the renderer plans them), into group and
162    /// composition-source compositions. A composition source counts only where the clip's own
163    /// composition draws the clip ([`crate::CompositionReads::draws_clip`]). Unconnected nodes,
164    /// unshown compositions, inputs a bypassed node skips and unread group ports read nothing.
165    #[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            // A composition source is the clip's picture, drawn only where the clip's own
184            // composition draws the clip.
185            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        // Validation rules out cycles; `walked` also avoids re-walking a composition many clips or
198        // groups draw.
199        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    /// Returns the HDR sources (PQ, HLG or declared HDR, per
244    /// [`alpha2_media::ColorMetadata::is_hdr`]), which the project tone-maps or refuses as its
245    /// [`ColorPipeline`] says.
246    #[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    /// Whether anything in the project may draw text: a caption track, or a text or layout
256    /// generator in a source or composition. A project that draws none needs no fonts. Layouts
257    /// count even without text because the text host resolves them.
258    #[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    /// Creates an empty project using the current schema version.
282    #[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    /// Checks structural invariants without performing media I/O.
298    ///
299    /// # Errors
300    /// Returns the first invalid document invariant encountered.
301    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    /// Checks each source, and returns them by ID.
405    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    /// Bounds the audio effect state held at once (see [`Project::audio_effect_load`]) to
420    /// [`crate::MAX_AUDIO_EFFECTS`] effects and [`crate::MAX_AUDIO_DELAY_MS`] of delay.
421    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
473/// Checks one clip's identity, ranges, source compatibility, and visual and audio
474/// parameters.
475fn 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        // Check the reference's own terms first since what follows computes with them. Composition
515        // time grows with timeline time, so the clip's last instant has the largest value.
516        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
565/// The document's sources and compositions by ID, so validation looks each reference up
566/// once.
567struct DocumentIndex<'a> {
568    sources: HashMap<&'a alpha2_media::SourceId, &'a MediaSource>,
569    compositions: HashSet<&'a crate::CompositionId>,
570}
571
572/// Rejects transitions on audio tracks, transitions not fully covered by both clips, and a
573/// second transition between two clips.
574fn 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        // The engine folds one transition over another: the same pair twice mixes twice, and both
601        // directions hide both clips.
602        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
618/// Rejects sidechain references to missing tracks, the referencing track itself, or any
619/// cycle between ducked tracks.
620///
621/// Either track kind may duck or be ducked. The track reported is the first in document
622/// order with an unusable sidechain. Each track ID is looked up once, so the check is linear
623/// in the tracks however long the chains.
624fn 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    // Track IDs are unique by now, so each names one position.
633    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        // A missing sidechain, or one that leads back to this track (itself included).
650        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
660/// Which tracks lie on a sidechain cycle, given for each track the position of the track its
661/// sidechain names.
662///
663/// Each track names at most one sidechain, so a track is on a cycle exactly when its own walk
664/// returns to it. Each walk stops at the first track an earlier walk reached, so every track
665/// is visited once overall.
666fn tracks_on_cycles(next: &[Option<usize>]) -> Vec<bool> {
667    const UNVISITED: usize = usize::MAX;
668    let mut cyclic = vec![false; next.len()];
669    // The walk that first reached each track; a walk that meets its own mark has closed a cycle.
670    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                    // Mark the cycle by going round it once from where the walk closed it.
681                    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
698/// Rejects a matte that names no visual clip, the clip itself, a clip with its own matte, or
699/// a clip in a transition.
700///
701/// A chain of mattes would need an evaluation order the document does not give, and a clip
702/// in a transition is drawn by the transition.
703fn 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    // In document order, so the reported clip is the same on every platform.
717    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
740/// Checks that the clips at no instant draw more than [`crate::MAX_FRAME_INLINED_NODES`]
741/// composition nodes together. A composition compiles once however many clips show it, but
742/// each clip on screen draws all of it.
743///
744/// The clip reported is the first, in document order, starting at the first instant over the
745/// bound; the count covers all clips there.
746fn 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    // Each clip drawing a composition, with its node count, at its start (+) and end (-).
753    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    // By instant, ends before starts (ranges are half-open), each in document order (the sort is
767    // stable).
768    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        // Every clip starting at this instant, so the count is the frame's.
778        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
794/// Rejects visual overlaps that no transition covers.
795///
796/// Two overlapping visual clips have no defined composite without a transition. Audio tracks
797/// may overlap freely because the mixer sums them.
798pub(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        // Every clip active here covers this instant, and so does the transition between each two.
847        // The engine folds them clip to clip, which needs no cycle: a tournament is acyclic exactly
848        // when its clips lead to 0, 1, ..., k - 1 others. A set only grows at a start, so starts
849        // are the instants to check.
850        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
872/// Checks that a caption track holds valid captions and nothing else, and no other track
873/// holds captions.
874fn 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
921/// Checks one source. A generated source has a valid generator and nothing a host would
922/// supply; any other source has a locator and no generator, and an image has no audio. A
923/// declared color is HDR exactly when its transfer is PQ or HLG. `canvas` is the project's,
924/// which a layout's pixel lengths refer to.
925fn 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        // Whether the composition exists is checked with the compositions.
950        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/// Output-wide settings independent of a renderer or encoder.
1032#[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    /// Canvas width in output pixels, at most [`MAX_CANVAS_DIMENSION`].
1038    pub width: u32,
1039    /// Canvas height in output pixels, at most [`MAX_CANVAS_DIMENSION`]. The canvas area is at
1040    /// most [`MAX_CANVAS_PIXELS`].
1041    pub height: u32,
1042    /// Timeline frame rate.
1043    pub frame_rate: FrameRate,
1044    /// Internal audio sample rate, within [`SAMPLE_RATE_RANGE`].
1045    pub sample_rate: u32,
1046    /// Opaque color under every layer, in preview and export alike, so empty canvas is never
1047    /// left to a host or encoder.
1048    #[serde(default)]
1049    pub background: Rgb,
1050    /// The space in which clips' blend modes are evaluated, in preview and export alike.
1051    #[serde(default)]
1052    pub blend_space: BlendSpace,
1053    /// How sources' colors come into the working space.
1054    #[serde(default)]
1055    pub color: ColorPipeline,
1056}
1057
1058/// How sources' colors come into the working space.
1059///
1060/// The working space is display-referred linear light with BT.709 primaries, `1.0` being SDR
1061/// white (100 nits); every export is SDR BT.709. SDR sources decode through the sRGB EOTF and
1062/// wide-gamut ones are converted to BT.709. An HDR (PQ or HLG) source is either refused or
1063/// linearized, converted to BT.709, and tone-mapped from [`Self::hdr_peak_nits`] to SDR white
1064/// by the ITU-R BT.2390 EETF on each pixel's largest component, preserving hue.
1065#[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    /// What an HDR source does.
1070    #[serde(default)]
1071    pub hdr: HdrSources,
1072    /// The luminance, in nits, HDR sources are taken to peak at: PQ above it is clipped, HLG is
1073    /// displayed at it, and the tone map brings it to SDR white. Within [`HDR_PEAK_NITS_RANGE`].
1074    #[serde(default = "default_hdr_peak")]
1075    pub hdr_peak_nits: u32,
1076}
1077
1078/// Supported [`ColorPipeline::hdr_peak_nits`]: SDR white to PQ's ceiling.
1079pub 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/// What a project does with HDR sources.
1095#[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    /// Tone-map them into the SDR working space.
1100    #[default]
1101    ToneMap,
1102    /// Refuse to render them.
1103    Reject,
1104}
1105
1106/// The color space in which blend modes are evaluated.
1107///
1108/// Compositing is always premultiplied; this chooses what the blend formulas see. Contrast
1109/// modes such as overlay and soft-light match design tools only in
1110/// [`BlendSpace::SrgbEncoded`].
1111#[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    /// Linear light, where blending is physically meaningful.
1116    #[default]
1117    LinearLight,
1118    /// sRGB-encoded values, as design tools and CSS blend.
1119    SrgbEncoded,
1120}
1121
1122/// An opaque color as a color picker shows it: straight, sRGB-encoded components in `0..=1`.
1123#[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    /// Red component.
1128    pub red: f32,
1129    /// Green component.
1130    pub green: f32,
1131    /// Blue component.
1132    pub blue: f32,
1133}
1134
1135impl Rgb {
1136    /// Opaque black, the default background.
1137    pub const BLACK: Self = Self::new(0.0, 0.0, 0.0);
1138
1139    /// Creates a color from sRGB-encoded components.
1140    #[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    /// Creates validated output settings.
1154    ///
1155    /// # Errors
1156    /// Returns a model error when dimensions or sample rate exceed supported bounds.
1157    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    /// Replaces the background color.
1177    ///
1178    /// # Errors
1179    /// Returns [`ModelError::InvalidBackground`] for a non-finite or out-of-range component.
1180    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}