Add HLS browser preview for live RTSP demo and real camera URLs.

MediaMTX pulls site-config RTSP into HLS with API proxying for hls.js on the demo client; simulated realtime keeps local file previews only. Also add optional JPEG frame capture and document Docker HLS host settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin
2026-05-22 14:44:28 +08:00
parent d6f4590969
commit e92dc1a6d9
14 changed files with 894 additions and 7 deletions

View File

@@ -13,6 +13,8 @@
let selectedConsumables = new Set();
let lastVideoBatchDoctorDisplay = "";
let videoVisToken = 0;
const hlsPlayers = {};
const slotBlobUrls = {};
const webcamSlotState = {};
const baseUrl = () => $("base-url").value.trim().replace(/\/+$/, "");
@@ -356,7 +358,8 @@
card.classList.toggle("active", card.dataset.mode === mode);
});
document.querySelectorAll(".mode-panel").forEach((panel) => {
panel.classList.toggle("active", panel.dataset.mode === mode);
const modes = (panel.dataset.mode || "").split(/\s+/).filter(Boolean);
panel.classList.toggle("active", modes.length === 0 || modes.includes(mode));
});
const voice = $("voice-callout");
if (voice) voice.classList.toggle("hidden", mode === "offline-batch");
@@ -379,6 +382,204 @@
: "链路 3 · 离线精确";
if (mode !== "offline-batch") hideVideoBatchVisualization();
refreshRecordingModesStatus();
if (mode === "live-rtsp") {
rebuildPreviewGrid();
startPreviewPolling();
} else {
stopPreviewPolling();
}
}
function isRtspPreviewMode() {
return activeMode === "live-rtsp";
}
function parseCameraIdsInput() {
return ($("camera-ids")?.value || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
function collectPreviewCameras() {
if (activeMode !== "live-rtsp") return [];
return parseCameraIdsInput().map((id) => ({ id, label: id, source: "rtsp" }));
}
function revokeSlotBlobUrls() {
for (const key of Object.keys(slotBlobUrls)) {
URL.revokeObjectURL(slotBlobUrls[key]);
delete slotBlobUrls[key];
}
}
function updateSlotFilePreview(slot, file) {
const vid = $("sim-preview-" + slot);
if (!vid) return;
if (slotBlobUrls[slot]) {
URL.revokeObjectURL(slotBlobUrls[slot]);
delete slotBlobUrls[slot];
}
if (!file) {
vid.removeAttribute("src");
vid.classList.add("hidden");
vid.pause();
return;
}
const url = URL.createObjectURL(file);
slotBlobUrls[slot] = url;
vid.src = url;
vid.classList.remove("hidden");
vid.loop = true;
vid.muted = true;
void vid.play().catch(() => {});
}
function rebuildPreviewGrid() {
const grid = $("rtsp-preview-grid");
if (!grid) return;
clearPreviewGridBlobUrls();
grid.innerHTML = "";
const cameras = collectPreviewCameras();
if (!cameras.length) {
grid.innerHTML = '<p class="labels-meta">请填写 camera_id逗号分隔。</p>';
return;
}
cameras.forEach((cam) => {
const tile = document.createElement("div");
tile.className = "preview-tile";
tile.dataset.cameraId = cam.id;
tile.dataset.source = "rtsp";
const h = document.createElement("h4");
h.textContent = cam.label;
tile.appendChild(h);
const video = document.createElement("video");
video.className = "preview-media";
video.playsInline = true;
video.muted = true;
video.autoplay = true;
video.controls = true;
tile.appendChild(video);
const err = document.createElement("div");
err.className = "preview-err";
tile.appendChild(err);
grid.appendChild(tile);
});
}
function clearPreviewGridBlobUrls() {
const grid = $("rtsp-preview-grid");
if (!grid) return;
grid.querySelectorAll(".preview-tile").forEach((tile) => {
if (tile._blobUrl) {
URL.revokeObjectURL(tile._blobUrl);
tile._blobUrl = null;
}
});
}
function destroyHlsPlayers() {
for (const key of Object.keys(hlsPlayers)) {
const entry = hlsPlayers[key];
try {
entry?.hls?.destroy();
} catch {
/* ignore */
}
delete hlsPlayers[key];
}
}
function attachHlsToTile(tile, playlistUrl) {
const video = tile.querySelector("video.preview-media");
const errEl = tile.querySelector(".preview-err");
const camId = tile.dataset.cameraId;
if (!video) return;
if (typeof Hls === "undefined") {
if (errEl) errEl.textContent = "hls.js 未加载";
return;
}
if (Hls.isSupported()) {
const hls = new Hls({ enableWorker: true, lowLatencyMode: true });
hls.on(Hls.Events.ERROR, (_ev, data) => {
if (data.fatal && errEl) {
errEl.textContent = "HLS 播放失败:" + (data.type || "");
}
});
hls.on(Hls.Events.MANIFEST_PARSED, () => {
if (errEl) errEl.textContent = "";
void video.play().catch(() => {});
});
hls.loadSource(playlistUrl);
hls.attachMedia(video);
if (camId) hlsPlayers[camId] = { hls, video };
return;
}
if (video.canPlayType("application/vnd.apple.mpegurl")) {
video.src = playlistUrl;
void video.play().catch(() => {});
if (errEl) errEl.textContent = "";
} else if (errEl) {
errEl.textContent = "当前浏览器不支持 HLS";
}
}
async function ensureHlsPreview() {
const rtspCams = collectPreviewCameras().filter((c) => c.source === "rtsp");
if (!rtspCams.length) return null;
const { res, body } = await apiJson("POST", "/internal/demo/hls-preview/ensure", {
camera_ids: rtspCams.map((c) => c.id),
});
if (!res.ok) {
throw new Error(formatDetail(body));
}
return body;
}
async function startPreviewPolling() {
stopPreviewPolling({ keepGrid: true });
if (!isRtspPreviewMode()) return;
const status = $("preview-status");
rebuildPreviewGrid();
const grid = $("rtsp-preview-grid");
try {
const hasRtsp = collectPreviewCameras().some((c) => c.source === "rtsp");
if (hasRtsp) {
if (status) status.textContent = "正在启动 HLS…";
const ensureBody = await ensureHlsPreview();
const byId = Object.fromEntries(
(ensureBody?.cameras || []).map((c) => [c.camera_id, c.playlist_url]),
);
grid?.querySelectorAll('.preview-tile[data-source="rtsp"]').forEach((tile) => {
const id = tile.dataset.cameraId;
if (byId[id]) attachHlsToTile(tile, byId[id]);
});
if (status) status.textContent = "HLS 预览中MediaMTX";
} else if (status) {
status.textContent = "本地文件预览";
}
} catch (e) {
if (status) status.textContent = "预览失败";
showBanner("HLS 预览失败:" + String(e), "err");
}
}
async function stopPreviewPolling(options = {}) {
destroyHlsPlayers();
if (!options.keepGrid) {
clearPreviewGridBlobUrls();
const grid = $("rtsp-preview-grid");
if (grid) grid.innerHTML = "";
}
const status = $("preview-status");
if (status && !options.keepGrid) status.textContent = "已停止";
if (!options.skipApi) {
try {
await apiJson("DELETE", "/internal/demo/hls-preview/stop");
} catch {
/* ignore */
}
}
}
async function refreshHealth() {
@@ -597,8 +798,9 @@
const hint = $("sim-hint-" + slot);
const fname = $("sim-fname-" + slot);
if (vfile && file) assignFileToInput(vfile, file);
if (fname) fname.textContent = file ? file.name : "";
if (fname) fname.textContent = file ? file.name : "点击或拖放视频";
if (hint) hint.textContent = file ? "已选:" + file.name + (source ? " (" + source + ")" : "") : "";
updateSlotFilePreview(slot, file || null);
}
function setOfflineFile(file, source) {
@@ -986,6 +1188,14 @@
if (logEl) logEl.innerHTML = "";
});
$("base-url")?.addEventListener("change", refreshRecordingModesStatus);
$("camera-ids")?.addEventListener("input", () => {
if (activeMode === "live-rtsp") startPreviewPolling();
});
$("debug-stream-count")?.addEventListener("change", applyDebugStreamVisibility);
$("btn-preview-refresh")?.addEventListener("click", () => startPreviewPolling());
$("btn-preview-stop")?.addEventListener("click", () => {
void stopPreviewPolling();
});
refreshHealth();
}

