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