@pep/term-deck 1.0.13 → 1.0.15

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.
Files changed (36) hide show
  1. package/dist/bin/term-deck.d.ts +1 -0
  2. package/dist/bin/term-deck.js +1720 -0
  3. package/dist/bin/term-deck.js.map +1 -0
  4. package/dist/index.d.ts +670 -0
  5. package/dist/index.js +159 -0
  6. package/dist/index.js.map +1 -0
  7. package/package.json +16 -13
  8. package/bin/term-deck.ts +0 -45
  9. package/src/cli/__tests__/errors.test.ts +0 -201
  10. package/src/cli/__tests__/help.test.ts +0 -157
  11. package/src/cli/__tests__/init.test.ts +0 -110
  12. package/src/cli/commands/export.ts +0 -33
  13. package/src/cli/commands/init.ts +0 -125
  14. package/src/cli/commands/present.ts +0 -29
  15. package/src/cli/errors.ts +0 -77
  16. package/src/core/__tests__/slide.test.ts +0 -1759
  17. package/src/core/__tests__/theme.test.ts +0 -1103
  18. package/src/core/slide.ts +0 -509
  19. package/src/core/theme.ts +0 -388
  20. package/src/export/__tests__/recorder.test.ts +0 -566
  21. package/src/export/recorder.ts +0 -639
  22. package/src/index.ts +0 -36
  23. package/src/presenter/__tests__/main.test.ts +0 -244
  24. package/src/presenter/main.ts +0 -658
  25. package/src/renderer/__tests__/screen-extended.test.ts +0 -801
  26. package/src/renderer/__tests__/screen.test.ts +0 -525
  27. package/src/renderer/screen.ts +0 -671
  28. package/src/schemas/__tests__/config.test.ts +0 -429
  29. package/src/schemas/__tests__/slide.test.ts +0 -349
  30. package/src/schemas/__tests__/theme.test.ts +0 -970
  31. package/src/schemas/__tests__/validation.test.ts +0 -256
  32. package/src/schemas/config.ts +0 -58
  33. package/src/schemas/slide.ts +0 -56
  34. package/src/schemas/theme.ts +0 -203
  35. package/src/schemas/validation.ts +0 -64
  36. package/src/themes/matrix/index.ts +0 -53
@@ -1,639 +0,0 @@
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 DELETED
@@ -1,36 +0,0 @@
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 };