|
| 1 | +/** |
| 2 | + * GIF Exporter - Renders tmux session captures as an animated GIF |
| 3 | + * |
| 4 | + * Uses node-canvas to render terminal content as frames and gif-encoder-2 to encode. |
| 5 | + */ |
| 6 | + |
| 7 | +import { createCanvas } from 'canvas' |
| 8 | +import GIFEncoder from 'gif-encoder-2' |
| 9 | +import path from 'path' |
| 10 | + |
| 11 | +import type { SessionData, Capture } from './types' |
| 12 | +import type { CanvasRenderingContext2D } from 'canvas' |
| 13 | + |
| 14 | +// Types are in gif-encoder-2.d.ts |
| 15 | + |
| 16 | +export interface GifExportOptions { |
| 17 | + /** Output file path for the GIF */ |
| 18 | + outputPath: string |
| 19 | + /** Delay between frames in milliseconds (default: 1500) */ |
| 20 | + frameDelay?: number |
| 21 | + /** Font size in pixels (default: 14) */ |
| 22 | + fontSize?: number |
| 23 | + /** Background color (default: '#1e1e1e') */ |
| 24 | + bgColor?: string |
| 25 | + /** Foreground/text color (default: '#d4d4d4') */ |
| 26 | + fgColor?: string |
| 27 | + /** Canvas width in pixels (default: auto-calculated from terminal width) */ |
| 28 | + width?: number |
| 29 | + /** Canvas height in pixels (default: auto-calculated from terminal height) */ |
| 30 | + height?: number |
| 31 | + /** Number of times to loop (0 = infinite, default: 0) */ |
| 32 | + loop?: number |
| 33 | + /** Quality setting 1-20, lower = better quality (default: 10) */ |
| 34 | + quality?: number |
| 35 | + /** Show frame label/timestamp overlay (default: true) */ |
| 36 | + showLabel?: boolean |
| 37 | +} |
| 38 | + |
| 39 | +interface RenderContext { |
| 40 | + fontSize: number |
| 41 | + lineHeight: number |
| 42 | + charWidth: number |
| 43 | + bgColor: string |
| 44 | + fgColor: string |
| 45 | + labelColor: string |
| 46 | + showLabel: boolean |
| 47 | +} |
| 48 | + |
| 49 | +/** |
| 50 | + * Calculate canvas dimensions based on terminal size and font metrics |
| 51 | + */ |
| 52 | +function calculateDimensions( |
| 53 | + sessionData: SessionData, |
| 54 | + options: GifExportOptions |
| 55 | +): { width: number; height: number; charWidth: number; lineHeight: number } { |
| 56 | + const fontSize = options.fontSize ?? 14 |
| 57 | + // Approximate character dimensions for monospace font |
| 58 | + const charWidth = fontSize * 0.6 |
| 59 | + const lineHeight = fontSize * 1.2 |
| 60 | + |
| 61 | + // Get terminal dimensions from session info or first capture |
| 62 | + let termWidth = 80 |
| 63 | + let termHeight = 24 |
| 64 | + |
| 65 | + if (sessionData.sessionInfo.dimensions) { |
| 66 | + termWidth = |
| 67 | + typeof sessionData.sessionInfo.dimensions.width === 'number' |
| 68 | + ? sessionData.sessionInfo.dimensions.width |
| 69 | + : 80 |
| 70 | + termHeight = |
| 71 | + typeof sessionData.sessionInfo.dimensions.height === 'number' |
| 72 | + ? sessionData.sessionInfo.dimensions.height |
| 73 | + : 24 |
| 74 | + } |
| 75 | + |
| 76 | + // Calculate canvas size with padding |
| 77 | + const padding = 20 |
| 78 | + const labelHeight = options.showLabel !== false ? 30 : 0 |
| 79 | + |
| 80 | + const width = options.width ?? Math.ceil(termWidth * charWidth + padding * 2) |
| 81 | + const height = |
| 82 | + options.height ?? Math.ceil(termHeight * lineHeight + padding * 2 + labelHeight) |
| 83 | + |
| 84 | + return { width, height, charWidth, lineHeight } |
| 85 | +} |
| 86 | + |
| 87 | +/** |
| 88 | + * Render a single capture frame to the canvas |
| 89 | + */ |
| 90 | +function renderFrame( |
| 91 | + ctx: CanvasRenderingContext2D, |
| 92 | + capture: Capture, |
| 93 | + canvasWidth: number, |
| 94 | + canvasHeight: number, |
| 95 | + renderCtx: RenderContext |
| 96 | +): void { |
| 97 | + const { fontSize, lineHeight, bgColor, fgColor, labelColor, showLabel } = renderCtx |
| 98 | + |
| 99 | + // Clear and fill background |
| 100 | + ctx.fillStyle = bgColor |
| 101 | + ctx.fillRect(0, 0, canvasWidth, canvasHeight) |
| 102 | + |
| 103 | + // Set up text rendering |
| 104 | + ctx.font = `${fontSize}px monospace` |
| 105 | + ctx.textBaseline = 'top' |
| 106 | + ctx.fillStyle = fgColor |
| 107 | + |
| 108 | + // Calculate content area |
| 109 | + const padding = 10 |
| 110 | + const labelHeight = showLabel ? 30 : 0 |
| 111 | + const contentStartY = padding + labelHeight |
| 112 | + |
| 113 | + // Render label if enabled |
| 114 | + if (showLabel) { |
| 115 | + const label = capture.frontMatter.label || `Capture ${capture.frontMatter.sequence}` |
| 116 | + const time = formatTimestamp(capture.frontMatter.timestamp) |
| 117 | + |
| 118 | + ctx.fillStyle = labelColor |
| 119 | + ctx.font = `bold ${fontSize - 2}px sans-serif` |
| 120 | + ctx.fillText(`[${capture.frontMatter.sequence}] ${label}`, padding, padding) |
| 121 | + |
| 122 | + // Time on the right |
| 123 | + const timeText = time |
| 124 | + const timeWidth = ctx.measureText(timeText).width |
| 125 | + ctx.fillText(timeText, canvasWidth - padding - timeWidth, padding) |
| 126 | + |
| 127 | + // Draw separator line |
| 128 | + ctx.strokeStyle = labelColor |
| 129 | + ctx.lineWidth = 1 |
| 130 | + ctx.beginPath() |
| 131 | + ctx.moveTo(padding, padding + fontSize + 5) |
| 132 | + ctx.lineTo(canvasWidth - padding, padding + fontSize + 5) |
| 133 | + ctx.stroke() |
| 134 | + } |
| 135 | + |
| 136 | + // Render terminal content |
| 137 | + ctx.font = `${fontSize}px monospace` |
| 138 | + ctx.fillStyle = fgColor |
| 139 | + |
| 140 | + const lines = capture.content.split('\n') |
| 141 | + const maxLines = Math.floor((canvasHeight - contentStartY - padding) / lineHeight) |
| 142 | + |
| 143 | + for (let i = 0; i < Math.min(lines.length, maxLines); i++) { |
| 144 | + const line = lines[i] |
| 145 | + // Strip ANSI codes for now (basic support) |
| 146 | + const cleanLine = stripAnsiCodes(line) |
| 147 | + ctx.fillText(cleanLine, padding, contentStartY + i * lineHeight) |
| 148 | + } |
| 149 | + |
| 150 | + // Show truncation indicator if content was cut off |
| 151 | + if (lines.length > maxLines) { |
| 152 | + ctx.fillStyle = labelColor |
| 153 | + ctx.font = `italic ${fontSize - 2}px sans-serif` |
| 154 | + ctx.fillText( |
| 155 | + `... ${lines.length - maxLines} more lines`, |
| 156 | + padding, |
| 157 | + canvasHeight - padding - fontSize |
| 158 | + ) |
| 159 | + } |
| 160 | +} |
| 161 | + |
| 162 | +/** |
| 163 | + * Strip ANSI escape codes from text |
| 164 | + */ |
| 165 | +function stripAnsiCodes(text: string): string { |
| 166 | + // eslint-disable-next-line no-control-regex |
| 167 | + return text.replace(/\x1b\[[0-9;]*m/g, '').replace(/\x1b\[[0-9;]*[A-Za-z]/g, '') |
| 168 | +} |
| 169 | + |
| 170 | +/** |
| 171 | + * Format ISO timestamp into readable time |
| 172 | + */ |
| 173 | +function formatTimestamp(isoTimestamp: string): string { |
| 174 | + try { |
| 175 | + const date = new Date(isoTimestamp) |
| 176 | + return date.toLocaleTimeString('en-US', { |
| 177 | + hour: '2-digit', |
| 178 | + minute: '2-digit', |
| 179 | + second: '2-digit', |
| 180 | + hour12: false, |
| 181 | + }) |
| 182 | + } catch { |
| 183 | + return isoTimestamp.slice(11, 19) |
| 184 | + } |
| 185 | +} |
| 186 | + |
| 187 | +/** |
| 188 | + * Export session replay as an animated GIF |
| 189 | + * |
| 190 | + * @param sessionData - The loaded session data |
| 191 | + * @param options - Export options |
| 192 | + * @returns Promise that resolves to the output path when complete |
| 193 | + */ |
| 194 | +export async function renderSessionToGif( |
| 195 | + sessionData: SessionData, |
| 196 | + options: GifExportOptions |
| 197 | +): Promise<string> { |
| 198 | + const captures = sessionData.captures |
| 199 | + |
| 200 | + if (captures.length === 0) { |
| 201 | + throw new Error('No captures to export - session has no captured frames') |
| 202 | + } |
| 203 | + |
| 204 | + // Apply defaults |
| 205 | + const frameDelay = options.frameDelay ?? 1500 |
| 206 | + const fontSize = options.fontSize ?? 14 |
| 207 | + const bgColor = options.bgColor ?? '#1e1e1e' |
| 208 | + const fgColor = options.fgColor ?? '#d4d4d4' |
| 209 | + const loop = options.loop ?? 0 |
| 210 | + const quality = options.quality ?? 10 |
| 211 | + const showLabel = options.showLabel !== false |
| 212 | + |
| 213 | + // Calculate dimensions |
| 214 | + const { width, height, charWidth, lineHeight } = calculateDimensions(sessionData, { |
| 215 | + ...options, |
| 216 | + fontSize, |
| 217 | + showLabel, |
| 218 | + }) |
| 219 | + |
| 220 | + // Create canvas |
| 221 | + const canvas = createCanvas(width, height) |
| 222 | + const ctx = canvas.getContext('2d') |
| 223 | + |
| 224 | + // Create GIF encoder |
| 225 | + const encoder = new GIFEncoder(width, height) |
| 226 | + |
| 227 | + encoder.start() |
| 228 | + encoder.setDelay(frameDelay) |
| 229 | + encoder.setRepeat(loop) |
| 230 | + encoder.setQuality(quality) |
| 231 | + |
| 232 | + // Render context for frames |
| 233 | + const renderCtx: RenderContext = { |
| 234 | + fontSize, |
| 235 | + lineHeight, |
| 236 | + charWidth, |
| 237 | + bgColor, |
| 238 | + fgColor, |
| 239 | + labelColor: '#888888', |
| 240 | + showLabel, |
| 241 | + } |
| 242 | + |
| 243 | + // Render each capture as a frame |
| 244 | + for (const capture of captures) { |
| 245 | + renderFrame(ctx, capture, width, height, renderCtx) |
| 246 | + encoder.addFrame(ctx) |
| 247 | + } |
| 248 | + |
| 249 | + encoder.finish() |
| 250 | + |
| 251 | + // Write to file |
| 252 | + const outputPath = path.resolve(options.outputPath) |
| 253 | + const buffer = encoder.out.getData() |
| 254 | + |
| 255 | + await Bun.write(outputPath, buffer) |
| 256 | + |
| 257 | + return outputPath |
| 258 | +} |
| 259 | + |
| 260 | +/** |
| 261 | + * Get suggested output filename based on session name |
| 262 | + */ |
| 263 | +export function getSuggestedFilename(sessionData: SessionData): string { |
| 264 | + const sessionName = sessionData.sessionInfo.session |
| 265 | + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) |
| 266 | + return `${sessionName}-${timestamp}.gif` |
| 267 | +} |
0 commit comments