From aa290a51b7591a0123c16b2c881351a5acc8749a Mon Sep 17 00:00:00 2001 From: core Date: Wed, 3 Jun 2026 16:09:49 +0500 Subject: [PATCH] v160 --- VERSION | 2 +- frontend/settings.html | 20 +--- frontend/static/app.js | 252 ++++++++++++++++++++--------------------- 3 files changed, 131 insertions(+), 143 deletions(-) diff --git a/VERSION b/VERSION index 96fb5fd..36942ce 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.158 +0.0.160 diff --git a/frontend/settings.html b/frontend/settings.html index 4a68f6a..bd2dbc8 100644 --- a/frontend/settings.html +++ b/frontend/settings.html @@ -153,23 +153,18 @@

Плеер и перекодирование — настройки этого браузера

- Для каждого потока выберите способ воспроизведения: как есть — - поток отдаётся без обработки (ffmpeg -c copy), браузер декодирует HEVC сам - (нужна аппаратная поддержка); встроенный плеер — декодирует H.265 - в браузере софтом (WASM hevc.js, в Web Worker), тянет и основной поток - (3200×1800, включая тайлы), и субпоток, без нагрузки на сервер (декод - нагружает CPU клиента); WebCodecs (железо) — аппаратный декод H.265 на GPU клиента, - тянет основной поток 3200×1800 при 0% CPU сервера (рекомендуется для - основного потока); перекодировать — сервер транскодирует в H.264 / - VP9 (нагрузка на CPU сервера, для слабых клиентов). + Для каждого потока выберите способ воспроизведения: встроенный плеер — + декодирует поток прямо в браузере без нагрузки на сервер (ffmpeg -c copy): + H.265/H.265+ — софтом (WASM hevc.js в Web Worker), H.264/H.264+ — средствами + браузера; тянет и основной поток (3200×1800, включая тайлы), и субпоток + (декод нагружает CPU клиента); перекодировать — сервер транскодирует + в H.264 / VP9 (нагрузка на CPU сервера, для слабых клиентов).

1 поток (основной)

- -
@@ -178,9 +173,7 @@

2 поток (суб)

- -
@@ -189,7 +182,6 @@

Архив

