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.
21    UnreachedKeyframe {
22        /// What the keyframe animates.
23        owner: LintOwner,
24        /// The animated property, as a path such as `visual.opacity` or
25        /// `masks[0].feather`.
26        property: String,
27        /// The keyframe's time.
28        time: MediaTime,
29    },
30    /// A clip wholly covered, for its whole time, by an opaque full-frame
31    /// clip on a later track. A matte clip is never reported, as hidden or
32    /// hiding: it is never composited itself.
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 between its two clips: shorter
40    /// than a frame, or about a frame long and starting between frames, so
41    /// each frame shows one clip or the other, never a mix.
42    ShortTransition {
43        /// Its track.
44        track: TrackId,
45        /// Its first clip.
46        from_clip: ClipId,
47        /// Its second clip.
48        to_clip: ClipId,
49    },
50    /// A boundary that is not where a project frame starts: the change it
51    /// marks shows at the next frame. A frame starts at the tick its exact
52    /// start rounds down to, as the engine renders it.
53    OffFrameBoundary {
54        /// Whose boundary.
55        owner: LintOwner,
56        /// Which end.
57        edge: LintEdge,
58        /// The boundary's time.
59        time: MediaTime,
60    },
61    /// A source no render reads: no clip shows it, and no `media` node of a
62    /// composition a clip shows reads it.
63    UnusedSource {
64        /// The source.
65        source: SourceId,
66    },
67    /// A high-dynamic-range source in a project whose color pipeline refuses
68    /// HDR, so rendering it fails.
69    HdrSource {
70        /// The source.
71        source: SourceId,
72    },
73    /// A path's subpath without vertices, which draws nothing.
74    EmptySubpath {
75        /// The shapes generator.
76        owner: ShapeLintOwner,
77        /// The empty subpath, like `items[0].path.subpaths[1]`, or in a
78        /// layout `root.children[0].shape.items[0].path.subpaths[1]`.
79        at: String,
80    },
81    /// A fill or stroke with no geometry before it in its group, which
82    /// paints nothing.
83    PaintWithoutGeometry {
84        /// The shapes generator.
85        owner: ShapeLintOwner,
86        /// The paint item, like `items[1].items[0]`.
87        at: String,
88    },
89}
90
91/// The shapes or layout generator a shape [`LintIssue`] is about.
92#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
93#[serde(tag = "type", rename_all = "snake_case")]
94#[non_exhaustive]
95pub enum ShapeLintOwner {
96    /// A generated source's.
97    Source {
98        /// The source.
99        source: SourceId,
100    },
101    /// A composition's generator node's.
102    Node {
103        /// The composition.
104        composition: CompositionId,
105        /// The node.
106        node: NodeId,
107    },
108}
109
110/// What a [`LintIssue`] is about.
111#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
112#[serde(tag = "type", rename_all = "snake_case")]
113#[non_exhaustive]
114pub enum LintOwner {
115    /// A clip.
116    Clip {
117        /// The clip.
118        clip: ClipId,
119    },
120    /// A transition.
121    Transition {
122        /// Its track.
123        track: TrackId,
124        /// Its first clip.
125        from_clip: ClipId,
126        /// Its second clip.
127        to_clip: ClipId,
128    },
129    /// A caption cue.
130    Cue {
131        /// Its caption track.
132        track: TrackId,
133        /// Its position among the track's cues.
134        index: usize,
135    },
136}
137
138/// The end of a range a [`LintIssue`] is about.
139#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize)]
140#[serde(rename_all = "snake_case")]
141pub enum LintEdge {
142    /// The start.
143    Start,
144    /// The end.
145    End,
146}
147
148impl Project {
149    /// Diagnostics that do not stop the project rendering, in a fixed order:
150    /// unused sources, then shapes generators (sources', then composition
151    /// nodes'), then HDR sources, then tracks in order and each track's clips,
152    /// transitions, and cues in order.
153    #[must_use]
154    pub fn lint(&self) -> Vec<LintIssue> {
155        let mut issues = Vec::new();
156        let used = self
157            .used_sources()
158            .into_iter()
159            .map(|source| &source.id)
160            .collect::<std::collections::HashSet<_>>();
161        for source in &self.sources {
162            if !used.contains(&source.id) {
163                issues.push(LintIssue::UnusedSource {
164                    source: source.id.clone(),
165                });
166            }
167        }
168        self.lint_shapes(&mut issues);
169        let refused = self.settings.color.hdr == crate::HdrSources::Reject;
170        for source in self.hdr_sources().into_iter().filter(|_| refused) {
171            issues.push(LintIssue::HdrSource {
172                source: source.clone(),
173            });
174        }
175        let mattes = self
176            .timeline
177            .tracks
178            .iter()
179            .flat_map(|track| &track.clips)
180            .filter_map(|clip| clip.matte.as_ref().map(|matte| &matte.source))
181            .collect::<HashSet<_>>();
182        let grid = FrameGrid::new(self);
183        let covers = self.covering_clips(&mattes);
184        for (order, track) in self.timeline.tracks.iter().enumerate() {
185            for clip in &track.clips {
186                let owner = LintOwner::Clip {
187                    clip: clip.id.clone(),
188                };
189                grid.check(&owner, clip.timeline_range, &mut issues);
190                let mut keyframes = Keyframes {
191                    owner: &owner,
192                    active: clip.timeline_range,
193                    issues: &mut issues,
194                };
195                keyframes.check_clip(clip);
196                if track.kind == TrackKind::Video
197                    && !mattes.contains(&clip.id)
198                    && let Some(by) = covering_clip(&covers, order, clip.timeline_range)
199                {
200                    issues.push(LintIssue::HiddenClip {
201                        clip: clip.id.clone(),
202                        by: by.clone(),
203                    });
204                }
205            }
206            for transition in &track.transitions {
207                let owner = LintOwner::Transition {
208                    track: track.id.clone(),
209                    from_clip: transition.from_clip.clone(),
210                    to_clip: transition.to_clip.clone(),
211                };
212                if !grid.shows_mixed(transition.timeline_range) {
213                    issues.push(LintIssue::ShortTransition {
214                        track: track.id.clone(),
215                        from_clip: transition.from_clip.clone(),
216                        to_clip: transition.to_clip.clone(),
217                    });
218                }
219                grid.check(&owner, transition.timeline_range, &mut issues);
220                let mut keyframes = Keyframes {
221                    owner: &owner,
222                    active: transition.timeline_range,
223                    issues: &mut issues,
224                };
225                for (name, value) in &transition.effect.params {
226                    keyframes.check(&format!("effect.params.{name}"), value);
227                }
228            }
229            for (index, cue) in track
230                .captions
231                .iter()
232                .flat_map(|captions| captions.cues.iter())
233                .enumerate()
234            {
235                let owner = LintOwner::Cue {
236                    track: track.id.clone(),
237                    index,
238                };
239                grid.check(&owner, cue.timeline_range, &mut issues);
240            }
241        }
242        issues
243    }
244
245    /// Every shapes generator's and layout shape node's empty subpaths and
246    /// paints without geometry: sources' first, then composition nodes', in
247    /// order.
248    fn lint_shapes(&self, issues: &mut Vec<LintIssue>) {
249        let lint = |owner: &ShapeLintOwner, generator: &Generator, issues: &mut Vec<LintIssue>| {
250            match generator {
251                Generator::Shapes { items } => {
252                    lint_shape_items(owner, items, "items", issues);
253                }
254                Generator::Layout { root } => root.walk(|at, node| {
255                    if let Some(shape) = &node.shape {
256                        lint_shape_items(owner, &shape.items, &format!("{at}.shape.items"), issues);
257                    }
258                }),
259                Generator::Fill { .. } | Generator::Text { .. } => {}
260            }
261        };
262        for source in &self.sources {
263            if let Some(generator) = &source.generator {
264                let owner = ShapeLintOwner::Source {
265                    source: source.id.clone(),
266                };
267                lint(&owner, generator, issues);
268            }
269        }
270        for composition in &self.compositions {
271            for node in &composition.nodes {
272                if let crate::NodeOp::Generator { generator } = &node.op {
273                    let owner = ShapeLintOwner::Node {
274                        composition: composition.id.clone(),
275                        node: node.id.clone(),
276                    };
277                    lint(&owner, generator, issues);
278                }
279            }
280        }
281    }
282
283    /// The clips on video tracks that certainly cover the whole frame,
284    /// opaquely, outside their transitions: composited (not a matte), and
285    /// [`is_opaque_full_frame`]. In track order, then clip order.
286    fn covering_clips<'a>(&'a self, mattes: &HashSet<&ClipId>) -> Vec<Cover<'a>> {
287        let sources = self
288            .sources
289            .iter()
290            .map(|source| (&source.id, source))
291            .collect::<HashMap<_, _>>();
292        let mut covers = Vec::new();
293        for (order, track) in self.timeline.tracks.iter().enumerate() {
294            if track.kind != TrackKind::Video {
295                continue;
296            }
297            for clip in &track.clips {
298                if mattes.contains(&clip.id)
299                    || !sources
300                        .get(&clip.source_id)
301                        .is_some_and(|source| is_opaque_full_frame(source, clip))
302                {
303                    continue;
304                }
305                let mixed = track
306                    .transitions
307                    .iter()
308                    .filter(|transition| {
309                        transition.from_clip == clip.id || transition.to_clip == clip.id
310                    })
311                    .map(|transition| transition.timeline_range)
312                    .collect();
313                covers.push(Cover { order, clip, mixed });
314            }
315        }
316        covers
317    }
318}
319
320/// A clip that covers the whole frame opaquely, except while it is in one
321/// of its transitions.
322struct Cover<'a> {
323    /// Its track's position.
324    order: usize,
325    clip: &'a crate::Clip,
326    /// Its transitions' ranges, where it is mixed with another clip.
327    mixed: Vec<TimeRange>,
328}
329
330/// A clip on a track after `order` that covers the frame for all of `range`.
331/// Only the few clips that cover the frame at all are looked at.
332fn covering_clip<'a>(covers: &[Cover<'a>], order: usize, range: TimeRange) -> Option<&'a ClipId> {
333    covers
334        .iter()
335        .filter(|cover| cover.order > order)
336        .find(|cover| {
337            cover.clip.timeline_range.start() <= range.start()
338                && cover.clip.timeline_range.end() >= range.end()
339                && !cover
340                    .mixed
341                    .iter()
342                    .any(|mixed| mixed.intersection(range).is_some())
343        })
344        .map(|cover| &cover.clip.id)
345}
346
347/// Whether a clip draws an opaque fill over the whole frame: a generated
348/// fill of opaque paint, shown untransformed, unblended, unmasked, with no
349/// composition.
350fn is_opaque_full_frame(source: &crate::MediaSource, clip: &crate::Clip) -> bool {
351    let opaque = source.kind == MediaKind::Generated
352        && matches!(&source.generator, Some(Generator::Fill { paint }) if paint_is_opaque(paint));
353    opaque
354        && clip.visual == crate::VisualProperties::default()
355        && clip.blend_mode == crate::ClipBlendMode::Normal
356        && clip.composition.is_none()
357        && clip.masks.is_empty()
358        && clip.matte.is_none()
359}
360
361fn paint_is_opaque(paint: &Paint) -> bool {
362    match paint {
363        Paint::Solid { color } => color.alpha >= 1.0,
364        Paint::LinearGradient { stops, .. }
365        | Paint::RadialGradient { stops, .. }
366        | Paint::ConicGradient { stops, .. } => stops.iter().all(|stop| stop.color.alpha >= 1.0),
367    }
368}
369
370/// The project's frames, starting where the engine renders them: frame `k`
371/// at [`FrameRate::frame_start`], its exact start rounded down to a tick.
372struct FrameGrid {
373    rate: FrameRate,
374}
375
376impl FrameGrid {
377    fn new(project: &Project) -> Self {
378        Self {
379            rate: project.settings.frame_rate,
380        }
381    }
382
383    fn on_grid(&self, time: MediaTime) -> bool {
384        self.rate
385            .frame_at_or_before(time)
386            .and_then(|frame| self.rate.frame_start(frame))
387            == Some(time)
388    }
389
390    /// Whether a frame shows a transition over `range` between its two
391    /// clips, as the evaluator's progress `clamp((t - start + F/2) / D, 0, 1)`
392    /// below 1 at a frame start `t` in the range. Progress grows with `t`, so
393    /// the first frame start in the range decides. Unsure is shown.
394    fn shows_mixed(&self, range: TimeRange) -> bool {
395        let first = self
396            .rate
397            .frame_at_or_before(range.start())
398            .and_then(|frame| {
399                let start = self.rate.frame_start(frame)?;
400                if start == range.start() {
401                    Some(start)
402                } else {
403                    self.rate.frame_start(frame.checked_add(1)?)
404                }
405            });
406        let Some(first) = first else {
407            return true;
408        };
409        let num = i128::from(self.rate.numerator());
410        let elapsed = i128::from(first.ticks()) - i128::from(range.start().ticks());
411        let duration = i128::from(range.end().ticks()) - i128::from(range.start().ticks());
412        // Scaled by 2 * num, as the evaluator's `centred_progress` is.
413        first < range.end()
414            && 2 * num * elapsed
415                + i128::from(TICKS_PER_SECOND) * i128::from(self.rate.denominator())
416                < 2 * num * duration
417    }
418
419    fn check(&self, owner: &LintOwner, range: TimeRange, issues: &mut Vec<LintIssue>) {
420        for (edge, time) in [
421            (LintEdge::Start, range.start()),
422            (LintEdge::End, range.end()),
423        ] {
424            if !self.on_grid(time) {
425                issues.push(LintIssue::OffFrameBoundary {
426                    owner: owner.clone(),
427                    edge,
428                    time,
429                });
430            }
431        }
432    }
433}
434
435/// Finds keyframes no time in `active` depends on.
436struct Keyframes<'a> {
437    owner: &'a LintOwner,
438    active: TimeRange,
439    issues: &'a mut Vec<LintIssue>,
440}
441
442impl Keyframes<'_> {
443    fn check_clip(&mut self, clip: &crate::Clip) {
444        let visual = &clip.visual;
445        self.check_point("visual.position", &visual.position);
446        self.check("visual.scale", &visual.scale);
447        self.check("visual.rotation_degrees", &visual.rotation_degrees);
448        self.check("visual.opacity", &visual.opacity);
449        for (index, mask) in clip.masks.iter().enumerate() {
450            self.check_geometry(&format!("masks[{index}].shape"), &mask.shape);
451            self.check(&format!("masks[{index}].feather"), &mask.feather);
452        }
453        self.check("audio.gain", &clip.audio.gain);
454        self.check("audio.pan", &clip.audio.pan);
455    }
456
457    fn check_geometry(&mut self, at: &str, geometry: &ShapeGeometry) {
458        match geometry {
459            ShapeGeometry::Rectangle(rectangle) => {
460                self.check_point(&format!("{at}.center"), &rectangle.center);
461                self.check(&format!("{at}.size"), &rectangle.size);
462                self.check(&format!("{at}.corner_radius"), &rectangle.corner_radius);
463                self.check(
464                    &format!("{at}.rotation_degrees"),
465                    &rectangle.rotation_degrees,
466                );
467            }
468            ShapeGeometry::Ellipse(ellipse) => {
469                self.check_point(&format!("{at}.center"), &ellipse.center);
470                self.check(&format!("{at}.size"), &ellipse.size);
471                self.check(&format!("{at}.rotation_degrees"), &ellipse.rotation_degrees);
472            }
473            ShapeGeometry::Polystar(star) => {
474                self.check_point(&format!("{at}.center"), &star.center);
475                self.check(&format!("{at}.points"), &star.points);
476                self.check(&format!("{at}.outer_radius"), &star.outer_radius);
477                self.check(&format!("{at}.inner_radius"), &star.inner_radius);
478                self.check(&format!("{at}.outer_roundness"), &star.outer_roundness);
479                self.check(&format!("{at}.inner_roundness"), &star.inner_roundness);
480                self.check(&format!("{at}.rotation_degrees"), &star.rotation_degrees);
481            }
482            ShapeGeometry::Path(path) => self.check(&format!("{at}.path"), &path.path),
483        }
484    }
485
486    fn check_point(&mut self, property: &str, animated: &AnimatedPoint) {
487        if let AnimatedPoint::Keyframes { keyframes } = animated {
488            let times: Vec<MediaTime> = keyframes.iter().map(|keyframe| keyframe.time).collect();
489            self.check_times(property, &times);
490        }
491    }
492
493    fn check<T>(&mut self, property: &str, animated: &Animated<T>) {
494        if let Animated::Keyframes { keyframes } = animated {
495            let times: Vec<MediaTime> = keyframes.iter().map(|keyframe| keyframe.time).collect();
496            self.check_times(property, &times);
497        }
498    }
499
500    /// Keyframe `i` shapes values only strictly between its neighbours'
501    /// times (the first one back to the start of time, the last one on to
502    /// its end); outside every shown tick of `active`, it has no effect.
503    fn check_times(&mut self, property: &str, times: &[MediaTime]) {
504        let (start, last) = (self.active.start().ticks(), self.active.end().ticks() - 1);
505        for (index, time) in times.iter().enumerate() {
506            let after = index.checked_sub(1).map_or(i64::MIN, |previous| {
507                times[previous].ticks().saturating_add(1)
508            });
509            let before = times
510                .get(index + 1)
511                .map_or(i64::MAX, |next| next.ticks().saturating_sub(1));
512            if after.max(start) > before.min(last) {
513                self.issues.push(LintIssue::UnreachedKeyframe {
514                    owner: self.owner.clone(),
515                    property: property.to_owned(),
516                    time: *time,
517                });
518            }
519        }
520    }
521}
522
523/// Reports a content tree's empty subpaths and its paints with nothing
524/// before them to paint, in document order. Returns whether the items hold
525/// any geometry, so an enclosing group knows its paints have some.
526fn lint_shape_items(
527    owner: &ShapeLintOwner,
528    items: &[ShapeItem],
529    at: &str,
530    issues: &mut Vec<LintIssue>,
531) -> bool {
532    let mut geometry = false;
533    for (index, item) in items.iter().enumerate() {
534        let at = format!("{at}[{index}]");
535        match item {
536            ShapeItem::Group { items, .. } => {
537                geometry |= lint_shape_items(owner, items, &format!("{at}.items"), issues);
538            }
539            ShapeItem::Rectangle(_) | ShapeItem::Ellipse(_) | ShapeItem::Polystar(_) => {
540                geometry = true;
541            }
542            ShapeItem::Path(path) => {
543                geometry = true;
544                let paths: Vec<&crate::Path> = match &path.path {
545                    Animated::Constant { value } => vec![value],
546                    Animated::Keyframes { keyframes } => {
547                        keyframes.iter().map(|keyframe| &keyframe.value).collect()
548                    }
549                };
550                // Keyframes share their structure, so the first tells.
551                if let Some(first) = paths.first() {
552                    for (subpath, entry) in first.subpaths.iter().enumerate() {
553                        if entry.vertices.is_empty() {
554                            issues.push(LintIssue::EmptySubpath {
555                                owner: owner.clone(),
556                                at: format!("{at}.path.subpaths[{subpath}]"),
557                            });
558                        }
559                    }
560                }
561            }
562            ShapeItem::Fill { .. } | ShapeItem::Stroke(_) => {
563                if !geometry {
564                    issues.push(LintIssue::PaintWithoutGeometry {
565                        owner: owner.clone(),
566                        at,
567                    });
568                }
569            }
570            ShapeItem::Trim(_) => {}
571        }
572    }
573    geometry
574}