1use 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#[derive(Clone, Debug, PartialEq, Serialize)]
17#[serde(tag = "kind", rename_all = "snake_case")]
18#[non_exhaustive]
19pub enum LintIssue {
20 UnreachedKeyframe {
25 owner: LintOwner,
27 property: String,
31 time: MediaTime,
33 },
34 HiddenClip {
38 clip: ClipId,
40 by: ClipId,
42 },
43 ShortTransition {
47 track: TrackId,
49 from_clip: ClipId,
51 to_clip: ClipId,
53 },
54 OffFrameBoundary {
58 owner: LintOwner,
60 edge: LintEdge,
62 time: MediaTime,
64 },
65 UnusedSource {
68 source: SourceId,
70 },
71 HdrSource {
74 source: SourceId,
76 },
77 EmptySubpath {
79 owner: ShapeLintOwner,
81 at: String,
84 },
85 PaintWithoutGeometry {
88 owner: ShapeLintOwner,
90 at: String,
92 },
93}
94
95#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
97#[serde(tag = "type", rename_all = "snake_case")]
98#[non_exhaustive]
99pub enum ShapeLintOwner {
100 Source {
102 source: SourceId,
104 },
105 Node {
107 composition: CompositionId,
109 node: NodeId,
111 },
112}
113
114#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
116#[serde(tag = "type", rename_all = "snake_case")]
117#[non_exhaustive]
118pub enum LintOwner {
119 Clip {
121 clip: ClipId,
123 },
124 Transition {
126 track: TrackId,
128 from_clip: ClipId,
130 to_clip: ClipId,
132 },
133 Cue {
135 track: TrackId,
137 index: usize,
139 },
140 CompositionNode {
142 composition: CompositionId,
144 node: NodeId,
146 },
147 Source {
150 source: SourceId,
152 },
153}
154
155#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize)]
157#[serde(rename_all = "snake_case")]
158pub enum LintEdge {
159 Start,
161 End,
163}
164
165impl Project {
166 #[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 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 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 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
390struct Cover<'a> {
393 order: usize,
395 clip: &'a crate::Clip,
396 mixed: Vec<TimeRange>,
398}
399
400fn 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
417fn 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
440struct 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 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 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
505struct Keyframes<'a> {
508 owner: &'a LintOwner,
509 shown: &'a [(i64, i64)],
510 issues: &'a mut Vec<LintIssue>,
511}
512
513fn 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, ×);
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, ×);
548 }
549 }
550
551 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#[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 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 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 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 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
758fn 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
819fn 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 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}