@afromero/kin3o 0.2.2 → 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 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
@@ -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.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>