This commit is contained in:
legop3
2026-07-11 19:13:30 -04:00
parent db44d23947
commit 64d6d5a601
7 changed files with 120 additions and 22 deletions
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
+1 -1
View File
@@ -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/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>
<script type="module" crossorigin src="/assets/index-CGjNAimd.js"></script>
<script type="module" crossorigin src="/assets/index-DPoEKYWc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-MAURNhur.css">
</head>
<body>
+76 -11
View File
@@ -63,6 +63,7 @@ const state = {
exitSignal: null,
exitedAt: null,
lastStderr: '',
progress: null,
lastEvent: 'idle',
},
};
@@ -108,6 +109,65 @@ function updatePublisherState(patch = {}, reason = 'publisher') {
emitChange(reason);
}
function parsePublisherProgressLine(line) {
/*
ffmpeg's "-progress pipe:2" emits simple key=value telemetry on stderr.
Warning lines also arrive on stderr, so keep parsing narrow: only accept the
known progress keys and let everything else remain user-visible stderr.
This gives the UI enough signal to tell whether the transcoder is actually
falling behind without flooding normal server logs.
*/
const match = String(line || '').match(/^([a-zA-Z_][a-zA-Z0-9_]*)=(.*)$/);
if (!match) return false;
const [, key, rawValue] = match;
const allowed = new Set([
'frame',
'fps',
'stream_0_0_q',
'bitrate',
'total_size',
'out_time_us',
'out_time_ms',
'out_time',
'dup_frames',
'drop_frames',
'speed',
'progress',
]);
if (!allowed.has(key)) return false;
state.publisher = {
...(state.publisher || {}),
progress: {
...(state.publisher?.progress || {}),
[key]: rawValue,
updatedAt: Date.now(),
},
lastEvent: 'progress',
};
return true;
}
function handlePublisherStderr(chunk) {
const text = String(chunk || '').trim();
if (!text) return;
const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
const warningLines = [];
lines.forEach((line) => {
if (!parsePublisherProgressLine(line)) warningLines.push(line);
});
if (warningLines.length) {
state.publisher = {
...(state.publisher || {}),
lastStderr: warningLines.join('\n').slice(-1000),
lastEvent: 'stderr',
};
}
schedulePublisherStateSync(warningLines.length ? 'publisher-stderr' : 'publisher-progress');
}
function clampUnit(value) {
const number = Number(value) || 0;
return Math.max(-1, Math.min(1, number));
@@ -322,12 +382,21 @@ function startPublisher() {
flags caused this camera stream to freeze after running for a while, so the
safer latency knob is to keep the encoder light and avoid building delay
inside x264 itself.
The mpegts muxer can also hold packets briefly before writing them to SRT.
flush_packets/muxdelay/muxpreload are output-side latency knobs; they do not
ask the camera or demuxer to discard frames, so they are a safer next step
than the stale-frame dropping experiments that made the Reolink feed freeze.
*/
const proc = spawn('ffmpeg', [
'-hide_banner',
'-loglevel',
'warning',
'-nostdin',
'-progress',
'pipe:2',
'-stats_period',
'2',
'-fflags',
'nobuffer',
'-flags',
@@ -374,6 +443,12 @@ function startPublisher() {
'48000',
'-strict',
'-2',
'-flush_packets',
'1',
'-muxdelay',
'0',
'-muxpreload',
'0',
'-f',
'mpegts',
output,
@@ -389,17 +464,7 @@ function startPublisher() {
exitedAt: null,
lastEvent: 'started',
}, 'publisher-start');
proc.stderr.on('data', (chunk) => {
const text = String(chunk || '').trim();
if (!text) return;
const lastStderr = text.slice(-1000);
state.publisher = {
...(state.publisher || {}),
lastStderr,
lastEvent: 'stderr',
};
schedulePublisherStateSync('publisher-stderr');
});
proc.stderr.on('data', handlePublisherStderr);
proc.on('exit', (code, signal) => {
if (publisherProcess === proc) publisherProcess = null;
logger.warn('publisher exited', { code, signal });
@@ -77,6 +77,22 @@ function StatusRow({ label, value, tone = '' }) {
);
}
function formatPublisherProgress(progress = null) {
/*
ffmpeg progress is intentionally shown as raw operational numbers instead
of translated prose. When the PTZ video feels delayed, fps/speed/drop/out
time make it obvious whether ffmpeg itself is keeping up or the delay is
somewhere before/after the transcoder.
*/
if (!progress) return null;
return [
progress.fps ? `fps ${progress.fps}` : null,
progress.speed ? `speed ${progress.speed}` : null,
progress.drop_frames ? `drop ${progress.drop_frames}` : null,
progress.out_time ? `out ${progress.out_time}` : null,
].filter(Boolean).join(' | ');
}
function PtzQueueList({ queue = [], operatorLabel = '' }) {
const hasQueue = Array.isArray(queue) && queue.length > 0;
return (
@@ -111,6 +127,7 @@ function PtzStatePanel({ ptz, onClose, onRelease, releaseDisabled = false }) {
: publisher.restartAt
? 'restarting'
: publisher.lastEvent || 'stopped';
const publisherProgress = formatPublisherProgress(publisher.progress);
const statusTone = ptz?.error ? 'text-amber-300' : ptz?.isOperator ? 'text-emerald-300' : 'text-slate-100';
return (
@@ -126,6 +143,7 @@ function PtzStatePanel({ ptz, onClose, onRelease, releaseDisabled = false }) {
<StatusRow label="Infrared mode" value={irMode} />
<StatusRow label="Stream" value={ptz?.status || ptz?.error || 'idle'} tone={ptz?.error ? 'text-amber-300' : ''} />
<StatusRow label="Transcoder" value={publisherStatus} tone={publisher.running ? 'text-emerald-300' : 'text-amber-300'} />
{publisherProgress ? <StatusRow label="Progress" value={publisherProgress} /> : null}
{publisher.lastStderr ? (
<div className="surface max-h-24 overflow-y-auto whitespace-pre-wrap break-words font-mono text-[0.68rem] leading-tight text-slate-200">
{publisher.lastStderr}
@@ -40,6 +40,19 @@ function InfoRow({ label, value, tone = '' }) {
);
}
function formatPublisherProgress(progress = null) {
/*
Keep the spectator card dense, but expose enough ffmpeg progress to tell if
the PTZ transcoder is running behind when the live feed looks delayed.
*/
if (!progress) return null;
return [
progress.fps ? `fps ${progress.fps}` : null,
progress.speed ? `speed ${progress.speed}` : null,
progress.drop_frames ? `drop ${progress.drop_frames}` : null,
].filter(Boolean).join(' | ');
}
function PtzSnapshotFallback({ label, source }) {
const snapshotFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: true });
const snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null;
@@ -85,6 +98,7 @@ export default function PtzSpectatorCard() {
: publisher.restartAt
? 'restarting'
: publisher.lastEvent || 'stopped';
const publisherProgress = formatPublisherProgress(publisher.progress);
const queueCount = Array.isArray(ptz?.queue) ? ptz.queue.length : 0;
const label = ptz?.name || 'PTZ Camera';
@@ -98,6 +112,7 @@ export default function PtzSpectatorCard() {
<InfoRow label="Spotlight" value={isSpotlightOn(ptz?.light) ? 'On' : 'Off'} />
<InfoRow label="Infrared" value={normalizeInfraredMode(ptz?.ir?.state)} />
<InfoRow label="Transcoder" value={publisherStatus} tone={publisher.running ? 'text-emerald-300' : 'text-amber-300'} />
{publisherProgress ? <InfoRow label="Progress" value={publisherProgress} /> : null}
{publisher.lastStderr ? (
<div className="line-clamp-2 break-words font-mono text-[0.65rem] leading-tight text-slate-400">
{publisher.lastStderr}