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; Opus stops
21/// at 48 kHz), and staying below the 120 kHz tick rate keeps every sample on a
22/// distinct tick. A source may be sampled faster; hosts resample it.
23pub const SAMPLE_RATE_RANGE: std::ops::RangeInclusive<u32> = 8_000..=96_000;
24/// Maximum number of tracks in one project.
25pub const MAX_TRACKS: usize = 256;
26/// Maximum number of clips across one project, and of sources in it.
27pub const MAX_CLIPS: usize = 100_000;
28/// Maximum number of clips on one track.
29pub const MAX_CLIPS_PER_TRACK: usize = 4_096;
30/// Maximum number of transitions on one track.
31pub const MAX_TRANSITIONS_PER_TRACK: usize = 4_096;
32
33/// A complete serializable project document.
34#[derive(Clone, Debug, PartialEq, Serialize)]
35pub struct Project {
36    /// Version used to serialize this document.
37    pub schema_version: u32,
38    /// Stable identity of this project.
39    pub id: ProjectId,
40    /// Human-readable project name. It has no semantic meaning.
41    pub name: String,
42    /// Output-wide timeline settings.
43    pub settings: ProjectSettings,
44    /// Media resources referenced by clips.
45    pub sources: Vec<MediaSource>,
46    /// Non-media resources referenced by effect invocations.
47    #[serde(default)]
48    pub effect_resources: Vec<EffectResource>,
49    /// Composition graphs that clips show.
50    #[serde(default)]
51    pub compositions: Vec<Composition>,
52    /// Ordered visual and audio tracks.
53    pub timeline: Timeline,
54    /// Master bus applied to the summed track audio.
55    #[serde(default)]
56    pub audio: MasterAudio,
57}
58
59/// The deserialized shape of a [`Project`], before its invariants are checked.
60///
61/// This is the wire contract, and the published JSON Schema is derived from
62/// it: a document the schema accepts has the right shape, and
63/// [`Project::validate`] then decides whether it means anything.
64#[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    /// Document version. This build reads and writes only
70    /// [`CURRENT_SCHEMA_VERSION`].
71    #[cfg_attr(feature = "schema", schemars(extend("const" = CURRENT_SCHEMA_VERSION)))]
72    pub(crate) schema_version: u32,
73    /// Stable identity of this project.
74    pub(crate) id: ProjectId,
75    /// Human-readable project name. It has no semantic meaning.
76    pub(crate) name: String,
77    /// Output-wide timeline settings.
78    pub(crate) settings: ProjectSettings,
79    /// Media resources referenced by clips.
80    pub(crate) sources: Vec<MediaSource>,
81    /// Non-media resources referenced by effect invocations.
82    #[serde(default)]
83    pub(crate) effect_resources: Vec<EffectResource>,
84    /// Composition graphs that clips show.
85    #[serde(default)]
86    pub(crate) compositions: Vec<Composition>,
87    /// Ordered visual and audio tracks.
88    pub(crate) timeline: Timeline,
89    /// Master bus applied to the summed track audio.
90    #[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    /// Parses and validates a serialized project document.
126    ///
127    /// The document's `schema_version` is read first: a document of another
128    /// version reports [`ModelError::UnsupportedSchemaVersion`] rather than
129    /// whatever shape difference the versions have.
130    ///
131    /// # Errors
132    /// Returns [`ProjectLoadError`] for malformed, unsupported, or structurally
133    /// invalid documents.
134    pub fn from_json(json: &str) -> Result<Self, ProjectLoadError> {
135        // Only the version, and every other field skipped unparsed. A probe that fails leaves the
136        // error to the full parse below.
137        #[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    /// The sources a render of this project can read, in source order: each
162    /// clip's own, and those the media nodes its compositions draw read.
163    ///
164    /// A clip's compositions are the one it names and, for a composition
165    /// source, the source's when the one it names draws the clip
166    /// ([`crate::CompositionReads::draws_clip`]). From a composition's output
167    /// the walk follows the inputs drawing a node reads
168    /// ([`crate::CompositionReads::needed_inputs`], as the renderer plans
169    /// them), into the composition a group node draws and the one a
170    /// composition source a media node reads shows. So an unconnected node, a
171    /// composition nothing shows, the inputs a bypassed node passes over, and
172    /// the group ports a group's composition never reads read nothing. An
173    /// unconnected input the output needs fails those frames; what the
174    /// connected inputs read is still used.
175    #[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            // A composition source is the clip's picture, drawn only where the clip's own
194            // composition draws the clip.
195            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        // Validation rules out cycles; `walked` also keeps a composition many
208        // clips or groups draw from being walked again.
209        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    /// Returns the HDR sources, which the project tone-maps or refuses as its
254    /// [`ColorPipeline`] says: those whose color is PQ or HLG, or declared
255    /// HDR, as the renderer decides ([`alpha2_media::ColorMetadata::is_hdr`]).
256    #[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    /// Whether anything in the project may draw text: a caption track, or a
266    /// text or layout generator in a source or a composition. A host whose
267    /// project draws none needs no fonts and prepares no text. A layout is
268    /// resolved by the text host, with or without text.
269    #[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    /// Creates an empty project using the current schema version.
293    #[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    /// Checks structural invariants without performing media I/O.
309    ///
310    /// # Errors
311    ///
312    /// Returns the first invalid document invariant encountered.
313    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    /// Checks each source, and returns them by ID.
417    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    /// Bounds the audio effect state the project holds at once (see
432    /// [`Project::audio_effect_load`]): [`crate::MAX_AUDIO_EFFECTS`] effects
433    /// and [`crate::MAX_AUDIO_DELAY_MS`] of delay.
434    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
486/// Checks one clip's identity, ranges, source compatibility, and authored
487/// visual and audio parameters.
488fn 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        // The reference's own terms first: what follows computes with them. The composition time
528        // grows with the timeline time, so the clip's last instant has the largest; every time a
529        // document holds fits in a serialized tick.
530        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
579/// The document's sources and compositions by ID, so that validation looks
580/// each reference up once rather than searching the lists.
581struct DocumentIndex<'a> {
582    sources: HashMap<&'a alpha2_media::SourceId, &'a MediaSource>,
583    compositions: HashSet<&'a crate::CompositionId>,
584}
585
586/// Rejects transitions on audio tracks, any transition not fully covered by
587/// both of its clips, and a second transition between two clips.
588fn 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        // The engine folds one transition over another: the same pair twice mixes twice, and both
615        // ways hides both clips.
616        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
632/// Rejects sidechain references to missing tracks, to the referencing track
633/// itself, and to any cycle between ducked tracks.
634///
635/// Every track carries an audio bus, so either kind may duck or be ducked.
636/// The track reported is the first, in document order, whose sidechain is
637/// unusable. Each track ID is looked up once, so the check takes time in
638/// proportion to the tracks and the length of their IDs, however long the
639/// chains between them.
640fn 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    // Track IDs are unique by now, so each names one position.
649    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        // A missing sidechain, or one that leads back to this track, itself included.
666        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
676/// Which tracks lie on a cycle of sidechains, given for each track the
677/// position of the track its sidechain names, if any.
678///
679/// Each track names at most one sidechain, so the walk from any track ends,
680/// or enters the one cycle its path reaches; a track is on a cycle exactly
681/// when its own walk returns to it. Each walk stops at the first track an
682/// earlier walk reached, so every track is visited once over all of them.
683fn tracks_on_cycles(next: &[Option<usize>]) -> Vec<bool> {
684    const UNVISITED: usize = usize::MAX;
685    let mut cyclic = vec![false; next.len()];
686    // The walk that first reached each track: a walk that meets its own mark has closed a cycle.
687    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                    // Mark the cycle by going round it once from where the walk closed it.
698                    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
715/// Rejects a matte that names no visual clip, the clip itself, a clip with a
716/// matte of its own, or a clip in a transition.
717///
718/// A matte clip is drawn only into the mattes that name it. A chain of mattes
719/// would need an order of evaluation the document does not give, and a clip
720/// in a transition is drawn by the transition.
721fn 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    // In document order, so the clip reported is the first, on every platform.
735    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
758/// Bounds what one frame draws: at no instant do the clips on the timeline
759/// draw more than [`crate::MAX_FRAME_INLINED_NODES`] composition nodes
760/// together, each clip counting the nodes its composition and its
761/// composition source draw once inlined. A composition is compiled once
762/// however many clips show it, but each clip on screen draws all of it.
763///
764/// The clip reported is the first, in document order, of those starting at
765/// the first instant over the bound, and the count is all the clips there.
766fn 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    // Each clip drawing a composition, with the nodes it draws, at its start (+) and its end (-).
773    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    // By instant, the clips ending there before those starting there (ranges are half-open), each
787    // in document order: the sort is stable.
788    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        // Every clip starting at this instant, so the count is the frame's.
798        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
814/// Rejects visual overlaps that no transition covers.
815///
816/// Two visual clips occupying one timestamp have no defined composition unless
817/// a transition says how to combine them. Audio has a defined answer (the
818/// mixer sums overlapping clips into their track bus), so audio tracks may
819/// overlay freely and shape the result with fades.
820pub(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        // Every clip active here covers this instant, and so does the transition between each
869        // two: the engine folds them from clip to clip, which needs no cycle. A tournament has none
870        // exactly when its clips lead to 0, 1, ..., k - 1 others. A set only grows at a start, so
871        // the starts are every instant to check.
872        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
894/// A caption track holds valid captions and nothing else; no other track
895/// holds captions.
896fn 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
922/// A generated source has a valid generator and nothing a host would supply;
923/// any other source has a locator and no generator, and an image no audio.
924/// A declared color is consistent: HDR exactly when its transfer is PQ or HLG.
925/// Checks one source; `canvas` is the project's, which a layout's pixels are
926/// pixels of.
927fn 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        // Whether the composition exists is checked with the compositions.
952        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/// Output-wide settings independent of a renderer or encoder.
1034#[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    /// Canvas width in output pixels, at most [`MAX_CANVAS_DIMENSION`].
1040    pub width: u32,
1041    /// Canvas height in output pixels, at most [`MAX_CANVAS_DIMENSION`]. The
1042    /// canvas area is at most [`MAX_CANVAS_PIXELS`].
1043    pub height: u32,
1044    /// Timeline frame rate.
1045    pub frame_rate: FrameRate,
1046    /// Internal audio sample rate, within [`SAMPLE_RATE_RANGE`].
1047    pub sample_rate: u32,
1048    /// Opaque color under every layer, in preview and export alike.
1049    ///
1050    /// Empty canvas, and whatever shows through a translucent layer, is this
1051    /// color rather than something a host or encoder decides.
1052    #[serde(default)]
1053    pub background: Rgb,
1054    /// The space in which clips' blend modes are evaluated, in preview and
1055    /// export alike.
1056    #[serde(default)]
1057    pub blend_space: BlendSpace,
1058    /// How sources' colors come into the working space.
1059    #[serde(default)]
1060    pub color: ColorPipeline,
1061}
1062
1063/// How sources' colors come into the working space.
1064///
1065/// The working space is display-referred linear light with BT.709 primaries,
1066/// where `1.0` is SDR white (100 nits), and every export is SDR BT.709. SDR
1067/// sources decode through the sRGB EOTF; wide-gamut ones are converted into
1068/// BT.709 primaries. An HDR (PQ or HLG) source is either refused or brought
1069/// in: linearized to display light, converted into BT.709 primaries, and
1070/// tone-mapped from [`Self::hdr_peak_nits`] to SDR white by the ITU-R BT.2390
1071/// EETF on each pixel's largest component, which keeps its hue.
1072#[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    /// What an HDR source does.
1077    #[serde(default)]
1078    pub hdr: HdrSources,
1079    /// The luminance, in nits, HDR sources are taken to peak at: PQ above it
1080    /// is clipped to it, HLG is displayed at it, and the tone map brings it
1081    /// to SDR white. Within [`HDR_PEAK_NITS_RANGE`].
1082    #[serde(default = "default_hdr_peak")]
1083    pub hdr_peak_nits: u32,
1084}
1085
1086/// Supported [`ColorPipeline::hdr_peak_nits`]: SDR white to PQ's ceiling.
1087pub 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/// What a project does with HDR sources.
1103#[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    /// Tone-map them into the SDR working space.
1108    #[default]
1109    ToneMap,
1110    /// Refuse to render them.
1111    Reject,
1112}
1113
1114/// The color space in which blend modes are evaluated.
1115///
1116/// Composition is always premultiplied; this chooses what the blend formulas
1117/// see. Design tools blend sRGB-encoded values, so overlay, soft-light, and
1118/// the other contrast modes look as they do there only in
1119/// [`BlendSpace::SrgbEncoded`].
1120#[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    /// Linear light, where blending is physically meaningful.
1125    #[default]
1126    LinearLight,
1127    /// sRGB-encoded values, as design tools and CSS blend.
1128    SrgbEncoded,
1129}
1130
1131/// An opaque color as a color picker shows it: straight, sRGB-encoded
1132/// components in the inclusive unit interval.
1133#[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    /// Red component.
1138    pub red: f32,
1139    /// Green component.
1140    pub green: f32,
1141    /// Blue component.
1142    pub blue: f32,
1143}
1144
1145impl Rgb {
1146    /// Opaque black, the default background.
1147    pub const BLACK: Self = Self::new(0.0, 0.0, 0.0);
1148
1149    /// Creates a color from sRGB-encoded components.
1150    #[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    /// Creates validated output settings.
1164    ///
1165    /// # Errors
1166    /// Returns a model error when dimensions or sample rate exceed supported bounds.
1167    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    /// Replaces the background color.
1187    ///
1188    /// # Errors
1189    /// Returns [`ModelError::InvalidBackground`] for a non-finite or
1190    /// out-of-range component.
1191    pub fn with_background(mut self, background: Rgb) -> Result<Self, ModelError> {
1192        self.background = background;
1193        self.validate()?;
1194        Ok(self)
1195    }
1196
1197    /// Checks that output settings are usable.
1198    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}