claude-exode-mcp 7.5.7 → 7.5.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/MCP_DOCUMENTATION.md +1 -0
  2. package/SKILL.md +10 -0
  3. package/examples/aurum-product-page.exode +3208 -0
  4. package/examples/bell-ring-icon.exode +1 -0
  5. package/examples/drift-kinetic-type.exode +770 -0
  6. package/examples/earbuds-product.exode +214 -0
  7. package/examples/energy-flow.exode +1736 -0
  8. package/examples/flux-hero.exode +223 -0
  9. package/examples/gooey-toggle.exode +214 -0
  10. package/examples/gravity-well.exode +3113 -0
  11. package/examples/heart-icon.exode +1 -0
  12. package/examples/hologram-temple.exode +8100 -0
  13. package/examples/jarful-app.exode +1 -0
  14. package/examples/kip-character-rig.exode +1 -0
  15. package/examples/low-tide-tilt.exode +2216 -0
  16. package/examples/manifest.json +352 -0
  17. package/examples/swipe-stack-carousel.exode +4048 -0
  18. package/examples/tokyo-neon-weather.exode +470 -0
  19. package/examples/typographic-collapse.exode +1578 -0
  20. package/examples/void-product-ad.exode +1269 -0
  21. package/examples/wire-globe-card.exode +580 -0
  22. package/index.d.ts +41 -1
  23. package/index.js +2553 -2407
  24. package/package.json +4 -2
  25. package/render-runtime/exode-runtime.js +20 -20
  26. package/skills/creative-motion/SKILL.md +10 -0
  27. package/skills/creative-motion/recipes/character-rig.js +104 -0
  28. package/skills/creative-motion/recipes/dashboard.js +120 -0
  29. package/skills/creative-motion/recipes/game-arcade.js +203 -0
  30. package/skills/creative-motion/recipes/hero-physics.js +65 -0
  31. package/skills/creative-motion/recipes/pricing.js +88 -0
  32. package/skills/creative-motion/recipes/title-sequence.js +62 -0
  33. package/skills/creative-motion/references/apps.md +37 -0
  34. package/skills/creative-motion/references/gotchas.md +80 -0
  35. package/skills/creative-motion/references/native-layers.md +60 -0
  36. package/skills/creative-motion/references/quality.md +54 -0
  37. package/src/__tests__/buildKit.test.ts +127 -0
  38. package/src/__tests__/effectKnobs.test.ts +162 -0
  39. package/src/__tests__/nativeLayers.test.ts +178 -0
  40. package/src/buildKit.d.ts +301 -0
  41. package/src/buildKit.d.ts.map +1 -0
  42. package/src/buildKit.js +559 -0
  43. package/src/buildKit.js.map +1 -0
  44. package/src/buildKit.ts +520 -0
  45. package/src/compiler.d.ts +4 -0
  46. package/src/compiler.d.ts.map +1 -1
  47. package/src/compiler.js +21 -45
  48. package/src/compiler.js.map +1 -1
  49. package/src/compiler.ts +22 -51
  50. package/src/critique.d.ts +8 -0
  51. package/src/critique.d.ts.map +1 -0
  52. package/src/critique.js +348 -0
  53. package/src/critique.js.map +1 -0
  54. package/src/critique.ts +239 -0
  55. package/src/effectKnobs.d.ts +72 -0
  56. package/src/effectKnobs.d.ts.map +1 -0
  57. package/src/effectKnobs.js +541 -0
  58. package/src/effectKnobs.js.map +1 -0
  59. package/src/effectKnobs.ts +445 -0
  60. package/src/effectOptimizer.d.ts +18 -0
  61. package/src/effectOptimizer.d.ts.map +1 -0
  62. package/src/effectOptimizer.js +125 -0
  63. package/src/effectOptimizer.js.map +1 -0
  64. package/src/effectOptimizer.ts +104 -0
  65. package/src/engine/layout.js +1 -1
  66. package/src/engine/layout.js.map +1 -1
  67. package/src/engine/layout.ts +1 -1
  68. package/src/examples.d.ts +30 -0
  69. package/src/examples.d.ts.map +1 -0
  70. package/src/examples.js +168 -0
  71. package/src/examples.js.map +1 -0
  72. package/src/examples.ts +151 -0
  73. package/src/handlers/artboardHandlers.d.ts.map +1 -1
  74. package/src/handlers/artboardHandlers.js +45 -10
  75. package/src/handlers/artboardHandlers.js.map +1 -1
  76. package/src/handlers/artboardHandlers.ts +43 -12
  77. package/src/handlers/buildHandlers.d.ts +2 -0
  78. package/src/handlers/buildHandlers.d.ts.map +1 -0
  79. package/src/handlers/buildHandlers.js +86 -0
  80. package/src/handlers/buildHandlers.js.map +1 -0
  81. package/src/handlers/buildHandlers.ts +76 -0
  82. package/src/handlers/compilerHandlers.d.ts.map +1 -1
  83. package/src/handlers/compilerHandlers.js +32 -2
  84. package/src/handlers/compilerHandlers.js.map +1 -1
  85. package/src/handlers/compilerHandlers.ts +31 -2
  86. package/src/handlers/exampleHandlers.d.ts +2 -0
  87. package/src/handlers/exampleHandlers.d.ts.map +1 -0
  88. package/src/handlers/exampleHandlers.js +81 -0
  89. package/src/handlers/exampleHandlers.js.map +1 -0
  90. package/src/handlers/exampleHandlers.ts +76 -0
  91. package/src/handlers/renderHandlers.d.ts.map +1 -1
  92. package/src/handlers/renderHandlers.js +100 -2
  93. package/src/handlers/renderHandlers.js.map +1 -1
  94. package/src/handlers/renderHandlers.ts +90 -2
  95. package/src/handlers/scaffoldHandlers.d.ts.map +1 -1
  96. package/src/handlers/scaffoldHandlers.js +3 -0
  97. package/src/handlers/scaffoldHandlers.js.map +1 -1
  98. package/src/handlers/scaffoldHandlers.ts +2 -0
  99. package/src/handlers/widgetHandlers.js +1 -1
  100. package/src/handlers/widgetHandlers.js.map +1 -1
  101. package/src/handlers/widgetHandlers.ts +1 -1
  102. package/src/lottie/convert.d.ts +16 -0
  103. package/src/lottie/convert.d.ts.map +1 -0
  104. package/src/lottie/convert.js +1225 -0
  105. package/src/lottie/convert.js.map +1 -0
  106. package/src/lottie/convert.ts +1134 -0
  107. package/src/lottie/evaluate.d.ts +48 -0
  108. package/src/lottie/evaluate.d.ts.map +1 -0
  109. package/src/lottie/evaluate.js +174 -0
  110. package/src/lottie/evaluate.js.map +1 -0
  111. package/src/lottie/evaluate.ts +184 -0
  112. package/src/nativeLayers.d.ts +76 -0
  113. package/src/nativeLayers.d.ts.map +1 -0
  114. package/src/nativeLayers.js +404 -0
  115. package/src/nativeLayers.js.map +1 -0
  116. package/src/nativeLayers.ts +339 -0
  117. package/src/render/motionBrief.d.ts.map +1 -1
  118. package/src/render/motionBrief.js +4 -0
  119. package/src/render/motionBrief.js.map +1 -1
  120. package/src/render/motionBrief.ts +4 -0
  121. package/src/render/videoRenderer.d.ts.map +1 -1
  122. package/src/render/videoRenderer.js +5 -2
  123. package/src/render/videoRenderer.js.map +1 -1
  124. package/src/render/videoRenderer.ts +5 -2
  125. package/src/rive/convert.d.ts +17 -0
  126. package/src/rive/convert.d.ts.map +1 -0
  127. package/src/rive/convert.js +1779 -0
  128. package/src/rive/convert.js.map +1 -0
  129. package/src/rive/convert.ts +1492 -0
  130. package/src/rive/font.d.ts +13 -0
  131. package/src/rive/font.d.ts.map +1 -0
  132. package/src/rive/font.js +154 -0
  133. package/src/rive/font.js.map +1 -0
  134. package/src/rive/font.ts +132 -0
  135. package/src/rive/layout.d.ts +20 -0
  136. package/src/rive/layout.d.ts.map +1 -0
  137. package/src/rive/layout.js +145 -0
  138. package/src/rive/layout.js.map +1 -0
  139. package/src/rive/layout.ts +140 -0
  140. package/src/rive/reader.d.ts +32 -0
  141. package/src/rive/reader.d.ts.map +1 -0
  142. package/src/rive/reader.js +172 -0
  143. package/src/rive/reader.js.map +1 -0
  144. package/src/rive/reader.ts +160 -0
  145. package/src/rive/schema.generated.d.ts +9 -0
  146. package/src/rive/schema.generated.d.ts.map +1 -0
  147. package/src/rive/schema.generated.js +13 -0
  148. package/src/rive/schema.generated.js.map +1 -0
  149. package/src/rive/schema.generated.ts +13 -0
  150. package/src/schema.d.ts +5 -0
  151. package/src/schema.d.ts.map +1 -1
  152. package/src/schema.js +5 -0
  153. package/src/schema.js.map +1 -1
  154. package/src/schema.ts +6 -0
  155. package/src/serverInstructions.d.ts +2 -0
  156. package/src/serverInstructions.d.ts.map +1 -0
  157. package/src/serverInstructions.js +22 -0
  158. package/src/serverInstructions.js.map +1 -0
  159. package/src/serverInstructions.ts +21 -0
  160. package/src/sseServer.d.ts +2 -0
  161. package/src/sseServer.d.ts.map +1 -0
  162. package/src/sseServer.js +165 -0
  163. package/src/sseServer.js.map +1 -0
  164. package/src/sseServer.ts +189 -0
  165. package/src/utils.d.ts.map +1 -1
  166. package/src/utils.js +23 -1
  167. package/src/utils.js.map +1 -1
  168. package/src/utils.ts +13 -1
  169. package/src/widgets.d.ts +26 -0
  170. package/src/widgets.d.ts.map +1 -1
  171. package/src/widgets.js +89 -45
  172. package/src/widgets.js.map +1 -1
  173. package/src/widgets.ts +88 -45
