mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
ptzreplay
This commit is contained in:
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user