@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
@@ -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
+ }