Skip to main content

renamite_validate/
lib.rs

1//! Project validation and diagnostics for renamite.
2//!
3//! Deterministic checks over a [`RenFile`]: document tree integrity,
4//! asset references, animation keyframe hygiene, clip/machine sanity, and
5//! export-readiness warnings. Use [`validate`] to produce a
6//! [`ValidationReport`]; [`ValidationReport::has_errors`] tells you whether the
7//! project is safe to save/render/export.
8
9use glam::DVec2;
10use renamite_animation::{Angle, Animated, AnimatedTransform, Frame};
11use renamite_geometry::VectorPath;
12use renamite_io_ren::RenFile;
13use renamite_machine::{
14    Condition, InputKind, ListenerAction, Machine, MachineId, StateKind, Transition,
15};
16use renamite_model::{
17    Asset, Color, CompId, Document, GradientStops, ModifierKind, Node, NodeId, NodeKind, PropRef,
18    ShapeKind, StyleKind, StylePaint, Value, node_supports_opacity, node_supports_transform,
19};
20use serde::{Deserialize, Serialize};
21use std::collections::HashSet;
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
24pub enum Severity {
25    Error,
26    Warning,
27    Info,
28}
29
30#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
31pub struct Diagnostic {
32    pub severity: Severity,
33    pub path: String,
34    pub message: String,
35}
36
37impl Diagnostic {
38    pub fn error(path: impl Into<String>, message: impl Into<String>) -> Self {
39        Self {
40            severity: Severity::Error,
41            path: path.into(),
42            message: message.into(),
43        }
44    }
45
46    pub fn warning(path: impl Into<String>, message: impl Into<String>) -> Self {
47        Self {
48            severity: Severity::Warning,
49            path: path.into(),
50            message: message.into(),
51        }
52    }
53
54    pub fn info(path: impl Into<String>, message: impl Into<String>) -> Self {
55        Self {
56            severity: Severity::Info,
57            path: path.into(),
58            message: message.into(),
59        }
60    }
61}
62
63#[derive(Clone, Debug, Default, Serialize, Deserialize)]
64pub struct ValidationReport {
65    pub diagnostics: Vec<Diagnostic>,
66}
67
68impl ValidationReport {
69    pub fn has_errors(&self) -> bool {
70        self.diagnostics
71            .iter()
72            .any(|d| d.severity == Severity::Error)
73    }
74
75    pub fn error_count(&self) -> usize {
76        self.diagnostics
77            .iter()
78            .filter(|d| d.severity == Severity::Error)
79            .count()
80    }
81
82    pub fn warning_count(&self) -> usize {
83        self.diagnostics
84            .iter()
85            .filter(|d| d.severity == Severity::Warning)
86            .count()
87    }
88
89    pub fn push(&mut self, d: Diagnostic) {
90        self.diagnostics.push(d);
91    }
92}
93
94pub fn validate(file: &RenFile) -> ValidationReport {
95    let mut v = Validator {
96        file,
97        report: ValidationReport::default(),
98    };
99    v.run();
100    v.report
101}
102
103struct Validator<'a> {
104    file: &'a RenFile,
105    report: ValidationReport,
106}
107
108impl<'a> Validator<'a> {
109    fn run(&mut self) {
110        self.validate_compositions();
111        self.validate_document_tree();
112        self.validate_assets();
113        self.validate_animations();
114        self.validate_scope();
115        self.validate_precomps();
116        self.validate_clips();
117        self.validate_machines();
118        self.validate_export_readiness();
119    }
120
121    fn err(&mut self, path: impl Into<String>, message: impl Into<String>) {
122        self.report.push(Diagnostic::error(path, message));
123    }
124
125    fn warn(&mut self, path: impl Into<String>, message: impl Into<String>) {
126        self.report.push(Diagnostic::warning(path, message));
127    }
128
129    fn validate_compositions(&mut self) {
130        let doc = &self.file.document;
131
132        if !doc.compositions.contains_key(doc.main) {
133            self.err("document.main", "main composition does not exist");
134        }
135
136        for (id, comp) in &doc.compositions {
137            if comp.rate.num == 0 || comp.rate.den == 0 {
138                self.err(format!("composition/{id:?}/rate"), "invalid frame rate");
139            }
140            if comp.range.1 <= comp.range.0 {
141                self.err(
142                    format!("composition/{id:?}/range"),
143                    "out frame must be after in frame",
144                );
145            }
146            if comp.size.0 == 0 || comp.size.1 == 0 {
147                self.warn(
148                    format!("composition/{id:?}/size"),
149                    "composition size is zero",
150                );
151            }
152            for (index, child) in comp.children.iter().enumerate() {
153                if !doc.nodes.contains_key(*child) {
154                    self.err(
155                        format!("composition/{id:?}/children/{index}"),
156                        "child node does not exist",
157                    );
158                }
159            }
160        }
161    }
162
163    fn validate_document_tree(&mut self) {
164        let doc = &self.file.document;
165        let mut seen = HashSet::new();
166
167        for (comp_id, comp) in &doc.compositions {
168            for &root in &comp.children {
169                self.walk_node_tree(
170                    root,
171                    format!("composition/{comp_id:?}"),
172                    &mut seen,
173                    Vec::new(),
174                );
175            }
176        }
177
178        for id in doc.nodes.keys() {
179            if !seen.contains(&id) {
180                self.warn(
181                    format!("node/{id:?}"),
182                    "detached arena node will be pruned on save",
183                );
184            }
185        }
186    }
187
188    fn walk_node_tree(
189        &mut self,
190        id: NodeId,
191        path: String,
192        seen: &mut HashSet<NodeId>,
193        mut stack: Vec<NodeId>,
194    ) {
195        if stack.contains(&id) {
196            self.err(format!("{path}/node/{id:?}"), "cycle in node tree");
197            return;
198        }
199
200        let Some(node) = self.file.document.nodes.get(id) else {
201            self.err(path, format!("node {id:?} does not exist"));
202            return;
203        };
204
205        seen.insert(id);
206        stack.push(id);
207
208        for (index, &child) in node.children.iter().enumerate() {
209            match self.file.document.nodes.get(child) {
210                Some(child_node) => {
211                    if child_node.parent != Some(id) {
212                        self.err(
213                            format!("node/{id:?}/children/{index}"),
214                            "child parent pointer does not point back to this node",
215                        );
216                    }
217                    self.walk_node_tree(
218                        child,
219                        format!("node/{id:?}/children/{index}"),
220                        seen,
221                        stack.clone(),
222                    );
223                }
224                None => {
225                    self.err(
226                        format!("node/{id:?}/children/{index}"),
227                        "child node does not exist",
228                    );
229                }
230            }
231        }
232    }
233
234    fn validate_assets(&mut self) {
235        let doc = &self.file.document;
236
237        let mut seen = HashSet::new();
238        for (i, &id) in doc.asset_order.iter().enumerate() {
239            if !doc.assets.contains_key(id) {
240                self.err(format!("assets/order/{i}"), "asset id does not exist");
241            }
242            if !seen.insert(id) {
243                self.err(
244                    format!("assets/order/{i}"),
245                    "duplicate asset id in asset_order",
246                );
247            }
248        }
249
250        for id in doc.assets.keys() {
251            if !seen.contains(&id) {
252                self.warn(
253                    format!("asset/{id:?}"),
254                    "asset exists but is not attached in asset_order",
255                );
256            }
257        }
258
259        for (id, node) in &doc.nodes {
260            match &node.kind {
261                NodeKind::Image(img) => match doc.assets.get(img.asset()) {
262                    Some(Asset::Image(img)) => {
263                        if img.width == 0 || img.height == 0 {
264                            self.err(
265                                format!("node/{id:?}/image"),
266                                "image dimensions must be nonzero",
267                            );
268                        }
269                        if img.bytes.is_empty() {
270                            self.err(format!("node/{id:?}/image"), "image asset has no bytes");
271                        }
272                    }
273                    Some(_) => self.err(
274                        format!("node/{id:?}/image"),
275                        "referenced asset is not an image",
276                    ),
277                    None => self.err(format!("node/{id:?}/image"), "image asset is missing"),
278                },
279                NodeKind::Text(text) => {
280                    if let Some(family) = &text.font
281                        && family != "default"
282                        && doc.font_asset_for_family(family).is_none()
283                    {
284                        self.warn(
285                            format!("node/{id:?}/text/font"),
286                            format!(
287                                "font family `{family}` not found; bundled default will be used"
288                            ),
289                        );
290                    }
291                }
292                _ => {}
293            }
294        }
295
296        for (id, asset) in &doc.assets {
297            match asset {
298                Asset::Image(img) => {
299                    if img.width == 0 || img.height == 0 {
300                        self.err(format!("asset/{id:?}"), "image dimensions must be nonzero");
301                    }
302                    if img.bytes.is_empty() {
303                        self.warn(format!("asset/{id:?}"), "image asset has empty bytes");
304                    }
305                }
306                Asset::Font(font) => {
307                    if font.bytes.is_empty() {
308                        self.err(format!("asset/{id:?}"), "font has no bytes");
309                    }
310                    if font.family.trim().is_empty() {
311                        self.err(format!("asset/{id:?}"), "font family is empty");
312                    }
313                }
314            }
315        }
316
317        self.validate_asset_usage(doc);
318    }
319
320    fn validate_asset_usage(&mut self, doc: &Document) {
321        for (id, asset) in &doc.assets {
322            match asset {
323                Asset::Image(_) => {
324                    let used = doc
325                        .nodes
326                        .values()
327                        .any(|n| matches!(&n.kind, NodeKind::Image(img) if img.asset() == id));
328                    if !used {
329                        self.warn(
330                            format!("asset/{id:?}"),
331                            "image asset is not used by any image layer",
332                        );
333                    }
334                }
335                Asset::Font(font) => {
336                    let used = doc.nodes.values().any(|n| {
337                        matches!(&n.kind, NodeKind::Text(t) if t.font.as_deref() == Some(font.family.as_str()))
338                    });
339                    if !used {
340                        self.warn(
341                            format!("asset/{id:?}"),
342                            "font asset is not used by any text node",
343                        );
344                    }
345                }
346            }
347        }
348    }
349
350    fn validate_animations(&mut self) {
351        let doc = &self.file.document;
352        for (id, node) in &doc.nodes {
353            self.validate_node_animations(id, node);
354        }
355    }
356
357    fn validate_node_animations(&mut self, id: NodeId, node: &Node) {
358        let base = format!("node/{id:?}");
359        self.check_transform(&format!("{base}/transform"), &node.transform);
360        self.check_animated(&format!("{base}/opacity"), &node.opacity, finite_f64);
361
362        if !node_supports_transform(&node.kind) && !transform_is_default(&node.transform) {
363            self.warn(
364                format!("{base}/transform"),
365                "transform is not honored for this node kind and has no render effect",
366            );
367        }
368        if !node_supports_opacity(&node.kind) && !opacity_is_default(&node.opacity) {
369            self.warn(
370                format!("{base}/opacity"),
371                "opacity is not honored for this node kind and has no render effect",
372            );
373        }
374
375        if node.transform.scale.base == DVec2::ZERO {
376            self.warn(format!("{base}/transform/scale"), "transform scale is zero");
377        }
378
379        match &node.kind {
380            NodeKind::Shape(shape) => self.validate_shape_animations(id, shape),
381            NodeKind::Style(style) => self.validate_style_animations(id, style),
382            NodeKind::Modifier(modifier) => self.validate_modifier_animations(id, modifier),
383            NodeKind::Text(text) => {
384                self.check_animated(&format!("{base}/text/size"), &text.size, finite_f64);
385                self.check_animated(&format!("{base}/text/tracking"), &text.tracking, finite_f64);
386                self.check_animated(&format!("{base}/text/leading"), &text.leading, finite_f64);
387            }
388            NodeKind::Layer(props) => {
389                if !props.time_stretch.is_finite() || props.time_stretch <= 0.0 {
390                    self.err(
391                        format!("{base}/layer/time_stretch"),
392                        "time stretch must be positive and finite",
393                    );
394                }
395                if props.out_frame <= props.in_frame {
396                    self.warn(
397                        format!("{base}/layer/range"),
398                        "layer out frame must be after in frame",
399                    );
400                }
401            }
402            NodeKind::Mask(mask) => {
403                self.validate_shape_animations(id, &mask.shape);
404                if shape_kind_is_empty(&mask.shape) {
405                    self.warn(format!("{base}/mask"), "mask has no geometry");
406                }
407            }
408            NodeKind::Image(img) => {
409                self.check_animated(&format!("{base}/image/tint"), img.tint(), finite_color);
410                let c = img.crop();
411                if !c.x.is_finite() || !c.y.is_finite() || !c.z.is_finite() || !c.w.is_finite() {
412                    self.err(format!("{base}/image/crop"), "crop is not finite");
413                } else {
414                    if !(0.0..=1.0).contains(&c.x)
415                        || !(0.0..=1.0).contains(&c.y)
416                        || c.z <= 0.0
417                        || c.w <= 0.0
418                        || c.z > 1.0
419                        || c.w > 1.0
420                    {
421                        self.err(
422                            format!("{base}/image/crop"),
423                            "crop must be x,y in [0,1] and w,h in (0,1]",
424                        );
425                    }
426                    if c.x + c.z > 1.0 + 1e-9 || c.y + c.w > 1.0 + 1e-9 {
427                        self.err(
428                            format!("{base}/image/crop"),
429                            "crop rect must be inside [0,1] image bounds (x+w<=1, y+h<=1)",
430                        );
431                    }
432                }
433            }
434            NodeKind::Group | NodeKind::Precomp { .. } => {}
435        }
436    }
437
438    fn validate_shape_animations(&mut self, id: NodeId, shape: &ShapeKind) {
439        let base = format!("node/{id:?}/shape");
440        match shape {
441            ShapeKind::Path(path) => {
442                self.check_animated(&format!("{base}/path"), path, finite_path);
443            }
444            ShapeKind::Rect { pos, size, rounded } => {
445                self.check_animated(&format!("{base}/pos"), pos, finite_vec2);
446                self.check_animated(&format!("{base}/size"), size, finite_vec2);
447                self.check_animated(&format!("{base}/rounded"), rounded, finite_f64);
448            }
449            ShapeKind::Ellipse { pos, size } => {
450                self.check_animated(&format!("{base}/pos"), pos, finite_vec2);
451                self.check_animated(&format!("{base}/size"), size, finite_vec2);
452            }
453            ShapeKind::Star {
454                pos,
455                points,
456                inner_r,
457                outer_r,
458                roundness,
459                ..
460            } => {
461                self.check_animated(&format!("{base}/pos"), pos, finite_vec2);
462                self.check_animated(&format!("{base}/points"), points, finite_f64);
463                self.check_animated(&format!("{base}/inner_r"), inner_r, finite_f64);
464                self.check_animated(&format!("{base}/outer_r"), outer_r, finite_f64);
465                self.check_animated(&format!("{base}/roundness"), roundness, finite_f64);
466            }
467            ShapeKind::Polygon {
468                pos,
469                points,
470                outer_r,
471                roundness,
472            } => {
473                self.check_animated(&format!("{base}/pos"), pos, finite_vec2);
474                self.check_animated(&format!("{base}/points"), points, finite_f64);
475                self.check_animated(&format!("{base}/outer_r"), outer_r, finite_f64);
476                self.check_animated(&format!("{base}/roundness"), roundness, finite_f64);
477            }
478            ShapeKind::CompoundPath(compound) => {
479                for (i, contour) in compound.contours.iter().enumerate() {
480                    self.check_animated(&format!("{base}/contour/{i}"), contour, finite_path);
481                }
482            }
483        }
484    }
485
486    fn validate_style_animations(&mut self, id: NodeId, style: &StyleKind) {
487        let base = format!("node/{id:?}/style");
488        match style {
489            StyleKind::Fill { paint, .. } => {
490                self.validate_paint(&format!("{base}/paint"), paint);
491            }
492            StyleKind::Stroke {
493                paint, width, dash, ..
494            } => {
495                self.validate_paint(&format!("{base}/paint"), paint);
496                self.check_animated(&format!("{base}/width"), width, finite_f64);
497                if let Some(dash) = dash {
498                    for (i, d) in dash.dashes.iter().enumerate() {
499                        self.check_animated(&format!("{base}/dash/{i}"), d, finite_f64);
500                    }
501                    self.check_animated(&format!("{base}/dash/offset"), &dash.offset, finite_f64);
502                }
503            }
504        }
505    }
506
507    fn validate_paint(&mut self, path: &str, paint: &StylePaint) {
508        match paint {
509            StylePaint::Solid { color } => self.check_animated(path, color, finite_color),
510            StylePaint::Gradient(gradient) => {
511                self.check_animated(&format!("{path}/start"), &gradient.start, finite_vec2);
512                self.check_animated(&format!("{path}/end"), &gradient.end, finite_vec2);
513                self.check_animated(&format!("{path}/stops"), &gradient.stops, finite_stops);
514            }
515        }
516    }
517
518    fn validate_modifier_animations(&mut self, id: NodeId, modifier: &ModifierKind) {
519        let base = format!("node/{id:?}/modifier");
520        match modifier {
521            ModifierKind::TrimPath {
522                start, end, offset, ..
523            } => {
524                self.check_animated(&format!("{base}/start"), start, finite_f64);
525                self.check_animated(&format!("{base}/end"), end, finite_f64);
526                self.check_animated(&format!("{base}/offset"), offset, finite_f64);
527            }
528            ModifierKind::Repeater {
529                copies,
530                offset,
531                transform,
532                start_opacity,
533                end_opacity,
534            } => {
535                self.check_animated(&format!("{base}/copies"), copies, finite_f64);
536                self.check_animated(&format!("{base}/offset"), offset, finite_f64);
537                self.check_animated(&format!("{base}/start_opacity"), start_opacity, finite_f64);
538                self.check_animated(&format!("{base}/end_opacity"), end_opacity, finite_f64);
539                self.check_transform(&format!("{base}/transform"), transform);
540            }
541            ModifierKind::RoundCorners { radius } => {
542                self.check_animated(&format!("{base}/radius"), radius, finite_f64);
543            }
544            ModifierKind::OffsetPath { amount } => {
545                self.check_animated(&format!("{base}/amount"), amount, finite_f64);
546            }
547            ModifierKind::ZigZag {
548                amplitude,
549                frequency,
550                ..
551            } => {
552                self.check_animated(&format!("{base}/amplitude"), amplitude, finite_f64);
553                self.check_animated(&format!("{base}/frequency"), frequency, finite_f64);
554            }
555            ModifierKind::PuckerBloat { amount } => {
556                self.check_animated(&format!("{base}/amount"), amount, finite_f64);
557            }
558        }
559    }
560
561    fn check_transform(&mut self, path: &str, transform: &AnimatedTransform) {
562        self.check_animated(&format!("{path}/anchor"), &transform.anchor, finite_vec2);
563        self.check_animated(
564            &format!("{path}/position"),
565            &transform.position,
566            finite_vec2,
567        );
568        self.check_animated(&format!("{path}/scale"), &transform.scale, finite_vec2);
569        self.check_animated(
570            &format!("{path}/rotation"),
571            &transform.rotation,
572            finite_angle,
573        );
574        self.check_animated(&format!("{path}/skew"), &transform.skew, finite_f64);
575        self.check_animated(
576            &format!("{path}/skew_axis"),
577            &transform.skew_axis,
578            finite_f64,
579        );
580    }
581
582    fn check_animated<T>(
583        &mut self,
584        path: &str,
585        animated: &Animated<T>,
586        check_value: impl Fn(&T) -> bool,
587    ) {
588        if !check_value(&animated.base) {
589            self.err(format!("{path}/base"), "value is not finite");
590        }
591        let mut prev: Option<Frame> = None;
592        for (i, key) in animated.keyframes.iter().enumerate() {
593            if let Some(p) = prev
594                && key.frame <= p
595            {
596                self.err(
597                    format!("{path}/key/{i}"),
598                    format!(
599                        "keyframes not strictly increasing (duplicate or out of order at frame {})",
600                        key.frame.0
601                    ),
602                );
603            }
604            if !check_value(&key.value) {
605                self.err(format!("{path}/key/{i}"), "keyframe value is not finite");
606            }
607            if !key.ease_out.x.is_finite()
608                || !key.ease_out.y.is_finite()
609                || !key.ease_in.x.is_finite()
610                || !key.ease_in.y.is_finite()
611            {
612                self.err(
613                    format!("{path}/key/{i}/easing"),
614                    "easing handle is not finite",
615                );
616            }
617            prev = Some(key.frame);
618        }
619    }
620
621    /// Style/modifier scoping mirrors group evaluation: a style paints every
622    /// shape path accumulated in its group, and a modifier only affects shapes
623    /// seen before it. Warn when either would be a no-op.
624    fn validate_scope(&mut self) {
625        let doc = &self.file.document;
626        let mut visited = HashSet::new();
627        for (comp_id, comp) in &doc.compositions {
628            self.scope_group(
629                comp.children.to_vec(),
630                format!("composition/{comp_id:?}"),
631                &mut visited,
632            );
633        }
634    }
635
636    fn scope_group(&mut self, children: Vec<NodeId>, path: String, visited: &mut HashSet<NodeId>) {
637        let doc = &self.file.document;
638        let mut has_shape = false;
639
640        for (index, &id) in children.iter().enumerate() {
641            let Some(node) = doc.nodes.get(id) else {
642                continue;
643            };
644            match &node.kind {
645                NodeKind::Shape(_) | NodeKind::Text(_) => has_shape = true,
646                NodeKind::Modifier(_) if !has_shape => {
647                    self.warn(
648                        format!("{path}/children/{index}"),
649                        "modifier appears before any shape in scope and will have no effect",
650                    );
651                }
652                _ => {}
653            }
654        }
655
656        if !has_shape {
657            for (index, &id) in children.iter().enumerate() {
658                let Some(node) = doc.nodes.get(id) else {
659                    continue;
660                };
661                if matches!(node.kind, NodeKind::Style(_)) {
662                    self.warn(
663                        format!("{path}/children/{index}"),
664                        "style node is not paired with any shape in scope",
665                    );
666                }
667            }
668        }
669
670        for &id in &children {
671            let Some(node) = doc.nodes.get(id) else {
672                continue;
673            };
674            if matches!(node.kind, NodeKind::Group | NodeKind::Layer(_)) && visited.insert(id) {
675                self.scope_group(
676                    node.children.clone(),
677                    format!("{path}/node/{id:?}"),
678                    visited,
679                );
680            }
681        }
682    }
683
684    fn validate_precomps(&mut self) {
685        let doc = &self.file.document;
686
687        for (id, node) in &doc.nodes {
688            if let NodeKind::Precomp { comp, time_map } = &node.kind {
689                if !doc.compositions.contains_key(*comp) {
690                    self.err(
691                        format!("node/{id:?}/precomp"),
692                        "referenced composition does not exist",
693                    );
694                }
695                if !time_map.stretch.is_finite() || time_map.stretch.abs() < 1e-6 {
696                    self.err(
697                        format!("node/{id:?}/precomp/stretch"),
698                        "invalid time stretch",
699                    );
700                }
701            }
702        }
703
704        let mut on_stack = HashSet::new();
705        let mut visited = HashSet::new();
706        for comp in doc.compositions.keys() {
707            self.walk_precomp(comp, &mut on_stack, &mut visited);
708        }
709    }
710
711    fn walk_precomp(
712        &mut self,
713        comp: CompId,
714        on_stack: &mut HashSet<CompId>,
715        visited: &mut HashSet<CompId>,
716    ) {
717        if on_stack.contains(&comp) {
718            self.err(
719                format!("precomp/{comp:?}"),
720                "composition is reachable from itself through precomps (cycle)",
721            );
722            return;
723        }
724        if !visited.insert(comp) {
725            return;
726        }
727        on_stack.insert(comp);
728        if let Some(c) = self.file.document.compositions.get(comp) {
729            let mut stack: Vec<NodeId> = c.children.clone();
730            let mut seen_nodes = HashSet::new();
731            while let Some(nid) = stack.pop() {
732                if !seen_nodes.insert(nid) {
733                    continue;
734                }
735                let Some(node) = self.file.document.nodes.get(nid) else {
736                    continue;
737                };
738                if let NodeKind::Precomp { comp: target, .. } = &node.kind {
739                    self.walk_precomp(*target, on_stack, visited);
740                }
741                if matches!(node.kind, NodeKind::Group | NodeKind::Layer(_)) {
742                    stack.extend(node.children.iter().copied());
743                }
744            }
745        }
746        on_stack.remove(&comp);
747    }
748
749    fn validate_clips(&mut self) {
750        let doc = &self.file.document;
751
752        let mut seen = HashSet::new();
753        for (i, &id) in self.file.clip_order.iter().enumerate() {
754            if !self.file.clips.contains_key(id) {
755                self.err(format!("clips/order/{i}"), "clip id does not exist");
756            }
757            if !seen.insert(id) {
758                self.err(
759                    format!("clips/order/{i}"),
760                    "duplicate clip id in clip_order",
761                );
762            }
763        }
764
765        for (clip_id, clip) in &self.file.clips {
766            if clip.range.1 <= clip.range.0 {
767                self.err(format!("clip/{clip_id:?}/range"), "invalid clip range");
768            }
769
770            for (track_index, track) in clip.tracks.iter().enumerate() {
771                let track_path = format!("clip/{clip_id:?}/track/{track_index}");
772                let prop = match doc.nodes.get(track.node) {
773                    Some(node) => match node.prop_ref(&track.prop) {
774                        Some(prop) => prop,
775                        None => {
776                            self.err(
777                                format!("{track_path}/prop"),
778                                "track references missing or incompatible property",
779                            );
780                            continue;
781                        }
782                    },
783                    None => {
784                        self.err(
785                            format!("{track_path}/node"),
786                            "track references missing node",
787                        );
788                        continue;
789                    }
790                };
791
792                let mut prev: Option<Frame> = None;
793                for (key_index, key) in track.keys.iter().enumerate() {
794                    if let Some(p) = prev
795                        && key.frame <= p
796                    {
797                        self.err(
798                            format!("{track_path}/key/{key_index}"),
799                            "clip keyframes not strictly increasing (duplicate or out of order)",
800                        );
801                    }
802                    if !key_value_matches_prop(&key.value, &prop) {
803                        self.err(
804                            format!("{track_path}/key/{key_index}/value"),
805                            "keyframe value type does not match property",
806                        );
807                    }
808                    prev = Some(key.frame);
809                }
810            }
811        }
812    }
813
814    fn validate_machines(&mut self) {
815        let doc = &self.file.document;
816
817        if let Some(start) = self.file.start_machine {
818            if !self.file.machines.contains_key(start) {
819                self.err("start_machine", "start machine does not exist");
820            }
821            if !self.file.machine_order.contains(&start) {
822                self.warn(
823                    "start_machine",
824                    "start machine exists but is detached from machine_order",
825                );
826            }
827        }
828
829        let mut seen = HashSet::new();
830        for (i, &id) in self.file.machine_order.iter().enumerate() {
831            if !self.file.machines.contains_key(id) {
832                self.err(format!("machines/order/{i}"), "machine id does not exist");
833            }
834            if !seen.insert(id) {
835                self.err(
836                    format!("machines/order/{i}"),
837                    "duplicate machine id in machine_order",
838                );
839            }
840        }
841
842        for (machine_id, machine) in &self.file.machines {
843            for (layer_index, layer) in machine.layers.iter().enumerate() {
844                if layer.states.is_empty() {
845                    self.err(
846                        format!("machine/{machine_id:?}/layer/{layer_index}"),
847                        "layer has no states",
848                    );
849                    continue;
850                }
851
852                if layer.entry >= layer.states.len() {
853                    self.err(
854                        format!("machine/{machine_id:?}/layer/{layer_index}/entry"),
855                        "entry state index is out of range",
856                    );
857                }
858
859                for (state_index, state) in layer.states.iter().enumerate() {
860                    match &state.kind {
861                        StateKind::Clip { clip, speed, .. } => {
862                            if !self.file.clips.contains_key(*clip) {
863                                self.err(
864                                    format!("machine/{machine_id:?}/layer/{layer_index}/state/{state_index}/clip"),
865                                    "state references missing clip",
866                                );
867                            }
868                            if !speed.is_finite() || *speed < 0.0 {
869                                self.err(
870                                    format!("machine/{machine_id:?}/layer/{layer_index}/state/{state_index}/speed"),
871                                    "clip state speed must be non-negative and finite",
872                                );
873                            }
874                        }
875                        StateKind::Blend1D { input, children } => {
876                            let base = format!(
877                                "machine/{machine_id:?}/layer/{layer_index}/state/{state_index}/blend"
878                            );
879                            match machine.inputs.get(*input) {
880                                Some(input_def) => {
881                                    if !matches!(input_def.kind, InputKind::Number { .. }) {
882                                        self.err(
883                                            format!("{base}/input"),
884                                            "Blend1D input must be a number input",
885                                        );
886                                    }
887                                }
888                                None => self.err(
889                                    format!("{base}/input"),
890                                    "Blend1D input index is out of range",
891                                ),
892                            }
893                            if children.is_empty() {
894                                self.err(format!("{base}/children"), "Blend1D has no children");
895                            }
896                            let mut prev: Option<f64> = None;
897                            for (child_index, child) in children.iter().enumerate() {
898                                if !self.file.clips.contains_key(child.clip) {
899                                    self.err(
900                                        format!("{base}/child/{child_index}"),
901                                        "blend child references missing clip",
902                                    );
903                                }
904                                if !child.threshold.is_finite() {
905                                    self.err(
906                                        format!("{base}/child/{child_index}/threshold"),
907                                        "blend threshold must be finite",
908                                    );
909                                }
910                                if let Some(p) = prev
911                                    && child.threshold <= p
912                                {
913                                    self.warn(
914                                        format!("{base}/child/{child_index}/threshold"),
915                                        "blend thresholds are not strictly increasing",
916                                    );
917                                }
918                                prev = Some(child.threshold);
919                            }
920                        }
921                        StateKind::Empty => {}
922                    }
923
924                    self.validate_transitions(
925                        machine_id,
926                        machine,
927                        layer_index,
928                        Some(state_index),
929                        &state.transitions,
930                    );
931                }
932
933                self.validate_transitions(
934                    machine_id,
935                    machine,
936                    layer_index,
937                    None,
938                    &layer.any_transitions,
939                );
940            }
941
942            for (listener_index, listener) in machine.listeners.iter().enumerate() {
943                if !doc.nodes.contains_key(listener.node) {
944                    self.err(
945                        format!("machine/{machine_id:?}/listener/{listener_index}/node"),
946                        "listener references missing node",
947                    );
948                }
949
950                let input = listener_action_input(&listener.action);
951                let base = format!("machine/{machine_id:?}/listener/{listener_index}");
952                match machine.inputs.get(input) {
953                    Some(input_def) => {
954                        if !listener_matches_input(&listener.action, input_def.kind) {
955                            self.err(
956                                format!("{base}/input"),
957                                "listener action type does not match input type",
958                            );
959                        }
960                    }
961                    None => self.err(format!("{base}/input"), "listener references missing input"),
962                }
963            }
964        }
965    }
966
967    #[allow(clippy::too_many_arguments)]
968    fn validate_transitions(
969        &mut self,
970        machine_id: MachineId,
971        machine: &Machine,
972        layer_index: usize,
973        state_index: Option<usize>,
974        transitions: &[Transition],
975    ) {
976        let Some(layer) = machine.layers.get(layer_index) else {
977            return;
978        };
979
980        for (transition_index, transition) in transitions.iter().enumerate() {
981            let base = match state_index {
982                Some(s) => format!(
983                    "machine/{machine_id:?}/layer/{layer_index}/state/{s}/transition/{transition_index}"
984                ),
985                None => format!(
986                    "machine/{machine_id:?}/layer/{layer_index}/any_transition/{transition_index}"
987                ),
988            };
989
990            if transition.to >= layer.states.len() {
991                self.err(&base, "transition target state is out of range");
992            }
993            if !transition.duration.is_finite() || transition.duration < 0.0 {
994                self.err(&base, "transition duration must be non-negative and finite");
995            }
996            if let Some(exit_time) = transition.exit_time
997                && (!exit_time.is_finite() || !(0.0..=1.0).contains(&exit_time))
998            {
999                self.err(&base, "transition exit_time must be in [0, 1]");
1000            }
1001
1002            for (condition_index, condition) in transition.conditions.iter().enumerate() {
1003                let input = condition_input(condition);
1004                let condition_path = format!("{base}/condition/{condition_index}");
1005                match machine.inputs.get(input) {
1006                    Some(input_def) => {
1007                        if !condition_matches_input(condition, input_def.kind) {
1008                            self.err(&condition_path, "condition type does not match input type");
1009                        }
1010                    }
1011                    None => self.err(&condition_path, "condition references missing input"),
1012                }
1013            }
1014        }
1015    }
1016
1017    fn validate_export_readiness(&mut self) {
1018        let doc = &self.file.document;
1019
1020        let direct: HashSet<NodeId> = doc
1021            .compositions
1022            .values()
1023            .flat_map(|c| c.children.iter().copied())
1024            .collect();
1025        let mut image_exportable = direct.clone();
1026        for id in &direct {
1027            if let Some(node) = doc.nodes.get(*id)
1028                && matches!(node.kind, NodeKind::Group | NodeKind::Layer(_))
1029            {
1030                image_exportable.extend(node.children.iter().copied());
1031            }
1032        }
1033
1034        for (id, node) in &doc.nodes {
1035            match &node.kind {
1036                NodeKind::Text(text) => {
1037                    self.warn(
1038                        format!("node/{id:?}/text"),
1039                        "Lottie export bakes text to vector outlines",
1040                    );
1041                    if !text.size.keyframes.is_empty() {
1042                        self.warn(
1043                            format!("node/{id:?}/text"),
1044                            "animated `text.size` bakes to its base value on Lottie export",
1045                        );
1046                    }
1047                    if !text.tracking.keyframes.is_empty() || !text.leading.keyframes.is_empty() {
1048                        self.warn(
1049                            format!("node/{id:?}/text"),
1050                            "animated `text.tracking`/`text.leading` bake to base on Lottie export",
1051                        );
1052                    }
1053                }
1054                NodeKind::Mask(_) => {
1055                    self.warn(
1056                        format!("node/{id:?}/mask"),
1057                        "Lottie mask export is best-effort and may differ from Renamite clip-stack semantics",
1058                    );
1059                }
1060                NodeKind::Image(img) => {
1061                    if doc.image_asset(img.asset()).is_none() {
1062                        self.err(
1063                            format!("node/{id:?}/image"),
1064                            "image layer references missing image asset",
1065                        );
1066                    }
1067                    if !image_exportable.contains(&id) {
1068                        self.warn(
1069                            format!("node/{id:?}/image"),
1070                            "deeply nested image layer is skipped by Lottie export (hoist to a top-level Layer/Group child)",
1071                        );
1072                    }
1073                    if img.tint().base != Color::WHITE || !img.tint().keyframes.is_empty() {
1074                        self.warn(
1075                            format!("node/{id:?}/image"),
1076                            "image tint is dropped by Lottie/SVG export",
1077                        );
1078                    }
1079                    let crop = img.crop();
1080                    if (crop.x.abs() > 1e-9
1081                        || crop.y.abs() > 1e-9
1082                        || (crop.z - 1.0).abs() > 1e-6
1083                        || (crop.w - 1.0).abs() > 1e-6)
1084                    {
1085                        self.warn(
1086                            format!("node/{id:?}/image"),
1087                            "image crop is approximated by GPU/SVG/Lottie sinks (full texture fitted into cropped rect)",
1088                        );
1089                    }
1090                }
1091                NodeKind::Precomp { .. } if !direct.contains(&id) => {
1092                    self.warn(
1093                        format!("node/{id:?}/precomp"),
1094                        "nested precomp is skipped by Lottie export (hoist to a top-level child)",
1095                    );
1096                }
1097                _ => {}
1098            }
1099        }
1100    }
1101}
1102
1103fn condition_input(condition: &Condition) -> usize {
1104    match condition {
1105        Condition::BoolIs { input, .. }
1106        | Condition::NumberCmp { input, .. }
1107        | Condition::Triggered { input } => *input,
1108    }
1109}
1110
1111fn condition_matches_input(condition: &Condition, input: InputKind) -> bool {
1112    matches!(
1113        (condition, input),
1114        (Condition::BoolIs { .. }, InputKind::Bool { .. })
1115            | (Condition::NumberCmp { .. }, InputKind::Number { .. })
1116            | (Condition::Triggered { .. }, InputKind::Trigger)
1117    )
1118}
1119
1120fn listener_action_input(action: &ListenerAction) -> usize {
1121    match action {
1122        ListenerAction::SetBool { input, .. }
1123        | ListenerAction::ToggleBool { input }
1124        | ListenerAction::SetNumber { input, .. }
1125        | ListenerAction::FireTrigger { input } => *input,
1126    }
1127}
1128
1129fn listener_matches_input(action: &ListenerAction, input: InputKind) -> bool {
1130    matches!(
1131        (action, input),
1132        (ListenerAction::SetBool { .. }, InputKind::Bool { .. })
1133            | (ListenerAction::ToggleBool { .. }, InputKind::Bool { .. })
1134            | (ListenerAction::SetNumber { .. }, InputKind::Number { .. })
1135            | (ListenerAction::FireTrigger { .. }, InputKind::Trigger)
1136    )
1137}
1138
1139fn key_value_matches_prop(value: &Value, prop: &PropRef) -> bool {
1140    matches!(
1141        (value, prop),
1142        (Value::F64(_), PropRef::F64(_))
1143            | (Value::DVec2(_), PropRef::Vec2(_))
1144            | (Value::Angle(_), PropRef::Angle(_))
1145            | (Value::Color(_), PropRef::Color(_))
1146            | (Value::Path(_), PropRef::Path(_))
1147            | (Value::Stops(_), PropRef::Stops(_))
1148    )
1149}
1150
1151fn finite_f64(value: &f64) -> bool {
1152    value.is_finite()
1153}
1154
1155fn finite_vec2(value: &DVec2) -> bool {
1156    value.is_finite()
1157}
1158
1159fn finite_angle(value: &Angle) -> bool {
1160    value.0.is_finite()
1161}
1162
1163fn finite_color(color: &Color) -> bool {
1164    color.r.is_finite() && color.g.is_finite() && color.b.is_finite() && color.a.is_finite()
1165}
1166
1167fn finite_stops(stops: &GradientStops) -> bool {
1168    stops
1169        .0
1170        .iter()
1171        .all(|s| s.offset.is_finite() && finite_color(&s.color))
1172}
1173
1174fn finite_path(path: &VectorPath) -> bool {
1175    path.anchors
1176        .iter()
1177        .all(|a| a.pos.is_finite() && a.tan_in.is_finite() && a.tan_out.is_finite())
1178}
1179
1180fn transform_is_default(t: &AnimatedTransform) -> bool {
1181    t.anchor.base == DVec2::ZERO
1182        && t.position.base == DVec2::ZERO
1183        && t.anchor.keyframes.is_empty()
1184        && t.position.keyframes.is_empty()
1185        && t.rotation.base.0 == 0.0
1186        && t.rotation.keyframes.is_empty()
1187        && t.skew.base == 0.0
1188        && t.skew.keyframes.is_empty()
1189        && t.skew_axis.base == 0.0
1190        && t.skew_axis.keyframes.is_empty()
1191        && scale_is_default(&t.scale)
1192}
1193
1194fn scale_is_default(scale: &Animated<DVec2>) -> bool {
1195    scale.base == DVec2::splat(100.0) && scale.keyframes.is_empty()
1196}
1197
1198fn opacity_is_default(opacity: &Animated<f64>) -> bool {
1199    opacity.base == 1.0 && opacity.keyframes.is_empty()
1200}
1201
1202/// Base-value heuristic for "this shape has no geometry": empty path, zero-size
1203/// rect/ellipse, or non-positive star/polygon radius.
1204fn shape_kind_is_empty(shape: &ShapeKind) -> bool {
1205    match shape {
1206        ShapeKind::Path(path) => path.base.anchors.is_empty(),
1207        ShapeKind::CompoundPath(compound) => compound.contours.is_empty(),
1208        ShapeKind::Rect { size, .. } | ShapeKind::Ellipse { size, .. } => {
1209            size.base.x == 0.0 || size.base.y == 0.0
1210        }
1211        ShapeKind::Star { outer_r, .. } | ShapeKind::Polygon { outer_r, .. } => outer_r.base <= 0.0,
1212    }
1213}