This commit is contained in:
legop3
2026-07-11 00:38:05 -04:00
parent b3001cf0a3
commit aee1a9d563
11 changed files with 389 additions and 251 deletions
+69 -28
View File
@@ -58,6 +58,7 @@ let blockedTimer = null;
let publisherProcess = null;
let publisherRestartTimer = null;
let snapshotTimer = null;
let vendorStatePromise = Promise.resolve();
let lastSnapshotState = null;
const snapshotSubscribers = new Map();
const socketSnapshotSubscriptions = new Map();
@@ -194,10 +195,10 @@ function startPublisher() {
const input = addCredentialsToRtsp(state.rtspUri);
const output = `srt://127.0.0.1:9000?streamid=publish:${encodeURIComponent(PTZ_STREAM_PATH)}`;
/*
MediaMTX already treats SRT publishers as trusted local media producers.
Copying the autotrack stream keeps the full-resolution camera feed intact;
if browser H.265 support becomes a problem later, this is the one place to
add a transcode without changing PTZ ownership or UI code.
MediaMTX should receive the camera feed exactly as the camera provides it.
The WHEP 404 issue is a path mismatch, not a codec problem, so this publisher
keeps the autotrack stream untouched and lets playback compatibility be
handled separately if it actually becomes the blocker.
*/
const proc = spawn('ffmpeg', [
'-hide_banner',
@@ -267,6 +268,18 @@ async function refreshVendorState() {
state.ir = ir?.IrLights || ir || null;
}
function serializeVendorState(operation) {
/*
The Reolink HTTP API can return stale light state when reads and writes are
overlapped. Keep spotlight/IR changes in one narrow queue so a button mash
becomes ordered camera operations instead of competing Get/Set requests.
*/
vendorStatePromise = vendorStatePromise
.catch(() => {})
.then(operation);
return vendorStatePromise;
}
async function initialize() {
if (!enabled || state.initialized || state.initializing) return;
state.initializing = true;
@@ -330,15 +343,30 @@ function activateOperator(socket) {
});
state.operatorSocketId = socket.id;
state.deadline = Date.now() + getTurnDurationMs();
turnTimer = setTimeout(() => {
revokeOperator('turn-expired');
advanceQueue('turn-expired');
}, getTurnDurationMs());
turnTimer = setTimeout(handleTurnDeadline, getTurnDurationMs());
socket.emit('ptzCamera:turn', { status: 'active', deadline: state.deadline });
events.emit('operator', { socketId: socket.id, action: 'active' });
emitChange('operator-active');
}
function handleTurnDeadline() {
turnTimer = null;
if (!state.operatorSocketId) return;
if (state.queue.length > 0) {
revokeOperator('turn-expired');
advanceQueue('turn-expired');
return;
}
/*
A turn timer only matters when somebody else is waiting. If the operator is
alone, keep them on the PTZ camera and roll the deadline forward so the UI
stays coherent without kicking out the only active viewer.
*/
state.deadline = Date.now() + getTurnDurationMs();
turnTimer = setTimeout(handleTurnDeadline, getTurnDurationMs());
emitChange('turn-extended-empty-queue');
}
function advanceQueue(reason = 'advance') {
clearBlockedTimer();
state.blocked = null;
@@ -446,30 +474,43 @@ async function getStatus(socket) {
async function setSpotlight(socket, payload = {}) {
requireOperator(socket);
const client = await ensureReolinkClient();
const current = (await client.api('GetWhiteLed', { channel: 0 })).WhiteLed;
const next = {
...current,
channel: 0,
state: payload.state === undefined ? (current.state ? 0 : 1) : Number(Boolean(payload.state)),
};
if (Number.isFinite(Number(payload.bright))) {
next.bright = Math.max(0, Math.min(100, Number(payload.bright)));
}
await client.api('SetWhiteLed', { WhiteLed: next });
await refreshVendorState();
emitChange('light');
return state.light;
return serializeVendorState(async () => {
const client = await ensureReolinkClient();
const current = (await client.api('GetWhiteLed', { channel: 0 })).WhiteLed || {};
const next = {
...current,
channel: 0,
state: payload.state === undefined ? (current.state ? 0 : 1) : Number(Boolean(payload.state)),
};
if (Number.isFinite(Number(payload.bright))) {
next.bright = Math.max(0, Math.min(100, Number(payload.bright)));
}
/*
Update local state optimistically before the refresh. That makes the
headlight button feel deterministic while the follow-up read still lets
the camera correct anything it refused or normalized.
*/
state.light = next;
emitChange('light-pending');
await client.api('SetWhiteLed', { WhiteLed: next });
await refreshVendorState();
emitChange('light');
return state.light;
});
}
async function setIr(socket, payload = {}) {
requireOperator(socket);
const nextState = String(payload.state || '').toLowerCase() === 'off' ? 'Off' : 'Auto';
const client = await ensureReolinkClient();
await client.api('SetIrLights', { IrLights: { state: nextState } });
await refreshVendorState();
emitChange('ir');
return state.ir;
return serializeVendorState(async () => {
const nextState = String(payload.state || '').toLowerCase() === 'off' ? 'Off' : 'Auto';
const client = await ensureReolinkClient();
state.ir = { ...(state.ir || {}), state: nextState };
emitChange('ir-pending');
await client.api('SetIrLights', { IrLights: { state: nextState } });
await refreshVendorState();
emitChange('ir');
return state.ir;
});
}
function canRequestLiveVideo(socket) {
@@ -5,6 +5,7 @@ const { loadConfig } = require('../../helpers/configLoader');
const config = loadConfig();
const mediaConfig = config.media || {};
const PTZ_STREAM_PATH = 'ptz-camera';
function getPathPrefix() {
const base = mediaConfig.whepBaseUrl;
@@ -37,6 +38,14 @@ function extractStreamInfo(path) {
const remaining = segments.slice(start, end);
if (remaining.length === 1) {
const rawId = remaining[0] || '';
/*
PTZ is intentionally published as a flat MediaMTX path so WHEP requests
line up with the real stream name. Treat that one reserved path as PTZ
before falling back to the normal one-segment rover parsing rules.
*/
if (rawId === PTZ_STREAM_PATH) {
return { type: 'ptz', id: rawId };
}
if (rawId.endsWith('-fwd')) {
return { type: 'rover', id: rawId, baseId: rawId.slice(0, -4) };
}
@@ -81,6 +90,10 @@ function extractStreamInfoFromBody(body = {}) {
extractSrtStreamId(body.query);
if (!srtId) return null;
if (srtId === PTZ_STREAM_PATH) {
return { type: 'ptz', id: srtId };
}
if (srtId.endsWith('-fwd')) {
return { type: 'rover', id: srtId, baseId: srtId.slice(0, -4) };
}
@@ -36,7 +36,14 @@ function buildWhepUrlForSource(source) {
if (source.type === 'room') {
segments.push('room', encodeURIComponent(source.id));
} else if (source.type === 'ptz') {
segments.push('ptz', encodeURIComponent(source.id));
/*
MediaMTX exposes WHEP by the exact path name that is being published.
The PTZ ffmpeg publisher registers the single camera as "ptz-camera",
so the browser must request "/video/ptz-camera/whep" instead of a
namespace-like "/video/ptz/ptz-camera/whep" path that MediaMTX has never
seen and correctly returns as 404.
*/
segments.push(encodeURIComponent(source.id));
} else {
segments.push(encodeURIComponent(source.id));
}