ptzreplay

This commit is contained in:
legop3
2026-07-11 12:01:21 -04:00
parent 775dd7b830
commit 10a586e5d0
12 changed files with 227 additions and 182 deletions
+1
View File
@@ -143,3 +143,4 @@ Input/control implementation:
- client-side input interception is only for UX; server-side operator checks are the real authority - client-side input interception is only for UX; server-side operator checks are the real authority
- all movement controls should send stop commands on key/button release, blur, disconnect, controller close, or turn loss - all movement controls should send stop commands on key/button release, blur, disconnect, controller close, or turn loss
## NEW UI STUFF
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
+1 -1
View File
@@ -78,7 +78,7 @@
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script> <script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script> <script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
<title>Roomba Rover</title> <title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-DC_FLGBQ.js"></script> <script type="module" crossorigin src="/assets/index-Ba_nEcwe.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DwpUkSbG.css"> <link rel="stylesheet" crossorigin href="/assets/index-DwpUkSbG.css">
</head> </head>
<body> <body>
+32 -15
View File
@@ -25,7 +25,9 @@ const DEFAULT_ONVIF_PORT = 8000;
const DEFAULT_PROFILE_TOKEN = '003'; const DEFAULT_PROFILE_TOKEN = '003';
const DEFAULT_TURN_DURATION_MS = 5 * 60 * 1000; const DEFAULT_TURN_DURATION_MS = 5 * 60 * 1000;
const DOCK_GRACE_MS = 60 * 1000; const DOCK_GRACE_MS = 60 * 1000;
const DEFAULT_REPLAY_ENABLED = false; // PTZ is a normal replay source now, so capture should be on unless the feature
// explicitly disables replay for the camera.
const DEFAULT_REPLAY_ENABLED = true;
const SNAPSHOT_DIR = process.env.ROVER_SNAPSHOT_DIR || '/var/lib/rover-snapshots'; const SNAPSHOT_DIR = process.env.ROVER_SNAPSHOT_DIR || '/var/lib/rover-snapshots';
const SNAPSHOT_POLL_MS = 300; const SNAPSHOT_POLL_MS = 300;
const SNAPSHOT_STREAM_INTERVAL_MS = 2000; const SNAPSHOT_STREAM_INTERVAL_MS = 2000;
@@ -899,21 +901,36 @@ module.exports = {
getReplaySource: () => enabled && isReplayEnabled() getReplaySource: () => enabled && isReplayEnabled()
? { type: 'ptz', id: PTZ_CAMERA_ID, label: cameraConfig.name || 'PTZ Camera' } ? { type: 'ptz', id: PTZ_CAMERA_ID, label: cameraConfig.name || 'PTZ Camera' }
: null, : null,
getReplayWorkerSource: () => { getReplayWorkerSources: () => {
/* /*
PTZ replay capture is optional because the server ffmpeg build must be PTZ replay uses two internal workers from the same MediaMTX path. The
able to produce browser/Discord-friendly replay segments. The camera live selectable replay source stays "ptz:ptz-camera", while the segment engine
feed can remain raw for MediaMTX while replay capture is left off until records video and audio separately so replayBuilder can mix PTZ microphone
the actual server has a working encoder or a copy-only PTZ replay path is audio the same way it already mixes rover audio.
intentionally designed.
*/ */
if (!enabled || !isReplayEnabled()) return null; if (!enabled || !isReplayEnabled()) return [];
return { const inputUrl = `srt://127.0.0.1:9000?streamid=read:${encodeURIComponent(PTZ_STREAM_PATH)}`;
id: PTZ_CAMERA_ID, const label = cameraConfig.name || 'PTZ Camera';
sourceType: 'ptz', return [
kind: 'video', {
label: cameraConfig.name || 'PTZ Camera', id: PTZ_CAMERA_ID,
inputUrl: `srt://127.0.0.1:9000?streamid=read:${encodeURIComponent(PTZ_STREAM_PATH)}`, sourceType: 'ptz',
}; kind: 'video',
label,
inputUrl,
},
{
id: `${PTZ_CAMERA_ID}-audio`,
sourceType: 'ptz',
sourceId: PTZ_CAMERA_ID,
kind: 'audio',
label: `${label} audio`,
inputUrl,
},
];
},
getReplayWorkerSource: () => {
const [videoSource] = module.exports.getReplayWorkerSources();
return videoSource || null;
}, },
}; };
+1 -1
View File
@@ -49,7 +49,7 @@ const replayBuilder = createReplayBuilder({
ensureDir, ensureDir,
renderSidebarVideo: sidebarRenderer.renderSidebarVideo, renderSidebarVideo: sidebarRenderer.renderSidebarVideo,
getVideoEntriesForSource: segmentStore.getVideoEntriesForSource, getVideoEntriesForSource: segmentStore.getVideoEntriesForSource,
getAudioEntriesForRover: segmentStore.getAudioEntriesForRover, getAudioEntriesForSource: segmentStore.getAudioEntriesForSource,
overlapping: segmentStore.overlapping, overlapping: segmentStore.overlapping,
}); });
registerReplaySocketHooks({ tryTriggerReplay, validateSources, getDefaultWebSources }); registerReplaySocketHooks({ tryTriggerReplay, validateSources, getDefaultWebSources });
@@ -51,7 +51,7 @@ function buildChatEventsForWindow(startMs, endMs, limit = 22, preWindowCount = 1
return [...beforeWindow, ...inWindow].sort((a, b) => a.ts - b.ts); return [...beforeWindow, ...inWindow].sort((a, b) => a.ts - b.ts);
} }
function createReplayBuilder({ execFileAsync, fsp, ensureDir, renderSidebarVideo, getVideoEntriesForSource, getAudioEntriesForRover, overlapping }) { function createReplayBuilder({ execFileAsync, fsp, ensureDir, renderSidebarVideo, getVideoEntriesForSource, getAudioEntriesForSource, overlapping }) {
function resolveReplayWindow({ sources = [], nowMs, guardMs, durationMs }) { function resolveReplayWindow({ sources = [], nowMs, guardMs, durationMs }) {
const tentativeEnd = nowMs - guardMs; const tentativeEnd = nowMs - guardMs;
const sourceEnds = []; const sourceEnds = [];
@@ -122,18 +122,22 @@ function createReplayBuilder({ execFileAsync, fsp, ensureDir, renderSidebarVideo
normalizedVideos.push({ path: videoTrimmed, source }); normalizedVideos.push({ path: videoTrimmed, source });
usedSources.push(source); usedSources.push(source);
if (source.type === 'rover') { const audioEntries = overlapping(getAudioEntriesForSource(source), tStart, tEnd);
const audioEntries = overlapping(getAudioEntriesForRover(sourceId), tStart, tEnd); if (audioEntries.length) {
if (audioEntries.length) { /*
const audioConcat = path.join(tmpDir, `audio-${i}.m4a`); Audio workers are separate from selected video sources, even for PTZ
await concatFiles(audioEntries.map((entry) => entry.filePath), audioConcat); where the live camera path carries inline Opus. Trim the matching
const audioTrimmed = path.join(tmpDir, `audio-${i}.trim.m4a`); source-owned audio window here and let the final graph mix every
const firstAudioStartMs = audioEntries[0].startMs; selected source's audio together.
const ass = Math.max(0, (tStart - firstAudioStartMs) / 1000); */
const ato = Math.max(ass + 0.1, (tEnd - firstAudioStartMs) / 1000); const audioConcat = path.join(tmpDir, `audio-${i}.m4a`);
await execFileAsync(FFMPEG_BIN, ['-y','-hide_banner','-loglevel','error','-ss',ass.toFixed(3),'-to',ato.toFixed(3),'-i',audioConcat,'-vn','-ac','1','-ar','48000','-c:a','aac','-b:a','96k',audioTrimmed]); await concatFiles(audioEntries.map((entry) => entry.filePath), audioConcat);
normalizedAudios.push(audioTrimmed); const audioTrimmed = path.join(tmpDir, `audio-${i}.trim.m4a`);
} const firstAudioStartMs = audioEntries[0].startMs;
const ass = Math.max(0, (tStart - firstAudioStartMs) / 1000);
const ato = Math.max(ass + 0.1, (tEnd - firstAudioStartMs) / 1000);
await execFileAsync(FFMPEG_BIN, ['-y','-hide_banner','-loglevel','error','-ss',ass.toFixed(3),'-to',ato.toFixed(3),'-i',audioConcat,'-vn','-ac','1','-ar','48000','-c:a','aac','-b:a','96k',audioTrimmed]);
normalizedAudios.push(audioTrimmed);
} }
} }
@@ -97,6 +97,24 @@ function createSegmentStore({ getActiveSegmentRoot }) {
return segmentIndex.get(key) || []; return segmentIndex.get(key) || [];
} }
function getAudioEntriesForSource(source) {
/*
Rover audio is published as a separate "<rover>-audio" stream, while PTZ
replay audio is split into an internal "ptz-camera-audio" worker from the
same live camera stream. Keep this mapping close to the segment index so
replayBuilder can ask for "audio that belongs to this selected source"
without knowing every worker naming convention.
*/
const type = String(source?.type || '');
const id = String(source?.id || '');
if (type === 'rover') return getAudioEntriesForRover(id);
if (type === 'ptz') {
const key = sourceKey({ sourceType: 'ptz', kind: 'audio', id: `${id}-audio` });
return segmentIndex.get(key) || [];
}
return [];
}
function overlapping(entries, startMs, endMs) { function overlapping(entries, startMs, endMs) {
return entries.filter((entry) => entry.endMs > startMs && entry.startMs < endMs); return entries.filter((entry) => entry.endMs > startMs && entry.startMs < endMs);
} }
@@ -157,6 +175,7 @@ function createSegmentStore({ getActiveSegmentRoot }) {
cleanupOldFiles, cleanupOldFiles,
getVideoEntriesForSource, getVideoEntriesForSource,
getAudioEntriesForRover, getAudioEntriesForRover,
getAudioEntriesForSource,
overlapping, overlapping,
bootstrapIndexFromDisk, bootstrapIndexFromDisk,
getReplayHealthSnapshot, getReplayHealthSnapshot,
@@ -47,8 +47,10 @@ function listDesiredSources() {
if (!streamUrl) continue; if (!streamUrl) continue;
sources.push({ id: String(camera.id), sourceType: 'room', kind: 'video', label: camera.name || camera.id, inputUrl: streamUrl }); sources.push({ id: String(camera.id), sourceType: 'room', kind: 'video', label: camera.name || camera.id, inputUrl: streamUrl });
} }
const ptzSource = ptzCameraService.getReplayWorkerSource(); // The PTZ service owns its own worker list because video and microphone audio
if (ptzSource) sources.push(ptzSource); // both come from the same live MediaMTX path, unlike rovers where audio is a
// separate published stream.
sources.push(...ptzCameraService.getReplayWorkerSources());
return sources; return sources;
} }
@@ -134,7 +134,9 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
const grouped = useMemo(() => { const grouped = useMemo(() => {
return { return {
rovers: sources.filter((source) => source.type === 'rover'), rovers: sources.filter((source) => source.type === 'rover'),
rooms: sources.filter((source) => source.type === 'room'), // PTZ is presented with room cameras because there is only one fixed room
// PTZ camera and it should not create a separate source category.
rooms: sources.filter((source) => source.type === 'room' || source.type === 'ptz'),
}; };
}, [sources]); }, [sources]);