diff --git a/server/src/services/replayEngineV2.js b/server/src/services/replayEngineV2.js index a66eeefd..d100a19f 100644 --- a/server/src/services/replayEngineV2.js +++ b/server/src/services/replayEngineV2.js @@ -12,6 +12,7 @@ const io = require('../globals/io'); const { getActiveDrivers } = require('./turnService'); const { getNickname } = require('./nicknameService'); const { getRecentMessages } = require('./chatService'); +const sharp = require('sharp'); const execFileAsync = promisify(execFile); @@ -363,23 +364,6 @@ function scalePadFilter(tileWidth, tileHeight) { return `scale=${tileWidth}:${tileHeight}:force_original_aspect_ratio=decrease:flags=lanczos,pad=${tileWidth}:${tileHeight}:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1`; } -function escapeAssText(text) { - return String(text || '') - .replace(/\\/g, '\\\\') - .replace(/\{/g, '\\{') - .replace(/\}/g, '\\}') - .replace(/\r?\n/g, '\\N'); -} - -function assTimeFromSeconds(totalSeconds) { - const safe = Math.max(0, Number(totalSeconds) || 0); - const hours = Math.floor(safe / 3600); - const mins = Math.floor((safe % 3600) / 60); - const secs = Math.floor(safe % 60); - const centis = Math.floor((safe - Math.floor(safe)) * 100); - return `${hours}:${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}.${String(centis).padStart(2, '0')}`; -} - function sanitizeReplayTitle(title, fallback = 'Replay') { const value = String(title || '').trim(); if (!value) return fallback; @@ -426,6 +410,96 @@ function buildChatEventsForWindow(startMs, endMs, limit = 18) { }); } +function escapeXml(text) { + return String(text || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function wrapTextLines(text, maxChars = 36) { + 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 renderSidebarSvg({ + width, + height, + title, + driverBatteryLines, + chatLines, +}) { + const safeTitle = escapeXml(title); + const statLines = driverBatteryLines.slice(0, 8).map((line) => escapeXml(line)); + const chatBlocks = chatLines.slice(0, 10).flatMap((line) => wrapTextLines(line, 34).map((wrapped) => escapeXml(wrapped))); + + let y = 34; + const titleParts = wrapTextLines(safeTitle, 26); + const textRows = []; + for (const part of titleParts) { + textRows.push(`${part}`); + y += 34; + } + y += 8; + textRows.push(`Active Drivers`); + y += 24; + if (!statLines.length) { + textRows.push(`No active drivers`); + y += 22; + } else { + for (const line of statLines) { + textRows.push(`${line}`); + y += 23; + } + } + y += 18; + textRows.push(`Chat`); + y += 24; + if (!chatBlocks.length) { + textRows.push(`No chat in replay window`); + } else { + for (const line of chatBlocks) { + textRows.push(`${line}`); + y += 22; + if (y > height - 20) break; + } + } + + return ` + + + + + + + + + + + ${textRows.join('\n ')} +`; +} + async function renderSidebarVideo({ tmpDir, title, @@ -435,58 +509,37 @@ async function renderSidebarVideo({ driverBatteryLines = [], chatEvents = [], }) { - const assPath = path.join(tmpDir, 'sidebar.ass'); + const framesDir = path.join(tmpDir, 'sidebar-frames'); const sidebarPath = path.join(tmpDir, 'sidebar.mp4'); - const headerLines = []; - headerLines.push('[Script Info]'); - headerLines.push('ScriptType: v4.00+'); - headerLines.push(`PlayResX: ${SIDEBAR_WIDTH}`); - headerLines.push(`PlayResY: ${height}`); - headerLines.push('ScaledBorderAndShadow: yes'); - headerLines.push(''); - headerLines.push('[V4+ Styles]'); - headerLines.push( - 'Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding', - ); - headerLines.push( - 'Style: Sidebar,DejaVu Sans,24,&H00FFFFFF,&H00FFFFFF,&H64000000,&H96000000,0,0,0,0,100,100,0,0,1,2,0,7,18,18,18,1', - ); - headerLines.push( - 'Style: Chat,DejaVu Sans,21,&H00E6F2FF,&H00E6F2FF,&H64000000,&H96000000,0,0,0,0,100,100,0,0,1,2,0,7,18,18,280,1', - ); - headerLines.push(''); - headerLines.push('[Events]'); - headerLines.push('Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text'); - - const fullEnd = assTimeFromSeconds(durationSec); - const staticLines = [escapeAssText(title), '', ...driverBatteryLines.map(escapeAssText)]; - headerLines.push(`Dialogue: 0,0:00:00.00,${fullEnd},Sidebar,,0,0,0,,${staticLines.join('\\N')}`); - - for (let i = 0; i < chatEvents.length; i += 1) { - const event = chatEvents[i]; - const start = Math.max(0, (event.ts - windowStartMs) / 1000); - const end = Math.min(durationSec, start + 4.5); - if (end <= start) continue; - headerLines.push( - `Dialogue: 0,${assTimeFromSeconds(start)},${assTimeFromSeconds(end)},Chat,,0,0,0,,${escapeAssText( - event.text, - )}`, - ); + 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).map((entry) => entry.text); + 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 fsp.writeFile(assPath, `${headerLines.join('\n')}\n`, 'utf8'); - await execFileAsync(FFMPEG_BIN, [ '-y', '-hide_banner', '-loglevel', 'error', - '-f', - 'lavfi', + '-framerate', + '1', '-i', - `color=c=0x111111:s=${SIDEBAR_WIDTH}x${height}:r=${TARGET_FPS}:d=${durationSec.toFixed(3)}`, + path.join(framesDir, 'frame-%04d.png'), '-vf', - `subtitles=${assPath}`, + `fps=${TARGET_FPS},format=yuv420p`, + '-t', + durationSec.toFixed(3), '-c:v', 'libx264', '-preset',