-
diff --git a/frontend/static/app.js b/frontend/static/app.js index 04f6923..db723af 100644 --- a/frontend/static/app.js +++ b/frontend/static/app.js @@ -69,9 +69,9 @@ function transcodeQueryFor(which) { // строка запрос return `profile=${e(pr.profile)}&bitrate=${e(pr.bitrate)}&preset=${e(pr.preset)}` + `&keyint=${e(pr.keyint)}&codec=${e(pr.codec)}&rc=${e(pr.rc)}&qp=${e(pr.qp)}`; } -function profileMode(which) { // "native" | "player" | "webcodecs" | "transcode" - const m = transcodeProfile(which).mode; - return (m === "transcode" || m === "native" || m === "webcodecs") ? m : "player"; +function profileMode(which) { // "player" | "transcode" + // режимы "native" (как есть) и "webcodecs" (железо) упразднены → встроенный плеер + return transcodeProfile(which).mode === "transcode" ? "transcode" : "player"; } // раскладки: маппинг камер по ячейкам, на размерность (этот браузер) function getLayout(dim) { @@ -198,19 +198,6 @@ function connectStatusWS(onCameras) { ws.onclose = () => setTimeout(() => connectStatusWS(onCameras), 3000); } -// ── go2rtc плеер ──────────────────────────────────────────────── -let _rtcLoaded = null; -function loadRtcComponent(go2rtcUrl) { - if (_rtcLoaded) return _rtcLoaded; - _rtcLoaded = new Promise((resolve, reject) => { - const s = document.createElement("script"); - s.src = go2rtcUrl.replace(/\/$/, "") + "/video-rtc.js"; - s.onload = resolve; - s.onerror = () => reject(new Error("go2rtc video-rtc.js не загрузился")); - document.head.appendChild(s); - }); - return _rtcLoaded; -} // ── встроенный плеер: hevc.js (open-source WASM HEVC) в Web Worker, рендер в OffscreenCanvas ── // Декод H.265 (включая тайлы) + рендер целиком в отдельном потоке: главный поток свободен, // сетка из нескольких камер не тормозит. Сервер отдаёт поток как есть (-c:v copy → 0% CPU). @@ -219,8 +206,10 @@ const HEVC_WASM_JS = "/static/vendor/hevcjs/hevc-decode.esm.js"; const HEVC_WASM_BIN = "/static/vendor/hevcjs/dist/wasm/hevc-decode.wasm"; class HevcWasmPlayer { - constructor(box, wsUrl, useMain, onFail) { + // external:true — не открывать свой WS; FLV-байты подаются методом feed() (SmartFlvPlayer) + constructor(box, wsUrl, useMain, onFail, opts) { this.box = box; this.wsUrl = wsUrl; this.onFail = onFail || null; + this.external = !!(opts && opts.external); this.ready = false; this.queue = []; this.closed = false; this.worker = null; this.ws = null; const cv = document.createElement("canvas"); cv.className = "wc-canvas"; @@ -234,8 +223,11 @@ class HevcWasmPlayer { try { this.worker = new Worker(HEVC_WORKER_URL, { type: "module" }); } catch (e) { this._fail("worker"); return; } this.worker.onmessage = (e) => { const m = e.data; - if (m.type === "ready") this._openWS(); - else if (m.type === "err") console.warn("HEVC worker:", m.msg); + if (m.type === "ready") { + this.ready = true; + if (this.external) { for (const b of this.queue) this._send(b); this.queue = []; } + else this._openWS(); + } else if (m.type === "err") console.warn("HEVC worker:", m.msg); }; this.worker.onerror = (e) => { console.warn("HEVC worker.onerror:", e && e.message); this._fail("worker-error"); }; this.worker.postMessage( @@ -251,6 +243,9 @@ class HevcWasmPlayer { this.ws.onclose = () => { /**/ }; } catch (e) { /**/ } } + _send(buf) { if (!this.closed && this.worker) try { this.worker.postMessage({ type: "data", buf }, [buf]); } catch (e) { /**/ } } + // внешняя подача FLV-байт (external-режим): до готовности воркера — в очередь + feed(buf) { if (this.closed) return; if (this.ready) this._send(buf); else this.queue.push(buf); } _fail(reason) { if (this.closed) return; const f = this.onFail; this.onFail = null; @@ -293,55 +288,7 @@ function bindFullscreenKey() { }); } -// умеет ли браузер декодировать HEVC (H.265) сам — для нативного просмотра -let _hevc = null; -function hevcSupported() { - if (_hevc !== null) return _hevc; - let ok = false; - try { - const v = document.createElement("video"); - const t = v.canPlayType('video/mp4; codecs="hvc1.1.6.L93.B0"'); - if (t === "probably" || t === "maybe") ok = true; - if (!ok && window.MediaSource && MediaSource.isTypeSupported) { - ok = MediaSource.isTypeSupported('video/mp4; codecs="hvc1.1.6.L93.B0"') || - MediaSource.isTypeSupported('video/mp4; codecs="hev1.1.6.L93.B0"'); - } - } catch (e) { /* ignore */ } - _hevc = ok; - return ok; -} - -// нативный плеер go2rtc (open-source video-rtc.js): MSE/WebRTC, поддерживает H.265 -function makePlayer(go2rtcUrl, srcName, transcode) { - const el = document.createElement("video-stream"); - // mse раньше webrtc — в Chrome HEVC доступен только в MSE; webrtc — фолбэк (Safari) - el.setAttribute("mode", transcode ? "webrtc,mse" : "mse,webrtc"); - el.setAttribute("background", "false"); - const base = go2rtcUrl.replace(/\/$/, ""); - el.src = new URL(base + "/api/ws?src=" + encodeURIComponent(srcName)); - return el; -} - -// ── свой WebCodecs-плеер H.265 (аппаратный декод на GPU клиента, 0% CPU сервера) ── -// есть ли в браузере аппаратный HEVC-декодер через WebCodecs (кэш Promise) -let _hevcHw = null; -function hevcHwSupported() { - if (_hevcHw) return _hevcHw; - _hevcHw = (async () => { - if (!("VideoDecoder" in window) || !window.isSecureContext) return false; - const cands = ["hvc1.1.6.L153.B0", "hev1.1.6.L153.B0", "hvc1.2.4.L153.B0"]; - for (const codec of cands) { - try { - const r = await VideoDecoder.isConfigSupported( - { codec, codedWidth: 3200, codedHeight: 1800, hardwareAcceleration: "prefer-hardware" }); - if (r && r.supported) return true; - } catch (e) { /* пробуем следующий */ } - } - return false; - })(); - return _hevcHw; -} - +// ── WebCodecs-плеер: декод на клиенте (для H.264 — единственный путь, H.265 идёт в WASM) ── // строка кодека из HEVCDecoderConfigurationRecord (hvcC) function hevcCodecFromHvcC(hvcC) { try { @@ -352,11 +299,21 @@ function hevcCodecFromHvcC(hvcC) { } catch (e) { return "hvc1.1.6.L153.B0"; } } -// демуксер FLV (enhanced-RTMP) → hvcC + length-prefixed HEVC-кадры +// строка кодека из AVCDecoderConfigurationRecord (avcC): +// [1]=profile_idc, [2]=constraint_flags, [3]=level_idc → "avc1.PPCCLL" (hex) +function avcCodecFromAvcC(avcC) { + try { + const h2 = (n) => n.toString(16).padStart(2, "0"); + return `avc1.${h2(avcC[1])}${h2(avcC[2])}${h2(avcC[3])}`; + } catch (e) { return "avc1.4d0033"; } +} + +// демуксер FLV (enhanced-RTMP) → hvcC/avcC + length-prefixed кадры (HEVC или AVC) class FlvHevcDemuxer { constructor({ onConfig, onSample }) { this.onConfig = onConfig; this.onSample = onSample; this.buf = new Uint8Array(0); this.headerDone = false; this.gotConfig = false; + this.codecType = "hevc"; // 'hevc' | 'avc' — определяется из видеотега } push(ab) { const inc = new Uint8Array(ab); @@ -395,7 +352,9 @@ class FlvHevcDemuxer { if (isEx) { const frameType = (b0 >> 4) & 0x07, packetType = b0 & 0x0f; const fourcc = String.fromCharCode(body[1], body[2], body[3], body[4]); - if (fourcc !== "hvc1" && fourcc !== "hev1") return; + if (fourcc === "hvc1" || fourcc === "hev1") this.codecType = "hevc"; + else if (fourcc === "avc1") this.codecType = "avc"; + else return; let off = 5; if (packetType === 1) off += 3; // CompositionTime const payload = body.subarray(off); @@ -403,16 +362,25 @@ class FlvHevcDemuxer { else if (packetType === 1 || packetType === 3) this._frames(payload, frameType === 1, tsMs); } else { const codecId = b0 & 0x0f, frameType = (b0 >> 4) & 0x0f; - if (codecId !== 12 && codecId !== 7) return; // 12=HEVC legacy, 7=AVC + if (codecId === 12) this.codecType = "hevc"; // 12=HEVC legacy + else if (codecId === 7) this.codecType = "avc"; // 7=AVC (H.264) + else return; const pkt = body[1], payload = body.subarray(5); if (pkt === 0) this._config(payload); else if (pkt === 1) this._frames(payload, frameType === 1, tsMs); } } - _config(hvcC) { - if (this.gotConfig || hvcC.length < 13) return; - this.gotConfig = true; - this.onConfig(hvcC.slice(), hevcCodecFromHvcC(hvcC)); + _config(rec) { + if (this.gotConfig) return; + if (this.codecType === "avc") { + if (rec.length < 7) return; + this.gotConfig = true; + this.onConfig(rec.slice(), avcCodecFromAvcC(rec), "avc"); + } else { + if (rec.length < 13) return; + this.gotConfig = true; + this.onConfig(rec.slice(), hevcCodecFromHvcC(rec), "hevc"); + } } _frames(payload, isKey, tsMs) { if (!this.gotConfig || payload.length === 0) return; @@ -421,9 +389,12 @@ class FlvHevcDemuxer { } // плеер: FLV(ws) → FlvHevcDemuxer → VideoDecoder (HW) → +// Декодирует и HEVC (H.265), и AVC (H.264) — кодек берётся из config-пакета FLV. +// external:true — не открывать свой WS, поток подаётся через feed() (для SmartFlvPlayer). class WebCodecsHevcPlayer { - constructor(box, wsUrl, onFail) { + constructor(box, wsUrl, onFail, opts) { this.box = box; this.wsUrl = wsUrl; this.onFail = onFail || null; + this.external = !!(opts && opts.external); this.closed = false; this.configured = false; this.pending = null; this.rafId = 0; this.canvas = document.createElement("canvas"); this.canvas.className = "wc-canvas"; @@ -431,6 +402,7 @@ class WebCodecsHevcPlayer { this.ctx = this.canvas.getContext("2d", { alpha: false, desynchronized: true }); this._start(); } + feed(ab) { if (!this.closed) { try { this.demux.push(ab); } catch (e) { /**/ } } } _fail(reason) { if (this.closed) return; const f = this.onFail; this.onFail = null; @@ -452,10 +424,10 @@ class WebCodecsHevcPlayer { } catch (e) { this._fail("ctor"); return; } this.demux = new FlvHevcDemuxer({ - onConfig: (hvcC, codec) => { - console.log("WC: config received codec=", codec, "hvcClen=", hvcC.length); + onConfig: (rec, codec, codecType) => { + console.log("WC: config received codec=", codec, "type=", codecType, "len=", rec.length); try { - this.decoder.configure({ codec, description: hvcC, optimizeForLatency: true, hardwareAcceleration: "prefer-hardware" }); + this.decoder.configure({ codec, description: rec, optimizeForLatency: true, hardwareAcceleration: "prefer-hardware" }); this.configured = true; console.log("WC: configure() ok, state=", this.decoder.state); } catch (e) { console.warn("WC: configure threw:", e && (e.message || e)); this._fail("configure:" + (e && e.name || "")); } @@ -472,6 +444,7 @@ class WebCodecsHevcPlayer { }, }); + if (this.external) return; // поток подаётся через feed() извне try { this.ws = new WebSocket(this.wsUrl); this.ws.binaryType = "arraybuffer"; @@ -499,6 +472,68 @@ class WebCodecsHevcPlayer { } } +// ── единый «Встроенный плеер»: автодетект кодека из FLV-потока ── +// Одно WS-соединение, кодек берётся из первого config-пакета. «+»-варианты +// (H.264+/H.265+, Hikvision SmartCodec) — тот же битстрим, поэтому различаем +// только AVC (H.264/H.264+) и HEVC (H.265/H.265+): +// • HEVC → WASM hevc.js (софт-декод в Web Worker) — как и было; +// • AVC → WebCodecs (avc1) — WASM-декодера H.264 в проекте нет, поэтому +// H.264 декодирует сам браузер (внутренняя деталь, не отдельный режим). +// До определения кодека сырые FLV-чанки буферизуются и затем переигрываются +// выбранному декодеру → реконнекта нет. +class SmartFlvPlayer { + constructor(box, wsUrl, useMain, onFail) { + this.box = box; this.wsUrl = wsUrl; this.useMain = useMain; + this.onFail = onFail || null; + this.closed = false; this.child = null; this.deciding = false; this.rawBuf = []; + this.sniff = new FlvHevcDemuxer({ + onConfig: (rec, codec, codecType) => this._decide(codecType), + onSample: () => { /* до выбора декодера кадры не нужны */ }, + }); + try { + this.ws = new WebSocket(wsUrl); + this.ws.binaryType = "arraybuffer"; + this.ws.onmessage = (ev) => this._onChunk(ev.data); + this.ws.onerror = () => { /**/ }; + this.ws.onclose = () => { if (!this.closed && !this.child) this._fail("ws-closed"); }; + } catch (e) { this._fail("ws"); } + } + _onChunk(ab) { + if (this.closed) return; + if (this.child) { this.child.feed(ab); return; } + this.rawBuf.push(ab.slice(0)); // копия для переигровки выбранному декодеру + try { this.sniff.push(ab); } catch (e) { /**/ } + } + _decide(codecType) { + if (this.closed || this.child || this.deciding) return; + this.deciding = true; + if (codecType === "avc") { // H.264/H.264+ → WebCodecs (браузер) + this._attach(new WebCodecsHevcPlayer(this.box, null, (r) => this._fail(r), { external: true })); + } else { // H.265/H.265+ → WASM hevc.js + this._attach(new HevcWasmPlayer(this.box, null, this.useMain, (r) => this._fail(r), { external: true })); + } + } + _attach(child) { + if (this.closed) { try { child.destroy(); } catch (e) { /**/ } return; } + this.child = child; + for (const b of this.rawBuf) child.feed(b); + this.rawBuf = []; + } + _fail(reason) { + if (this.closed) return; + const f = this.onFail; this.onFail = null; + this.destroy(); + if (f) f(reason); + } + resize() { if (this.child && this.child.resize) try { this.child.resize(); } catch (e) { /**/ } } + destroy() { + this.closed = true; + try { if (this.ws) { this.ws.onmessage = null; this.ws.onclose = null; this.ws.close(); } } catch (e) { /**/ } + this.ws = null; + if (this.child) { try { this.child.destroy(); } catch (e) { /**/ } this.child = null; } + } +} + // ════════════════════════════════════════════════════════════════ // Страница LIVE VIEW // ════════════════════════════════════════════════════════════════ @@ -518,11 +553,7 @@ const Live = { this.singleMain = opt.single_main_stream !== false; // по умолчанию вкл this.maxDim = opt.mode_16plus ? 8 : 4; // 16+ открывает 25..64 this.go2rtcUrl = (this.cameras[0] && this.cameras[0].live.go2rtc_url) || ""; - // встроенный плеер (hevc.js WASM) грузит свой worker по требованию — предзагрузка не нужна - if (this.go2rtcUrl && (this.mode1 === "native" || this.mode2 === "native")) { // «как есть» - try { await loadRtcComponent(this.go2rtcUrl); } catch (e) { console.warn(e); } - } - + // встроенный плеер (WASM/WebCodecs) грузит декодер по требованию — предзагрузка не нужна this.applyMaxDim(); this.setDim(this.initialDim(), false); this.bindSubnav(); @@ -695,52 +726,22 @@ const Live = { const which = useMain ? "1" : "2"; // профиль потока const mode = profileMode(which); if (mode === "transcode") return this.transcodeVideo(cam, useMain); - if (mode === "native") { // «как есть»: нативный go2rtc-плеер - if (!this.go2rtcUrl) return null; - const src = useMain ? (cam.id + "_main") : cam.live.src; - const el = makePlayer(this.go2rtcUrl, src, false); // браузер сам декодирует HEVC - el.dataset.kind = "native"; - return el; - } const proto = location.protocol === "https:" ? "wss" : "ws"; const url = `${proto}://${location.host}/api/cameras/${cam.id}/flv?stream=${useMain ? "main" : "sub"}`; const box = document.createElement("div"); box.className = "hevc-player"; - const startWasm = () => { // встроенный плеер: hevc.js WASM в Web Worker - box.dataset.kind = "wasm"; - box._hevc = new HevcWasmPlayer(box, url, useMain, () => { - if (box.isConnected) box.innerHTML = '
плеер не запустился
'; - }); - }; - if (mode === "webcodecs") { // свой плеер: WebCodecs (HW), 0% CPU сервера - box.dataset.kind = "webcodecs"; - const fallback = (msg) => { - if (!box.isConnected) return; - const h = document.createElement("div"); - h.className = "hevc-hint"; - h.textContent = msg; - box.appendChild(h); - startWasm(); // запасной — WASM-декод в воркере - }; - hevcHwSupported().then((hw) => { - if (!box.isConnected) return; - if (!hw) { // нет аппаратного HEVC в этом браузере - fallback("Нет аппаратного H.265 (WebCodecs) в этом браузере — запасной WASM-декодер"); - return; - } - box._wc = new WebCodecsHevcPlayer(box, url, (reason) => - fallback("WebCodecs ошибка (" + reason + ") — запасной WASM-декодер")); - }); - return box; - } - // встроенный плеер: hevc.js (клиентский WASM-декод H.265 + тайлы, в Web Worker) - startWasm(); + box.dataset.kind = "player"; + // встроенный плеер: автодетект кодека (H.265/H.265+ → WASM, H.264/H.264+ → WebCodecs браузера) + box._smart = new SmartFlvPlayer(box, url, useMain, () => { + if (box.isConnected) box.innerHTML = '
плеер не запустился
'; + }); return box; }, // уничтожить плеер-инстансы внутри ячейки (WASM-воркер/WebCodecs держат worker/decoder) destroyTilePlayers(tile) { tile.querySelectorAll(".hevc-player").forEach((b) => { + if (b._smart) { try { b._smart.destroy(); } catch (e) { /* ignore */ } b._smart = null; } if (b._wc) { try { b._wc.destroy(); } catch (e) { /* ignore */ } b._wc = null; } if (b._hevc) { try { b._hevc.destroy(); } catch (e) { /* ignore */ } b._hevc = null; } }); @@ -767,13 +768,6 @@ const Live = { tile.insertBefore(d, tile.firstChild); return; } tile.insertBefore(p, tile.firstChild); - // подсказка только для нативного go2rtc-плеера, если браузер не умеет HEVC - if (p.dataset.kind === "native" && !hevcSupported()) { - const h = document.createElement("div"); - h.className = "hevc-hint"; - h.textContent = "Браузер не декодирует H.265 — выберите «встроенный плеер» в настройках"; - tile.appendChild(h); - } }, renderGrid() { @@ -841,6 +835,7 @@ const Live = { resizePlayers() { // пересчитать canvas плееров после смены размера ячейки setTimeout(() => { document.querySelectorAll(".hevc-player").forEach((b) => { + if (b._smart && typeof b._smart.resize === "function") { try { b._smart.resize(); } catch (e) { /**/ } } if (b._wc && typeof b._wc.resize === "function") { try { b._wc.resize(); } catch (e) { /**/ } } if (b._hevc && typeof b._hevc.resize === "function") { try { b._hevc.resize(); } catch (e) { /**/ } } }); @@ -1662,7 +1657,8 @@ const Settings = { row.innerHTML = this.trRowHtml(pr); // режим: проставить выбранный radio const radios = block.querySelectorAll(`input[name="mode-${which}"]`); - radios.forEach((r) => { r.checked = (r.value === pr.mode); }); + const dispMode = pr.mode === "transcode" ? "transcode" : "player"; // native/webcodecs → встроенный + radios.forEach((r) => { r.checked = (r.value === dispMode); }); // показывать ряд параметров только в режиме «Перекодировать» const syncMode = () => { const sel = block.querySelector(`input[name="mode-${which}"]:checked`);