Skip to main content

alpha2_model/
lint.rs

1//! Non-fatal diagnostics: what a valid project probably does not mean to do.
2//!
3//! [`Project::lint`] never refuses a project. Checks are conservative: every reported issue
4//! is certain, and a missed one is not an error.
5
6use crate::{
7    Animated, AnimatedPoint, ClipId, CompositionId, Generator, MediaKind, NodeId, Paint, Project,
8    ShapeGeometry, ShapeItem, TrackId, TrackKind,
9};
10use alpha2_media::SourceId;
11use alpha2_time::{FrameRate, MediaTime, TICKS_PER_SECOND, TimeRange};
12use serde::Serialize;
13use std::collections::{HashMap, HashSet};
14
15/// Something in a project that is valid but probably not meant.
16#[derive(Clone, Debug, PartialEq, Serialize)]
17#[serde(tag = "kind", rename_all = "snake_case")]
18#[non_exhaustive]
19pub enum LintIssue {
20    /// A keyframe that no shown time depends on, checked in the owner's own time (timeline,
21    /// composition, or picture time).
22    UnreachedKeyframe {
23        /// What the keyframe animates.
24        owner: LintOwner,
25        /// The animated property as a path, such as `visual.opacity`, `masks[0].feather`,
26        /// `effect.params.radius` or `items[0].transform.opacity`.
27        property: String,
28        /// The keyframe's time.
29        time: MediaTime,
30    },
31    /// A clip wholly covered for its whole time by an opaque full-frame clip on a later track.
32    /// Matte clips are never reported, since they are never composited.
33    HiddenClip {
34        /// The covered clip.
35        clip: ClipId,
36        /// The clip that covers it.
37        by: ClipId,
38    },
39    /// A transition no project frame shows as a mix: shorter than a frame, or about a frame long
40    /// and starting between frames.
41    ShortTransition {
42        /// Its track.
43        track: TrackId,
44        /// Its first clip.
45        from_clip: ClipId,
46        /// Its second clip.
47        to_clip: ClipId,
48    },
49    /// A boundary that is not where a project frame starts, so the change shows at the next
50    /// frame. A frame starts at its exact start rounded down to a tick.
51    OffFrameBoundary {
52        /// Whose boundary.
53        owner: LintOwner,
54        /// Which end.
55        edge: LintEdge,
56        /// The boundary's time.
57        time: MediaTime,
58    },
59    /// A source no render reads: no clip shows it and no `media` node of a shown composition
60    /// reads it.
61    UnusedSource {
62        /// The source.
63        source: SourceId,
64    },
65    /// A high-dynamic-range source in a project whose color pipeline refuses HDR, so rendering
66    /// it fails.
67    HdrSource {
68        /// The source.
69        source: SourceId,
70    },
71    /// A path's subpath without vertices, which draws nothing.
72    EmptySubpath {
73        /// The shapes generator.
74        owner: ShapeLintOwner,
75        /// The empty subpath, like `items[0].path.subpaths[1]` or, in a layout,
76        /// `root.children[0].shape.items[0].path.subpaths[1]`.
77        at: String,
78    },
79    /// A fill or stroke with no geometry before it in its group, which paints nothing.
80    PaintWithoutGeometry {
81        /// The shapes generator.
82        owner: ShapeLintOwner,
83        /// The paint item, like `items[1].items[0]`.
84        at: String,
85    },
86    /// A caption style no cue names and no cue's speaker matches.
87    UnusedCaptionStyle {
88        /// The caption track.
89        track: TrackId,
90        /// The style's name.
91        style: String,
92    },
93    /// A text animator that sets no property, which changes nothing.
94    EmptyTextAnimator {
95        /// What holds the text.
96        owner: LintOwner,
97        /// The animator, like `style.animators[0]` or, in a layout,
98        /// `root.children[1].text_animators[0]`.
99        at: String,
100    },
101}
102
103/// The shapes or layout generator a shape [`LintIssue`] is about.
104#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
105#[serde(tag = "type", rename_all = "snake_case")]
106#[non_exhaustive]
107pub enum ShapeLintOwner {
108    /// A generated source's.
109    Source {
110        /// The source.
111        source: SourceId,
112    },
113    /// A composition's generator node's.
114    Node {
115        /// The composition.
116        composition: CompositionId,
117        /// The node.
118        node: NodeId,
119    },
120}
121
122/// What a [`LintIssue`] is about.
123#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
124#[serde(tag = "type", rename_all = "snake_case")]
125#[non_exhaustive]
126pub enum LintOwner {
127    /// A clip.
128    Clip {
129        /// The clip.
130        clip: ClipId,
131    },
132    /// A transition.
133    Transition {
134        /// Its track.
135        track: TrackId,
136        /// Its first clip.
137        from_clip: ClipId,
138        /// Its second clip.
139        to_clip: ClipId,
140    },
141    /// A caption cue.
142    Cue {
143        /// Its caption track.
144        track: TrackId,
145        /// Its position among the track's cues.
146        index: usize,
147    },
148    /// A composition's node, whose settings are keyed in composition time.
149    CompositionNode {
150        /// The composition.
151        composition: CompositionId,
152        /// The node.
153        node: NodeId,
154    },
155    /// A generated source, whose shapes or layout tree is keyed in picture time.
156    Source {
157        /// The source.
158        source: SourceId,
159    },
160    /// A caption track, whose style's animators are keyed in each cue's time from its start.
161    CaptionTrack {
162        /// The track.
163        track: TrackId,
164    },
165    /// A timed run of a caption cue, whose times are from the cue's start.
166    CueRun {
167        /// Its caption track.
168        track: TrackId,
169        /// Its cue's position among the track's cues.
170        cue: usize,
171        /// Its position among the cue's runs.
172        run: usize,
173    },
174}
175
176/// The end of a range a [`LintIssue`] is about.
177#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize)]
178#[serde(rename_all = "snake_case")]
179pub enum LintEdge {
180    /// The start.
181    Start,
182    /// The end.
183    End,
184}
185
186impl Project {
187    /// Diagnostics that do not stop the project rendering, in a fixed order: unused sources,
188    /// shapes generators (sources', then composition nodes'), HDR sources, each track's clips,
189    /// transitions and cues, composition nodes, then generated sources' trees.
190    #[must_use]
191    pub fn lint(&self) -> Vec<LintIssue> {
192        let mut issues = Vec::new();
193        let used = self
194            .used_sources()
195            .into_iter()
196            .map(|source| &source.id)
197            .collect::<std::collections::HashSet<_>>();
198        for source in &self.sources {
199            if !used.contains(&source.id) {
200                issues.push(LintIssue::UnusedSource {
201                    source: source.id.clone(),
202                });
203            }
204        }
205        self.lint_shapes(&mut issues);
206        let refused = self.settings.color.hdr == crate::HdrSources::Reject;
207        for source in self.hdr_sources().into_iter().filter(|_| refused) {
208            issues.push(LintIssue::HdrSource {
209                source: source.clone(),
210            });
211        }
212        let mattes = self
213            .timeline
214            .tracks
215            .iter()
216            .flat_map(|track| &track.clips)
217            .filter_map(|clip| clip.matte.as_ref().map(|matte| &matte.source))
218            .collect::<HashSet<_>>();
219        let grid = FrameGrid::new(self);
220        let covers = self.covering_clips(&mattes);
221        for (order, track) in self.timeline.tracks.iter().enumerate() {
222            for clip in &track.clips {
223                let owner = LintOwner::Clip {
224                    clip: clip.id.clone(),
225                };
226                grid.check(&owner, clip.timeline_range, &mut issues);
227                let mut keyframes = Keyframes {
228                    owner: &owner,
229                    shown: &[span(clip.timeline_range)],
230                    issues: &mut issues,
231                };
232                keyframes.check_clip(clip);
233                if track.kind == TrackKind::Video
234                    && !mattes.contains(&clip.id)
235                    && let Some(by) = covering_clip(&covers, order, clip.timeline_range)
236                {
237                    issues.push(LintIssue::HiddenClip {
238                        clip: clip.id.clone(),
239                        by: by.clone(),
240                    });
241                }
242            }
243            for transition in &track.transitions {
244                let owner = LintOwner::Transition {
245                    track: track.id.clone(),
246                    from_clip: transition.from_clip.clone(),
247                    to_clip: transition.to_clip.clone(),
248                };
249                if !grid.shows_mixed(transition.timeline_range) {
250                    issues.push(LintIssue::ShortTransition {
251                        track: track.id.clone(),
252                        from_clip: transition.from_clip.clone(),
253                        to_clip: transition.to_clip.clone(),
254                    });
255                }
256                grid.check(&owner, transition.timeline_range, &mut issues);
257                let mut keyframes = Keyframes {
258                    owner: &owner,
259                    shown: &[span(transition.timeline_range)],
260                    issues: &mut issues,
261                };
262                for (name, value) in &transition.effect.params {
263                    keyframes.check(&format!("effect.params.{name}"), value);
264                }
265            }
266            lint_cues(&grid, track, &mut issues);
267            if let Some(captions) = &track.captions {
268                lint_caption_animators(&track.id, captions, &mut issues);
269            }
270        }
271        self.lint_keyframes_in_other_times(&mut issues);
272        self.lint_text_animators(&mut issues);
273        issues
274    }
275
276    /// Every text animator that sets nothing, in text generators and layout text nodes.
277    fn lint_text_animators(&self, issues: &mut Vec<LintIssue>) {
278        let lint =
279            |owner: &LintOwner, generator: &Generator, at: &str, issues: &mut Vec<LintIssue>| {
280                match generator {
281                    Generator::Text { style, .. } => {
282                        empty_animators(
283                            owner,
284                            &style.animators,
285                            &format!("{at}style.animators"),
286                            issues,
287                        );
288                    }
289                    Generator::Layout { root } => root.walk(|path, node| {
290                        empty_animators(
291                            owner,
292                            &node.text_animators,
293                            &format!("{at}{path}.text_animators"),
294                            issues,
295                        );
296                    }),
297                    Generator::Fill { .. } | Generator::Shapes { .. } => {}
298                }
299            };
300        for source in &self.sources {
301            if let Some(generator) = &source.generator {
302                let owner = LintOwner::Source {
303                    source: source.id.clone(),
304                };
305                lint(&owner, generator, "", issues);
306            }
307        }
308        for composition in &self.compositions {
309            for node in &composition.nodes {
310                if let crate::NodeOp::Generator { generator } = &node.op {
311                    let owner = LintOwner::CompositionNode {
312                        composition: composition.id.clone(),
313                        node: node.id.clone(),
314                    };
315                    lint(&owner, generator, "generator.", issues);
316                }
317            }
318        }
319    }
320
321    /// Unreached keyframes in the other two time bases: composition nodes' settings in
322    /// composition time and generated sources' trees in picture time. Compositions or sources
323    /// that no clip shows, or whose shown times cannot be bounded, are skipped.
324    fn lint_keyframes_in_other_times(&self, issues: &mut Vec<LintIssue>) {
325        let shown = Shown::of(self);
326        for composition in &self.compositions {
327            let Some(spans) = shown.compositions.get(&composition.id) else {
328                continue;
329            };
330            for node in &composition.nodes {
331                let owner = LintOwner::CompositionNode {
332                    composition: composition.id.clone(),
333                    node: node.id.clone(),
334                };
335                let mut keyframes = Keyframes {
336                    owner: &owner,
337                    shown: spans,
338                    issues,
339                };
340                node_keyed(&node.op, &mut |property, times| {
341                    keyframes.check_times(property, times);
342                });
343            }
344        }
345        for source in &self.sources {
346            let Some(spans) = shown.sources.get(&source.id) else {
347                continue;
348            };
349            let owner = LintOwner::Source {
350                source: source.id.clone(),
351            };
352            let mut keyframes = Keyframes {
353                owner: &owner,
354                shown: spans,
355                issues,
356            };
357            let mut check = |property: &str, times: &[MediaTime]| {
358                keyframes.check_times(property, times);
359            };
360            match &source.generator {
361                Some(Generator::Shapes { items }) => {
362                    crate::shapes::for_each_keyed_item(items, "items", &mut check);
363                }
364                Some(Generator::Layout { root }) => root.for_each_keyed("root", &mut check),
365                Some(Generator::Text { style, .. }) => {
366                    crate::text_animation::for_each_keyed_animator(
367                        &style.animators,
368                        "style.animators",
369                        &mut check,
370                    );
371                }
372                _ => {}
373            }
374        }
375    }
376
377    /// Empty subpaths and paints without geometry in every shapes generator and layout shape
378    /// node: sources' first, then composition nodes'.
379    fn lint_shapes(&self, issues: &mut Vec<LintIssue>) {
380        let lint = |owner: &ShapeLintOwner, generator: &Generator, issues: &mut Vec<LintIssue>| {
381            match generator {
382                Generator::Shapes { items } => {
383                    lint_shape_items(owner, items, "items", issues);
384                }
385                Generator::Layout { root } => root.walk(|at, node| {
386                    if let Some(shape) = &node.shape {
387                        lint_shape_items(owner, &shape.items, &format!("{at}.shape.items"), issues);
388                    }
389                }),
390                Generator::Fill { .. } | Generator::Text { .. } => {}
391            }
392        };
393        for source in &self.sources {
394            if let Some(generator) = &source.generator {
395                let owner = ShapeLintOwner::Source {
396                    source: source.id.clone(),
397                };
398                lint(&owner, generator, issues);
399            }
400        }
401        for composition in &self.compositions {
402            for node in &composition.nodes {
403                if let crate::NodeOp::Generator { generator } = &node.op {
404                    let owner = ShapeLintOwner::Node {
405                        composition: composition.id.clone(),
406                        node: node.id.clone(),
407                    };
408                    lint(&owner, generator, issues);
409                }
410            }
411        }
412    }
413
414    /// The video-track clips that certainly cover the whole frame opaquely outside their
415    /// transitions: composited (not a matte) and [`is_opaque_full_frame`]. In track then clip
416    /// order.
417    fn covering_clips<'a>(&'a self, mattes: &HashSet<&ClipId>) -> Vec<Cover<'a>> {
418        let sources = self
419            .sources
420            .iter()
421            .map(|source| (&source.id, source))
422            .collect::<HashMap<_, _>>();
423        let mut covers = Vec::new();
424        for (order, track) in self.timeline.tracks.iter().enumerate() {
425            if track.kind != TrackKind::Video {
426                continue;
427            }
428            for clip in &track.clips {
429                if mattes.contains(&clip.id)
430                    || !sources
431                        .get(&clip.source_id)
432                        .is_some_and(|source| is_opaque_full_frame(source, clip))
433                {
434                    continue;
435                }
436                let mixed = track
437                    .transitions
438                    .iter()
439                    .filter(|transition| {
440                        transition.from_clip == clip.id || transition.to_clip == clip.id
441                    })
442                    .map(|transition| transition.timeline_range)
443                    .collect();
444                covers.push(Cover { order, clip, mixed });
445            }
446        }
447        covers
448    }
449}
450
451/// A clip that covers the whole frame opaquely, except during its transitions.
452struct Cover<'a> {
453    /// Its track's position.
454    order: usize,
455    clip: &'a crate::Clip,
456    /// Its transitions' ranges, where it is mixed with another clip.
457    mixed: Vec<TimeRange>,
458}
459
460/// A clip on a track after `order` that covers the frame for all of `range`.
461fn covering_clip<'a>(covers: &[Cover<'a>], order: usize, range: TimeRange) -> Option<&'a ClipId> {
462    covers
463        .iter()
464        .filter(|cover| cover.order > order)
465        .find(|cover| {
466            cover.clip.timeline_range.start() <= range.start()
467                && cover.clip.timeline_range.end() >= range.end()
468                && !cover
469                    .mixed
470                    .iter()
471                    .any(|mixed| mixed.intersection(range).is_some())
472        })
473        .map(|cover| &cover.clip.id)
474}
475
476/// Whether a clip draws an opaque fill over the whole frame: a generated fill of opaque
477/// paint with no transform, blend, mask or composition.
478fn is_opaque_full_frame(source: &crate::MediaSource, clip: &crate::Clip) -> bool {
479    let opaque = source.kind == MediaKind::Generated
480        && matches!(&source.generator, Some(Generator::Fill { paint }) if paint_is_opaque(paint));
481    opaque
482        && clip.visual == crate::VisualProperties::default()
483        && clip.blend_mode == crate::ClipBlendMode::Normal
484        && clip.composition.is_none()
485        && clip.masks.is_empty()
486        && clip.matte.is_none()
487}
488
489fn paint_is_opaque(paint: &Paint) -> bool {
490    match paint {
491        Paint::Solid { color } => color.alpha >= 1.0,
492        Paint::LinearGradient { stops, .. }
493        | Paint::RadialGradient { stops, .. }
494        | Paint::ConicGradient { stops, .. } => stops.iter().all(|stop| stop.color.alpha >= 1.0),
495    }
496}
497
498/// The project's frames as the engine renders them: frame `k` at
499/// [`FrameRate::frame_start`], its exact start rounded down to a tick.
500struct FrameGrid {
501    rate: FrameRate,
502}
503
504impl FrameGrid {
505    fn new(project: &Project) -> Self {
506        Self {
507            rate: project.settings.frame_rate,
508        }
509    }
510
511    fn on_grid(&self, time: MediaTime) -> bool {
512        self.rate
513            .frame_at_or_before(time)
514            .and_then(|frame| self.rate.frame_start(frame))
515            == Some(time)
516    }
517
518    /// Whether a frame shows a transition over `range` as a mix, i.e. the evaluator's progress
519    /// `clamp((t - start + F/2) / D, 0, 1)` is below 1 at some frame start `t` in the range.
520    /// Progress grows with `t`, so the first frame start decides. Unsure counts as shown.
521    fn shows_mixed(&self, range: TimeRange) -> bool {
522        let first = self
523            .rate
524            .frame_at_or_before(range.start())
525            .and_then(|frame| {
526                let start = self.rate.frame_start(frame)?;
527                if start == range.start() {
528                    Some(start)
529                } else {
530                    self.rate.frame_start(frame.checked_add(1)?)
531                }
532            });
533        let Some(first) = first else {
534            return true;
535        };
536        let num = i128::from(self.rate.numerator());
537        let elapsed = i128::from(first.ticks()) - i128::from(range.start().ticks());
538        let duration = i128::from(range.end().ticks()) - i128::from(range.start().ticks());
539        // Scaled by 2 * num, as the evaluator's `centred_progress` is.
540        first < range.end()
541            && 2 * num * elapsed
542                + i128::from(TICKS_PER_SECOND) * i128::from(self.rate.denominator())
543                < 2 * num * duration
544    }
545
546    fn check(&self, owner: &LintOwner, range: TimeRange, issues: &mut Vec<LintIssue>) {
547        for (edge, time) in [
548            (LintEdge::Start, range.start()),
549            (LintEdge::End, range.end()),
550        ] {
551            if !self.on_grid(time) {
552                issues.push(LintIssue::OffFrameBoundary {
553                    owner: owner.clone(),
554                    edge,
555                    time,
556                });
557            }
558        }
559    }
560}
561
562/// Finds keyframes no shown time depends on. `shown` holds every tick span (first, last) the
563/// owner may be shown at.
564struct Keyframes<'a> {
565    owner: &'a LintOwner,
566    shown: &'a [(i64, i64)],
567    issues: &'a mut Vec<LintIssue>,
568}
569
570/// The first and last ticks of `range`.
571fn span(range: TimeRange) -> (i64, i64) {
572    (range.start().ticks(), range.end().ticks() - 1)
573}
574
575impl Keyframes<'_> {
576    fn check_clip(&mut self, clip: &crate::Clip) {
577        let visual = &clip.visual;
578        self.check_point("visual.position", &visual.position);
579        self.check("visual.scale", &visual.scale);
580        self.check("visual.rotation_degrees", &visual.rotation_degrees);
581        self.check("visual.opacity", &visual.opacity);
582        for (index, mask) in clip.masks.iter().enumerate() {
583            self.check_geometry(&format!("masks[{index}].shape"), &mask.shape);
584            self.check(&format!("masks[{index}].feather"), &mask.feather);
585        }
586        self.check("audio.gain", &clip.audio.gain);
587        self.check("audio.pan", &clip.audio.pan);
588    }
589
590    fn check_geometry(&mut self, at: &str, geometry: &ShapeGeometry) {
591        geometry.for_each_keyed(at, &mut |property, times| self.check_times(property, times));
592    }
593
594    fn check_point(&mut self, property: &str, animated: &AnimatedPoint) {
595        if let AnimatedPoint::Keyframes { keyframes } = animated {
596            let times: Vec<MediaTime> = keyframes.iter().map(|keyframe| keyframe.time).collect();
597            self.check_times(property, &times);
598        }
599    }
600
601    fn check<T>(&mut self, property: &str, animated: &Animated<T>) {
602        if let Animated::Keyframes { keyframes } = animated {
603            let times: Vec<MediaTime> = keyframes.iter().map(|keyframe| keyframe.time).collect();
604            self.check_times(property, &times);
605        }
606    }
607
608    /// Keyframe `i` affects values only strictly between its neighbours' times (open-ended for
609    /// the first and last), so outside every shown tick it has no effect.
610    fn check_times(&mut self, property: &str, times: &[MediaTime]) {
611        for (index, time) in times.iter().enumerate() {
612            let after = index.checked_sub(1).map_or(i64::MIN, |previous| {
613                times[previous].ticks().saturating_add(1)
614            });
615            let before = times
616                .get(index + 1)
617                .map_or(i64::MAX, |next| next.ticks().saturating_sub(1));
618            let reached = self
619                .shown
620                .iter()
621                .any(|&(first, last)| after.max(first) <= before.min(last));
622            if !reached {
623                self.issues.push(LintIssue::UnreachedKeyframe {
624                    owner: self.owner.clone(),
625                    property: property.to_owned(),
626                    time: *time,
627                });
628            }
629        }
630    }
631}
632
633/// Every tick span (first, last) each composition and each generated or composition source
634/// may be shown at.
635///
636/// Composition time comes from a clip's [`crate::ClipComposition`] mapping, a composition
637/// source's picture time, or a parent composition drawing it as a group. Picture time comes
638/// from a clip's source range through its time map, or a media node's `start` plus its
639/// composition's time. Spans may over-cover, so a keyframe outside all of them is certainly
640/// unreached. Anything whose time cannot be represented is left out with all it shows.
641#[derive(Default)]
642struct Shown<'a> {
643    compositions: HashMap<&'a CompositionId, Vec<(i64, i64)>>,
644    sources: HashMap<&'a SourceId, Vec<(i64, i64)>>,
645}
646
647impl<'a> Shown<'a> {
648    fn of(project: &'a Project) -> Self {
649        let (base, unbounded) = Self::clips(project);
650        let mut shown = base.propagated(project);
651        shown.drop_unbounded(project, unbounded);
652        shown
653    }
654
655    /// What the clips show directly (sources at their time-mapped picture times, compositions at
656    /// their mapped times), and the compositions mapped to unrepresentable times.
657    fn clips(project: &'a Project) -> (Self, Vec<&'a CompositionId>) {
658        let mut base = Self::default();
659        let mut unbounded = Vec::new();
660        for clip in project
661            .timeline
662            .tracks
663            .iter()
664            .flat_map(|track| &track.clips)
665        {
666            let picture = match clip.time_map {
667                crate::TimeMap::Freeze { source_time } => {
668                    (source_time.ticks(), source_time.ticks())
669                }
670                crate::TimeMap::Forward | crate::TimeMap::Reverse | crate::TimeMap::Loop { .. } => {
671                    span(clip.source_range)
672                }
673            };
674            base.sources
675                .entry(&clip.source_id)
676                .or_default()
677                .push(picture);
678            if let Some(composition) = &clip.composition {
679                let start = clip.timeline_range.start();
680                let last = MediaTime(clip.timeline_range.end().ticks() - 1);
681                match (
682                    composition.local_time(start, start),
683                    composition.local_time(start, last),
684                ) {
685                    (Some(first), Some(last)) => base
686                        .compositions
687                        .entry(&composition.id)
688                        .or_default()
689                        .push((first.ticks(), last.ticks())),
690                    _ => unbounded.push(&composition.id),
691                }
692            }
693        }
694        (base, unbounded)
695    }
696
697    /// Extends `self` with what it shows in turn: composition sources' compositions, groups'
698    /// compositions, and media nodes' sources. Each pass adds one level; the graph is acyclic,
699    /// so one pass per composition and source reaches every level.
700    fn propagated(self, project: &'a Project) -> Self {
701        let mut shown = Self {
702            compositions: self.compositions.clone(),
703            sources: self.sources.clone(),
704        };
705        shown.normalize();
706        for _ in 0..=project.compositions.len() + project.sources.len() {
707            let mut next = Self {
708                compositions: self.compositions.clone(),
709                sources: self.sources.clone(),
710            };
711            for source in &project.sources {
712                if let (Some(composition), Some(spans)) =
713                    (&source.composition, shown.sources.get(&source.id))
714                {
715                    next.compositions
716                        .entry(composition)
717                        .or_default()
718                        .extend(spans);
719                }
720            }
721            for composition in &project.compositions {
722                let Some(spans) = shown.compositions.get(&composition.id) else {
723                    continue;
724                };
725                for node in &composition.nodes {
726                    match &node.op {
727                        crate::NodeOp::Group { composition, .. } => {
728                            next.compositions
729                                .entry(composition)
730                                .or_default()
731                                .extend(spans);
732                        }
733                        crate::NodeOp::Media { source_id, start } => {
734                            next.sources
735                                .entry(source_id)
736                                .or_default()
737                                .extend(spans.iter().map(|&(first, last)| {
738                                    (
739                                        first.saturating_add(start.ticks()),
740                                        last.saturating_add(start.ticks()),
741                                    )
742                                }));
743                        }
744                        _ => {}
745                    }
746                }
747            }
748            next.normalize();
749            if next.compositions == shown.compositions && next.sources == shown.sources {
750                break;
751            }
752            shown = next;
753        }
754        shown
755    }
756
757    /// Leaves out the compositions in `unbounded` and what they draw (groups' compositions and
758    /// media nodes' sources).
759    fn drop_unbounded(&mut self, project: &'a Project, mut unbounded: Vec<&'a CompositionId>) {
760        while let Some(id) = unbounded.pop() {
761            self.compositions.remove(id);
762            let Some(composition) = project.compositions.iter().find(|held| held.id == *id) else {
763                continue;
764            };
765            for node in &composition.nodes {
766                match &node.op {
767                    crate::NodeOp::Group { composition, .. } => {
768                        if self.compositions.contains_key(composition) {
769                            unbounded.push(composition);
770                        }
771                    }
772                    crate::NodeOp::Media { source_id, .. } => {
773                        self.sources.remove(source_id);
774                        if let Some(inner) = project
775                            .sources
776                            .iter()
777                            .find(|source| source.id == *source_id)
778                            .and_then(|source| source.composition.as_ref())
779                            && self.compositions.contains_key(inner)
780                        {
781                            unbounded.push(inner);
782                        }
783                    }
784                    _ => {}
785                }
786            }
787        }
788    }
789
790    /// Sorts each list and merges spans that meet or touch.
791    fn normalize(&mut self) {
792        fn merge(spans: &mut Vec<(i64, i64)>) {
793            spans.sort_unstable();
794            let mut merged: Vec<(i64, i64)> = Vec::with_capacity(spans.len());
795            for &(first, last) in spans.iter() {
796                match merged.last_mut() {
797                    Some(held) if first <= held.1.saturating_add(1) => held.1 = held.1.max(last),
798                    _ => merged.push((first, last)),
799                }
800            }
801            *spans = merged;
802        }
803        self.compositions.values_mut().for_each(merge);
804        self.sources.values_mut().for_each(merge);
805    }
806}
807
808/// Calls `visit` with each keyframed setting of a node, by its path in the node
809/// (`effect.params.radius`, `generator.items[0].transform.scale`, ...), and its times.
810fn node_keyed(op: &crate::NodeOp, visit: &mut impl FnMut(&str, &[MediaTime])) {
811    use crate::animation::{visit_keyed, visit_keyed_point};
812    let params = |effect: &crate::ClipEffect, visit: &mut dyn FnMut(&str, &[MediaTime])| {
813        for (name, value) in &effect.params {
814            visit_keyed(&format!("effect.params.{name}"), value, &mut |at, times| {
815                visit(at, times);
816            });
817        }
818    };
819    match op {
820        crate::NodeOp::Effect { effect, .. } => params(effect, visit),
821        crate::NodeOp::Transition {
822            effect, progress, ..
823        } => {
824            params(effect, visit);
825            visit_keyed("progress", progress, visit);
826        }
827        crate::NodeOp::Transform { transform, .. } => {
828            visit_keyed_point("transform.position", &transform.position, visit);
829            visit_keyed("transform.scale", &transform.scale, visit);
830            visit_keyed(
831                "transform.rotation_degrees",
832                &transform.rotation_degrees,
833                visit,
834            );
835            visit_keyed("transform.opacity", &transform.opacity, visit);
836        }
837        crate::NodeOp::Merge { layers } => {
838            for (index, layer) in layers.iter().enumerate() {
839                visit_keyed(&format!("layers[{index}].opacity"), &layer.opacity, visit);
840            }
841        }
842        crate::NodeOp::ShapeMask { masks, .. } => {
843            for (index, mask) in masks.iter().enumerate() {
844                mask.shape
845                    .for_each_keyed(&format!("masks[{index}].shape"), visit);
846                visit_keyed(&format!("masks[{index}].feather"), &mask.feather, visit);
847            }
848        }
849        crate::NodeOp::Group { controls, .. } => {
850            for (name, value) in controls {
851                visit_keyed(&format!("controls.{name}"), value, visit);
852            }
853        }
854        crate::NodeOp::Generator { generator } => match generator {
855            Generator::Shapes { items } => {
856                crate::shapes::for_each_keyed_item(items, "generator.items", visit);
857            }
858            Generator::Layout { root } => root.for_each_keyed("generator.root", visit),
859            Generator::Text { style, .. } => crate::text_animation::for_each_keyed_animator(
860                &style.animators,
861                "generator.style.animators",
862                visit,
863            ),
864            Generator::Fill { .. } => {}
865        },
866        crate::NodeOp::Clip {}
867        | crate::NodeOp::Media { .. }
868        | crate::NodeOp::Matte { .. }
869        | crate::NodeOp::GroupInput { .. } => {}
870    }
871}
872
873/// Reports a content tree's empty subpaths and paints with nothing before them, in document
874/// order. Returns whether the items hold any geometry, for an enclosing group's paints.
875fn lint_shape_items(
876    owner: &ShapeLintOwner,
877    items: &[ShapeItem],
878    at: &str,
879    issues: &mut Vec<LintIssue>,
880) -> bool {
881    let mut geometry = false;
882    for (index, item) in items.iter().enumerate() {
883        let at = format!("{at}[{index}]");
884        match item {
885            ShapeItem::Group { items, .. } => {
886                geometry |= lint_shape_items(owner, items, &format!("{at}.items"), issues);
887            }
888            ShapeItem::Rectangle(_) | ShapeItem::Ellipse(_) | ShapeItem::Polystar(_) => {
889                geometry = true;
890            }
891            ShapeItem::Path(path) => {
892                geometry = true;
893                let paths: Vec<&crate::Path> = match &path.path {
894                    Animated::Constant { value } => vec![value],
895                    Animated::Keyframes { keyframes } => {
896                        keyframes.iter().map(|keyframe| &keyframe.value).collect()
897                    }
898                };
899                // Keyframes share their structure, so the first tells.
900                if let Some(first) = paths.first() {
901                    for (subpath, entry) in first.subpaths.iter().enumerate() {
902                        if entry.vertices.is_empty() {
903                            issues.push(LintIssue::EmptySubpath {
904                                owner: owner.clone(),
905                                at: format!("{at}.path.subpaths[{subpath}]"),
906                            });
907                        }
908                    }
909                }
910            }
911            ShapeItem::Fill { .. } | ShapeItem::Stroke(_) => {
912                if !geometry {
913                    issues.push(LintIssue::PaintWithoutGeometry {
914                        owner: owner.clone(),
915                        at,
916                    });
917                }
918            }
919            ShapeItem::Trim(_) => {}
920        }
921    }
922    geometry
923}
924
925/// Each of `animators` that sets nothing, under `at`.
926fn empty_animators(
927    owner: &LintOwner,
928    animators: &[crate::TextAnimator],
929    at: &str,
930    issues: &mut Vec<LintIssue>,
931) {
932    for (index, animator) in animators.iter().enumerate() {
933        if animator.properties.is_empty() {
934            issues.push(LintIssue::EmptyTextAnimator {
935                owner: owner.clone(),
936                at: format!("{at}[{index}]"),
937            });
938        }
939    }
940}
941
942/// Each cue's timeline range, and each timed run's, against the frame grid.
943fn lint_cues(grid: &FrameGrid, track: &crate::Track, issues: &mut Vec<LintIssue>) {
944    for (index, cue) in track
945        .captions
946        .iter()
947        .flat_map(|captions| captions.cues.iter())
948        .enumerate()
949    {
950        let owner = LintOwner::Cue {
951            track: track.id.clone(),
952            index,
953        };
954        grid.check(&owner, cue.timeline_range, issues);
955        // A karaoke switch between frames shows at the next frame, so check each run's ends on the
956        // timeline.
957        for (run, timed) in cue.text.runs().iter().enumerate() {
958            let Some(time) = timed.time else {
959                continue;
960            };
961            let start = cue.timeline_range.start().ticks();
962            let Ok(range) = alpha2_time::TimeRange::new(
963                MediaTime(start.saturating_add(time.start.ticks())),
964                MediaTime(start.saturating_add(time.end.ticks())),
965            ) else {
966                continue;
967            };
968            let owner = LintOwner::CueRun {
969                track: track.id.clone(),
970                cue: index,
971                run,
972            };
973            grid.check(&owner, range, issues);
974        }
975    }
976}
977
978/// A caption track's animators: keyframes in cue time against every cue's span, empty
979/// animators, then styles no cue takes.
980fn lint_caption_animators(
981    track: &TrackId,
982    captions: &crate::Captions,
983    issues: &mut Vec<LintIssue>,
984) {
985    let owner = LintOwner::CaptionTrack {
986        track: track.clone(),
987    };
988    let spans = captions
989        .cues
990        .iter()
991        .filter_map(|cue| cue.timeline_range.duration())
992        .map(|duration| (0, duration.ticks().saturating_sub(1)))
993        .collect::<Vec<_>>();
994    let mut keyframes = Keyframes {
995        owner: &owner,
996        shown: &spans,
997        issues,
998    };
999    crate::text_animation::for_each_keyed_animator(
1000        &captions.style.animators,
1001        "style.animators",
1002        &mut |property, times| keyframes.check_times(property, times),
1003    );
1004    empty_animators(&owner, &captions.style.animators, "style.animators", issues);
1005    for name in captions.styles.keys() {
1006        let used = captions.cues.iter().any(|cue| {
1007            cue.style.as_deref() == Some(name.as_str())
1008                || (cue.style.is_none() && cue.speaker.as_deref() == Some(name.as_str()))
1009        });
1010        if !used {
1011            issues.push(LintIssue::UnusedCaptionStyle {
1012                track: track.clone(),
1013                style: name.clone(),
1014            });
1015        }
1016    }
1017}