@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 +36 -0
- package/dist/export.d.ts +31 -0
- package/dist/export.js +265 -0
- package/dist/index.js +74 -2
- package/dist/providers/claude.d.ts +1 -1
- package/dist/providers/claude.js +6 -3
- package/dist/providers/codex.d.ts +1 -1
- package/dist/providers/codex.js +7 -3
- package/dist/providers/registry.d.ts +3 -1
- package/dist/providers/registry.js +6 -0
- package/package.json +4 -2
- package/preview/template-export.html +47 -0
package/README.md
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
**Text to Motion. From your terminal.**
|
|
6
6
|
|
|
7
7
|
[](https://www.npmjs.com/package/@afromero/kin3o)
|
|
8
|
+
[](https://www.npmjs.com/package/@afromero/kin3o)
|
|
8
9
|
[](https://github.com/affromero/kin3o/actions/workflows/ci.yml)
|
|
9
10
|
[](https://www.typescriptlang.org/)
|
|
10
11
|
[](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
|
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')
|
|
@@ -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>;
|
package/dist/providers/claude.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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>;
|
package/dist/providers/codex.js
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
"
|
|
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>
|