@afromero/kin3o 0.2.2 → 0.2.4

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
@@ -5,6 +5,7 @@
5
5
  **Text to Motion. From your terminal.**
6
6
 
7
7
  [![npm](https://img.shields.io/npm/v/@afromero/kin3o)](https://www.npmjs.com/package/@afromero/kin3o)
8
+ [![downloads](https://img.shields.io/npm/dm/@afromero/kin3o)](https://www.npmjs.com/package/@afromero/kin3o)
8
9
  [![CI](https://github.com/affromero/kin3o/actions/workflows/ci.yml/badge.svg)](https://github.com/affromero/kin3o/actions/workflows/ci.yml)
9
10
  [![TypeScript](https://img.shields.io/badge/TypeScript-strict-blue)](https://www.typescriptlang.org/)
10
11
  [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT)
@@ -92,6 +93,13 @@ kin3o login # Authenticate for publis
92
93
  kin3o publish output/anim.json --name "My Anim" --tags "loading,ui"
93
94
  kin3o logout # Clear auth token
94
95
 
96
+ # Export to video
97
+ kin3o export output/animation.json # MP4 (default: 1080p 30fps)
98
+ kin3o export output/animation.json --format gif # GIF
99
+ kin3o export output/animation.json --format webm # WebM (supports transparency)
100
+ kin3o export output/animation.lottie --res 720p # dotLottie → MP4 at 720p
101
+ kin3o export output/animation.json --bg white -o out.mp4 # Custom background + path
102
+
95
103
  # Live preview with hot reload
96
104
  kin3o view output/animation.json # File watcher + auto-reload
97
105
  kin3o view output/animation.lottie --port 3000 # Specific port
@@ -114,6 +122,10 @@ kin3o view output/animation.lottie --port 3000 # Specific port
114
122
  | `--no-browser` | Print search results in terminal |
115
123
  | `--lottie` | Download `.lottie` format instead of `.json` |
116
124
  | `--port <n>` | Port for view server (auto-selects if omitted) |
125
+ | `--format <fmt>` | Export format: `mp4`, `webm`, `gif` (default: `mp4`) |
126
+ | `--res <res>` | Export resolution: `1080p`, `720p`, `480p`, `4k`, or `WxH` |
127
+ | `--fps <n>` | Export frames per second (default: `30`) |
128
+ | `--bg <color>` | Export background color (hex or name) |
117
129
 
118
130
  ## Using Generated Animations
119
131
 
@@ -183,6 +195,7 @@ Lottie covers shapes, paths, easing, color transitions, masking, and — via dot
183
195
  ```
184
196
  Static: prompt → generate() → extractJson() → validateLottie() → autoFix() → .json → preview
185
197
  Interactive: prompt → generate() → extractInteractiveJson() → validate animations + state machine → .lottie → preview
198
+ Export: .json/.lottie → headless Chrome (lottie-web) → frame capture → FFmpeg → MP4/WebM/GIF
186
199
  Marketplace: search → browse → download → validate → .json/.lottie → refine → publish
187
200
  Live preview: view <file> → HTTP server + fs.watch → SSE → browser auto-reload
188
201
  ```
@@ -200,6 +213,21 @@ All prompts live in `src/prompts/` with a barrel export at `src/prompts/index.ts
200
213
  | `examples-mascot.ts` | kin3o mascot/logo (static + interactive) |
201
214
  | `tokens.ts` | Design token loader (hex → Lottie RGBA) |
202
215
 
216
+ ## Prerequisites for Export
217
+
218
+ The `export` command requires Chrome/Chromium and FFmpeg installed on your system:
219
+
220
+ ```bash
221
+ # macOS
222
+ brew install --cask google-chrome
223
+ brew install ffmpeg
224
+
225
+ # Linux
226
+ sudo apt install chromium-browser ffmpeg
227
+ ```
228
+
229
+ Or set `CHROME_PATH` and `FFMPEG_PATH` environment variables to custom paths.
230
+
203
231
  ## Development
204
232
 
205
233
  ```bash
@@ -210,6 +238,14 @@ npm run ci # typecheck + test
210
238
  npm run build # Compile to dist/
211
239
  ```
212
240
 
241
+ ## Related Projects
242
+
243
+ | Project | Description |
244
+ |---------|-------------|
245
+ | [**Fairtrail**](https://github.com/affromero/fairtrail) | Flight price evolution tracker with natural language search |
246
+ | [**PriceToken**](https://github.com/affromero/pricetoken) | Real-time LLM pricing API, npm/PyPI packages, and live dashboard |
247
+ | [**gitpane**](https://github.com/affromero/gitpane) | Multi-repo Git workspace dashboard for the terminal |
248
+
213
249
  ## License
214
250
 
215
251
  MIT
@@ -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')
@@ -27,6 +28,7 @@ program
27
28
  .option('--no-preview', 'Skip opening preview in browser')
28
29
  .option('-t, --tokens <path>', 'Path to design tokens JSON (or "sotto" for built-in)')
29
30
  .option('-i, --interactive', 'Generate interactive state machine (.lottie output)', false)
31
+ .option('--timeout <ms>', 'CLI subprocess timeout in milliseconds (default: auto per model)', parseInt)
30
32
  .action(async (prompt, options) => {
31
33
  const mode = options.interactive ? 'interactive' : 'static';
32
34
  console.log(`\nkin3o — Generating ${mode} animation...`);
@@ -55,7 +57,7 @@ program
55
57
  : buildSystemPrompt(tokens);
56
58
  // 4. Generate
57
59
  try {
58
- const result = await provider.generate(model, systemPrompt, prompt);
60
+ const result = await provider.generate(model, systemPrompt, prompt, options.timeout);
59
61
  console.log(` ✓ Generated in ${(result.durationMs / 1000).toFixed(1)}s`);
60
62
  if (options.interactive) {
61
63
  await handleInteractiveOutput(result.content, prompt, options);
@@ -165,6 +167,7 @@ program
165
167
  .option('-o, --output <path>', 'Output file path')
166
168
  .option('--no-preview', 'Skip opening preview in browser')
167
169
  .option('-t, --tokens <path>', 'Path to design tokens JSON (or "sotto" for built-in)')
170
+ .option('--timeout <ms>', 'CLI subprocess timeout in milliseconds (default: 600000)', parseInt)
168
171
  .action(async (file, prompt, rawOptions) => {
169
172
  const resolvedPath = resolve(file);
170
173
  // 1. Validate input file
@@ -224,7 +227,7 @@ program
224
227
  : buildRefinementUserPrompt(currentJson, prompt);
225
228
  // 6. Generate refined output
226
229
  try {
227
- const result = await provider.generate(model, systemPrompt, userPrompt);
230
+ const result = await provider.generate(model, systemPrompt, userPrompt, rawOptions.timeout);
228
231
  console.log(` ✓ Refined in ${(result.durationMs / 1000).toFixed(1)}s`);
229
232
  // 7. Compute output path
230
233
  const outputDir = ensureOutputDir();
@@ -265,6 +268,75 @@ program
265
268
  process.exit(1);
266
269
  }
267
270
  });
271
+ program
272
+ .command('export <file>')
273
+ .description('Export a Lottie animation to MP4, WebM, or GIF video')
274
+ .option('--format <format>', 'Output format: mp4, webm, gif', 'mp4')
275
+ .option('--res <resolution>', 'Resolution: 1080p, 720p, 480p, 360p, 4k, or WxH', '1080p')
276
+ .option('--fps <fps>', 'Frames per second', '30')
277
+ .option('-o, --output <path>', 'Output file path')
278
+ .option('--bg <color>', 'Background color (hex or name, default: black for mp4, transparent for gif/webm)')
279
+ .action(async (file, options) => {
280
+ const resolvedPath = resolve(file);
281
+ if (!existsSync(resolvedPath)) {
282
+ console.error(` ✗ File not found: ${resolvedPath}`);
283
+ process.exit(1);
284
+ }
285
+ const format = options.format.toLowerCase();
286
+ if (!['mp4', 'webm', 'gif'].includes(format)) {
287
+ console.error(` ✗ Unsupported format "${options.format}". Use mp4, webm, or gif.`);
288
+ process.exit(1);
289
+ }
290
+ let resolution;
291
+ try {
292
+ resolution = parseResolution(options.res);
293
+ }
294
+ catch (err) {
295
+ console.error(` ✗ ${err instanceof Error ? err.message : String(err)}`);
296
+ process.exit(1);
297
+ }
298
+ const fps = parseInt(options.fps, 10);
299
+ if (isNaN(fps) || fps <= 0) {
300
+ console.error(` ✗ Invalid fps "${options.fps}". Must be a positive number.`);
301
+ process.exit(1);
302
+ }
303
+ let background = options.bg ?? (format === 'mp4' ? '#000000' : 'transparent');
304
+ if (format === 'mp4' && background === 'transparent') {
305
+ console.log(' ⚠ MP4 does not support transparency — using black background. Use --format webm for alpha.');
306
+ background = '#000000';
307
+ }
308
+ const outputDir = ensureOutputDir();
309
+ const slug = slugify(file.replace(/\.[^.]+$/, '').split('/').pop() ?? 'animation');
310
+ const outputPath = options.output
311
+ ? resolve(options.output)
312
+ : join(outputDir, `${slug}.${format}`);
313
+ console.log('\nkin3o — Exporting animation...');
314
+ try {
315
+ const lottieJson = await extractLottieJsonFromFile(resolvedPath);
316
+ const meta = extractAnimationMeta(lottieJson);
317
+ console.log(` Source: ${file}`);
318
+ console.log(` Format: ${format.toUpperCase()} ${resolution.width}x${resolution.height} @ ${fps}fps`);
319
+ console.log(` Duration: ${meta.durationSeconds.toFixed(1)}s (${meta.totalFrames} frames)\n`);
320
+ await exportAnimation(resolvedPath, {
321
+ format: format,
322
+ width: resolution.width,
323
+ height: resolution.height,
324
+ fps,
325
+ background,
326
+ output: outputPath,
327
+ }, (frame, total) => {
328
+ const pct = Math.round((frame / total) * 100);
329
+ process.stdout.write(`\r Exporting: frame ${frame}/${total} (${pct}%)`);
330
+ });
331
+ process.stdout.write('\n');
332
+ console.log(` ✓ Exported to ${outputPath}\n`);
333
+ }
334
+ catch (err) {
335
+ process.stdout.write('\n');
336
+ console.error(` ✗ Export failed: ${err instanceof Error ? err.message : String(err)}`);
337
+ process.exit(1);
338
+ }
339
+ });
268
340
  program
269
341
  .command('view <file>')
270
342
  .description('Live preview with hot reload (file watcher + auto-reload)')
@@ -1,2 +1,2 @@
1
1
  import type { GenerationResult } from './registry.js';
2
- export declare function generateWithClaude(model: string, systemPrompt: string, userPrompt: string): Promise<GenerationResult>;
2
+ export declare function generateWithClaude(model: string, systemPrompt: string, userPrompt: string, timeoutMs?: number): Promise<GenerationResult>;
@@ -1,13 +1,15 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { getTimeoutMs } from './registry.js';
2
3
  import { filterCliStderr } from '../utils.js';
3
- export async function generateWithClaude(model, systemPrompt, userPrompt) {
4
+ export async function generateWithClaude(model, systemPrompt, userPrompt, timeoutMs) {
4
5
  const start = Date.now();
5
6
  const fullPrompt = `${systemPrompt}\n\n${userPrompt}`;
7
+ const timeout = getTimeoutMs(timeoutMs);
6
8
  const content = await new Promise((resolve, reject) => {
7
9
  const env = { ...process.env };
8
10
  delete env.ANTHROPIC_API_KEY;
9
11
  const proc = spawn('claude', ['--print', '--model', model], {
10
- timeout: 240_000,
12
+ timeout,
11
13
  env,
12
14
  });
13
15
  let stdout = '';
@@ -21,7 +23,8 @@ export async function generateWithClaude(model, systemPrompt, userPrompt) {
21
23
  proc.on('close', (code) => {
22
24
  if (code !== 0) {
23
25
  const filtered = filterCliStderr(stderr);
24
- reject(new Error(`claude CLI exited ${code}: ${filtered}`));
26
+ const hint = code === 143 ? ` (timed out after ${Math.round(timeout / 1000)}s — try --timeout <ms> or a faster model)` : '';
27
+ reject(new Error(`claude CLI exited ${code}: ${filtered}${hint}`));
25
28
  }
26
29
  else {
27
30
  resolve(stdout.trim());
@@ -1,2 +1,2 @@
1
1
  import type { GenerationResult } from './registry.js';
2
- export declare function generateWithCodex(model: string, systemPrompt: string, userPrompt: string): Promise<GenerationResult>;
2
+ export declare function generateWithCodex(model: string, systemPrompt: string, userPrompt: string, timeoutMs?: number): Promise<GenerationResult>;
@@ -2,11 +2,13 @@ import { spawn } from 'node:child_process';
2
2
  import { mkdtempSync, readFileSync, unlinkSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { tmpdir } from 'node:os';
5
+ import { getTimeoutMs } from './registry.js';
5
6
  import { filterCliStderr } from '../utils.js';
6
- export async function generateWithCodex(model, systemPrompt, userPrompt) {
7
+ export async function generateWithCodex(model, systemPrompt, userPrompt, timeoutMs) {
7
8
  const start = Date.now();
8
9
  const fullPrompt = `${systemPrompt}\n\n${userPrompt}`;
9
10
  const tmpFile = join(mkdtempSync(join(tmpdir(), 'codex-')), 'output.txt');
11
+ const timeout = getTimeoutMs(timeoutMs);
10
12
  const args = [
11
13
  'exec', '-',
12
14
  '--skip-git-repo-check',
@@ -16,7 +18,7 @@ export async function generateWithCodex(model, systemPrompt, userPrompt) {
16
18
  ];
17
19
  const content = await new Promise((resolve, reject) => {
18
20
  const proc = spawn('codex', args, {
19
- timeout: 240_000,
21
+ timeout,
20
22
  env: { ...process.env },
21
23
  });
22
24
  let stderr = '';
@@ -27,7 +29,9 @@ export async function generateWithCodex(model, systemPrompt, userPrompt) {
27
29
  const filtered = filterCliStderr(stderr);
28
30
  const hint = filtered.includes('401') || filtered.includes('Unauthorized')
29
31
  ? ' (ensure codex is authenticated via `codex auth`)'
30
- : '';
32
+ : code === 143
33
+ ? ` (timed out after ${Math.round(timeout / 1000)}s — try --timeout <ms> or a faster model)`
34
+ : '';
31
35
  try {
32
36
  const output = readFileSync(tmpFile, 'utf-8').trim();
33
37
  unlinkSync(tmpFile);
@@ -9,8 +9,10 @@ export interface ProviderConfig {
9
9
  models: string[];
10
10
  defaultModel: string;
11
11
  isAvailable: () => Promise<boolean>;
12
- generate: (model: string, systemPrompt: string, userPrompt: string) => Promise<GenerationResult>;
12
+ generate: (model: string, systemPrompt: string, userPrompt: string, timeoutMs?: number) => Promise<GenerationResult>;
13
13
  }
14
+ /** Get timeout with optional user override */
15
+ export declare function getTimeoutMs(userOverride?: number): number;
14
16
  export declare const PROVIDERS: Record<string, ProviderConfig>;
15
17
  /** Detect which providers are available (binary + auth) */
16
18
  export declare function detectAvailableProviders(): Promise<string[]>;
@@ -4,6 +4,12 @@ import { homedir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { generateWithClaude } from './claude.js';
6
6
  import { generateWithCodex } from './codex.js';
7
+ /** Default CLI subprocess timeout: 10 minutes */
8
+ const DEFAULT_TIMEOUT_MS = 600_000;
9
+ /** Get timeout with optional user override */
10
+ export function getTimeoutMs(userOverride) {
11
+ return userOverride ?? DEFAULT_TIMEOUT_MS;
12
+ }
7
13
  const home = homedir();
8
14
  function hasBinary(name) {
9
15
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afromero/kin3o",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
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,9 @@
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
+ "lottie-react": "^2.4.1",
45
+ "open": "^10.1.0",
46
+ "puppeteer-core": "^24.0.0"
45
47
  },
46
48
  "devDependencies": {
47
49
  "@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>