View File

@@ -79,8 +79,8 @@
<section class="card mode-panel active" data-mode="live-rtsp">
<h2>真摄像头配置</h2>
<label for="camera-ids">摄像头 ID逗号分隔</label>
<input id="camera-ids" type="text" value="or-cam-03" placeholder="or-cam-03, or-cam-02" />
<p class="labels-meta" style="margin-top:8px">使用站点 JSON 中的真实 RTSP 地址。</p>
<input id="camera-ids" type="text" value="or-cam-01, or-cam-02, or-cam-03, or-cam-04" placeholder="or-cam-01, or-cam-02" />
<p class="labels-meta" style="margin-top:8px">使用站点 JSON 中的真实 RTSP 地址;下方可预览每路画面</p>
</section>
<section class="card mode-panel" data-mode="live-simulated">
@@ -106,6 +106,7 @@
<button type="button" class="secondary" id="sim-webcam-1">摄像头</button>
</div>
<div class="stream-hint" id="sim-hint-1"></div>
<video class="slot-file-preview hidden" id="sim-preview-1" playsinline muted></video>
</div>
<div class="stream-slot hidden" id="sim-stream-2">
<h4>路 2</h4>
@@ -119,6 +120,7 @@
<button type="button" class="secondary" id="sim-webcam-2">摄像头</button>
</div>
<div class="stream-hint" id="sim-hint-2"></div>
<video class="slot-file-preview hidden" id="sim-preview-2" playsinline muted></video>
</div>
<div class="stream-slot hidden" id="sim-stream-3">
<h4>路 3</h4>
@@ -132,6 +134,7 @@
<button type="button" class="secondary" id="sim-webcam-3">摄像头</button>
</div>
<div class="stream-hint" id="sim-hint-3"></div>
<video class="slot-file-preview hidden" id="sim-preview-3" playsinline muted></video>
</div>
<div class="stream-slot hidden" id="sim-stream-4">
<h4>路 4</h4>
@@ -145,6 +148,7 @@
<button type="button" class="secondary" id="sim-webcam-4">摄像头</button>
</div>
<div class="stream-hint" id="sim-hint-4"></div>
<video class="slot-file-preview hidden" id="sim-preview-4" playsinline muted></video>
</div>
</div>
<details class="advanced">
@@ -180,6 +184,19 @@
</label>
</section>
<section class="card mode-panel" data-mode="live-rtsp">
<h2>视频预览HLS</h2>
<p class="labels-meta">
仅链路 1MediaMTX 将术间 RTSP 转为 HLS经 API 反代后由 hls.js 播放。模拟实时请用各路槽位下的本地文件预览。
</p>
<div class="preview-toolbar">
<button type="button" class="secondary" id="btn-preview-refresh">启动/刷新预览</button>
<button type="button" class="secondary" id="btn-preview-stop">停止预览</button>
<span id="preview-status" class="labels-meta">未启动</span>
</div>
<div id="rtsp-preview-grid" class="preview-grid"></div>
</section>
<section class="card">
<h2>操作</h2>
<div class="steps">
@@ -212,6 +229,7 @@
</aside>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.17/dist/hls.min.js"></script>
<script src="app.js"></script>
</body>
</html>

View File

@@ -435,6 +435,58 @@ details.advanced .advanced-body {
min-height: 1.2em;
}
.slot-file-preview {
width: 100%;
margin-top: 8px;
max-height: 140px;
border-radius: var(--radius-sm);
background: #000;
object-fit: contain;
}
.preview-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin: 10px 0 12px;
}
.preview-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
}
.preview-tile {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px;
background: var(--bg-elevated);
}
.preview-tile h4 {
margin: 0 0 8px;
font-size: 0.8125rem;
color: var(--accent);
word-break: break-all;
}
.preview-tile .preview-media {
width: 100%;
aspect-ratio: 16 / 10;
object-fit: contain;
background: #000;
border-radius: var(--radius-sm);
}
.preview-tile .preview-err {
font-size: 0.75rem;
color: var(--danger);
margin-top: 6px;
min-height: 1.2em;
}
.steps {
display: flex;
flex-wrap: wrap;