1use 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 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
1202fn 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}