mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
lets see . . .
This commit is contained in:
@@ -28,10 +28,12 @@ roomCameras:
|
||||
name: "Lobby Camera"
|
||||
description: "Wide shot of the staging area."
|
||||
url: "http://192.168.0.50/snapshot.jpg"
|
||||
streamUrl: "http://192.168.0.50/stream.mjpg"
|
||||
- id: "workshop"
|
||||
name: "Workshop Bench"
|
||||
description: "Shows the workbench and charging docks."
|
||||
url: "http://192.168.0.51/snapshot.jpg"
|
||||
streamUrl: "http://192.168.0.51/stream.mjpg"
|
||||
|
||||
discord:
|
||||
token: "DISCORD_BOT_TOKEN"
|
||||
|
||||
@@ -28,5 +28,6 @@ require('./src/services/homeAssistantService');
|
||||
require('./src/services/sessionService');
|
||||
require('./src/services/batteryManager');
|
||||
require('./src/services/replaySocketService');
|
||||
require('./src/services/replaySegmentManager');
|
||||
require('./src/services/discordBotService');
|
||||
require('./src/services/httpServer');
|
||||
|
||||
@@ -10,6 +10,7 @@ MEDIAMTX_SNAPSHOT_SCRIPT="$MEDIAMTX_CONF_DIR/rover-snapshot.sh"
|
||||
MEDIAMTX_SERVICE="/etc/systemd/system/mediamtx.service"
|
||||
MULTIROVER_SERVICE="/etc/systemd/system/multirover.service"
|
||||
SNAPSHOT_DIR="/var/lib/rover-snapshots"
|
||||
REPLAY_SEGMENT_DIR="/var/lib/replay-segments"
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "This installer must be run with sudo/root." >&2
|
||||
@@ -86,6 +87,8 @@ chown -R "$TARGET_USER":"$TARGET_USER" "$MEDIAMTX_CONF_DIR"
|
||||
echo "[4/6] Writing systemd units..."
|
||||
mkdir -p "$SNAPSHOT_DIR"
|
||||
chown "$TARGET_USER":"$TARGET_USER" "$SNAPSHOT_DIR"
|
||||
mkdir -p "$REPLAY_SEGMENT_DIR"
|
||||
chown "$TARGET_USER":"$TARGET_USER" "$REPLAY_SEGMENT_DIR"
|
||||
cat > "$MEDIAMTX_SERVICE" <<EOF
|
||||
[Unit]
|
||||
Description=mediaMTX WebRTC Server
|
||||
@@ -118,6 +121,7 @@ WorkingDirectory=$SERVER_DIR
|
||||
Environment=NODE_ENV=production
|
||||
Environment=SERVER_CONFIG=$CONFIG_PATH
|
||||
Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_DIR
|
||||
Environment=REPLAY_SEGMENT_DIR=$REPLAY_SEGMENT_DIR
|
||||
ExecStart=$NODE_BIN $SERVER_DIR/index.js
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
|
||||
@@ -12,7 +12,7 @@ if [[ "${STREAM_ID}" == *-audio ]]; then
|
||||
fi
|
||||
|
||||
OUTPUT_DIR="${ROVER_SNAPSHOT_DIR:-/var/lib/rover-snapshots}"
|
||||
FPS="${ROVER_SNAPSHOT_FPS:-1}"
|
||||
FPS="${ROVER_SNAPSHOT_FPS:-3}"
|
||||
WIDTH="${ROVER_SNAPSHOT_WIDTH:-640}"
|
||||
QUALITY="${ROVER_SNAPSHOT_QUALITY:-8}"
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,8 +11,8 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||
<title>Multi Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-ClyuL_YR.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-COnIgwcF.css">
|
||||
<script type="module" crossorigin src="/assets/index-FaKr6Sxz.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DAAnNTU0.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -15,8 +15,8 @@ const { subscribe } = require('./eventBus');
|
||||
const { getRoster, lockRover, rovers } = require('./roverManager');
|
||||
const { MODES, getMode, setMode } = require('./modeManager');
|
||||
const { sendExternalMessage } = require('./chatService');
|
||||
const { getRoomCameras, getRoomCamera } = require('./roomCameraService');
|
||||
const { buildRoomCameraReplayVideo } = require('./roomCameraReplayService');
|
||||
const { buildReplayVideo } = require('./replayBuildService');
|
||||
const { getReplaySources, getDefaultDiscordSources, validateSources } = require('./replaySourceService');
|
||||
const { getActiveDrivers } = require('./turnService');
|
||||
const { getNickname } = require('./nicknameService');
|
||||
const { tryTriggerReplay } = require('./replayService');
|
||||
@@ -207,7 +207,7 @@ function formatHelp() {
|
||||
'**Rover Bot Commands**',
|
||||
'`rs help` — show this help',
|
||||
'`rs status [id]` — show rover status (all or one)',
|
||||
'`rs replay [camera]` — send room camera instant replay',
|
||||
'`rs replay [sources]` — send instant replay (room/rover)',
|
||||
'`rs bridge` — show chat bridge status for this server',
|
||||
'`rs bridge here <global|private>` — set chat bridge to this channel',
|
||||
'`rs bridge mode <global|private>` — change chat bridge mode',
|
||||
@@ -263,57 +263,59 @@ function buildDriverCaption() {
|
||||
return `Drivers: ${entries.join(', ')}`;
|
||||
}
|
||||
|
||||
function normalizeCameraQuery(input) {
|
||||
function normalizeReplayQuery(input) {
|
||||
return String(input || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function resolveReplayCamera(query) {
|
||||
const cleaned = normalizeCameraQuery(query);
|
||||
if (!cleaned || cleaned === 'all' || cleaned === '*') return { camera: null };
|
||||
const cameras = getRoomCameras();
|
||||
const direct = cameras.find(
|
||||
(camera) =>
|
||||
String(camera.id).toLowerCase() === cleaned ||
|
||||
String(camera.name || '').toLowerCase() === cleaned,
|
||||
);
|
||||
if (direct) return { camera: direct };
|
||||
const starts = cameras.filter(
|
||||
(camera) =>
|
||||
String(camera.id).toLowerCase().startsWith(cleaned) ||
|
||||
String(camera.name || '').toLowerCase().startsWith(cleaned),
|
||||
);
|
||||
if (starts.length === 1) return { camera: starts[0] };
|
||||
if (starts.length > 1) return { error: 'Ambiguous camera name', matches: starts };
|
||||
const includes = cameras.filter(
|
||||
(camera) =>
|
||||
String(camera.id).toLowerCase().includes(cleaned) ||
|
||||
String(camera.name || '').toLowerCase().includes(cleaned),
|
||||
);
|
||||
if (includes.length === 1) return { camera: includes[0] };
|
||||
if (includes.length > 1) return { error: 'Ambiguous camera name', matches: includes };
|
||||
return { error: 'Camera not found', matches: [] };
|
||||
function resolveReplaySources(query) {
|
||||
const cleaned = normalizeReplayQuery(query);
|
||||
if (!cleaned || cleaned === 'all' || cleaned === '*') {
|
||||
return { sources: getDefaultDiscordSources() };
|
||||
}
|
||||
const tokens = cleaned.split(',').map((token) => token.trim()).filter(Boolean);
|
||||
const all = getReplaySources();
|
||||
const matches = [];
|
||||
tokens.forEach((token) => {
|
||||
const [prefix, rest] = token.includes(':') ? token.split(':', 2) : [null, token];
|
||||
const candidates = all.filter((entry) => {
|
||||
const matchId = String(entry.id).toLowerCase() === rest;
|
||||
const matchLabel = String(entry.label || '').toLowerCase() === rest;
|
||||
if (!matchId && !matchLabel) return false;
|
||||
if (!prefix) return true;
|
||||
return entry.type === prefix;
|
||||
});
|
||||
if (candidates.length === 1) {
|
||||
matches.push({ type: candidates[0].type, id: candidates[0].id, label: candidates[0].label });
|
||||
}
|
||||
});
|
||||
const sources = validateSources(matches);
|
||||
if (!sources.length) {
|
||||
return { error: 'No matching sources found', matches: [] };
|
||||
}
|
||||
return { sources };
|
||||
}
|
||||
|
||||
function buildReplayCaption(requester, camera) {
|
||||
function buildReplayCaption(requester, sources = []) {
|
||||
const requesterLabel = requester || 'unknown';
|
||||
const cameraLabel = camera ? `Camera: ${camera.name || camera.id}.` : null;
|
||||
const sourceLabel = sources.length
|
||||
? `Sources: ${sources.map((source) => source.label || `${source.type}:${source.id}`).join(', ')}.`
|
||||
: 'No sources.';
|
||||
return [
|
||||
`Replay requested by ${requesterLabel}.`,
|
||||
cameraLabel,
|
||||
sourceLabel,
|
||||
buildDriverCaption(),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
async function sendReplayToChannel(channelId, requester, cameraId = null) {
|
||||
async function sendReplayToChannel(channelId, requester, sources = []) {
|
||||
if (!channelId) {
|
||||
throw new Error('Replay channel not configured');
|
||||
}
|
||||
const buffer = await buildRoomCameraReplayVideo({ cameraId });
|
||||
const buffer = await buildReplayVideo({ sources });
|
||||
const attachment = new AttachmentBuilder(buffer, { name: 'replay.mp4' });
|
||||
const camera = cameraId ? getRoomCamera(cameraId) : null;
|
||||
const caption = buildReplayCaption(requester, camera);
|
||||
const caption = buildReplayCaption(requester, sources);
|
||||
await sendToChannel(channelId, caption, { files: [attachment] }, { parse: [] });
|
||||
}
|
||||
|
||||
@@ -337,25 +339,21 @@ async function handleReplayCommand(message, query) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
const resolved = resolveReplayCamera(query);
|
||||
const resolved = resolveReplaySources(query);
|
||||
if (resolved?.error) {
|
||||
const matches = resolved.matches || [];
|
||||
const list = matches.length
|
||||
? `Matches: ${matches.map((cam) => cam.name || cam.id).join(', ')}`
|
||||
: 'No matching cameras found.';
|
||||
await message.reply({
|
||||
content: sanitizeMentions(`${resolved.error}. ${list}`),
|
||||
content: sanitizeMentions(resolved.error),
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const cameraId = resolved.camera?.id || null;
|
||||
const sources = resolved.sources || [];
|
||||
const requester =
|
||||
message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord';
|
||||
try {
|
||||
const buffer = await buildRoomCameraReplayVideo({ cameraId });
|
||||
const buffer = await buildReplayVideo({ sources });
|
||||
const attachment = new AttachmentBuilder(buffer, { name: 'replay.mp4' });
|
||||
const caption = buildReplayCaption(requester, resolved.camera || null);
|
||||
const caption = buildReplayCaption(requester, sources);
|
||||
await message.reply({
|
||||
content: sanitizeMentions(caption),
|
||||
files: [attachment],
|
||||
@@ -1009,7 +1007,7 @@ function handleBusEvent(event) {
|
||||
updatePresence();
|
||||
break;
|
||||
case 'replay.requested':
|
||||
sendReplayToChannel(payload?.channelId, payload?.requester, payload?.cameraId || null).catch((err) => {
|
||||
sendReplayToChannel(payload?.channelId, payload?.requester, payload?.sources || []).catch((err) => {
|
||||
logger.warn('Replay send failed', err.message);
|
||||
});
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
const { execFile } = require('child_process');
|
||||
const fsp = require('fs/promises');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { promisify } = require('util');
|
||||
const logger = require('../globals/logger').child('replayBuild');
|
||||
const { replaySegmentsDir, segmentSeconds } = require('./replaySegmentManager');
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const REPLAY_DURATION_MS = 20000;
|
||||
const REPLAY_FPS = 15;
|
||||
const MAX_REPLAY_WIDTH = 1280;
|
||||
const MAX_REPLAY_HEIGHT = 720;
|
||||
const REPLAY_MAX_BYTES = Math.floor(9.5 * 1024 * 1024);
|
||||
|
||||
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 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`;
|
||||
}
|
||||
|
||||
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 [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 clip size', err.message);
|
||||
}
|
||||
}
|
||||
return { maxWidth, maxHeight };
|
||||
}
|
||||
|
||||
async function listLatestSegments(sourceKey, neededCount) {
|
||||
const dir = path.join(replaySegmentsDir, sourceKey);
|
||||
const entries = await fsp.readdir(dir, { withFileTypes: true });
|
||||
const files = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.mp4')) continue;
|
||||
const filePath = path.join(dir, entry.name);
|
||||
const stat = await fsp.stat(filePath);
|
||||
files.push({ filePath, mtimeMs: stat.mtimeMs });
|
||||
}
|
||||
files.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
||||
return files.slice(-neededCount).map((file) => file.filePath);
|
||||
}
|
||||
|
||||
async function buildReplayVideo({ sources = [] } = {}) {
|
||||
if (!sources.length) {
|
||||
throw new Error('No replay sources selected');
|
||||
}
|
||||
const segmentCount = Math.max(1, Math.ceil(REPLAY_DURATION_MS / (segmentSeconds * 1000)));
|
||||
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'rover-replay-'));
|
||||
try {
|
||||
const clipPaths = [];
|
||||
for (let i = 0; i < sources.length; i += 1) {
|
||||
const source = sources[i];
|
||||
const key = `${source.type}__${source.id}`;
|
||||
let segmentPaths;
|
||||
try {
|
||||
segmentPaths = await listLatestSegments(key, segmentCount);
|
||||
} catch {
|
||||
throw new Error(`Missing replay segments for ${source.type}:${source.id}`);
|
||||
}
|
||||
if (segmentPaths.length < segmentCount) {
|
||||
throw new Error(`Not enough replay segments for ${source.type}:${source.id}`);
|
||||
}
|
||||
const concatPath = path.join(tmpDir, `concat-${i}.txt`);
|
||||
const concatBody = segmentPaths.map((file) => `file '${file}'`).join('\n');
|
||||
await fsp.writeFile(concatPath, concatBody);
|
||||
const clipPath = path.join(tmpDir, `clip-${i}.mp4`);
|
||||
await execFileAsync('ffmpeg', [
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'error',
|
||||
'-f',
|
||||
'concat',
|
||||
'-safe',
|
||||
'0',
|
||||
'-i',
|
||||
concatPath,
|
||||
'-c',
|
||||
'copy',
|
||||
clipPath,
|
||||
]);
|
||||
clipPaths.push(clipPath);
|
||||
}
|
||||
|
||||
const { maxWidth, maxHeight } = await probeMaxFrameSize(clipPaths);
|
||||
const layout = buildGridLayout(clipPaths.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 *= scale;
|
||||
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 inputArgs = [];
|
||||
const filterParts = [];
|
||||
const layoutParts = [];
|
||||
for (let i = 0; i < clipPaths.length; i += 1) {
|
||||
inputArgs.push('-i', clipPaths[i]);
|
||||
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 (clipPaths.length === 1) {
|
||||
filterParts.push('[v0]null[v]');
|
||||
} else {
|
||||
filterParts.push(
|
||||
`${clipPaths.map((_, i) => `[v${i}]`).join('')}` +
|
||||
`xstack=inputs=${clipPaths.length}:layout=${layoutParts.join('|')}:fill=black[v]`,
|
||||
);
|
||||
}
|
||||
|
||||
const durationSec = Math.max(1, REPLAY_DURATION_MS / 1000);
|
||||
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 outPath = path.join(tmpDir, 'replay.mp4');
|
||||
await execFileAsync('ffmpeg', [
|
||||
'-y',
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'error',
|
||||
...inputArgs,
|
||||
'-filter_complex',
|
||||
filterParts.join(';'),
|
||||
'-map',
|
||||
'[v]',
|
||||
'-r',
|
||||
String(REPLAY_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 = {
|
||||
buildReplayVideo,
|
||||
};
|
||||
@@ -0,0 +1,231 @@
|
||||
const { spawn } = require('child_process');
|
||||
const fsp = require('fs/promises');
|
||||
const path = require('path');
|
||||
const logger = require('../globals/logger').child('replaySegments');
|
||||
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
|
||||
const roverManager = require('./roverManager');
|
||||
|
||||
const SEGMENT_DIR = process.env.REPLAY_SEGMENT_DIR || '/var/lib/replay-segments';
|
||||
const SEGMENT_SECONDS = 2;
|
||||
const BUFFER_SECONDS = 40;
|
||||
const CLEANUP_INTERVAL_MS = 20000;
|
||||
const FPS = 15;
|
||||
const SCALE_WIDTH = 640;
|
||||
const MAX_BYTES = Number.parseInt(process.env.REPLAY_SEGMENT_MAX_BYTES || '0', 10);
|
||||
const FFMPEG_BIN = process.env.FFMPEG_BIN || 'ffmpeg';
|
||||
|
||||
const recorders = new Map(); // key -> { proc, source }
|
||||
let cleanupTimer = null;
|
||||
|
||||
function sourceKey(source) {
|
||||
return `${source.type}__${source.id}`;
|
||||
}
|
||||
|
||||
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 listSources() {
|
||||
const rooms = getRoomCameras().map((camera) => ({
|
||||
type: 'room',
|
||||
id: String(camera.id),
|
||||
label: camera.name || camera.id,
|
||||
streamUrl: getRoomCameraStream(camera),
|
||||
}));
|
||||
const rovers = roverManager.getRoster().map((rover) => ({
|
||||
type: 'rover',
|
||||
id: String(rover.id),
|
||||
label: rover.name || rover.id,
|
||||
}));
|
||||
return [...rooms, ...rovers];
|
||||
}
|
||||
|
||||
function buildInputUrl(source) {
|
||||
if (source.type === 'room') {
|
||||
return source.streamUrl || null;
|
||||
}
|
||||
return `srt://127.0.0.1:9000?streamid=read:${encodeURIComponent(source.id)}`;
|
||||
}
|
||||
|
||||
async function ensureDir(dir) {
|
||||
await fsp.mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
function buildOutputPattern(key) {
|
||||
return path.join(SEGMENT_DIR, key, '%Y%m%d-%H%M%S.mp4');
|
||||
}
|
||||
|
||||
function spawnRecorder(source) {
|
||||
const key = sourceKey(source);
|
||||
const inputUrl = buildInputUrl(source);
|
||||
if (!inputUrl) {
|
||||
logger.warn('Replay recorder missing input URL', { key, source });
|
||||
return;
|
||||
}
|
||||
const outPattern = buildOutputPattern(key);
|
||||
ensureDir(path.dirname(outPattern))
|
||||
.then(() => {
|
||||
const args = [
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'warning',
|
||||
'-fflags',
|
||||
'nobuffer',
|
||||
'-flags',
|
||||
'low_delay',
|
||||
'-i',
|
||||
inputUrl,
|
||||
'-r',
|
||||
String(FPS),
|
||||
'-vf',
|
||||
`fps=${FPS},scale=${SCALE_WIDTH}:-1`,
|
||||
'-an',
|
||||
'-c:v',
|
||||
'libx264',
|
||||
'-preset',
|
||||
'veryfast',
|
||||
'-tune',
|
||||
'zerolatency',
|
||||
'-g',
|
||||
String(FPS),
|
||||
'-keyint_min',
|
||||
String(FPS),
|
||||
'-sc_threshold',
|
||||
'0',
|
||||
'-f',
|
||||
'segment',
|
||||
'-segment_time',
|
||||
String(SEGMENT_SECONDS),
|
||||
'-segment_format',
|
||||
'mp4',
|
||||
'-reset_timestamps',
|
||||
'1',
|
||||
'-strftime',
|
||||
'1',
|
||||
outPattern,
|
||||
];
|
||||
const proc = spawn(FFMPEG_BIN, args, { stdio: 'ignore' });
|
||||
recorders.set(key, { proc, source });
|
||||
proc.on('exit', (code, signal) => {
|
||||
recorders.delete(key);
|
||||
if (!shouldRecord(source)) {
|
||||
return;
|
||||
}
|
||||
const delay = 2000;
|
||||
logger.warn('Replay recorder exited; restarting', { key, code, signal });
|
||||
setTimeout(() => {
|
||||
if (!recorders.has(key) && shouldRecord(source)) {
|
||||
spawnRecorder(source);
|
||||
}
|
||||
}, delay);
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.warn('Replay recorder setup failed', { key, err: err.message });
|
||||
});
|
||||
}
|
||||
|
||||
function stopRecorder(key) {
|
||||
const entry = recorders.get(key);
|
||||
if (!entry) return;
|
||||
entry.proc.kill('SIGTERM');
|
||||
recorders.delete(key);
|
||||
}
|
||||
|
||||
function shouldRecord(source) {
|
||||
if (source.type === 'room') {
|
||||
return Boolean(source.streamUrl);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function syncRecorders() {
|
||||
const sources = listSources();
|
||||
const desiredKeys = new Set();
|
||||
sources.forEach((source) => {
|
||||
if (!shouldRecord(source)) return;
|
||||
const key = sourceKey(source);
|
||||
desiredKeys.add(key);
|
||||
if (!recorders.has(key)) {
|
||||
spawnRecorder(source);
|
||||
}
|
||||
});
|
||||
Array.from(recorders.keys()).forEach((key) => {
|
||||
if (!desiredKeys.has(key)) {
|
||||
stopRecorder(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanupSegments() {
|
||||
try {
|
||||
await ensureDir(SEGMENT_DIR);
|
||||
const cutoff = Date.now() - BUFFER_SECONDS * 1000;
|
||||
const entries = await fsp.readdir(SEGMENT_DIR, { withFileTypes: true });
|
||||
let totalBytes = 0;
|
||||
const files = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const dirPath = path.join(SEGMENT_DIR, entry.name);
|
||||
const inner = await fsp.readdir(dirPath, { withFileTypes: true });
|
||||
for (const file of inner) {
|
||||
if (!file.isFile() || !file.name.endsWith('.mp4')) continue;
|
||||
const filePath = path.join(dirPath, file.name);
|
||||
const stats = await fsp.stat(filePath);
|
||||
totalBytes += stats.size;
|
||||
files.push({ filePath, mtimeMs: stats.mtimeMs, size: stats.size });
|
||||
if (stats.mtimeMs < cutoff) {
|
||||
await fsp.unlink(filePath);
|
||||
}
|
||||
}
|
||||
const remaining = await fsp.readdir(dirPath);
|
||||
if (!remaining.length) {
|
||||
await fsp.rmdir(dirPath);
|
||||
}
|
||||
}
|
||||
if (MAX_BYTES > 0 && totalBytes > MAX_BYTES) {
|
||||
const overBy = totalBytes - MAX_BYTES;
|
||||
let freed = 0;
|
||||
files.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
||||
for (const file of files) {
|
||||
if (freed >= overBy) break;
|
||||
try {
|
||||
await fsp.unlink(file.filePath);
|
||||
freed += file.size;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Replay cleanup failed', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
syncRecorders();
|
||||
cleanupSegments();
|
||||
if (cleanupTimer) clearInterval(cleanupTimer);
|
||||
cleanupTimer = setInterval(cleanupSegments, CLEANUP_INTERVAL_MS);
|
||||
}
|
||||
|
||||
roomCameraEvents.on('update', () => {
|
||||
syncRecorders();
|
||||
});
|
||||
|
||||
roverManager.managerEvents.on('rover', () => {
|
||||
syncRecorders();
|
||||
});
|
||||
|
||||
start();
|
||||
|
||||
module.exports = {
|
||||
replaySegmentsDir: SEGMENT_DIR,
|
||||
segmentSeconds: SEGMENT_SECONDS,
|
||||
bufferSeconds: BUFFER_SECONDS,
|
||||
};
|
||||
@@ -3,9 +3,10 @@ const logger = require('../globals/logger').child('replaySocket');
|
||||
const { getMode, MODES } = require('./modeManager');
|
||||
const { publishEvent } = require('./eventBus');
|
||||
const { tryTriggerReplay } = require('./replayService');
|
||||
const { validateSources, getDefaultWebSources } = require('./replaySourceService');
|
||||
const assignmentService = require('./assignmentService');
|
||||
const { getNickname } = require('./nicknameService');
|
||||
const { loadConfig } = require('../helpers/configLoader');
|
||||
const { getRoomCamera } = require('./roomCameraService');
|
||||
|
||||
const config = loadConfig();
|
||||
const discordConfig = config.discord || {};
|
||||
@@ -25,9 +26,14 @@ io.on('connection', (socket) => {
|
||||
cb({ error: 'Replay channel not configured', state: null });
|
||||
return;
|
||||
}
|
||||
const requestedCameraId = payload?.cameraId ? String(payload.cameraId) : null;
|
||||
if (requestedCameraId && !getRoomCamera(requestedCameraId)) {
|
||||
cb({ error: 'Unknown camera', state: null });
|
||||
const requestedSources = Array.isArray(payload?.sources) ? payload.sources : null;
|
||||
let sources = requestedSources ? validateSources(requestedSources) : [];
|
||||
if (!sources.length) {
|
||||
const assignment = assignmentService.describeAssignment(socket.id);
|
||||
sources = getDefaultWebSources(assignment);
|
||||
}
|
||||
if (!sources.length) {
|
||||
cb({ error: 'No replay sources selected', state: null });
|
||||
return;
|
||||
}
|
||||
const requester = buildRequesterLabel(socket);
|
||||
@@ -42,7 +48,7 @@ io.on('connection', (socket) => {
|
||||
payload: {
|
||||
channelId,
|
||||
requester,
|
||||
cameraId: requestedCameraId,
|
||||
sources,
|
||||
requestedBy: { socketId: socket.id },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
const roverManager = require('./roverManager');
|
||||
const { getRoomCameras } = require('./roomCameraService');
|
||||
|
||||
function getReplaySources() {
|
||||
const roverSources = roverManager.getRoster().map((rover) => ({
|
||||
type: 'rover',
|
||||
id: String(rover.id),
|
||||
label: rover.name || rover.id,
|
||||
}));
|
||||
const roomSources = getRoomCameras().map((camera) => ({
|
||||
type: 'room',
|
||||
id: String(camera.id),
|
||||
label: camera.name || camera.id,
|
||||
}));
|
||||
return [...roverSources, ...roomSources];
|
||||
}
|
||||
|
||||
function normalizeSource(entry) {
|
||||
if (!entry) return null;
|
||||
if (typeof entry === 'string') {
|
||||
const [type, id] = entry.split(':');
|
||||
if (!type || !id) return null;
|
||||
return { type, id: String(id) };
|
||||
}
|
||||
if (typeof entry === 'object') {
|
||||
if (entry.type && entry.id) {
|
||||
return { type: entry.type, id: String(entry.id) };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateSources(list = []) {
|
||||
const allowed = new Map();
|
||||
getReplaySources().forEach((source) => {
|
||||
allowed.set(`${source.type}:${source.id}`, source);
|
||||
});
|
||||
const unique = new Map();
|
||||
(Array.isArray(list) ? list : []).forEach((entry) => {
|
||||
const normalized = normalizeSource(entry);
|
||||
if (!normalized) return;
|
||||
const key = `${normalized.type}:${normalized.id}`;
|
||||
const source = allowed.get(key);
|
||||
if (!source) return;
|
||||
unique.set(key, { type: source.type, id: source.id, label: source.label });
|
||||
});
|
||||
return Array.from(unique.values());
|
||||
}
|
||||
|
||||
function getDefaultWebSources(assignment = {}) {
|
||||
if (assignment?.roverId) {
|
||||
const id = String(assignment.roverId);
|
||||
const match = getReplaySources().find((entry) => entry.type === 'rover' && entry.id === id);
|
||||
return [{ type: 'rover', id, label: match?.label || id }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function getDefaultDiscordSources() {
|
||||
return getRoomCameras().map((camera) => ({
|
||||
type: 'room',
|
||||
id: String(camera.id),
|
||||
label: camera.name || camera.id,
|
||||
}));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getReplaySources,
|
||||
validateSources,
|
||||
getDefaultWebSources,
|
||||
getDefaultDiscordSources,
|
||||
};
|
||||
@@ -23,6 +23,7 @@ function normalizeCamera(camera) {
|
||||
name: camera.name || camera.id || String(id),
|
||||
description: camera.description || null,
|
||||
url: camera.url,
|
||||
streamUrl: camera.streamUrl || camera.mjpegUrl || null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
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 FETCH_TIMEOUT_MS = 2000;
|
||||
@@ -36,7 +32,6 @@ async function fetchSnapshot(camera) {
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
const ts = Date.now();
|
||||
markState(id, { frame: buffer, ts, error: null, failures: 0 });
|
||||
recordRoomCameraFrame(id, buffer, ts);
|
||||
events.emit('frame', { id, buffer, ts });
|
||||
} catch (err) {
|
||||
const failures = (state?.failures || 0) + 1;
|
||||
@@ -55,7 +50,6 @@ function stopAll() {
|
||||
pollTimer = null;
|
||||
}
|
||||
cameraState.clear();
|
||||
clearRoomCameraReplayFrames();
|
||||
}
|
||||
|
||||
function startAll() {
|
||||
|
||||
@@ -5,7 +5,7 @@ const logger = require('../globals/logger').child('roverSnapshot');
|
||||
const roverManager = require('./roverManager');
|
||||
|
||||
const SNAPSHOT_DIR = process.env.ROVER_SNAPSHOT_DIR || '/var/lib/rover-snapshots';
|
||||
const POLL_INTERVAL_MS = 500;
|
||||
const POLL_INTERVAL_MS = 300;
|
||||
|
||||
const roverState = new Map(); // id -> { frame, ts, error, failures, fetching, mtimeMs }
|
||||
const events = new EventEmitter(); // frame, status
|
||||
|
||||
@@ -7,7 +7,7 @@ const { roverSnapshotEvents, getRoverSnapshotState } = require('./roverSnapshotS
|
||||
|
||||
const SUBSCRIBE_LIMIT = 50;
|
||||
const SUBSCRIBE_WINDOW_MS = 10000;
|
||||
const STREAM_INTERVAL_MS = 800;
|
||||
const STREAM_INTERVAL_MS = 333;
|
||||
|
||||
function passesMode(socket) {
|
||||
const mode = getMode();
|
||||
|
||||
@@ -10,6 +10,7 @@ const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
|
||||
const { getState: getHomeAssistantState, homeAssistantEvents } = require('./homeAssistantService');
|
||||
const { getNickname, nicknameEvents } = require('./nicknameService');
|
||||
const { getReplayState, replayEvents } = require('./replayService');
|
||||
const { getReplaySources } = require('./replaySourceService');
|
||||
const { loadConfig } = require('../helpers/configLoader');
|
||||
|
||||
const discordInvite = loadConfig().discord?.invite || null;
|
||||
@@ -49,6 +50,7 @@ function buildSession(socket) {
|
||||
roomCameras: getRoomCameras(),
|
||||
homeAssistant: getHomeAssistantState(),
|
||||
replay: getReplayState(),
|
||||
replaySources: getReplaySources(),
|
||||
users,
|
||||
discord: {
|
||||
invite: discordInvite,
|
||||
|
||||
Reference in New Issue
Block a user