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