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:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
仅链路 1:MediaMTX 将术间 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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user