@@ -0,0 +1,1134 @@
1
+ // @ts-nocheck
2
+ // GENERATED by scripts/sync-lottie-converter.mjs from editor/src/utils/lottie. Edit there, then re-run.
3
+ // Lottie (Bodymovin JSON) -> Exode artboard.
4
+ //
5
+ // Written for the Exode runtime and editor as they are:
6
+ // * every Lottie layer / shape group becomes a Group carrying p, r, s, o with
7
+ // a child Group offset by -anchor, so anchors, rotation and scale are exact;
8
+ // * parenting is reproduced with transform-only copies of the parent chain
9
+ // (Lottie parents pass transforms, not opacity, and keep z-order);
10
+ // * Groups are created with clipContent: false (the runtime clips by default);
11
+ // * strokes are multiplied by their accumulated scale, because the runtime
12
+ // draws stroke widths in screen pixels whatever the transform;
13
+ // * track mattes use Exode alpha/luma masks, with the matte layer parked in a
14
+ // zero-size clipping Group so it only draws into the mask;
15
+ // * keyframes stay native (cubic-bezier easing per dimension) wherever Exode
16
+ // can express them; motion paths, time remapping and derived values are
17
+ // sampled per frame from evaluate.ts.
18
+ //
19
+ // Pure TypeScript with no editor imports.
20
+
21
+ import { isAnimated, isNativeFriendly, keyFrames, pathAt, scalarAt, valueAt, type LottieKeyframe, type LottiePath } from './evaluate.js';
22
+
23
+ export interface LottieConvertResult {
24
+ artboard: any;
25
+ warnings: string[];
26
+ stats: { layers: number; objects: number; tracks: number; keyframes: number; sampledTracks: number };
27
+ }
28
+
29
+ // ---------------------------------------------------------------- helpers
30
+
31
+ const r3 = (n: number) => Math.round(n * 1000) / 1000;
32
+ const r2 = (n: number) => Math.round(n * 100) / 100;
33
+ const hex2 = (n: number) => Math.round(Math.max(0, Math.min(1, n)) * 255).toString(16).padStart(2, '0');
34
+ const colorHex = (c: any): string => {
35
+ if (typeof c === 'string') return c.length >= 7 ? c.slice(0, 7) : c;
36
+ if (!Array.isArray(c)) return '#000000';
37
+ const big = c.slice(0, 3).some((v: number) => v > 1);
38
+ const f = (v: number) => hex2(big ? v / 255 : v);
39
+ return `#${f(c[0] ?? 0)}${f(c[1] ?? 0)}${f(c[2] ?? 0)}`;
40
+ };
41
+ const vec2 = (v: any, fb = 0): [number, number] => (Array.isArray(v) ? [v[0] ?? fb, v[1] ?? fb] : [v ?? fb, v ?? fb]);
42
+
43
+ const baseTransform = (x = 0, y = 0) => ({ x: r3(x), y: r3(y), rotation: 0, scale_x: 1, scale_y: 1, opacity: 1, skew_x: 0, skew_y: 0 });
44
+
45
+ /** Global comp frame -> a layer's local frame. */
46
+ interface TimeMap { scale: number; offset: number; remap?: (globalFrame: number) => number }
47
+ const ROOT_TIME: TimeMap = { scale: 1, offset: 0 };
48
+ const localFrame = (tm: TimeMap, g: number) => (tm.remap ? tm.remap(g) : (g - tm.offset) / tm.scale);
49
+ const affine = (tm: TimeMap) => !tm.remap;
50
+
51
+ interface Key { time: number; value: any; easing: string }
52
+
53
+ interface Ctx {
54
+ fr: number; ip: number; op: number;
55
+ objects: any[];
56
+ tracks: any[];
57
+ warnings: Set<string>;
58
+ assets: Map<string, any>;
59
+ fonts: Map<string, any>;
60
+ sampled: number;
61
+ layers: number;
62
+ }
63
+
64
+ const easingOf = (k: LottieKeyframe, d: number) => {
65
+ if (k.h === 1) return 'StepEnd';
66
+ if (!k.o || !k.i) return 'linear';
67
+ const g = (v: any, fb: number) => (Array.isArray(v) ? (v[d] ?? v[0] ?? fb) : (v ?? fb));
68
+ const x1 = g(k.o.x, 0), y1 = g(k.o.y, 0), x2 = g(k.i.x, 1), y2 = g(k.i.y, 1);
69
+ if (Math.abs(x1 - y1) < 1e-4 && Math.abs(x2 - y2) < 1e-4) return 'linear';
70
+ return `cubic-bezier(${r3(Math.max(0, Math.min(1, x1)))},${r3(y1)},${r3(Math.max(0, Math.min(1, x2)))},${r3(y2)})`;
71
+ };
72
+
73
+ // ---------------------------------------------------------------- tracks
74
+
75
+ /** Drop samples that a straight line between neighbours already reproduces. */
76
+ function simplify(keys: Key[], tol: number): Key[] {
77
+ if (keys.length <= 2) return keys;
78
+ const out: Key[] = [keys[0]];
79
+ for (let i = 1; i < keys.length - 1; i++) {
80
+ const a = out[out.length - 1], b = keys[i], c = keys[i + 1];
81
+ if (typeof b.value !== 'number' || typeof a.value !== 'number' || typeof c.value !== 'number') {
82
+ if (b.value !== a.value || b.value !== c.value) out.push(b);
83
+ continue;
84
+ }
85
+ const t = (b.time - a.time) / ((c.time - a.time) || 1);
86
+ if (Math.abs(a.value + (c.value - a.value) * t - b.value) > tol) out.push(b);
87
+ }
88
+ out.push(keys[keys.length - 1]);
89
+ return out;
90
+ }
91
+
92
+ class Tracks {
93
+ constructor(private ctx: Ctx) {}
94
+
95
+ private push(objId: string, property: string, keys: Key[]) {
96
+ if (!keys.length) return;
97
+ const first = keys[0].value;
98
+ if (keys.every(k => k.value === first) && keys.length > 1) keys = [keys[0]];
99
+ this.ctx.tracks.push({
100
+ id: `t_${objId}_${property.replace(/[^A-Za-z0-9]+/g, '_')}`,
101
+ object_id: objId,
102
+ property,
103
+ keyframes: keys.map((k, i) => ({ id: `k_${objId}_${property.replace(/[^A-Za-z0-9]+/g, '_')}_${i}`, time: r3(k.time), value: k.value, easing: k.easing })),
104
+ });
105
+ }
106
+
107
+ private sec(globalFrame: number) { return (globalFrame - this.ctx.ip) / this.ctx.fr; }
108
+
109
+ /** Sample fn(globalFrame) on every comp frame (plus the last), simplified. */
110
+ sample(objId: string, property: string, fn: (g: number) => number | string | boolean, tol = 0.01, step = 1) {
111
+ const keys: Key[] = [];
112
+ for (let g = this.ctx.ip; g <= this.ctx.op; g += step) keys.push({ time: this.sec(g), value: fn(Math.min(g, this.ctx.op)), easing: typeof fn(g) === 'boolean' ? 'StepEnd' : 'linear' });
113
+ const vals = keys.map(k => k.value);
114
+ if (vals.every(v => v === vals[0])) return;
115
+ this.ctx.sampled++;
116
+ this.push(objId, property, simplify(keys, tol).map(k => typeof k.value === 'number' ? { ...k, value: r3(k.value as number) } : k));
117
+ }
118
+
119
+ /**
120
+ * Animate `property` from dimension `d` of a Lottie property through an
121
+ * affine `map`. Native keyframes when possible, per-frame samples otherwise.
122
+ */
123
+ scalar(objId: string, property: string, p: any, d: number, map: (v: number) => number, tm: TimeMap, fallback = 0) {
124
+ if (!isAnimated(p)) return;
125
+ if (isNativeFriendly(p) && affine(tm)) {
126
+ const keys: LottieKeyframe[] = p.k;
127
+ const out: Key[] = keys.map((k, n) => {
128
+ const g = k.t * tm.scale + tm.offset;
129
+ const v = scalarAt(p, k.t, d, fallback);
130
+ return { time: this.sec(g), value: r3(map(v)), easing: n < keys.length - 1 ? easingOf(k, d) : 'linear' };
131
+ });
132
+ this.push(objId, property, out);
133
+ return;
134
+ }
135
+ this.sample(objId, property, g => map(scalarAt(p, localFrame(tm, g), d, fallback)));
136
+ }
137
+
138
+ /** Colour track (hex strings) from an animated Lottie colour. */
139
+ color(objId: string, property: string, p: any, tm: TimeMap) {
140
+ if (!isAnimated(p)) return;
141
+ if (isNativeFriendly(p) && affine(tm)) {
142
+ const keys: LottieKeyframe[] = p.k;
143
+ this.push(objId, property, keys.map((k, n) => ({ time: this.sec(k.t * tm.scale + tm.offset), value: colorHex(valueAt(p, k.t)), easing: n < keys.length - 1 ? easingOf(k, 0) : 'linear' })));
144
+ return;
145
+ }
146
+ this.sample(objId, property, g => colorHex(valueAt(p, localFrame(tm, g))));
147
+ }
148
+
149
+ raw(objId: string, property: string, keys: Key[]) { this.push(objId, property, keys); }
150
+ seconds(globalFrame: number) { return this.sec(globalFrame); }
151
+ }
152
+
153
+ // ---------------------------------------------------------------- objects
154
+
155
+ function group(ctx: Ctx, id: string, name: string, parentId: string | undefined, x = 0, y = 0): any {
156
+ const g = {
157
+ id, name, type: 'Group',
158
+ transform: baseTransform(x, y),
159
+ width: 1, height: 1,
160
+ geometry: { type: 'Rectangle', width: 1, height: 1, corner_radius: 0 },
161
+ style: { fill: { type: 'None', opacity: 0 }, stroke: { color: '#000000', width: 0, opacity: 0, isEnabled: false } },
162
+ clipContent: false,
163
+ isVisible: true, isLocked: false, isExpanded: false,
164
+ children: [] as string[],
165
+ ...(parentId ? { parentId } : {}),
166
+ };
167
+ ctx.objects.push(g);
168
+ return g;
169
+ }
170
+
171
+ const attach = (ctx: Ctx, parent: any | undefined, child: any) => {
172
+ if (parent) { child.parentId = parent.id; parent.children.push(child.id); }
173
+ };
174
+
175
+ /**
176
+ * Lottie transform (layer `ks` or group `tr`) as a pair of Groups:
177
+ * outer: position, rotation, scale (and opacity when `withOpacity`)
178
+ * inner: -anchor (omitted when the anchor is a static 0,0)
179
+ * Returns the innermost group, where content goes.
180
+ */
181
+ function transformGroups(ctx: Ctx, T: Tracks, ks: any, id: string, name: string, parent: any | undefined, tm: TimeMap, withOpacity: boolean): { outer: any; inner: any } {
182
+ ks = ks || {};
183
+ const outer = group(ctx, id, name, undefined);
184
+ attach(ctx, parent, outer);
185
+ const f0 = localFrame(tm, ctx.ip);
186
+ const t = outer.transform;
187
+
188
+ // Position: combined or split (px, py)
189
+ const p = ks.p || { k: [0, 0] };
190
+ if (p.s) {
191
+ t.x = r3(scalarAt(p.x, f0)); t.y = r3(scalarAt(p.y, f0));
192
+ T.scalar(id, 'x', p.x, 0, v => v, tm);
193
+ T.scalar(id, 'y', p.y, 0, v => v, tm);
194
+ } else if (isAnimated(p) && (!isNativeFriendly(p) || !affine(tm))) {
195
+ const [x, y] = vec2(valueAt(p, f0));
196
+ t.x = r3(x); t.y = r3(y);
197
+ T.sample(id, 'x', g => vec2(valueAt(p, localFrame(tm, g)))[0]);
198
+ T.sample(id, 'y', g => vec2(valueAt(p, localFrame(tm, g)))[1]);
199
+ } else {
200
+ t.x = r3(scalarAt(p, f0, 0)); t.y = r3(scalarAt(p, f0, 1));
201
+ T.scalar(id, 'x', p, 0, v => v, tm);
202
+ T.scalar(id, 'y', p, 1, v => v, tm);
203
+ }
204
+ if (ks.p?.k?.length === 3 || (Array.isArray(ks.p?.k?.[0]?.s) && ks.p.k[0].s.length === 3 && ks.p.k[0].s[2])) { /* z ignored */ }
205
+
206
+ const r = ks.r || ks.rz;
207
+ if (r) {
208
+ t.rotation = r3(scalarAt(r, f0));
209
+ T.scalar(id, 'rotation', r, 0, v => v, tm);
210
+ }
211
+ if (ks.s) {
212
+ t.scale_x = r3(scalarAt(ks.s, f0, 0, 100) / 100);
213
+ t.scale_y = r3(scalarAt(ks.s, f0, 1, 100) / 100);
214
+ T.scalar(id, 'scale_x', ks.s, 0, v => v / 100, tm, 100);
215
+ T.scalar(id, 'scale_y', ks.s, 1, v => v / 100, tm, 100);
216
+ }
217
+ if (withOpacity && ks.o) {
218
+ t.opacity = r3(Math.max(0, Math.min(1, scalarAt(ks.o, f0, 0, 100) / 100)));
219
+ T.scalar(id, 'opacity', ks.o, 0, v => Math.max(0, Math.min(1, v / 100)), tm, 100);
220
+ }
221
+ if (ks.sk && (isAnimated(ks.sk) || scalarAt(ks.sk, f0))) ctx.warnings.add('Skew is not imported');
222
+ // 3D layers: rotation about X / Y foreshortens to a scale (no perspective).
223
+ if (ks.rx || ks.ry || ks.or) {
224
+ const cosOf = (p: any, f: number) => (p ? Math.cos((scalarAt(p, f) * Math.PI) / 180) : 1);
225
+ const orient = (f: number, d: number) => (ks.or ? Math.cos((scalarAt(ks.or, f, d) * Math.PI) / 180) : 1);
226
+ const sx = (f: number) => (scalarAt(ks.s, f, 0, 100) / 100) * cosOf(ks.ry, f) * orient(f, 1);
227
+ const sy = (f: number) => (scalarAt(ks.s, f, 1, 100) / 100) * cosOf(ks.rx, f) * orient(f, 0);
228
+ t.scale_x = r3(sx(f0)); t.scale_y = r3(sy(f0));
229
+ ctx.tracks = ctx.tracks.filter(tr => !(tr.object_id === id && (tr.property === 'scale_x' || tr.property === 'scale_y')));
230
+ T.sample(id, 'scale_x', g => sx(localFrame(tm, g)), 0.002);
231
+ T.sample(id, 'scale_y', g => sy(localFrame(tm, g)), 0.002);
232
+ ctx.warnings.add('3D rotation is flattened (X/Y rotation becomes scale, no perspective)');
233
+ }
234
+
235
+ const a = ks.a;
236
+ const anchorMoves = isAnimated(a) || (a && vec2(a.k)[0] !== 0) || (a && vec2(a.k)[1] !== 0);
237
+ if (!anchorMoves) return { outer, inner: outer };
238
+ const inner = group(ctx, `${id}_a`, `${name} · anchor`, undefined, -scalarAt(a, f0, 0), -scalarAt(a, f0, 1));
239
+ attach(ctx, outer, inner);
240
+ T.scalar(inner.id, 'x', a, 0, v => -v, tm);
241
+ T.scalar(inner.id, 'y', a, 1, v => -v, tm);
242
+ return { outer, inner };
243
+ }
244
+
245
+ // ---------------------------------------------------------------- scale tracking (strokes)
246
+
247
+ interface ScaleRef { s: any; tm: TimeMap }
248
+ const scaleAt = (chain: ScaleRef[], g: number) => chain.reduce((acc, c) => {
249
+ const f = localFrame(c.tm, g);
250
+ return acc * Math.sqrt(Math.abs((scalarAt(c.s, f, 0, 100) / 100) * (scalarAt(c.s, f, 1, 100) / 100)));
251
+ }, 1);
252
+ const chainAnimated = (chain: ScaleRef[]) => chain.some(c => isAnimated(c.s) || !affine(c.tm) && isAnimated(c.s));
253
+
254
+ // ---------------------------------------------------------------- shapes
255
+
256
+ const KAPPA = 0.5522847498;
257
+
258
+ interface Pt { x: number; y: number; in_x: number; in_y: number; out_x: number; out_y: number; moveTo?: boolean; closePath?: boolean; pointType?: string }
259
+
260
+ const lottieToPts = (path: LottiePath, reverse: boolean): Pt[] => {
261
+ let v = path.v, i = path.i, o = path.o;
262
+ if (reverse) {
263
+ v = [...v].reverse(); const oi = [...o].reverse(), ii = [...i].reverse();
264
+ i = oi; o = ii;
265
+ }
266
+ return v.map((p, j) => ({
267
+ x: p[0], y: p[1],
268
+ in_x: p[0] + (i[j]?.[0] || 0), in_y: p[1] + (i[j]?.[1] || 0),
269
+ out_x: p[0] + (o[j]?.[0] || 0), out_y: p[1] + (o[j]?.[1] || 0),
270
+ pointType: (i[j]?.[0] || i[j]?.[1] || o[j]?.[0] || o[j]?.[1]) ? 'smooth' : 'corner',
271
+ }));
272
+ };
273
+
274
+ /** Path for rc/el/sr at a frame, in lottie-web's vertex order (matters for trim paths). */
275
+ function primitivePath(it: any, f: number): LottiePath | undefined {
276
+ if (it.ty === 'sh') return pathAt(it.ks, f);
277
+ if (it.ty === 'el') {
278
+ const [cx, cy] = vec2(valueAt(it.p, f)); const [w, h] = vec2(valueAt(it.s, f));
279
+ const rx = w / 2, ry = h / 2, kx = rx * KAPPA, ky = ry * KAPPA;
280
+ return {
281
+ c: true,
282
+ v: [[cx, cy - ry], [cx + rx, cy], [cx, cy + ry], [cx - rx, cy]],
283
+ i: [[-kx, 0], [0, -ky], [kx, 0], [0, ky]],
284
+ o: [[kx, 0], [0, ky], [-kx, 0], [0, -ky]],
285
+ };
286
+ }
287
+ if (it.ty === 'rc') {
288
+ const [cx, cy] = vec2(valueAt(it.p, f)); const [w, h] = vec2(valueAt(it.s, f));
289
+ const r = Math.min(scalarAt(it.r, f), w / 2, h / 2);
290
+ const x0 = cx - w / 2, x1 = cx + w / 2, y0 = cy - h / 2, y1 = cy + h / 2;
291
+ if (r <= 0) return { c: true, v: [[x1, y0], [x1, y1], [x0, y1], [x0, y0]], i: [[0, 0], [0, 0], [0, 0], [0, 0]], o: [[0, 0], [0, 0], [0, 0], [0, 0]] };
292
+ const k = r * (1 - KAPPA);
293
+ return {
294
+ c: true,
295
+ v: [[x1, y0 + r], [x1, y1 - r], [x1 - r, y1], [x0 + r, y1], [x0, y1 - r], [x0, y0 + r], [x0 + r, y0], [x1 - r, y0]],
296
+ i: [[0, 0], [0, 0], [r - k, 0], [0, 0], [0, r - k], [0, 0], [-(r - k), 0], [0, 0]].map((v, j) => (j % 2 === 0 && j > 0 ? v : j === 0 ? [0, -(r - k)] : [0, 0])),
297
+ o: [[0, 0], [0, r - k], [0, 0], [-(r - k), 0], [0, 0], [0, -(r - k)], [0, 0], [r - k, 0]],
298
+ };
299
+ }
300
+ if (it.ty === 'sr') {
301
+ const [cx, cy] = vec2(valueAt(it.p, f));
302
+ const n = Math.max(3, Math.round(scalarAt(it.pt, f, 0, 5)));
303
+ const rot = (scalarAt(it.r, f) - 90) * Math.PI / 180;
304
+ const or = scalarAt(it.or, f), ir = scalarAt(it.ir, f);
305
+ const star = it.sy === 1;
306
+ const count = star ? n * 2 : n;
307
+ const v: number[][] = [];
308
+ for (let j = 0; j < count; j++) {
309
+ const rad = star && j % 2 ? ir : or;
310
+ const a = rot + (j * Math.PI * 2) / count;
311
+ v.push([cx + Math.cos(a) * rad, cy + Math.sin(a) * rad]);
312
+ }
313
+ if (scalarAt(it.os, f) || scalarAt(it.is, f)) { /* roundness ignored */ }
314
+ return { c: true, v, i: v.map(() => [0, 0]), o: v.map(() => [0, 0]) };
315
+ }
316
+ return undefined;
317
+ }
318
+
319
+ const SHAPE_TYPES = new Set(['sh', 'rc', 'el', 'sr']);
320
+ const STYLE_TYPES = new Set(['fl', 'st', 'gf', 'gs']);
321
+
322
+ /** Signed area of a closed contour (shoelace on anchor points). */
323
+ const area = (pts: Pt[]) => pts.reduce((s, p, i) => { const q = pts[(i + 1) % pts.length]; return s + p.x * q.y - q.x * p.y; }, 0) / 2;
324
+ const inside = (pt: Pt, poly: Pt[]) => {
325
+ let c = false;
326
+ for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
327
+ const a = poly[i], b = poly[j];
328
+ if ((a.y > pt.y) !== (b.y > pt.y) && pt.x < ((b.x - a.x) * (pt.y - a.y)) / ((b.y - a.y) || 1e-9) + a.x) c = !c;
329
+ }
330
+ return c;
331
+ };
332
+ const reverseContour = (pts: Pt[]): Pt[] => [...pts].reverse().map(p => ({ ...p, in_x: p.out_x, in_y: p.out_y, out_x: p.in_x, out_y: p.in_y }));
333
+
334
+ /**
335
+ * The runtime fills with the nonzero rule only. For even-odd fills, reverse
336
+ * contours so nesting depth alternates direction, which nonzero then fills
337
+ * the way even-odd would (holes stay holes).
338
+ */
339
+ function evenOddAsNonzero(contours: Pt[][]): Pt[][] {
340
+ if (contours.length < 2) return contours;
341
+ return contours.map((c, i) => {
342
+ const depth = contours.reduce((d, other, j) => (j !== i && other.length > 2 && inside(c[0], other) ? d + 1 : d), 0);
343
+ const ccw = area(c) > 0;
344
+ const want = depth % 2 === 0;
345
+ return ccw === want ? c : reverseContour(c);
346
+ });
347
+ }
348
+
349
+ /** Contours of the shapes at a frame, as Exode points (moveTo / closePath set). */
350
+ function contoursAt(shapes: any[], f: number, evenOdd: boolean): Pt[][] {
351
+ const contours: Pt[][] = [];
352
+ for (const it of shapes) {
353
+ const path = primitivePath(it, f);
354
+ if (!path || !path.v?.length) continue;
355
+ const pts = lottieToPts(path, it.d === 3);
356
+ (pts as any).closed = path.c;
357
+ contours.push(pts);
358
+ }
359
+ return evenOdd ? evenOddAsNonzero(contours).map((c, i) => Object.assign(c, { closed: (contours[i] as any).closed })) : contours;
360
+ }
361
+
362
+ const flatten = (contours: Pt[][]): Pt[] => {
363
+ const out: Pt[] = [];
364
+ for (const c of contours) {
365
+ c.forEach((p, i) => out.push({ ...p, ...(i === 0 ? { moveTo: true } : {}), ...(i === c.length - 1 && (c as any).closed ? { closePath: true } : {}) }));
366
+ }
367
+ return out;
368
+ };
369
+
370
+ const ptsToData = (pts: Pt[]) => {
371
+ const parts: string[] = [];
372
+ let start = 0;
373
+ pts.forEach((p, i) => {
374
+ if (p.moveTo || i === 0) { parts.push(`M${r3(p.x)} ${r3(p.y)}`); start = i; }
375
+ else { const q = pts[i - 1]; parts.push(`C${r3(q.out_x)} ${r3(q.out_y)} ${r3(p.in_x)} ${r3(p.in_y)} ${r3(p.x)} ${r3(p.y)}`); }
376
+ if (p.closePath) { const s = pts[start]; parts.push(`C${r3(p.out_x)} ${r3(p.out_y)} ${r3(s.in_x)} ${r3(s.in_y)} ${r3(s.x)} ${r3(s.y)} Z`); }
377
+ });
378
+ return parts.join(' ');
379
+ };
380
+
381
+ const bbox = (pts: Pt[]) => {
382
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
383
+ for (const p of pts) for (const [x, y] of [[p.x, p.y], [p.in_x, p.in_y], [p.out_x, p.out_y]]) { x0 = Math.min(x0, x); y0 = Math.min(y0, y); x1 = Math.max(x1, x); y1 = Math.max(y1, y); }
384
+ return { x0, y0, x1, y1, w: Math.max(0, x1 - x0), h: Math.max(0, y1 - y0) };
385
+ };
386
+
387
+ const gradientStops = (g: any, f: number) => {
388
+ const n = g?.p || 0;
389
+ const raw: number[] = vec2(0) && (valueAt(g?.k, f) || []);
390
+ const stops: any[] = [];
391
+ for (let j = 0; j < n; j++) stops.push({ offset: r3(raw[j * 4]), color: colorHex([raw[j * 4 + 1], raw[j * 4 + 2], raw[j * 4 + 3]]), opacity: 1 });
392
+ const alphas = raw.slice(n * 4);
393
+ if (alphas.length >= 2) {
394
+ const alphaAt = (pos: number) => {
395
+ for (let j = 0; j < alphas.length / 2 - 1; j++) {
396
+ const p0 = alphas[j * 2], p1 = alphas[j * 2 + 2];
397
+ if (pos <= p1) { const w = p1 > p0 ? (pos - p0) / (p1 - p0) : 0; return alphas[j * 2 + 1] + (alphas[j * 2 + 3] - alphas[j * 2 + 1]) * Math.max(0, Math.min(1, w)); }
398
+ }
399
+ return alphas[alphas.length - 1];
400
+ };
401
+ stops.forEach(s => (s.opacity = r3(alphaAt(s.offset))));
402
+ }
403
+ return stops;
404
+ };
405
+
406
+ /**
407
+ * Exode gradient fill for a Lottie gradient, normalised to the object's box
408
+ * (origin-centred). The runtime applies a gradient fill's opacity twice (in the
409
+ * stop colours and again as alpha), so the opacity is folded into the stops
410
+ * and the fill itself stays at 1.
411
+ */
412
+ function gradientFill(st: any, f: number, box: { w: number; h: number }, opacity: number) {
413
+ const [sx, sy] = vec2(valueAt(st.s, f)); const [ex, ey] = vec2(valueAt(st.e, f));
414
+ const w = box.w || 1, h = box.h || 1;
415
+ const nx = (x: number) => r3((x + w / 2) / w), ny = (y: number) => r3((y + h / 2) / h);
416
+ const stops = gradientStops(st.g, f).map((s: any) => ({ ...s, opacity: r3(s.opacity * opacity) }));
417
+ opacity = 1;
418
+ if (st.t === 2) return { type: 'RadialGradient', center: [nx(sx), ny(sy)], radius: r3(Math.hypot(ex - sx, ey - sy) / Math.max(w, h)), stops, opacity };
419
+ return { type: 'LinearGradient', start: [nx(sx), ny(sy)], end: [nx(ex), ny(ey)], stops, opacity };
420
+ }
421
+
422
+ interface Trim { s: any; e: any; o: any; tm: TimeMap }
423
+
424
+ interface Scope {
425
+ parent: any;
426
+ tm: TimeMap;
427
+ scale: ScaleRef[];
428
+ trims: Trim[];
429
+ idBase: string;
430
+ }
431
+
432
+ /** Shape layer / group contents -> Exode objects, bottom to top. */
433
+ function shapeItems(ctx: Ctx, T: Tracks, items: any[], scope: Scope) {
434
+ items = (items || []).filter(it => it && !it.hd);
435
+ // Repeater: copies of everything above it (earlier in the list).
436
+ const rpAt = items.findIndex(it => it.ty === 'rp');
437
+ if (rpAt >= 0) {
438
+ shapeItems(ctx, T, items.slice(rpAt + 1), { ...scope, idBase: `${scope.idBase}_below` });
439
+ repeater(ctx, T, items[rpAt], items.slice(0, rpAt), scope);
440
+ return;
441
+ }
442
+ const trims = [...scope.trims, ...items.filter(it => it.ty === 'tm').map(it => ({ s: it.s, e: it.e, o: it.o, tm: scope.tm }))];
443
+ if (items.some(it => it.ty === 'rd')) ctx.warnings.add('Round corners on paths are approximated');
444
+ if (items.some(it => ['tw', 'pb', 'zz', 'op', 'ms'].includes(it.ty))) ctx.warnings.add('Twist, pucker, zig-zag and offset path modifiers are not imported');
445
+
446
+ for (let i = items.length - 1; i >= 0; i--) {
447
+ const it = items[i];
448
+ const id = `${scope.idBase}_${i}`;
449
+ if (it.ty === 'gr') {
450
+ const tr = (it.it || []).find((x: any) => x.ty === 'tr');
451
+ const { inner } = transformGroups(ctx, T, tr, id, it.nm || `Group ${i}`, scope.parent, scope.tm, true);
452
+ shapeItems(ctx, T, (it.it || []).filter((x: any) => x.ty !== 'tr'), { ...scope, parent: inner, idBase: id, trims, scale: tr?.s ? [...scope.scale, { s: tr.s, tm: scope.tm }] : scope.scale });
453
+ } else if (STYLE_TYPES.has(it.ty)) {
454
+ paintWith(ctx, T, it, items.slice(0, i), { ...scope, trims }, id);
455
+ }
456
+ }
457
+ }
458
+
459
+ /**
460
+ * A style paints every shape above it: those at its own level (one compound
461
+ * object) and those inside earlier groups, which get a copy of that group's
462
+ * transform so the shapes land where Lottie draws them.
463
+ */
464
+ function paintWith(ctx: Ctx, T: Tracks, style: any, above: any[], scope: Scope, id: string) {
465
+ const shapes = above.filter(x => SHAPE_TYPES.has(x.ty) && !x.hd);
466
+ if (shapes.length) {
467
+ const rd = above.find(x => x.ty === 'rd');
468
+ styledShape(ctx, T, style, shapes, rd, scope, `${id}_s`);
469
+ }
470
+ for (let j = above.length - 1; j >= 0; j--) {
471
+ const g = above[j];
472
+ if (g.ty !== 'gr' || g.hd) continue;
473
+ const inner = (g.it || []).filter((x: any) => x.ty !== 'tr');
474
+ const hasShapes = (list: any[]): boolean => list.some(x => SHAPE_TYPES.has(x.ty) || (x.ty === 'gr' && hasShapes(x.it || [])));
475
+ if (!hasShapes(inner)) continue;
476
+ const tr = (g.it || []).find((x: any) => x.ty === 'tr');
477
+ const gid = `${id}_in${j}`;
478
+ const { inner: host } = transformGroups(ctx, T, tr, gid, `${g.nm || 'Group'} (${style.nm || 'style'})`, scope.parent, scope.tm, true);
479
+ const trims = [...scope.trims, ...inner.filter((x: any) => x.ty === 'tm').map((x: any) => ({ s: x.s, e: x.e, o: x.o, tm: scope.tm }))];
480
+ paintWith(ctx, T, style, inner, { ...scope, parent: host, trims, scale: tr?.s ? [...scope.scale, { s: tr.s, tm: scope.tm }] : scope.scale }, gid);
481
+ }
482
+ }
483
+
484
+ function repeater(ctx: Ctx, T: Tracks, rp: any, above: any[], scope: Scope) {
485
+ const f0 = localFrame(scope.tm, ctx.ip);
486
+ const count = Math.max(0, Math.min(200, Math.round(scalarAt(rp.c, f0, 0, 1))));
487
+ const offset = scalarAt(rp.o, f0);
488
+ if (isAnimated(rp.c) || isAnimated(rp.o) || ['p', 'a', 's', 'r', 'so', 'eo'].some(k => isAnimated(rp.tr?.[k]))) ctx.warnings.add('Animated repeaters are imported at their first frame');
489
+ const tr = rp.tr || {};
490
+ const [px, py] = vec2(valueAt(tr.p, f0)); const [ax, ay] = vec2(valueAt(tr.a, f0));
491
+ const [sx, sy] = vec2(valueAt(tr.s, f0) ?? [100, 100], 100);
492
+ const rot = scalarAt(tr.r, f0) * Math.PI / 180;
493
+ const so = scalarAt(tr.so, f0, 0, 100) / 100, eo = scalarAt(tr.eo, f0, 0, 100) / 100;
494
+ // One step: T(p) T(a) R S T(-a), applied k times.
495
+ type M = [number, number, number, number, number, number];
496
+ const mul = (p: M, q: M): M => [p[0] * q[0] + p[2] * q[1], p[1] * q[0] + p[3] * q[1], p[0] * q[2] + p[2] * q[3], p[1] * q[2] + p[3] * q[3], p[0] * q[4] + p[2] * q[5] + p[4], p[1] * q[4] + p[3] * q[5] + p[5]];
497
+ const c = Math.cos(rot), s = Math.sin(rot);
498
+ const step: M = mul(mul(mul([1, 0, 0, 1, px + ax, py + ay], [c, s, -s, c, 0, 0]), [sx / 100, 0, 0, sy / 100, 0, 0]), [1, 0, 0, 1, -ax, -ay]);
499
+ const order = Array.from({ length: count }, (_, k) => k);
500
+ if (rp.m !== 2) order.reverse(); // m=1: later copies above
501
+ const power = (k: number): M => { let m: M = [1, 0, 0, 1, 0, 0]; for (let j = 0; j < Math.abs(k); j++) m = mul(m, step); return m; };
502
+ for (const k of order.reverse()) {
503
+ const m = power(k + offset);
504
+ const g = group(ctx, `${scope.idBase}_rp${k}`, `Repeat ${k + 1}`, undefined, m[4], m[5]);
505
+ g.transform.rotation = r3(Math.atan2(m[1], m[0]) * 180 / Math.PI);
506
+ g.transform.scale_x = r3(Math.hypot(m[0], m[1]));
507
+ g.transform.scale_y = r3((m[0] * m[3] - m[1] * m[2]) / (Math.hypot(m[0], m[1]) || 1));
508
+ g.transform.opacity = r3(count > 1 ? so + (eo - so) * (k / (count - 1)) : so);
509
+ attach(ctx, scope.parent, g);
510
+ shapeItems(ctx, T, above, { ...scope, parent: g, idBase: `${scope.idBase}_rp${k}`, scale: [...scope.scale, { s: { k: [g.transform.scale_x * 100, g.transform.scale_y * 100] }, tm: scope.tm }] });
511
+ }
512
+ }
513
+
514
+ function styledShape(ctx: Ctx, T: Tracks, st: any, shapes: any[], rd: any, scope: Scope, id: string) {
515
+ const tm = scope.tm;
516
+ const f0 = localFrame(tm, ctx.ip);
517
+ const isStroke = st.ty === 'st' || st.ty === 'gs';
518
+ const evenOdd = !isStroke && st.r === 2;
519
+ const trimmed = isStroke && scope.trims.length > 0;
520
+ const name = st.nm || (isStroke ? 'Stroke' : 'Fill');
521
+ const obj: any = { id, name: shapes.length === 1 ? (shapes[0].nm || name) : name, type: 'Shape', transform: baseTransform(), isVisible: true, isLocked: false };
522
+ const one = shapes.length === 1 ? shapes[0] : null;
523
+ const roundness = rd ? scalarAt(rd.r, f0) : 0;
524
+
525
+ // Geometry ----------------------------------------------------------
526
+ let box = { w: 1, h: 1 };
527
+ // Lottie trims fills too (the cut outline is closed and filled).
528
+ const trimFill = !isStroke && scope.trims.length > 0 && (() => {
529
+ for (let g = ctx.ip; g <= ctx.op; g++) { const r = trimRange(scope.trims, g); if (r.a > 1e-4 || r.b < 1 - 1e-4) return true; }
530
+ return false;
531
+ })();
532
+ if (trimFill) {
533
+ const per: Pt[][] = [];
534
+ for (let g = ctx.ip; g <= ctx.op; g++) per.push(flatten(trimmedContours(shapes, scope.trims, tm, g, evenOdd)));
535
+ const pts = per[0];
536
+ if (!pts.length) return;
537
+ const b = bbox(per.flat());
538
+ box = { w: b.w, h: b.h };
539
+ obj.geometry = { type: 'Path', points: pts, is_closed: true, width: r3(b.w), height: r3(b.h), baseWidth: r3(b.w), baseHeight: r3(b.h), fillRule: 'nonzero' };
540
+ // Keep the box fixed: the runtime scales points by width / baseWidth.
541
+ (['x', 'y'] as (keyof Pt)[]).forEach(fd => pts.forEach((_, j) => {
542
+ const series = per.map(p => p[j][fd] as number);
543
+ if (series.every(v => Math.abs(v - series[0]) < 1e-6)) return;
544
+ T.sample(id, `geometry.points.${j}.${fd}`, g => series[Math.max(0, Math.min(series.length - 1, Math.round(g - ctx.ip)))], 0.05);
545
+ T.sample(id, `geometry.points.${j}.in_${fd}`, g => series[Math.max(0, Math.min(series.length - 1, Math.round(g - ctx.ip)))], 0.05);
546
+ T.sample(id, `geometry.points.${j}.out_${fd}`, g => series[Math.max(0, Math.min(series.length - 1, Math.round(g - ctx.ip)))], 0.05);
547
+ }));
548
+ } else if (one && (one.ty === 'rc' || one.ty === 'el') && !trimmed && !evenOdd) {
549
+ const [x, y] = vec2(valueAt(one.p, f0)); const [w, h] = vec2(valueAt(one.s, f0));
550
+ obj.transform.x = r3(x); obj.transform.y = r3(y);
551
+ box = { w, h };
552
+ obj.geometry = one.ty === 'rc'
553
+ ? { type: 'Rectangle', width: r3(w), height: r3(h), corner_radius: r3(Math.min(scalarAt(one.r, f0) || roundness, w / 2, h / 2)) }
554
+ : { type: 'Ellipse', width: r3(w), height: r3(h) };
555
+ T.scalar(id, 'x', one.p, 0, v => v, tm); T.scalar(id, 'y', one.p, 1, v => v, tm);
556
+ T.scalar(id, 'geometry.width', one.s, 0, v => v, tm); T.scalar(id, 'geometry.height', one.s, 1, v => v, tm);
557
+ if (one.ty === 'rc') T.scalar(id, 'geometry.corner_radius', one.r, 0, v => v, tm);
558
+ } else {
559
+ const contours = contoursAt(shapes, f0, evenOdd);
560
+ const pts = flatten(contours);
561
+ if (!pts.length) return;
562
+ const b = bbox(pts);
563
+ box = { w: b.w, h: b.h };
564
+ // Points stay in the group's coordinates: the runtime draws them as-is
565
+ // while width equals baseWidth.
566
+ obj.geometry = { type: 'Path', points: pts, is_closed: pts.some(p => p.closePath), width: r3(b.w), height: r3(b.h), baseWidth: r3(b.w), baseHeight: r3(b.h), fillRule: 'nonzero' };
567
+ const animated = shapes.some(s => SHAPE_TYPES.has(s.ty) && (['ks', 'p', 's', 'r', 'pt', 'or', 'ir'].some(k => isAnimated(s[k]))));
568
+ if (animated) {
569
+ const single = one && one.ty === 'sh' && isNativeFriendly(one.ks) && affine(tm) && !evenOdd;
570
+ const counts = new Set<number>();
571
+ const frames: number[] = [];
572
+ for (let g = ctx.ip; g <= ctx.op; g++) frames.push(g);
573
+ const per = frames.map(g => flatten(contoursAt(shapes, localFrame(tm, g), evenOdd)));
574
+ per.forEach(p => counts.add(p.length));
575
+ if (counts.size !== 1) {
576
+ ctx.warnings.add('A path changes its vertex count while animating; it is imported at its first frame');
577
+ } else if (single) {
578
+ // Native morph: one track per coordinate, Lottie's own keyframes and easing.
579
+ const keys: LottieKeyframe[] = one.ks.k;
580
+ const at = keys.map(k => flatten(contoursAt([one], k.t, false)));
581
+ const fields: (keyof Pt)[] = ['x', 'y', 'in_x', 'in_y', 'out_x', 'out_y'];
582
+ pts.forEach((_, j) => fields.forEach(fd => {
583
+ const vals = at.map(p => p[j][fd] as number);
584
+ if (vals.every(v => Math.abs(v - vals[0]) < 1e-6)) return;
585
+ T.raw(id, `geometry.points.${j}.${fd}`, keys.map((k, n) => ({ time: T.seconds(k.t * tm.scale + tm.offset), value: r3(vals[n]), easing: n < keys.length - 1 ? easingOf(k, 0) : 'linear' })));
586
+ }));
587
+ } else {
588
+ const fields: (keyof Pt)[] = ['x', 'y', 'in_x', 'in_y', 'out_x', 'out_y'];
589
+ pts.forEach((_, j) => fields.forEach(fd => {
590
+ const series = per.map(p => p[j][fd] as number);
591
+ if (series.every(v => Math.abs(v - series[0]) < 1e-6)) return;
592
+ T.sample(id, `geometry.points.${j}.${fd}`, g => series[Math.max(0, Math.min(series.length - 1, Math.round(g - ctx.ip)))], 0.05);
593
+ }));
594
+ }
595
+ // The editor builds animated paths from points, not the static data string.
596
+ } else {
597
+ obj.geometry.data = ptsToData(pts);
598
+ }
599
+ }
600
+
601
+ // Paint -------------------------------------------------------------
602
+ const opacity = r3(Math.max(0, Math.min(1, scalarAt(st.o, f0, 0, 100) / 100)));
603
+ let paint: any;
604
+ if (st.ty === 'fl' || st.ty === 'st') paint = { type: 'Solid', color: colorHex(valueAt(st.c, f0)), opacity };
605
+ else {
606
+ // Gradient coordinates are in the group's space; normalise around the object origin.
607
+ const originBox = obj.geometry.type === 'Path' ? (() => { const bb = bbox(obj.geometry.points); return { w: 2 * Math.max(Math.abs(bb.x0), Math.abs(bb.x1)) || 1, h: 2 * Math.max(Math.abs(bb.y0), Math.abs(bb.y1)) || 1 }; })() : box;
608
+ if (obj.geometry.type !== 'Path') {
609
+ // Rect/Ellipse objects sit at their own centre; gradient points are group space.
610
+ const [ox, oy] = [obj.transform.x, obj.transform.y];
611
+ paint = gradientFill({ ...st, s: { k: (() => { const [a, b] = vec2(valueAt(st.s, f0)); return [a - ox, b - oy]; })() }, e: { k: (() => { const [a, b] = vec2(valueAt(st.e, f0)); return [a - ox, b - oy]; })() } }, f0, box, opacity);
612
+ } else {
613
+ paint = gradientFill(st, f0, originBox, opacity);
614
+ // Engine maps gradients over geometry width/height around the origin.
615
+ obj.geometry.width = r3(originBox.w); obj.geometry.height = r3(originBox.h);
616
+ obj.geometry.baseWidth = obj.geometry.width; obj.geometry.baseHeight = obj.geometry.height;
617
+ }
618
+ if (isAnimated(st.s) || isAnimated(st.e) || isAnimated(st.g?.k) || isAnimated(st.o)) {
619
+ const base = isStroke ? 'style.stroke.fill' : 'style.fill';
620
+ const geoBox = obj.geometry.type === 'Path' ? { w: obj.geometry.width, h: obj.geometry.height } : box;
621
+ const origin = obj.geometry.type === 'Path' ? [0, 0] : [obj.transform.x, obj.transform.y];
622
+ const at = (g: number) => {
623
+ const f = localFrame(tm, g);
624
+ const [sx, sy] = vec2(valueAt(st.s, f)); const [ex, ey] = vec2(valueAt(st.e, f));
625
+ const o = Math.max(0, Math.min(1, scalarAt(st.o, f, 0, 100) / 100));
626
+ return gradientFill({ ...st, s: { k: [sx - origin[0], sy - origin[1]] }, e: { k: [ex - origin[0], ey - origin[1]] } }, f, geoBox, o);
627
+ };
628
+ const first = at(ctx.ip);
629
+ first.stops.forEach((_: any, j: number) => {
630
+ T.sample(id, `${base}.stops.${j}.color`, g => at(g).stops[j]?.color ?? first.stops[j].color);
631
+ T.sample(id, `${base}.stops.${j}.offset`, g => at(g).stops[j]?.offset ?? 0, 0.002);
632
+ T.sample(id, `${base}.stops.${j}.opacity`, g => at(g).stops[j]?.opacity ?? 1, 0.002);
633
+ });
634
+ const keys = first.type === 'RadialGradient' ? [['center', 0], ['center', 1]] : [['start', 0], ['start', 1], ['end', 0], ['end', 1]];
635
+ keys.forEach(([k, d]) => T.sample(id, `${base}.${k}.${d}`, g => (at(g) as any)[k as string][d as number], 0.002));
636
+ if (first.type === 'RadialGradient') T.sample(id, `${base}.radius`, g => (at(g) as any).radius, 0.002);
637
+ }
638
+ }
639
+
640
+ const none = { type: 'Solid', color: '#000000', opacity: 0 };
641
+ if (!isStroke) {
642
+ obj.style = { fill: paint, stroke: { color: '#000000', width: 0, opacity: 0, isEnabled: false } };
643
+ if (st.ty === 'fl') { T.color(id, 'style.fill.color', st.c, tm); T.scalar(id, 'style.fill.opacity', st.o, 0, v => Math.max(0, Math.min(1, v / 100)), tm, 100); }
644
+ } else {
645
+ const w0 = scalarAt(st.w, f0, 0, 1);
646
+ const lc = st.lc === 2 ? 'round' : st.lc === 3 ? 'square' : 'butt';
647
+ const lj = st.lj === 2 ? 'round' : st.lj === 3 ? 'bevel' : 'miter';
648
+ const worldScale = (g: number) => scaleAt(scope.scale, g);
649
+ const stroke: any = {
650
+ width: r3(w0 * worldScale(ctx.ip)),
651
+ opacity: paint.type === 'Solid' ? opacity : 1,
652
+ alignment: 'center', lineCap: lc, lineJoin: lj, isEnabled: true,
653
+ color: paint.type === 'Solid' ? paint.color : (paint.stops?.[0]?.color || '#000000'),
654
+ ...(paint.type !== 'Solid' ? { fill: paint } : {}),
655
+ };
656
+ const dashes = (st.d || []).filter((d: any) => d.n === 'd' || d.n === 'g').map((d: any) => r3(scalarAt(d.v, f0) * worldScale(ctx.ip)));
657
+ if (dashes.length && dashes.some((v: number) => v > 0)) stroke.dashArray = dashes;
658
+ obj.style = { fill: none, stroke };
659
+ if (st.ty === 'st') { T.color(id, 'style.stroke.color', st.c, tm); }
660
+ if (st.ty === 'st') T.scalar(id, 'style.stroke.opacity', st.o, 0, v => Math.max(0, Math.min(1, v / 100)), tm, 100);
661
+ if (isAnimated(st.w) || chainAnimated(scope.scale)) {
662
+ if (!chainAnimated(scope.scale) && isNativeFriendly(st.w)) { const k = worldScale(ctx.ip); T.scalar(id, 'style.stroke.width', st.w, 0, v => v * k, tm); }
663
+ else T.sample(id, 'style.stroke.width', g => scalarAt(st.w, localFrame(tm, g), 0, 1) * worldScale(g), 0.02);
664
+ }
665
+ if (trimmed) applyTrims(ctx, T, obj, scope.trims, id);
666
+ }
667
+
668
+ attach(ctx, scope.parent, obj);
669
+ ctx.objects.push(obj);
670
+ }
671
+
672
+ /** Trim range (0..1 start/end, offset in turns) at a global frame; several trims nest. */
673
+ function trimRange(trims: Trim[], g: number) {
674
+ let a = 0, b = 1, off = 0;
675
+ for (const t of trims) {
676
+ const f = localFrame(t.tm, g);
677
+ let s = scalarAt(t.s, f) / 100, e = scalarAt(t.e, f, 0, 100) / 100;
678
+ if (s > e) [s, e] = [e, s];
679
+ const len = b - a;
680
+ [a, b] = [a + len * s, a + len * e];
681
+ off += scalarAt(t.o, f) / 360;
682
+ }
683
+ return { a, b, off: ((off % 1) + 1) % 1 };
684
+ }
685
+
686
+ const TRIM_SAMPLES = 64;
687
+
688
+ /** A contour as a dense polyline with cumulative length. */
689
+ function polyline(c: Pt[], closed: boolean) {
690
+ const pts: [number, number][] = [];
691
+ const segs = closed ? c.length : c.length - 1;
692
+ for (let i = 0; i < segs; i++) {
693
+ const p = c[i], q = c[(i + 1) % c.length];
694
+ for (let k = 0; k < 24; k++) {
695
+ const t = k / 24, m = 1 - t;
696
+ pts.push([
697
+ m * m * m * p.x + 3 * m * m * t * p.out_x + 3 * m * t * t * q.in_x + t * t * t * q.x,
698
+ m * m * m * p.y + 3 * m * m * t * p.out_y + 3 * m * t * t * q.in_y + t * t * t * q.y,
699
+ ]);
700
+ }
701
+ }
702
+ const last = closed ? c[0] : c[c.length - 1];
703
+ pts.push([last.x, last.y]);
704
+ const lens = [0];
705
+ for (let i = 1; i < pts.length; i++) lens.push(lens[i - 1] + Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]));
706
+ return { pts, lens, total: lens[lens.length - 1] };
707
+ }
708
+
709
+ const pointAtLength = (pl: ReturnType<typeof polyline>, L: number): [number, number] => {
710
+ if (pl.total <= 0) return pl.pts[0];
711
+ L = Math.max(0, Math.min(pl.total, L));
712
+ let i = 1;
713
+ while (i < pl.lens.length - 1 && pl.lens[i] < L) i++;
714
+ const l0 = pl.lens[i - 1], l1 = pl.lens[i], w = l1 > l0 ? (L - l0) / (l1 - l0) : 0;
715
+ return [pl.pts[i - 1][0] + (pl.pts[i][0] - pl.pts[i - 1][0]) * w, pl.pts[i - 1][1] + (pl.pts[i][1] - pl.pts[i - 1][1]) * w];
716
+ };
717
+
718
+ /** The trimmed part of each contour, resampled to a fixed point count (so it can morph). */
719
+ function trimmedContours(shapes: any[], trims: Trim[], tm: TimeMap, g: number, evenOdd: boolean): Pt[][] {
720
+ const { a, b, off } = trimRange(trims, g);
721
+ return contoursAt(shapes, localFrame(tm, g), evenOdd).map(c => {
722
+ const pl = polyline(c, !!(c as any).closed);
723
+ const span = Math.max(0, b - a);
724
+ const start = (a + off) * pl.total;
725
+ const out: Pt[] = [];
726
+ for (let k = 0; k < TRIM_SAMPLES; k++) {
727
+ let L = start + (span * pl.total * k) / (TRIM_SAMPLES - 1);
728
+ if ((c as any).closed) L = ((L % pl.total) + pl.total) % pl.total;
729
+ const [x, y] = pointAtLength(pl, L);
730
+ out.push({ x, y, in_x: x, in_y: y, out_x: x, out_y: y, pointType: 'corner' });
731
+ }
732
+ (out as any).closed = true;
733
+ return out;
734
+ });
735
+ }
736
+
737
+ /** Trim paths -> stroke trim (0..1). Several trims multiply into one range. */
738
+ function applyTrims(ctx: Ctx, T: Tracks, obj: any, trims: Trim[], id: string) {
739
+ const range = (g: number) => {
740
+ let a = 0, b = 1, off = 0;
741
+ for (const t of trims) {
742
+ const f = localFrame(t.tm, g);
743
+ let s = scalarAt(t.s, f) / 100, e = scalarAt(t.e, f, 0, 100) / 100;
744
+ if (s > e) [s, e] = [e, s];
745
+ const len = b - a;
746
+ [a, b] = [a + len * s, a + len * e];
747
+ off += scalarAt(t.o, f) / 360;
748
+ }
749
+ return { a, b, off: ((off % 1) + 1) % 1 };
750
+ };
751
+ const r0 = range(ctx.ip);
752
+ Object.assign(obj.style.stroke, { trimStart: r3(r0.a), trimEnd: r3(r0.b), trimOffset: r3(r0.off) });
753
+ const one = trims.length === 1 ? trims[0] : null;
754
+ if (one && affine(one.tm) && !isAnimated(one.o) && [one.s, one.e].every(p => !isAnimated(p) || isNativeFriendly(p))) {
755
+ const sAnimated = isAnimated(one.s), eAnimated = isAnimated(one.e);
756
+ // Native when start never passes end (no swap needed).
757
+ const swaps = (() => { for (let g = ctx.ip; g <= ctx.op; g++) { const f = localFrame(one.tm, g); if (scalarAt(one.s, f) > scalarAt(one.e, f, 0, 100)) return true; } return false; })();
758
+ if (!swaps) {
759
+ if (sAnimated) T.scalar(id, 'style.stroke.trimStart', one.s, 0, v => v / 100, one.tm);
760
+ if (eAnimated) T.scalar(id, 'style.stroke.trimEnd', one.e, 0, v => v / 100, one.tm, 100);
761
+ return;
762
+ }
763
+ }
764
+ T.sample(id, 'style.stroke.trimStart', g => range(g).a, 0.002);
765
+ T.sample(id, 'style.stroke.trimEnd', g => range(g).b, 0.002);
766
+ T.sample(id, 'style.stroke.trimOffset', g => range(g).off, 0.002);
767
+ }
768
+
769
+ // ---------------------------------------------------------------- layers
770
+
771
+ const layerTime = (parent: TimeMap, layer: any, fr: number): TimeMap => {
772
+ const sr = layer.sr || 1, st = layer.st || 0;
773
+ if (layer.tm) {
774
+ // Time remap: the precomp's local time (seconds) is an animated property.
775
+ const tmProp = layer.tm;
776
+ // Like every layer property, tm is keyed in the parent composition's time.
777
+ return { scale: 1, offset: 0, remap: (g: number) => scalarAt(tmProp, localFrame(parent, g)) * fr };
778
+ }
779
+ if (parent.remap) {
780
+ const pr = parent.remap;
781
+ return { scale: 1, offset: 0, remap: (g: number) => (pr(g) - st) / sr };
782
+ }
783
+ return { scale: parent.scale * sr, offset: parent.offset + st * parent.scale };
784
+ };
785
+
786
+ /** Visibility of a layer between its in and out points (in the parent's time). */
787
+ function layerVisibility(ctx: Ctx, T: Tracks, objId: string, layer: any, parentTm: TimeMap, obj: any) {
788
+ const ip = layer.ip ?? -Infinity, op = layer.op ?? Infinity;
789
+ const visibleAt = (g: number) => { const f = localFrame(parentTm, g); return f >= ip && f < op; };
790
+ if (affine(parentTm)) {
791
+ const gIn = ip * parentTm.scale + parentTm.offset, gOut = op * parentTm.scale + parentTm.offset;
792
+ if (gIn <= ctx.ip && gOut >= ctx.op) return;
793
+ const keys: Key[] = [{ time: 0, value: visibleAt(ctx.ip), easing: 'StepEnd' }];
794
+ if (gIn > ctx.ip && gIn < ctx.op) keys.push({ time: T.seconds(gIn), value: true, easing: 'StepEnd' });
795
+ if (gOut > ctx.ip && gOut < ctx.op) keys.push({ time: T.seconds(gOut), value: false, easing: 'StepEnd' });
796
+ obj.isVisible = keys[0].value !== false || keys.length > 1;
797
+ if (keys.length === 1) { obj.isVisible = !!keys[0].value; return; }
798
+ T.raw(objId, 'visible', keys);
799
+ return;
800
+ }
801
+ T.sample(objId, 'visible', g => visibleAt(g));
802
+ }
803
+
804
+ function layerContent(ctx: Ctx, T: Tracks, layer: any, parent: any, tm: TimeMap, scale: ScaleRef[], idBase: string) {
805
+ switch (layer.ty) {
806
+ case 4:
807
+ shapeItems(ctx, T, layer.shapes, { parent, tm, scale, trims: [], idBase });
808
+ break;
809
+ case 1: {
810
+ const w = layer.sw || 0, h = layer.sh || 0;
811
+ const o: any = { id: `${idBase}_solid`, name: layer.nm || 'Solid', type: 'Shape', transform: baseTransform(w / 2, h / 2), geometry: { type: 'Rectangle', width: w, height: h, corner_radius: 0 }, style: { fill: { type: 'Solid', color: colorHex(layer.sc), opacity: 1 }, stroke: { color: '#000000', width: 0, opacity: 0, isEnabled: false } }, isVisible: true };
812
+ attach(ctx, parent, o); ctx.objects.push(o);
813
+ break;
814
+ }
815
+ case 2: {
816
+ const asset = ctx.assets.get(layer.refId);
817
+ if (!asset) { ctx.warnings.add('An image layer references a missing asset'); break; }
818
+ const src = String(asset.p || '').startsWith('data:') ? asset.p : asset.e === 1 ? asset.p : '';
819
+ if (!src) { ctx.warnings.add(`Image "${asset.p}" is not embedded in the JSON; export with images included`); break; }
820
+ const o: any = { id: `${idBase}_img`, name: layer.nm || 'Image', type: 'Shape', transform: baseTransform(asset.w / 2, asset.h / 2), geometry: { type: 'Image', src, originalSrc: src, width: asset.w, height: asset.h }, style: { fill: { type: 'None', opacity: 0 } }, isVisible: true };
821
+ attach(ctx, parent, o); ctx.objects.push(o);
822
+ break;
823
+ }
824
+ case 0: {
825
+ const asset = ctx.assets.get(layer.refId);
826
+ if (!asset?.layers) { ctx.warnings.add('A precomp layer references a missing composition'); break; }
827
+ compLayers(ctx, T, asset.layers, parent, tm, scale, `${idBase}_c`);
828
+ break;
829
+ }
830
+ case 5:
831
+ textLayer(ctx, T, layer, parent, tm, idBase);
832
+ break;
833
+ case 3:
834
+ break; // null: contributes only through parenting
835
+ default:
836
+ ctx.warnings.add(`Layer type ${layer.ty} (camera/light/audio/data) is skipped`);
837
+ }
838
+ }
839
+
840
+ function textLayer(ctx: Ctx, T: Tracks, layer: any, parent: any, tm: TimeMap, idBase: string) {
841
+ const doc = valueAt(layer.t?.d, localFrame(tm, ctx.ip)) ?? layer.t?.d?.k?.[0]?.s;
842
+ const d = Array.isArray(layer.t?.d?.k) ? (layer.t.d.k[0]?.s ?? doc) : doc;
843
+ if (!d) return;
844
+ if (layer.t?.a?.length) ctx.warnings.add('Text animators are not imported');
845
+
846
+ const font = ctx.fonts.get(d.f) || {};
847
+ const family = font.fFamily || d.f || 'Inter';
848
+ const styleName = String(font.fStyle || '');
849
+ const weight = /black|heavy/i.test(styleName) ? 900 : /extra ?bold/i.test(styleName) ? 800 : /semi ?bold/i.test(styleName) ? 600 : /bold/i.test(styleName) ? 700 : /medium/i.test(styleName) ? 500 : /light/i.test(styleName) ? 300 : 400;
850
+ const size = d.s || 16;
851
+ const text = String(d.t || '').replace(/\r/g, '\n');
852
+ const lines = text.split('\n');
853
+ const lh = d.lh || size * 1.2;
854
+ const align = d.j === 1 ? 'right' : d.j === 2 ? 'center' : 'left';
855
+ const est = Math.max(...lines.map(l => l.length)) * size * 0.6 + size;
856
+ const boxed = Array.isArray(d.sz);
857
+ const w = boxed ? d.sz[0] : est;
858
+ const h = boxed ? d.sz[1] : lines.length * lh;
859
+ // Point text: (0,0) is the first baseline at the aligned edge; box text: ps is its top-left.
860
+ let cx: number, cy: number;
861
+ if (boxed) { cx = (d.ps?.[0] || 0) + w / 2; cy = (d.ps?.[1] || 0) + h / 2; }
862
+ else { cx = align === 'left' ? w / 2 : align === 'right' ? -w / 2 : 0; cy = -size * 0.75 + h / 2; }
863
+ const o: any = {
864
+ id: `${idBase}_text`, name: layer.nm || 'Text', type: 'Shape', transform: baseTransform(cx, cy),
865
+ geometry: { type: 'Text', text, fontFamily: family, fontSize: size, fontWeight: weight, fontStyle: /italic/i.test(styleName) ? 'italic' : 'normal', textAlign: align, lineHeight: r3(lh / size), letterSpacing: r3(((d.tr || 0) / 1000) * size), width: r3(w), height: r3(h), isPointText: false, textVerticalAlign: 'top', ...(boxed ? { autoHeight: false } : {}) },
866
+ style: { fill: { type: 'Solid', color: colorHex(d.fc), opacity: 1 }, stroke: d.sc ? { color: colorHex(d.sc), width: d.sw || 1, opacity: 1, isEnabled: !!d.sw } : { color: '#000000', width: 0, opacity: 0, isEnabled: false } },
867
+ isVisible: true,
868
+ };
869
+ attach(ctx, parent, o); ctx.objects.push(o);
870
+ // Keyframed text documents switch content (held, like After Effects).
871
+ const docs = Array.isArray(layer.t?.d?.k) ? layer.t.d.k : [];
872
+ if (docs.length > 1 && docs.every((k: any) => k.s && k.t !== undefined)) {
873
+ T.raw(o.id, 'geometry.text', docs.map((k: any) => ({ time: T.seconds(k.t * tm.scale + tm.offset), value: String(k.s.t || '').replace(/\r/g, '\n'), easing: 'StepEnd' })));
874
+ if (docs.some((k: any) => k.s.fc && colorHex(k.s.fc) !== colorHex(d.fc))) T.raw(o.id, 'style.fill.color', docs.map((k: any) => ({ time: T.seconds(k.t * tm.scale + tm.offset), value: colorHex(k.s.fc), easing: 'StepEnd' })));
875
+ }
876
+ }
877
+
878
+ /** Layers of a composition, drawn bottom (last) to top (first). */
879
+ function compLayers(ctx: Ctx, T: Tracks, layers: any[], parent: any | undefined, tm: TimeMap, scale: ScaleRef[], idBase: string) {
880
+ const byInd = new Map<number, any>();
881
+ layers.forEach(l => { if (l.ind !== undefined) byInd.set(l.ind, l); });
882
+ const mattes = new Map<any, any>(); // matted layer -> matte source layer
883
+ layers.forEach((l, i) => {
884
+ if (!l.tt) return;
885
+ const src = l.tp !== undefined ? byInd.get(l.tp) : layers[i - 1];
886
+ if (src) mattes.set(l, src);
887
+ });
888
+ const isMatteSource = new Set([...mattes.values()]);
889
+
890
+ const build = (l: any, i: number, into: any): any | null => {
891
+ ctx.layers++;
892
+ const lt = layerTime(tm, l, ctx.fr);
893
+ const id = `${idBase}_${l.ind ?? i}`;
894
+ // Parent chain: transform-only copies, outermost first.
895
+ const chain: any[] = [];
896
+ for (let p = l.parent !== undefined ? byInd.get(l.parent) : null, guard = 0; p && guard < 32; p = p.parent !== undefined ? byInd.get(p.parent) : null, guard++) chain.unshift(p);
897
+ let host = into;
898
+ let root: any = null;
899
+ let chainScale = [...scale];
900
+ chain.forEach((p, k) => {
901
+ const pt = layerTime(tm, p, ctx.fr);
902
+ const { outer, inner } = transformGroups(ctx, T, p.ks, `${id}_p${k}`, `${p.nm || 'Parent'} (parent)`, host, tm, false);
903
+ if (!root) root = outer;
904
+ host = inner;
905
+ if (p.ks?.s) chainScale.push({ s: p.ks.s, tm });
906
+ void pt;
907
+ });
908
+ // A layer's own keyframes are in its composition's time (exports bake st in);
909
+ // st / sr only shift the inner time of a precomp (measured against lottie-web).
910
+ const { outer, inner } = transformGroups(ctx, T, l.ks, id, l.nm || `Layer ${i}`, host, tm, true);
911
+ if (!root) root = outer;
912
+ if (l.ks?.s) chainScale.push({ s: l.ks.s, tm });
913
+ if (l.bm) { const modes = ['source-over', 'multiply', 'screen', 'overlay', 'darken', 'lighten', 'color-dodge', 'color-burn', 'hard-light', 'soft-light', 'difference', 'exclusion', 'hue', 'saturation', 'color', 'luminosity']; if (modes[l.bm]) outer.style.compositeOperation = modes[l.bm]; }
914
+ layerVisibility(ctx, T, root.id, l, tm, root);
915
+ layerMasks(ctx, T, l, inner, root, tm, id);
916
+
917
+
918
+ if (l.ao) ctx.warnings.add('Auto-orient is not imported');
919
+ const firstObj = ctx.objects.length;
920
+ layerContent(ctx, T, l, inner, l.ty === 0 ? lt : tm, chainScale, id);
921
+ if (l.ef?.length) layerEffects(ctx, l, ctx.objects.slice(firstObj), tm);
922
+ return root;
923
+ };
924
+
925
+ for (let i = layers.length - 1; i >= 0; i--) {
926
+ const l = layers[i];
927
+ if (l.hd || isMatteSource.has(l) && !mattes.has(l) && false) continue;
928
+ if (isMatteSource.has(l)) continue; // drawn only into the matte of the layer above it
929
+ if (l.hd) continue;
930
+ const matte = mattes.get(l);
931
+ let holder: any = null, matteRoot: any = null;
932
+ if (matte) {
933
+ // Zero-size clipping holder: the matte draws only into the mask buffer.
934
+ holder = group(ctx, `${idBase}_${l.ind ?? i}_matteholder`, `${matte.nm || 'Matte'} (matte)`, undefined);
935
+ holder.width = holder.height = 0.001;
936
+ holder.geometry = { type: 'Rectangle', width: 0.001, height: 0.001, corner_radius: 0 };
937
+ holder.clipContent = true;
938
+ attach(ctx, parent, holder);
939
+ matteRoot = build(matte, layers.indexOf(matte), holder);
940
+ }
941
+ const root = build(l, i, parent);
942
+ if (matte && matteRoot && root) {
943
+ const inverted = l.tt === 2 || l.tt === 4;
944
+ root.masks = [{ id: `${root.id}_matte`, maskId: matteRoot.id, type: l.tt >= 3 ? 'luma' : 'alpha', inverted, isVisible: true }];
945
+ if (inverted) ctx.warnings.add('Inverted track mattes depend on runtime support');
946
+ }
947
+ }
948
+ }
949
+
950
+ const efValue = (ef: any, i: number, f: number) => valueAt(ef?.ef?.[i]?.v, f);
951
+
952
+ /** Fill and Drop Shadow layer effects, applied to the layer's shapes. */
953
+ function layerEffects(ctx: Ctx, layer: any, objs: any[], tm: TimeMap) {
954
+ const f0 = localFrame(tm, ctx.ip);
955
+ const shapes = objs.filter(o => o.type === 'Shape' && o.style && !o.geometry?.isMask);
956
+ for (const ef of layer.ef) {
957
+ if (ef.en === 0) continue;
958
+ if (ef.ty === 21) {
959
+ // Fill: every fill and stroke takes the effect colour.
960
+ const c = efValue(ef, 2, f0);
961
+ const op = Number(efValue(ef, 6, f0) ?? 1);
962
+ const color = colorHex(c);
963
+ for (const o of shapes) {
964
+ if (o.style.fill?.type === 'Solid' && o.style.fill.opacity > 0) o.style.fill = { ...o.style.fill, color, opacity: r3(o.style.fill.opacity * op) };
965
+ if (o.style.stroke?.isEnabled) o.style.stroke = { ...o.style.stroke, color, opacity: r3((o.style.stroke.opacity ?? 1) * op) };
966
+ ctx.tracks = ctx.tracks.filter(t => !(t.object_id === o.id && (t.property === 'style.fill.color' || t.property === 'style.stroke.color')));
967
+ }
968
+ if ((ef.ef || []).some((e: any) => isAnimated(e.v))) ctx.warnings.add('Animated Fill effects are imported at their first frame');
969
+ } else if (ef.ty === 25) {
970
+ // Drop Shadow: colour, opacity (0-255), direction (deg, 0 = up), distance, softness.
971
+ const c = efValue(ef, 0, f0), a = Number(efValue(ef, 1, f0) ?? 128) / 255;
972
+ const dir = Number(efValue(ef, 2, f0) ?? 135) * Math.PI / 180, dist = Number(efValue(ef, 3, f0) ?? 5), soft = Number(efValue(ef, 4, f0) ?? 0);
973
+ const shadow = { color: colorHex(c), opacity: r3(Math.min(1, a / 0.75)), blur: r2(soft), offsetX: r2(Math.sin(dir) * dist), offsetY: r2(-Math.cos(dir) * dist), spread: 0, isEnabled: true };
974
+ for (const o of shapes) o.style.shadow = shadow;
975
+ if (shapes.length > 1) ctx.warnings.add('Drop shadows are applied per shape (a layer-wide shadow can double up where shapes overlap)');
976
+ } else {
977
+ ctx.warnings.add(`Layer effect "${ef.nm || ef.ty}" is not imported`);
978
+ }
979
+ }
980
+ }
981
+
982
+ function maskShape(ctx: Ctx, T: Tracks, masks: any[], tm: TimeMap, id: string, name: string, clip: boolean): any | null {
983
+ const f0 = localFrame(tm, ctx.ip);
984
+ const shapes = masks.map((m: any) => ({ ty: 'sh', ks: m.pt }));
985
+ const pts = flatten(contoursAt(shapes, f0, false));
986
+ if (!pts.length) return null;
987
+ const b = bbox(pts);
988
+ const o: any = {
989
+ id, name, type: 'Shape', transform: baseTransform(),
990
+ geometry: { type: 'Path', points: pts, is_closed: true, width: r3(b.w), height: r3(b.h), baseWidth: r3(b.w), baseHeight: r3(b.h), ...(clip ? { isMask: true } : {}) },
991
+ // A clip mask is never painted; an alpha-mask source must be opaque.
992
+ style: { fill: { type: 'Solid', color: '#000000', opacity: clip ? 0 : 1 }, stroke: { color: '#000000', width: 0, opacity: 0, isEnabled: false } },
993
+ isVisible: true,
994
+ };
995
+ if (masks.some((m: any) => isAnimated(m.pt))) {
996
+ const per: Pt[][] = [];
997
+ for (let g = ctx.ip; g <= ctx.op; g++) per.push(flatten(contoursAt(shapes, localFrame(tm, g), false)));
998
+ if (per.every(p => p.length === pts.length)) {
999
+ (['x', 'y', 'in_x', 'in_y', 'out_x', 'out_y'] as (keyof Pt)[]).forEach(fd => pts.forEach((_, j) => {
1000
+ const series = per.map(p => p[j][fd] as number);
1001
+ if (series.every(v => Math.abs(v - series[0]) < 1e-6)) return;
1002
+ T.sample(id, `geometry.points.${j}.${fd}`, g => series[Math.max(0, Math.min(series.length - 1, Math.round(g - ctx.ip)))], 0.05);
1003
+ }));
1004
+ } else ctx.warnings.add('A mask changes its vertex count while animating; it is imported at its first frame');
1005
+ } else o.geometry.data = ptsToData(pts);
1006
+ return o;
1007
+ }
1008
+
1009
+ /**
1010
+ * Layer masks. Add masks (unioned) and each intersect mask become clip children;
1011
+ * subtract and inverted masks become one inverted alpha mask on the layer.
1012
+ */
1013
+ function layerMasks(ctx: Ctx, T: Tracks, layer: any, inner: any, root: any, tm: TimeMap, id: string) {
1014
+ const masks = (layer.masksProperties || []).filter((m: any) => m.mode !== 'n' && m.mode !== undefined || m.mode === undefined);
1015
+ if (!masks.length) return;
1016
+ // Each mask either keeps its inside (add, intersect, inverted subtract) or
1017
+ // cuts it out (subtract, inverted add / intersect).
1018
+ const additive = (m: any) => !m.mode || ['a', 'l', 'd', 'f'].includes(m.mode);
1019
+ const adds = masks.filter((m: any) => additive(m) && !m.inv);
1020
+ const intersects = masks.filter((m: any) => (m.mode === 'i' && !m.inv) || (m.mode === 's' && m.inv));
1021
+ const cuts = masks.filter((m: any) => (m.mode === 's' && !m.inv) || ((additive(m) || m.mode === 'i') && m.inv));
1022
+ if (masks.some((m: any) => ['l', 'd', 'f'].includes(m.mode))) ctx.warnings.add('Lighten, darken and difference masks are imported as add masks');
1023
+ const clips: any[][] = [];
1024
+ if (adds.length) clips.push(adds);
1025
+ intersects.forEach((m: any) => clips.push([m]));
1026
+ clips.forEach((group, k) => {
1027
+ const o = maskShape(ctx, T, group, tm, `${id}_mask${k}`, 'Mask', true);
1028
+ if (o) { attach(ctx, inner, o); ctx.objects.push(o); }
1029
+ });
1030
+ if (cuts.length) {
1031
+ const holder = group(ctx, `${id}_cutholder`, 'Subtract mask', undefined);
1032
+ holder.width = holder.height = 0.001;
1033
+ holder.geometry = { type: 'Rectangle', width: 0.001, height: 0.001, corner_radius: 0 };
1034
+ holder.clipContent = true;
1035
+ attach(ctx, inner, holder);
1036
+ // A subtract mask removes its area from the layer (or from everything when alone).
1037
+ const o = maskShape(ctx, T, cuts, tm, `${id}_cut`, 'Subtract mask', false);
1038
+ if (o) {
1039
+ attach(ctx, holder, o); ctx.objects.push(o);
1040
+ root.masks = [...(root.masks || []), { id: `${id}_cutmask`, maskId: o.id, type: 'alpha', inverted: true, isVisible: true }];
1041
+ }
1042
+ }
1043
+ if (masks.some((m: any) => m.x && (isAnimated(m.x) || scalarAt(m.x, 0)))) ctx.warnings.add('Mask expansion is not imported');
1044
+ }
1045
+
1046
+ /**
1047
+ * The editor's design view draws objects under 50% opacity at 50% ("ghost
1048
+ * reveal"), so layers that wait at opacity 0 pile up there. Such objects
1049
+ * start hidden instead, with a visibility track that is on only while their
1050
+ * opacity is above zero; the runtime result is identical.
1051
+ */
1052
+ function hideWhileTransparent(ctx: Ctx) {
1053
+ const byObj = new Map<string, any[]>();
1054
+ ctx.tracks.forEach(t => { const l = byObj.get(t.object_id) || []; l.push(t); byObj.set(t.object_id, l); });
1055
+ const sec = (g: number) => (g - ctx.ip) / ctx.fr;
1056
+ const valueAt = (keys: any[], t: number) => {
1057
+ if (t <= keys[0].time) return Number(keys[0].value);
1058
+ for (let i = 0; i < keys.length - 1; i++) {
1059
+ const a = keys[i], b = keys[i + 1];
1060
+ if (t < b.time) {
1061
+ if (a.easing === 'StepEnd') return Number(a.value);
1062
+ return Number(a.value) + (Number(b.value) - Number(a.value)) * ((t - a.time) / ((b.time - a.time) || 1));
1063
+ }
1064
+ }
1065
+ return Number(keys[keys.length - 1].value);
1066
+ };
1067
+ for (const o of ctx.objects) {
1068
+ const tr = byObj.get(o.id) || [];
1069
+ const op = tr.find(t => t.property === 'opacity');
1070
+ if (tr.some(t => t.property === 'visible')) continue;
1071
+ if (!op) {
1072
+ if (o.transform?.opacity === 0 && o.type !== 'Group') o.isVisible = false;
1073
+ continue;
1074
+ }
1075
+ const keys = op.keyframes;
1076
+ // Visible exactly while opacity > 0 (checked on every frame, plus key times).
1077
+ const times = new Set<number>();
1078
+ for (let g = ctx.ip; g <= ctx.op; g++) times.add(sec(g));
1079
+ keys.forEach((k: any) => times.add(k.time));
1080
+ const sorted = [...times].sort((a, b) => a - b);
1081
+ const vis: { time: number; value: boolean; easing: string }[] = [];
1082
+ for (const t of sorted) {
1083
+ const on = valueAt(keys, t) > 1e-4 || (keys.some((k: any) => Math.abs(k.time - t) < 1e-6 && Number(k.value) > 1e-4));
1084
+ if (!vis.length || vis[vis.length - 1].value !== on) vis.push({ time: Math.round(t * 1000) / 1000, value: on, easing: 'StepEnd' });
1085
+ }
1086
+ if (vis.length && vis[0].value) continue; // visible at the start: nothing to hide in the editor
1087
+ o.isVisible = false;
1088
+ if (vis.length > 1) ctx.tracks.push({ id: `t_${o.id}_visible_fade`, object_id: o.id, property: 'visible', keyframes: vis.map((k, i) => ({ id: `k_${o.id}_visfade_${i}`, ...k })) });
1089
+ }
1090
+ }
1091
+
1092
+ // ---------------------------------------------------------------- entry
1093
+
1094
+ export function convertLottie(json: any, opts: { name?: string; background?: string } = {}): LottieConvertResult {
1095
+ const fr = json.fr || 30, ip = json.ip || 0, op = json.op ?? ip + fr * 3;
1096
+ const ctx: Ctx = {
1097
+ fr, ip, op, objects: [], tracks: [], warnings: new Set(),
1098
+ assets: new Map((json.assets || []).map((a: any) => [a.id, a])),
1099
+ fonts: new Map((json.fonts?.list || []).map((f: any) => [f.fName, f])),
1100
+ sampled: 0, layers: 0,
1101
+ };
1102
+ const T = new Tracks(ctx);
1103
+ if (json.chars?.length) ctx.warnings.add('Text drawn with embedded glyph shapes is imported as live text');
1104
+ if (JSON.stringify(json).includes('"x":"')) ctx.warnings.add('Expressions are not evaluated; their keyframed values are used');
1105
+ compLayers(ctx, T, json.layers || [], undefined, ROOT_TIME, [], 'lt');
1106
+
1107
+ hideWhileTransparent(ctx);
1108
+ // Explicit stacking, unique across the file in draw order (depth first); the
1109
+ // runtime sorts each Group's children by zIndex, which keeps sibling order.
1110
+ const byId = new Map(ctx.objects.map(o => [o.id, o]));
1111
+ let z = 0;
1112
+ const stack = (o: any) => { o.zIndex = z++; (o.children || []).forEach((cid: string) => { const c = byId.get(cid); if (c) stack(c); }); };
1113
+ ctx.objects.filter(o => !o.parentId).forEach(stack);
1114
+ const duration = r3((op - ip) / fr);
1115
+ const animId = 'anim_lottie_main';
1116
+ const keyframes = ctx.tracks.reduce((n, t) => n + t.keyframes.length, 0);
1117
+ const artboard = {
1118
+ id: `artboard_lottie_${String(json.nm || 'animation').replace(/[^A-Za-z0-9]+/g, '_')}`,
1119
+ name: opts.name || json.nm || 'Lottie animation',
1120
+ width: json.w || 512, height: json.h || 512,
1121
+ cornerRadius: 0, fit: 'Contain',
1122
+ backgroundColor: opts.background ?? 'transparent',
1123
+ opacity: 1,
1124
+ objects: ctx.objects,
1125
+ animations: [{ id: animId, name: json.nm || 'Main', duration, loop: true, tracks: ctx.tracks }],
1126
+ navigation: [], animationFlow: [], stateVariables: [],
1127
+ stateMachine: {
1128
+ inputs: [],
1129
+ layers: [{ id: 'layer_lottie', name: 'Timeline', entryStateId: 'state_lottie_play', states: [{ id: 'state_lottie_play', name: 'Play', animationId: animId, loop: true, transitions: [] }] }],
1130
+ },
1131
+ source: { kind: 'lottie', version: json.v, fps: fr, frames: op - ip },
1132
+ };
1133
+ return { artboard, warnings: [...ctx.warnings], stats: { layers: ctx.layers, objects: ctx.objects.length, tracks: ctx.tracks.length, keyframes, sampledTracks: ctx.sampled } };
1134
+ }