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
+32 -15
View File
@@ -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;
},
};
+1 -1
View File
@@ -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;
}