@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.
- package/LICENSE +21 -0
- package/README.md +111 -2
- package/bin/cli.js +50 -0
- package/dist/api/catalog-client.d.ts +27 -0
- package/dist/api/catalog-client.js +67 -0
- package/dist/api/server.d.ts +29 -0
- package/dist/api/server.js +55 -0
- package/dist/auth/cli-login.d.ts +3 -0
- package/dist/auth/cli-login.js +121 -0
- package/dist/auth/client.d.ts +45 -0
- package/dist/auth/client.js +170 -0
- package/dist/auth/config.d.ts +36 -0
- package/dist/auth/config.js +106 -0
- package/dist/auth/device-login.d.ts +2 -0
- package/dist/auth/device-login.js +63 -0
- package/dist/auth/product.d.ts +17 -0
- package/dist/auth/product.js +9 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +262 -0
- package/dist/theming/classifier.d.ts +16 -0
- package/dist/theming/classifier.js +74 -0
- package/dist/theming/color-walker.d.ts +19 -0
- package/dist/theming/color-walker.js +303 -0
- package/dist/theming/engine.d.ts +29 -0
- package/dist/theming/engine.js +144 -0
- package/dist/theming/oklch.d.ts +63 -0
- package/dist/theming/oklch.js +147 -0
- package/dist/tools/code.d.ts +20 -0
- package/dist/tools/code.js +104 -0
- package/dist/tools/get.d.ts +13 -0
- package/dist/tools/get.js +10 -0
- package/dist/tools/save.d.ts +22 -0
- package/dist/tools/save.js +78 -0
- package/dist/tools/schemas.d.ts +137 -0
- package/dist/tools/schemas.js +50 -0
- package/dist/tools/search.d.ts +29 -0
- package/dist/tools/search.js +22 -0
- package/dist/tools/theme.d.ts +28 -0
- package/dist/tools/theme.js +66 -0
- package/dist/utils/lottie-file.d.ts +5 -0
- package/dist/utils/lottie-file.js +47 -0
- package/dist/utils/workspace.d.ts +20 -0
- package/dist/utils/workspace.js +116 -0
- 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
|
+
}
|