mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
ptzreplay
This commit is contained in:
@@ -25,7 +25,9 @@ const DEFAULT_ONVIF_PORT = 8000;
|
||||
const DEFAULT_PROFILE_TOKEN = '003';
|
||||
const DEFAULT_TURN_DURATION_MS = 5 * 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_POLL_MS = 300;
|
||||
const SNAPSHOT_STREAM_INTERVAL_MS = 2000;
|
||||
@@ -899,21 +901,36 @@ module.exports = {
|
||||
getReplaySource: () => enabled && isReplayEnabled()
|
||||
? { type: 'ptz', id: PTZ_CAMERA_ID, label: cameraConfig.name || 'PTZ Camera' }
|
||||
: null,
|
||||
getReplayWorkerSource: () => {
|
||||
getReplayWorkerSources: () => {
|
||||
/*
|
||||
PTZ replay capture is optional because the server ffmpeg build must be
|
||||
able to produce browser/Discord-friendly replay segments. The camera live
|
||||
feed can remain raw for MediaMTX while replay capture is left off until
|
||||
the actual server has a working encoder or a copy-only PTZ replay path is
|
||||
intentionally designed.
|
||||
PTZ replay uses two internal workers from the same MediaMTX path. The
|
||||
selectable replay source stays "ptz:ptz-camera", while the segment engine
|
||||
records video and audio separately so replayBuilder can mix PTZ microphone
|
||||
audio the same way it already mixes rover audio.
|
||||
*/
|
||||
if (!enabled || !isReplayEnabled()) return null;
|
||||
return {
|
||||
id: PTZ_CAMERA_ID,
|
||||
sourceType: 'ptz',
|
||||
kind: 'video',
|
||||
label: cameraConfig.name || 'PTZ Camera',
|
||||
inputUrl: `srt://127.0.0.1:9000?streamid=read:${encodeURIComponent(PTZ_STREAM_PATH)}`,
|
||||
};
|
||||
if (!enabled || !isReplayEnabled()) return [];
|
||||
const inputUrl = `srt://127.0.0.1:9000?streamid=read:${encodeURIComponent(PTZ_STREAM_PATH)}`;
|
||||
const label = cameraConfig.name || 'PTZ Camera';
|
||||
return [
|
||||
{
|
||||
id: PTZ_CAMERA_ID,
|
||||
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,
|
||||
renderSidebarVideo: sidebarRenderer.renderSidebarVideo,
|
||||
getVideoEntriesForSource: segmentStore.getVideoEntriesForSource,
|
||||
getAudioEntriesForRover: segmentStore.getAudioEntriesForRover,
|
||||
getAudioEntriesForSource: segmentStore.getAudioEntriesForSource,
|
||||
overlapping: segmentStore.overlapping,
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
function createReplayBuilder({ execFileAsync, fsp, ensureDir, renderSidebarVideo, getVideoEntriesForSource, getAudioEntriesForRover, overlapping }) {
|
||||
function createReplayBuilder({ execFileAsync, fsp, ensureDir, renderSidebarVideo, getVideoEntriesForSource, getAudioEntriesForSource, overlapping }) {
|
||||
function resolveReplayWindow({ sources = [], nowMs, guardMs, durationMs }) {
|
||||
const tentativeEnd = nowMs - guardMs;
|
||||
const sourceEnds = [];
|
||||
@@ -122,18 +122,22 @@ function createReplayBuilder({ execFileAsync, fsp, ensureDir, renderSidebarVideo
|
||||
normalizedVideos.push({ path: videoTrimmed, source });
|
||||
usedSources.push(source);
|
||||
|
||||
if (source.type === 'rover') {
|
||||
const audioEntries = overlapping(getAudioEntriesForRover(sourceId), tStart, tEnd);
|
||||
if (audioEntries.length) {
|
||||
const audioConcat = path.join(tmpDir, `audio-${i}.m4a`);
|
||||
await concatFiles(audioEntries.map((entry) => entry.filePath), audioConcat);
|
||||
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);
|
||||
}
|
||||
const audioEntries = overlapping(getAudioEntriesForSource(source), tStart, tEnd);
|
||||
if (audioEntries.length) {
|
||||
/*
|
||||
Audio workers are separate from selected video sources, even for PTZ
|
||||
where the live camera path carries inline Opus. Trim the matching
|
||||
source-owned audio window here and let the final graph mix every
|
||||
selected source's audio together.
|
||||
*/
|
||||
const audioConcat = path.join(tmpDir, `audio-${i}.m4a`);
|
||||
await concatFiles(audioEntries.map((entry) => entry.filePath), audioConcat);
|
||||
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) || [];
|
||||
}
|
||||
|
||||
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) {
|
||||
return entries.filter((entry) => entry.endMs > startMs && entry.startMs < endMs);
|
||||
}
|
||||
@@ -157,6 +175,7 @@ function createSegmentStore({ getActiveSegmentRoot }) {
|
||||
cleanupOldFiles,
|
||||
getVideoEntriesForSource,
|
||||
getAudioEntriesForRover,
|
||||
getAudioEntriesForSource,
|
||||
overlapping,
|
||||
bootstrapIndexFromDisk,
|
||||
getReplayHealthSnapshot,
|
||||
|
||||
@@ -47,8 +47,10 @@ function listDesiredSources() {
|
||||
if (!streamUrl) continue;
|
||||
sources.push({ id: String(camera.id), sourceType: 'room', kind: 'video', label: camera.name || camera.id, inputUrl: streamUrl });
|
||||
}
|
||||
const ptzSource = ptzCameraService.getReplayWorkerSource();
|
||||
if (ptzSource) sources.push(ptzSource);
|
||||
// The PTZ service owns its own worker list because video and microphone audio
|
||||
// both come from the same live MediaMTX path, unlike rovers where audio is a
|
||||
// separate published stream.
|
||||
sources.push(...ptzCameraService.getReplayWorkerSources());
|
||||
return sources;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user