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 `
+`;
+}
+
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',