@rafaylabs/animation-mcp 0.0.0-stage → 1.0.0

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 (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +111 -2
  3. package/bin/cli.js +50 -0
  4. package/dist/api/catalog-client.d.ts +27 -0
  5. package/dist/api/catalog-client.js +67 -0
  6. package/dist/api/server.d.ts +29 -0
  7. package/dist/api/server.js +55 -0
  8. package/dist/auth/cli-login.d.ts +3 -0
  9. package/dist/auth/cli-login.js +121 -0
  10. package/dist/auth/client.d.ts +45 -0
  11. package/dist/auth/client.js +170 -0
  12. package/dist/auth/config.d.ts +36 -0
  13. package/dist/auth/config.js +106 -0
  14. package/dist/auth/device-login.d.ts +2 -0
  15. package/dist/auth/device-login.js +63 -0
  16. package/dist/auth/product.d.ts +17 -0
  17. package/dist/auth/product.js +9 -0
  18. package/dist/index.d.ts +4 -0
  19. package/dist/index.js +262 -0
  20. package/dist/theming/classifier.d.ts +16 -0
  21. package/dist/theming/classifier.js +74 -0
  22. package/dist/theming/color-walker.d.ts +19 -0
  23. package/dist/theming/color-walker.js +303 -0
  24. package/dist/theming/engine.d.ts +29 -0
  25. package/dist/theming/engine.js +144 -0
  26. package/dist/theming/oklch.d.ts +63 -0
  27. package/dist/theming/oklch.js +147 -0
  28. package/dist/tools/code.d.ts +20 -0
  29. package/dist/tools/code.js +104 -0
  30. package/dist/tools/get.d.ts +13 -0
  31. package/dist/tools/get.js +10 -0
  32. package/dist/tools/save.d.ts +22 -0
  33. package/dist/tools/save.js +78 -0
  34. package/dist/tools/schemas.d.ts +137 -0
  35. package/dist/tools/schemas.js +50 -0
  36. package/dist/tools/search.d.ts +29 -0
  37. package/dist/tools/search.js +22 -0
  38. package/dist/tools/theme.d.ts +28 -0
  39. package/dist/tools/theme.js +66 -0
  40. package/dist/utils/lottie-file.d.ts +5 -0
  41. package/dist/utils/lottie-file.js +47 -0
  42. package/dist/utils/workspace.d.ts +20 -0
  43. package/dist/utils/workspace.js +116 -0
  44. package/package.json +56 -4
package/dist/index.js ADDED
@@ -0,0 +1,262 @@
1
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
4
+ import { z } from 'zod';
5
+ import { handleSearchAnimations } from './tools/search.js';
6
+ import { handleGetAnimation } from './tools/get.js';
7
+ import { executeApplyTheme } from './tools/theme.js';
8
+ import { saveAnimationToProject } from './tools/save.js';
9
+ import { generateFrameworkCode } from './tools/code.js';
10
+ import { SearchAnimationsArgsSchema, GetAnimationArgsSchema, ApplyThemeArgsSchema, SaveAnimationArgsSchema, GetCodeArgsSchema, } from './tools/schemas.js';
11
+ /**
12
+ * Creates and initializes the Animation MCP Server
13
+ */
14
+ export async function startMcpServer() {
15
+ const server = new Server({
16
+ name: 'rafaylabs-animation-mcp',
17
+ version: '1.0.0',
18
+ }, {
19
+ capabilities: {
20
+ tools: {},
21
+ },
22
+ });
23
+ // 1. List Available Tools
24
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
25
+ return {
26
+ tools: [
27
+ {
28
+ name: 'search_animations',
29
+ description: 'Search the RafayLabs catalogue of 11,000+ production Lottie animations using plain language (e.g. "success checkmark with confetti", "pulsing order delivery truck", "loading skeleton shimmer"). Returns short structured JSON with preview URLs, style, size, and category. Never returns raw Lottie JSON to save context window.',
30
+ inputSchema: {
31
+ type: 'object',
32
+ properties: {
33
+ query: {
34
+ type: 'string',
35
+ description: 'Search keywords in plain words (e.g. "checkmark", "loading spinner", "checkout success")',
36
+ },
37
+ style: {
38
+ type: 'string',
39
+ description: 'Optional visual style: flat, 3d, minimal, gradient, outline',
40
+ },
41
+ category: {
42
+ type: 'string',
43
+ description: 'Optional category: success, loading, error, empty state, onboarding, ecommerce',
44
+ },
45
+ loop: {
46
+ type: 'boolean',
47
+ description: 'Whether the animation should continuously loop',
48
+ },
49
+ max_size_kb: {
50
+ type: 'number',
51
+ description: 'Maximum file size in KB',
52
+ },
53
+ limit: {
54
+ type: 'number',
55
+ description: 'Maximum number of results to return (default 5)',
56
+ default: 5,
57
+ },
58
+ },
59
+ required: ['query'],
60
+ },
61
+ },
62
+ {
63
+ name: 'get_animation',
64
+ description: 'Fetch the complete original Lottie JSON and metadata for a specific animation ID found via search_animations.',
65
+ inputSchema: {
66
+ type: 'object',
67
+ properties: {
68
+ id: {
69
+ type: 'string',
70
+ description: 'The animation ID (e.g. "success-checkmark" or "pulse-loader")',
71
+ },
72
+ },
73
+ required: ['id'],
74
+ },
75
+ },
76
+ {
77
+ name: 'apply_theme',
78
+ description: 'Automatically recolor any Lottie animation into the user brand colors without breaking shadows, highlights, or contrast. Writes the themed file directly to output_path if provided, avoiding dumping large JSON into AI context.',
79
+ inputSchema: {
80
+ type: 'object',
81
+ properties: {
82
+ id: {
83
+ type: 'string',
84
+ description: 'Optional catalogue animation ID',
85
+ },
86
+ file_path: {
87
+ type: 'string',
88
+ description: 'Optional path to a local .json or .lottie file in the user project to recolor',
89
+ },
90
+ lottie_json: {
91
+ type: 'object',
92
+ description: 'Optional direct Lottie JSON object to recolor',
93
+ },
94
+ output_path: {
95
+ type: 'string',
96
+ description: 'Recommended destination path in your project (e.g. "assets/hero.json" or "assets/hero.lottie"). Saves directly to file without polluting AI context.',
97
+ },
98
+ format: {
99
+ type: 'string',
100
+ enum: ['json', 'lottie'],
101
+ description: 'File format when saving: "json" (standard) or "lottie" (dotLottie compressed zip). Inferred from output_path extension if omitted.',
102
+ },
103
+ overwrite: {
104
+ type: 'boolean',
105
+ description: 'Whether to overwrite existing file if output_path exists (default: false)',
106
+ },
107
+ theme: {
108
+ type: 'object',
109
+ description: 'Target theme hex colors',
110
+ properties: {
111
+ primary: {
112
+ type: 'string',
113
+ description: 'Primary brand hex color (e.g. "#0476FF")',
114
+ },
115
+ secondary: {
116
+ type: 'string',
117
+ description: 'Optional secondary brand hex color (e.g. "#00C2FF")',
118
+ },
119
+ background: {
120
+ type: 'string',
121
+ description: 'Optional background hex color (e.g. "#FFFFFF" or "#010712")',
122
+ },
123
+ accent: {
124
+ type: 'string',
125
+ description: 'Optional accent highlight hex color (e.g. "#10B981")',
126
+ },
127
+ },
128
+ required: ['primary'],
129
+ },
130
+ mode: {
131
+ type: 'string',
132
+ enum: ['light', 'dark'],
133
+ description: 'Optional light or dark mode palette adjustment',
134
+ },
135
+ },
136
+ required: ['theme'],
137
+ },
138
+ },
139
+ {
140
+ name: 'save_animation',
141
+ description: 'Save an existing Lottie JSON animation directly to a file path inside the user project (e.g. "assets/animations/checkout-success.json" or ".lottie"). Creates parent directories automatically.',
142
+ inputSchema: {
143
+ type: 'object',
144
+ properties: {
145
+ themed_json: {
146
+ type: 'object',
147
+ description: 'The Lottie JSON object to save',
148
+ },
149
+ path: {
150
+ type: 'string',
151
+ description: 'Relative or absolute project destination path (e.g. "assets/animations/success.json")',
152
+ },
153
+ format: {
154
+ type: 'string',
155
+ enum: ['json', 'lottie'],
156
+ description: 'File format: "json" or "lottie" (dotLottie v1 zip). Default: "json"',
157
+ default: 'json',
158
+ },
159
+ overwrite: {
160
+ type: 'boolean',
161
+ description: 'Whether to overwrite an existing file (default: false)',
162
+ default: false,
163
+ },
164
+ },
165
+ required: ['themed_json', 'path'],
166
+ },
167
+ },
168
+ {
169
+ name: 'get_code',
170
+ description: 'Generate clean, production-ready framework component code and package install instructions for displaying the animation in React, React Native (Expo), Next.js, or plain HTML.',
171
+ inputSchema: {
172
+ type: 'object',
173
+ properties: {
174
+ framework: {
175
+ type: 'string',
176
+ enum: ['react', 'react-native', 'nextjs', 'html'],
177
+ description: 'Target frontend framework',
178
+ },
179
+ file_path: {
180
+ type: 'string',
181
+ description: 'Relative path where the animation was saved (e.g. "assets/animations/success.json")',
182
+ },
183
+ options: {
184
+ type: 'object',
185
+ description: 'Optional display options',
186
+ properties: {
187
+ loop: { type: 'boolean', description: 'Enable looping (default: true)' },
188
+ autoplay: { type: 'boolean', description: 'Enable autoplay (default: true)' },
189
+ size: { type: 'number', description: 'Width and height in px (default: 200)' },
190
+ componentName: { type: 'string', description: 'React component name' },
191
+ },
192
+ },
193
+ },
194
+ required: ['framework', 'file_path'],
195
+ },
196
+ },
197
+ ],
198
+ };
199
+ });
200
+ // 2. Call Tool Handlers
201
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
202
+ const { name, arguments: rawArgs } = request.params;
203
+ try {
204
+ switch (name) {
205
+ case 'search_animations': {
206
+ const validated = SearchAnimationsArgsSchema.parse(rawArgs);
207
+ const result = await handleSearchAnimations(validated);
208
+ return {
209
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
210
+ };
211
+ }
212
+ case 'get_animation': {
213
+ const validated = GetAnimationArgsSchema.parse(rawArgs);
214
+ const result = await handleGetAnimation(validated);
215
+ return {
216
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
217
+ };
218
+ }
219
+ case 'apply_theme': {
220
+ const validated = ApplyThemeArgsSchema.parse(rawArgs);
221
+ const result = await executeApplyTheme(validated, server);
222
+ return {
223
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
224
+ };
225
+ }
226
+ case 'save_animation': {
227
+ const validated = SaveAnimationArgsSchema.parse(rawArgs);
228
+ const result = await saveAnimationToProject(validated, server);
229
+ return {
230
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
231
+ };
232
+ }
233
+ case 'get_code': {
234
+ const validated = GetCodeArgsSchema.parse(rawArgs);
235
+ const result = generateFrameworkCode(validated.framework, validated.file_path, validated.options);
236
+ return {
237
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
238
+ };
239
+ }
240
+ default:
241
+ throw new Error(`Unknown MCP tool name: ${name}`);
242
+ }
243
+ }
244
+ catch (err) {
245
+ const errorMessage = err instanceof z.ZodError
246
+ ? `Invalid arguments for tool ${name}: ${err.errors.map((e) => `${e.path.join('.') || 'param'}: ${e.message}`).join('; ')}`
247
+ : err.message || 'Tool execution failed';
248
+ return {
249
+ isError: true,
250
+ content: [
251
+ {
252
+ type: 'text',
253
+ text: JSON.stringify({ error: errorMessage }, null, 2),
254
+ },
255
+ ],
256
+ };
257
+ }
258
+ });
259
+ // Connect stdio transport
260
+ const transport = new StdioServerTransport();
261
+ await server.connect(transport);
262
+ }
@@ -0,0 +1,16 @@
1
+ import { RGB, OKLCH } from './oklch.js';
2
+ import { ColorOccurrence } from './color-walker.js';
3
+ export type ColorRole = 'primary' | 'secondary' | 'background' | 'accent' | 'neutral';
4
+ export interface ColorCluster {
5
+ id: string;
6
+ representativeRgb: RGB;
7
+ representativeLch: OKLCH;
8
+ occurrences: ColorOccurrence[];
9
+ weight: number;
10
+ isNeutral: boolean;
11
+ assignedRole?: ColorRole;
12
+ }
13
+ /**
14
+ * Groups colors into perceptual clusters and assigns design roles (primary, secondary, background, etc.)
15
+ */
16
+ export declare function clusterAndClassifyColors(occurrences: ColorOccurrence[]): ColorCluster[];
@@ -0,0 +1,74 @@
1
+ import { rgbToOklch, oklabDistance, isNeutral } from './oklch.js';
2
+ const MERGE_DISTANCE_THRESHOLD = 0.045; // in OKLAB space
3
+ /**
4
+ * Groups colors into perceptual clusters and assigns design roles (primary, secondary, background, etc.)
5
+ */
6
+ export function clusterAndClassifyColors(occurrences) {
7
+ const clusters = [];
8
+ for (const occ of occurrences) {
9
+ const lch = rgbToOklch(occ.color);
10
+ let matchedCluster = null;
11
+ // Check if this color fits an existing cluster
12
+ for (const cluster of clusters) {
13
+ const dist = oklabDistance(occ.color, cluster.representativeRgb);
14
+ if (dist < MERGE_DISTANCE_THRESHOLD) {
15
+ matchedCluster = cluster;
16
+ break;
17
+ }
18
+ }
19
+ if (matchedCluster) {
20
+ matchedCluster.occurrences.push(occ);
21
+ matchedCluster.weight += 1;
22
+ }
23
+ else {
24
+ clusters.push({
25
+ id: `cluster_${clusters.length + 1}`,
26
+ representativeRgb: occ.color,
27
+ representativeLch: lch,
28
+ occurrences: [occ],
29
+ weight: 1,
30
+ isNeutral: isNeutral(lch),
31
+ });
32
+ }
33
+ }
34
+ // Sort by weight descending
35
+ clusters.sort((a, b) => b.weight - a.weight);
36
+ // Assign roles
37
+ assignRoles(clusters);
38
+ return clusters;
39
+ }
40
+ function assignRoles(clusters) {
41
+ // 1. Separate neutrals vs chromatic (saturated) colors
42
+ const neutrals = clusters.filter((c) => c.isNeutral);
43
+ const saturated = clusters.filter((c) => !c.isNeutral);
44
+ // 2. Identify potential background:
45
+ // A colour can ONLY be background when it fills the frame (a solid layer, or a rectangle covering >= 80% of w × h).
46
+ // Eye whites, teeth, highlights or character neutrals do NOT fill the frame and must stay neutral!
47
+ let backgroundCluster = null;
48
+ const frameFillingClusters = clusters.filter((c) => c.occurrences.some((occ) => occ.isBackgroundCandidate === true));
49
+ if (frameFillingClusters.length > 0) {
50
+ // Pick the largest frame-filling neutral first, or failing that, the largest frame-filling cluster
51
+ const neutralBgs = frameFillingClusters.filter((c) => c.isNeutral);
52
+ backgroundCluster = neutralBgs.length > 0 ? neutralBgs[0] : frameFillingClusters[0];
53
+ backgroundCluster.assignedRole = 'background';
54
+ }
55
+ // 3. Assign Saturated Colors (Primary, Secondary, Accent)
56
+ // Exclude backgroundCluster if it was saturated
57
+ const availableSaturated = saturated.filter((c) => c !== backgroundCluster);
58
+ availableSaturated.sort((a, b) => b.weight - a.weight);
59
+ if (availableSaturated.length > 0) {
60
+ availableSaturated[0].assignedRole = 'primary';
61
+ }
62
+ if (availableSaturated.length > 1) {
63
+ availableSaturated[1].assignedRole = 'secondary';
64
+ }
65
+ for (let i = 2; i < availableSaturated.length; i++) {
66
+ availableSaturated[i].assignedRole = 'accent';
67
+ }
68
+ // 4. Remaining neutrals remain neutral (preserved, never inverted into dark background)
69
+ for (const n of neutrals) {
70
+ if (!n.assignedRole) {
71
+ n.assignedRole = 'neutral';
72
+ }
73
+ }
74
+ }
@@ -0,0 +1,19 @@
1
+ import { RGB } from './oklch.js';
2
+ export interface ColorOccurrence {
3
+ path: (string | number)[];
4
+ type: 'fill' | 'stroke' | 'gradient_stop' | 'solid_layer';
5
+ color: RGB;
6
+ originalLength: number;
7
+ isKeyframed: boolean;
8
+ keyframeIndex?: number;
9
+ gradientIndex?: number;
10
+ isBackgroundCandidate?: boolean;
11
+ }
12
+ /**
13
+ * Traverses a Lottie JSON tree and extracts all color references with their exact paths.
14
+ */
15
+ export declare function walkLottieColors(lottie: Record<string, any>): ColorOccurrence[];
16
+ /**
17
+ * Sets a value at a nested path inside a clone of the Lottie JSON
18
+ */
19
+ export declare function setNestedValue(obj: any, path: (string | number)[], value: any): void;
@@ -0,0 +1,303 @@
1
+ import { hexToRgb } from './oklch.js';
2
+ /**
3
+ * Traverses a Lottie JSON tree and extracts all color references with their exact paths.
4
+ */
5
+ export function walkLottieColors(lottie) {
6
+ const occurrences = [];
7
+ const width = lottie.w || 500;
8
+ const height = lottie.h || 500;
9
+ // 1. Process layers in root
10
+ if (Array.isArray(lottie.layers)) {
11
+ walkLayers(lottie.layers, ['layers'], occurrences, width, height);
12
+ }
13
+ // 2. Process layers inside precomps in assets[]
14
+ if (Array.isArray(lottie.assets)) {
15
+ lottie.assets.forEach((asset, assetIdx) => {
16
+ if (asset && Array.isArray(asset.layers)) {
17
+ walkLayers(asset.layers, ['assets', assetIdx, 'layers'], occurrences, asset.w || width, asset.h || height);
18
+ }
19
+ });
20
+ }
21
+ return occurrences;
22
+ }
23
+ function walkLayers(layers, basePath, occurrences, docWidth, docHeight) {
24
+ layers.forEach((layer, layerIdx) => {
25
+ if (!layer)
26
+ return;
27
+ const layerPath = [...basePath, layerIdx];
28
+ // Solid layer color: layer.sc (Hex string)
29
+ if (layer.ty === 1 && typeof layer.sc === 'string') {
30
+ const sw = layer.sw || 0;
31
+ const sh = layer.sh || 0;
32
+ const isFullFrame = sw >= docWidth * 0.8 && sh >= docHeight * 0.8;
33
+ occurrences.push({
34
+ path: [...layerPath, 'sc'],
35
+ type: 'solid_layer',
36
+ color: hexToRgb(layer.sc),
37
+ originalLength: 1,
38
+ isKeyframed: false,
39
+ isBackgroundCandidate: isFullFrame,
40
+ });
41
+ }
42
+ // Text layer (ty: 5): colors in t.d.k[].s.fc (fill) and sc (stroke)
43
+ if (layer.ty === 5 && layer.t?.d) {
44
+ walkTextLayer(layer.t.d, [...layerPath, 't', 'd'], occurrences);
45
+ }
46
+ // Layer Effects (ef): Fill (ty: 21) or Tint (ty: 20)
47
+ if (Array.isArray(layer.ef)) {
48
+ walkLayerEffects(layer.ef, [...layerPath, 'ef'], occurrences);
49
+ }
50
+ // Shape layer (ty: 4) shapes array
51
+ if (Array.isArray(layer.shapes)) {
52
+ walkShapes(layer.shapes, [...layerPath, 'shapes'], occurrences, docWidth, docHeight);
53
+ }
54
+ });
55
+ }
56
+ function walkTextLayer(tDoc, basePath, occurrences) {
57
+ if (!tDoc)
58
+ return;
59
+ // Keyframed text document
60
+ if (Array.isArray(tDoc.k)) {
61
+ tDoc.k.forEach((item, idx) => {
62
+ if (!item)
63
+ return;
64
+ const docData = item.s || item;
65
+ extractTextDocColors(docData, [...basePath, 'k', idx, item.s ? 's' : ''], occurrences);
66
+ });
67
+ return;
68
+ }
69
+ // Static text document
70
+ if (tDoc.k && typeof tDoc.k === 'object') {
71
+ const docData = tDoc.k.s || tDoc.k;
72
+ extractTextDocColors(docData, [...basePath, 'k', tDoc.k.s ? 's' : ''], occurrences);
73
+ }
74
+ }
75
+ function extractTextDocColors(docData, basePath, occurrences) {
76
+ if (!docData)
77
+ return;
78
+ const cleanPath = basePath.filter((p) => p !== '');
79
+ // Fill color (fc): [r, g, b, (a)]
80
+ if (Array.isArray(docData.fc) && typeof docData.fc[0] === 'number') {
81
+ occurrences.push({
82
+ path: [...cleanPath, 'fc'],
83
+ type: 'fill',
84
+ color: {
85
+ r: docData.fc[0],
86
+ g: docData.fc[1],
87
+ b: docData.fc[2],
88
+ a: docData.fc.length > 3 ? docData.fc[3] : 1,
89
+ },
90
+ originalLength: docData.fc.length,
91
+ isKeyframed: false,
92
+ });
93
+ }
94
+ // Stroke color (sc): [r, g, b, (a)]
95
+ if (Array.isArray(docData.sc) && typeof docData.sc[0] === 'number') {
96
+ occurrences.push({
97
+ path: [...cleanPath, 'sc'],
98
+ type: 'stroke',
99
+ color: {
100
+ r: docData.sc[0],
101
+ g: docData.sc[1],
102
+ b: docData.sc[2],
103
+ a: docData.sc.length > 3 ? docData.sc[3] : 1,
104
+ },
105
+ originalLength: docData.sc.length,
106
+ isKeyframed: false,
107
+ });
108
+ }
109
+ }
110
+ function walkLayerEffects(effects, basePath, occurrences) {
111
+ effects.forEach((effect, efIdx) => {
112
+ if (!effect)
113
+ return;
114
+ const efPath = [...basePath, efIdx];
115
+ // After Effects Fill Effect (ty: 21) or Tint Effect (ty: 20)
116
+ if (Array.isArray(effect.ef)) {
117
+ effect.ef.forEach((prop, propIdx) => {
118
+ if (!prop)
119
+ return;
120
+ const propPath = [...efPath, 'ef', propIdx];
121
+ // Property value containing color (ty: 2 indicates color property)
122
+ if (prop.v && prop.v.k !== undefined) {
123
+ extractColorProperty(prop.v, [...propPath, 'v'], 'fill', occurrences);
124
+ }
125
+ });
126
+ }
127
+ });
128
+ }
129
+ function walkShapes(shapes, basePath, occurrences, docWidth, docHeight) {
130
+ // Check if this shapes group contains a rectangle covering the frame
131
+ let hasFullFrameRect = false;
132
+ for (const s of shapes) {
133
+ if (s && s.ty === 'rc' && s.s?.k) {
134
+ const size = Array.isArray(s.s.k) ? s.s.k : (s.s.k?.k || []);
135
+ if (Array.isArray(size) && size[0] >= docWidth * 0.8 && size[1] >= docHeight * 0.8) {
136
+ hasFullFrameRect = true;
137
+ break;
138
+ }
139
+ }
140
+ }
141
+ shapes.forEach((shape, shapeIdx) => {
142
+ if (!shape)
143
+ return;
144
+ const shapePath = [...basePath, shapeIdx];
145
+ // Shape Group (ty: "gr") containing sub-items
146
+ if (shape.ty === 'gr' && Array.isArray(shape.it)) {
147
+ walkShapes(shape.it, [...shapePath, 'it'], occurrences, docWidth, docHeight);
148
+ return;
149
+ }
150
+ // Fill (ty: "fl")
151
+ if (shape.ty === 'fl' && shape.c) {
152
+ extractColorProperty(shape.c, [...shapePath, 'c'], 'fill', occurrences, hasFullFrameRect);
153
+ }
154
+ // Stroke (ty: "st")
155
+ if (shape.ty === 'st' && shape.c) {
156
+ extractColorProperty(shape.c, [...shapePath, 'c'], 'stroke', occurrences, false);
157
+ }
158
+ // Gradient Fill (ty: "gf") or Gradient Stroke (ty: "gs")
159
+ if ((shape.ty === 'gf' || shape.ty === 'gs') && shape.g) {
160
+ const stopCount = shape.g.p !== undefined ? shape.g.p : (shape.p !== undefined ? shape.p : 0);
161
+ extractGradientProperty(shape.g, [...shapePath, 'g'], stopCount, occurrences);
162
+ }
163
+ });
164
+ }
165
+ /**
166
+ * Extracts colors from Lottie animated/static color property (c)
167
+ */
168
+ function extractColorProperty(cProp, cPath, type, occurrences, isBackgroundCandidate = false) {
169
+ if (!cProp || cProp.k === undefined)
170
+ return;
171
+ // Static color: c.k is [r, g, b, (a)]
172
+ if (Array.isArray(cProp.k) && typeof cProp.k[0] === 'number') {
173
+ const raw = cProp.k;
174
+ occurrences.push({
175
+ path: [...cPath, 'k'],
176
+ type,
177
+ color: {
178
+ r: raw[0],
179
+ g: raw[1],
180
+ b: raw[2],
181
+ a: raw.length > 3 ? raw[3] : 1,
182
+ },
183
+ originalLength: raw.length,
184
+ isKeyframed: false,
185
+ isBackgroundCandidate,
186
+ });
187
+ return;
188
+ }
189
+ // Keyframed color: c.k is array of keyframe objects { s: [r, g, b, (a)], e?: ... }
190
+ if (Array.isArray(cProp.k)) {
191
+ cProp.k.forEach((kf, kfIdx) => {
192
+ // Start keyframe color (s)
193
+ if (kf && Array.isArray(kf.s) && typeof kf.s[0] === 'number') {
194
+ const raw = kf.s;
195
+ occurrences.push({
196
+ path: [...cPath, 'k', kfIdx, 's'],
197
+ type,
198
+ color: {
199
+ r: raw[0],
200
+ g: raw[1],
201
+ b: raw[2],
202
+ a: raw.length > 3 ? raw[3] : 1,
203
+ },
204
+ originalLength: raw.length,
205
+ isKeyframed: true,
206
+ keyframeIndex: kfIdx,
207
+ isBackgroundCandidate,
208
+ });
209
+ }
210
+ // End keyframe color (e) - Fix for older Lottie exports (Item 5)
211
+ if (kf && Array.isArray(kf.e) && typeof kf.e[0] === 'number') {
212
+ const raw = kf.e;
213
+ occurrences.push({
214
+ path: [...cPath, 'k', kfIdx, 'e'],
215
+ type,
216
+ color: {
217
+ r: raw[0],
218
+ g: raw[1],
219
+ b: raw[2],
220
+ a: raw.length > 3 ? raw[3] : 1,
221
+ },
222
+ originalLength: raw.length,
223
+ isKeyframed: true,
224
+ keyframeIndex: kfIdx,
225
+ isBackgroundCandidate,
226
+ });
227
+ }
228
+ });
229
+ }
230
+ }
231
+ /**
232
+ * Extracts gradient stops from Lottie gradient property (shape.g)
233
+ * Structure:
234
+ * - Color stops count: shape.g.p
235
+ * - First p * 4 numbers are color stops: [offset, r, g, b, ...]
236
+ * - Remaining numbers are opacity stops: [offset, alpha, ...] - left UNTOUCHED!
237
+ * - Handles both static gradients and animated gradients (g.k.a === 1 with keyframes).
238
+ */
239
+ function extractGradientProperty(gProp, gPath, numStops, occurrences) {
240
+ if (!gProp || !gProp.k)
241
+ return;
242
+ const isAnimated = gProp.k.a === 1 || (Array.isArray(gProp.k) && gProp.k[0] && typeof gProp.k[0] === 'object' && 's' in gProp.k[0]);
243
+ // Case 1: Animated gradient (g.k.a === 1)
244
+ if (isAnimated) {
245
+ const keyframes = Array.isArray(gProp.k) ? gProp.k : (Array.isArray(gProp.k.k) ? gProp.k.k : []);
246
+ const kPrefix = Array.isArray(gProp.k) ? [...gPath, 'k'] : [...gPath, 'k', 'k'];
247
+ keyframes.forEach((kf, kfIdx) => {
248
+ if (!kf)
249
+ return;
250
+ if (Array.isArray(kf.s)) {
251
+ walkGradientArray(kf.s, [...kPrefix, kfIdx, 's'], numStops, occurrences, true, kfIdx);
252
+ }
253
+ if (Array.isArray(kf.e)) {
254
+ walkGradientArray(kf.e, [...kPrefix, kfIdx, 'e'], numStops, occurrences, true, kfIdx);
255
+ }
256
+ });
257
+ return;
258
+ }
259
+ // Case 2: Static gradient
260
+ const rawStops = Array.isArray(gProp.k) ? gProp.k : (Array.isArray(gProp.k?.k) ? gProp.k.k : null);
261
+ if (!Array.isArray(rawStops))
262
+ return;
263
+ const pathPrefix = Array.isArray(gProp.k) ? [...gPath, 'k'] : [...gPath, 'k', 'k'];
264
+ walkGradientArray(rawStops, pathPrefix, numStops, occurrences, false);
265
+ }
266
+ function walkGradientArray(stopsArray, pathPrefix, numStops, occurrences, isKeyframed, keyframeIndex) {
267
+ // If stop count not explicitly set in shape.g.p, infer from array length
268
+ const count = numStops > 0 ? numStops : Math.floor(stopsArray.length / 4);
269
+ for (let i = 0; i < count; i++) {
270
+ const offsetIdx = i * 4;
271
+ if (offsetIdx + 3 < stopsArray.length) {
272
+ occurrences.push({
273
+ path: [...pathPrefix, offsetIdx + 1], // points to red index
274
+ type: 'gradient_stop',
275
+ color: {
276
+ r: stopsArray[offsetIdx + 1],
277
+ g: stopsArray[offsetIdx + 2],
278
+ b: stopsArray[offsetIdx + 3],
279
+ a: 1,
280
+ },
281
+ originalLength: 4,
282
+ isKeyframed,
283
+ keyframeIndex,
284
+ gradientIndex: i,
285
+ });
286
+ }
287
+ }
288
+ }
289
+ /**
290
+ * Sets a value at a nested path inside a clone of the Lottie JSON
291
+ */
292
+ export function setNestedValue(obj, path, value) {
293
+ let curr = obj;
294
+ for (let i = 0; i < path.length - 1; i++) {
295
+ const key = path[i];
296
+ if (curr[key] === undefined || curr[key] === null) {
297
+ return;
298
+ }
299
+ curr = curr[key];
300
+ }
301
+ const lastKey = path[path.length - 1];
302
+ curr[lastKey] = value;
303
+ }