@afromero/kin3o 0.2.1 → 0.2.3
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/README.md +27 -0
- package/dist/export.d.ts +31 -0
- package/dist/export.js +265 -0
- package/dist/index.js +70 -0
- package/package.json +3 -2
- package/preview/template-export.html +47 -0
package/README.md
CHANGED
|
@@ -92,6 +92,13 @@ kin3o login # Authenticate for publis
|
|
|
92
92
|
kin3o publish output/anim.json --name "My Anim" --tags "loading,ui"
|
|
93
93
|
kin3o logout # Clear auth token
|
|
94
94
|
|
|
95
|
+
# Export to video
|
|
96
|
+
kin3o export output/animation.json # MP4 (default: 1080p 30fps)
|
|
97
|
+
kin3o export output/animation.json --format gif # GIF
|
|
98
|
+
kin3o export output/animation.json --format webm # WebM (supports transparency)
|
|
99
|
+
kin3o export output/animation.lottie --res 720p # dotLottie → MP4 at 720p
|
|
100
|
+
kin3o export output/animation.json --bg white -o out.mp4 # Custom background + path
|
|
101
|
+
|
|
95
102
|
# Live preview with hot reload
|
|
96
103
|
kin3o view output/animation.json # File watcher + auto-reload
|
|
97
104
|
kin3o view output/animation.lottie --port 3000 # Specific port
|
|
@@ -114,6 +121,10 @@ kin3o view output/animation.lottie --port 3000 # Specific port
|
|
|
114
121
|
| `--no-browser` | Print search results in terminal |
|
|
115
122
|
| `--lottie` | Download `.lottie` format instead of `.json` |
|
|
116
123
|
| `--port <n>` | Port for view server (auto-selects if omitted) |
|
|
124
|
+
| `--format <fmt>` | Export format: `mp4`, `webm`, `gif` (default: `mp4`) |
|
|
125
|
+
| `--res <res>` | Export resolution: `1080p`, `720p`, `480p`, `4k`, or `WxH` |
|
|
126
|
+
| `--fps <n>` | Export frames per second (default: `30`) |
|
|
127
|
+
| `--bg <color>` | Export background color (hex or name) |
|
|
117
128
|
|
|
118
129
|
## Using Generated Animations
|
|
119
130
|
|
|
@@ -183,6 +194,7 @@ Lottie covers shapes, paths, easing, color transitions, masking, and — via dot
|
|
|
183
194
|
```
|
|
184
195
|
Static: prompt → generate() → extractJson() → validateLottie() → autoFix() → .json → preview
|
|
185
196
|
Interactive: prompt → generate() → extractInteractiveJson() → validate animations + state machine → .lottie → preview
|
|
197
|
+
Export: .json/.lottie → headless Chrome (lottie-web) → frame capture → FFmpeg → MP4/WebM/GIF
|
|
186
198
|
Marketplace: search → browse → download → validate → .json/.lottie → refine → publish
|
|
187
199
|
Live preview: view <file> → HTTP server + fs.watch → SSE → browser auto-reload
|
|
188
200
|
```
|
|
@@ -200,6 +212,21 @@ All prompts live in `src/prompts/` with a barrel export at `src/prompts/index.ts
|
|
|
200
212
|
| `examples-mascot.ts` | kin3o mascot/logo (static + interactive) |
|
|
201
213
|
| `tokens.ts` | Design token loader (hex → Lottie RGBA) |
|
|
202
214
|
|
|
215
|
+
## Prerequisites for Export
|
|
216
|
+
|
|
217
|
+
The `export` command requires Chrome/Chromium and FFmpeg installed on your system:
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
# macOS
|
|
221
|
+
brew install --cask google-chrome
|
|
222
|
+
brew install ffmpeg
|
|
223
|
+
|
|
224
|
+
# Linux
|
|
225
|
+
sudo apt install chromium-browser ffmpeg
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Or set `CHROME_PATH` and `FFMPEG_PATH` environment variables to custom paths.
|
|
229
|
+
|
|
203
230
|
## Development
|
|
204
231
|
|
|
205
232
|
```bash
|
package/dist/export.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface ExportOptions {
|
|
2
|
+
format: 'mp4' | 'webm' | 'gif';
|
|
3
|
+
width: number;
|
|
4
|
+
height: number;
|
|
5
|
+
fps: number;
|
|
6
|
+
background: string | 'transparent';
|
|
7
|
+
output: string;
|
|
8
|
+
}
|
|
9
|
+
export interface AnimationMeta {
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
frameRate: number;
|
|
13
|
+
inPoint: number;
|
|
14
|
+
outPoint: number;
|
|
15
|
+
totalFrames: number;
|
|
16
|
+
durationSeconds: number;
|
|
17
|
+
}
|
|
18
|
+
export declare function parseResolution(res: string): {
|
|
19
|
+
width: number;
|
|
20
|
+
height: number;
|
|
21
|
+
};
|
|
22
|
+
export declare function detectChromePath(): string | null;
|
|
23
|
+
export declare function detectFfmpeg(): string | null;
|
|
24
|
+
export declare function extractAnimationMeta(lottieJson: object): AnimationMeta;
|
|
25
|
+
export declare function extractLottieJsonFromFile(filePath: string): Promise<object>;
|
|
26
|
+
export declare function buildFfmpegArgs(ffmpegPath: string, format: ExportOptions['format'], fps: number, outputPath: string, tempDir?: string): {
|
|
27
|
+
pass1?: string[];
|
|
28
|
+
pass2: string[];
|
|
29
|
+
ffmpegPath: string;
|
|
30
|
+
};
|
|
31
|
+
export declare function exportAnimation(filePath: string, options: ExportOptions, onProgress?: (frame: number, total: number) => void): Promise<string>;
|
package/dist/export.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { readFileSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { join, extname } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { spawn, execSync } from 'node:child_process';
|
|
7
|
+
import puppeteer from 'puppeteer-core';
|
|
8
|
+
import { readDotLottie } from './packager.js';
|
|
9
|
+
const templateDir = join(fileURLToPath(new URL('.', import.meta.url)), '..', 'preview');
|
|
10
|
+
const RESOLUTION_PRESETS = {
|
|
11
|
+
'1080p': { width: 1920, height: 1080 },
|
|
12
|
+
'720p': { width: 1280, height: 720 },
|
|
13
|
+
'480p': { width: 854, height: 480 },
|
|
14
|
+
'360p': { width: 640, height: 360 },
|
|
15
|
+
'4k': { width: 3840, height: 2160 },
|
|
16
|
+
};
|
|
17
|
+
export function parseResolution(res) {
|
|
18
|
+
const preset = RESOLUTION_PRESETS[res.toLowerCase()];
|
|
19
|
+
if (preset)
|
|
20
|
+
return preset;
|
|
21
|
+
const match = res.match(/^(\d+)x(\d+)$/);
|
|
22
|
+
if (match) {
|
|
23
|
+
const width = parseInt(match[1], 10);
|
|
24
|
+
const height = parseInt(match[2], 10);
|
|
25
|
+
if (width > 0 && height > 0)
|
|
26
|
+
return { width, height };
|
|
27
|
+
}
|
|
28
|
+
throw new Error(`Invalid resolution "${res}". Use 1080p, 720p, 480p, 360p, 4k, or WxH (e.g. 800x600)`);
|
|
29
|
+
}
|
|
30
|
+
export function detectChromePath() {
|
|
31
|
+
const envPath = process.env['CHROME_PATH'];
|
|
32
|
+
if (envPath)
|
|
33
|
+
return envPath;
|
|
34
|
+
if (process.platform === 'darwin') {
|
|
35
|
+
const candidates = [
|
|
36
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
37
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
38
|
+
'/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
|
|
39
|
+
];
|
|
40
|
+
for (const p of candidates) {
|
|
41
|
+
try {
|
|
42
|
+
execSync(`test -f "${p}"`, { stdio: 'ignore' });
|
|
43
|
+
return p;
|
|
44
|
+
}
|
|
45
|
+
catch { /* not found */ }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (process.platform === 'linux') {
|
|
49
|
+
const candidates = ['google-chrome', 'chromium-browser', 'chromium'];
|
|
50
|
+
for (const bin of candidates) {
|
|
51
|
+
try {
|
|
52
|
+
return execSync(`which ${bin}`, { encoding: 'utf-8' }).trim();
|
|
53
|
+
}
|
|
54
|
+
catch { /* not found */ }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (process.platform === 'win32') {
|
|
58
|
+
const candidates = [
|
|
59
|
+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
60
|
+
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
|
61
|
+
];
|
|
62
|
+
for (const p of candidates) {
|
|
63
|
+
try {
|
|
64
|
+
execSync(`if exist "${p}" exit 0`, { stdio: 'ignore' });
|
|
65
|
+
return p;
|
|
66
|
+
}
|
|
67
|
+
catch { /* not found */ }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
export function detectFfmpeg() {
|
|
73
|
+
const envPath = process.env['FFMPEG_PATH'];
|
|
74
|
+
if (envPath)
|
|
75
|
+
return envPath;
|
|
76
|
+
try {
|
|
77
|
+
return execSync('which ffmpeg', { encoding: 'utf-8' }).trim();
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export function extractAnimationMeta(lottieJson) {
|
|
84
|
+
const json = lottieJson;
|
|
85
|
+
const width = json['w'];
|
|
86
|
+
const height = json['h'];
|
|
87
|
+
const frameRate = json['fr'];
|
|
88
|
+
const inPoint = json['ip'];
|
|
89
|
+
const outPoint = json['op'];
|
|
90
|
+
if (typeof width !== 'number' || typeof height !== 'number' ||
|
|
91
|
+
typeof frameRate !== 'number' || typeof inPoint !== 'number' ||
|
|
92
|
+
typeof outPoint !== 'number') {
|
|
93
|
+
throw new Error('Invalid Lottie JSON: missing required fields (w, h, fr, ip, op)');
|
|
94
|
+
}
|
|
95
|
+
const totalFrames = outPoint - inPoint;
|
|
96
|
+
if (totalFrames <= 0) {
|
|
97
|
+
throw new Error(`Animation has zero duration (op=${outPoint} equals ip=${inPoint})`);
|
|
98
|
+
}
|
|
99
|
+
const durationSeconds = totalFrames / frameRate;
|
|
100
|
+
return { width, height, frameRate, inPoint, outPoint, totalFrames, durationSeconds };
|
|
101
|
+
}
|
|
102
|
+
export async function extractLottieJsonFromFile(filePath) {
|
|
103
|
+
const ext = extname(filePath).toLowerCase();
|
|
104
|
+
if (ext === '.lottie') {
|
|
105
|
+
const { animations } = await readDotLottie(filePath);
|
|
106
|
+
const entries = Object.entries(animations);
|
|
107
|
+
if (entries.length === 0) {
|
|
108
|
+
throw new Error('No animations found in .lottie file');
|
|
109
|
+
}
|
|
110
|
+
if (entries.length > 1) {
|
|
111
|
+
console.log(` Note: .lottie has ${entries.length} animations — exporting "${entries[0][0]}"`);
|
|
112
|
+
}
|
|
113
|
+
return entries[0][1];
|
|
114
|
+
}
|
|
115
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
116
|
+
return JSON.parse(raw);
|
|
117
|
+
}
|
|
118
|
+
export function buildFfmpegArgs(ffmpegPath, format, fps, outputPath, tempDir) {
|
|
119
|
+
const baseInput = ['-y', '-f', 'image2pipe', '-c:v', 'png', '-framerate', String(fps), '-i', 'pipe:0'];
|
|
120
|
+
switch (format) {
|
|
121
|
+
case 'mp4':
|
|
122
|
+
return {
|
|
123
|
+
ffmpegPath,
|
|
124
|
+
pass2: [...baseInput, '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-preset', 'medium', '-crf', '18', outputPath],
|
|
125
|
+
};
|
|
126
|
+
case 'webm':
|
|
127
|
+
return {
|
|
128
|
+
ffmpegPath,
|
|
129
|
+
pass2: [...baseInput, '-c:v', 'libvpx-vp9', '-pix_fmt', 'yuva420p', '-crf', '30', '-b:v', '0', outputPath],
|
|
130
|
+
};
|
|
131
|
+
case 'gif': {
|
|
132
|
+
if (!tempDir)
|
|
133
|
+
throw new Error('GIF export requires a temp directory');
|
|
134
|
+
const palette = join(tempDir, 'palette.png');
|
|
135
|
+
return {
|
|
136
|
+
ffmpegPath,
|
|
137
|
+
pass1: [
|
|
138
|
+
'-y', '-framerate', String(fps),
|
|
139
|
+
'-i', join(tempDir, '%06d.png'),
|
|
140
|
+
'-vf', 'palettegen=stats_mode=diff',
|
|
141
|
+
palette,
|
|
142
|
+
],
|
|
143
|
+
pass2: [
|
|
144
|
+
'-y', '-framerate', String(fps),
|
|
145
|
+
'-i', join(tempDir, '%06d.png'),
|
|
146
|
+
'-i', palette,
|
|
147
|
+
'-lavfi', 'paletteuse=dither=floyd_steinberg',
|
|
148
|
+
outputPath,
|
|
149
|
+
],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function serveExportPage(lottieJson, background) {
|
|
155
|
+
const template = readFileSync(join(templateDir, 'template-export.html'), 'utf-8');
|
|
156
|
+
const html = template
|
|
157
|
+
.replace('__ANIMATION_DATA__', JSON.stringify(lottieJson))
|
|
158
|
+
.replace('__BACKGROUND_COLOR__', background === 'transparent' ? 'transparent' : background);
|
|
159
|
+
const server = createServer((_req, res) => {
|
|
160
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
161
|
+
res.end(html);
|
|
162
|
+
});
|
|
163
|
+
return new Promise((resolve) => {
|
|
164
|
+
server.listen(0, () => {
|
|
165
|
+
const port = server.address().port;
|
|
166
|
+
resolve({
|
|
167
|
+
url: `http://localhost:${port}`,
|
|
168
|
+
close: () => server.close(),
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
function runFfmpeg(ffmpegPath, args) {
|
|
174
|
+
return new Promise((resolve, reject) => {
|
|
175
|
+
const proc = spawn(ffmpegPath, args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
176
|
+
let stderr = '';
|
|
177
|
+
proc.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
178
|
+
proc.on('close', (code) => {
|
|
179
|
+
if (code === 0)
|
|
180
|
+
resolve();
|
|
181
|
+
else
|
|
182
|
+
reject(new Error(`FFmpeg exited with code ${code}: ${stderr.slice(-500)}`));
|
|
183
|
+
});
|
|
184
|
+
proc.on('error', reject);
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
export async function exportAnimation(filePath, options, onProgress) {
|
|
188
|
+
const chromePath = detectChromePath();
|
|
189
|
+
if (!chromePath) {
|
|
190
|
+
throw new Error('Chrome or Chromium not found. Install Chrome or set CHROME_PATH environment variable.' +
|
|
191
|
+
(process.platform === 'darwin' ? '\n brew install --cask google-chrome' : ''));
|
|
192
|
+
}
|
|
193
|
+
const ffmpegPath = detectFfmpeg();
|
|
194
|
+
if (!ffmpegPath) {
|
|
195
|
+
throw new Error('FFmpeg not found. Install FFmpeg or set FFMPEG_PATH environment variable.' +
|
|
196
|
+
(process.platform === 'darwin' ? '\n brew install ffmpeg' : ''));
|
|
197
|
+
}
|
|
198
|
+
const lottieJson = await extractLottieJsonFromFile(filePath);
|
|
199
|
+
const meta = extractAnimationMeta(lottieJson);
|
|
200
|
+
const { totalFrames } = meta;
|
|
201
|
+
const server = await serveExportPage(lottieJson, options.background);
|
|
202
|
+
const browser = await puppeteer.launch({
|
|
203
|
+
executablePath: chromePath,
|
|
204
|
+
headless: true,
|
|
205
|
+
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu'],
|
|
206
|
+
});
|
|
207
|
+
let tempDir;
|
|
208
|
+
try {
|
|
209
|
+
const page = await browser.newPage();
|
|
210
|
+
await page.setViewport({ width: options.width, height: options.height });
|
|
211
|
+
await page.goto(server.url, { waitUntil: 'networkidle0' });
|
|
212
|
+
await page.waitForFunction('window.__animReady === true', { timeout: 10000 });
|
|
213
|
+
const omitBackground = options.format !== 'mp4' && options.background === 'transparent';
|
|
214
|
+
if (options.format === 'gif') {
|
|
215
|
+
tempDir = mkdtempSync(join(tmpdir(), 'kin3o-export-'));
|
|
216
|
+
for (let i = 0; i < totalFrames; i++) {
|
|
217
|
+
await page.evaluate((f) => window.__goToFrame(f), i);
|
|
218
|
+
const buffer = await page.screenshot({ type: 'png', omitBackground });
|
|
219
|
+
writeFileSync(join(tempDir, `${String(i).padStart(6, '0')}.png`), buffer);
|
|
220
|
+
onProgress?.(i + 1, totalFrames);
|
|
221
|
+
}
|
|
222
|
+
const ffmpegArgs = buildFfmpegArgs(ffmpegPath, 'gif', options.fps, options.output, tempDir);
|
|
223
|
+
await runFfmpeg(ffmpegPath, ffmpegArgs.pass1);
|
|
224
|
+
await runFfmpeg(ffmpegPath, ffmpegArgs.pass2);
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
const ffmpegArgs = buildFfmpegArgs(ffmpegPath, options.format, options.fps, options.output);
|
|
228
|
+
const proc = spawn(ffmpegPath, ffmpegArgs.pass2, { stdio: ['pipe', 'ignore', 'pipe'] });
|
|
229
|
+
let ffmpegError = '';
|
|
230
|
+
proc.stderr.on('data', (chunk) => { ffmpegError += chunk.toString(); });
|
|
231
|
+
const ffmpegDone = new Promise((resolve, reject) => {
|
|
232
|
+
proc.on('close', (code) => {
|
|
233
|
+
if (code === 0)
|
|
234
|
+
resolve();
|
|
235
|
+
else
|
|
236
|
+
reject(new Error(`FFmpeg exited with code ${code}: ${ffmpegError.slice(-500)}`));
|
|
237
|
+
});
|
|
238
|
+
proc.on('error', reject);
|
|
239
|
+
});
|
|
240
|
+
for (let i = 0; i < totalFrames; i++) {
|
|
241
|
+
await page.evaluate((f) => window.__goToFrame(f), i);
|
|
242
|
+
const buffer = await page.screenshot({ type: 'png', omitBackground });
|
|
243
|
+
await new Promise((resolve, reject) => {
|
|
244
|
+
proc.stdin.write(buffer, (err) => {
|
|
245
|
+
if (err)
|
|
246
|
+
reject(err);
|
|
247
|
+
else
|
|
248
|
+
resolve();
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
onProgress?.(i + 1, totalFrames);
|
|
252
|
+
}
|
|
253
|
+
proc.stdin.end();
|
|
254
|
+
await ffmpegDone;
|
|
255
|
+
}
|
|
256
|
+
return options.output;
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
await browser.close();
|
|
260
|
+
server.close();
|
|
261
|
+
if (tempDir) {
|
|
262
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -13,6 +13,7 @@ import { searchAnimations, featuredAnimations, popularAnimations, recentAnimatio
|
|
|
13
13
|
import { openSearchResults } from './marketplace-preview.js';
|
|
14
14
|
import { loadAuthToken, loadAuthExpiry, saveAuthToken, clearAuthToken } from './marketplace-auth.js';
|
|
15
15
|
import { startViewServer } from './view.js';
|
|
16
|
+
import { exportAnimation, parseResolution, extractLottieJsonFromFile, extractAnimationMeta } from './export.js';
|
|
16
17
|
const program = new Command();
|
|
17
18
|
program
|
|
18
19
|
.name('kin3o')
|
|
@@ -265,6 +266,75 @@ program
|
|
|
265
266
|
process.exit(1);
|
|
266
267
|
}
|
|
267
268
|
});
|
|
269
|
+
program
|
|
270
|
+
.command('export <file>')
|
|
271
|
+
.description('Export a Lottie animation to MP4, WebM, or GIF video')
|
|
272
|
+
.option('--format <format>', 'Output format: mp4, webm, gif', 'mp4')
|
|
273
|
+
.option('--res <resolution>', 'Resolution: 1080p, 720p, 480p, 360p, 4k, or WxH', '1080p')
|
|
274
|
+
.option('--fps <fps>', 'Frames per second', '30')
|
|
275
|
+
.option('-o, --output <path>', 'Output file path')
|
|
276
|
+
.option('--bg <color>', 'Background color (hex or name, default: black for mp4, transparent for gif/webm)')
|
|
277
|
+
.action(async (file, options) => {
|
|
278
|
+
const resolvedPath = resolve(file);
|
|
279
|
+
if (!existsSync(resolvedPath)) {
|
|
280
|
+
console.error(` ✗ File not found: ${resolvedPath}`);
|
|
281
|
+
process.exit(1);
|
|
282
|
+
}
|
|
283
|
+
const format = options.format.toLowerCase();
|
|
284
|
+
if (!['mp4', 'webm', 'gif'].includes(format)) {
|
|
285
|
+
console.error(` ✗ Unsupported format "${options.format}". Use mp4, webm, or gif.`);
|
|
286
|
+
process.exit(1);
|
|
287
|
+
}
|
|
288
|
+
let resolution;
|
|
289
|
+
try {
|
|
290
|
+
resolution = parseResolution(options.res);
|
|
291
|
+
}
|
|
292
|
+
catch (err) {
|
|
293
|
+
console.error(` ✗ ${err instanceof Error ? err.message : String(err)}`);
|
|
294
|
+
process.exit(1);
|
|
295
|
+
}
|
|
296
|
+
const fps = parseInt(options.fps, 10);
|
|
297
|
+
if (isNaN(fps) || fps <= 0) {
|
|
298
|
+
console.error(` ✗ Invalid fps "${options.fps}". Must be a positive number.`);
|
|
299
|
+
process.exit(1);
|
|
300
|
+
}
|
|
301
|
+
let background = options.bg ?? (format === 'mp4' ? '#000000' : 'transparent');
|
|
302
|
+
if (format === 'mp4' && background === 'transparent') {
|
|
303
|
+
console.log(' ⚠ MP4 does not support transparency — using black background. Use --format webm for alpha.');
|
|
304
|
+
background = '#000000';
|
|
305
|
+
}
|
|
306
|
+
const outputDir = ensureOutputDir();
|
|
307
|
+
const slug = slugify(file.replace(/\.[^.]+$/, '').split('/').pop() ?? 'animation');
|
|
308
|
+
const outputPath = options.output
|
|
309
|
+
? resolve(options.output)
|
|
310
|
+
: join(outputDir, `${slug}.${format}`);
|
|
311
|
+
console.log('\nkin3o — Exporting animation...');
|
|
312
|
+
try {
|
|
313
|
+
const lottieJson = await extractLottieJsonFromFile(resolvedPath);
|
|
314
|
+
const meta = extractAnimationMeta(lottieJson);
|
|
315
|
+
console.log(` Source: ${file}`);
|
|
316
|
+
console.log(` Format: ${format.toUpperCase()} ${resolution.width}x${resolution.height} @ ${fps}fps`);
|
|
317
|
+
console.log(` Duration: ${meta.durationSeconds.toFixed(1)}s (${meta.totalFrames} frames)\n`);
|
|
318
|
+
await exportAnimation(resolvedPath, {
|
|
319
|
+
format: format,
|
|
320
|
+
width: resolution.width,
|
|
321
|
+
height: resolution.height,
|
|
322
|
+
fps,
|
|
323
|
+
background,
|
|
324
|
+
output: outputPath,
|
|
325
|
+
}, (frame, total) => {
|
|
326
|
+
const pct = Math.round((frame / total) * 100);
|
|
327
|
+
process.stdout.write(`\r Exporting: frame ${frame}/${total} (${pct}%)`);
|
|
328
|
+
});
|
|
329
|
+
process.stdout.write('\n');
|
|
330
|
+
console.log(` ✓ Exported to ${outputPath}\n`);
|
|
331
|
+
}
|
|
332
|
+
catch (err) {
|
|
333
|
+
process.stdout.write('\n');
|
|
334
|
+
console.error(` ✗ Export failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
335
|
+
process.exit(1);
|
|
336
|
+
}
|
|
337
|
+
});
|
|
268
338
|
program
|
|
269
339
|
.command('view <file>')
|
|
270
340
|
.description('Live preview with hot reload (file watcher + auto-reload)')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@afromero/kin3o",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "AI-powered Lottie animation generator — text to motion from your terminal",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -41,7 +41,8 @@
|
|
|
41
41
|
"@anthropic-ai/sdk": "^0.78.0",
|
|
42
42
|
"@dotlottie/dotlottie-js": "^1.6.3",
|
|
43
43
|
"commander": "^13.0.0",
|
|
44
|
-
"open": "^10.1.0"
|
|
44
|
+
"open": "^10.1.0",
|
|
45
|
+
"puppeteer-core": "^24.0.0"
|
|
45
46
|
},
|
|
46
47
|
"devDependencies": {
|
|
47
48
|
"@types/node": "^22.13.0",
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<style>
|
|
6
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
7
|
+
body {
|
|
8
|
+
background: __BACKGROUND_COLOR__;
|
|
9
|
+
width: 100vw;
|
|
10
|
+
height: 100vh;
|
|
11
|
+
overflow: hidden;
|
|
12
|
+
display: flex;
|
|
13
|
+
align-items: center;
|
|
14
|
+
justify-content: center;
|
|
15
|
+
}
|
|
16
|
+
#animation-container {
|
|
17
|
+
width: 100vw;
|
|
18
|
+
height: 100vh;
|
|
19
|
+
}
|
|
20
|
+
</style>
|
|
21
|
+
</head>
|
|
22
|
+
<body>
|
|
23
|
+
<div id="animation-container"></div>
|
|
24
|
+
|
|
25
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.12.2/lottie.min.js"></script>
|
|
26
|
+
<script>
|
|
27
|
+
window.__animReady = false;
|
|
28
|
+
|
|
29
|
+
const animData = __ANIMATION_DATA__;
|
|
30
|
+
const anim = lottie.loadAnimation({
|
|
31
|
+
container: document.getElementById('animation-container'),
|
|
32
|
+
renderer: 'svg',
|
|
33
|
+
loop: false,
|
|
34
|
+
autoplay: false,
|
|
35
|
+
animationData: animData,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
anim.addEventListener('DOMLoaded', function() {
|
|
39
|
+
window.__animReady = true;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
window.__goToFrame = function(frame) {
|
|
43
|
+
anim.goToAndStop(frame, true);
|
|
44
|
+
};
|
|
45
|
+
</script>
|
|
46
|
+
</body>
|
|
47
|
+
</html>
|