diff --git a/server/src/services/discordBotService.js b/server/src/services/discordBotService.js index ce26ec56..03de1c3b 100644 --- a/server/src/services/discordBotService.js +++ b/server/src/services/discordBotService.js @@ -6,11 +6,6 @@ const { EmbedBuilder, AttachmentBuilder, } = require('discord.js'); -const fsp = require('fs/promises'); -const os = require('os'); -const path = require('path'); -const { promisify } = require('util'); -const { execFile } = require('child_process'); const logger = require('../globals/logger').child('discordBot'); const io = require('../globals/io'); const { loadConfig } = require('../helpers/configLoader'); @@ -19,11 +14,7 @@ const { getRoster, lockRover, rovers } = require('./roverManager'); const { MODES, getMode, setMode } = require('./modeManager'); const { sendExternalMessage } = require('./chatService'); const { getRoomCameras, getRoomCamera } = require('./roomCameraService'); -const { - getRoomCameraFrames, - getRoomCameraReplayDelayMs, - getRoomCameraReplayFrameCount, -} = require('./roomCameraSnapshotService'); +const { buildRoomCameraReplayVideo } = require('./roomCameraReplayService'); const { getActiveDrivers } = require('./turnService'); const { getNickname } = require('./nicknameService'); const { tryTriggerReplay } = require('./replayService'); @@ -53,8 +44,6 @@ const client = new Client({ const channelCache = new Map(); let skippedFirstModeAnnouncement = false; -const execFileAsync = promisify(execFile); - function sanitizeMentions(text) { if (!text) return ''; return String(text) @@ -281,111 +270,6 @@ function resolveReplayCamera(query) { return { error: 'Camera not found', matches: [] }; } -async function buildReplayVideo({ cameraId = null } = {}) { - const cameras = cameraId ? [getRoomCamera(cameraId)].filter(Boolean) : getRoomCameras(); - if (!cameras.length) { - throw new Error('No room cameras configured'); - } - const frames = []; - cameras.forEach((camera) => { - const history = getRoomCameraFrames(camera.id, getRoomCameraReplayFrameCount()); - history.forEach((entry) => { - frames.push({ camera, buffer: entry.buffer }); - }); - }); - if (!frames.length) { - throw new Error('No camera frames available yet'); - } - const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'rover-replay-')); - try { - const firstFrameByCamera = new Map(); - for (let i = 0; i < frames.length; i += 1) { - const filename = `frame-${String(i + 1).padStart(4, '0')}.jpg`; - await fsp.writeFile(path.join(tmpDir, filename), frames[i].buffer); - const cameraId = frames[i].camera?.id; - if (cameraId && !firstFrameByCamera.has(cameraId)) { - firstFrameByCamera.set(cameraId, filename); - } - } - const outPath = path.join(tmpDir, 'replay.mp4'); - const delayMs = getRoomCameraReplayDelayMs(); - const fpsValue = 1000 / delayMs; - const fps = fpsValue.toFixed(3); - let maxWidth = 0; - let maxHeight = 0; - for (const filename of firstFrameByCamera.values()) { - try { - const { stdout } = await execFileAsync('ffprobe', [ - '-v', - 'error', - '-select_streams', - 'v:0', - '-show_entries', - 'stream=width,height', - '-of', - 'csv=p=0', - path.join(tmpDir, filename), - ]); - const [widthRaw, heightRaw] = stdout.trim().split(','); - const width = Number(widthRaw); - const height = Number(heightRaw); - if (Number.isFinite(width) && Number.isFinite(height)) { - maxWidth = Math.max(maxWidth, width); - maxHeight = Math.max(maxHeight, height); - } - } catch (err) { - logger.warn('Failed to probe replay frame size', err.message); - } - } - const MAX_REPLAY_WIDTH = 1280; - const MAX_REPLAY_HEIGHT = 720; - let targetWidth = maxWidth || MAX_REPLAY_WIDTH; - let targetHeight = maxHeight || MAX_REPLAY_HEIGHT; - if (targetWidth > MAX_REPLAY_WIDTH || targetHeight > MAX_REPLAY_HEIGHT) { - const scale = Math.min(MAX_REPLAY_WIDTH / targetWidth, MAX_REPLAY_HEIGHT / targetHeight); - targetWidth = Math.max(2, Math.floor((targetWidth * scale) / 2) * 2); - targetHeight = Math.max(2, Math.floor((targetHeight * scale) / 2) * 2); - } - const sizeFilter = `scale=${targetWidth}:${targetHeight}:force_original_aspect_ratio=decrease:flags=lanczos,pad=${targetWidth}:${targetHeight}:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1`; - const maxBytes = Math.floor(9.5 * 1024 * 1024); - const durationSec = Math.max(1, frames.length / fpsValue); - const targetBitrateKbps = Math.max(300, Math.floor((maxBytes * 8) / durationSec / 1000)); - const maxrateKbps = Math.floor(targetBitrateKbps * 1.1); - const bufsizeKbps = Math.floor(targetBitrateKbps * 2); - await execFileAsync('ffmpeg', [ - '-y', - '-hide_banner', - '-loglevel', - 'error', - '-framerate', - fps, - '-i', - 'frame-%04d.jpg', - '-vf', - sizeFilter, - '-c:v', - 'libx264', - '-b:v', - `${targetBitrateKbps}k`, - '-maxrate', - `${maxrateKbps}k`, - '-bufsize', - `${bufsizeKbps}k`, - '-pix_fmt', - 'yuv420p', - outPath, - ], { cwd: tmpDir }); - const buffer = await fsp.readFile(outPath); - return buffer; - } finally { - try { - await fsp.rm(tmpDir, { recursive: true, force: true }); - } catch (err) { - logger.warn('Failed to cleanup replay temp dir', err.message); - } - } -} - function buildReplayCaption(requester, camera) { const requesterLabel = requester || 'unknown'; const cameraLabel = camera ? `Camera: ${camera.name || camera.id}.` : null; @@ -402,7 +286,7 @@ async function sendReplayToChannel(channelId, requester, cameraId = null) { if (!channelId) { throw new Error('Replay channel not configured'); } - const buffer = await buildReplayVideo({ cameraId }); + const buffer = await buildRoomCameraReplayVideo({ cameraId }); const attachment = new AttachmentBuilder(buffer, { name: 'replay.mp4' }); const camera = cameraId ? getRoomCamera(cameraId) : null; const caption = buildReplayCaption(requester, camera); @@ -445,7 +329,7 @@ async function handleReplayCommand(message, query) { const requester = message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord'; try { - const buffer = await buildReplayVideo({ cameraId }); + const buffer = await buildRoomCameraReplayVideo({ cameraId }); const attachment = new AttachmentBuilder(buffer, { name: 'replay.mp4' }); const caption = buildReplayCaption(requester, resolved.camera || null); await message.reply({ diff --git a/server/src/services/roomCameraReplayService.js b/server/src/services/roomCameraReplayService.js new file mode 100644 index 00000000..ce03580c --- /dev/null +++ b/server/src/services/roomCameraReplayService.js @@ -0,0 +1,246 @@ +const { execFile } = require('child_process'); +const EventEmitter = require('events'); +const fsp = require('fs/promises'); +const os = require('os'); +const path = require('path'); +const { promisify } = require('util'); + +const logger = require('../globals/logger').child('roomCameraReplay'); +const { getRoomCamera, getRoomCameras } = require('./roomCameraService'); + +const execFileAsync = promisify(execFile); + +const REPLAY_DURATION_MS = 20000; +const REPLAY_FPS = 15; +const HISTORY_WINDOW_MS = 60000; +const MAX_REPLAY_WIDTH = 1280; +const MAX_REPLAY_HEIGHT = 720; +const REPLAY_MAX_BYTES = Math.floor(9.5 * 1024 * 1024); + +const frameHistory = new Map(); // id -> [{ buffer, ts }] +const latestFrames = new Map(); // id -> { buffer, ts } +const events = new EventEmitter(); + +function recordFrame(id, buffer, ts = Date.now()) { + if (!id || !buffer) return; + const entry = { buffer, ts }; + latestFrames.set(id, entry); + const history = frameHistory.get(id) || []; + history.push(entry); + const cutoff = ts - HISTORY_WINDOW_MS; + while (history.length && history[0].ts < cutoff) { + history.shift(); + } + frameHistory.set(id, history); + events.emit('frame', { id, ts }); +} + +function clearFrames() { + frameHistory.clear(); + latestFrames.clear(); +} + +function getReplayMetadata() { + return { + durationMs: REPLAY_DURATION_MS, + fps: REPLAY_FPS, + }; +} + +function buildTimelineForCamera(id, startMs, frameCount, frameStepMs) { + const history = frameHistory.get(id) || []; + const fallback = latestFrames.get(id)?.buffer || null; + if (!history.length && !fallback) return null; + let idx = 0; + let lastBuffer = null; + while (idx < history.length && history[idx].ts < startMs) { + lastBuffer = history[idx].buffer; + idx += 1; + } + if (!lastBuffer) { + lastBuffer = history[0]?.buffer || fallback; + } + const frames = new Array(frameCount); + for (let i = 0; i < frameCount; i += 1) { + const slotTs = startMs + i * frameStepMs; + while (idx < history.length && history[idx].ts <= slotTs) { + lastBuffer = history[idx].buffer; + idx += 1; + } + frames[i] = lastBuffer || fallback; + } + return frames; +} + +function buildGridLayout(count) { + const cols = Math.ceil(Math.sqrt(count)); + const rows = Math.ceil(count / cols); + return { cols, rows }; +} + +async function probeMaxFrameSize(framePaths) { + let maxWidth = 0; + let maxHeight = 0; + for (const framePath of framePaths) { + try { + const { stdout } = await execFileAsync('ffprobe', [ + '-v', + 'error', + '-select_streams', + 'v:0', + '-show_entries', + 'stream=width,height', + '-of', + 'csv=p=0', + framePath, + ]); + const [widthRaw, heightRaw] = stdout.trim().split(','); + const width = Number(widthRaw); + const height = Number(heightRaw); + if (Number.isFinite(width) && Number.isFinite(height)) { + maxWidth = Math.max(maxWidth, width); + maxHeight = Math.max(maxHeight, height); + } + } catch (err) { + logger.warn('Failed to probe replay frame size', err.message); + } + } + return { maxWidth, maxHeight }; +} + +function buildScalePadFilter(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 clampEven(value) { + return Math.max(2, Math.floor(value / 2) * 2); +} + +async function buildReplayVideo({ cameraId = null } = {}) { + const cameras = cameraId ? [getRoomCamera(cameraId)].filter(Boolean) : getRoomCameras(); + if (!cameras.length) { + throw new Error('No room cameras configured'); + } + + const fpsValue = REPLAY_FPS; + const frameCount = Math.max(1, Math.round((REPLAY_DURATION_MS / 1000) * fpsValue)); + const frameStepMs = 1000 / fpsValue; + const startMs = Date.now() - REPLAY_DURATION_MS; + + const cameraEntries = []; + cameras.forEach((camera) => { + const frames = buildTimelineForCamera(camera.id, startMs, frameCount, frameStepMs); + if (!frames) return; + cameraEntries.push({ camera, frames }); + }); + + if (!cameraEntries.length) { + throw new Error('No camera frames available yet'); + } + + const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'rover-replay-')); + try { + const firstFramePaths = []; + for (let i = 0; i < cameraEntries.length; i += 1) { + const entry = cameraEntries[i]; + const camDir = path.join(tmpDir, `cam-${String(i).padStart(2, '0')}`); + entry.dir = camDir; + await fsp.mkdir(camDir, { recursive: true }); + for (let j = 0; j < entry.frames.length; j += 1) { + const buffer = entry.frames[j]; + if (!buffer) { + throw new Error(`Camera ${entry.camera.id} missing replay frame`); + } + const filename = `frame-${String(j + 1).padStart(4, '0')}.jpg`; + const fullPath = path.join(camDir, filename); + if (j === 0) { + firstFramePaths.push(fullPath); + } + await fsp.writeFile(fullPath, buffer); + } + } + + const { maxWidth, maxHeight } = await probeMaxFrameSize(firstFramePaths); + const layout = buildGridLayout(cameraEntries.length); + let tileWidth = maxWidth || 640; + let tileHeight = maxHeight || 360; + let outputWidth = tileWidth * layout.cols; + let outputHeight = tileHeight * layout.rows; + if (outputWidth > MAX_REPLAY_WIDTH || outputHeight > MAX_REPLAY_HEIGHT) { + const scale = Math.min(MAX_REPLAY_WIDTH / outputWidth, MAX_REPLAY_HEIGHT / outputHeight); + tileWidth = tileWidth * scale; + tileHeight = tileHeight * scale; + outputWidth = tileWidth * layout.cols; + outputHeight = tileHeight * layout.rows; + } + tileWidth = clampEven(tileWidth); + tileHeight = clampEven(tileHeight); + outputWidth = clampEven(tileWidth * layout.cols); + outputHeight = clampEven(tileHeight * layout.rows); + + const fps = fpsValue.toFixed(3); + const durationSec = Math.max(1, frameCount / fpsValue); + const targetBitrateKbps = Math.max(300, Math.floor((REPLAY_MAX_BYTES * 8) / durationSec / 1000)); + const maxrateKbps = Math.floor(targetBitrateKbps * 1.1); + const bufsizeKbps = Math.floor(targetBitrateKbps * 2); + + const inputArgs = []; + const filterParts = []; + const layoutParts = []; + for (let i = 0; i < cameraEntries.length; i += 1) { + inputArgs.push('-framerate', fps, '-i', path.join(cameraEntries[i].dir, 'frame-%04d.jpg')); + filterParts.push( + `[${i}:v]${buildScalePadFilter(tileWidth, tileHeight)}[v${i}]`, + ); + const x = (i % layout.cols) * tileWidth; + const y = Math.floor(i / layout.cols) * tileHeight; + layoutParts.push(`${x}_${y}`); + } + filterParts.push( + `${cameraEntries.map((_, i) => `[v${i}]`).join('')}` + + `xstack=inputs=${cameraEntries.length}:layout=${layoutParts.join('|')}:fill=black[v]`, + ); + + const outPath = path.join(tmpDir, 'replay.mp4'); + await execFileAsync('ffmpeg', [ + '-y', + '-hide_banner', + '-loglevel', + 'error', + ...inputArgs, + '-filter_complex', + filterParts.join(';'), + '-map', + '[v]', + '-r', + fps, + '-c:v', + 'libx264', + '-b:v', + `${targetBitrateKbps}k`, + '-maxrate', + `${maxrateKbps}k`, + '-bufsize', + `${bufsizeKbps}k`, + '-pix_fmt', + 'yuv420p', + outPath, + ]); + const buffer = await fsp.readFile(outPath); + return buffer; + } finally { + try { + await fsp.rm(tmpDir, { recursive: true, force: true }); + } catch (err) { + logger.warn('Failed to cleanup replay temp dir', err.message); + } + } +} + +module.exports = { + recordRoomCameraFrame: recordFrame, + clearRoomCameraReplayFrames: clearFrames, + getRoomCameraReplayMetadata: getReplayMetadata, + buildRoomCameraReplayVideo: buildReplayVideo, + roomCameraReplayEvents: events, +}; diff --git a/server/src/services/roomCameraSnapshotService.js b/server/src/services/roomCameraSnapshotService.js index 85a29f04..82fe355b 100644 --- a/server/src/services/roomCameraSnapshotService.js +++ b/server/src/services/roomCameraSnapshotService.js @@ -1,13 +1,15 @@ const EventEmitter = require('events'); const logger = require('../globals/logger').child('roomCameraSnapshot'); const { getRoomCameras, roomCameraEvents } = require('./roomCameraService'); +const { + recordRoomCameraFrame, + clearRoomCameraReplayFrames, +} = require('./roomCameraReplayService'); const POLL_INTERVAL_MS = 67; -const REPLAY_FRAME_COUNT = 225; const FETCH_TIMEOUT_MS = 2000; const cameraState = new Map(); // id -> {frame, ts, error, failures, fetching} -const frameHistory = new Map(); // id -> [{buffer, ts}] const events = new EventEmitter(); // frame, status let pollTimer = null; @@ -18,15 +20,6 @@ function markState(id, updates = {}) { return next; } -function recordFrame(id, buffer, ts) { - const history = frameHistory.get(id) || []; - history.push({ buffer, ts }); - if (history.length > REPLAY_FRAME_COUNT) { - history.splice(0, history.length - REPLAY_FRAME_COUNT); - } - frameHistory.set(id, history); -} - async function fetchSnapshot(camera) { const { id, url } = camera; const state = cameraState.get(id); @@ -43,7 +36,7 @@ async function fetchSnapshot(camera) { const buffer = Buffer.from(arrayBuffer); const ts = Date.now(); markState(id, { frame: buffer, ts, error: null, failures: 0 }); - recordFrame(id, buffer, ts); + recordRoomCameraFrame(id, buffer, ts); events.emit('frame', { id, buffer, ts }); } catch (err) { const failures = (state?.failures || 0) + 1; @@ -62,7 +55,7 @@ function stopAll() { pollTimer = null; } cameraState.clear(); - frameHistory.clear(); + clearRoomCameraReplayFrames(); } function startAll() { @@ -84,20 +77,6 @@ function getState(id) { }; } -function getReplayFrames(id, count = REPLAY_FRAME_COUNT) { - const history = frameHistory.get(id) || []; - if (!history.length) return []; - return history.slice(Math.max(0, history.length - count)); -} - -function getReplayFrameDelayMs() { - return POLL_INTERVAL_MS; -} - -function getReplayFrameCount() { - return REPLAY_FRAME_COUNT; -} - roomCameraEvents.on('update', () => { logger.info('Room cameras changed; restarting snapshot pollers'); startAll(); @@ -108,7 +87,4 @@ startAll(); module.exports = { roomCameraStreamEvents: events, getRoomCameraState: getState, - getRoomCameraFrames: getReplayFrames, - getRoomCameraReplayDelayMs: getReplayFrameDelayMs, - getRoomCameraReplayFrameCount: getReplayFrameCount, };