@pep/term-deck 1.0.10
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/LICENSE +21 -0
- package/README.md +356 -0
- package/bin/term-deck.ts +45 -0
- package/examples/slides/01-welcome.md +9 -0
- package/examples/slides/02-features.md +12 -0
- package/examples/slides/03-colors.md +17 -0
- package/examples/slides/04-ascii-art.md +11 -0
- package/examples/slides/05-gradients.md +14 -0
- package/examples/slides/06-themes.md +13 -0
- package/examples/slides/07-markdown.md +13 -0
- package/examples/slides/08-controls.md +13 -0
- package/examples/slides/09-thanks.md +11 -0
- package/examples/slides/deck.config.ts +13 -0
- package/examples/slides-hacker/01-welcome.md +9 -0
- package/examples/slides-hacker/02-features.md +12 -0
- package/examples/slides-hacker/03-colors.md +17 -0
- package/examples/slides-hacker/04-ascii-art.md +11 -0
- package/examples/slides-hacker/05-gradients.md +14 -0
- package/examples/slides-hacker/06-themes.md +13 -0
- package/examples/slides-hacker/07-markdown.md +13 -0
- package/examples/slides-hacker/08-controls.md +13 -0
- package/examples/slides-hacker/09-thanks.md +11 -0
- package/examples/slides-hacker/deck.config.ts +13 -0
- package/examples/slides-matrix/01-welcome.md +9 -0
- package/examples/slides-matrix/02-features.md +12 -0
- package/examples/slides-matrix/03-colors.md +17 -0
- package/examples/slides-matrix/04-ascii-art.md +11 -0
- package/examples/slides-matrix/05-gradients.md +14 -0
- package/examples/slides-matrix/06-themes.md +13 -0
- package/examples/slides-matrix/07-markdown.md +13 -0
- package/examples/slides-matrix/08-controls.md +13 -0
- package/examples/slides-matrix/09-thanks.md +11 -0
- package/examples/slides-matrix/deck.config.ts +13 -0
- package/examples/slides-minimal/01-welcome.md +9 -0
- package/examples/slides-minimal/02-features.md +12 -0
- package/examples/slides-minimal/03-colors.md +17 -0
- package/examples/slides-minimal/04-ascii-art.md +11 -0
- package/examples/slides-minimal/05-gradients.md +14 -0
- package/examples/slides-minimal/06-themes.md +13 -0
- package/examples/slides-minimal/07-markdown.md +13 -0
- package/examples/slides-minimal/08-controls.md +13 -0
- package/examples/slides-minimal/09-thanks.md +11 -0
- package/examples/slides-minimal/deck.config.ts +13 -0
- package/examples/slides-neon/01-welcome.md +9 -0
- package/examples/slides-neon/02-features.md +12 -0
- package/examples/slides-neon/03-colors.md +17 -0
- package/examples/slides-neon/04-ascii-art.md +11 -0
- package/examples/slides-neon/05-gradients.md +14 -0
- package/examples/slides-neon/06-themes.md +13 -0
- package/examples/slides-neon/07-markdown.md +13 -0
- package/examples/slides-neon/08-controls.md +13 -0
- package/examples/slides-neon/09-thanks.md +11 -0
- package/examples/slides-neon/deck.config.ts +13 -0
- package/examples/slides-retro/01-welcome.md +9 -0
- package/examples/slides-retro/02-features.md +12 -0
- package/examples/slides-retro/03-colors.md +17 -0
- package/examples/slides-retro/04-ascii-art.md +11 -0
- package/examples/slides-retro/05-gradients.md +14 -0
- package/examples/slides-retro/06-themes.md +13 -0
- package/examples/slides-retro/07-markdown.md +13 -0
- package/examples/slides-retro/08-controls.md +13 -0
- package/examples/slides-retro/09-thanks.md +11 -0
- package/examples/slides-retro/deck.config.ts +13 -0
- package/package.json +66 -0
- package/src/cli/__tests__/errors.test.ts +201 -0
- package/src/cli/__tests__/help.test.ts +157 -0
- package/src/cli/__tests__/init.test.ts +110 -0
- package/src/cli/commands/export.ts +33 -0
- package/src/cli/commands/init.ts +125 -0
- package/src/cli/commands/present.ts +29 -0
- package/src/cli/errors.ts +77 -0
- package/src/core/__tests__/slide.test.ts +1759 -0
- package/src/core/__tests__/theme.test.ts +1103 -0
- package/src/core/slide.ts +509 -0
- package/src/core/theme.ts +388 -0
- package/src/export/__tests__/recorder.test.ts +566 -0
- package/src/export/recorder.ts +639 -0
- package/src/index.ts +36 -0
- package/src/presenter/__tests__/main.test.ts +244 -0
- package/src/presenter/main.ts +658 -0
- package/src/renderer/__tests__/screen-extended.test.ts +801 -0
- package/src/renderer/__tests__/screen.test.ts +525 -0
- package/src/renderer/screen.ts +671 -0
- package/src/schemas/__tests__/config.test.ts +429 -0
- package/src/schemas/__tests__/slide.test.ts +349 -0
- package/src/schemas/__tests__/theme.test.ts +970 -0
- package/src/schemas/__tests__/validation.test.ts +256 -0
- package/src/schemas/config.ts +58 -0
- package/src/schemas/slide.ts +56 -0
- package/src/schemas/theme.ts +203 -0
- package/src/schemas/validation.ts +64 -0
- package/src/themes/matrix/index.ts +53 -0
- package/themes/hacker.ts +53 -0
- package/themes/minimal.ts +53 -0
- package/themes/neon.ts +53 -0
- package/themes/retro.ts +53 -0
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Export Data Structures
|
|
3
|
+
*
|
|
4
|
+
* This module defines the core interfaces and types for exporting presentations
|
|
5
|
+
* to video (MP4) or GIF formats.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Export options
|
|
10
|
+
*/
|
|
11
|
+
export interface ExportOptions {
|
|
12
|
+
/** Output file path (.mp4 or .gif) */
|
|
13
|
+
output: string
|
|
14
|
+
/** Terminal width in characters */
|
|
15
|
+
width?: number
|
|
16
|
+
/** Terminal height in characters */
|
|
17
|
+
height?: number
|
|
18
|
+
/** Frames per second */
|
|
19
|
+
fps?: number
|
|
20
|
+
/** Time per slide in seconds (for auto-advance) */
|
|
21
|
+
slideTime?: number
|
|
22
|
+
/** Quality (1-100, only for video) */
|
|
23
|
+
quality?: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Export format
|
|
28
|
+
*/
|
|
29
|
+
export type ExportFormat = 'mp4' | 'gif'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Asciicast v2 format header
|
|
33
|
+
* https://docs.asciinema.org/manual/asciicast/v2/
|
|
34
|
+
*/
|
|
35
|
+
export interface AsciicastHeader {
|
|
36
|
+
version: 2
|
|
37
|
+
width: number
|
|
38
|
+
height: number
|
|
39
|
+
timestamp?: number
|
|
40
|
+
env?: Record<string, string>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Asciicast frame: [time, stream, data]
|
|
45
|
+
* time: timestamp in seconds
|
|
46
|
+
* stream: 'o' for stdout, 'i' for stdin
|
|
47
|
+
* data: the text content
|
|
48
|
+
*/
|
|
49
|
+
export type AsciicastFrame = [number, 'o' | 'i', string]
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Recording session state
|
|
53
|
+
*/
|
|
54
|
+
export interface RecordingSession {
|
|
55
|
+
tempDir: string
|
|
56
|
+
frameCount: number
|
|
57
|
+
width: number
|
|
58
|
+
height: number
|
|
59
|
+
fps: number
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Virtual terminal buffer for capturing frames
|
|
64
|
+
*
|
|
65
|
+
* We render to a string buffer and convert to images
|
|
66
|
+
*/
|
|
67
|
+
export class VirtualTerminal {
|
|
68
|
+
private buffer: string[][]
|
|
69
|
+
private colors: string[][]
|
|
70
|
+
|
|
71
|
+
constructor(
|
|
72
|
+
public width: number,
|
|
73
|
+
public height: number
|
|
74
|
+
) {
|
|
75
|
+
this.buffer = Array.from({ length: height }, () =>
|
|
76
|
+
Array(width).fill(' ')
|
|
77
|
+
)
|
|
78
|
+
this.colors = Array.from({ length: height }, () =>
|
|
79
|
+
Array(width).fill('#ffffff')
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Set character at position
|
|
85
|
+
*/
|
|
86
|
+
setChar(x: number, y: number, char: string, color: string = '#ffffff'): void {
|
|
87
|
+
if (x >= 0 && x < this.width && y >= 0 && y < this.height) {
|
|
88
|
+
this.buffer[y][x] = char
|
|
89
|
+
this.colors[y][x] = color
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Clear the buffer
|
|
95
|
+
*/
|
|
96
|
+
clear(): void {
|
|
97
|
+
for (let y = 0; y < this.height; y++) {
|
|
98
|
+
for (let x = 0; x < this.width; x++) {
|
|
99
|
+
this.buffer[y][x] = ' '
|
|
100
|
+
this.colors[y][x] = '#ffffff'
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Get buffer as string (for debugging)
|
|
107
|
+
*/
|
|
108
|
+
toString(): string {
|
|
109
|
+
return this.buffer.map((row) => row.join('')).join('\n')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Convert buffer to PNG image data
|
|
114
|
+
*/
|
|
115
|
+
async toPng(): Promise<Uint8Array> {
|
|
116
|
+
// Use canvas or similar to render text to image
|
|
117
|
+
return renderTerminalToPng(this.buffer, this.colors, this.width, this.height)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Character dimensions in pixels (monospace font)
|
|
122
|
+
const CHAR_WIDTH = 10
|
|
123
|
+
const CHAR_HEIGHT = 20
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Render terminal buffer to PNG
|
|
127
|
+
*/
|
|
128
|
+
async function renderTerminalToPng(
|
|
129
|
+
buffer: string[][],
|
|
130
|
+
colors: string[][],
|
|
131
|
+
width: number,
|
|
132
|
+
height: number
|
|
133
|
+
): Promise<Uint8Array> {
|
|
134
|
+
// Dynamic import to avoid bundling issues
|
|
135
|
+
const { createCanvas } = await import('canvas')
|
|
136
|
+
|
|
137
|
+
const canvas = createCanvas(width * CHAR_WIDTH, height * CHAR_HEIGHT)
|
|
138
|
+
const ctx = canvas.getContext('2d')
|
|
139
|
+
|
|
140
|
+
// Background
|
|
141
|
+
ctx.fillStyle = '#0a0a0a'
|
|
142
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
|
143
|
+
|
|
144
|
+
// Set font
|
|
145
|
+
ctx.font = `${CHAR_HEIGHT - 4}px monospace`
|
|
146
|
+
ctx.textBaseline = 'top'
|
|
147
|
+
|
|
148
|
+
// Render each character
|
|
149
|
+
for (let y = 0; y < height; y++) {
|
|
150
|
+
for (let x = 0; x < width; x++) {
|
|
151
|
+
const char = buffer[y][x]
|
|
152
|
+
const color = colors[y][x]
|
|
153
|
+
|
|
154
|
+
if (char !== ' ') {
|
|
155
|
+
ctx.fillStyle = color
|
|
156
|
+
ctx.fillText(char, x * CHAR_WIDTH, y * CHAR_HEIGHT + 2)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return canvas.toBuffer('image/png')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Capture blessed screen content to virtual terminal
|
|
166
|
+
*
|
|
167
|
+
* This parses the blessed screen's internal buffer
|
|
168
|
+
*/
|
|
169
|
+
export function captureScreen(
|
|
170
|
+
screen: any, // neo-blessed screen type
|
|
171
|
+
vt: VirtualTerminal
|
|
172
|
+
): void {
|
|
173
|
+
// blessed stores screen content in screen.lines
|
|
174
|
+
// Each line is an array of cells with char, fg, bg
|
|
175
|
+
|
|
176
|
+
const lines = screen.lines || []
|
|
177
|
+
|
|
178
|
+
for (let y = 0; y < Math.min(lines.length, vt.height); y++) {
|
|
179
|
+
const line = lines[y]
|
|
180
|
+
if (!line) continue
|
|
181
|
+
|
|
182
|
+
for (let x = 0; x < Math.min(line.length, vt.width); x++) {
|
|
183
|
+
const cell = line[x]
|
|
184
|
+
if (!cell) continue
|
|
185
|
+
|
|
186
|
+
// Cell format: [char, attr] or just char
|
|
187
|
+
const char = Array.isArray(cell) ? cell[0] : cell
|
|
188
|
+
const attr = Array.isArray(cell) ? cell[1] : null
|
|
189
|
+
|
|
190
|
+
// Extract color from attr (blessed-specific format)
|
|
191
|
+
const color = extractColor(attr) || '#ffffff'
|
|
192
|
+
|
|
193
|
+
vt.setChar(x, y, char || ' ', color)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Capture blessed screen content as ANSI escape sequence string
|
|
200
|
+
*
|
|
201
|
+
* This generates a string with ANSI color codes that can be played back
|
|
202
|
+
* in a terminal or saved to an asciicast file.
|
|
203
|
+
*
|
|
204
|
+
* @param screen - The neo-blessed screen to capture
|
|
205
|
+
* @returns ANSI-encoded string representation of the screen
|
|
206
|
+
*/
|
|
207
|
+
export function captureScreenAsAnsi(screen: any): string {
|
|
208
|
+
// blessed can output its content with ANSI codes
|
|
209
|
+
// We need to convert the internal buffer to ANSI escape sequences
|
|
210
|
+
|
|
211
|
+
const lines = screen.lines || []
|
|
212
|
+
const output: string[] = []
|
|
213
|
+
|
|
214
|
+
// Clear screen and reset cursor
|
|
215
|
+
output.push('\x1b[2J\x1b[H')
|
|
216
|
+
|
|
217
|
+
for (let y = 0; y < lines.length; y++) {
|
|
218
|
+
const line = lines[y]
|
|
219
|
+
if (!line) {
|
|
220
|
+
output.push('\n')
|
|
221
|
+
continue
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
let lineStr = ''
|
|
225
|
+
let lastColor: string | null = null
|
|
226
|
+
|
|
227
|
+
for (let x = 0; x < line.length; x++) {
|
|
228
|
+
const cell = line[x]
|
|
229
|
+
if (!cell) {
|
|
230
|
+
lineStr += ' '
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Cell format: [char, attr] or just char
|
|
235
|
+
const char = Array.isArray(cell) ? cell[0] : cell
|
|
236
|
+
const attr = Array.isArray(cell) ? cell[1] : null
|
|
237
|
+
|
|
238
|
+
// Extract color
|
|
239
|
+
const color = extractColor(attr)
|
|
240
|
+
|
|
241
|
+
// Apply color if changed
|
|
242
|
+
if (color && color !== lastColor) {
|
|
243
|
+
// Convert hex to ANSI 256-color code
|
|
244
|
+
lineStr += hexToAnsi256(color)
|
|
245
|
+
lastColor = color
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
lineStr += char || ' '
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Reset color at end of line
|
|
252
|
+
if (lastColor) {
|
|
253
|
+
lineStr += '\x1b[0m'
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
output.push(lineStr)
|
|
257
|
+
if (y < lines.length - 1) {
|
|
258
|
+
output.push('\n')
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return output.join('')
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Convert hex color to ANSI 256-color escape sequence
|
|
267
|
+
*/
|
|
268
|
+
function hexToAnsi256(hex: string): string {
|
|
269
|
+
// Parse hex color
|
|
270
|
+
const r = Number.parseInt(hex.slice(1, 3), 16)
|
|
271
|
+
const g = Number.parseInt(hex.slice(3, 5), 16)
|
|
272
|
+
const b = Number.parseInt(hex.slice(5, 7), 16)
|
|
273
|
+
|
|
274
|
+
// Convert to 256-color palette
|
|
275
|
+
// Use 216-color cube (16-231)
|
|
276
|
+
const rIndex = Math.round(r / 51)
|
|
277
|
+
const gIndex = Math.round(g / 51)
|
|
278
|
+
const bIndex = Math.round(b / 51)
|
|
279
|
+
|
|
280
|
+
const colorCode = 16 + (rIndex * 36) + (gIndex * 6) + bIndex
|
|
281
|
+
|
|
282
|
+
// Return ANSI escape sequence for foreground color
|
|
283
|
+
return `\x1b[38;5;${colorCode}m`
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Extract hex color from blessed attribute
|
|
288
|
+
*/
|
|
289
|
+
function extractColor(attr: any): string | null {
|
|
290
|
+
if (!attr) return null
|
|
291
|
+
|
|
292
|
+
// blessed stores fg color in attr.fg
|
|
293
|
+
if (typeof attr === 'object' && attr.fg !== undefined) {
|
|
294
|
+
// Could be number (256 color) or string (hex)
|
|
295
|
+
if (typeof attr.fg === 'string' && attr.fg.startsWith('#')) {
|
|
296
|
+
return attr.fg
|
|
297
|
+
}
|
|
298
|
+
// Convert 256 color to hex
|
|
299
|
+
if (typeof attr.fg === 'number') {
|
|
300
|
+
return ansi256ToHex(attr.fg)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return null
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Convert ANSI 256 color to hex
|
|
309
|
+
*/
|
|
310
|
+
export function ansi256ToHex(code: number): string {
|
|
311
|
+
// Standard 16 colors
|
|
312
|
+
const standard16 = [
|
|
313
|
+
'#000000', '#800000', '#008000', '#808000',
|
|
314
|
+
'#000080', '#800080', '#008080', '#c0c0c0',
|
|
315
|
+
'#808080', '#ff0000', '#00ff00', '#ffff00',
|
|
316
|
+
'#0000ff', '#ff00ff', '#00ffff', '#ffffff',
|
|
317
|
+
]
|
|
318
|
+
|
|
319
|
+
if (code < 16) {
|
|
320
|
+
return standard16[code]
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// 216 color cube (16-231)
|
|
324
|
+
if (code < 232) {
|
|
325
|
+
const n = code - 16
|
|
326
|
+
const r = Math.floor(n / 36) * 51
|
|
327
|
+
const g = Math.floor((n % 36) / 6) * 51
|
|
328
|
+
const b = (n % 6) * 51
|
|
329
|
+
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Grayscale (232-255)
|
|
333
|
+
const gray = (code - 232) * 10 + 8
|
|
334
|
+
const hex = gray.toString(16).padStart(2, '0')
|
|
335
|
+
return `#${hex}${hex}${hex}`
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Create a new recording session
|
|
340
|
+
*/
|
|
341
|
+
export async function createRecordingSession(
|
|
342
|
+
options: ExportOptions
|
|
343
|
+
): Promise<RecordingSession> {
|
|
344
|
+
const { tmpdir } = await import('os')
|
|
345
|
+
const { join } = await import('path')
|
|
346
|
+
const { mkdir } = await import('fs/promises')
|
|
347
|
+
|
|
348
|
+
// Create temp directory for frames
|
|
349
|
+
const tempDir = join(tmpdir(), `term-deck-export-${Date.now()}`)
|
|
350
|
+
await mkdir(tempDir, { recursive: true })
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
tempDir,
|
|
354
|
+
frameCount: 0,
|
|
355
|
+
width: options.width ?? 120,
|
|
356
|
+
height: options.height ?? 40,
|
|
357
|
+
fps: options.fps ?? 30,
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Save a frame to the recording session
|
|
363
|
+
*/
|
|
364
|
+
export async function saveFrame(
|
|
365
|
+
session: RecordingSession,
|
|
366
|
+
png: Uint8Array
|
|
367
|
+
): Promise<void> {
|
|
368
|
+
const { join } = await import('path')
|
|
369
|
+
|
|
370
|
+
const frameNum = session.frameCount.toString().padStart(6, '0')
|
|
371
|
+
const framePath = join(session.tempDir, `frame_${frameNum}.png`)
|
|
372
|
+
|
|
373
|
+
await Bun.write(framePath, png)
|
|
374
|
+
session.frameCount++
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Cleanup recording session
|
|
379
|
+
*/
|
|
380
|
+
export async function cleanupSession(session: RecordingSession): Promise<void> {
|
|
381
|
+
const { rm } = await import('fs/promises')
|
|
382
|
+
await rm(session.tempDir, { recursive: true, force: true })
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Check if ffmpeg is available
|
|
387
|
+
*
|
|
388
|
+
* Throws an error with installation instructions if ffmpeg is not found
|
|
389
|
+
*/
|
|
390
|
+
export async function checkFfmpeg(): Promise<void> {
|
|
391
|
+
try {
|
|
392
|
+
const { $ } = await import('bun')
|
|
393
|
+
await $`which ffmpeg`.quiet()
|
|
394
|
+
} catch {
|
|
395
|
+
throw new Error(
|
|
396
|
+
'ffmpeg not found. Install it with:\n' +
|
|
397
|
+
' macOS: brew install ffmpeg\n' +
|
|
398
|
+
' Ubuntu: sudo apt install ffmpeg'
|
|
399
|
+
)
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Detect export format from filename
|
|
405
|
+
*
|
|
406
|
+
* Checks the file extension and returns the corresponding export format.
|
|
407
|
+
* Throws an error if the extension is not recognized.
|
|
408
|
+
*
|
|
409
|
+
* @param output - The output file path
|
|
410
|
+
* @returns The export format ('mp4' or 'gif')
|
|
411
|
+
* @throws Error if the file extension is not .mp4 or .gif
|
|
412
|
+
*/
|
|
413
|
+
export function detectFormat(output: string): ExportFormat {
|
|
414
|
+
if (output.endsWith('.gif')) return 'gif'
|
|
415
|
+
if (output.endsWith('.mp4')) return 'mp4'
|
|
416
|
+
|
|
417
|
+
throw new Error(
|
|
418
|
+
`Unknown output format for ${output}. Use .mp4 or .gif extension.`
|
|
419
|
+
)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Encode frames to video using ffmpeg
|
|
424
|
+
*/
|
|
425
|
+
async function encodeVideo(
|
|
426
|
+
session: RecordingSession,
|
|
427
|
+
output: string,
|
|
428
|
+
format: ExportFormat,
|
|
429
|
+
quality?: number
|
|
430
|
+
): Promise<void> {
|
|
431
|
+
const { join } = await import('path')
|
|
432
|
+
const inputPattern = join(session.tempDir, 'frame_%06d.png')
|
|
433
|
+
|
|
434
|
+
if (format === 'mp4') {
|
|
435
|
+
await encodeMp4(inputPattern, output, session.fps, quality ?? 80)
|
|
436
|
+
} else {
|
|
437
|
+
await encodeGif(inputPattern, output, session.fps)
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Encode to MP4
|
|
443
|
+
*/
|
|
444
|
+
async function encodeMp4(
|
|
445
|
+
input: string,
|
|
446
|
+
output: string,
|
|
447
|
+
fps: number,
|
|
448
|
+
quality: number
|
|
449
|
+
): Promise<void> {
|
|
450
|
+
const { $ } = await import('bun')
|
|
451
|
+
|
|
452
|
+
// CRF: 0 = lossless, 51 = worst. ~18-23 is good quality
|
|
453
|
+
const crf = Math.round(51 - (quality / 100) * 33)
|
|
454
|
+
|
|
455
|
+
await $`ffmpeg -y -framerate ${fps} -i ${input} -c:v libx264 -crf ${crf} -pix_fmt yuv420p ${output}`
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Encode to GIF
|
|
460
|
+
*/
|
|
461
|
+
async function encodeGif(
|
|
462
|
+
input: string,
|
|
463
|
+
output: string,
|
|
464
|
+
fps: number
|
|
465
|
+
): Promise<void> {
|
|
466
|
+
const { $ } = await import('bun')
|
|
467
|
+
const { tmpdir } = await import('os')
|
|
468
|
+
const { join } = await import('path')
|
|
469
|
+
|
|
470
|
+
// Two-pass encoding for better quality GIF
|
|
471
|
+
const paletteFile = join(tmpdir(), `palette-${Date.now()}.png`)
|
|
472
|
+
|
|
473
|
+
try {
|
|
474
|
+
// Pass 1: Generate palette
|
|
475
|
+
await $`ffmpeg -y -framerate ${fps} -i ${input} -vf "fps=${fps},scale=-1:-1:flags=lanczos,palettegen=stats_mode=diff" ${paletteFile}`
|
|
476
|
+
|
|
477
|
+
// Pass 2: Encode with palette
|
|
478
|
+
await $`ffmpeg -y -framerate ${fps} -i ${input} -i ${paletteFile} -lavfi "fps=${fps},scale=-1:-1:flags=lanczos[x];[x][1:v]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" ${output}`
|
|
479
|
+
} finally {
|
|
480
|
+
// Cleanup palette
|
|
481
|
+
try {
|
|
482
|
+
await Bun.file(paletteFile).delete()
|
|
483
|
+
} catch {
|
|
484
|
+
// Ignore
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Export a presentation to video
|
|
491
|
+
*/
|
|
492
|
+
export async function exportPresentation(
|
|
493
|
+
slidesDir: string,
|
|
494
|
+
options: ExportOptions
|
|
495
|
+
): Promise<void> {
|
|
496
|
+
// Check ffmpeg is available
|
|
497
|
+
await checkFfmpeg()
|
|
498
|
+
|
|
499
|
+
// Detect format from output extension
|
|
500
|
+
const format = detectFormat(options.output)
|
|
501
|
+
|
|
502
|
+
// Load deck
|
|
503
|
+
const { loadDeck } = await import('../core/slide.js')
|
|
504
|
+
const deck = await loadDeck(slidesDir)
|
|
505
|
+
|
|
506
|
+
if (deck.slides.length === 0) {
|
|
507
|
+
throw new Error(`No slides found in ${slidesDir}`)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Create recording session
|
|
511
|
+
const session = await createRecordingSession(options)
|
|
512
|
+
|
|
513
|
+
// Create virtual terminal
|
|
514
|
+
const vt = new VirtualTerminal(session.width, session.height)
|
|
515
|
+
|
|
516
|
+
// Create renderer (headless mode)
|
|
517
|
+
const { createRenderer, destroyRenderer, renderSlide } = await import('../renderer/screen.js')
|
|
518
|
+
const renderer = createRenderer(deck.config.theme)
|
|
519
|
+
|
|
520
|
+
// Override screen dimensions
|
|
521
|
+
;(renderer.screen as any).width = session.width
|
|
522
|
+
;(renderer.screen as any).height = session.height
|
|
523
|
+
|
|
524
|
+
const slideTime = options.slideTime ?? 3 // seconds per slide
|
|
525
|
+
const framesPerSlide = session.fps * slideTime
|
|
526
|
+
|
|
527
|
+
console.log(`Exporting ${deck.slides.length} slides...`)
|
|
528
|
+
|
|
529
|
+
try {
|
|
530
|
+
for (let i = 0; i < deck.slides.length; i++) {
|
|
531
|
+
const slide = deck.slides[i]
|
|
532
|
+
console.log(` Slide ${i + 1}/${deck.slides.length}: ${slide.frontmatter.title}`)
|
|
533
|
+
|
|
534
|
+
// Render slide
|
|
535
|
+
await renderSlide(renderer, slide)
|
|
536
|
+
|
|
537
|
+
// Capture frames for this slide
|
|
538
|
+
for (let f = 0; f < framesPerSlide; f++) {
|
|
539
|
+
// Update matrix rain
|
|
540
|
+
renderer.screen.render()
|
|
541
|
+
|
|
542
|
+
// Capture to virtual terminal
|
|
543
|
+
captureScreen(renderer.screen, vt)
|
|
544
|
+
|
|
545
|
+
// Save frame
|
|
546
|
+
const png = await vt.toPng()
|
|
547
|
+
await saveFrame(session, png)
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Encode video
|
|
552
|
+
console.log('Encoding video...')
|
|
553
|
+
await encodeVideo(session, options.output, format, options.quality)
|
|
554
|
+
|
|
555
|
+
console.log(`Exported to ${options.output}`)
|
|
556
|
+
} finally {
|
|
557
|
+
// Cleanup
|
|
558
|
+
destroyRenderer(renderer)
|
|
559
|
+
await cleanupSession(session)
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Record presentation as ANSI text file (asciicast format)
|
|
565
|
+
*
|
|
566
|
+
* This is an alternative to the video export that doesn't require ffmpeg or canvas.
|
|
567
|
+
* The output can be played back with asciinema: `asciinema play output.cast`
|
|
568
|
+
*
|
|
569
|
+
* @param slidesDir - Directory containing slide markdown files
|
|
570
|
+
* @param output - Output file path (e.g., 'presentation.cast')
|
|
571
|
+
* @param options - Recording options
|
|
572
|
+
*/
|
|
573
|
+
export async function recordAnsi(
|
|
574
|
+
slidesDir: string,
|
|
575
|
+
output: string,
|
|
576
|
+
options: { slideTime?: number; width?: number; height?: number } = {}
|
|
577
|
+
): Promise<void> {
|
|
578
|
+
// Load deck
|
|
579
|
+
const { loadDeck } = await import('../core/slide.js')
|
|
580
|
+
const deck = await loadDeck(slidesDir)
|
|
581
|
+
|
|
582
|
+
if (deck.slides.length === 0) {
|
|
583
|
+
throw new Error(`No slides found in ${slidesDir}`)
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Create renderer (headless mode)
|
|
587
|
+
const { createRenderer, destroyRenderer, renderSlide } = await import('../renderer/screen.js')
|
|
588
|
+
const renderer = createRenderer(deck.config.theme)
|
|
589
|
+
|
|
590
|
+
// Override screen dimensions if specified
|
|
591
|
+
const width = options.width ?? 120
|
|
592
|
+
const height = options.height ?? 40
|
|
593
|
+
;(renderer.screen as any).width = width
|
|
594
|
+
;(renderer.screen as any).height = height
|
|
595
|
+
|
|
596
|
+
const slideTime = options.slideTime ?? 3
|
|
597
|
+
const frames: AsciicastFrame[] = []
|
|
598
|
+
let currentTime = 0
|
|
599
|
+
|
|
600
|
+
console.log(`Recording ${deck.slides.length} slides to asciicast format...`)
|
|
601
|
+
|
|
602
|
+
try {
|
|
603
|
+
for (let i = 0; i < deck.slides.length; i++) {
|
|
604
|
+
const slide = deck.slides[i]
|
|
605
|
+
console.log(` Slide ${i + 1}/${deck.slides.length}: ${slide.frontmatter.title}`)
|
|
606
|
+
|
|
607
|
+
// Render slide
|
|
608
|
+
await renderSlide(renderer, slide)
|
|
609
|
+
|
|
610
|
+
// Capture screen as ANSI string
|
|
611
|
+
const content = captureScreenAsAnsi(renderer.screen)
|
|
612
|
+
|
|
613
|
+
// Add frame
|
|
614
|
+
frames.push([currentTime, 'o', content])
|
|
615
|
+
currentTime += slideTime
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Write asciicast file
|
|
619
|
+
const header: AsciicastHeader = {
|
|
620
|
+
version: 2,
|
|
621
|
+
width,
|
|
622
|
+
height,
|
|
623
|
+
timestamp: Math.floor(Date.now() / 1000),
|
|
624
|
+
env: { TERM: 'xterm-256color' },
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const lines = [JSON.stringify(header)]
|
|
628
|
+
for (const frame of frames) {
|
|
629
|
+
lines.push(JSON.stringify(frame))
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
await Bun.write(output, lines.join('\n') + '\n')
|
|
633
|
+
|
|
634
|
+
console.log(`Recorded to ${output}`)
|
|
635
|
+
console.log(`Play with: asciinema play ${output}`)
|
|
636
|
+
} finally {
|
|
637
|
+
destroyRenderer(renderer)
|
|
638
|
+
}
|
|
639
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public API for term-deck
|
|
3
|
+
*
|
|
4
|
+
* Exports functions and types for use in deck.config.ts files
|
|
5
|
+
* and external integrations.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { DeckConfigSchema, type DeckConfig } from './schemas/config.js';
|
|
9
|
+
import { type Theme } from './schemas/theme.js';
|
|
10
|
+
import { type Slide, type SlideFrontmatter } from './schemas/slide.js';
|
|
11
|
+
import { createTheme, type ThemeObject } from './core/theme.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Define deck configuration with validation
|
|
15
|
+
*
|
|
16
|
+
* Usage in deck.config.ts:
|
|
17
|
+
* ```typescript
|
|
18
|
+
* import { defineConfig } from 'term-deck'
|
|
19
|
+
* import matrix from '@term-deck/theme-matrix'
|
|
20
|
+
*
|
|
21
|
+
* export default defineConfig({
|
|
22
|
+
* title: 'My Presentation',
|
|
23
|
+
* theme: matrix,
|
|
24
|
+
* })
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export function defineConfig(config: DeckConfig): DeckConfig {
|
|
28
|
+
return DeckConfigSchema.parse(config);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Re-export theme creation
|
|
32
|
+
export { createTheme };
|
|
33
|
+
export type { ThemeObject };
|
|
34
|
+
|
|
35
|
+
// Re-export types for consumers
|
|
36
|
+
export type { DeckConfig, Theme, Slide, SlideFrontmatter };
|