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    /// [`ProjectLoadError`] for a malformed, unsupported, or structurally invalid document.
129    pub fn from_json(json: &str) -> Result<Self, ProjectLoadError> {
130        // Reads only the version and skips every other field. A failed probe leaves the error to
131        // the full parse below.
132        #[derive(Deserialize)]
133        struct VersionProbe {
134            schema_version: u32,
135        }
136
137        if json.len() > MAX_PROJECT_JSON_BYTES {
138            return Err(ProjectLoadError::TooLarge {
139                bytes: json.len(),
140                maximum: MAX_PROJECT_JSON_BYTES,
141            });
142        }
143        if let Ok(VersionProbe { schema_version }) = serde_json::from_str(json)
144            && schema_version != CURRENT_SCHEMA_VERSION
145        {
146            return Err(ProjectLoadError::InvalidProject(
147                ModelError::UnsupportedSchemaVersion {
148                    found: schema_version,
149                },
150            ));
151        }
152        let wire = serde_json::from_str::<ProjectWire>(json).map_err(ProjectLoadError::Json)?;
153        Self::try_from(wire).map_err(ProjectLoadError::InvalidProject)
154    }
155
156    /// The sources a render of this project can read, in source order: each clip's own, and
157    /// those read by media nodes its compositions draw.
158    ///
159    /// From a composition's output the walk follows the inputs each node reads
160    /// ([`crate::CompositionReads::needed_inputs`], as the renderer plans them), into group and
161    /// composition-source compositions. A composition source counts only where the clip's own
162    /// composition draws the clip ([`crate::CompositionReads::draws_clip`]). Unconnected nodes,
163    /// unshown compositions, inputs a bypassed node skips and unread group ports read nothing.
164    #[must_use]
165    pub fn used_sources(&self) -> Vec<&MediaSource> {
166        let sources = self
167            .sources
168            .iter()
169            .map(|source| (&source.id, source))
170            .collect::<HashMap<_, _>>();
171        let compositions = self
172            .compositions
173            .iter()
174            .map(|composition| (&composition.id, composition))
175            .collect::<HashMap<_, _>>();
176        let reads = crate::CompositionReads::new(&compositions);
177        let mut used = HashSet::new();
178        let mut walked = HashSet::new();
179        let mut pending = Vec::new();
180        for clip in self.timeline.tracks.iter().flat_map(|track| &track.clips) {
181            used.insert(&clip.source_id);
182            // A composition source is the clip's picture, drawn only where the clip's own
183            // composition draws the clip.
184            let drawn = clip
185                .composition
186                .as_ref()
187                .is_none_or(|reference| reads.draws_clip(&reference.id));
188            pending.extend(
189                sources
190                    .get(&clip.source_id)
191                    .and_then(|source| source.composition.as_ref())
192                    .filter(|_| drawn),
193            );
194            pending.extend(clip.composition.as_ref().map(|reference| &reference.id));
195        }
196        // Validation rules out cycles. `walked` also keeps a composition that many clips or groups
197        // draw from being walked again.
198        while let Some(id) = pending.pop() {
199            if !walked.insert(id) {
200                continue;
201            }
202            let Some(composition) = compositions.get(id) else {
203                continue;
204            };
205            let nodes = composition
206                .nodes
207                .iter()
208                .map(|node| (&node.id, node))
209                .collect::<HashMap<_, _>>();
210            let mut reached = HashSet::new();
211            let mut frontier = composition.output.iter().collect::<Vec<_>>();
212            while let Some(node) = frontier.pop() {
213                let Some(node) = nodes.get(node).filter(|_| reached.insert(node)) else {
214                    continue;
215                };
216                match &node.op {
217                    crate::NodeOp::Media { source_id, .. } => {
218                        used.insert(source_id);
219                        pending.extend(
220                            sources
221                                .get(source_id)
222                                .and_then(|source| source.composition.as_ref()),
223                        );
224                    }
225                    crate::NodeOp::Group { composition, .. } => pending.push(composition),
226                    _ => {}
227                }
228                frontier.extend(
229                    reads
230                        .needed_inputs(node)
231                        .into_iter()
232                        .filter_map(crate::NodeInput::node),
233                );
234            }
235        }
236        self.sources
237            .iter()
238            .filter(|source| used.contains(&source.id))
239            .collect()
240    }
241
242    /// Returns the HDR sources (PQ, HLG or declared HDR, per
243    /// [`alpha2_media::ColorMetadata::is_hdr`]), which the project tone-maps or refuses as its
244    /// [`ColorPipeline`] says.
245    #[must_use]
246    pub fn hdr_sources(&self) -> Vec<&alpha2_media::SourceId> {
247        self.sources
248            .iter()
249            .filter(|source| source.effective_color_metadata().is_hdr())
250            .map(|source| &source.id)
251            .collect()
252    }
253
254    /// Whether anything in the project may draw text: a caption track, or a text or layout
255    /// generator in a source or composition. A project that draws none needs no fonts. Layouts
256    /// count even without text because the text host resolves them.
257    #[must_use]
258    pub fn draws_text(&self) -> bool {
259        let is_text = |generator: &crate::Generator| {
260            matches!(
261                generator,
262                crate::Generator::Text { .. } | crate::Generator::Layout { .. }
263            )
264        };
265        self.timeline
266            .tracks
267            .iter()
268            .any(|track| track.captions.is_some())
269            || self
270                .sources
271                .iter()
272                .any(|source| source.generator.as_ref().is_some_and(is_text))
273            || self.compositions.iter().any(|composition| {
274                composition.nodes.iter().any(|node| {
275                    matches!(&node.op, crate::NodeOp::Generator { generator } if is_text(generator))
276                })
277            })
278    }
279
280    /// Creates an empty project using the current schema version.
281    #[must_use]
282    pub fn new(id: ProjectId, name: impl Into<String>, settings: ProjectSettings) -> Self {
283        Self {
284            schema_version: CURRENT_SCHEMA_VERSION,
285            id,
286            name: name.into(),
287            settings,
288            sources: Vec::new(),
289            effect_resources: Vec::new(),
290            compositions: Vec::new(),
291            timeline: Timeline { tracks: Vec::new() },
292            audio: MasterAudio::default(),
293        }
294    }
295
296    /// Checks structural invariants without performing media I/O.
297    ///
298    /// # Errors
299    /// Returns the first invalid document invariant encountered.
300    pub fn validate(&self) -> Result<(), ModelError> {
301        if self.schema_version != CURRENT_SCHEMA_VERSION {
302            return Err(ModelError::UnsupportedSchemaVersion {
303                found: self.schema_version,
304            });
305        }
306        self.settings.validate()?;
307        ensure_id(&self.id)?;
308        if !self.audio.validate() {
309            return Err(ModelError::InvalidMasterAudio);
310        }
311        if self.sources.len() > MAX_CLIPS {
312            return Err(ModelError::TooManySources {
313                found: self.sources.len(),
314                maximum: MAX_CLIPS,
315            });
316        }
317        if self.timeline.tracks.len() > MAX_TRACKS {
318            return Err(ModelError::TooManyTracks {
319                found: self.timeline.tracks.len(),
320                maximum: MAX_TRACKS,
321            });
322        }
323
324        let sources = self.validate_sources()?;
325        let effect_resource_ids = self.validate_effect_resources()?;
326        let inlined = crate::composition::validate_compositions(
327            &self.compositions,
328            &self.sources,
329            &sources,
330            &effect_resource_ids,
331            (self.settings.width, self.settings.height),
332        )?;
333        let index = DocumentIndex {
334            sources,
335            compositions: self
336                .compositions
337                .iter()
338                .map(|composition| &composition.id)
339                .collect(),
340        };
341
342        let mut track_ids = HashSet::new();
343        let mut clip_ids = HashSet::new();
344        let mut clip_count = 0_usize;
345        for track in &self.timeline.tracks {
346            ensure_id(&track.id)?;
347            if !track_ids.insert(&track.id) {
348                return Err(ModelError::DuplicateTrackId(track.id.clone()));
349            }
350            if track.clips.len() > MAX_CLIPS_PER_TRACK {
351                return Err(ModelError::TooManyClipsOnTrack {
352                    track: track.id.clone(),
353                    found: track.clips.len(),
354                    maximum: MAX_CLIPS_PER_TRACK,
355                });
356            }
357            validate_captions(track)?;
358            if track.transitions.len() > MAX_TRANSITIONS_PER_TRACK {
359                return Err(ModelError::TooManyTransitions {
360                    track: track.id.clone(),
361                    found: track.transitions.len(),
362                    maximum: MAX_TRANSITIONS_PER_TRACK,
363                });
364            }
365            clip_count =
366                clip_count
367                    .checked_add(track.clips.len())
368                    .ok_or(ModelError::TooManyClips {
369                        found: usize::MAX,
370                        maximum: MAX_CLIPS,
371                    })?;
372            if clip_count > MAX_CLIPS {
373                return Err(ModelError::TooManyClips {
374                    found: clip_count,
375                    maximum: MAX_CLIPS,
376                });
377            }
378            for clip in &track.clips {
379                validate_clip(&index, track, clip, &mut clip_ids)?;
380            }
381            for transition in &track.transitions {
382                ensure_effect_resources_exist(
383                    &transition.effect,
384                    &effect_resource_ids,
385                    &transition.to_clip,
386                )?;
387            }
388            ensure_transitions_are_supported(track)?;
389            if !track.audio.validate() {
390                return Err(ModelError::InvalidTrackAudio {
391                    track: track.id.clone(),
392                });
393            }
394            ensure_overlaps_have_transitions(track)?;
395        }
396        self.ensure_audio_effects_fit()?;
397        ensure_ducking_is_acyclic(&self.timeline)?;
398        ensure_mattes_are_usable(&self.timeline)?;
399        ensure_frames_fit(&self.timeline, &index.sources, &inlined)?;
400        Ok(())
401    }
402
403    /// Checks each source, and returns them by ID.
404    fn validate_sources(
405        &self,
406    ) -> Result<HashMap<&alpha2_media::SourceId, &MediaSource>, ModelError> {
407        let mut sources = HashMap::with_capacity(self.sources.len());
408        for source in &self.sources {
409            ensure_id(&source.id)?;
410            validate_source_content(source, (self.settings.width, self.settings.height))?;
411            if sources.insert(&source.id, source).is_some() {
412                return Err(ModelError::DuplicateSourceId(source.id.clone()));
413            }
414        }
415        Ok(sources)
416    }
417
418    /// Bounds the audio effect state held at once (see [`Project::audio_effect_load`]) to
419    /// [`crate::MAX_AUDIO_EFFECTS`] effects and [`crate::MAX_AUDIO_DELAY_MS`] of delay.
420    fn ensure_audio_effects_fit(&self) -> Result<(), ModelError> {
421        let load = self.audio_effect_load();
422        if load.effects > crate::MAX_AUDIO_EFFECTS {
423            return Err(ModelError::TooManyAudioEffects {
424                found: load.effects,
425                maximum: crate::MAX_AUDIO_EFFECTS,
426                at_ticks: load.effects_at_ticks,
427            });
428        }
429        if load.delay_ms() > u64::from(crate::MAX_AUDIO_DELAY_MS) {
430            return Err(ModelError::TooMuchAudioDelay {
431                found_ms: load.delay_ms(),
432                maximum_ms: crate::MAX_AUDIO_DELAY_MS,
433                at_ticks: load.delay_at_ticks,
434            });
435        }
436        Ok(())
437    }
438
439    fn validate_effect_resources(&self) -> Result<HashSet<&EffectResourceId>, ModelError> {
440        let mut ids = HashSet::new();
441        for resource in &self.effect_resources {
442            ensure_id(&resource.id)?;
443            if resource.locator.is_empty() {
444                return Err(ModelError::EmptyEffectResourceLocator {
445                    resource: resource.id.clone(),
446                });
447            }
448            if !ids.insert(&resource.id) {
449                return Err(ModelError::DuplicateEffectResourceId(resource.id.clone()));
450            }
451        }
452        Ok(ids)
453    }
454}
455
456fn ensure_effect_resources_exist(
457    effect: &crate::ClipEffect,
458    known: &HashSet<&EffectResourceId>,
459    clip: &crate::ClipId,
460) -> Result<(), ModelError> {
461    for resource in effect.resources.values() {
462        if !known.contains(resource) {
463            return Err(ModelError::UnknownEffectResource {
464                clip: clip.clone(),
465                resource: resource.clone(),
466            });
467        }
468    }
469    Ok(())
470}
471
472/// Checks one clip's identity, ranges, source compatibility, and visual and audio settings.
473fn validate_clip<'a>(
474    index: &DocumentIndex<'a>,
475    track: &'a crate::Track,
476    clip: &'a crate::Clip,
477    clip_ids: &mut HashSet<&'a crate::ClipId>,
478) -> Result<(), ModelError> {
479    ensure_id(&clip.id)?;
480    if clip.timeline_range.is_empty() || clip.source_range.is_empty() {
481        return Err(ModelError::EmptyClipRange {
482            clip: clip.id.clone(),
483        });
484    }
485    if clip.timeline_range.start().ticks() < 0 || clip.source_range.start().ticks() < 0 {
486        return Err(ModelError::NegativeClipRange {
487            clip: clip.id.clone(),
488        });
489    }
490    if !clip_ids.insert(&clip.id) {
491        return Err(ModelError::DuplicateClipId(clip.id.clone()));
492    }
493    let Some(source) = index.sources.get(&clip.source_id) else {
494        return Err(ModelError::UnknownClipSource {
495            clip: clip.id.clone(),
496            source: clip.source_id.clone(),
497        });
498    };
499    if !track.kind.accepts(source.kind) {
500        return Err(ModelError::IncompatibleClipSource {
501            clip: clip.id.clone(),
502            track: track.id.clone(),
503            source: source.id.clone(),
504        });
505    }
506    if !clip.visual.validate() {
507        return Err(ModelError::InvalidVisualProperties {
508            clip: clip.id.clone(),
509        });
510    }
511    if let Some(composition) = &clip.composition {
512        // Check the reference's own terms first since what follows computes with them. Composition
513        // time grows with timeline time, so the clip's last instant has the largest value.
514        let held = || {
515            let last = MediaTime::from_ticks(clip.timeline_range.end().ticks().saturating_sub(1));
516            composition
517                .local_time(clip.timeline_range.start(), last)
518                .is_some_and(|time| time.ticks() <= alpha2_time::MAX_SERIALIZED_TICKS)
519        };
520        if track.kind != TrackKind::Video || !composition.scale.is_valid() || !held() {
521            return Err(ModelError::InvalidClipComposition {
522                clip: clip.id.clone(),
523            });
524        }
525        if !index.compositions.contains(&composition.id) {
526            return Err(ModelError::UnknownComposition {
527                clip: clip.id.clone(),
528                composition: composition.id.clone(),
529            });
530        }
531    }
532    if !clip.audio.validate() {
533        return Err(ModelError::InvalidAudioProperties {
534            clip: clip.id.clone(),
535        });
536    }
537    let visual = track.kind == TrackKind::Video;
538    if clip.masks.len() > crate::MAX_CLIP_MASKS || (!visual && !clip.masks.is_empty()) {
539        return Err(ModelError::InvalidClipMask {
540            clip: clip.id.clone(),
541        });
542    }
543    crate::mask::validate_masks(&clip.masks).map_err(|issue| ModelError::InvalidShape {
544        owner: crate::ShapeOwner::Clip(clip.id.clone()),
545        at: issue.at,
546        problem: issue.problem,
547    })?;
548    if !visual && clip.matte.is_some() {
549        return Err(ModelError::InvalidMatte {
550            clip: clip.id.clone(),
551        });
552    }
553    if let crate::TimeMap::Freeze { source_time } = clip.time_map
554        && !clip.source_range.contains(source_time)
555    {
556        return Err(ModelError::InvalidTimeMap {
557            clip: clip.id.clone(),
558        });
559    }
560    Ok(())
561}
562
563/// The document's sources and compositions by ID, so validation looks up each reference once.
564struct DocumentIndex<'a> {
565    sources: HashMap<&'a alpha2_media::SourceId, &'a MediaSource>,
566    compositions: HashSet<&'a crate::CompositionId>,
567}
568
569/// Rejects transitions on audio tracks, transitions not fully covered by both clips, and a
570/// second transition between two clips.
571fn ensure_transitions_are_supported(track: &crate::Track) -> Result<(), ModelError> {
572    let clips = track
573        .clips
574        .iter()
575        .map(|clip| (&clip.id, clip))
576        .collect::<HashMap<_, _>>();
577    let mut pairs = HashSet::with_capacity(track.transitions.len());
578    for transition in &track.transitions {
579        let covers = |id: &crate::ClipId| {
580            clips.get(id).is_some_and(|clip| {
581                clip.timeline_range.start() <= transition.timeline_range.start()
582                    && clip.timeline_range.end() >= transition.timeline_range.end()
583            })
584        };
585        let valid = track.kind == TrackKind::Video
586            && transition.from_clip != transition.to_clip
587            && !transition.timeline_range.is_empty()
588            && transition.easing.is_valid()
589            && transition.effect.is_valid()
590            && covers(&transition.from_clip)
591            && covers(&transition.to_clip);
592        if !valid {
593            return Err(ModelError::InvalidClipTransition {
594                track: track.id.clone(),
595            });
596        }
597        // The engine folds one transition over another: the same pair twice mixes twice, and both
598        // directions hide both clips.
599        let pair = if transition.from_clip < transition.to_clip {
600            (&transition.from_clip, &transition.to_clip)
601        } else {
602            (&transition.to_clip, &transition.from_clip)
603        };
604        if !pairs.insert(pair) {
605            return Err(ModelError::DuplicateTransition {
606                track: track.id.clone(),
607                first: transition.from_clip.clone(),
608                second: transition.to_clip.clone(),
609            });
610        }
611    }
612    Ok(())
613}
614
615/// Rejects sidechain references to missing tracks, the referencing track itself, or any
616/// cycle between ducked tracks.
617///
618/// Either track kind may duck or be ducked. The track reported is the first in document
619/// order with an unusable sidechain. Each track ID is looked up once, so the check is linear
620/// in the tracks however long the chains.
621fn ensure_ducking_is_acyclic(timeline: &Timeline) -> Result<(), ModelError> {
622    fn sidechain_of(track: &crate::Track) -> Option<&crate::TrackId> {
623        track
624            .audio
625            .ducking
626            .as_ref()
627            .map(|ducking| &ducking.sidechain_track)
628    }
629    // Track IDs are unique by now, so each names one position.
630    let positions = timeline
631        .tracks
632        .iter()
633        .enumerate()
634        .map(|(position, track)| (&track.id, position))
635        .collect::<HashMap<_, _>>();
636    let next = timeline
637        .tracks
638        .iter()
639        .map(|track| sidechain_of(track).and_then(|sidechain| positions.get(sidechain).copied()))
640        .collect::<Vec<_>>();
641    let cyclic = tracks_on_cycles(&next);
642    for ((track, next), on_cycle) in timeline.tracks.iter().zip(next).zip(cyclic) {
643        let Some(sidechain) = sidechain_of(track) else {
644            continue;
645        };
646        // A missing sidechain, or one that leads back to this track (itself included).
647        if next.is_none() || on_cycle {
648            return Err(ModelError::InvalidDuckingSidechain {
649                track: track.id.clone(),
650                sidechain: sidechain.clone(),
651            });
652        }
653    }
654    Ok(())
655}
656
657/// Which tracks lie on a sidechain cycle, given for each track the position of the track named
658/// as its sidechain.
659///
660/// Each track names at most one sidechain, so a track is on a cycle exactly when its own walk
661/// returns to it. Each walk stops at the first track an earlier walk reached, so every track
662/// is visited once overall.
663fn tracks_on_cycles(next: &[Option<usize>]) -> Vec<bool> {
664    const UNVISITED: usize = usize::MAX;
665    let mut cyclic = vec![false; next.len()];
666    // The walk that first reached each track; a walk that meets its own mark has closed a cycle.
667    let mut walk_of = vec![UNVISITED; next.len()];
668    for start in 0..next.len() {
669        let mut cursor = Some(start);
670        while let Some(position) = cursor {
671            match walk_of[position] {
672                UNVISITED => {
673                    walk_of[position] = start;
674                    cursor = next[position];
675                }
676                walk if walk == start => {
677                    // Mark the cycle by going round it once from where the walk closed it.
678                    let mut member = position;
679                    loop {
680                        cyclic[member] = true;
681                        match next[member] {
682                            Some(following) if following != position => member = following,
683                            _ => break,
684                        }
685                    }
686                    break;
687                }
688                _ => break,
689            }
690        }
691    }
692    cyclic
693}
694
695/// Rejects a matte that names no visual clip, the clip itself, a clip with its own matte, or
696/// a clip in a transition.
697///
698/// A chain of mattes would need an evaluation order the document does not give, and a clip
699/// in a transition is drawn by the transition.
700fn ensure_mattes_are_usable(timeline: &Timeline) -> Result<(), ModelError> {
701    let visual = timeline
702        .tracks
703        .iter()
704        .filter(|track| track.kind == TrackKind::Video)
705        .flat_map(|track| track.clips.iter().map(|clip| (&clip.id, clip)))
706        .collect::<HashMap<_, _>>();
707    let in_transition = timeline
708        .tracks
709        .iter()
710        .flat_map(|track| &track.transitions)
711        .flat_map(|transition| [&transition.from_clip, &transition.to_clip])
712        .collect::<HashSet<_>>();
713    // In document order, so the reported clip is the same on every platform.
714    let clips = timeline
715        .tracks
716        .iter()
717        .filter(|track| track.kind == TrackKind::Video)
718        .flat_map(|track| &track.clips);
719    for clip in clips {
720        let Some(matte) = &clip.matte else {
721            continue;
722        };
723        let usable = matte.source != clip.id
724            && visual
725                .get(&matte.source)
726                .is_some_and(|source| source.matte.is_none())
727            && !in_transition.contains(&matte.source);
728        if !usable {
729            return Err(ModelError::InvalidMatte {
730                clip: clip.id.clone(),
731            });
732        }
733    }
734    Ok(())
735}
736
737/// Checks that the clips at no instant draw more than [`crate::MAX_FRAME_INLINED_NODES`]
738/// composition nodes together. A composition compiles once however many clips show it, but
739/// each clip on screen draws all of it.
740///
741/// The clip reported is the first, in document order, starting at the first instant over the
742/// bound; the count covers all clips there.
743fn ensure_frames_fit(
744    timeline: &Timeline,
745    sources: &HashMap<&alpha2_media::SourceId, &MediaSource>,
746    inlined: &crate::composition::InlinedNodes<'_>,
747) -> Result<(), ModelError> {
748    let nodes = |id: Option<&crate::CompositionId>| id.and_then(|id| inlined.get(id)).copied();
749    // Each clip drawing a composition, with its node count, at its start (+) and end (-).
750    let mut edges = Vec::new();
751    for clip in timeline.tracks.iter().flat_map(|track| &track.clips) {
752        let source = sources
753            .get(&clip.source_id)
754            .and_then(|source| source.composition.as_ref());
755        let drawn = nodes(clip.composition.as_ref().map(|reference| &reference.id))
756            .unwrap_or(0)
757            .saturating_add(nodes(source).unwrap_or(0));
758        if drawn > 0 {
759            edges.push((clip.timeline_range.start(), true, drawn, &clip.id));
760            edges.push((clip.timeline_range.end(), false, drawn, &clip.id));
761        }
762    }
763    // By instant, ends before starts since ranges are half-open, and in document order since the
764    // sort is stable.
765    edges.sort_by_key(|&(time, starts, ..)| (time, starts));
766    let mut drawn = 0_u64;
767    let mut edges = edges.into_iter().peekable();
768    while let Some((time, starts, nodes, clip)) = edges.next() {
769        if !starts {
770            drawn -= nodes;
771            continue;
772        }
773        drawn = drawn.saturating_add(nodes);
774        // Every clip starting at this instant, so the count is the frame's.
775        while let Some((_, _, nodes, _)) =
776            edges.next_if(|&(next, next_starts, ..)| next == time && next_starts)
777        {
778            drawn = drawn.saturating_add(nodes);
779        }
780        if drawn > crate::MAX_FRAME_INLINED_NODES as u64 {
781            return Err(ModelError::FrameDrawsTooManyNodes {
782                clip: clip.clone(),
783                found: drawn,
784                maximum: crate::MAX_FRAME_INLINED_NODES,
785            });
786        }
787    }
788    Ok(())
789}
790
791/// Rejects visual overlaps that no transition covers.
792///
793/// Two overlapping visual clips have no defined composite without a transition. Audio tracks
794/// may overlap freely because the mixer sums them.
795pub(crate) fn ensure_overlaps_have_transitions(track: &crate::Track) -> Result<(), ModelError> {
796    if track.kind != TrackKind::Video {
797        return Ok(());
798    }
799    let transitions = track
800        .transitions
801        .iter()
802        .flat_map(|transition| {
803            [
804                (
805                    &transition.from_clip,
806                    &transition.to_clip,
807                    transition.timeline_range,
808                ),
809                (
810                    &transition.to_clip,
811                    &transition.from_clip,
812                    transition.timeline_range,
813                ),
814            ]
815        })
816        .collect::<HashSet<_>>();
817    let directed = track
818        .transitions
819        .iter()
820        .map(|transition| (&transition.from_clip, &transition.to_clip))
821        .collect::<HashSet<_>>();
822    let mut ordered = track.clips.iter().collect::<Vec<_>>();
823    ordered.sort_by_key(|clip| (clip.timeline_range.start(), clip.timeline_range.end()));
824    let mut active = Vec::new();
825    for second in ordered {
826        active.retain(|first: &&crate::Clip| {
827            first.timeline_range.end() > second.timeline_range.start()
828        });
829        for first in &active {
830            let Some(intersection) = first.timeline_range.intersection(second.timeline_range)
831            else {
832                continue;
833            };
834            if !transitions.contains(&(&first.id, &second.id, intersection)) {
835                return Err(ModelError::OverlappingClips {
836                    track: track.id.clone(),
837                    first: first.id.clone(),
838                    second: second.id.clone(),
839                });
840            }
841        }
842        active.push(second);
843        // Every clip active here covers this instant, and so does the transition between each two.
844        // The engine folds them clip to clip, which needs no cycle: a tournament is acyclic exactly
845        // when its clips lead to 0, 1, ..., k - 1 others. A set only grows at a start, so starts
846        // are the instants to check.
847        if active.len() >= 3 {
848            let mut leads = active
849                .iter()
850                .map(|from| {
851                    active
852                        .iter()
853                        .filter(|to| directed.contains(&(&from.id, &to.id)))
854                        .count()
855                })
856                .collect::<Vec<_>>();
857            leads.sort_unstable();
858            if leads.iter().enumerate().any(|(rank, lead)| rank != *lead) {
859                return Err(ModelError::TransitionCycle {
860                    track: track.id.clone(),
861                    at: second.timeline_range.start(),
862                });
863            }
864        }
865    }
866    Ok(())
867}
868
869/// Checks that a caption track holds only valid captions and that no other track holds any.
870fn validate_captions(track: &crate::Track) -> Result<(), ModelError> {
871    if let Some(captions) = &track.captions
872        && captions.cues.len() > MAX_CLIPS_PER_TRACK
873    {
874        return Err(ModelError::TooManyCaptionCues {
875            track: track.id.clone(),
876            found: captions.cues.len(),
877            maximum: MAX_CLIPS_PER_TRACK,
878        });
879    }
880    let valid = match (&track.kind, &track.captions) {
881        (TrackKind::Caption, Some(captions)) => {
882            if !(track.clips.is_empty() && track.transitions.is_empty()) {
883                return Err(ModelError::InvalidCaptions {
884                    track: track.id.clone(),
885                });
886            }
887            match captions.validate() {
888                Ok(()) => true,
889                Err(crate::caption::CaptionIssue::Invalid) => false,
890                Err(crate::caption::CaptionIssue::UnknownStyle { cue, name }) => {
891                    return Err(ModelError::UnknownCaptionStyle {
892                        track: track.id.clone(),
893                        cue,
894                        name,
895                    });
896                }
897                Err(crate::caption::CaptionIssue::CueStyle { cue }) => {
898                    return Err(ModelError::InvalidCaptionCueStyle {
899                        track: track.id.clone(),
900                        cue,
901                    });
902                }
903            }
904        }
905        (TrackKind::Caption, None) => false,
906        (_, captions) => captions.is_none(),
907    };
908    if valid {
909        Ok(())
910    } else {
911        Err(ModelError::InvalidCaptions {
912            track: track.id.clone(),
913        })
914    }
915}
916
917/// Checks one source. A generated source has a valid generator and nothing a host would
918/// supply; any other source has a locator and no generator, and an image has no audio. A
919/// declared color is HDR exactly when its transfer is PQ or HLG. `canvas` is the project's,
920/// which a layout's pixel lengths refer to.
921fn validate_source_content(source: &MediaSource, canvas: (u32, u32)) -> Result<(), ModelError> {
922    if let Some(fingerprint) = &source.fingerprint
923        && (source.kind == MediaKind::Generated || !fingerprint.is_valid())
924    {
925        return Err(ModelError::InvalidFingerprint {
926            source: source.id.clone(),
927        });
928    }
929    if source
930        .color_metadata
931        .is_some_and(|color| !color.is_consistent())
932    {
933        return Err(ModelError::ContradictoryColor {
934            source: source.id.clone(),
935        });
936    }
937    if let Some(provider) = &source.provider
938        && (provider.kind() != source.kind || source.has_audio || !provider.is_valid())
939    {
940        return Err(ModelError::InvalidProvider {
941            source: source.id.clone(),
942        });
943    }
944    if source.kind == MediaKind::Composition {
945        // Whether the composition exists is checked with the compositions.
946        let valid = source.composition.is_some()
947            && source.generator.is_none()
948            && source.locator.is_empty()
949            && source.color_metadata.is_none()
950            && source.fingerprint.is_none()
951            && source.provider.is_none()
952            && !source.has_audio;
953        return if valid {
954            Ok(())
955        } else {
956            Err(ModelError::InvalidCompositionSource {
957                source: source.id.clone(),
958            })
959        };
960    }
961    if source.composition.is_some() {
962        return Err(ModelError::InvalidCompositionSource {
963            source: source.id.clone(),
964        });
965    }
966    if source.kind == MediaKind::Generated {
967        let owner = || crate::ShapeOwner::Source(source.id.clone());
968        match source
969            .generator
970            .as_ref()
971            .map(|generator| generator.validate_on(canvas.0, canvas.1))
972        {
973            Some(Err(crate::GeneratorIssue::Shape(issue))) => {
974                return Err(ModelError::InvalidShape {
975                    owner: owner(),
976                    at: issue.at,
977                    problem: issue.problem,
978                });
979            }
980            Some(Err(crate::GeneratorIssue::Layout(issue))) => {
981                return Err(ModelError::InvalidLayout {
982                    owner: owner(),
983                    at: issue.at,
984                    problem: issue.problem,
985                });
986            }
987            _ => {}
988        }
989        let valid = source.generator.as_ref().is_some_and(Generator::is_valid)
990            && source.locator.is_empty()
991            && source.color_metadata.is_none()
992            && !source.has_audio;
993        return if valid {
994            Ok(())
995        } else {
996            Err(ModelError::InvalidGenerator {
997                source: source.id.clone(),
998            })
999        };
1000    }
1001    if source.generator.is_some() {
1002        return Err(ModelError::InvalidGenerator {
1003            source: source.id.clone(),
1004        });
1005    }
1006    if let Some(checked) = validate_fileless_source(source) {
1007        return checked;
1008    }
1009    if source.locator.is_empty() {
1010        return Err(ModelError::EmptyLocator {
1011            source: source.id.clone(),
1012        });
1013    }
1014    if source.kind == MediaKind::Image && source.has_audio {
1015        return Err(ModelError::AudioOnImage {
1016            source: source.id.clone(),
1017        });
1018    }
1019    Ok(())
1020}
1021
1022/// Checks a source whose provider reads no file has no locator or fingerprint. `None` for any
1023/// source whose media is a file.
1024fn validate_fileless_source(source: &MediaSource) -> Option<Result<(), ModelError>> {
1025    let provider = source.provider.as_ref()?;
1026    (!provider.reads_file()).then(|| {
1027        if source.locator.is_empty() && source.fingerprint.is_none() {
1028            Ok(())
1029        } else {
1030            Err(ModelError::InvalidProvider {
1031                source: source.id.clone(),
1032            })
1033        }
1034    })
1035}
1036
1037fn ensure_id<T: fmt::Display>(id: &T) -> Result<(), ModelError> {
1038    if id.to_string().is_empty() {
1039        Err(ModelError::EmptyIdentifier)
1040    } else {
1041        Ok(())
1042    }
1043}
1044
1045/// Output-wide settings independent of a renderer or encoder.
1046#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
1047#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1048#[serde(deny_unknown_fields)]
1049#[non_exhaustive]
1050pub struct ProjectSettings {
1051    /// Canvas width in output pixels, at most [`MAX_CANVAS_DIMENSION`].
1052    pub width: u32,
1053    /// Canvas height in output pixels, at most [`MAX_CANVAS_DIMENSION`]. The canvas is at most
1054    /// [`MAX_CANVAS_PIXELS`] in area.
1055    pub height: u32,
1056    /// Timeline frame rate.
1057    pub frame_rate: FrameRate,
1058    /// Internal audio sample rate, within [`SAMPLE_RATE_RANGE`].
1059    pub sample_rate: u32,
1060    /// Opaque color under every layer, in preview and export alike, so empty canvas is never
1061    /// left to a host or encoder.
1062    #[serde(default)]
1063    pub background: Rgb,
1064    /// The space in which clips' blend modes are evaluated, in preview and export alike.
1065    #[serde(default)]
1066    pub blend_space: BlendSpace,
1067    /// How sources' colors come into the working space.
1068    #[serde(default)]
1069    pub color: ColorPipeline,
1070}
1071
1072/// How sources' colors come into the working space.
1073///
1074/// The working space is display-referred linear light with BT.709 primaries, `1.0` being SDR
1075/// white (100 nits); every export is SDR BT.709. SDR sources decode through the sRGB EOTF and
1076/// wide-gamut ones are converted to BT.709. An HDR (PQ or HLG) source is either refused or
1077/// linearized, converted to BT.709, and tone-mapped from [`Self::hdr_peak_nits`] to SDR white
1078/// by the ITU-R BT.2390 EETF on each pixel's largest component, preserving hue.
1079#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1080#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1081#[serde(deny_unknown_fields)]
1082pub struct ColorPipeline {
1083    /// What an HDR source does.
1084    #[serde(default)]
1085    pub hdr: HdrSources,
1086    /// The luminance, in nits, HDR sources are taken to peak at: PQ above it is clipped, HLG is
1087    /// displayed at it, and the tone map brings it to SDR white. Within [`HDR_PEAK_NITS_RANGE`].
1088    #[serde(default = "default_hdr_peak")]
1089    pub hdr_peak_nits: u32,
1090}
1091
1092/// Supported [`ColorPipeline::hdr_peak_nits`]: SDR white to PQ's ceiling.
1093pub const HDR_PEAK_NITS_RANGE: std::ops::RangeInclusive<u32> = 100..=10_000;
1094
1095const fn default_hdr_peak() -> u32 {
1096    1_000
1097}
1098
1099impl Default for ColorPipeline {
1100    fn default() -> Self {
1101        Self {
1102            hdr: HdrSources::default(),
1103            hdr_peak_nits: default_hdr_peak(),
1104        }
1105    }
1106}
1107
1108/// What a project does with HDR sources.
1109#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1110#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1111#[serde(rename_all = "snake_case")]
1112pub enum HdrSources {
1113    /// Tone-map them into the SDR working space.
1114    #[default]
1115    ToneMap,
1116    /// Refuse to render them.
1117    Reject,
1118}
1119
1120/// The color space in which blend modes are evaluated.
1121///
1122/// Compositing is always premultiplied; this chooses what the blend formulas see. Contrast
1123/// modes such as overlay and soft-light match design tools only in
1124/// [`BlendSpace::SrgbEncoded`].
1125#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1126#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1127#[serde(rename_all = "snake_case")]
1128pub enum BlendSpace {
1129    /// Linear light, where blending is physically meaningful.
1130    #[default]
1131    LinearLight,
1132    /// sRGB-encoded values, as design tools and CSS blend.
1133    SrgbEncoded,
1134}
1135
1136/// An opaque color as a color picker shows it: straight, sRGB-encoded components in `0..=1`.
1137#[derive(Copy, Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
1138#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1139#[serde(deny_unknown_fields)]
1140pub struct Rgb {
1141    /// Red component.
1142    pub red: f32,
1143    /// Green component.
1144    pub green: f32,
1145    /// Blue component.
1146    pub blue: f32,
1147}
1148
1149impl Rgb {
1150    /// Opaque black, the default background.
1151    pub const BLACK: Self = Self::new(0.0, 0.0, 0.0);
1152
1153    /// Creates a color from sRGB-encoded components.
1154    #[must_use]
1155    pub const fn new(red: f32, green: f32, blue: f32) -> Self {
1156        Self { red, green, blue }
1157    }
1158
1159    fn is_valid(self) -> bool {
1160        [self.red, self.green, self.blue]
1161            .into_iter()
1162            .all(|component| component.is_finite() && (0.0..=1.0).contains(&component))
1163    }
1164}
1165
1166impl ProjectSettings {
1167    /// Creates validated output settings.
1168    ///
1169    /// # Errors
1170    /// Returns a model error when dimensions or sample rate exceed supported bounds.
1171    pub fn new(
1172        width: u32,
1173        height: u32,
1174        frame_rate: FrameRate,
1175        sample_rate: u32,
1176    ) -> Result<Self, ModelError> {
1177        let settings = Self {
1178            width,
1179            height,
1180            frame_rate,
1181            sample_rate,
1182            background: Rgb::BLACK,
1183            blend_space: BlendSpace::LinearLight,
1184            color: ColorPipeline::default(),
1185        };
1186        settings.validate()?;
1187        Ok(settings)
1188    }
1189
1190    /// Replaces the background color.
1191    ///
1192    /// # Errors
1193    /// Returns [`ModelError::InvalidBackground`] for a non-finite or out-of-range component.
1194    pub fn with_background(mut self, background: Rgb) -> Result<Self, ModelError> {
1195        self.background = background;
1196        self.validate()?;
1197        Ok(self)
1198    }
1199
1200    fn validate(&self) -> Result<(), ModelError> {
1201        if !HDR_PEAK_NITS_RANGE.contains(&self.color.hdr_peak_nits) {
1202            return Err(ModelError::InvalidColorPipeline);
1203        }
1204        if self.width == 0 || self.height == 0 {
1205            return Err(ModelError::ZeroCanvasDimension);
1206        }
1207        if self.width > MAX_CANVAS_DIMENSION || self.height > MAX_CANVAS_DIMENSION {
1208            return Err(ModelError::CanvasDimensionTooLarge {
1209                width: self.width,
1210                height: self.height,
1211                maximum: MAX_CANVAS_DIMENSION,
1212            });
1213        }
1214        let pixels = u64::from(self.width) * u64::from(self.height);
1215        if pixels > MAX_CANVAS_PIXELS {
1216            return Err(ModelError::CanvasPixelBudgetExceeded {
1217                pixels,
1218                maximum: MAX_CANVAS_PIXELS,
1219            });
1220        }
1221        if !self.background.is_valid() {
1222            return Err(ModelError::InvalidBackground);
1223        }
1224        if !SAMPLE_RATE_RANGE.contains(&self.sample_rate) {
1225            return Err(ModelError::UnsupportedSampleRate {
1226                found: self.sample_rate,
1227                minimum: *SAMPLE_RATE_RANGE.start(),
1228                maximum: *SAMPLE_RATE_RANGE.end(),
1229            });
1230        }
1231        Ok(())
1232    }
1233}