mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
replay overhaul
This commit is contained in:
@@ -6,11 +6,6 @@ const {
|
|||||||
EmbedBuilder,
|
EmbedBuilder,
|
||||||
AttachmentBuilder,
|
AttachmentBuilder,
|
||||||
} = require('discord.js');
|
} = 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 logger = require('../globals/logger').child('discordBot');
|
||||||
const io = require('../globals/io');
|
const io = require('../globals/io');
|
||||||
const { loadConfig } = require('../helpers/configLoader');
|
const { loadConfig } = require('../helpers/configLoader');
|
||||||
@@ -19,11 +14,7 @@ const { getRoster, lockRover, rovers } = require('./roverManager');
|
|||||||
const { MODES, getMode, setMode } = require('./modeManager');
|
const { MODES, getMode, setMode } = require('./modeManager');
|
||||||
const { sendExternalMessage } = require('./chatService');
|
const { sendExternalMessage } = require('./chatService');
|
||||||
const { getRoomCameras, getRoomCamera } = require('./roomCameraService');
|
const { getRoomCameras, getRoomCamera } = require('./roomCameraService');
|
||||||
const {
|
const { buildRoomCameraReplayVideo } = require('./roomCameraReplayService');
|
||||||
getRoomCameraFrames,
|
|
||||||
getRoomCameraReplayDelayMs,
|
|
||||||
getRoomCameraReplayFrameCount,
|
|
||||||
} = require('./roomCameraSnapshotService');
|
|
||||||
const { getActiveDrivers } = require('./turnService');
|
const { getActiveDrivers } = require('./turnService');
|
||||||
const { getNickname } = require('./nicknameService');
|
const { getNickname } = require('./nicknameService');
|
||||||
const { tryTriggerReplay } = require('./replayService');
|
const { tryTriggerReplay } = require('./replayService');
|
||||||
@@ -53,8 +44,6 @@ const client = new Client({
|
|||||||
|
|
||||||
const channelCache = new Map();
|
const channelCache = new Map();
|
||||||
let skippedFirstModeAnnouncement = false;
|
let skippedFirstModeAnnouncement = false;
|
||||||
const execFileAsync = promisify(execFile);
|
|
||||||
|
|
||||||
function sanitizeMentions(text) {
|
function sanitizeMentions(text) {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
return String(text)
|
return String(text)
|
||||||
@@ -281,111 +270,6 @@ function resolveReplayCamera(query) {
|
|||||||
return { error: 'Camera not found', matches: [] };
|
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) {
|
function buildReplayCaption(requester, camera) {
|
||||||
const requesterLabel = requester || 'unknown';
|
const requesterLabel = requester || 'unknown';
|
||||||
const cameraLabel = camera ? `Camera: ${camera.name || camera.id}.` : null;
|
const cameraLabel = camera ? `Camera: ${camera.name || camera.id}.` : null;
|
||||||
@@ -402,7 +286,7 @@ async function sendReplayToChannel(channelId, requester, cameraId = null) {
|
|||||||
if (!channelId) {
|
if (!channelId) {
|
||||||
throw new Error('Replay channel not configured');
|
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 attachment = new AttachmentBuilder(buffer, { name: 'replay.mp4' });
|
||||||
const camera = cameraId ? getRoomCamera(cameraId) : null;
|
const camera = cameraId ? getRoomCamera(cameraId) : null;
|
||||||
const caption = buildReplayCaption(requester, camera);
|
const caption = buildReplayCaption(requester, camera);
|
||||||
@@ -445,7 +329,7 @@ async function handleReplayCommand(message, query) {
|
|||||||
const requester =
|
const requester =
|
||||||
message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord';
|
message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord';
|
||||||
try {
|
try {
|
||||||
const buffer = await buildReplayVideo({ cameraId });
|
const buffer = await buildRoomCameraReplayVideo({ cameraId });
|
||||||
const attachment = new AttachmentBuilder(buffer, { name: 'replay.mp4' });
|
const attachment = new AttachmentBuilder(buffer, { name: 'replay.mp4' });
|
||||||
const caption = buildReplayCaption(requester, resolved.camera || null);
|
const caption = buildReplayCaption(requester, resolved.camera || null);
|
||||||
await message.reply({
|
await message.reply({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
const EventEmitter = require('events');
|
const EventEmitter = require('events');
|
||||||
const logger = require('../globals/logger').child('roomCameraSnapshot');
|
const logger = require('../globals/logger').child('roomCameraSnapshot');
|
||||||
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
|
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
|
||||||
|
const {
|
||||||
|
recordRoomCameraFrame,
|
||||||
|
clearRoomCameraReplayFrames,
|
||||||
|
} = require('./roomCameraReplayService');
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 67;
|
const POLL_INTERVAL_MS = 67;
|
||||||
const REPLAY_FRAME_COUNT = 225;
|
|
||||||
const FETCH_TIMEOUT_MS = 2000;
|
const FETCH_TIMEOUT_MS = 2000;
|
||||||
|
|
||||||
const cameraState = new Map(); // id -> {frame, ts, error, failures, fetching}
|
const cameraState = new Map(); // id -> {frame, ts, error, failures, fetching}
|
||||||
const frameHistory = new Map(); // id -> [{buffer, ts}]
|
|
||||||
const events = new EventEmitter(); // frame, status
|
const events = new EventEmitter(); // frame, status
|
||||||
let pollTimer = null;
|
let pollTimer = null;
|
||||||
|
|
||||||
@@ -18,15 +20,6 @@ function markState(id, updates = {}) {
|
|||||||
return next;
|
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) {
|
async function fetchSnapshot(camera) {
|
||||||
const { id, url } = camera;
|
const { id, url } = camera;
|
||||||
const state = cameraState.get(id);
|
const state = cameraState.get(id);
|
||||||
@@ -43,7 +36,7 @@ async function fetchSnapshot(camera) {
|
|||||||
const buffer = Buffer.from(arrayBuffer);
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
const ts = Date.now();
|
const ts = Date.now();
|
||||||
markState(id, { frame: buffer, ts, error: null, failures: 0 });
|
markState(id, { frame: buffer, ts, error: null, failures: 0 });
|
||||||
recordFrame(id, buffer, ts);
|
recordRoomCameraFrame(id, buffer, ts);
|
||||||
events.emit('frame', { id, buffer, ts });
|
events.emit('frame', { id, buffer, ts });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const failures = (state?.failures || 0) + 1;
|
const failures = (state?.failures || 0) + 1;
|
||||||
@@ -62,7 +55,7 @@ function stopAll() {
|
|||||||
pollTimer = null;
|
pollTimer = null;
|
||||||
}
|
}
|
||||||
cameraState.clear();
|
cameraState.clear();
|
||||||
frameHistory.clear();
|
clearRoomCameraReplayFrames();
|
||||||
}
|
}
|
||||||
|
|
||||||
function startAll() {
|
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', () => {
|
roomCameraEvents.on('update', () => {
|
||||||
logger.info('Room cameras changed; restarting snapshot pollers');
|
logger.info('Room cameras changed; restarting snapshot pollers');
|
||||||
startAll();
|
startAll();
|
||||||
@@ -108,7 +87,4 @@ startAll();
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
roomCameraStreamEvents: events,
|
roomCameraStreamEvents: events,
|
||||||
getRoomCameraState: getState,
|
getRoomCameraState: getState,
|
||||||
getRoomCameraFrames: getReplayFrames,
|
|
||||||
getRoomCameraReplayDelayMs: getReplayFrameDelayMs,
|
|
||||||
getRoomCameraReplayFrameCount: getReplayFrameCount,
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user