This commit is contained in:
legop3
2026-01-17 19:32:36 -05:00
parent 2975d394b6
commit 7041a3c6df
15 changed files with 640 additions and 163 deletions
+8
View File
@@ -13,6 +13,14 @@ media:
# http://<base>/<roverId>/whep
# Example: http://192.168.0.86:8889/video
whepBaseUrl: "http://192.168.0.86:8889/video"
preview:
enabled: false
codec: "av1"
fps: 10
width: 640
roomBitrateKbps: 200
roverBitrateKbps: 350
gopSeconds: 2
homeAssistant:
url: "http://homeassistant.local:8123"
+1
View File
@@ -30,5 +30,6 @@ require('./src/services/sessionService');
require('./src/services/batteryManager');
require('./src/services/replaySocketService');
require('./src/services/replaySegmentManager');
require('./src/services/media/previewTranscoderService');
require('./src/services/discordBotService');
require('./src/services/httpServer');
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -11,7 +11,7 @@
<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-BosVsfhJ.js"></script>
<script type="module" crossorigin src="/assets/index-CiUnVds0.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Fk2eqSbH.css">
</head>
<body>
@@ -0,0 +1,229 @@
const { spawn } = require('child_process');
const EventEmitter = require('events');
const logger = require('../../globals/logger').child('previewTranscoder');
const { loadConfig } = require('../../helpers/configLoader');
const roverManager = require('../roverManager');
const { getRoomCameras, roomCameraEvents } = require('../roomCameraService');
const events = new EventEmitter();
const config = loadConfig();
const mediaConfig = config.media || {};
const previewConfig = mediaConfig.preview || {};
const ENABLED = Boolean(previewConfig.enabled);
const PREVIEW_CODEC = String(previewConfig.codec || 'av1').toLowerCase();
const PREVIEW_FPS = Number(previewConfig.fps || 10);
const PREVIEW_WIDTH = Number(previewConfig.width || 640);
const ROOM_BITRATE_KBPS = Number(previewConfig.roomBitrateKbps || 200);
const ROVER_BITRATE_KBPS = Number(previewConfig.roverBitrateKbps || 350);
const PRESET = Number.isFinite(previewConfig.preset) ? String(previewConfig.preset) : '8';
const GOP_SECONDS = Number(previewConfig.gopSeconds || 2);
const FFMPEG_BIN = previewConfig.ffmpegBin || process.env.FFMPEG_BIN || 'ffmpeg';
const recorders = new Map(); // key -> { proc, source }
let syncTimer = null;
function encodeStreamId(streamId) {
return encodeURIComponent(streamId).replace(/%2F/g, '/');
}
function buildSrtReadUrl(streamId) {
return `srt://127.0.0.1:9000?streamid=read:${encodeStreamId(streamId)}`;
}
function buildSrtPublishUrl(streamId) {
const encoded = encodeStreamId(streamId);
return `srt://127.0.0.1:9000?streamid=#!::r=${encoded},m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316`;
}
function sanitizeCodec(codec) {
return String(codec || '').toLowerCase().replace(/[^a-z0-9]/g, '') || 'av1';
}
function buildPreviewId(id, codec) {
return `${id}-preview-${sanitizeCodec(codec)}`;
}
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) => {
const streamUrl = getRoomCameraStream(camera);
if (!streamUrl) return null;
const id = String(camera.id);
return {
type: 'room',
id,
label: camera.name || id,
inputUrl: streamUrl,
outputId: `room/${buildPreviewId(id, PREVIEW_CODEC)}`,
bitrateKbps: ROOM_BITRATE_KBPS,
};
})
.filter(Boolean);
const rovers = roverManager.getRoster().map((rover) => {
const id = String(rover.id);
return {
type: 'rover',
id,
label: rover.name || id,
inputUrl: buildSrtReadUrl(id),
outputId: buildPreviewId(id, PREVIEW_CODEC),
bitrateKbps: ROVER_BITRATE_KBPS,
};
});
return [...rooms, ...rovers];
}
function buildKey(source) {
return `${source.type}:${source.id}:${source.outputId}`;
}
function buildArgs(source) {
const gop = Math.max(1, Math.round(GOP_SECONDS * PREVIEW_FPS));
const maxrate = Math.floor(source.bitrateKbps * 1.1);
const bufsize = Math.max(1, source.bitrateKbps * 2);
return [
'-hide_banner',
'-loglevel',
'info',
'-fflags',
'nobuffer',
'-flags',
'low_delay',
'-i',
source.inputUrl,
'-an',
'-vf',
`fps=${PREVIEW_FPS},scale=${PREVIEW_WIDTH}:-1`,
'-c:v',
PREVIEW_CODEC === 'av1' ? 'libsvtav1' : 'libx264',
'-preset',
PRESET,
'-g',
String(gop),
'-keyint_min',
String(gop),
'-b:v',
`${source.bitrateKbps}k`,
'-maxrate',
`${maxrate}k`,
'-bufsize',
`${bufsize}k`,
'-pix_fmt',
'yuv420p',
'-f',
'mpegts',
buildSrtPublishUrl(source.outputId),
];
}
function spawnRecorder(source) {
const key = buildKey(source);
if (recorders.has(key)) return;
const args = buildArgs(source);
const proc = spawn(FFMPEG_BIN, args, { stdio: ['ignore', 'ignore', 'pipe'] });
const stderrChunks = [];
let stderrSize = 0;
proc.stderr.on('data', (chunk) => {
if (!chunk || stderrSize > 8192) return;
stderrChunks.push(chunk);
stderrSize += chunk.length;
});
recorders.set(key, { proc, source });
proc.on('exit', (code, signal) => {
recorders.delete(key);
const stderrBuffer = stderrChunks.length ? Buffer.concat(stderrChunks) : null;
if (stderrBuffer && stderrBuffer.length) {
const preview = stderrBuffer.toString('utf8', 0, 600).trim();
logger.warn('Preview transcoder stderr', {
key,
stderrBytes: stderrBuffer.length,
stderrPreview: preview || '<non-utf8>',
});
} else {
logger.warn('Preview transcoder stderr', { key, stderrBytes: 0 });
}
if (!ENABLED) return;
const delay = 2000;
logger.warn('Preview transcoder exited; restarting', { key, code, signal });
setTimeout(() => {
if (!recorders.has(key) && ENABLED) {
spawnRecorder(source);
}
}, delay);
});
events.emit('spawn', { key, source });
}
function stopRecorder(key) {
const entry = recorders.get(key);
if (!entry) return;
entry.proc.kill('SIGTERM');
recorders.delete(key);
events.emit('stop', { key, source: entry.source });
}
function syncRecorders() {
const sources = listSources();
const desiredKeys = new Set();
sources.forEach((source) => {
const key = buildKey(source);
desiredKeys.add(key);
if (!recorders.has(key)) {
spawnRecorder(source);
}
});
Array.from(recorders.keys()).forEach((key) => {
if (!desiredKeys.has(key)) {
stopRecorder(key);
}
});
logger.info('Preview transcoders synced', { total: desiredKeys.size });
}
function start() {
if (!ENABLED) {
logger.info('Preview transcoders disabled');
return;
}
syncRecorders();
if (!syncTimer) {
syncTimer = setInterval(syncRecorders, 10000);
}
}
function stop() {
if (syncTimer) {
clearInterval(syncTimer);
syncTimer = null;
}
Array.from(recorders.keys()).forEach((key) => stopRecorder(key));
}
roverManager.managerEvents.on('rover', () => {
if (ENABLED) {
syncRecorders();
}
});
roomCameraEvents.on('update', () => {
if (ENABLED) {
syncRecorders();
}
});
start();
module.exports = {
previewEvents: events,
syncPreviewTranscoders: syncRecorders,
stopPreviewTranscoders: stop,
};
+5 -1
View File
@@ -46,7 +46,11 @@ function extractStreamInfo(path) {
const remaining = segments.slice(start, end);
if (remaining.length === 1) {
const rawId = remaining[0] || '';
const baseId = rawId.endsWith('-audio') ? rawId.slice(0, -6) : rawId;
let baseId = rawId.endsWith('-audio') ? rawId.slice(0, -6) : rawId;
const previewMatch = baseId.match(/^(.*)-preview-[a-z0-9]+$/);
if (previewMatch) {
baseId = previewMatch[1];
}
return { type: 'rover', id: rawId, baseId };
}
if (remaining.length === 2 && remaining[0] === 'room') {
+40 -7
View File
@@ -4,6 +4,7 @@ const { getMode, MODES } = require('./modeManager');
const { isAdmin, isLockdownAdmin, getRole } = require('./roleService');
const videoSessions = require('./videoSessions');
const roverManager = require('./roverManager');
const { getRoomCamera } = require('./roomCameraService');
const { loadConfig } = require('../helpers/configLoader');
const config = loadConfig();
@@ -36,6 +37,17 @@ function buildWhepUrlForSource(source) {
return `${cleanBase}/${segments.join('/')}/whep`;
}
function sanitizeCodec(codec) {
return String(codec || '')
.toLowerCase()
.replace(/[^a-z0-9]/g, '');
}
function buildPreviewId(id, codec) {
const cleanCodec = sanitizeCodec(codec) || 'av1';
return `${id}-preview-${cleanCodec}`;
}
function passesMode(socket) {
const mode = getMode();
if (mode === MODES.LOCKDOWN) {
@@ -65,14 +77,21 @@ function canViewRoomCamera(socket) {
function normalizeRequest(payload = {}) {
if (!payload) return null;
const preview = Boolean(payload.preview || payload.mode === 'preview');
const codec = payload.codec ? String(payload.codec) : null;
if (payload.type && payload.id) {
return { type: payload.type, id: String(payload.id) };
return {
type: payload.type,
id: String(payload.id),
preview,
codec,
};
}
if (payload.roverId) {
return { type: 'rover', id: String(payload.roverId) };
return { type: 'rover', id: String(payload.roverId), preview, codec };
}
if (payload.roomCameraId) {
return { type: 'room', id: String(payload.roomCameraId) };
return { type: 'room', id: String(payload.roomCameraId), preview, codec };
}
return null;
}
@@ -93,16 +112,30 @@ io.on('connection', (socket) => {
throw new Error('Not authorized for video');
}
} else if (target.type === 'room') {
throw new Error('Room cameras now use the snapshot feed');
if (!target.preview) {
throw new Error('Room cameras now use the snapshot feed');
}
if (!getRoomCamera(target.id)) {
throw new Error('Room camera not found');
}
} else {
throw new Error('Unsupported video source');
}
const url = buildWhepUrlForSource(target);
const requestId = target.preview ? buildPreviewId(target.id, target.codec) : target.id;
const requestTarget = { ...target, id: requestId };
const url = buildWhepUrlForSource(requestTarget);
if (!url) {
throw new Error('Server video base URL missing');
}
const sessionId = videoSessions.createSession(socket, target);
cb({ url, token: sessionId, type: target.type, id: target.id });
const sessionId = videoSessions.createSession(socket, requestTarget);
cb({
url,
token: sessionId,
type: requestTarget.type,
id: requestTarget.id,
preview: Boolean(target.preview),
codec: target.codec || null,
});
} catch (err) {
logger.warn('video request failed: %s', err.message);
cb({ error: err.message });