@kernel.chat/kbot 2.23.1 → 2.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +6 -1
- package/dist/agent.js.map +1 -1
- package/dist/pair.d.ts +81 -0
- package/dist/pair.d.ts.map +1 -0
- package/dist/pair.js +993 -0
- package/dist/pair.js.map +1 -0
- package/dist/plugin-sdk.d.ts +136 -0
- package/dist/plugin-sdk.d.ts.map +1 -0
- package/dist/plugin-sdk.js +946 -0
- package/dist/plugin-sdk.js.map +1 -0
- package/dist/record.d.ts +174 -0
- package/dist/record.d.ts.map +1 -0
- package/dist/record.js +1182 -0
- package/dist/record.js.map +1 -0
- package/dist/team.d.ts +106 -0
- package/dist/team.d.ts.map +1 -0
- package/dist/team.js +917 -0
- package/dist/team.js.map +1 -0
- package/dist/tools/database.d.ts +2 -0
- package/dist/tools/database.d.ts.map +1 -0
- package/dist/tools/database.js +751 -0
- package/dist/tools/database.js.map +1 -0
- package/dist/tools/deploy.d.ts +2 -0
- package/dist/tools/deploy.d.ts.map +1 -0
- package/dist/tools/deploy.js +824 -0
- package/dist/tools/deploy.js.map +1 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +11 -1
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/mcp-marketplace.d.ts +2 -0
- package/dist/tools/mcp-marketplace.d.ts.map +1 -0
- package/dist/tools/mcp-marketplace.js +759 -0
- package/dist/tools/mcp-marketplace.js.map +1 -0
- package/package.json +25 -3
package/dist/record.js
ADDED
|
@@ -0,0 +1,1182 @@
|
|
|
1
|
+
// K:BOT Terminal Recording — Capture sessions as SVG, GIF, or Asciicast
|
|
2
|
+
//
|
|
3
|
+
// Records terminal sessions with full timing data for animated playback.
|
|
4
|
+
// Output formats:
|
|
5
|
+
// - SVG: Animated terminal replay for READMEs (no dependencies)
|
|
6
|
+
// - GIF: Via ImageMagick/ffmpeg if available
|
|
7
|
+
// - Asciicast v2: Compatible with asciinema player
|
|
8
|
+
// - JSON: Raw frame data for custom renderers
|
|
9
|
+
//
|
|
10
|
+
// Usage:
|
|
11
|
+
// kbot record start # Start recording
|
|
12
|
+
// kbot record stop # Stop + choose format
|
|
13
|
+
// kbot record start --output demo.svg # Auto-save as SVG
|
|
14
|
+
// kbot record list # Show saved recordings
|
|
15
|
+
// kbot record replay <file> # Replay in terminal
|
|
16
|
+
import { homedir, platform } from 'node:os';
|
|
17
|
+
import { join, extname, basename } from 'node:path';
|
|
18
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, unlinkSync, statSync, } from 'node:fs';
|
|
19
|
+
import { spawn, execSync } from 'node:child_process';
|
|
20
|
+
// ── Paths ──
|
|
21
|
+
const RECORDINGS_DIR = join(homedir(), '.kbot', 'recordings');
|
|
22
|
+
const STATE_FILE = join(RECORDINGS_DIR, '.recording-state.json');
|
|
23
|
+
function ensureDir() {
|
|
24
|
+
if (!existsSync(RECORDINGS_DIR))
|
|
25
|
+
mkdirSync(RECORDINGS_DIR, { recursive: true });
|
|
26
|
+
}
|
|
27
|
+
const THEMES = {
|
|
28
|
+
dark: {
|
|
29
|
+
bg: '#1a1a2e',
|
|
30
|
+
fg: '#e0e0e0',
|
|
31
|
+
titleBg: '#16162a',
|
|
32
|
+
titleFg: '#8888aa',
|
|
33
|
+
dotRed: '#ff5f57',
|
|
34
|
+
dotYellow: '#febc2e',
|
|
35
|
+
dotGreen: '#28c840',
|
|
36
|
+
cursor: '#a78bfa',
|
|
37
|
+
bold: '#ffffff',
|
|
38
|
+
dim: '#666680',
|
|
39
|
+
},
|
|
40
|
+
light: {
|
|
41
|
+
bg: '#fafaf8',
|
|
42
|
+
fg: '#2e2e2e',
|
|
43
|
+
titleBg: '#e8e8e4',
|
|
44
|
+
titleFg: '#888888',
|
|
45
|
+
dotRed: '#ff5f57',
|
|
46
|
+
dotYellow: '#febc2e',
|
|
47
|
+
dotGreen: '#28c840',
|
|
48
|
+
cursor: '#6B5B95',
|
|
49
|
+
bold: '#000000',
|
|
50
|
+
dim: '#999999',
|
|
51
|
+
},
|
|
52
|
+
monokai: {
|
|
53
|
+
bg: '#272822',
|
|
54
|
+
fg: '#f8f8f2',
|
|
55
|
+
titleBg: '#1e1f1c',
|
|
56
|
+
titleFg: '#75715e',
|
|
57
|
+
dotRed: '#f92672',
|
|
58
|
+
dotYellow: '#e6db74',
|
|
59
|
+
dotGreen: '#a6e22e',
|
|
60
|
+
cursor: '#fd971f',
|
|
61
|
+
bold: '#ffffff',
|
|
62
|
+
dim: '#75715e',
|
|
63
|
+
},
|
|
64
|
+
dracula: {
|
|
65
|
+
bg: '#282a36',
|
|
66
|
+
fg: '#f8f8f2',
|
|
67
|
+
titleBg: '#21222c',
|
|
68
|
+
titleFg: '#6272a4',
|
|
69
|
+
dotRed: '#ff5555',
|
|
70
|
+
dotYellow: '#f1fa8c',
|
|
71
|
+
dotGreen: '#50fa7b',
|
|
72
|
+
cursor: '#bd93f9',
|
|
73
|
+
bold: '#ffffff',
|
|
74
|
+
dim: '#6272a4',
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
// ── ANSI stripping ──
|
|
78
|
+
/** Strip ANSI escape codes from a string for measuring / rendering */
|
|
79
|
+
function stripAnsi(str) {
|
|
80
|
+
// eslint-disable-next-line no-control-regex
|
|
81
|
+
return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
|
|
82
|
+
.replace(/\x1b\][^\x07]*\x07/g, '') // OSC sequences
|
|
83
|
+
.replace(/\x1b\(B/g, '') // charset selection
|
|
84
|
+
.replace(/\r/g, ''); // carriage returns
|
|
85
|
+
}
|
|
86
|
+
/** Escape XML special characters */
|
|
87
|
+
function escapeXML(str) {
|
|
88
|
+
return str
|
|
89
|
+
.replace(/&/g, '&')
|
|
90
|
+
.replace(/</g, '<')
|
|
91
|
+
.replace(/>/g, '>')
|
|
92
|
+
.replace(/"/g, '"')
|
|
93
|
+
.replace(/'/g, ''');
|
|
94
|
+
}
|
|
95
|
+
// ── Recording ID generation ──
|
|
96
|
+
function generateId() {
|
|
97
|
+
const now = new Date();
|
|
98
|
+
const date = now.toISOString().split('T')[0].replace(/-/g, '');
|
|
99
|
+
const time = now.toTimeString().split(' ')[0].replace(/:/g, '');
|
|
100
|
+
const rand = Math.random().toString(36).slice(2, 6);
|
|
101
|
+
return `rec-${date}-${time}-${rand}`;
|
|
102
|
+
}
|
|
103
|
+
// ── Recording state management ──
|
|
104
|
+
function saveState(state) {
|
|
105
|
+
ensureDir();
|
|
106
|
+
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), 'utf-8');
|
|
107
|
+
}
|
|
108
|
+
function loadState() {
|
|
109
|
+
if (!existsSync(STATE_FILE))
|
|
110
|
+
return null;
|
|
111
|
+
try {
|
|
112
|
+
return JSON.parse(readFileSync(STATE_FILE, 'utf-8'));
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function clearState() {
|
|
119
|
+
if (existsSync(STATE_FILE))
|
|
120
|
+
unlinkSync(STATE_FILE);
|
|
121
|
+
}
|
|
122
|
+
function isRecording() {
|
|
123
|
+
const state = loadState();
|
|
124
|
+
if (!state)
|
|
125
|
+
return false;
|
|
126
|
+
// Check if the process is still alive
|
|
127
|
+
try {
|
|
128
|
+
process.kill(state.pid, 0);
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// Process is dead — clean up stale state
|
|
133
|
+
clearState();
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// ── Start / Stop recording ──
|
|
138
|
+
/**
|
|
139
|
+
* Start recording the terminal session.
|
|
140
|
+
*
|
|
141
|
+
* Uses the `script` command (POSIX) to capture all terminal output
|
|
142
|
+
* with timing data. Works on macOS and Linux.
|
|
143
|
+
*/
|
|
144
|
+
export function startRecording(options = {}) {
|
|
145
|
+
if (isRecording()) {
|
|
146
|
+
return { success: false, message: 'A recording is already in progress. Run `kbot record stop` first.' };
|
|
147
|
+
}
|
|
148
|
+
ensureDir();
|
|
149
|
+
const id = generateId();
|
|
150
|
+
const cols = options.cols || process.stdout.columns || 80;
|
|
151
|
+
const rows = options.rows || process.stdout.rows || 24;
|
|
152
|
+
const shell = options.shell || process.env.SHELL || '/bin/bash';
|
|
153
|
+
const title = options.title || `K:BOT Recording ${id}`;
|
|
154
|
+
const scriptFile = join(RECORDINGS_DIR, `${id}.script`);
|
|
155
|
+
const timingFile = join(RECORDINGS_DIR, `${id}.timing`);
|
|
156
|
+
// Build the script command based on platform
|
|
157
|
+
let child;
|
|
158
|
+
if (platform() === 'darwin') {
|
|
159
|
+
// macOS: script -q <file> uses a subshell;
|
|
160
|
+
// timing is captured with our own wrapper
|
|
161
|
+
child = spawn('script', ['-q', scriptFile], {
|
|
162
|
+
stdio: 'inherit',
|
|
163
|
+
env: {
|
|
164
|
+
...process.env,
|
|
165
|
+
KBOT_RECORDING: '1',
|
|
166
|
+
KBOT_RECORDING_ID: id,
|
|
167
|
+
COLUMNS: String(cols),
|
|
168
|
+
LINES: String(rows),
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
// Linux: script -q --timing=<file> -c <shell> <file>
|
|
174
|
+
child = spawn('script', [
|
|
175
|
+
'-q',
|
|
176
|
+
`--timing=${timingFile}`,
|
|
177
|
+
'-c', shell,
|
|
178
|
+
scriptFile,
|
|
179
|
+
], {
|
|
180
|
+
stdio: 'inherit',
|
|
181
|
+
env: {
|
|
182
|
+
...process.env,
|
|
183
|
+
KBOT_RECORDING: '1',
|
|
184
|
+
KBOT_RECORDING_ID: id,
|
|
185
|
+
COLUMNS: String(cols),
|
|
186
|
+
LINES: String(rows),
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
// Write start time for timing reconstruction
|
|
191
|
+
writeFileSync(timingFile, `${Date.now()}\n`, 'utf-8');
|
|
192
|
+
const state = {
|
|
193
|
+
pid: child.pid,
|
|
194
|
+
id,
|
|
195
|
+
startedAt: new Date().toISOString(),
|
|
196
|
+
scriptFile,
|
|
197
|
+
timingFile,
|
|
198
|
+
output: options.output,
|
|
199
|
+
title: options.title,
|
|
200
|
+
cols,
|
|
201
|
+
rows,
|
|
202
|
+
shell,
|
|
203
|
+
};
|
|
204
|
+
saveState(state);
|
|
205
|
+
// When the child exits, we process the recording
|
|
206
|
+
child.on('exit', () => {
|
|
207
|
+
processRecordingOnExit(state);
|
|
208
|
+
});
|
|
209
|
+
return {
|
|
210
|
+
success: true,
|
|
211
|
+
message: `Recording started (${id}). Type \`exit\` or press Ctrl-D to stop.`,
|
|
212
|
+
id,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Stop an active recording session.
|
|
217
|
+
*/
|
|
218
|
+
export function stopRecording() {
|
|
219
|
+
const state = loadState();
|
|
220
|
+
if (!state) {
|
|
221
|
+
return { success: false, message: 'No active recording found.' };
|
|
222
|
+
}
|
|
223
|
+
// Send SIGHUP to the script process to terminate it
|
|
224
|
+
try {
|
|
225
|
+
process.kill(state.pid, 'SIGHUP');
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
// Process may already be dead
|
|
229
|
+
}
|
|
230
|
+
// Wait briefly for the file to be flushed, then process
|
|
231
|
+
const recording = buildRecordingFromScript(state);
|
|
232
|
+
if (!recording) {
|
|
233
|
+
clearState();
|
|
234
|
+
return { success: false, message: 'Failed to read recording data.' };
|
|
235
|
+
}
|
|
236
|
+
// Save the recording JSON
|
|
237
|
+
const jsonPath = join(RECORDINGS_DIR, `${state.id}.json`);
|
|
238
|
+
writeFileSync(jsonPath, JSON.stringify(recording, null, 2), 'utf-8');
|
|
239
|
+
// Determine output format
|
|
240
|
+
let outputPath;
|
|
241
|
+
if (state.output) {
|
|
242
|
+
const ext = extname(state.output).toLowerCase();
|
|
243
|
+
switch (ext) {
|
|
244
|
+
case '.svg':
|
|
245
|
+
writeFileSync(state.output, toSVG(recording), 'utf-8');
|
|
246
|
+
outputPath = state.output;
|
|
247
|
+
break;
|
|
248
|
+
case '.gif':
|
|
249
|
+
outputPath = toGIF(recording, state.output);
|
|
250
|
+
break;
|
|
251
|
+
case '.cast':
|
|
252
|
+
writeFileSync(state.output, toAsciicast(recording), 'utf-8');
|
|
253
|
+
outputPath = state.output;
|
|
254
|
+
break;
|
|
255
|
+
default:
|
|
256
|
+
writeFileSync(state.output, JSON.stringify(recording, null, 2), 'utf-8');
|
|
257
|
+
outputPath = state.output;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// Clean up intermediate files
|
|
261
|
+
cleanupTempFiles(state);
|
|
262
|
+
clearState();
|
|
263
|
+
return {
|
|
264
|
+
success: true,
|
|
265
|
+
message: outputPath
|
|
266
|
+
? `Recording saved: ${outputPath} (${recording.duration.toFixed(1)}s, ${recording.frames.length} frames)`
|
|
267
|
+
: `Recording saved: ${jsonPath} (${recording.duration.toFixed(1)}s, ${recording.frames.length} frames)`,
|
|
268
|
+
recording,
|
|
269
|
+
outputPath: outputPath || jsonPath,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
/** Process a recording after the script session exits naturally */
|
|
273
|
+
function processRecordingOnExit(state) {
|
|
274
|
+
try {
|
|
275
|
+
const recording = buildRecordingFromScript(state);
|
|
276
|
+
if (!recording) {
|
|
277
|
+
clearState();
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const jsonPath = join(RECORDINGS_DIR, `${state.id}.json`);
|
|
281
|
+
writeFileSync(jsonPath, JSON.stringify(recording, null, 2), 'utf-8');
|
|
282
|
+
if (state.output) {
|
|
283
|
+
const ext = extname(state.output).toLowerCase();
|
|
284
|
+
switch (ext) {
|
|
285
|
+
case '.svg':
|
|
286
|
+
writeFileSync(state.output, toSVG(recording), 'utf-8');
|
|
287
|
+
break;
|
|
288
|
+
case '.gif':
|
|
289
|
+
toGIF(recording, state.output);
|
|
290
|
+
break;
|
|
291
|
+
case '.cast':
|
|
292
|
+
writeFileSync(state.output, toAsciicast(recording), 'utf-8');
|
|
293
|
+
break;
|
|
294
|
+
default:
|
|
295
|
+
writeFileSync(state.output, JSON.stringify(recording, null, 2), 'utf-8');
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
cleanupTempFiles(state);
|
|
299
|
+
clearState();
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
clearState();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
/** Build a Recording from script output + timing data */
|
|
306
|
+
function buildRecordingFromScript(state) {
|
|
307
|
+
if (!existsSync(state.scriptFile))
|
|
308
|
+
return null;
|
|
309
|
+
const rawScript = readFileSync(state.scriptFile, 'utf-8');
|
|
310
|
+
const startTime = parseTimingFile(state.timingFile);
|
|
311
|
+
// Split the raw script output into frames
|
|
312
|
+
// Each line break or substantial chunk of output becomes a frame
|
|
313
|
+
const frames = buildFrames(rawScript, startTime);
|
|
314
|
+
const duration = frames.length > 0 ? frames[frames.length - 1].time : 0;
|
|
315
|
+
return {
|
|
316
|
+
id: state.id,
|
|
317
|
+
title: state.title || `K:BOT Recording ${state.id}`,
|
|
318
|
+
startedAt: state.startedAt,
|
|
319
|
+
duration,
|
|
320
|
+
cols: state.cols,
|
|
321
|
+
rows: state.rows,
|
|
322
|
+
frames,
|
|
323
|
+
env: {
|
|
324
|
+
shell: state.shell,
|
|
325
|
+
term: process.env.TERM || 'xterm-256color',
|
|
326
|
+
platform: platform(),
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
/** Parse the timing file to get the start timestamp */
|
|
331
|
+
function parseTimingFile(timingFile) {
|
|
332
|
+
if (!existsSync(timingFile))
|
|
333
|
+
return Date.now();
|
|
334
|
+
try {
|
|
335
|
+
const content = readFileSync(timingFile, 'utf-8').trim();
|
|
336
|
+
const firstLine = content.split('\n')[0];
|
|
337
|
+
const ts = parseInt(firstLine, 10);
|
|
338
|
+
return isNaN(ts) ? Date.now() : ts;
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
return Date.now();
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Build frame array from raw script output.
|
|
346
|
+
*
|
|
347
|
+
* Splits output into logical frames: each line or burst of output
|
|
348
|
+
* becomes a separate frame with simulated timing based on content length.
|
|
349
|
+
*/
|
|
350
|
+
function buildFrames(rawOutput, _startTime) {
|
|
351
|
+
const frames = [];
|
|
352
|
+
// Split on newlines, preserving the newline in each chunk
|
|
353
|
+
const lines = rawOutput.split('\n');
|
|
354
|
+
let elapsed = 0;
|
|
355
|
+
for (const line of lines) {
|
|
356
|
+
if (line.length === 0 && frames.length > 0) {
|
|
357
|
+
// Empty line — small pause
|
|
358
|
+
elapsed += 0.05;
|
|
359
|
+
frames.push({ time: elapsed, data: '\n' });
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
// Simulate realistic typing timing:
|
|
363
|
+
// - Short lines (prompts, commands): 0.03s per char (typing)
|
|
364
|
+
// - Long lines (output): burst at 0.001s per char
|
|
365
|
+
const stripped = stripAnsi(line);
|
|
366
|
+
const isTyping = stripped.length < 80 && (stripped.includes('$') || stripped.includes('>')
|
|
367
|
+
|| stripped.includes('#') || stripped.startsWith('kbot'));
|
|
368
|
+
if (isTyping) {
|
|
369
|
+
// Character-by-character typing for prompts
|
|
370
|
+
for (const char of line) {
|
|
371
|
+
elapsed += 0.03 + Math.random() * 0.05;
|
|
372
|
+
frames.push({ time: elapsed, data: char });
|
|
373
|
+
}
|
|
374
|
+
elapsed += 0.02;
|
|
375
|
+
frames.push({ time: elapsed, data: '\n' });
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
// Burst output
|
|
379
|
+
elapsed += 0.01 + (stripped.length * 0.001);
|
|
380
|
+
frames.push({ time: elapsed, data: line + '\n' });
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return frames;
|
|
384
|
+
}
|
|
385
|
+
/** Clean up intermediate .script and .timing files */
|
|
386
|
+
function cleanupTempFiles(state) {
|
|
387
|
+
try {
|
|
388
|
+
if (existsSync(state.scriptFile))
|
|
389
|
+
unlinkSync(state.scriptFile);
|
|
390
|
+
}
|
|
391
|
+
catch { /* ignore */ }
|
|
392
|
+
try {
|
|
393
|
+
if (existsSync(state.timingFile))
|
|
394
|
+
unlinkSync(state.timingFile);
|
|
395
|
+
}
|
|
396
|
+
catch { /* ignore */ }
|
|
397
|
+
}
|
|
398
|
+
// ── SVG Generation ──
|
|
399
|
+
/**
|
|
400
|
+
* Convert a recording to an animated SVG.
|
|
401
|
+
*
|
|
402
|
+
* Produces a self-contained SVG with CSS keyframe animations that
|
|
403
|
+
* replays the terminal session. Compatible with GitHub READMEs,
|
|
404
|
+
* browsers, and any SVG viewer.
|
|
405
|
+
*/
|
|
406
|
+
export function toSVG(recording, options = {}) {
|
|
407
|
+
const theme = THEMES[options.theme || 'dark'] || THEMES.dark;
|
|
408
|
+
const cols = options.cols || recording.cols || 80;
|
|
409
|
+
const rows = options.rows || recording.rows || 24;
|
|
410
|
+
const fontSize = options.fontSize || 14;
|
|
411
|
+
const chrome = options.chrome !== false;
|
|
412
|
+
const speed = options.speed || 1;
|
|
413
|
+
const loop = options.loop !== false;
|
|
414
|
+
const title = options.title || recording.title || 'K:BOT Terminal';
|
|
415
|
+
const charWidth = fontSize * 0.6;
|
|
416
|
+
const lineHeight = fontSize * 1.4;
|
|
417
|
+
const padding = 16;
|
|
418
|
+
const chromeHeight = chrome ? 40 : 0;
|
|
419
|
+
const width = Math.ceil(cols * charWidth + padding * 2);
|
|
420
|
+
const height = Math.ceil(rows * lineHeight + padding * 2 + chromeHeight);
|
|
421
|
+
// Build screen states from frames
|
|
422
|
+
const screenStates = buildScreenStates(recording.frames, cols, rows, speed);
|
|
423
|
+
const totalDuration = screenStates.length > 0
|
|
424
|
+
? screenStates[screenStates.length - 1].time + 2
|
|
425
|
+
: 5;
|
|
426
|
+
// Generate SVG
|
|
427
|
+
const lines = [];
|
|
428
|
+
lines.push(`<?xml version="1.0" encoding="UTF-8"?>`);
|
|
429
|
+
lines.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`);
|
|
430
|
+
lines.push(`<defs>`);
|
|
431
|
+
lines.push(` <style>`);
|
|
432
|
+
lines.push(` @keyframes cursor-blink {`);
|
|
433
|
+
lines.push(` 0%, 50% { opacity: 1; }`);
|
|
434
|
+
lines.push(` 51%, 100% { opacity: 0; }`);
|
|
435
|
+
lines.push(` }`);
|
|
436
|
+
// Generate visibility keyframes for each screen state
|
|
437
|
+
for (let i = 0; i < screenStates.length; i++) {
|
|
438
|
+
const startPct = (screenStates[i].time / totalDuration) * 100;
|
|
439
|
+
const endPct = i < screenStates.length - 1
|
|
440
|
+
? (screenStates[i + 1].time / totalDuration) * 100
|
|
441
|
+
: 100;
|
|
442
|
+
lines.push(` @keyframes frame-${i} {`);
|
|
443
|
+
lines.push(` 0%, ${Math.max(0, startPct - 0.01).toFixed(2)}% { visibility: hidden; }`);
|
|
444
|
+
lines.push(` ${startPct.toFixed(2)}%, ${endPct.toFixed(2)}% { visibility: visible; }`);
|
|
445
|
+
if (endPct < 100) {
|
|
446
|
+
lines.push(` ${(endPct + 0.01).toFixed(2)}%, 100% { visibility: hidden; }`);
|
|
447
|
+
}
|
|
448
|
+
lines.push(` }`);
|
|
449
|
+
}
|
|
450
|
+
lines.push(` </style>`);
|
|
451
|
+
lines.push(`</defs>`);
|
|
452
|
+
// Background
|
|
453
|
+
lines.push(`<rect width="${width}" height="${height}" rx="8" ry="8" fill="${theme.bg}"/>`);
|
|
454
|
+
// Terminal chrome (title bar with traffic light dots)
|
|
455
|
+
if (chrome) {
|
|
456
|
+
lines.push(`<rect width="${width}" height="${chromeHeight}" rx="8" ry="0" fill="${theme.titleBg}"/>`);
|
|
457
|
+
// Clip the bottom corners of the title bar
|
|
458
|
+
lines.push(`<rect x="0" y="${chromeHeight - 8}" width="${width}" height="8" fill="${theme.titleBg}"/>`);
|
|
459
|
+
// Traffic light dots
|
|
460
|
+
const dotY = chromeHeight / 2;
|
|
461
|
+
const dotR = 6;
|
|
462
|
+
lines.push(`<circle cx="${padding + dotR}" cy="${dotY}" r="${dotR}" fill="${theme.dotRed}"/>`);
|
|
463
|
+
lines.push(`<circle cx="${padding + dotR * 3 + 4}" cy="${dotY}" r="${dotR}" fill="${theme.dotYellow}"/>`);
|
|
464
|
+
lines.push(`<circle cx="${padding + dotR * 5 + 8}" cy="${dotY}" r="${dotR}" fill="${theme.dotGreen}"/>`);
|
|
465
|
+
// Title text
|
|
466
|
+
const titleX = width / 2;
|
|
467
|
+
lines.push(`<text x="${titleX}" y="${dotY + 4}" fill="${theme.titleFg}" font-family="monospace" font-size="${fontSize - 2}" text-anchor="middle">${escapeXML(title)}</text>`);
|
|
468
|
+
}
|
|
469
|
+
// Render each screen state as a group with visibility animation
|
|
470
|
+
const contentY = chromeHeight + padding;
|
|
471
|
+
for (let i = 0; i < screenStates.length; i++) {
|
|
472
|
+
const state = screenStates[i];
|
|
473
|
+
const animDuration = totalDuration.toFixed(2);
|
|
474
|
+
const iterCount = loop ? 'infinite' : '1';
|
|
475
|
+
lines.push(`<g style="visibility:hidden;animation:frame-${i} ${animDuration}s linear ${iterCount}">`);
|
|
476
|
+
// Render each line of the screen state
|
|
477
|
+
for (let row = 0; row < state.lines.length && row < rows; row++) {
|
|
478
|
+
const line = state.lines[row];
|
|
479
|
+
if (!line || line.trim().length === 0)
|
|
480
|
+
continue;
|
|
481
|
+
const x = padding;
|
|
482
|
+
const y = contentY + (row + 1) * lineHeight;
|
|
483
|
+
lines.push(` <text x="${x}" y="${y}" fill="${theme.fg}" font-family="'Courier New',monospace" font-size="${fontSize}" xml:space="preserve">${escapeXML(line)}</text>`);
|
|
484
|
+
}
|
|
485
|
+
// Cursor (blinking block at end of last non-empty line)
|
|
486
|
+
const lastLineIdx = findLastNonEmptyLine(state.lines);
|
|
487
|
+
if (lastLineIdx >= 0) {
|
|
488
|
+
const cursorX = padding + state.lines[lastLineIdx].length * charWidth;
|
|
489
|
+
const cursorY = contentY + lastLineIdx * lineHeight + 2;
|
|
490
|
+
lines.push(` <rect x="${cursorX}" y="${cursorY}" width="${charWidth}" height="${lineHeight}" fill="${theme.cursor}" opacity="0.7" style="animation:cursor-blink 1s step-end infinite"/>`);
|
|
491
|
+
}
|
|
492
|
+
lines.push(`</g>`);
|
|
493
|
+
}
|
|
494
|
+
// Watermark
|
|
495
|
+
lines.push(`<text x="${width - padding}" y="${height - 6}" fill="${theme.dim}" font-family="monospace" font-size="10" text-anchor="end" opacity="0.5">recorded with kbot</text>`);
|
|
496
|
+
lines.push(`</svg>`);
|
|
497
|
+
return lines.join('\n');
|
|
498
|
+
}
|
|
499
|
+
/** Find the last non-empty line index */
|
|
500
|
+
function findLastNonEmptyLine(lines) {
|
|
501
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
502
|
+
if (lines[i] && lines[i].trim().length > 0)
|
|
503
|
+
return i;
|
|
504
|
+
}
|
|
505
|
+
return -1;
|
|
506
|
+
}
|
|
507
|
+
function buildScreenStates(frames, cols, rows, speed) {
|
|
508
|
+
const states = [];
|
|
509
|
+
const screen = new Array(rows).fill('');
|
|
510
|
+
let cursorRow = 0;
|
|
511
|
+
// Accumulate frames and snapshot at intervals
|
|
512
|
+
// For SVG, we snapshot every ~0.1 seconds of real content change
|
|
513
|
+
let lastSnapshotTime = -1;
|
|
514
|
+
const minInterval = 0.1 / speed;
|
|
515
|
+
let pendingData = '';
|
|
516
|
+
for (const frame of frames) {
|
|
517
|
+
pendingData += frame.data;
|
|
518
|
+
const adjustedTime = frame.time / speed;
|
|
519
|
+
if (adjustedTime - lastSnapshotTime < minInterval)
|
|
520
|
+
continue;
|
|
521
|
+
// Apply pending data to the virtual screen
|
|
522
|
+
const result = applyToScreen(screen, cursorRow, pendingData, cols, rows);
|
|
523
|
+
cursorRow = result.cursorRow;
|
|
524
|
+
pendingData = '';
|
|
525
|
+
// Take a snapshot
|
|
526
|
+
states.push({
|
|
527
|
+
time: adjustedTime,
|
|
528
|
+
lines: [...screen],
|
|
529
|
+
});
|
|
530
|
+
lastSnapshotTime = adjustedTime;
|
|
531
|
+
}
|
|
532
|
+
// Final state with remaining data
|
|
533
|
+
if (pendingData.length > 0) {
|
|
534
|
+
const result = applyToScreen(screen, cursorRow, pendingData, cols, rows);
|
|
535
|
+
cursorRow = result.cursorRow;
|
|
536
|
+
const finalTime = frames.length > 0 ? frames[frames.length - 1].time / speed : 0;
|
|
537
|
+
states.push({
|
|
538
|
+
time: finalTime,
|
|
539
|
+
lines: [...screen],
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
// Deduplicate identical consecutive states
|
|
543
|
+
const deduped = [];
|
|
544
|
+
for (const state of states) {
|
|
545
|
+
if (deduped.length === 0) {
|
|
546
|
+
deduped.push(state);
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
const prev = deduped[deduped.length - 1];
|
|
550
|
+
const same = prev.lines.length === state.lines.length
|
|
551
|
+
&& prev.lines.every((l, i) => l === state.lines[i]);
|
|
552
|
+
if (!same) {
|
|
553
|
+
deduped.push(state);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
// Cap at a reasonable number of frames for SVG size
|
|
557
|
+
const MAX_SVG_FRAMES = 200;
|
|
558
|
+
if (deduped.length > MAX_SVG_FRAMES) {
|
|
559
|
+
const step = Math.ceil(deduped.length / MAX_SVG_FRAMES);
|
|
560
|
+
const sampled = [];
|
|
561
|
+
for (let i = 0; i < deduped.length; i += step) {
|
|
562
|
+
sampled.push(deduped[i]);
|
|
563
|
+
}
|
|
564
|
+
// Always include the last frame
|
|
565
|
+
if (sampled[sampled.length - 1] !== deduped[deduped.length - 1]) {
|
|
566
|
+
sampled.push(deduped[deduped.length - 1]);
|
|
567
|
+
}
|
|
568
|
+
return sampled;
|
|
569
|
+
}
|
|
570
|
+
return deduped;
|
|
571
|
+
}
|
|
572
|
+
/** Apply raw data to a virtual screen buffer, handling newlines and basic ANSI */
|
|
573
|
+
function applyToScreen(screen, cursorRow, data, cols, rows) {
|
|
574
|
+
const clean = stripAnsi(data);
|
|
575
|
+
for (const char of clean) {
|
|
576
|
+
if (char === '\n') {
|
|
577
|
+
cursorRow++;
|
|
578
|
+
if (cursorRow >= rows) {
|
|
579
|
+
// Scroll up
|
|
580
|
+
screen.shift();
|
|
581
|
+
screen.push('');
|
|
582
|
+
cursorRow = rows - 1;
|
|
583
|
+
}
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
if (char === '\r')
|
|
587
|
+
continue; // Ignore carriage returns
|
|
588
|
+
if (char === '\t') {
|
|
589
|
+
// Tab: advance to next 8-col boundary
|
|
590
|
+
const currentLen = screen[cursorRow]?.length || 0;
|
|
591
|
+
const spaces = 8 - (currentLen % 8);
|
|
592
|
+
screen[cursorRow] = (screen[cursorRow] || '') + ' '.repeat(spaces);
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
// Printable character
|
|
596
|
+
if (cursorRow >= 0 && cursorRow < rows) {
|
|
597
|
+
const line = screen[cursorRow] || '';
|
|
598
|
+
if (line.length < cols) {
|
|
599
|
+
screen[cursorRow] = line + char;
|
|
600
|
+
}
|
|
601
|
+
// If line is at max cols, next char would wrap but we just truncate for SVG
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return { cursorRow };
|
|
605
|
+
}
|
|
606
|
+
// ── GIF Generation ──
|
|
607
|
+
/**
|
|
608
|
+
* Convert a recording to an animated GIF.
|
|
609
|
+
*
|
|
610
|
+
* Shells out to ImageMagick (`convert`) or `ffmpeg` if available.
|
|
611
|
+
* Falls back to SVG if no image tools are installed.
|
|
612
|
+
*
|
|
613
|
+
* @returns The output file path, or undefined if generation failed.
|
|
614
|
+
*/
|
|
615
|
+
export function toGIF(recording, outputPath, options = {}) {
|
|
616
|
+
const cols = options.cols || recording.cols || 80;
|
|
617
|
+
const rows = options.rows || recording.rows || 24;
|
|
618
|
+
const fontSize = options.fontSize || 14;
|
|
619
|
+
const theme = THEMES[options.theme || 'dark'] || THEMES.dark;
|
|
620
|
+
const speed = options.speed || 1;
|
|
621
|
+
const fps = options.fps || 10;
|
|
622
|
+
// Check which tool is available
|
|
623
|
+
const hasConvert = commandExists('convert');
|
|
624
|
+
const hasFfmpeg = commandExists('ffmpeg');
|
|
625
|
+
if (!hasConvert && !hasFfmpeg) {
|
|
626
|
+
// Fall back to SVG
|
|
627
|
+
const svgPath = outputPath.replace(/\.gif$/i, '.svg');
|
|
628
|
+
writeFileSync(svgPath, toSVG(recording, { theme: options.theme, speed }), 'utf-8');
|
|
629
|
+
return svgPath;
|
|
630
|
+
}
|
|
631
|
+
// Build screen states
|
|
632
|
+
const screenStates = buildScreenStates(recording.frames, cols, rows, speed);
|
|
633
|
+
if (screenStates.length === 0)
|
|
634
|
+
return undefined;
|
|
635
|
+
// Create temporary directory for frame PNGs
|
|
636
|
+
const tmpDir = join(RECORDINGS_DIR, '.tmp-gif-' + recording.id);
|
|
637
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
638
|
+
try {
|
|
639
|
+
// Generate SVG frames and convert to PNG
|
|
640
|
+
const charWidth = fontSize * 0.6;
|
|
641
|
+
const lineHeight = fontSize * 1.4;
|
|
642
|
+
const padding = 16;
|
|
643
|
+
const chromeHeight = 40;
|
|
644
|
+
const width = Math.ceil(cols * charWidth + padding * 2);
|
|
645
|
+
const height = Math.ceil(rows * lineHeight + padding * 2 + chromeHeight);
|
|
646
|
+
const framePaths = [];
|
|
647
|
+
for (let i = 0; i < screenStates.length; i++) {
|
|
648
|
+
const state = screenStates[i];
|
|
649
|
+
const frameSvg = renderFrameSVG(state, {
|
|
650
|
+
width, height, cols, rows, fontSize, charWidth, lineHeight,
|
|
651
|
+
padding, chromeHeight, theme, title: recording.title,
|
|
652
|
+
});
|
|
653
|
+
const svgPath = join(tmpDir, `frame-${String(i).padStart(5, '0')}.svg`);
|
|
654
|
+
writeFileSync(svgPath, frameSvg, 'utf-8');
|
|
655
|
+
framePaths.push(svgPath);
|
|
656
|
+
}
|
|
657
|
+
// Calculate frame delay (in centiseconds for ImageMagick, ms for ffmpeg)
|
|
658
|
+
const frameDelay = Math.round(100 / fps); // centiseconds
|
|
659
|
+
if (hasConvert) {
|
|
660
|
+
// Use ImageMagick
|
|
661
|
+
try {
|
|
662
|
+
const args = [
|
|
663
|
+
'-delay', String(frameDelay),
|
|
664
|
+
'-loop', '0',
|
|
665
|
+
...framePaths,
|
|
666
|
+
'-layers', 'Optimize',
|
|
667
|
+
outputPath,
|
|
668
|
+
];
|
|
669
|
+
execSync(`convert ${args.map(a => `"${a}"`).join(' ')}`, {
|
|
670
|
+
timeout: 60000,
|
|
671
|
+
stdio: 'pipe',
|
|
672
|
+
});
|
|
673
|
+
return outputPath;
|
|
674
|
+
}
|
|
675
|
+
catch {
|
|
676
|
+
// ImageMagick failed — try ffmpeg
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
if (hasFfmpeg) {
|
|
680
|
+
try {
|
|
681
|
+
// Convert SVGs to PNGs first with ImageMagick or rsvg-convert
|
|
682
|
+
const hasRsvg = commandExists('rsvg-convert');
|
|
683
|
+
const pngPaths = [];
|
|
684
|
+
for (let i = 0; i < framePaths.length; i++) {
|
|
685
|
+
const pngPath = framePaths[i].replace('.svg', '.png');
|
|
686
|
+
if (hasRsvg) {
|
|
687
|
+
execSync(`rsvg-convert "${framePaths[i]}" -o "${pngPath}"`, {
|
|
688
|
+
timeout: 10000,
|
|
689
|
+
stdio: 'pipe',
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
else if (hasConvert) {
|
|
693
|
+
execSync(`convert "${framePaths[i]}" "${pngPath}"`, {
|
|
694
|
+
timeout: 10000,
|
|
695
|
+
stdio: 'pipe',
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
else {
|
|
699
|
+
// Cannot convert SVG to PNG without rsvg-convert or ImageMagick
|
|
700
|
+
// Fall back to SVG output
|
|
701
|
+
const svgPath = outputPath.replace(/\.gif$/i, '.svg');
|
|
702
|
+
writeFileSync(svgPath, toSVG(recording, { theme: options.theme, speed }), 'utf-8');
|
|
703
|
+
return svgPath;
|
|
704
|
+
}
|
|
705
|
+
pngPaths.push(pngPath);
|
|
706
|
+
}
|
|
707
|
+
// Use ffmpeg to create GIF from PNGs
|
|
708
|
+
const inputPattern = join(tmpDir, 'frame-%05d.png');
|
|
709
|
+
execSync(`ffmpeg -y -framerate ${fps} -i "${inputPattern}" -vf "split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse" "${outputPath}"`, { timeout: 60000, stdio: 'pipe' });
|
|
710
|
+
return outputPath;
|
|
711
|
+
}
|
|
712
|
+
catch {
|
|
713
|
+
// ffmpeg failed — fallback to SVG
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
// All methods failed — produce SVG fallback
|
|
717
|
+
const svgPath = outputPath.replace(/\.gif$/i, '.svg');
|
|
718
|
+
writeFileSync(svgPath, toSVG(recording, { theme: options.theme, speed }), 'utf-8');
|
|
719
|
+
return svgPath;
|
|
720
|
+
}
|
|
721
|
+
finally {
|
|
722
|
+
// Clean up temp directory
|
|
723
|
+
try {
|
|
724
|
+
const tmpFiles = readdirSync(tmpDir);
|
|
725
|
+
for (const f of tmpFiles)
|
|
726
|
+
unlinkSync(join(tmpDir, f));
|
|
727
|
+
// Remove the directory itself
|
|
728
|
+
try {
|
|
729
|
+
execSync(`rmdir "${tmpDir}"`, { stdio: 'pipe' });
|
|
730
|
+
}
|
|
731
|
+
catch { /* ignore */ }
|
|
732
|
+
}
|
|
733
|
+
catch { /* ignore */ }
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
/** Render a single frame as a complete SVG (for GIF frame generation) */
|
|
737
|
+
function renderFrameSVG(state, opts) {
|
|
738
|
+
const { width, height, padding, chromeHeight, lineHeight, fontSize, theme, title } = opts;
|
|
739
|
+
const contentY = chromeHeight + padding;
|
|
740
|
+
const lines = [];
|
|
741
|
+
lines.push(`<?xml version="1.0" encoding="UTF-8"?>`);
|
|
742
|
+
lines.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`);
|
|
743
|
+
// Background
|
|
744
|
+
lines.push(`<rect width="${width}" height="${height}" rx="8" ry="8" fill="${theme.bg}"/>`);
|
|
745
|
+
// Chrome
|
|
746
|
+
lines.push(`<rect width="${width}" height="${chromeHeight}" rx="8" ry="0" fill="${theme.titleBg}"/>`);
|
|
747
|
+
lines.push(`<rect x="0" y="${chromeHeight - 8}" width="${width}" height="8" fill="${theme.titleBg}"/>`);
|
|
748
|
+
const dotY = chromeHeight / 2;
|
|
749
|
+
const dotR = 6;
|
|
750
|
+
lines.push(`<circle cx="${padding + dotR}" cy="${dotY}" r="${dotR}" fill="${theme.dotRed}"/>`);
|
|
751
|
+
lines.push(`<circle cx="${padding + dotR * 3 + 4}" cy="${dotY}" r="${dotR}" fill="${theme.dotYellow}"/>`);
|
|
752
|
+
lines.push(`<circle cx="${padding + dotR * 5 + 8}" cy="${dotY}" r="${dotR}" fill="${theme.dotGreen}"/>`);
|
|
753
|
+
lines.push(`<text x="${width / 2}" y="${dotY + 4}" fill="${theme.titleFg}" font-family="monospace" font-size="${fontSize - 2}" text-anchor="middle">${escapeXML(title)}</text>`);
|
|
754
|
+
// Content
|
|
755
|
+
for (let row = 0; row < state.lines.length; row++) {
|
|
756
|
+
const line = state.lines[row];
|
|
757
|
+
if (!line || line.trim().length === 0)
|
|
758
|
+
continue;
|
|
759
|
+
const x = padding;
|
|
760
|
+
const y = contentY + (row + 1) * lineHeight;
|
|
761
|
+
lines.push(`<text x="${x}" y="${y}" fill="${theme.fg}" font-family="'Courier New',monospace" font-size="${fontSize}" xml:space="preserve">${escapeXML(line)}</text>`);
|
|
762
|
+
}
|
|
763
|
+
lines.push(`</svg>`);
|
|
764
|
+
return lines.join('\n');
|
|
765
|
+
}
|
|
766
|
+
/** Check if a command exists on the system */
|
|
767
|
+
function commandExists(cmd) {
|
|
768
|
+
try {
|
|
769
|
+
execSync(`which "${cmd}"`, { stdio: 'pipe' });
|
|
770
|
+
return true;
|
|
771
|
+
}
|
|
772
|
+
catch {
|
|
773
|
+
return false;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
// ── Asciicast v2 Format ──
|
|
777
|
+
/**
|
|
778
|
+
* Convert a recording to asciinema v2 format (.cast).
|
|
779
|
+
*
|
|
780
|
+
* Produces a file compatible with the asciinema player and asciinema.org.
|
|
781
|
+
* Format spec: https://docs.asciinema.org/manual/asciicast/v2/
|
|
782
|
+
*/
|
|
783
|
+
export function toAsciicast(recording) {
|
|
784
|
+
const header = {
|
|
785
|
+
version: 2,
|
|
786
|
+
width: recording.cols || 80,
|
|
787
|
+
height: recording.rows || 24,
|
|
788
|
+
timestamp: Math.floor(new Date(recording.startedAt).getTime() / 1000),
|
|
789
|
+
title: recording.title || 'K:BOT Terminal Recording',
|
|
790
|
+
env: {
|
|
791
|
+
SHELL: recording.env.shell,
|
|
792
|
+
TERM: recording.env.term,
|
|
793
|
+
},
|
|
794
|
+
};
|
|
795
|
+
const lines = [JSON.stringify(header)];
|
|
796
|
+
for (const frame of recording.frames) {
|
|
797
|
+
// Asciicast v2 event: [time, event_type, data]
|
|
798
|
+
// "o" = output (stdout)
|
|
799
|
+
const event = [
|
|
800
|
+
parseFloat(frame.time.toFixed(6)),
|
|
801
|
+
'o',
|
|
802
|
+
frame.data,
|
|
803
|
+
];
|
|
804
|
+
lines.push(JSON.stringify(event));
|
|
805
|
+
}
|
|
806
|
+
return lines.join('\n') + '\n';
|
|
807
|
+
}
|
|
808
|
+
// ── Recording from raw frame data (programmatic) ──
|
|
809
|
+
/**
|
|
810
|
+
* Create a recording programmatically from an array of frames.
|
|
811
|
+
* Useful for generating demo recordings without actually running a shell.
|
|
812
|
+
*/
|
|
813
|
+
export function createRecording(frames, options = {}) {
|
|
814
|
+
const id = generateId();
|
|
815
|
+
const cols = options.cols || 80;
|
|
816
|
+
const rows = options.rows || 24;
|
|
817
|
+
const duration = frames.length > 0 ? frames[frames.length - 1].time : 0;
|
|
818
|
+
return {
|
|
819
|
+
id,
|
|
820
|
+
title: options.title || `K:BOT Recording ${id}`,
|
|
821
|
+
startedAt: new Date().toISOString(),
|
|
822
|
+
duration,
|
|
823
|
+
cols,
|
|
824
|
+
rows,
|
|
825
|
+
frames,
|
|
826
|
+
env: {
|
|
827
|
+
shell: process.env.SHELL || '/bin/bash',
|
|
828
|
+
term: process.env.TERM || 'xterm-256color',
|
|
829
|
+
platform: platform(),
|
|
830
|
+
},
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
// ── Load / List / Delete Recordings ──
|
|
834
|
+
/**
|
|
835
|
+
* Load a recording by ID or file path.
|
|
836
|
+
*/
|
|
837
|
+
export function loadRecording(idOrPath) {
|
|
838
|
+
// Try as direct path first
|
|
839
|
+
if (existsSync(idOrPath)) {
|
|
840
|
+
try {
|
|
841
|
+
const ext = extname(idOrPath).toLowerCase();
|
|
842
|
+
if (ext === '.json') {
|
|
843
|
+
return JSON.parse(readFileSync(idOrPath, 'utf-8'));
|
|
844
|
+
}
|
|
845
|
+
if (ext === '.cast') {
|
|
846
|
+
return parseCastFile(idOrPath);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
catch {
|
|
850
|
+
return null;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
// Try as ID in recordings directory
|
|
854
|
+
ensureDir();
|
|
855
|
+
const jsonPath = join(RECORDINGS_DIR, `${idOrPath}.json`);
|
|
856
|
+
if (existsSync(jsonPath)) {
|
|
857
|
+
try {
|
|
858
|
+
return JSON.parse(readFileSync(jsonPath, 'utf-8'));
|
|
859
|
+
}
|
|
860
|
+
catch {
|
|
861
|
+
return null;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
return null;
|
|
865
|
+
}
|
|
866
|
+
/** Parse an asciicast v2 file into a Recording */
|
|
867
|
+
function parseCastFile(filePath) {
|
|
868
|
+
try {
|
|
869
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
870
|
+
const lines = content.trim().split('\n');
|
|
871
|
+
if (lines.length === 0)
|
|
872
|
+
return null;
|
|
873
|
+
const header = JSON.parse(lines[0]);
|
|
874
|
+
const frames = [];
|
|
875
|
+
for (let i = 1; i < lines.length; i++) {
|
|
876
|
+
const event = JSON.parse(lines[i]);
|
|
877
|
+
if (Array.isArray(event) && event[1] === 'o') {
|
|
878
|
+
frames.push({ time: event[0], data: event[2] });
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return {
|
|
882
|
+
id: basename(filePath, extname(filePath)),
|
|
883
|
+
title: header.title || basename(filePath),
|
|
884
|
+
startedAt: header.timestamp
|
|
885
|
+
? new Date(header.timestamp * 1000).toISOString()
|
|
886
|
+
: new Date().toISOString(),
|
|
887
|
+
duration: frames.length > 0 ? frames[frames.length - 1].time : 0,
|
|
888
|
+
cols: header.width || 80,
|
|
889
|
+
rows: header.height || 24,
|
|
890
|
+
frames,
|
|
891
|
+
env: {
|
|
892
|
+
shell: header.env?.SHELL || '/bin/bash',
|
|
893
|
+
term: header.env?.TERM || 'xterm-256color',
|
|
894
|
+
platform: platform(),
|
|
895
|
+
},
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
catch {
|
|
899
|
+
return null;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* List all saved recordings.
|
|
904
|
+
*/
|
|
905
|
+
export function listRecordings() {
|
|
906
|
+
ensureDir();
|
|
907
|
+
const files = readdirSync(RECORDINGS_DIR)
|
|
908
|
+
.filter(f => f.endsWith('.json') && !f.startsWith('.'))
|
|
909
|
+
.sort()
|
|
910
|
+
.reverse();
|
|
911
|
+
const recordings = [];
|
|
912
|
+
for (const file of files) {
|
|
913
|
+
try {
|
|
914
|
+
const filePath = join(RECORDINGS_DIR, file);
|
|
915
|
+
const stat = statSync(filePath);
|
|
916
|
+
const data = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
917
|
+
recordings.push({
|
|
918
|
+
id: data.id,
|
|
919
|
+
title: data.title,
|
|
920
|
+
date: new Date(data.startedAt).toLocaleDateString(),
|
|
921
|
+
duration: data.duration,
|
|
922
|
+
frames: data.frames.length,
|
|
923
|
+
size: formatSize(stat.size),
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
catch {
|
|
927
|
+
// Skip corrupt files
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
return recordings;
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* Delete a recording by ID.
|
|
934
|
+
*/
|
|
935
|
+
export function deleteRecording(id) {
|
|
936
|
+
ensureDir();
|
|
937
|
+
const jsonPath = join(RECORDINGS_DIR, `${id}.json`);
|
|
938
|
+
if (existsSync(jsonPath)) {
|
|
939
|
+
unlinkSync(jsonPath);
|
|
940
|
+
return true;
|
|
941
|
+
}
|
|
942
|
+
return false;
|
|
943
|
+
}
|
|
944
|
+
/** Format byte size to human-readable */
|
|
945
|
+
function formatSize(bytes) {
|
|
946
|
+
if (bytes < 1024)
|
|
947
|
+
return `${bytes}B`;
|
|
948
|
+
if (bytes < 1024 * 1024)
|
|
949
|
+
return `${(bytes / 1024).toFixed(1)}KB`;
|
|
950
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
951
|
+
}
|
|
952
|
+
// ── Terminal Replay ──
|
|
953
|
+
/**
|
|
954
|
+
* Replay a recording in the terminal with original timing.
|
|
955
|
+
*
|
|
956
|
+
* @param recording The recording to play back
|
|
957
|
+
* @param speed Playback speed multiplier (2 = double speed)
|
|
958
|
+
* @returns A promise that resolves when playback is complete,
|
|
959
|
+
* and an abort function to stop early.
|
|
960
|
+
*/
|
|
961
|
+
export function replayInTerminal(recording, speed = 1) {
|
|
962
|
+
let aborted = false;
|
|
963
|
+
let currentTimeout = null;
|
|
964
|
+
const abort = () => {
|
|
965
|
+
aborted = true;
|
|
966
|
+
if (currentTimeout)
|
|
967
|
+
clearTimeout(currentTimeout);
|
|
968
|
+
};
|
|
969
|
+
const promise = new Promise((resolve) => {
|
|
970
|
+
if (recording.frames.length === 0) {
|
|
971
|
+
resolve();
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
// Clear screen
|
|
975
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
976
|
+
let frameIdx = 0;
|
|
977
|
+
function playNextFrame() {
|
|
978
|
+
if (aborted || frameIdx >= recording.frames.length) {
|
|
979
|
+
process.stdout.write('\n');
|
|
980
|
+
resolve();
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
const frame = recording.frames[frameIdx];
|
|
984
|
+
process.stdout.write(frame.data);
|
|
985
|
+
frameIdx++;
|
|
986
|
+
if (frameIdx < recording.frames.length) {
|
|
987
|
+
const nextFrame = recording.frames[frameIdx];
|
|
988
|
+
const delay = Math.max(1, (nextFrame.time - frame.time) * 1000 / speed);
|
|
989
|
+
currentTimeout = setTimeout(playNextFrame, delay);
|
|
990
|
+
}
|
|
991
|
+
else {
|
|
992
|
+
process.stdout.write('\n');
|
|
993
|
+
resolve();
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
// Start with the first frame
|
|
997
|
+
const firstDelay = Math.max(1, recording.frames[0].time * 1000 / speed);
|
|
998
|
+
currentTimeout = setTimeout(playNextFrame, firstDelay);
|
|
999
|
+
});
|
|
1000
|
+
return { promise, abort };
|
|
1001
|
+
}
|
|
1002
|
+
// ── Convert existing recording to different format ──
|
|
1003
|
+
/**
|
|
1004
|
+
* Convert a recording file to a different format.
|
|
1005
|
+
*/
|
|
1006
|
+
export function convertRecording(inputPath, outputPath, options = {}) {
|
|
1007
|
+
const recording = loadRecording(inputPath);
|
|
1008
|
+
if (!recording) {
|
|
1009
|
+
throw new Error(`Cannot load recording: ${inputPath}`);
|
|
1010
|
+
}
|
|
1011
|
+
const ext = extname(outputPath).toLowerCase();
|
|
1012
|
+
switch (ext) {
|
|
1013
|
+
case '.svg':
|
|
1014
|
+
writeFileSync(outputPath, toSVG(recording, options), 'utf-8');
|
|
1015
|
+
return outputPath;
|
|
1016
|
+
case '.gif':
|
|
1017
|
+
return toGIF(recording, outputPath, options);
|
|
1018
|
+
case '.cast':
|
|
1019
|
+
writeFileSync(outputPath, toAsciicast(recording), 'utf-8');
|
|
1020
|
+
return outputPath;
|
|
1021
|
+
case '.json':
|
|
1022
|
+
writeFileSync(outputPath, JSON.stringify(recording, null, 2), 'utf-8');
|
|
1023
|
+
return outputPath;
|
|
1024
|
+
default:
|
|
1025
|
+
throw new Error(`Unsupported output format: ${ext}`);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
// ── CLI Integration ──
|
|
1029
|
+
/**
|
|
1030
|
+
* Register the `kbot record` command group with the CLI.
|
|
1031
|
+
*
|
|
1032
|
+
* Subcommands:
|
|
1033
|
+
* kbot record start [--output <file>] [--title <title>]
|
|
1034
|
+
* kbot record stop
|
|
1035
|
+
* kbot record list
|
|
1036
|
+
* kbot record replay <file> [--speed <multiplier>]
|
|
1037
|
+
* kbot record convert <input> <output> [--theme <theme>]
|
|
1038
|
+
* kbot record delete <id>
|
|
1039
|
+
*/
|
|
1040
|
+
export function registerRecordCommand(program) {
|
|
1041
|
+
const recordCmd = program
|
|
1042
|
+
.command('record')
|
|
1043
|
+
.description('Record terminal sessions as SVG, GIF, or asciicast for demos');
|
|
1044
|
+
// ── start ──
|
|
1045
|
+
recordCmd
|
|
1046
|
+
.command('start')
|
|
1047
|
+
.description('Start recording the terminal session')
|
|
1048
|
+
.option('-o, --output <file>', 'Output file (extension sets format: .svg, .gif, .cast, .json)')
|
|
1049
|
+
.option('-t, --title <title>', 'Recording title')
|
|
1050
|
+
.option('--cols <cols>', 'Terminal columns', String(process.stdout.columns || 80))
|
|
1051
|
+
.option('--rows <rows>', 'Terminal rows', String(process.stdout.rows || 24))
|
|
1052
|
+
.option('--shell <shell>', 'Shell to use')
|
|
1053
|
+
.action((opts) => {
|
|
1054
|
+
const result = startRecording({
|
|
1055
|
+
output: opts.output,
|
|
1056
|
+
title: opts.title,
|
|
1057
|
+
cols: opts.cols ? parseInt(opts.cols, 10) : undefined,
|
|
1058
|
+
rows: opts.rows ? parseInt(opts.rows, 10) : undefined,
|
|
1059
|
+
shell: opts.shell,
|
|
1060
|
+
});
|
|
1061
|
+
if (result.success) {
|
|
1062
|
+
console.log(`\x1b[32m \u2713 ${result.message}\x1b[0m`);
|
|
1063
|
+
}
|
|
1064
|
+
else {
|
|
1065
|
+
console.error(`\x1b[31m \u2717 ${result.message}\x1b[0m`);
|
|
1066
|
+
process.exit(1);
|
|
1067
|
+
}
|
|
1068
|
+
});
|
|
1069
|
+
// ── stop ──
|
|
1070
|
+
recordCmd
|
|
1071
|
+
.command('stop')
|
|
1072
|
+
.description('Stop the active recording and save it')
|
|
1073
|
+
.action(() => {
|
|
1074
|
+
const result = stopRecording();
|
|
1075
|
+
if (result.success) {
|
|
1076
|
+
console.log(`\x1b[32m \u2713 ${result.message}\x1b[0m`);
|
|
1077
|
+
if (result.outputPath) {
|
|
1078
|
+
console.log(` Output: ${result.outputPath}`);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
else {
|
|
1082
|
+
console.error(`\x1b[31m \u2717 ${result.message}\x1b[0m`);
|
|
1083
|
+
process.exit(1);
|
|
1084
|
+
}
|
|
1085
|
+
});
|
|
1086
|
+
// ── list ──
|
|
1087
|
+
recordCmd
|
|
1088
|
+
.command('list')
|
|
1089
|
+
.description('List all saved recordings')
|
|
1090
|
+
.action(() => {
|
|
1091
|
+
const recordings = listRecordings();
|
|
1092
|
+
if (recordings.length === 0) {
|
|
1093
|
+
console.log(' No recordings found. Run `kbot record start` to create one.');
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
console.log();
|
|
1097
|
+
console.log(' \x1b[1mSaved Recordings\x1b[0m');
|
|
1098
|
+
console.log(' \x1b[2m' + '\u2500'.repeat(60) + '\x1b[0m');
|
|
1099
|
+
for (const rec of recordings) {
|
|
1100
|
+
const dur = rec.duration < 60
|
|
1101
|
+
? `${rec.duration.toFixed(1)}s`
|
|
1102
|
+
: `${Math.floor(rec.duration / 60)}m${Math.round(rec.duration % 60)}s`;
|
|
1103
|
+
console.log(` \x1b[36m${rec.id}\x1b[0m ${rec.title}`);
|
|
1104
|
+
console.log(` \x1b[2m${rec.date} | ${dur} | ${rec.frames} frames | ${rec.size}\x1b[0m`);
|
|
1105
|
+
console.log();
|
|
1106
|
+
}
|
|
1107
|
+
});
|
|
1108
|
+
// ── replay ──
|
|
1109
|
+
recordCmd
|
|
1110
|
+
.command('replay <file>')
|
|
1111
|
+
.description('Replay a recording in the terminal')
|
|
1112
|
+
.option('-s, --speed <speed>', 'Playback speed multiplier', '1')
|
|
1113
|
+
.action(async (file, opts) => {
|
|
1114
|
+
const recording = loadRecording(file);
|
|
1115
|
+
if (!recording) {
|
|
1116
|
+
console.error(`\x1b[31m \u2717 Cannot load recording: ${file}\x1b[0m`);
|
|
1117
|
+
process.exit(1);
|
|
1118
|
+
}
|
|
1119
|
+
const speed = opts.speed ? parseFloat(opts.speed) : 1;
|
|
1120
|
+
const dur = recording.duration < 60
|
|
1121
|
+
? `${recording.duration.toFixed(1)}s`
|
|
1122
|
+
: `${Math.floor(recording.duration / 60)}m${Math.round(recording.duration % 60)}s`;
|
|
1123
|
+
console.log(`\x1b[2m Replaying: ${recording.title} (${dur} at ${speed}x)\x1b[0m`);
|
|
1124
|
+
console.log(`\x1b[2m Press Ctrl-C to stop.\x1b[0m`);
|
|
1125
|
+
console.log();
|
|
1126
|
+
const { promise, abort } = replayInTerminal(recording, speed);
|
|
1127
|
+
// Handle Ctrl-C gracefully
|
|
1128
|
+
const handler = () => {
|
|
1129
|
+
abort();
|
|
1130
|
+
process.stdout.write('\x1b[0m\n');
|
|
1131
|
+
console.log('\x1b[2m Playback stopped.\x1b[0m');
|
|
1132
|
+
process.removeListener('SIGINT', handler);
|
|
1133
|
+
};
|
|
1134
|
+
process.on('SIGINT', handler);
|
|
1135
|
+
await promise;
|
|
1136
|
+
process.removeListener('SIGINT', handler);
|
|
1137
|
+
console.log('\x1b[2m Playback complete.\x1b[0m');
|
|
1138
|
+
});
|
|
1139
|
+
// ── convert ──
|
|
1140
|
+
recordCmd
|
|
1141
|
+
.command('convert <input> <output>')
|
|
1142
|
+
.description('Convert a recording to a different format')
|
|
1143
|
+
.option('--theme <theme>', 'SVG/GIF theme: dark, light, monokai, dracula', 'dark')
|
|
1144
|
+
.option('--speed <speed>', 'Playback speed multiplier', '1')
|
|
1145
|
+
.option('--fps <fps>', 'GIF frames per second', '10')
|
|
1146
|
+
.option('--font-size <size>', 'Font size in pixels', '14')
|
|
1147
|
+
.action((input, output, opts) => {
|
|
1148
|
+
try {
|
|
1149
|
+
const result = convertRecording(input, output, {
|
|
1150
|
+
theme: (opts.theme || 'dark'),
|
|
1151
|
+
speed: opts.speed ? parseFloat(opts.speed) : 1,
|
|
1152
|
+
fps: opts.fps ? parseInt(opts.fps, 10) : 10,
|
|
1153
|
+
fontSize: opts.fontSize ? parseInt(opts.fontSize, 10) : 14,
|
|
1154
|
+
});
|
|
1155
|
+
if (result) {
|
|
1156
|
+
console.log(`\x1b[32m \u2713 Converted: ${result}\x1b[0m`);
|
|
1157
|
+
}
|
|
1158
|
+
else {
|
|
1159
|
+
console.error('\x1b[31m \u2717 Conversion failed.\x1b[0m');
|
|
1160
|
+
process.exit(1);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
catch (err) {
|
|
1164
|
+
console.error(`\x1b[31m \u2717 ${err instanceof Error ? err.message : String(err)}\x1b[0m`);
|
|
1165
|
+
process.exit(1);
|
|
1166
|
+
}
|
|
1167
|
+
});
|
|
1168
|
+
// ── delete ──
|
|
1169
|
+
recordCmd
|
|
1170
|
+
.command('delete <id>')
|
|
1171
|
+
.description('Delete a saved recording')
|
|
1172
|
+
.action((id) => {
|
|
1173
|
+
if (deleteRecording(id)) {
|
|
1174
|
+
console.log(`\x1b[32m \u2713 Deleted recording: ${id}\x1b[0m`);
|
|
1175
|
+
}
|
|
1176
|
+
else {
|
|
1177
|
+
console.error(`\x1b[31m \u2717 Recording not found: ${id}\x1b[0m`);
|
|
1178
|
+
process.exit(1);
|
|
1179
|
+
}
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
1182
|
+
//# sourceMappingURL=record.js.map
|