Files
MultiRoombaRover/server/src/services/roomCameraReplayService.js
T
2026-01-04 13:14:32 -05:00

251 lines
7.8 KiB
JavaScript

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}`);
}
if (cameraEntries.length === 1) {
filterParts.push('[v0]null[v]');
} else {
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,
};