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 -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;
}