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 {
23 owner: LintOwner,
25 property: String,
28 time: MediaTime,
30 },
31 HiddenClip {
34 clip: ClipId,
36 by: ClipId,
38 },
39 ShortTransition {
42 track: TrackId,
44 from_clip: ClipId,
46 to_clip: ClipId,
48 },
49 OffFrameBoundary {
52 owner: LintOwner,
54 edge: LintEdge,
56 time: MediaTime,
58 },
59 UnusedSource {
62 source: SourceId,
64 },
65 HdrSource {
68 source: SourceId,
70 },
71 EmptySubpath {
73 owner: ShapeLintOwner,
75 at: String,
78 },
79 PaintWithoutGeometry {
81 owner: ShapeLintOwner,
83 at: String,
85 },
86 UnusedCaptionStyle {
88 track: TrackId,
90 style: String,
92 },
93 EmptyTextAnimator {
95 owner: LintOwner,
97 at: String,
100 },
101}
102
103#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
105#[serde(tag = "type", rename_all = "snake_case")]
106#[non_exhaustive]
107pub enum ShapeLintOwner {
108 Source {
110 source: SourceId,
112 },
113 Node {
115 composition: CompositionId,
117 node: NodeId,
119 },
120}
121
122#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
124#[serde(tag = "type", rename_all = "snake_case")]
125#[non_exhaustive]
126pub enum LintOwner {
127 Clip {
129 clip: ClipId,
131 },
132 Transition {
134 track: TrackId,
136 from_clip: ClipId,
138 to_clip: ClipId,
140 },
141 Cue {
143 track: TrackId,
145 index: usize,
147 },
148 CompositionNode {
150 composition: CompositionId,
152 node: NodeId,
154 },
155 Source {
157 source: SourceId,
159 },
160 CaptionTrack {
162 track: TrackId,
164 },
165 CueRun {
167 track: TrackId,
169 cue: usize,
171 run: usize,
173 },
174}
175
176#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize)]
178#[serde(rename_all = "snake_case")]
179pub enum LintEdge {
180 Start,
182 End,
184}
185
186impl Project {
187 #[must_use]
191 pub fn lint(&self) -> Vec<LintIssue> {
192 let mut issues = Vec::new();
193 let used = self
194 .used_sources()
195 .into_iter()
196 .map(|source| &source.id)
197 .collect::<std::collections::HashSet<_>>();
198 for source in &self.sources {
199 if !used.contains(&source.id) {
200 issues.push(LintIssue::UnusedSource {
201 source: source.id.clone(),
202 });
203 }
204 }
205 self.lint_shapes(&mut issues);
206 let refused = self.settings.color.hdr == crate::HdrSources::Reject;
207 for source in self.hdr_sources().into_iter().filter(|_| refused) {
208 issues.push(LintIssue::HdrSource {
209 source: source.clone(),
210 });
211 }
212 let mattes = self
213 .timeline
214 .tracks
215 .iter()
216 .flat_map(|track| &track.clips)
217 .filter_map(|clip| clip.matte.as_ref().map(|matte| &matte.source))
218 .collect::<HashSet<_>>();
219 let grid = FrameGrid::new(self);
220 let covers = self.covering_clips(&mattes);
221 for (order, track) in self.timeline.tracks.iter().enumerate() {
222 for clip in &track.clips {
223 let owner = LintOwner::Clip {
224 clip: clip.id.clone(),
225 };
226 grid.check(&owner, clip.timeline_range, &mut issues);
227 let mut keyframes = Keyframes {
228 owner: &owner,
229 shown: &[span(clip.timeline_range)],
230 issues: &mut issues,
231 };
232 keyframes.check_clip(clip);
233 if track.kind == TrackKind::Video
234 && !mattes.contains(&clip.id)
235 && let Some(by) = covering_clip(&covers, order, clip.timeline_range)
236 {
237 issues.push(LintIssue::HiddenClip {
238 clip: clip.id.clone(),
239 by: by.clone(),
240 });
241 }
242 }
243 for transition in &track.transitions {
244 let owner = LintOwner::Transition {
245 track: track.id.clone(),
246 from_clip: transition.from_clip.clone(),
247 to_clip: transition.to_clip.clone(),
248 };
249 if !grid.shows_mixed(transition.timeline_range) {
250 issues.push(LintIssue::ShortTransition {
251 track: track.id.clone(),
252 from_clip: transition.from_clip.clone(),
253 to_clip: transition.to_clip.clone(),
254 });
255 }
256 grid.check(&owner, transition.timeline_range, &mut issues);
257 let mut keyframes = Keyframes {
258 owner: &owner,
259 shown: &[span(transition.timeline_range)],
260 issues: &mut issues,
261 };
262 for (name, value) in &transition.effect.params {
263 keyframes.check(&format!("effect.params.{name}"), value);
264 }
265 }
266 lint_cues(&grid, track, &mut issues);
267 if let Some(captions) = &track.captions {
268 lint_caption_animators(&track.id, captions, &mut issues);
269 }
270 }
271 self.lint_keyframes_in_other_times(&mut issues);
272 self.lint_text_animators(&mut issues);
273 issues
274 }
275
276 fn lint_text_animators(&self, issues: &mut Vec<LintIssue>) {
278 let lint =
279 |owner: &LintOwner, generator: &Generator, at: &str, issues: &mut Vec<LintIssue>| {
280 match generator {
281 Generator::Text { style, .. } => {
282 empty_animators(
283 owner,
284 &style.animators,
285 &format!("{at}style.animators"),
286 issues,
287 );
288 }
289 Generator::Layout { root } => root.walk(|path, node| {
290 empty_animators(
291 owner,
292 &node.text_animators,
293 &format!("{at}{path}.text_animators"),
294 issues,
295 );
296 }),
297 Generator::Fill { .. } | Generator::Shapes { .. } => {}
298 }
299 };
300 for source in &self.sources {
301 if let Some(generator) = &source.generator {
302 let owner = LintOwner::Source {
303 source: source.id.clone(),
304 };
305 lint(&owner, generator, "", issues);
306 }
307 }
308 for composition in &self.compositions {
309 for node in &composition.nodes {
310 if let crate::NodeOp::Generator { generator } = &node.op {
311 let owner = LintOwner::CompositionNode {
312 composition: composition.id.clone(),
313 node: node.id.clone(),
314 };
315 lint(&owner, generator, "generator.", issues);
316 }
317 }
318 }
319 }
320
321 fn lint_keyframes_in_other_times(&self, issues: &mut Vec<LintIssue>) {
325 let shown = Shown::of(self);
326 for composition in &self.compositions {
327 let Some(spans) = shown.compositions.get(&composition.id) else {
328 continue;
329 };
330 for node in &composition.nodes {
331 let owner = LintOwner::CompositionNode {
332 composition: composition.id.clone(),
333 node: node.id.clone(),
334 };
335 let mut keyframes = Keyframes {
336 owner: &owner,
337 shown: spans,
338 issues,
339 };
340 node_keyed(&node.op, &mut |property, times| {
341 keyframes.check_times(property, times);
342 });
343 }
344 }
345 for source in &self.sources {
346 let Some(spans) = shown.sources.get(&source.id) else {
347 continue;
348 };
349 let owner = LintOwner::Source {
350 source: source.id.clone(),
351 };
352 let mut keyframes = Keyframes {
353 owner: &owner,
354 shown: spans,
355 issues,
356 };
357 let mut check = |property: &str, times: &[MediaTime]| {
358 keyframes.check_times(property, times);
359 };
360 match &source.generator {
361 Some(Generator::Shapes { items }) => {
362 crate::shapes::for_each_keyed_item(items, "items", &mut check);
363 }
364 Some(Generator::Layout { root }) => root.for_each_keyed("root", &mut check),
365 Some(Generator::Text { style, .. }) => {
366 crate::text_animation::for_each_keyed_animator(
367 &style.animators,
368 "style.animators",
369 &mut check,
370 );
371 }
372 _ => {}
373 }
374 }
375 }
376
377 fn lint_shapes(&self, issues: &mut Vec<LintIssue>) {
380 let lint = |owner: &ShapeLintOwner, generator: &Generator, issues: &mut Vec<LintIssue>| {
381 match generator {
382 Generator::Shapes { items } => {
383 lint_shape_items(owner, items, "items", issues);
384 }
385 Generator::Layout { root } => root.walk(|at, node| {
386 if let Some(shape) = &node.shape {
387 lint_shape_items(owner, &shape.items, &format!("{at}.shape.items"), issues);
388 }
389 }),
390 Generator::Fill { .. } | Generator::Text { .. } => {}
391 }
392 };
393 for source in &self.sources {
394 if let Some(generator) = &source.generator {
395 let owner = ShapeLintOwner::Source {
396 source: source.id.clone(),
397 };
398 lint(&owner, generator, issues);
399 }
400 }
401 for composition in &self.compositions {
402 for node in &composition.nodes {
403 if let crate::NodeOp::Generator { generator } = &node.op {
404 let owner = ShapeLintOwner::Node {
405 composition: composition.id.clone(),
406 node: node.id.clone(),
407 };
408 lint(&owner, generator, issues);
409 }
410 }
411 }
412 }
413
414 fn covering_clips<'a>(&'a self, mattes: &HashSet<&ClipId>) -> Vec<Cover<'a>> {
418 let sources = self
419 .sources
420 .iter()
421 .map(|source| (&source.id, source))
422 .collect::<HashMap<_, _>>();
423 let mut covers = Vec::new();
424 for (order, track) in self.timeline.tracks.iter().enumerate() {
425 if track.kind != TrackKind::Video {
426 continue;
427 }
428 for clip in &track.clips {
429 if mattes.contains(&clip.id)
430 || !sources
431 .get(&clip.source_id)
432 .is_some_and(|source| is_opaque_full_frame(source, clip))
433 {
434 continue;
435 }
436 let mixed = track
437 .transitions
438 .iter()
439 .filter(|transition| {
440 transition.from_clip == clip.id || transition.to_clip == clip.id
441 })
442 .map(|transition| transition.timeline_range)
443 .collect();
444 covers.push(Cover { order, clip, mixed });
445 }
446 }
447 covers
448 }
449}
450
451struct Cover<'a> {
453 order: usize,
455 clip: &'a crate::Clip,
456 mixed: Vec<TimeRange>,
458}
459
460fn covering_clip<'a>(covers: &[Cover<'a>], order: usize, range: TimeRange) -> Option<&'a ClipId> {
462 covers
463 .iter()
464 .filter(|cover| cover.order > order)
465 .find(|cover| {
466 cover.clip.timeline_range.start() <= range.start()
467 && cover.clip.timeline_range.end() >= range.end()
468 && !cover
469 .mixed
470 .iter()
471 .any(|mixed| mixed.intersection(range).is_some())
472 })
473 .map(|cover| &cover.clip.id)
474}
475
476fn is_opaque_full_frame(source: &crate::MediaSource, clip: &crate::Clip) -> bool {
479 let opaque = source.kind == MediaKind::Generated
480 && matches!(&source.generator, Some(Generator::Fill { paint }) if paint_is_opaque(paint));
481 opaque
482 && clip.visual == crate::VisualProperties::default()
483 && clip.blend_mode == crate::ClipBlendMode::Normal
484 && clip.composition.is_none()
485 && clip.masks.is_empty()
486 && clip.matte.is_none()
487}
488
489fn paint_is_opaque(paint: &Paint) -> bool {
490 match paint {
491 Paint::Solid { color } => color.alpha >= 1.0,
492 Paint::LinearGradient { stops, .. }
493 | Paint::RadialGradient { stops, .. }
494 | Paint::ConicGradient { stops, .. } => stops.iter().all(|stop| stop.color.alpha >= 1.0),
495 }
496}
497
498struct FrameGrid {
501 rate: FrameRate,
502}
503
504impl FrameGrid {
505 fn new(project: &Project) -> Self {
506 Self {
507 rate: project.settings.frame_rate,
508 }
509 }
510
511 fn on_grid(&self, time: MediaTime) -> bool {
512 self.rate
513 .frame_at_or_before(time)
514 .and_then(|frame| self.rate.frame_start(frame))
515 == Some(time)
516 }
517
518 fn shows_mixed(&self, range: TimeRange) -> bool {
522 let first = self
523 .rate
524 .frame_at_or_before(range.start())
525 .and_then(|frame| {
526 let start = self.rate.frame_start(frame)?;
527 if start == range.start() {
528 Some(start)
529 } else {
530 self.rate.frame_start(frame.checked_add(1)?)
531 }
532 });
533 let Some(first) = first else {
534 return true;
535 };
536 let num = i128::from(self.rate.numerator());
537 let elapsed = i128::from(first.ticks()) - i128::from(range.start().ticks());
538 let duration = i128::from(range.end().ticks()) - i128::from(range.start().ticks());
539 first < range.end()
541 && 2 * num * elapsed
542 + i128::from(TICKS_PER_SECOND) * i128::from(self.rate.denominator())
543 < 2 * num * duration
544 }
545
546 fn check(&self, owner: &LintOwner, range: TimeRange, issues: &mut Vec<LintIssue>) {
547 for (edge, time) in [
548 (LintEdge::Start, range.start()),
549 (LintEdge::End, range.end()),
550 ] {
551 if !self.on_grid(time) {
552 issues.push(LintIssue::OffFrameBoundary {
553 owner: owner.clone(),
554 edge,
555 time,
556 });
557 }
558 }
559 }
560}
561
562struct Keyframes<'a> {
565 owner: &'a LintOwner,
566 shown: &'a [(i64, i64)],
567 issues: &'a mut Vec<LintIssue>,
568}
569
570fn span(range: TimeRange) -> (i64, i64) {
572 (range.start().ticks(), range.end().ticks() - 1)
573}
574
575impl Keyframes<'_> {
576 fn check_clip(&mut self, clip: &crate::Clip) {
577 let visual = &clip.visual;
578 self.check_point("visual.position", &visual.position);
579 self.check("visual.scale", &visual.scale);
580 self.check("visual.rotation_degrees", &visual.rotation_degrees);
581 self.check("visual.opacity", &visual.opacity);
582 for (index, mask) in clip.masks.iter().enumerate() {
583 self.check_geometry(&format!("masks[{index}].shape"), &mask.shape);
584 self.check(&format!("masks[{index}].feather"), &mask.feather);
585 }
586 self.check("audio.gain", &clip.audio.gain);
587 self.check("audio.pan", &clip.audio.pan);
588 }
589
590 fn check_geometry(&mut self, at: &str, geometry: &ShapeGeometry) {
591 geometry.for_each_keyed(at, &mut |property, times| self.check_times(property, times));
592 }
593
594 fn check_point(&mut self, property: &str, animated: &AnimatedPoint) {
595 if let AnimatedPoint::Keyframes { keyframes } = animated {
596 let times: Vec<MediaTime> = keyframes.iter().map(|keyframe| keyframe.time).collect();
597 self.check_times(property, ×);
598 }
599 }
600
601 fn check<T>(&mut self, property: &str, animated: &Animated<T>) {
602 if let Animated::Keyframes { keyframes } = animated {
603 let times: Vec<MediaTime> = keyframes.iter().map(|keyframe| keyframe.time).collect();
604 self.check_times(property, ×);
605 }
606 }
607
608 fn check_times(&mut self, property: &str, times: &[MediaTime]) {
611 for (index, time) in times.iter().enumerate() {
612 let after = index.checked_sub(1).map_or(i64::MIN, |previous| {
613 times[previous].ticks().saturating_add(1)
614 });
615 let before = times
616 .get(index + 1)
617 .map_or(i64::MAX, |next| next.ticks().saturating_sub(1));
618 let reached = self
619 .shown
620 .iter()
621 .any(|&(first, last)| after.max(first) <= before.min(last));
622 if !reached {
623 self.issues.push(LintIssue::UnreachedKeyframe {
624 owner: self.owner.clone(),
625 property: property.to_owned(),
626 time: *time,
627 });
628 }
629 }
630 }
631}
632
633#[derive(Default)]
642struct Shown<'a> {
643 compositions: HashMap<&'a CompositionId, Vec<(i64, i64)>>,
644 sources: HashMap<&'a SourceId, Vec<(i64, i64)>>,
645}
646
647impl<'a> Shown<'a> {
648 fn of(project: &'a Project) -> Self {
649 let (base, unbounded) = Self::clips(project);
650 let mut shown = base.propagated(project);
651 shown.drop_unbounded(project, unbounded);
652 shown
653 }
654
655 fn clips(project: &'a Project) -> (Self, Vec<&'a CompositionId>) {
658 let mut base = Self::default();
659 let mut unbounded = Vec::new();
660 for clip in project
661 .timeline
662 .tracks
663 .iter()
664 .flat_map(|track| &track.clips)
665 {
666 let picture = match clip.time_map {
667 crate::TimeMap::Freeze { source_time } => {
668 (source_time.ticks(), source_time.ticks())
669 }
670 crate::TimeMap::Forward | crate::TimeMap::Reverse | crate::TimeMap::Loop { .. } => {
671 span(clip.source_range)
672 }
673 };
674 base.sources
675 .entry(&clip.source_id)
676 .or_default()
677 .push(picture);
678 if let Some(composition) = &clip.composition {
679 let start = clip.timeline_range.start();
680 let last = MediaTime(clip.timeline_range.end().ticks() - 1);
681 match (
682 composition.local_time(start, start),
683 composition.local_time(start, last),
684 ) {
685 (Some(first), Some(last)) => base
686 .compositions
687 .entry(&composition.id)
688 .or_default()
689 .push((first.ticks(), last.ticks())),
690 _ => unbounded.push(&composition.id),
691 }
692 }
693 }
694 (base, unbounded)
695 }
696
697 fn propagated(self, project: &'a Project) -> Self {
701 let mut shown = Self {
702 compositions: self.compositions.clone(),
703 sources: self.sources.clone(),
704 };
705 shown.normalize();
706 for _ in 0..=project.compositions.len() + project.sources.len() {
707 let mut next = Self {
708 compositions: self.compositions.clone(),
709 sources: self.sources.clone(),
710 };
711 for source in &project.sources {
712 if let (Some(composition), Some(spans)) =
713 (&source.composition, shown.sources.get(&source.id))
714 {
715 next.compositions
716 .entry(composition)
717 .or_default()
718 .extend(spans);
719 }
720 }
721 for composition in &project.compositions {
722 let Some(spans) = shown.compositions.get(&composition.id) else {
723 continue;
724 };
725 for node in &composition.nodes {
726 match &node.op {
727 crate::NodeOp::Group { composition, .. } => {
728 next.compositions
729 .entry(composition)
730 .or_default()
731 .extend(spans);
732 }
733 crate::NodeOp::Media { source_id, start } => {
734 next.sources
735 .entry(source_id)
736 .or_default()
737 .extend(spans.iter().map(|&(first, last)| {
738 (
739 first.saturating_add(start.ticks()),
740 last.saturating_add(start.ticks()),
741 )
742 }));
743 }
744 _ => {}
745 }
746 }
747 }
748 next.normalize();
749 if next.compositions == shown.compositions && next.sources == shown.sources {
750 break;
751 }
752 shown = next;
753 }
754 shown
755 }
756
757 fn drop_unbounded(&mut self, project: &'a Project, mut unbounded: Vec<&'a CompositionId>) {
760 while let Some(id) = unbounded.pop() {
761 self.compositions.remove(id);
762 let Some(composition) = project.compositions.iter().find(|held| held.id == *id) else {
763 continue;
764 };
765 for node in &composition.nodes {
766 match &node.op {
767 crate::NodeOp::Group { composition, .. } => {
768 if self.compositions.contains_key(composition) {
769 unbounded.push(composition);
770 }
771 }
772 crate::NodeOp::Media { source_id, .. } => {
773 self.sources.remove(source_id);
774 if let Some(inner) = project
775 .sources
776 .iter()
777 .find(|source| source.id == *source_id)
778 .and_then(|source| source.composition.as_ref())
779 && self.compositions.contains_key(inner)
780 {
781 unbounded.push(inner);
782 }
783 }
784 _ => {}
785 }
786 }
787 }
788 }
789
790 fn normalize(&mut self) {
792 fn merge(spans: &mut Vec<(i64, i64)>) {
793 spans.sort_unstable();
794 let mut merged: Vec<(i64, i64)> = Vec::with_capacity(spans.len());
795 for &(first, last) in spans.iter() {
796 match merged.last_mut() {
797 Some(held) if first <= held.1.saturating_add(1) => held.1 = held.1.max(last),
798 _ => merged.push((first, last)),
799 }
800 }
801 *spans = merged;
802 }
803 self.compositions.values_mut().for_each(merge);
804 self.sources.values_mut().for_each(merge);
805 }
806}
807
808fn node_keyed(op: &crate::NodeOp, visit: &mut impl FnMut(&str, &[MediaTime])) {
811 use crate::animation::{visit_keyed, visit_keyed_point};
812 let params = |effect: &crate::ClipEffect, visit: &mut dyn FnMut(&str, &[MediaTime])| {
813 for (name, value) in &effect.params {
814 visit_keyed(&format!("effect.params.{name}"), value, &mut |at, times| {
815 visit(at, times);
816 });
817 }
818 };
819 match op {
820 crate::NodeOp::Effect { effect, .. } => params(effect, visit),
821 crate::NodeOp::Transition {
822 effect, progress, ..
823 } => {
824 params(effect, visit);
825 visit_keyed("progress", progress, visit);
826 }
827 crate::NodeOp::Transform { transform, .. } => {
828 visit_keyed_point("transform.position", &transform.position, visit);
829 visit_keyed("transform.scale", &transform.scale, visit);
830 visit_keyed(
831 "transform.rotation_degrees",
832 &transform.rotation_degrees,
833 visit,
834 );
835 visit_keyed("transform.opacity", &transform.opacity, visit);
836 }
837 crate::NodeOp::Merge { layers } => {
838 for (index, layer) in layers.iter().enumerate() {
839 visit_keyed(&format!("layers[{index}].opacity"), &layer.opacity, visit);
840 }
841 }
842 crate::NodeOp::ShapeMask { masks, .. } => {
843 for (index, mask) in masks.iter().enumerate() {
844 mask.shape
845 .for_each_keyed(&format!("masks[{index}].shape"), visit);
846 visit_keyed(&format!("masks[{index}].feather"), &mask.feather, visit);
847 }
848 }
849 crate::NodeOp::Group { controls, .. } => {
850 for (name, value) in controls {
851 visit_keyed(&format!("controls.{name}"), value, visit);
852 }
853 }
854 crate::NodeOp::Generator { generator } => match generator {
855 Generator::Shapes { items } => {
856 crate::shapes::for_each_keyed_item(items, "generator.items", visit);
857 }
858 Generator::Layout { root } => root.for_each_keyed("generator.root", visit),
859 Generator::Text { style, .. } => crate::text_animation::for_each_keyed_animator(
860 &style.animators,
861 "generator.style.animators",
862 visit,
863 ),
864 Generator::Fill { .. } => {}
865 },
866 crate::NodeOp::Clip {}
867 | crate::NodeOp::Media { .. }
868 | crate::NodeOp::Matte { .. }
869 | crate::NodeOp::GroupInput { .. } => {}
870 }
871}
872
873fn lint_shape_items(
876 owner: &ShapeLintOwner,
877 items: &[ShapeItem],
878 at: &str,
879 issues: &mut Vec<LintIssue>,
880) -> bool {
881 let mut geometry = false;
882 for (index, item) in items.iter().enumerate() {
883 let at = format!("{at}[{index}]");
884 match item {
885 ShapeItem::Group { items, .. } => {
886 geometry |= lint_shape_items(owner, items, &format!("{at}.items"), issues);
887 }
888 ShapeItem::Rectangle(_) | ShapeItem::Ellipse(_) | ShapeItem::Polystar(_) => {
889 geometry = true;
890 }
891 ShapeItem::Path(path) => {
892 geometry = true;
893 let paths: Vec<&crate::Path> = match &path.path {
894 Animated::Constant { value } => vec![value],
895 Animated::Keyframes { keyframes } => {
896 keyframes.iter().map(|keyframe| &keyframe.value).collect()
897 }
898 };
899 if let Some(first) = paths.first() {
901 for (subpath, entry) in first.subpaths.iter().enumerate() {
902 if entry.vertices.is_empty() {
903 issues.push(LintIssue::EmptySubpath {
904 owner: owner.clone(),
905 at: format!("{at}.path.subpaths[{subpath}]"),
906 });
907 }
908 }
909 }
910 }
911 ShapeItem::Fill { .. } | ShapeItem::Stroke(_) => {
912 if !geometry {
913 issues.push(LintIssue::PaintWithoutGeometry {
914 owner: owner.clone(),
915 at,
916 });
917 }
918 }
919 ShapeItem::Trim(_) => {}
920 }
921 }
922 geometry
923}
924
925fn empty_animators(
927 owner: &LintOwner,
928 animators: &[crate::TextAnimator],
929 at: &str,
930 issues: &mut Vec<LintIssue>,
931) {
932 for (index, animator) in animators.iter().enumerate() {
933 if animator.properties.is_empty() {
934 issues.push(LintIssue::EmptyTextAnimator {
935 owner: owner.clone(),
936 at: format!("{at}[{index}]"),
937 });
938 }
939 }
940}
941
942fn lint_cues(grid: &FrameGrid, track: &crate::Track, issues: &mut Vec<LintIssue>) {
944 for (index, cue) in track
945 .captions
946 .iter()
947 .flat_map(|captions| captions.cues.iter())
948 .enumerate()
949 {
950 let owner = LintOwner::Cue {
951 track: track.id.clone(),
952 index,
953 };
954 grid.check(&owner, cue.timeline_range, issues);
955 for (run, timed) in cue.text.runs().iter().enumerate() {
958 let Some(time) = timed.time else {
959 continue;
960 };
961 let start = cue.timeline_range.start().ticks();
962 let Ok(range) = alpha2_time::TimeRange::new(
963 MediaTime(start.saturating_add(time.start.ticks())),
964 MediaTime(start.saturating_add(time.end.ticks())),
965 ) else {
966 continue;
967 };
968 let owner = LintOwner::CueRun {
969 track: track.id.clone(),
970 cue: index,
971 run,
972 };
973 grid.check(&owner, range, issues);
974 }
975 }
976}
977
978fn lint_caption_animators(
981 track: &TrackId,
982 captions: &crate::Captions,
983 issues: &mut Vec<LintIssue>,
984) {
985 let owner = LintOwner::CaptionTrack {
986 track: track.clone(),
987 };
988 let spans = captions
989 .cues
990 .iter()
991 .filter_map(|cue| cue.timeline_range.duration())
992 .map(|duration| (0, duration.ticks().saturating_sub(1)))
993 .collect::<Vec<_>>();
994 let mut keyframes = Keyframes {
995 owner: &owner,
996 shown: &spans,
997 issues,
998 };
999 crate::text_animation::for_each_keyed_animator(
1000 &captions.style.animators,
1001 "style.animators",
1002 &mut |property, times| keyframes.check_times(property, times),
1003 );
1004 empty_animators(&owner, &captions.style.animators, "style.animators", issues);
1005 for name in captions.styles.keys() {
1006 let used = captions.cues.iter().any(|cue| {
1007 cue.style.as_deref() == Some(name.as_str())
1008 || (cue.style.is_none() && cue.speaker.as_deref() == Some(name.as_str()))
1009 });
1010 if !used {
1011 issues.push(LintIssue::UnusedCaptionStyle {
1012 track: track.clone(),
1013 style: name.clone(),
1014 });
1015 }
1016 }
1017}