@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
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { ColorRole } from './classifier.js';
|
|
2
|
+
export interface UserTheme {
|
|
3
|
+
primary: string;
|
|
4
|
+
secondary?: string;
|
|
5
|
+
background?: string;
|
|
6
|
+
accent?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface ThemingOptions {
|
|
9
|
+
mode?: 'light' | 'dark';
|
|
10
|
+
}
|
|
11
|
+
export interface ColorMappingResult {
|
|
12
|
+
role: ColorRole;
|
|
13
|
+
originalHex: string;
|
|
14
|
+
themedHex: string;
|
|
15
|
+
count: number;
|
|
16
|
+
}
|
|
17
|
+
export interface ThemingResult {
|
|
18
|
+
themedLottie: Record<string, any>;
|
|
19
|
+
colorMap: ColorMappingResult[];
|
|
20
|
+
themeableScore: number;
|
|
21
|
+
hasRasterImages: boolean;
|
|
22
|
+
totalColorsCount: number;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Main Theming Engine.
|
|
26
|
+
* Takes any Lottie JSON and a target theme, returning a cleanly recolored Lottie JSON
|
|
27
|
+
* that preserves shadows, highlights, and contrast.
|
|
28
|
+
*/
|
|
29
|
+
export declare function applyThemeToLottie(originalLottie: Record<string, any>, theme: UserTheme, options?: ThemingOptions): ThemingResult;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { rgbToHex, hexToRgb, rgbToOklch, oklchToRgb, clamp, } from './oklch.js';
|
|
2
|
+
import { walkLottieColors, setNestedValue } from './color-walker.js';
|
|
3
|
+
import { clusterAndClassifyColors } from './classifier.js';
|
|
4
|
+
/**
|
|
5
|
+
* Main Theming Engine.
|
|
6
|
+
* Takes any Lottie JSON and a target theme, returning a cleanly recolored Lottie JSON
|
|
7
|
+
* that preserves shadows, highlights, and contrast.
|
|
8
|
+
*/
|
|
9
|
+
export function applyThemeToLottie(originalLottie, theme, options = {}) {
|
|
10
|
+
// Deep clone to avoid mutating input
|
|
11
|
+
const lottie = JSON.parse(JSON.stringify(originalLottie));
|
|
12
|
+
// Check for raster images in assets
|
|
13
|
+
let hasRasterImages = false;
|
|
14
|
+
if (Array.isArray(lottie.assets)) {
|
|
15
|
+
hasRasterImages = lottie.assets.some((a) => a && (a.p?.endsWith?.('.png') || a.p?.endsWith?.('.jpg') || a.e === 1));
|
|
16
|
+
}
|
|
17
|
+
// 1. Walk colors
|
|
18
|
+
const occurrences = walkLottieColors(lottie);
|
|
19
|
+
if (occurrences.length === 0) {
|
|
20
|
+
return {
|
|
21
|
+
themedLottie: lottie,
|
|
22
|
+
colorMap: [],
|
|
23
|
+
themeableScore: hasRasterImages ? 0.2 : 0.8,
|
|
24
|
+
hasRasterImages,
|
|
25
|
+
totalColorsCount: 0,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
// 2. Cluster & Classify
|
|
29
|
+
const clusters = clusterAndClassifyColors(occurrences);
|
|
30
|
+
// 3. Resolve target theme colors into OKLCH
|
|
31
|
+
const targetPrimaryRgb = hexToRgb(theme.primary);
|
|
32
|
+
const targetPrimaryLch = rgbToOklch(targetPrimaryRgb);
|
|
33
|
+
const targetSecondaryLch = theme.secondary
|
|
34
|
+
? rgbToOklch(hexToRgb(theme.secondary))
|
|
35
|
+
: { ...targetPrimaryLch, h: (targetPrimaryLch.h + 30) % 360, c: targetPrimaryLch.c * 0.85 };
|
|
36
|
+
const targetBackgroundLch = theme.background
|
|
37
|
+
? rgbToOklch(hexToRgb(theme.background))
|
|
38
|
+
: options.mode === 'dark'
|
|
39
|
+
? { l: 0.12, c: 0.01, h: targetPrimaryLch.h }
|
|
40
|
+
: { l: 0.98, c: 0.005, h: targetPrimaryLch.h };
|
|
41
|
+
const targetAccentLch = theme.accent
|
|
42
|
+
? rgbToOklch(hexToRgb(theme.accent))
|
|
43
|
+
: { ...targetPrimaryLch, h: (targetPrimaryLch.h + 180) % 360, c: targetPrimaryLch.c * 1.1 };
|
|
44
|
+
// Calculate cluster baseline lightness for relative shading preservation
|
|
45
|
+
const colorMap = [];
|
|
46
|
+
for (const cluster of clusters) {
|
|
47
|
+
const role = cluster.assignedRole || 'neutral';
|
|
48
|
+
const clusterBaseLch = cluster.representativeLch;
|
|
49
|
+
// Pick target base based on role
|
|
50
|
+
let targetBaseLch;
|
|
51
|
+
switch (role) {
|
|
52
|
+
case 'primary':
|
|
53
|
+
targetBaseLch = targetPrimaryLch;
|
|
54
|
+
break;
|
|
55
|
+
case 'secondary':
|
|
56
|
+
targetBaseLch = targetSecondaryLch;
|
|
57
|
+
break;
|
|
58
|
+
case 'background':
|
|
59
|
+
targetBaseLch = targetBackgroundLch;
|
|
60
|
+
break;
|
|
61
|
+
case 'accent':
|
|
62
|
+
targetBaseLch = targetAccentLch;
|
|
63
|
+
break;
|
|
64
|
+
case 'neutral':
|
|
65
|
+
default:
|
|
66
|
+
// Neutrals preserve their natural lightness; only the designated background role flips in dark mode
|
|
67
|
+
targetBaseLch = clusterBaseLch;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
// Process every occurrence in this cluster
|
|
71
|
+
for (const occ of cluster.occurrences) {
|
|
72
|
+
const origLch = rgbToOklch(occ.color);
|
|
73
|
+
// If cluster was assigned 'background' (e.g. canvas is white), but this specific
|
|
74
|
+
// occurrence is a content shape (e.g. eye whites or highlights) that does not fill the frame:
|
|
75
|
+
// keep it as a content neutral so it does not turn dark in dark mode.
|
|
76
|
+
const effectiveBaseLch = role === 'background' && !occ.isBackgroundCandidate
|
|
77
|
+
? clusterBaseLch
|
|
78
|
+
: targetBaseLch;
|
|
79
|
+
const isEffectiveNeutral = role === 'neutral' || (role === 'background' && !occ.isBackgroundCandidate);
|
|
80
|
+
// Relative shading offset: How much darker/lighter was this occurrence compared to cluster base?
|
|
81
|
+
const lightnessDelta = origLch.l - clusterBaseLch.l;
|
|
82
|
+
// New lightness preserves the delta, clamped to valid ranges
|
|
83
|
+
let newLightness = effectiveBaseLch.l + lightnessDelta;
|
|
84
|
+
newLightness = clamp(newLightness, 0.02, 0.98);
|
|
85
|
+
// Construct new OKLCH color
|
|
86
|
+
const newLch = {
|
|
87
|
+
l: newLightness,
|
|
88
|
+
c: isEffectiveNeutral ? origLch.c : effectiveBaseLch.c,
|
|
89
|
+
h: isEffectiveNeutral ? origLch.h : effectiveBaseLch.h,
|
|
90
|
+
a: occ.color.a,
|
|
91
|
+
};
|
|
92
|
+
const newRgb = oklchToRgb(newLch);
|
|
93
|
+
// Write back to cloned JSON
|
|
94
|
+
if (occ.type === 'solid_layer') {
|
|
95
|
+
setNestedValue(lottie, occ.path, rgbToHex(newRgb));
|
|
96
|
+
}
|
|
97
|
+
else if (occ.type === 'gradient_stop') {
|
|
98
|
+
// Gradient stops are raw numbers [offset, r, g, b]
|
|
99
|
+
// path points to red stop index
|
|
100
|
+
const basePath = occ.path.slice(0, -1);
|
|
101
|
+
const redIdx = occ.path[occ.path.length - 1];
|
|
102
|
+
setNestedValue(lottie, [...basePath, redIdx], Number(newRgb.r.toFixed(4)));
|
|
103
|
+
setNestedValue(lottie, [...basePath, redIdx + 1], Number(newRgb.g.toFixed(4)));
|
|
104
|
+
setNestedValue(lottie, [...basePath, redIdx + 2], Number(newRgb.b.toFixed(4)));
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
// Fill or Stroke static or keyframed color: [r, g, b, (a)]
|
|
108
|
+
// Preserve exact original array length (Item 6)
|
|
109
|
+
const values = [
|
|
110
|
+
Number(newRgb.r.toFixed(4)),
|
|
111
|
+
Number(newRgb.g.toFixed(4)),
|
|
112
|
+
Number(newRgb.b.toFixed(4)),
|
|
113
|
+
];
|
|
114
|
+
if (occ.originalLength >= 4) {
|
|
115
|
+
values.push(Number((occ.color.a ?? 1).toFixed(4)));
|
|
116
|
+
}
|
|
117
|
+
setNestedValue(lottie, occ.path, values);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
colorMap.push({
|
|
121
|
+
role,
|
|
122
|
+
originalHex: rgbToHex(cluster.representativeRgb),
|
|
123
|
+
themedHex: rgbToHex(oklchToRgb(targetBaseLch)),
|
|
124
|
+
count: cluster.weight,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
// Calculate themeable score (0 - 1)
|
|
128
|
+
// Clean recoloring = few clusters, no raster images, reasonable shape count
|
|
129
|
+
let themeableScore = 1.0;
|
|
130
|
+
if (hasRasterImages)
|
|
131
|
+
themeableScore -= 0.5;
|
|
132
|
+
if (clusters.length > 8)
|
|
133
|
+
themeableScore -= 0.2;
|
|
134
|
+
if (clusters.length > 15)
|
|
135
|
+
themeableScore -= 0.2;
|
|
136
|
+
themeableScore = Math.max(0.1, Number(themeableScore.toFixed(2)));
|
|
137
|
+
return {
|
|
138
|
+
themedLottie: lottie,
|
|
139
|
+
colorMap,
|
|
140
|
+
themeableScore,
|
|
141
|
+
hasRasterImages,
|
|
142
|
+
totalColorsCount: occurrences.length,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ultra-fast, zero-dependency OKLCH color math & conversion.
|
|
3
|
+
* Implements Björn Ottosson's Oklab/Oklch specification for perceptual color theming.
|
|
4
|
+
*/
|
|
5
|
+
export interface RGB {
|
|
6
|
+
r: number;
|
|
7
|
+
g: number;
|
|
8
|
+
b: number;
|
|
9
|
+
a?: number;
|
|
10
|
+
}
|
|
11
|
+
export interface OKLCH {
|
|
12
|
+
l: number;
|
|
13
|
+
c: number;
|
|
14
|
+
h: number;
|
|
15
|
+
a?: number;
|
|
16
|
+
}
|
|
17
|
+
export interface OKLAB {
|
|
18
|
+
l: number;
|
|
19
|
+
a: number;
|
|
20
|
+
b: number;
|
|
21
|
+
alpha?: number;
|
|
22
|
+
}
|
|
23
|
+
export declare function clamp(v: number, min?: number, max?: number): number;
|
|
24
|
+
/**
|
|
25
|
+
* Convert RGB (0..1) to OKLAB
|
|
26
|
+
*/
|
|
27
|
+
export declare function rgbToOklab(rgb: RGB): OKLAB;
|
|
28
|
+
/**
|
|
29
|
+
* Convert OKLAB to RGB (0..1)
|
|
30
|
+
*/
|
|
31
|
+
export declare function oklabToRgb(lab: OKLAB): RGB;
|
|
32
|
+
/**
|
|
33
|
+
* Convert OKLAB to OKLCH
|
|
34
|
+
*/
|
|
35
|
+
export declare function oklabToOklch(lab: OKLAB): OKLCH;
|
|
36
|
+
/**
|
|
37
|
+
* Convert OKLCH to OKLAB
|
|
38
|
+
*/
|
|
39
|
+
export declare function oklchToOklab(lch: OKLCH): OKLAB;
|
|
40
|
+
/**
|
|
41
|
+
* Convert RGB (0..1) directly to OKLCH
|
|
42
|
+
*/
|
|
43
|
+
export declare function rgbToOklch(rgb: RGB): OKLCH;
|
|
44
|
+
/**
|
|
45
|
+
* Convert OKLCH directly to RGB (0..1)
|
|
46
|
+
*/
|
|
47
|
+
export declare function oklchToRgb(lch: OKLCH): RGB;
|
|
48
|
+
/**
|
|
49
|
+
* Perceptual color distance Delta E in OKLAB
|
|
50
|
+
*/
|
|
51
|
+
export declare function oklabDistance(a: RGB, b: RGB): number;
|
|
52
|
+
/**
|
|
53
|
+
* Parse hex string (#RGB, #RGBA, #RRGGBB, #RRGGBBAA) into RGB (0..1)
|
|
54
|
+
*/
|
|
55
|
+
export declare function hexToRgb(hex: string): RGB;
|
|
56
|
+
/**
|
|
57
|
+
* Convert RGB (0..1) to #RRGGBB hex string
|
|
58
|
+
*/
|
|
59
|
+
export declare function rgbToHex(rgb: RGB, includeAlpha?: boolean): string;
|
|
60
|
+
/**
|
|
61
|
+
* Check if a color is considered neutral (near grey/white/black)
|
|
62
|
+
*/
|
|
63
|
+
export declare function isNeutral(lch: OKLCH, threshold?: number): boolean;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ultra-fast, zero-dependency OKLCH color math & conversion.
|
|
3
|
+
* Implements Björn Ottosson's Oklab/Oklch specification for perceptual color theming.
|
|
4
|
+
*/
|
|
5
|
+
// Convert sRGB (0-1) to linear sRGB
|
|
6
|
+
function srgbToLinear(c) {
|
|
7
|
+
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
8
|
+
}
|
|
9
|
+
// Convert linear sRGB to sRGB (0-1)
|
|
10
|
+
function linearToSrgb(c) {
|
|
11
|
+
return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(Math.max(0, c), 1 / 2.4) - 0.055;
|
|
12
|
+
}
|
|
13
|
+
// Clamp number between min and max
|
|
14
|
+
export function clamp(v, min = 0, max = 1) {
|
|
15
|
+
return Math.min(Math.max(v, min), max);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Convert RGB (0..1) to OKLAB
|
|
19
|
+
*/
|
|
20
|
+
export function rgbToOklab(rgb) {
|
|
21
|
+
const lr = srgbToLinear(clamp(rgb.r));
|
|
22
|
+
const lg = srgbToLinear(clamp(rgb.g));
|
|
23
|
+
const lb = srgbToLinear(clamp(rgb.b));
|
|
24
|
+
const l_ = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
|
|
25
|
+
const m_ = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
|
|
26
|
+
const s_ = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
|
|
27
|
+
return {
|
|
28
|
+
l: 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
|
|
29
|
+
a: 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
|
|
30
|
+
b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_,
|
|
31
|
+
alpha: rgb.a ?? 1,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Convert OKLAB to RGB (0..1)
|
|
36
|
+
*/
|
|
37
|
+
export function oklabToRgb(lab) {
|
|
38
|
+
const l_ = lab.l + 0.3963377774 * lab.a + 0.2158037573 * lab.b;
|
|
39
|
+
const m_ = lab.l - 0.1055613458 * lab.a - 0.0638541728 * lab.b;
|
|
40
|
+
const s_ = lab.l - 0.0894841775 * lab.a - 1.2914855480 * lab.b;
|
|
41
|
+
const l = l_ * l_ * l_;
|
|
42
|
+
const m = m_ * m_ * m_;
|
|
43
|
+
const s = s_ * s_ * s_;
|
|
44
|
+
const r = +4.0767439362 * l - 3.3077115913 * m + 0.2309699292 * s;
|
|
45
|
+
const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
|
|
46
|
+
const b = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s;
|
|
47
|
+
return {
|
|
48
|
+
r: clamp(linearToSrgb(r)),
|
|
49
|
+
g: clamp(linearToSrgb(g)),
|
|
50
|
+
b: clamp(linearToSrgb(b)),
|
|
51
|
+
a: lab.alpha ?? 1,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Convert OKLAB to OKLCH
|
|
56
|
+
*/
|
|
57
|
+
export function oklabToOklch(lab) {
|
|
58
|
+
const c = Math.sqrt(lab.a * lab.a + lab.b * lab.b);
|
|
59
|
+
let h = (Math.atan2(lab.b, lab.a) * 180) / Math.PI;
|
|
60
|
+
if (h < 0)
|
|
61
|
+
h += 360;
|
|
62
|
+
return {
|
|
63
|
+
l: lab.l,
|
|
64
|
+
c,
|
|
65
|
+
h,
|
|
66
|
+
a: lab.alpha,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Convert OKLCH to OKLAB
|
|
71
|
+
*/
|
|
72
|
+
export function oklchToOklab(lch) {
|
|
73
|
+
const rad = (lch.h * Math.PI) / 180;
|
|
74
|
+
return {
|
|
75
|
+
l: lch.l,
|
|
76
|
+
a: lch.c * Math.cos(rad),
|
|
77
|
+
b: lch.c * Math.sin(rad),
|
|
78
|
+
alpha: lch.a,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Convert RGB (0..1) directly to OKLCH
|
|
83
|
+
*/
|
|
84
|
+
export function rgbToOklch(rgb) {
|
|
85
|
+
return oklabToOklch(rgbToOklab(rgb));
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Convert OKLCH directly to RGB (0..1)
|
|
89
|
+
*/
|
|
90
|
+
export function oklchToRgb(lch) {
|
|
91
|
+
return oklabToRgb(oklchToOklab(lch));
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Perceptual color distance Delta E in OKLAB
|
|
95
|
+
*/
|
|
96
|
+
export function oklabDistance(a, b) {
|
|
97
|
+
const la = rgbToOklab(a);
|
|
98
|
+
const lb = rgbToOklab(b);
|
|
99
|
+
const dl = la.l - lb.l;
|
|
100
|
+
const da = la.a - lb.a;
|
|
101
|
+
const db = la.b - lb.b;
|
|
102
|
+
return Math.sqrt(dl * dl + da * da + db * db);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Parse hex string (#RGB, #RGBA, #RRGGBB, #RRGGBBAA) into RGB (0..1)
|
|
106
|
+
*/
|
|
107
|
+
export function hexToRgb(hex) {
|
|
108
|
+
let cleaned = hex.trim().replace(/^#/, '');
|
|
109
|
+
if (cleaned.length === 3) {
|
|
110
|
+
cleaned = cleaned.split('').map((c) => c + c).join('') + 'ff';
|
|
111
|
+
}
|
|
112
|
+
else if (cleaned.length === 4) {
|
|
113
|
+
cleaned = cleaned.split('').map((c) => c + c).join('');
|
|
114
|
+
}
|
|
115
|
+
else if (cleaned.length === 6) {
|
|
116
|
+
cleaned += 'ff';
|
|
117
|
+
}
|
|
118
|
+
const num = parseInt(cleaned, 16);
|
|
119
|
+
if (isNaN(num) || cleaned.length !== 8) {
|
|
120
|
+
// Fallback to black if invalid
|
|
121
|
+
return { r: 0, g: 0, b: 0, a: 1 };
|
|
122
|
+
}
|
|
123
|
+
const r = ((num >> 24) & 255) / 255;
|
|
124
|
+
const g = ((num >> 16) & 255) / 255;
|
|
125
|
+
const b = ((num >> 8) & 255) / 255;
|
|
126
|
+
const a = (num & 255) / 255;
|
|
127
|
+
return { r, g, b, a };
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Convert RGB (0..1) to #RRGGBB hex string
|
|
131
|
+
*/
|
|
132
|
+
export function rgbToHex(rgb, includeAlpha = false) {
|
|
133
|
+
const r = Math.round(clamp(rgb.r) * 255).toString(16).padStart(2, '0');
|
|
134
|
+
const g = Math.round(clamp(rgb.g) * 255).toString(16).padStart(2, '0');
|
|
135
|
+
const b = Math.round(clamp(rgb.b) * 255).toString(16).padStart(2, '0');
|
|
136
|
+
if (includeAlpha && rgb.a !== undefined && rgb.a < 1) {
|
|
137
|
+
const a = Math.round(clamp(rgb.a) * 255).toString(16).padStart(2, '0');
|
|
138
|
+
return `#${r}${g}${b}${a}`;
|
|
139
|
+
}
|
|
140
|
+
return `#${r}${g}${b}`;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Check if a color is considered neutral (near grey/white/black)
|
|
144
|
+
*/
|
|
145
|
+
export function isNeutral(lch, threshold = 0.035) {
|
|
146
|
+
return lch.c < threshold;
|
|
147
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type SupportedFramework = 'react' | 'react-native' | 'nextjs' | 'html';
|
|
2
|
+
export interface GetCodeOptions {
|
|
3
|
+
loop?: boolean;
|
|
4
|
+
autoplay?: boolean;
|
|
5
|
+
size?: number | {
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
};
|
|
9
|
+
componentName?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface GeneratedCodeResult {
|
|
12
|
+
framework: SupportedFramework;
|
|
13
|
+
installCommand: string;
|
|
14
|
+
componentCode: string;
|
|
15
|
+
filePath: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Generates ready-to-paste framework code for displaying a Lottie animation
|
|
19
|
+
*/
|
|
20
|
+
export declare function generateFrameworkCode(framework: SupportedFramework, filePath: string, options?: GetCodeOptions): GeneratedCodeResult;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates ready-to-paste framework code for displaying a Lottie animation
|
|
3
|
+
*/
|
|
4
|
+
export function generateFrameworkCode(framework, filePath, options = {}) {
|
|
5
|
+
const loop = options.loop !== false;
|
|
6
|
+
const autoplay = options.autoplay !== false;
|
|
7
|
+
const size = options.size || 200;
|
|
8
|
+
const width = typeof size === 'number' ? size : size.width;
|
|
9
|
+
const height = typeof size === 'number' ? size : size.height;
|
|
10
|
+
const compName = options.componentName || 'AnimatedIcon';
|
|
11
|
+
switch (framework) {
|
|
12
|
+
case 'react-native': {
|
|
13
|
+
const installCommand = 'npx expo install lottie-react-native || npm install lottie-react-native';
|
|
14
|
+
const componentCode = `import React, { useRef } from 'react';
|
|
15
|
+
import { View, StyleSheet } from 'react-native';
|
|
16
|
+
import LottieView from 'lottie-react-native';
|
|
17
|
+
import animationData from './${filePath.replace(/^(\.\/|\/)/, '')}';
|
|
18
|
+
|
|
19
|
+
export default function ${compName}() {
|
|
20
|
+
const animationRef = useRef<LottieView>(null);
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<View style={styles.container}>
|
|
24
|
+
<LottieView
|
|
25
|
+
ref={animationRef}
|
|
26
|
+
source={animationData}
|
|
27
|
+
autoPlay={${autoplay}}
|
|
28
|
+
loop={${loop}}
|
|
29
|
+
style={{ width: ${width}, height: ${height} }}
|
|
30
|
+
/>
|
|
31
|
+
</View>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const styles = StyleSheet.create({
|
|
36
|
+
container: {
|
|
37
|
+
alignItems: 'center',
|
|
38
|
+
justifyContent: 'center',
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
`;
|
|
42
|
+
return { framework, installCommand, componentCode, filePath };
|
|
43
|
+
}
|
|
44
|
+
case 'nextjs': {
|
|
45
|
+
const installCommand = 'npm install lottie-react';
|
|
46
|
+
const componentCode = `"use client";
|
|
47
|
+
|
|
48
|
+
import React from 'react';
|
|
49
|
+
import dynamic from 'next/dynamic';
|
|
50
|
+
import animationData from './${filePath.replace(/^(\.\/|\/)/, '')}';
|
|
51
|
+
|
|
52
|
+
// Client-side dynamic import prevents SSR hydration mismatch
|
|
53
|
+
const Lottie = dynamic(() => import('lottie-react'), { ssr: false });
|
|
54
|
+
|
|
55
|
+
export default function ${compName}() {
|
|
56
|
+
return (
|
|
57
|
+
<div style={{ width: ${width}, height: ${height}, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
58
|
+
<Lottie
|
|
59
|
+
animationData={animationData}
|
|
60
|
+
loop={${loop}}
|
|
61
|
+
autoPlay={${autoplay}}
|
|
62
|
+
style={{ width: '100%', height: '100%' }}
|
|
63
|
+
/>
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
`;
|
|
68
|
+
return { framework, installCommand, componentCode, filePath };
|
|
69
|
+
}
|
|
70
|
+
case 'react': {
|
|
71
|
+
const installCommand = 'npm install lottie-react';
|
|
72
|
+
const componentCode = `import React from 'react';
|
|
73
|
+
import Lottie from 'lottie-react';
|
|
74
|
+
import animationData from './${filePath.replace(/^(\.\/|\/)/, '')}';
|
|
75
|
+
|
|
76
|
+
export default function ${compName}() {
|
|
77
|
+
return (
|
|
78
|
+
<div style={{ width: ${width}, height: ${height} }}>
|
|
79
|
+
<Lottie
|
|
80
|
+
animationData={animationData}
|
|
81
|
+
loop={${loop}}
|
|
82
|
+
autoPlay={${autoplay}}
|
|
83
|
+
style={{ width: '100%', height: '100%' }}
|
|
84
|
+
/>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
`;
|
|
89
|
+
return { framework, installCommand, componentCode, filePath };
|
|
90
|
+
}
|
|
91
|
+
case 'html': {
|
|
92
|
+
const installCommand = '<!-- Include dotLottie player script in <head> -->\n<script src="https://unpkg.com/@lottiefiles/dotlottie-wc@latest/dist/dotlottie-wc.js" type="module"></script>';
|
|
93
|
+
const componentCode = `<!-- Ready-to-paste DotLottie Web Component -->
|
|
94
|
+
<dotlottie-wc
|
|
95
|
+
src="./${filePath.replace(/^(\.\/|\/)/, '')}"
|
|
96
|
+
style="width: ${width}px; height: ${height}px;"
|
|
97
|
+
${autoplay ? 'autoplay' : ''}
|
|
98
|
+
${loop ? 'loop' : ''}
|
|
99
|
+
></dotlottie-wc>
|
|
100
|
+
`;
|
|
101
|
+
return { framework, installCommand, componentCode, filePath };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const GetAnimationSchema: z.ZodObject<{
|
|
3
|
+
id: z.ZodString;
|
|
4
|
+
}, "strip", z.ZodTypeAny, {
|
|
5
|
+
id: string;
|
|
6
|
+
}, {
|
|
7
|
+
id: string;
|
|
8
|
+
}>;
|
|
9
|
+
export type GetAnimationInput = z.infer<typeof GetAnimationSchema>;
|
|
10
|
+
export declare function handleGetAnimation(input: GetAnimationInput): Promise<{
|
|
11
|
+
id: string;
|
|
12
|
+
lottie_json: Record<string, any>;
|
|
13
|
+
}>;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { fetchAnimationById } from '../api/catalog-client.js';
|
|
3
|
+
export const GetAnimationSchema = z.object({
|
|
4
|
+
id: z.string().min(1, 'Animation ID is required, e.g. "checkmark-success" or "loader-spinner"'),
|
|
5
|
+
});
|
|
6
|
+
export async function handleGetAnimation(input) {
|
|
7
|
+
// Sign-in, plan and daily limits are enforced by the server.
|
|
8
|
+
const lottieJson = await fetchAnimationById(input.id);
|
|
9
|
+
return { id: input.id, lottie_json: lottieJson };
|
|
10
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface SaveAnimationInput {
|
|
2
|
+
themed_json: Record<string, any> | string;
|
|
3
|
+
path: string;
|
|
4
|
+
format?: 'json' | 'lottie';
|
|
5
|
+
overwrite?: boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface SaveAnimationResult {
|
|
8
|
+
success: boolean;
|
|
9
|
+
savedPath: string;
|
|
10
|
+
absolutePath: string;
|
|
11
|
+
sizeBytes: number;
|
|
12
|
+
sizeKb: string;
|
|
13
|
+
format: 'json' | 'lottie';
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Builds a valid dotLottie v1 ZIP container containing manifest.json and animations/<id>.json
|
|
17
|
+
*/
|
|
18
|
+
export declare function buildDotLottieZip(json: Record<string, any>, animationId?: string): Uint8Array;
|
|
19
|
+
/**
|
|
20
|
+
* Saves themed animation into the developer's project directory
|
|
21
|
+
*/
|
|
22
|
+
export declare function saveAnimationToProject(input: SaveAnimationInput, server?: any): Promise<SaveAnimationResult>;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { zipSync, strToU8 } from 'fflate';
|
|
4
|
+
import { resolveSafePath } from '../utils/workspace.js';
|
|
5
|
+
/**
|
|
6
|
+
* Builds a valid dotLottie v1 ZIP container containing manifest.json and animations/<id>.json
|
|
7
|
+
*/
|
|
8
|
+
export function buildDotLottieZip(json, animationId = 'animation') {
|
|
9
|
+
const manifest = {
|
|
10
|
+
version: '1',
|
|
11
|
+
generator: '@rafaylabs/animation-mcp',
|
|
12
|
+
animations: [
|
|
13
|
+
{
|
|
14
|
+
id: animationId,
|
|
15
|
+
speed: 1,
|
|
16
|
+
loop: true,
|
|
17
|
+
autoplay: true,
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
};
|
|
21
|
+
const jsonStr = typeof json === 'string' ? json : JSON.stringify(json);
|
|
22
|
+
const manifestStr = JSON.stringify(manifest);
|
|
23
|
+
return zipSync({
|
|
24
|
+
'manifest.json': strToU8(manifestStr),
|
|
25
|
+
[`animations/${animationId}.json`]: strToU8(jsonStr),
|
|
26
|
+
}, { level: 9 });
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Saves themed animation into the developer's project directory
|
|
30
|
+
*/
|
|
31
|
+
export async function saveAnimationToProject(input, server) {
|
|
32
|
+
let targetPath = input.path.trim();
|
|
33
|
+
const lower = targetPath.toLowerCase();
|
|
34
|
+
const pathFormat = lower.endsWith('.lottie')
|
|
35
|
+
? 'lottie'
|
|
36
|
+
: lower.endsWith('.json')
|
|
37
|
+
? 'json'
|
|
38
|
+
: null;
|
|
39
|
+
// Explicit `format` wins; otherwise infer from the extension; default json.
|
|
40
|
+
const format = input.format ?? pathFormat ?? 'json';
|
|
41
|
+
// Make the file extension always match the real content (never a ZIP named .json).
|
|
42
|
+
if (pathFormat && pathFormat !== format) {
|
|
43
|
+
targetPath = targetPath.slice(0, targetPath.lastIndexOf('.'));
|
|
44
|
+
}
|
|
45
|
+
if (!targetPath.toLowerCase().endsWith(`.${format === 'lottie' ? 'lottie' : 'json'}`)) {
|
|
46
|
+
targetPath += format === 'lottie' ? '.lottie' : '.json';
|
|
47
|
+
}
|
|
48
|
+
// Resolve safe path against MCP workspace root
|
|
49
|
+
const { absolutePath } = await resolveSafePath(targetPath, { server });
|
|
50
|
+
// Overwrite check (Item 10)
|
|
51
|
+
if (fs.existsSync(absolutePath) && !input.overwrite) {
|
|
52
|
+
throw new Error(`File already exists at "${targetPath}". To replace it, pass "overwrite: true".`);
|
|
53
|
+
}
|
|
54
|
+
// Ensure target directory exists
|
|
55
|
+
const targetDir = path.dirname(absolutePath);
|
|
56
|
+
if (!fs.existsSync(targetDir)) {
|
|
57
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
58
|
+
}
|
|
59
|
+
const parsedJson = typeof input.themed_json === 'string' ? JSON.parse(input.themed_json) : input.themed_json;
|
|
60
|
+
const baseId = path.basename(absolutePath, path.extname(absolutePath)) || 'animation';
|
|
61
|
+
if (format === 'lottie') {
|
|
62
|
+
const zipBytes = buildDotLottieZip(parsedJson, baseId);
|
|
63
|
+
fs.writeFileSync(absolutePath, Buffer.from(zipBytes));
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
const jsonStr = JSON.stringify(parsedJson, null, 2);
|
|
67
|
+
fs.writeFileSync(absolutePath, jsonStr, 'utf-8');
|
|
68
|
+
}
|
|
69
|
+
const stats = fs.statSync(absolutePath);
|
|
70
|
+
return {
|
|
71
|
+
success: true,
|
|
72
|
+
savedPath: targetPath,
|
|
73
|
+
absolutePath,
|
|
74
|
+
sizeBytes: stats.size,
|
|
75
|
+
sizeKb: `${(stats.size / 1024).toFixed(1)} KB`,
|
|
76
|
+
format,
|
|
77
|
+
};
|
|
78
|
+
}
|