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 { 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 { 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 { 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| -> 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); +}