// Replay Sidebar Renderer // Purpose: Renders replay sidebar visuals (title, drivers, chat) and encodes them as a video stream. // Scope: Owns SVG/frame synthesis and ffmpeg encoding for optional replay sidebars. const path = require('path'); const sharp = require('sharp'); const { FFMPEG_BIN, TARGET_FPS, SIDEBAR_WIDTH } = require('./constants'); function createSidebarRenderer({ execFileAsync, ensureDir }) { function escapeXml(text) { return String(text || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } function wrapTextLines(text, maxChars = 24) { const words = String(text || '').split(/\s+/).filter(Boolean); const lines = []; let current = ''; for (const word of words) { const candidate = current ? `${current} ${word}` : word; if (candidate.length > maxChars && current) { lines.push(current); current = word; } else { current = candidate; } } if (current) lines.push(current); return lines.slice(0, 4); } function hexToRgb(hex) { const value = String(hex || '').trim(); const match = /^#([0-9A-Fa-f]{6})$/.exec(value); if (!match) return null; const raw = match[1]; return { r: parseInt(raw.slice(0, 2), 16), g: parseInt(raw.slice(2, 4), 16), b: parseInt(raw.slice(4, 6), 16) }; } function roleColor(role = '') { switch (String(role)) { case 'admin': case 'lockdown': return '#FCD34D'; case 'spectator': return '#94A3B8'; default: return '#7DD3FC'; } } function renderSidebarSvg({ width, height, title, driverBatteryLines, chatLines }) { const textCols = Math.max(24, Math.floor((width - 16) / 6)); const titleParts = wrapTextLines(title, Math.max(26, textCols)).slice(0, 4).map((line) => escapeXml(line)); const statLines = driverBatteryLines.slice(0, 8).map((line) => escapeXml(line)); const normalizedChats = chatLines.slice(-12).map((entry) => { const wrapped = wrapTextLines(entry.text || '', Math.max(24, textCols)).slice(0, 4).map((line) => escapeXml(line)); const nick = escapeXml(entry.nickname || 'user'); const roverId = escapeXml(entry.roverId || ''); const roverRgb = hexToRgb(entry.roverColor || ''); const roverBadgeBg = roverRgb ? `rgba(${roverRgb.r},${roverRgb.g},${roverRgb.b},0.18)` : 'rgba(30,41,59,0.70)'; const roverBadgeBorder = roverRgb ? `rgba(${roverRgb.r},${roverRgb.g},${roverRgb.b},0.60)` : 'rgba(71,85,105,0.75)'; return { nick, wrapped, nameColor: roleColor(entry.role), fromDiscord: Boolean(entry.fromDiscord), roverId, roverBadgeBg, roverBadgeBorder, bubbleTone: entry.fromDiscord ? 'discordBubble' : 'chatBubble' }; }); const shapes = []; const textRows = []; const pad = 3; const cardX = pad; const cardW = width - pad * 2; let y = pad; const titleCardH = Math.max(26, pad * 2 + titleParts.length * 15); shapes.push(``); let ty = y + pad + 11; for (const part of titleParts) { textRows.push(`${part}`); ty += 15; } y += titleCardH + pad; const driverLines = statLines.length ? statLines : ['No active drivers']; const driversCardH = pad * 2 + 11 + driverLines.length * 13; shapes.push(``); textRows.push(`Drivers`); let dy = y + pad + 19; for (const line of driverLines) { textRows.push(`${line}`); dy += 13; } y += driversCardH + pad; const chatCardH = Math.max(80, height - y - pad); shapes.push(``); textRows.push(`Chat`); let cy = y + pad + 12; const bubbleX = cardX + pad; const bubbleW = cardW - pad * 2; if (!normalizedChats.length) { textRows.push(`No chat in replay window`); } else { for (let i = 0; i < normalizedChats.length; i += 1) { const block = normalizedChats[i]; const nameW = Math.min(72, block.nick.length * 5.2); const badgeW = block.roverId ? Math.min(46, Math.max(18, block.roverId.length * 5 + 6)) : 0; const badgeGap = block.roverId ? 3 : 0; const prefixChars = Math.ceil((nameW + badgeW + badgeGap + 10) / 5.8); const firstLineRaw = String(block.wrapped[0] || '').trim(); const remainingRaw = block.wrapped.slice(firstLineRaw ? 1 : 0).map((line) => String(line || '').trim()).filter(Boolean); const fullText = (firstLineRaw ? [firstLineRaw, ...remainingRaw] : remainingRaw).join(' '); const inlineWrapped = wrapTextLines(fullText, Math.max(16, textCols - prefixChars)).slice(0, 4).map((line) => escapeXml(line)); const bubbleH = pad * 2 + Math.max(1, inlineWrapped.length) * 12; if (cy + bubbleH + pad > y + chatCardH - pad) break; shapes.push(``); const nameX = bubbleX + pad; const textStartX = nameX + nameW + 3 + (block.roverId ? badgeW + badgeGap : 0); textRows.push(`${block.nick}`); if (block.fromDiscord) textRows.push(``); if (block.roverId) { const badgeX = nameX + nameW + 3; const badgeTextX = badgeX + 3; shapes.push(``); textRows.push(`${block.roverId}`); } let by = cy + pad + 8; for (let lineIdx = 0; lineIdx < inlineWrapped.length; lineIdx += 1) { const line = inlineWrapped[lineIdx]; const lineX = lineIdx === 0 ? textStartX : bubbleX + pad; textRows.push(`${line}`); by += 12; } cy += bubbleH + pad; } } return `\n\n \n \n ${shapes.join('\n ')}\n ${textRows.join('\n ')}\n`; } async function renderSidebarVideo({ tmpDir, title, durationSec, height, windowStartMs, driverBatteryLines = [], chatEvents = [] }) { const framesDir = path.join(tmpDir, 'sidebar-frames'); const sidebarPath = path.join(tmpDir, 'sidebar.mp4'); await ensureDir(framesDir); const secondCount = Math.max(1, Math.ceil(durationSec)); for (let second = 0; second < secondCount; second += 1) { const sliceEndMs = windowStartMs + (second + 1) * 1000; const visibleChat = chatEvents.filter((entry) => entry.ts <= sliceEndMs).slice(-10); const svg = renderSidebarSvg({ width: SIDEBAR_WIDTH, height, title, driverBatteryLines, chatLines: visibleChat }); const framePath = path.join(framesDir, `frame-${String(second + 1).padStart(4, '0')}.png`); await sharp(Buffer.from(svg, 'utf8')).png().toFile(framePath); } await execFileAsync(FFMPEG_BIN, [ '-y','-hide_banner','-loglevel','error','-framerate','1','-i',path.join(framesDir, 'frame-%04d.png'), '-vf',`fps=${TARGET_FPS},format=yuv420p`,'-t',durationSec.toFixed(3),'-c:v','libx264','-preset','veryfast','-pix_fmt','yuv420p',sidebarPath, ]); return sidebarPath; } return { renderSidebarVideo }; } module.exports = { createSidebarRenderer };