1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
|
diff --git a/src/composition/limits.rs b/src/composition/limits.rs
index f8de04c..d4715ab 100644
--- a/src/composition/limits.rs
+++ b/src/composition/limits.rs
@@ -87,7 +87,9 @@ pub struct Limits {
pub max_render_points: usize,
/// Maximum cumulative dash output allocation estimate per frame, in bytes.
pub max_render_dash_bytes: usize,
- /// Maximum geometry/raster work units charged per frame.
+ /// Maximum geometry/raster work units charged per frame. Telegram's gift
+ /// models peak near 5M (~1.25ms/frame, half of what rlottie spends on
+ /// them uncapped), so the cap keeps headroom above real content.
pub max_render_work: usize,
/// Maximum weighted pixel visits per frame at 64x64 or smaller.
/// Larger outputs scale this allowance proportionally to canvas area.
@@ -140,7 +142,7 @@ impl Default for Limits {
max_dimension: 8192,
max_render_points: 1_048_576,
max_render_dash_bytes: 6_330_240,
- max_render_work: 4_194_304,
+ max_render_work: 8_388_608,
max_render_pixels: 64 * 1024 * 1024,
max_render_bytes: 36 * 1024 * 1024,
}
diff --git a/src/composition/parse.rs b/src/composition/parse.rs
index 47fdaa6..4a0aac8 100644
--- a/src/composition/parse.rs
+++ b/src/composition/parse.rs
@@ -222,7 +222,9 @@ fn parse_vec2(c: &mut Cursor<'_>) -> Result<Vec2> {
match got {
[Some(x), Some(y)] => Ok(Vec2::new(x, y)),
[Some(x), None] => Ok(Vec2::new(x, x)),
- _ => Err(invalid(c, "expected [x, y]")),
+ // rlottie zero-fills a missing component, and optimized .tgs files write
+ // `[]` for every straight-line tangent; rejecting those loses the file.
+ _ => Ok(Vec2::ZERO),
}
}
@@ -1060,6 +1062,14 @@ fn parse_shape_item(c: &mut Cursor<'_>, limits: &Limits) -> Result<ParsedItem> {
let mut seen = SeenFields::default();
let inline_group = for_each_field_until(c, |c, key| {
+ // rlottie dispatches a shape item on `ty` and Skip()s every key ahead of
+ // it, so fields authored before `ty` never reach the reference parser.
+ // Telegram's gift patterns write {"d":3,"ty":"el"} and rely on that: the
+ // ellipses keep the exporter's winding and punch holes in the body path.
+ if ty.is_none() && !matches!(key, b"ty") {
+ c.skip_value()?;
+ return Ok(false);
+ }
match_once!(seen, key, {
b"t" => {
grad_type = parse_f32(c)?;
@@ -1208,9 +1218,8 @@ fn parse_shape_item(c: &mut Cursor<'_>, limits: &Limits) -> Result<ParsedItem> {
None => Ok(Property::Static(default)),
}
};
- // Path direction on primitives: `d` == 3 reverses the winding. rlottie
- // supports this too, but accidentally ignores `d` if it appears before
- // `ty`; honor the authored field order-independently.
+ // Path direction on primitives: `d` == 3 reverses the winding, for a `d`
+ // that the field loop above accepted (it is dropped before `ty`).
let direction_reversed = |c: &Cursor<'_>, pos: Option<usize>| -> bool { pos.is_some_and(|p| parse_f32(&mut c.fork_at(p)).unwrap_or(1.0) == 3.0) };
match &ty {
diff --git a/src/composition/tests/parse.rs b/src/composition/tests/parse.rs
index abbdbba..00aceab 100644
--- a/src/composition/tests/parse.rs
+++ b/src/composition/tests/parse.rs
@@ -113,21 +113,29 @@ fn parses_shape_layer() {
}
#[test]
-fn primitive_direction_is_order_independent() {
- let comp = parse(
- r#"{"fr":30,"ip":0,"op":30,"w":100,"h":100,"layers":[
- {"ty":4,"ind":1,"ip":0,"op":30,"st":0,"ks":{},
+fn primitive_direction_before_ty_is_dropped() {
+ // rlottie dispatches on `ty` and Skip()s every key ahead of it, so a `d`
+ // written first never reaches the reference parser. Telegram's gift
+ // patterns are authored against that: {"d":3,"ty":"el"} keeps the
+ // exporter's winding so the ellipses punch holes in the body path.
+ let ellipse = |shape: &str| {
+ let json = alloc::format!(
+ r#"{{"fr":30,"ip":0,"op":30,"w":100,"h":100,"layers":[
+ {{"ty":4,"ind":1,"ip":0,"op":30,"st":0,"ks":{{}},
"shapes":[
- {"d":3,"ty":"el","p":{"a":0,"k":[50,50]},"s":{"a":0,"k":[10,10]}},
- {"ty":"fl","c":{"a":0,"k":[1,0,0,1]},"o":{"a":0,"k":100},"r":1}
- ]}
- ]}"#,
- )
- .unwrap();
- let Some(Shape::Ellipse(ellipse)) = comp.layers[0].shapes.first() else {
- panic!("expected ellipse");
+ {shape},
+ {{"ty":"fl","c":{{"a":0,"k":[1,0,0,1]}},"o":{{"a":0,"k":100}},"r":1}}
+ ]}}
+ ]}}"#
+ );
+ let comp = parse(&json).unwrap();
+ let Some(Shape::Ellipse(ellipse)) = comp.layers[0].shapes.first() else {
+ panic!("expected ellipse");
+ };
+ ellipse.reversed
};
- assert!(ellipse.reversed);
+ assert!(!ellipse(r#"{"d":3,"ty":"el","p":{"a":0,"k":[50,50]},"s":{"a":0,"k":[10,10]}}"#));
+ assert!(ellipse(r#"{"ty":"el","p":{"a":0,"k":[50,50]},"s":{"a":0,"k":[10,10]},"d":3}"#));
}
#[test]
@@ -394,6 +402,19 @@ fn inherited_keyframe_heap_budget_is_cumulative_and_preserves_end_values() {
}
}
+#[test]
+fn empty_tangent_points_parse_as_zero_corners() {
+ // Optimized .tgs files write `[]` for every straight-line tangent, and
+ // rlottie zero-fills a point it cannot read; rejecting them loses the
+ // whole animation (Telegram's "21_heart_white" typing sticker).
+ let json = br#"{"i":[[],[]],"o":[[],[]],"v":[[2,0],[4,0]],"c":true}"#;
+ let limits = Limits::default();
+ let path = parse_path_value(&mut Cursor::new(json.as_slice(), 128), &limits).unwrap();
+ assert_eq!(path.vertices, alloc::vec![Vec2::new(2.0, 0.0), Vec2::new(4.0, 0.0)]);
+ assert!(path.in_tangents.iter().all(|t| *t == Vec2::ZERO));
+ assert!(path.out_tangents.iter().all(|t| *t == Vec2::ZERO));
+}
+
#[test]
fn inherited_path_budget_charges_padded_tangents_before_cloning() {
let json = br#"[{"t":0,"s":[{"v":[[1,2],[3,4]],"i":[],"o":[]}]},{"t":1}]"#;
@@ -412,10 +433,9 @@ fn inherited_path_budget_charges_padded_tangents_before_cloning() {
#[test]
fn group_walk_preserves_siblings_transforms_and_shared_limits() {
- // Exercise both field orders and resume the parent after the child list.
+ // Resume the parent after the child list.
for group in [
r#"{"ty":"gr","it":[{"ty":"tr","p":{"k":[3,4]}},{"ty":"gr","it":[{"ty":"rc","p":{"k":[5,6]},"s":{"k":[7,8]}}]},{"ty":"el","p":{"k":[9,10]},"s":{"k":[11,12]}}]}"#,
- r#"{"it":[{"ty":"tr","p":{"k":[3,4]}},{"it":[{"ty":"rc","p":{"k":[5,6]},"s":{"k":[7,8]}}],"ty":"gr"},{"ty":"el","p":{"k":[9,10]},"s":{"k":[11,12]}}],"ty":"gr"}"#,
] {
let json = alloc::format!(r#"[{group},{{"ty":"fl","c":{{"k":[1,0,0,1]}}}}]"#);
let mut counts = ShapeCounts::default();
@@ -440,3 +460,14 @@ fn group_walk_preserves_siblings_transforms_and_shared_limits() {
assert!(parse_shape_list(&mut Cursor::new(json, 128), &Limits::default(), 0, &mut ShapeCounts::default()).is_err());
}
}
+
+#[test]
+fn group_items_before_ty_are_dropped() {
+ // Same rlottie rule as primitive_direction_before_ty_is_dropped: an `it`
+ // written ahead of `ty` is skipped, so the reference walks an empty group.
+ let json = br#"[{"it":[{"ty":"el","p":{"k":[9,10]},"s":{"k":[11,12]}}],"ty":"gr"},{"ty":"fl","c":{"k":[1,0,0,1]}}]"#;
+ let mut counts = ShapeCounts::default();
+ let (shapes, _) = parse_shape_list(&mut Cursor::new(json.as_slice(), 128), &Limits::default(), 0, &mut counts).unwrap();
+ assert!(matches!(&shapes[..], [Shape::Fill(_)]));
+ assert_eq!(counts.paints, 1);
+}
|