const { spawn, execFile } = require('child_process'); const fsp = require('fs/promises'); const fs = require('fs'); const os = require('os'); const path = require('path'); const { promisify } = require('util'); const EventEmitter = require('events'); const logger = require('../globals/logger').child('replayEngineV2'); const roverManager = require('./roverManager'); const { getRoomCameras, roomCameraEvents } = require('./roomCameraService'); const io = require('../globals/io'); const { getActiveDrivers } = require('./turnService'); const { getNickname } = require('./nicknameService'); const { getRecentMessages } = require('./chatService'); const execFileAsync = promisify(execFile); const FFMPEG_BIN = process.env.FFMPEG_BIN || 'ffmpeg'; const SEGMENT_ROOT = path.resolve(__dirname, '..', '..', 'data', 'replay-segments'); const SEGMENT_SECONDS = Math.max(1, Number.parseInt(process.env.REPLAY_SEGMENT_SECONDS || '1', 10)); const BUFFER_SECONDS = Math.max(20, Number.parseInt(process.env.REPLAY_BUFFER_SECONDS || '45', 10)); const CLEANUP_INTERVAL_MS = 10_000; const BUILD_DURATION_MS = Math.max(5000, Number.parseInt(process.env.REPLAY_DURATION_MS || '20000', 10)); const BUILD_GUARD_MS = Math.max(200, Number.parseInt(process.env.REPLAY_GUARD_MS || '1200', 10)); const TARGET_FPS = Math.max(10, Number.parseInt(process.env.REPLAY_TARGET_FPS || '30', 10)); const MAX_WIDTH = Math.max(320, Number.parseInt(process.env.REPLAY_MAX_WIDTH || '1280', 10)); const MAX_HEIGHT = Math.max(180, Number.parseInt(process.env.REPLAY_MAX_HEIGHT || '720', 10)); const MAX_BYTES = Math.floor(Number.parseFloat(process.env.REPLAY_MAX_OUTPUT_MB || '9.5') * 1024 * 1024); const SIDEBAR_WIDTH = Math.max(220, Number.parseInt(process.env.REPLAY_SIDEBAR_WIDTH || '360', 10)); const events = new EventEmitter(); const workers = new Map(); // key -> worker const pendingWorkerStarts = new Set(); // key const segmentIndex = new Map(); // key -> [{filePath,startMs,endMs,mtimeMs,size,kind,sourceType,sourceId}] let cleanupTimer = null; let activeSegmentRoot = SEGMENT_ROOT; let tickInFlight = false; function sourceKey(source) { return `${source.sourceType}__${source.kind}__${source.id}`; } function sourceDirForKey(key) { return path.join(activeSegmentRoot, key); } function toSrtReadPath(streamId) { return `srt://127.0.0.1:9000?streamid=read:${encodeURIComponent(streamId)}`; } function getRoomCameraStream(camera) { if (camera?.streamUrl) return camera.streamUrl; const url = String(camera?.url || ''); if (url.includes('.mjpg') || url.includes('mjpeg') || url.includes('stream')) return url; return null; } function listDesiredSources() { const sources = []; for (const rover of roverManager.getRoster()) { if (!roverManager.canReplayRoverId(rover.id)) continue; const roverId = String(rover.id); sources.push({ id: roverId, sourceType: 'rover', kind: 'video', label: rover.name || roverId, inputUrl: toSrtReadPath(roverId), }); if (rover?.media?.audioPublishUrl) { sources.push({ id: `${roverId}-audio`, sourceType: 'rover', roverId, kind: 'audio', label: `${rover.name || roverId} audio`, inputUrl: toSrtReadPath(`${roverId}-audio`), }); } } for (const camera of getRoomCameras()) { const streamUrl = getRoomCameraStream(camera); if (!streamUrl) continue; sources.push({ id: String(camera.id), sourceType: 'room', kind: 'video', label: camera.name || camera.id, inputUrl: streamUrl, }); } return sources; } function buildWorkerArgs(source) { const dir = sourceDirForKey(sourceKey(source)); const pattern = path.join(dir, 'seg-%06d.mp4'); const common = [ '-hide_banner', '-loglevel', 'warning', '-y', '-fflags', '+genpts', '-use_wallclock_as_timestamps', '1', '-i', source.inputUrl, ]; if (source.kind === 'audio') { return [ ...common, '-vn', '-ac', '1', '-ar', '48000', '-af', 'aresample=async=1:first_pts=0:min_hard_comp=0.100000,asetpts=N/SR/TB', '-c:a', 'aac', '-b:a', '96k', '-f', 'segment', '-segment_time', String(SEGMENT_SECONDS), '-segment_atclocktime', '1', '-reset_timestamps', '1', pattern, ]; } return [ ...common, '-an', '-vf', `fps=${TARGET_FPS}`, '-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency', '-pix_fmt', 'yuv420p', '-g', String(TARGET_FPS * SEGMENT_SECONDS), '-keyint_min', String(TARGET_FPS * SEGMENT_SECONDS), '-sc_threshold', '0', '-f', 'segment', '-segment_time', String(SEGMENT_SECONDS), '-segment_atclocktime', '1', '-reset_timestamps', '1', pattern, ]; } async function ensureDir(dir) { await fsp.mkdir(dir, { recursive: true }); } function startWorker(source) { const key = sourceKey(source); if (workers.has(key) || pendingWorkerStarts.has(key)) return; pendingWorkerStarts.add(key); const dir = sourceDirForKey(key); ensureDir(dir) .then(() => { if (workers.has(key)) { pendingWorkerStarts.delete(key); return; } const proc = spawn(FFMPEG_BIN, buildWorkerArgs(source), { stdio: ['ignore', 'ignore', 'pipe'] }); const worker = { key, source, proc, }; workers.set(key, worker); pendingWorkerStarts.delete(key); proc.stderr.on('data', (chunk) => { const text = String(chunk || '').trim(); if (!text) return; logger.warn('worker stderr', { key, text: text.slice(0, 500) }); }); proc.on('exit', (code, signal) => { const current = workers.get(key); if (current?.proc === proc) { workers.delete(key); } logger.warn('worker exited', { key, code, signal }); setTimeout(() => { const desired = listDesiredSources().find((entry) => sourceKey(entry) === key); if (desired && !workers.has(key)) { startWorker(desired); } }, 1500); }); }) .catch((err) => { pendingWorkerStarts.delete(key); logger.warn('failed to start worker', { key, error: err.message }); }); } function stopWorker(key) { const worker = workers.get(key); if (!worker) return; try { worker.proc.kill('SIGTERM'); } catch { // noop } workers.delete(key); } async function syncWorkers() { const desired = listDesiredSources(); const desiredKeys = new Set(desired.map(sourceKey)); for (const source of desired) { const key = sourceKey(source); if (!workers.has(key)) { startWorker(source); } } for (const key of Array.from(workers.keys())) { if (!desiredKeys.has(key)) { stopWorker(key); } } } async function refreshIndexForWorker(worker) { const key = worker.key; const dir = sourceDirForKey(key); let files; try { files = await fsp.readdir(dir, { withFileTypes: true }); } catch { return; } const segmentFiles = files .filter((entry) => entry.isFile() && /^seg-\d{6}\.mp4$/.test(entry.name)) .map((entry) => entry.name) .sort(); if (!segmentFiles.length) { segmentIndex.set(key, []); return; } const cutoffMs = Date.now() - BUFFER_SECONDS * 1000 - 5000; const entries = []; for (const filename of segmentFiles) { const filePath = path.join(dir, filename); let stat; try { stat = await fsp.stat(filePath); } catch { continue; } if (!stat.isFile() || stat.size < 4096) continue; const durationSec = SEGMENT_SECONDS; const endMs = Math.round(stat.mtimeMs); const startMs = Math.round(endMs - durationSec * 1000); if (endMs < cutoffMs) continue; entries.push({ filePath, startMs, endMs, mtimeMs: stat.mtimeMs, size: stat.size, kind: worker.source.kind, sourceType: worker.source.sourceType, sourceId: worker.source.id, roverId: worker.source.roverId || null, }); } segmentIndex.set(key, entries.sort((a, b) => a.startMs - b.startMs)); } async function refreshSegmentIndex() { const list = Array.from(workers.values()); for (const worker of list) { await refreshIndexForWorker(worker); } } async function cleanupOldFiles() { const cutoff = Date.now() - BUFFER_SECONDS * 1000; try { await ensureDir(activeSegmentRoot); const dirs = await fsp.readdir(activeSegmentRoot, { withFileTypes: true }); for (const dirent of dirs) { if (!dirent.isDirectory()) continue; const dirPath = path.join(activeSegmentRoot, dirent.name); let files; try { files = await fsp.readdir(dirPath, { withFileTypes: true }); } catch { continue; } for (const file of files) { if (!file.isFile() || !file.name.endsWith('.mp4')) continue; const filePath = path.join(dirPath, file.name); try { const stat = await fsp.stat(filePath); if (stat.mtimeMs < cutoff) { await fsp.unlink(filePath); } } catch { // noop } } } } catch (err) { logger.warn('cleanup failed', err.message); } } function getVideoEntriesForSource(source) { const key = sourceKey({ sourceType: String(source.type), kind: 'video', id: String(source.id) }); return segmentIndex.get(key) || []; } function getAudioEntriesForRover(roverId) { const key = sourceKey({ sourceType: 'rover', kind: 'audio', id: `${String(roverId)}-audio` }); return segmentIndex.get(key) || []; } function overlapping(entries, startMs, endMs) { return entries.filter((entry) => entry.endMs > startMs && entry.startMs < endMs); } function buildGridLayout(count) { const cols = Math.ceil(Math.sqrt(count)); const rows = Math.ceil(count / cols); return { cols, rows }; } function clampEven(value) { return Math.max(2, Math.floor(value / 2) * 2); } 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; return value.slice(0, 120); } function resolveDefaultReplayTitle(requester = '', sources = []) { const requesterLabel = String(requester || 'Someone').trim() || 'Someone'; const rover = (Array.isArray(sources) ? sources : []).find((entry) => entry?.type === 'rover'); const roverLabel = rover?.label || rover?.id || 'a rover'; return `${requesterLabel} driving ${roverLabel}`; } function buildDriverBatterySnapshot(selectedRoverIds = []) { const activeDrivers = getActiveDrivers(); const byId = new Map(roverManager.getRoster().map((rover) => [String(rover.id), rover])); const lines = []; for (const roverId of selectedRoverIds) { const socketId = activeDrivers[String(roverId)]; if (!socketId) continue; const socket = io.sockets.sockets.get(socketId); const nickname = getNickname(socket) || socket?.data?.user?.username || String(socketId); const rover = byId.get(String(roverId)); const roverName = rover?.name || roverId; const percent = rover?.batteryState?.percentDisplay; const batteryLabel = Number.isFinite(percent) ? `${percent}%` : '--%'; lines.push(`${nickname} driving ${roverName} (${batteryLabel})`); } return lines; } function buildChatEventsForWindow(startMs, endMs, limit = 18) { const all = getRecentMessages(300, { includeSystem: false }); const windowed = all .filter((msg) => Number.isFinite(msg?.ts) && msg.ts >= startMs && msg.ts <= endMs) .slice(-limit); return windowed.map((msg) => { const nickname = String(msg?.nickname || msg?.discordUserName || 'user').trim() || 'user'; const text = String(msg?.text || '').replace(/\s+/g, ' ').trim(); return { ts: Number(msg.ts), text: `${nickname}: ${text}`.slice(0, 120), }; }); } async function renderSidebarVideo({ tmpDir, title, durationSec, height, windowStartMs, driverBatteryLines = [], chatEvents = [], }) { const assPath = path.join(tmpDir, 'sidebar.ass'); 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 fsp.writeFile(assPath, `${headerLines.join('\n')}\n`, 'utf8'); await execFileAsync(FFMPEG_BIN, [ '-y', '-hide_banner', '-loglevel', 'error', '-f', 'lavfi', '-i', `color=c=0x111111:s=${SIDEBAR_WIDTH}x${height}:r=${TARGET_FPS}:d=${durationSec.toFixed(3)}`, '-vf', `subtitles=${assPath}`, '-c:v', 'libx264', '-preset', 'veryfast', '-pix_fmt', 'yuv420p', sidebarPath, ]); return sidebarPath; } async function concatFiles(inputPaths, outPath) { const listPath = `${outPath}.concat.txt`; const body = inputPaths.map((file) => `file '${file.replace(/'/g, "'\\''")}'`).join('\n'); await fsp.writeFile(listPath, `${body}\n`, 'utf8'); await execFileAsync(FFMPEG_BIN, [ '-y', '-hide_banner', '-loglevel', 'error', '-f', 'concat', '-safe', '0', '-i', listPath, '-c', 'copy', outPath, ]); } async function probeMaxFrameSize(paths) { let maxWidth = 0; let maxHeight = 0; for (const filePath of paths) { try { const { stdout } = await execFileAsync('ffprobe', [ '-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'csv=p=0', filePath, ]); const [wRaw, hRaw] = stdout.trim().split(','); const w = Number(wRaw); const h = Number(hRaw); if (Number.isFinite(w) && Number.isFinite(h)) { maxWidth = Math.max(maxWidth, w); maxHeight = Math.max(maxHeight, h); } } catch { // noop } } return { maxWidth, maxHeight }; } async function buildReplayVideo({ sources = [], title = '', requester = '' } = {}) { if (!Array.isArray(sources) || !sources.length) { throw new Error('No replay sources selected'); } const tEnd = Date.now() - BUILD_GUARD_MS; const tStart = tEnd - BUILD_DURATION_MS; const resolvedTitle = sanitizeReplayTitle(title, resolveDefaultReplayTitle(requester, sources)); const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'mrr-replay-v2-')); try { await refreshSegmentIndex(); const usedSources = []; const missingSources = []; const normalizedVideos = []; const normalizedAudios = []; for (let i = 0; i < sources.length; i += 1) { const source = sources[i]; const sourceId = String(source.id); const videoEntries = overlapping( getVideoEntriesForSource({ type: String(source.type), id: sourceId }), tStart, tEnd, ); if (!videoEntries.length) { missingSources.push({ ...source, reason: 'no video coverage in replay window' }); continue; } const videoConcat = path.join(tmpDir, `video-${i}.mp4`); await concatFiles(videoEntries.map((entry) => entry.filePath), videoConcat); const videoTrimmed = path.join(tmpDir, `video-${i}.trim.mp4`); const firstStartMs = videoEntries[0].startMs; const ss = Math.max(0, (tStart - firstStartMs) / 1000); const to = Math.max(ss + 0.1, (tEnd - firstStartMs) / 1000); await execFileAsync(FFMPEG_BIN, [ '-y', '-hide_banner', '-loglevel', 'error', '-ss', ss.toFixed(3), '-to', to.toFixed(3), '-i', videoConcat, '-an', '-c:v', 'libx264', '-preset', 'veryfast', '-pix_fmt', 'yuv420p', '-r', String(TARGET_FPS), videoTrimmed, ]); normalizedVideos.push({ path: videoTrimmed, source }); usedSources.push(source); if (source.type === 'rover') { const audioEntries = overlapping(getAudioEntriesForRover(sourceId), tStart, tEnd); if (audioEntries.length) { const audioConcat = path.join(tmpDir, `audio-${i}.m4a`); await concatFiles(audioEntries.map((entry) => entry.filePath), audioConcat); const audioTrimmed = path.join(tmpDir, `audio-${i}.trim.m4a`); const firstAudioStartMs = audioEntries[0].startMs; const ass = Math.max(0, (tStart - firstAudioStartMs) / 1000); const ato = Math.max(ass + 0.1, (tEnd - firstAudioStartMs) / 1000); await execFileAsync(FFMPEG_BIN, [ '-y', '-hide_banner', '-loglevel', 'error', '-ss', ass.toFixed(3), '-to', ato.toFixed(3), '-i', audioConcat, '-vn', '-ac', '1', '-ar', '48000', '-c:a', 'aac', '-b:a', '96k', audioTrimmed, ]); normalizedAudios.push(audioTrimmed); } } } if (!normalizedVideos.length) { throw new Error('No replay segments available for selected sources'); } const layout = buildGridLayout(normalizedVideos.length); const { maxWidth, maxHeight } = await probeMaxFrameSize(normalizedVideos.map((v) => v.path)); let tileWidth = maxWidth || 640; let tileHeight = maxHeight || 360; let outWidth = tileWidth * layout.cols; let outHeight = tileHeight * layout.rows; if (outWidth > MAX_WIDTH || outHeight > MAX_HEIGHT) { const scale = Math.min(MAX_WIDTH / outWidth, MAX_HEIGHT / outHeight); tileWidth *= scale; tileHeight *= scale; outWidth = tileWidth * layout.cols; outHeight = tileHeight * layout.rows; } tileWidth = clampEven(tileWidth); tileHeight = clampEven(tileHeight); const selectedRoverIds = usedSources .filter((entry) => entry?.type === 'rover') .map((entry) => String(entry.id)); const driverBatteryLines = buildDriverBatterySnapshot(selectedRoverIds); const chatEvents = buildChatEventsForWindow(tStart, tEnd); const durationSec = BUILD_DURATION_MS / 1000; const sidebarPath = await renderSidebarVideo({ tmpDir, title: resolvedTitle, durationSec, height: clampEven(tileHeight * layout.rows), windowStartMs: tStart, driverBatteryLines, chatEvents, }); const inputArgs = []; const filterParts = []; const layoutParts = []; for (let i = 0; i < normalizedVideos.length; i += 1) { inputArgs.push('-i', normalizedVideos[i].path); filterParts.push(`[${i}:v]${scalePadFilter(tileWidth, tileHeight)}[v${i}]`); const x = (i % layout.cols) * tileWidth; const y = Math.floor(i / layout.cols) * tileHeight; layoutParts.push(`${x}_${y}`); } inputArgs.push('-i', sidebarPath); const sidebarInputIndex = normalizedVideos.length; const audioInputStart = normalizedVideos.length + 1; for (const audioPath of normalizedAudios) { inputArgs.push('-i', audioPath); } if (normalizedVideos.length === 1) { filterParts.push('[v0]null[vgrid]'); } else { filterParts.push( `${normalizedVideos.map((_, i) => `[v${i}]`).join('')}` + `xstack=inputs=${normalizedVideos.length}:layout=${layoutParts.join('|')}:fill=black[vgrid]`, ); } filterParts.push(`[vgrid][${sidebarInputIndex}:v]hstack=inputs=2[vout]`); if (normalizedAudios.length) { const audioRefs = normalizedAudios.map((_, idx) => `[${audioInputStart + idx}:a]`).join(''); filterParts.push(`${audioRefs}amix=inputs=${normalizedAudios.length}:normalize=0,alimiter=limit=0.9[aout]`); } const targetBitrateKbps = Math.max(400, Math.floor((MAX_BYTES * 8) / durationSec / 1000)); const outPath = path.join(tmpDir, 'replay.mp4'); const args = [ '-y', '-hide_banner', '-loglevel', 'error', ...inputArgs, '-filter_complex', filterParts.join(';'), '-map', '[vout]', '-c:v', 'libx264', '-preset', 'veryfast', '-pix_fmt', 'yuv420p', '-r', String(TARGET_FPS), '-b:v', `${targetBitrateKbps}k`, '-maxrate', `${Math.floor(targetBitrateKbps * 1.15)}k`, '-bufsize', `${Math.floor(targetBitrateKbps * 2)}k`, ]; if (normalizedAudios.length) { args.push('-map', '[aout]', '-c:a', 'aac', '-b:a', '128k'); } args.push(outPath); await execFileAsync(FFMPEG_BIN, args); const buffer = await fsp.readFile(outPath); return { buffer, usedSources, missingSources, title: resolvedTitle }; } finally { try { await fsp.rm(tmpDir, { recursive: true, force: true }); } catch { // noop } } } function getReplayHealthSnapshot() { const now = Date.now(); const neededCount = Math.max(1, Math.ceil(BUILD_DURATION_MS / 1000)); const sources = []; let readyCount = 0; const replaySources = []; for (const rover of roverManager.getRoster().filter((entry) => roverManager.canReplayRoverId(entry.id))) { replaySources.push({ type: 'rover', id: String(rover.id), label: rover.name || rover.id }); } for (const camera of getRoomCameras()) { replaySources.push({ type: 'room', id: String(camera.id), label: camera.name || camera.id }); } for (const source of replaySources) { const key = sourceKey({ sourceType: source.type, kind: 'video', id: String(source.id) }); const dir = sourceDirForKey(key); let recentCount = 0; let lastSegmentAt = null; try { const files = fs.readdirSync(dir); for (const name of files) { if (!/^seg-\d{6}\.mp4$/.test(name)) continue; const full = path.join(dir, name); let stat; try { stat = fs.statSync(full); } catch { continue; } if (!stat.isFile() || stat.size < 4096) continue; if (stat.mtimeMs > (lastSegmentAt || 0)) { lastSegmentAt = stat.mtimeMs; } if (now - stat.mtimeMs <= BUFFER_SECONDS * 1000) { recentCount += 1; } } } catch { // ignore missing source directory } const ready = recentCount >= neededCount; if (ready) readyCount += 1; sources.push({ type: source.type, id: source.id, label: source.label, recentCount, neededCount, lastSegmentAt, ready, }); } return { sources, readyCount, totalCount: sources.length, }; } async function bootstrapIndexFromDisk() { await ensureDir(activeSegmentRoot); const dirs = await fsp.readdir(activeSegmentRoot, { withFileTypes: true }); for (const dirent of dirs) { if (!dirent.isDirectory()) continue; segmentIndex.set(dirent.name, []); } } async function tick() { if (tickInFlight) return; tickInFlight = true; try { await syncWorkers(); await refreshSegmentIndex(); await cleanupOldFiles(); events.emit('health', getReplayHealthSnapshot()); } finally { tickInFlight = false; } } async function start() { await ensureDir(SEGMENT_ROOT); activeSegmentRoot = SEGMENT_ROOT; logger.info('Replay engine using segment root', { segmentRoot: activeSegmentRoot }); await bootstrapIndexFromDisk(); await tick(); if (cleanupTimer) clearInterval(cleanupTimer); cleanupTimer = setInterval(() => { tick().catch((err) => logger.warn('tick failed', err.message)); }, CLEANUP_INTERVAL_MS); } roomCameraEvents.on('update', () => { tick().catch((err) => logger.warn('room camera update tick failed', err.message)); }); roverManager.managerEvents.on('rover', () => { tick().catch((err) => logger.warn('rover update tick failed', err.message)); }); roverManager.managerEvents.on('private', () => { tick().catch((err) => logger.warn('private update tick failed', err.message)); }); start().catch((err) => { logger.warn('replay engine startup failed', err.message); }); module.exports = { buildReplayVideo, getReplayHealthSnapshot, replayEngineEvents: events, replaySegmentRootDir: () => activeSegmentRoot, replaySegmentSeconds: SEGMENT_SECONDS, replayBufferSeconds: BUFFER_SECONDS, };