From e0cadb493fee29f113d5024da4c5f77973099722 Mon Sep 17 00:00:00 2001 From: legop3 Date: Sat, 3 Jan 2026 20:53:38 -0500 Subject: [PATCH] replay capture ? --- server/src/services/discordBotService.js | 132 +++++++++++++++++- server/src/services/replayService.js | 42 ++++++ .../src/services/roomCameraSnapshotService.js | 30 ++++ server/src/services/sessionService.js | 7 + 4 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 server/src/services/replayService.js diff --git a/server/src/services/discordBotService.js b/server/src/services/discordBotService.js index e77a2e8c..f8fba3b7 100644 --- a/server/src/services/discordBotService.js +++ b/server/src/services/discordBotService.js @@ -4,13 +4,29 @@ const { Partials, ActivityType, 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'); const { subscribe } = require('./eventBus'); const { getRoster, lockRover, rovers } = require('./roverManager'); const { MODES, getMode, setMode } = require('./modeManager'); const { sendExternalMessage } = require('./chatService'); +const { getRoomCameras } = require('./roomCameraService'); +const { + getRoomCameraFrames, + getRoomCameraReplayDelayMs, + getRoomCameraReplayFrameCount, +} = require('./roomCameraSnapshotService'); +const { getActiveDrivers } = require('./turnService'); +const { getNickname } = require('./nicknameService'); +const { tryTriggerReplay } = require('./replayService'); const config = loadConfig(); const discordConfig = config.discord || {}; @@ -37,6 +53,7 @@ const client = new Client({ const channelCache = new Map(); let skippedFirstModeAnnouncement = false; +const execFileAsync = promisify(execFile); function sanitizeMentions(text) { if (!text) return ''; @@ -182,6 +199,7 @@ function formatHelp() { '**Rover Bot Commands**', '`rs help` — show this help', '`rs status [id]` — show rover status (all or one)', + '`rs replay` — send room camera instant replay', '`rs lock ` — lock a rover', '`rs unlock ` — unlock a rover', '`rs mode ` — change server mode', @@ -216,6 +234,115 @@ async function handleStatusCommand(message, roverId) { }); } +function buildDriverCaption() { + const activeDrivers = getActiveDrivers(); + const roster = Array.from(rovers.values()); + if (!roster.length) return 'Drivers: no rovers online.'; + const entries = roster.map((record) => { + const driverId = activeDrivers[record.id]; + if (!driverId) { + return `${record.meta?.name || record.id}: none`; + } + const socket = io.sockets.sockets.get(driverId); + const nickname = getNickname(socket) || socket?.data?.user?.username || driverId; + return `${record.meta?.name || record.id}: ${nickname}`; + }); + return `Drivers: ${entries.join(', ')}`; +} + +async function buildReplayGif() { + const cameras = 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 { + 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 outPath = path.join(tmpDir, 'replay.gif'); + const delayMs = getRoomCameraReplayDelayMs(); + const fps = (1000 / delayMs).toFixed(3); + await execFileAsync('ffmpeg', [ + '-y', + '-hide_banner', + '-loglevel', + 'error', + '-framerate', + fps, + '-i', + 'frame-%04d.jpg', + '-vf', + 'scale=640:-1:flags=lanczos', + '-loop', + '0', + 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); + } + } +} + +async function handleReplayCommand(message) { + if (getMode() === MODES.LOCKDOWN) { + await message.reply({ + content: 'Replay is disabled while the server is in lockdown.', + allowedMentions: { parse: [], repliedUser: false }, + }); + return; + } + const attempt = tryTriggerReplay({ + by: message.author?.id || null, + source: 'discord', + }); + if (!attempt.ok) { + const remaining = Math.ceil(attempt.remainingMs / 1000); + await message.reply({ + content: `Replay cooldown active. Try again in ${remaining}s.`, + allowedMentions: { parse: [], repliedUser: false }, + }); + return; + } + const requester = + message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord'; + try { + const buffer = await buildReplayGif(); + const attachment = new AttachmentBuilder(buffer, { name: 'replay.gif' }); + const caption = [ + `Replay requested by ${requester}.`, + buildDriverCaption(), + ].join(' '); + await message.reply({ + content: sanitizeMentions(caption), + files: [attachment], + allowedMentions: { parse: [], repliedUser: false }, + }); + } catch (err) { + logger.warn('Replay capture failed', err.message); + await message.reply({ + content: sanitizeMentions(`Replay failed: ${err.message}`), + allowedMentions: { parse: [], repliedUser: false }, + }); + } +} + async function handleLockCommand(message, roverId, locked) { if (!roverId) { await message.reply({ @@ -271,7 +398,7 @@ async function handleCommand(message) { const action = (tokens.shift() || '').toLowerCase(); const isAdmin = isAdminUser(message.author.id); - if (!isAdmin && action !== '' && action !== 'status' && action !== 'help') { + if (!isAdmin && action !== '' && action !== 'status' && action !== 'help' && action !== 'replay') { return; // ignore non-admins for privileged commands } @@ -285,6 +412,9 @@ async function handleCommand(message) { case 'status': await handleStatusCommand(message, tokens[0]); break; + case 'replay': + await handleReplayCommand(message); + break; case 'lock': await handleLockCommand(message, tokens[0], true); break; diff --git a/server/src/services/replayService.js b/server/src/services/replayService.js new file mode 100644 index 00000000..553c7e9c --- /dev/null +++ b/server/src/services/replayService.js @@ -0,0 +1,42 @@ +const EventEmitter = require('events'); + +const COOLDOWN_MS = 10 * 1000; + +const events = new EventEmitter(); +let lastTriggeredAt = null; +let lastTriggeredBy = null; + +function getRemainingMs(now = Date.now()) { + if (!lastTriggeredAt) return 0; + const elapsed = now - lastTriggeredAt; + return Math.max(0, COOLDOWN_MS - elapsed); +} + +function getState() { + const remainingMs = getRemainingMs(); + return { + cooldownMs: COOLDOWN_MS, + lastTriggeredAt, + lastTriggeredBy, + remainingMs, + available: remainingMs === 0, + }; +} + +function tryTrigger(by = null) { + const remainingMs = getRemainingMs(); + if (remainingMs > 0) { + return { ok: false, remainingMs, state: getState() }; + } + lastTriggeredAt = Date.now(); + lastTriggeredBy = by || null; + const state = getState(); + events.emit('update', { state, by: lastTriggeredBy }); + return { ok: true, state }; +} + +module.exports = { + tryTriggerReplay: tryTrigger, + getReplayState: getState, + replayEvents: events, +}; diff --git a/server/src/services/roomCameraSnapshotService.js b/server/src/services/roomCameraSnapshotService.js index b7f929fc..93649247 100644 --- a/server/src/services/roomCameraSnapshotService.js +++ b/server/src/services/roomCameraSnapshotService.js @@ -3,9 +3,11 @@ const logger = require('../globals/logger').child('roomCameraSnapshot'); const { getRoomCameras, roomCameraEvents } = require('./roomCameraService'); const POLL_INTERVAL_MS = 800; +const REPLAY_FRAME_COUNT = 15; 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; @@ -16,6 +18,15 @@ 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); @@ -32,6 +43,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); events.emit('frame', { id, buffer, ts }); } catch (err) { const failures = (state?.failures || 0) + 1; @@ -50,6 +62,7 @@ function stopAll() { pollTimer = null; } cameraState.clear(); + frameHistory.clear(); } function startAll() { @@ -71,6 +84,20 @@ 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(); @@ -81,4 +108,7 @@ startAll(); module.exports = { roomCameraStreamEvents: events, getRoomCameraState: getState, + getRoomCameraFrames: getReplayFrames, + getRoomCameraReplayDelayMs: getReplayFrameDelayMs, + getRoomCameraReplayFrameCount: getReplayFrameCount, }; diff --git a/server/src/services/sessionService.js b/server/src/services/sessionService.js index b695d274..7e7ff6af 100644 --- a/server/src/services/sessionService.js +++ b/server/src/services/sessionService.js @@ -9,6 +9,7 @@ const { getActiveDrivers, getTurnQueues, turnEvents } = require('./turnService') const { getRoomCameras, roomCameraEvents } = require('./roomCameraService'); const { getState: getHomeAssistantState, homeAssistantEvents } = require('./homeAssistantService'); const { getNickname, nicknameEvents } = require('./nicknameService'); +const { getReplayState, replayEvents } = require('./replayService'); const { loadConfig } = require('../helpers/configLoader'); const discordInvite = loadConfig().discord?.invite || null; @@ -45,6 +46,7 @@ function buildSession(socket) { turnQueues: getTurnQueues(), roomCameras: getRoomCameras(), homeAssistant: getHomeAssistantState(), + replay: getReplayState(), users, discord: { invite: discordInvite, @@ -156,6 +158,11 @@ homeAssistantEvents.on('status', () => { syncAll(); }); +replayEvents.on('update', () => { + logger.info('Replay cooldown updated; syncing all clients'); + syncAll(); +}); + nicknameEvents.on('change', ({ socketId }) => { const socket = socketId ? io.sockets.sockets.get(socketId) : null; if (socket) {