Achieving Lottie Parity
SkillFiles & storageUse when a Lottie file renders incorrectly after conversion to Popkorn, when debugging or fixing the converter (lottie2popkorn) or player rendering/animation, when implementing a missing Lottie capability, or when adding/fixing a DSL property in this repo. Covers triage of conversion bugs vs player bugs vs deliberate skips, the measurement tools, and the full add-a-property checklist.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Achieving Lottie Parity skill
What this skill tells your AI
The instructions your AI receives, as published by ayarse/popkorn in .claude/skills/achieving-lottie-parity/SKILL.md and read by ahel’s review.
Overview
Popkorn targets parity with Lottie players in rendering and animation capability. Every "this file looks wrong" task is a triage problem first, fix second: the same symptom can be a converter bug, a player bug, a deliberate skip, or a documented limitation — and each has a different correct response. Never fix before classifying.
Core principles (learned the hard way):
- Two reference players, two roles. thorvg is the parity TARGET — the quality bar Popkorn aims to match. lottie-web canvas is the FLOOR — never render worse than it — and the sanity cross-check: thorvg has its own failures, so before chasing a thorvg-only behavior, confirm it against the source JSON's intent (if lottie-web and the JSON agree and thorvg disagrees, thorvg is the one that's wrong). Matching lottie-web but falling short of thorvg is not "done" — it's a target gap: implement if the nearly-free rule allows, otherwise record it as a warned, documented gap rather than silently calling it correct.
- Warnings are the scoreboard. A silent drop is always a bug, even when
the drop itself is a deliberate skip.
warnOnce(...)every discard. - CSS idiom first when adding anything to the DSL: use the real CSS
property if one exists, then SVG/SMIL lineage, and only then coin syntax.
(Precedents used:
:state(),@container style()guards, SMILtarget.eventtriggers,animation-timeline. Rejected: adopting CSSscroll()because its browser meaning didn't match ours — a borrowed spelling that lies is worse than a new name.) - Measure, don't eyeball-guess. The tools below give frame-accurate or sampled-numeric truth cheaply. "Screenshots lie less than tests" for visuals; sampled curves lie less than screenshots for motion.
Triage: classify before touching anything
digraph triage {
"File renders wrong vs thorvg" [shape=box];
"Feature in deliberate-skip list?" [shape=diamond];
"thorvg uniquely wrong (JSON + lottie-web agree)?" [shape=diamond];
"lottie-web CANVAS also wrong?" [shape=diamond];
"Converted CSS faithful to JSON?" [shape=diamond];
"Skip is correct; ensure a warning exists" [shape=box];
"Don't chase thorvg; no change" [shape=box];
"TARGET GAP: implement if nearly free, else warn + document" [shape=box];
"PLAYER bug (builder/runtime/renderer)" [shape=box];
"CONVERTER bug" [shape=box];
"File renders wrong vs thorvg" -> "Feature in deliberate-skip list?";
"Feature in deliberate-skip list?" -> "Skip is correct; ensure a warning exists" [label="yes"];
"Feature in deliberate-skip list?" -> "thorvg uniquely wrong (JSON + lottie-web agree)?" [label="no"];
"thorvg uniquely wrong (JSON + lottie-web agree)?" -> "Don't chase thorvg; no change" [label="yes"];
"thorvg uniquely wrong (JSON + lottie-web agree)?" -> "lottie-web CANVAS also wrong?" [label="no"];
"lottie-web CANVAS also wrong?" -> "TARGET GAP: implement if nearly free, else warn + document" [label="yes"];
"lottie-web CANVAS also wrong?" -> "Converted CSS faithful to JSON?" [label="no"];
"Converted CSS faithful to JSON?" -> "PLAYER bug (builder/runtime/renderer)" [label="yes"];
"Converted CSS faithful to JSON?" -> "CONVERTER bug" [label="no"];
}
Deliberate skips (CLAUDE.md is authoritative): JS expressions, text
animators, merge-path subtract/intersect (union IS supported),
offset/zig-zag/pucker/round-corner modifiers, 3D/camera, most layer effects.
Not a skip: precomp time remap (layer tm) converts to time-remap. Overturning a skip requires
showing it's nearly free: implementable by reusing existing machinery (the
offscreen compositor, the channel plumbing, a native ctx capability) with
no new subsystem and no new dependency (blur qualified: ctx.filter + the
existing compositor). "The file needs it" alone does not.
Quick probe before firing up the harness: lottie-web canvas is known to
drop all layer effects (blur/glow/shadow) — an effects-shaped symptom where
thorvg renders the effect is a classic TARGET GAP (that's how
filter: blur() got in). Anything geometry/paint/timing-shaped, assume
both references render it and keep triaging toward a real bug.
Marker-driven interactivity (named segments a host app plays) is not a
conversion target at all — it maps to a hand-authored @machine scene
(see docs/state-machines.md). Markers are playback metadata, not rendering;
dropping them needs no warning.
Investigation playbook
Always in this order; each step is cheap and eliminates guesswork.
- Convert with validation (quote filenames — many have spaces/parens):
bun packages/popkorn-converters/src/cli.ts "examples/lottie/<file>.json" --validateCapture warnings and blocked features.validate: okdoes NOT mean correct — the worst bugs are silent visual wrongness. - Feature inventory via jq. Grep the JSON for markers:
ty:0precomp ·ty:3null ·ty:4shape ·ty:5text · shape itemssh/gr/fl/st/gf/gs/tr/rc/el/sr·tmtrim (shape) or time-remap (layer) ·tt/td/tptrack mattes ·rprepeater ·mmmerge ·efeffects ·masksProperties·markers·.xstring fields = expressions. Count each; this tells you which converter paths execute before you read any code. - Pin the symptom concretely. Diff converted CSS against the source JSON at specific values (a keyframe, a gradient stop, a matte target) — most converter bugs are visible as a wrong/missing declaration.
- Frame-accurate visual truth: the comparison harness at
tools/harness/(read its README) renders popkorn vs lottie-web side-by-side; sample pixels at specific (x, y, t) to compare numerically. If another agent is editing shared files, snapshot the converter into the scratchpad and work from the snapshot. - Motion issues (jerky/unsmooth/wrong timing): don't stare at pixels —
write a throwaway bun script:
parse → buildSceneGraph → scheduler sampleNodeat 1 ms steps, print the animated channel, numerically differentiate. Velocity discontinuities and dead-stops localize the problem exactly (this separated "authored badly" from "player bug" for the bounce scene: the player was faithful; the keyframes were wrong). - Isolate by removal. In the harness page or a scene copy, delete the suspect declaration/matte/effect and re-render. One removal flipping the symptom identifies the trigger (this is how nested-matte re-entrancy was proven: outer matte kept + inner mattes removed → content reappeared).
- Attribute pre-existing failures before blaming your change:
git stash -u, re-run the failing test,git stash pop. Two agents independently mis-blaming a dirty-tree failure wastes everyone's time.
Known bug patterns — check these FIRST
Every one of these shipped at least once. Cheap to check, likely culprits:
| Pattern | Example that happened |
|---|---|
| Shared case bodies mapping to the wrong target | case 'gf': case 'gs': shared one body → gradient strokes emitted as gradient fills (blob instead of pen line) |
| Opacity not folded into color alpha | fl.o animated → hard-set to 1 (channel only built when color animated); st.o never read at all, silently |
xs[0] truncation | block.selectors[0] → 0%, 100% { } lost the 100% frame; five gallery scenes never returned to start |
| Non-re-entrant shared buffers | compositeMask used fixed offscreen pair 0/1 → nested mattes cleared the outer content → whole subtree invisible. Fix shape: depth-indexed resources + try/finally depth counter |
| Solid-only paint branches | :state() fill override only matched solid colors → gradients silently dropped; ALSO the override wrote node.fill while the renderer preferred node.fillGradient — overriding one paint channel of two |
| Baking animation to first value without warning | Fine as a fallback, but must warnOnce; and check whether the existing channel machinery makes the real fix cheap (grid-union usually does) |
| Clip semantics vs interactive scenes | The loop wrapped/clamped at sceneDuration; machine entry anchors folded negative on wrap → state animations replayed. Interactive scenes are unbounded — no wrap, no end |
| Wrong default fill-mode for stateful animation | CSS default fill: none snaps a finished one-shot back to base; stateful runtimes hold the last frame → state animations default both |
| Gradient endpoint incompatibility | linear↔radial (or stop-count mismatch) can't interpolate — it steps. For a cross-fade, overlay a second node and animate opacity instead of swapping the fill |
Fix protocols
Converter fix (packages/popkorn-converters/src/lottie2popkorn.ts + packages/popkorn-converters/src/lottie2popkorn.test.ts):
- Reuse existing patterns, don't invent:
lottieColor(rgb, a)folds alpha (emitsrgba()when a<1);warnOnce(msg)deduplicates warnings into the result's warning list (tests assert on that list). The grid-union channel pattern (seecolorOpacityChannel/ thegfcase) is the template whenever two animated Lottie properties collapse into one CSS value: collect the union of both properties' keyframe times, sorted; for each merged time emit one keyframe carrying the source keyframe'si/o/heasing; the channel's sample function evaluates BOTH properties at t and combines them (e.g.lottieColor(color.at(t), opacity.at(t))). - Mapping facts that are easy to get wrong: both easing tangents live on the
departing keyframe; anchor bakes into position (
translate = p − a, origin =a); parenting is transform-only; sibling contours sharing a group fill are ONE nonzero compound path; layerip/op→visible-from/until; AE Gaussian blurriness ≈ 4 × CSS blur radius (lottie-web SVG convention); verify AE polar conventions (e.g. drop-shadow direction) against lottie-web source, not intuition. - Regression test in the converter suite; end-to-end grep of the real file's
output (e.g.
rgba(count 0 → 222 is better evidence than a unit test).
Player fix (packages/popkorn-player/):
- Respect the six CLAUDE.md invariants; the ones that bite in practice:
transform math only in
scene/transform.ts; the per-frame resolution order inRenderLoop.resolveNodeis fixed (reset→bindings→state→ animation→hover);animation/registry.tsis the ONLY path to animatability; timeline is a pure function of time — anything stateful (machine state, hover tweens) lives OFF the timeline soseek(t)twice is identical. - Renderer work: reuse the offscreen compositor (depth-indexed since the
matte fix) for anything needing subtree composition (mattes, filters).
Feature-detect (
ctx.filter— Safari lags) and degrade to today's behavior, never crash.
Adding/fixing a DSL property — the full checklist (missing any item is a shipped bug; the serializer was forgotten once and broke round-trip):
- Syntax: real CSS property if one exists → SVG/SMIL precedent → coin last.
- Parser: declarations parse generically (no parser change for plain
properties); new at-rules need a branch in the top-level loop (
@defineis the template); new pseudo-classes extend theStateRulepath. - Builder: map declaration → scene-node field.
- Animatable? Registry entry (number/color/gradient/path kind) + dirty flags for geometry-affecting properties.
- Runtime: does it need a slot in
resolveNodeorder orsceneHasDynamicContent(anything that can change without the clock)? - Serializer: emit it, both pretty and minify — the round-trip test
globs every
examples/popkorn/*.css, so a missing emitter fails CI only after someone authors a scene using the feature. - Docs:
docs/reference.mdsection, terse and code-first. - Example:
examples/popkorn/NN-name.css— the demo gallery auto-globs this directory (any "sync to examples.ts" note is stale). Author the example in the SAME change — it's what makes the round-trip glob exercise your serializer step proactively instead of failing later.
Verification bar (all of it, every time)
bun run testfully green;bun run buildgreen.- Bun is DOM-free: no
document/Path2D/OffscreenCanvas. Headless fallbacks are markedponytail:; renderer tests mock resources by index (seecanvas2d-*.test.ts); a few Path2D tests skip under bun — expected. - Corpus gate:
--batch <corpus>/dataagainst a clone ofLottieFiles/test-files(160 .json upstream as of 2026-07-17), baseline 142/11/7/0 clean/warn/blocked/failed as of 2026-07-17. Assume it is absent until found (find ~ -maxdepth 4 -name test-files -type d); when absent, skip it but SAY SO in your report; never imply it ran. - Browser eyeball of an affected scene (several real bugs were only visible on canvas). If you can't, say the eyeball is pending — don't claim done.
- Report classification honestly: fixed / parity-floor match / deliberate skip / documented limitation, each with its evidence.
Multi-agent etiquette (if orchestrating)
- Fence agents to disjoint files.
packages/popkorn-converters/src/lottie2popkorn.tsis a serialization point — most converter fixes touch the same case blocks, so concurrent edits clobber each other: diagnose in parallel (read-only, snapshot the converter into the scratchpad), fix serially. - Diagnosis reports must be blind-implementable: root cause with file:line, the fix sketch, and verification criteria — a later agent should need nothing else.
- Don't commit with unrelated dirty files in the tree; report and let the coordinator stage explicitly.
Common mistakes
| Mistake | Correction |
|---|---|
| Fixing before classifying | Run the triage flowchart; the same symptom has five different correct responses |
| Treating "matches lottie-web" as done | lottie-web is the floor; thorvg is the target — a gap vs thorvg is a target gap, not correctness |
| Chasing a thorvg-only behavior | thorvg fails things too; when JSON intent + lottie-web agree against it, thorvg is wrong |
Trusting validate: ok | It catches structure, not silent visual wrongness |
| Eyeballing motion quality | Sample the curves at 1 ms and differentiate |
| Implementing a skipped feature because a file uses it | Skips are overturned by "nearly free", not by demand |
| Silent drops | Every discard gets a warnOnce |
| Forgetting the serializer for new syntax | Round-trip glob will fail the moment an example uses it |
| Claiming the corpus gate ran when the corpus is absent | State it was skipped |
Signals
- GitHub stars
- 23
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
achieving-lottie-parity- Source
- github.com/ayarse/popkorn