v160
This commit is contained in:
+6
-14
@@ -153,23 +153,18 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Плеер и перекодирование <span class="muted" style="font-weight:400;font-size:12px">— настройки этого браузера</span></h2>
|
<h2>Плеер и перекодирование <span class="muted" style="font-weight:400;font-size:12px">— настройки этого браузера</span></h2>
|
||||||
<div class="muted" style="margin-top:-4px;margin-bottom:16px;font-size:12px">
|
<div class="muted" style="margin-top:-4px;margin-bottom:16px;font-size:12px">
|
||||||
Для каждого потока выберите способ воспроизведения: <b>как есть</b> —
|
Для каждого потока выберите способ воспроизведения: <b>встроенный плеер</b> —
|
||||||
поток отдаётся без обработки (ffmpeg -c copy), браузер декодирует HEVC сам
|
декодирует поток прямо в браузере без нагрузки на сервер (ffmpeg -c copy):
|
||||||
(нужна аппаратная поддержка); <b>встроенный плеер</b> — декодирует H.265
|
H.265/H.265+ — софтом (WASM hevc.js в Web Worker), H.264/H.264+ — средствами
|
||||||
в браузере софтом (WASM hevc.js, в Web Worker), тянет и основной поток
|
браузера; тянет и основной поток (3200×1800, включая тайлы), и субпоток
|
||||||
(3200×1800, включая тайлы), и субпоток, без нагрузки на сервер (декод
|
(декод нагружает CPU клиента); <b>перекодировать</b> — сервер транскодирует
|
||||||
нагружает CPU клиента); <b>WebCodecs (железо)</b> — аппаратный декод H.265 на GPU клиента,
|
в H.264 / VP9 (нагрузка на CPU сервера, для слабых клиентов).
|
||||||
тянет основной поток 3200×1800 при 0% CPU сервера (рекомендуется для
|
|
||||||
основного потока); <b>перекодировать</b> — сервер транскодирует в H.264 /
|
|
||||||
VP9 (нагрузка на CPU сервера, для слабых клиентов).
|
|
||||||
</div>
|
</div>
|
||||||
<!-- 3 независимых профиля: 1 поток / 2 поток / архив -->
|
<!-- 3 независимых профиля: 1 поток / 2 поток / архив -->
|
||||||
<div class="tr-block" data-tr="1">
|
<div class="tr-block" data-tr="1">
|
||||||
<h3>1 поток (основной)</h3>
|
<h3>1 поток (основной)</h3>
|
||||||
<div class="seg-toggle">
|
<div class="seg-toggle">
|
||||||
<label><input type="radio" name="mode-1" value="native"><span>Как есть</span></label>
|
|
||||||
<label><input type="radio" name="mode-1" value="player"><span>Встроенный плеер</span></label>
|
<label><input type="radio" name="mode-1" value="player"><span>Встроенный плеер</span></label>
|
||||||
<label><input type="radio" name="mode-1" value="webcodecs"><span>WebCodecs (железо)</span></label>
|
|
||||||
<label><input type="radio" name="mode-1" value="transcode"><span>Перекодировать</span></label>
|
<label><input type="radio" name="mode-1" value="transcode"><span>Перекодировать</span></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="tr-row"></div>
|
<div class="tr-row"></div>
|
||||||
@@ -178,9 +173,7 @@
|
|||||||
<div class="tr-block" data-tr="2">
|
<div class="tr-block" data-tr="2">
|
||||||
<h3>2 поток (суб)</h3>
|
<h3>2 поток (суб)</h3>
|
||||||
<div class="seg-toggle">
|
<div class="seg-toggle">
|
||||||
<label><input type="radio" name="mode-2" value="native"><span>Как есть</span></label>
|
|
||||||
<label><input type="radio" name="mode-2" value="player"><span>Встроенный плеер</span></label>
|
<label><input type="radio" name="mode-2" value="player"><span>Встроенный плеер</span></label>
|
||||||
<label><input type="radio" name="mode-2" value="webcodecs"><span>WebCodecs (железо)</span></label>
|
|
||||||
<label><input type="radio" name="mode-2" value="transcode"><span>Перекодировать</span></label>
|
<label><input type="radio" name="mode-2" value="transcode"><span>Перекодировать</span></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="tr-row"></div>
|
<div class="tr-row"></div>
|
||||||
@@ -189,7 +182,6 @@
|
|||||||
<div class="tr-block" data-tr="a">
|
<div class="tr-block" data-tr="a">
|
||||||
<h3>Архив</h3>
|
<h3>Архив</h3>
|
||||||
<div class="seg-toggle">
|
<div class="seg-toggle">
|
||||||
<label><input type="radio" name="mode-a" value="native"><span>Как есть</span></label>
|
|
||||||
<label><input type="radio" name="mode-a" value="player"><span>Встроенный плеер</span></label>
|
<label><input type="radio" name="mode-a" value="player"><span>Встроенный плеер</span></label>
|
||||||
<label><input type="radio" name="mode-a" value="transcode"><span>Перекодировать</span></label>
|
<label><input type="radio" name="mode-a" value="transcode"><span>Перекодировать</span></label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+124
-128
@@ -69,9 +69,9 @@ function transcodeQueryFor(which) { // строка запрос
|
|||||||
return `profile=${e(pr.profile)}&bitrate=${e(pr.bitrate)}&preset=${e(pr.preset)}` +
|
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)}`;
|
`&keyint=${e(pr.keyint)}&codec=${e(pr.codec)}&rc=${e(pr.rc)}&qp=${e(pr.qp)}`;
|
||||||
}
|
}
|
||||||
function profileMode(which) { // "native" | "player" | "webcodecs" | "transcode"
|
function profileMode(which) { // "player" | "transcode"
|
||||||
const m = transcodeProfile(which).mode;
|
// режимы "native" (как есть) и "webcodecs" (железо) упразднены → встроенный плеер
|
||||||
return (m === "transcode" || m === "native" || m === "webcodecs") ? m : "player";
|
return transcodeProfile(which).mode === "transcode" ? "transcode" : "player";
|
||||||
}
|
}
|
||||||
// раскладки: маппинг камер по ячейкам, на размерность (этот браузер)
|
// раскладки: маппинг камер по ячейкам, на размерность (этот браузер)
|
||||||
function getLayout(dim) {
|
function getLayout(dim) {
|
||||||
@@ -198,19 +198,6 @@ function connectStatusWS(onCameras) {
|
|||||||
ws.onclose = () => setTimeout(() => connectStatusWS(onCameras), 3000);
|
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 ──
|
// ── встроенный плеер: hevc.js (open-source WASM HEVC) в Web Worker, рендер в OffscreenCanvas ──
|
||||||
// Декод H.265 (включая тайлы) + рендер целиком в отдельном потоке: главный поток свободен,
|
// Декод H.265 (включая тайлы) + рендер целиком в отдельном потоке: главный поток свободен,
|
||||||
// сетка из нескольких камер не тормозит. Сервер отдаёт поток как есть (-c:v copy → 0% CPU).
|
// сетка из нескольких камер не тормозит. Сервер отдаёт поток как есть (-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";
|
const HEVC_WASM_BIN = "/static/vendor/hevcjs/dist/wasm/hevc-decode.wasm";
|
||||||
|
|
||||||
class HevcWasmPlayer {
|
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.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;
|
this.closed = false; this.worker = null; this.ws = null;
|
||||||
const cv = document.createElement("canvas");
|
const cv = document.createElement("canvas");
|
||||||
cv.className = "wc-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; }
|
try { this.worker = new Worker(HEVC_WORKER_URL, { type: "module" }); } catch (e) { this._fail("worker"); return; }
|
||||||
this.worker.onmessage = (e) => {
|
this.worker.onmessage = (e) => {
|
||||||
const m = e.data;
|
const m = e.data;
|
||||||
if (m.type === "ready") this._openWS();
|
if (m.type === "ready") {
|
||||||
else if (m.type === "err") console.warn("HEVC worker:", m.msg);
|
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.onerror = (e) => { console.warn("HEVC worker.onerror:", e && e.message); this._fail("worker-error"); };
|
||||||
this.worker.postMessage(
|
this.worker.postMessage(
|
||||||
@@ -251,6 +243,9 @@ class HevcWasmPlayer {
|
|||||||
this.ws.onclose = () => { /**/ };
|
this.ws.onclose = () => { /**/ };
|
||||||
} catch (e) { /**/ }
|
} 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) {
|
_fail(reason) {
|
||||||
if (this.closed) return;
|
if (this.closed) return;
|
||||||
const f = this.onFail; this.onFail = null;
|
const f = this.onFail; this.onFail = null;
|
||||||
@@ -293,55 +288,7 @@ function bindFullscreenKey() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// умеет ли браузер декодировать HEVC (H.265) сам — для нативного просмотра
|
// ── WebCodecs-плеер: декод на клиенте (для H.264 — единственный путь, H.265 идёт в WASM) ──
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// строка кодека из HEVCDecoderConfigurationRecord (hvcC)
|
// строка кодека из HEVCDecoderConfigurationRecord (hvcC)
|
||||||
function hevcCodecFromHvcC(hvcC) {
|
function hevcCodecFromHvcC(hvcC) {
|
||||||
try {
|
try {
|
||||||
@@ -352,11 +299,21 @@ function hevcCodecFromHvcC(hvcC) {
|
|||||||
} catch (e) { return "hvc1.1.6.L153.B0"; }
|
} 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 {
|
class FlvHevcDemuxer {
|
||||||
constructor({ onConfig, onSample }) {
|
constructor({ onConfig, onSample }) {
|
||||||
this.onConfig = onConfig; this.onSample = onSample;
|
this.onConfig = onConfig; this.onSample = onSample;
|
||||||
this.buf = new Uint8Array(0); this.headerDone = false; this.gotConfig = false;
|
this.buf = new Uint8Array(0); this.headerDone = false; this.gotConfig = false;
|
||||||
|
this.codecType = "hevc"; // 'hevc' | 'avc' — определяется из видеотега
|
||||||
}
|
}
|
||||||
push(ab) {
|
push(ab) {
|
||||||
const inc = new Uint8Array(ab);
|
const inc = new Uint8Array(ab);
|
||||||
@@ -395,7 +352,9 @@ class FlvHevcDemuxer {
|
|||||||
if (isEx) {
|
if (isEx) {
|
||||||
const frameType = (b0 >> 4) & 0x07, packetType = b0 & 0x0f;
|
const frameType = (b0 >> 4) & 0x07, packetType = b0 & 0x0f;
|
||||||
const fourcc = String.fromCharCode(body[1], body[2], body[3], body[4]);
|
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;
|
let off = 5;
|
||||||
if (packetType === 1) off += 3; // CompositionTime
|
if (packetType === 1) off += 3; // CompositionTime
|
||||||
const payload = body.subarray(off);
|
const payload = body.subarray(off);
|
||||||
@@ -403,16 +362,25 @@ class FlvHevcDemuxer {
|
|||||||
else if (packetType === 1 || packetType === 3) this._frames(payload, frameType === 1, tsMs);
|
else if (packetType === 1 || packetType === 3) this._frames(payload, frameType === 1, tsMs);
|
||||||
} else {
|
} else {
|
||||||
const codecId = b0 & 0x0f, frameType = (b0 >> 4) & 0x0f;
|
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);
|
const pkt = body[1], payload = body.subarray(5);
|
||||||
if (pkt === 0) this._config(payload);
|
if (pkt === 0) this._config(payload);
|
||||||
else if (pkt === 1) this._frames(payload, frameType === 1, tsMs);
|
else if (pkt === 1) this._frames(payload, frameType === 1, tsMs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_config(hvcC) {
|
_config(rec) {
|
||||||
if (this.gotConfig || hvcC.length < 13) return;
|
if (this.gotConfig) return;
|
||||||
this.gotConfig = true;
|
if (this.codecType === "avc") {
|
||||||
this.onConfig(hvcC.slice(), hevcCodecFromHvcC(hvcC));
|
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) {
|
_frames(payload, isKey, tsMs) {
|
||||||
if (!this.gotConfig || payload.length === 0) return;
|
if (!this.gotConfig || payload.length === 0) return;
|
||||||
@@ -421,9 +389,12 @@ class FlvHevcDemuxer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// плеер: FLV(ws) → FlvHevcDemuxer → VideoDecoder (HW) → <canvas>
|
// плеер: FLV(ws) → FlvHevcDemuxer → VideoDecoder (HW) → <canvas>
|
||||||
|
// Декодирует и HEVC (H.265), и AVC (H.264) — кодек берётся из config-пакета FLV.
|
||||||
|
// external:true — не открывать свой WS, поток подаётся через feed() (для SmartFlvPlayer).
|
||||||
class WebCodecsHevcPlayer {
|
class WebCodecsHevcPlayer {
|
||||||
constructor(box, wsUrl, onFail) {
|
constructor(box, wsUrl, onFail, opts) {
|
||||||
this.box = box; this.wsUrl = wsUrl; this.onFail = onFail || null;
|
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.closed = false; this.configured = false; this.pending = null; this.rafId = 0;
|
||||||
this.canvas = document.createElement("canvas");
|
this.canvas = document.createElement("canvas");
|
||||||
this.canvas.className = "wc-canvas";
|
this.canvas.className = "wc-canvas";
|
||||||
@@ -431,6 +402,7 @@ class WebCodecsHevcPlayer {
|
|||||||
this.ctx = this.canvas.getContext("2d", { alpha: false, desynchronized: true });
|
this.ctx = this.canvas.getContext("2d", { alpha: false, desynchronized: true });
|
||||||
this._start();
|
this._start();
|
||||||
}
|
}
|
||||||
|
feed(ab) { if (!this.closed) { try { this.demux.push(ab); } catch (e) { /**/ } } }
|
||||||
_fail(reason) {
|
_fail(reason) {
|
||||||
if (this.closed) return;
|
if (this.closed) return;
|
||||||
const f = this.onFail; this.onFail = null;
|
const f = this.onFail; this.onFail = null;
|
||||||
@@ -452,10 +424,10 @@ class WebCodecsHevcPlayer {
|
|||||||
} catch (e) { this._fail("ctor"); return; }
|
} catch (e) { this._fail("ctor"); return; }
|
||||||
|
|
||||||
this.demux = new FlvHevcDemuxer({
|
this.demux = new FlvHevcDemuxer({
|
||||||
onConfig: (hvcC, codec) => {
|
onConfig: (rec, codec, codecType) => {
|
||||||
console.log("WC: config received codec=", codec, "hvcClen=", hvcC.length);
|
console.log("WC: config received codec=", codec, "type=", codecType, "len=", rec.length);
|
||||||
try {
|
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;
|
this.configured = true;
|
||||||
console.log("WC: configure() ok, state=", this.decoder.state);
|
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 || "")); }
|
} 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 {
|
try {
|
||||||
this.ws = new WebSocket(this.wsUrl);
|
this.ws = new WebSocket(this.wsUrl);
|
||||||
this.ws.binaryType = "arraybuffer";
|
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
|
// Страница LIVE VIEW
|
||||||
// ════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════
|
||||||
@@ -518,11 +553,7 @@ const Live = {
|
|||||||
this.singleMain = opt.single_main_stream !== false; // по умолчанию вкл
|
this.singleMain = opt.single_main_stream !== false; // по умолчанию вкл
|
||||||
this.maxDim = opt.mode_16plus ? 8 : 4; // 16+ открывает 25..64
|
this.maxDim = opt.mode_16plus ? 8 : 4; // 16+ открывает 25..64
|
||||||
this.go2rtcUrl = (this.cameras[0] && this.cameras[0].live.go2rtc_url) || "";
|
this.go2rtcUrl = (this.cameras[0] && this.cameras[0].live.go2rtc_url) || "";
|
||||||
// встроенный плеер (hevc.js WASM) грузит свой worker по требованию — предзагрузка не нужна
|
// встроенный плеер (WASM/WebCodecs) грузит декодер по требованию — предзагрузка не нужна
|
||||||
if (this.go2rtcUrl && (this.mode1 === "native" || this.mode2 === "native")) { // «как есть»
|
|
||||||
try { await loadRtcComponent(this.go2rtcUrl); } catch (e) { console.warn(e); }
|
|
||||||
}
|
|
||||||
|
|
||||||
this.applyMaxDim();
|
this.applyMaxDim();
|
||||||
this.setDim(this.initialDim(), false);
|
this.setDim(this.initialDim(), false);
|
||||||
this.bindSubnav();
|
this.bindSubnav();
|
||||||
@@ -695,52 +726,22 @@ const Live = {
|
|||||||
const which = useMain ? "1" : "2"; // профиль потока
|
const which = useMain ? "1" : "2"; // профиль потока
|
||||||
const mode = profileMode(which);
|
const mode = profileMode(which);
|
||||||
if (mode === "transcode") return this.transcodeVideo(cam, useMain);
|
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 proto = location.protocol === "https:" ? "wss" : "ws";
|
||||||
const url = `${proto}://${location.host}/api/cameras/${cam.id}/flv?stream=${useMain ? "main" : "sub"}`;
|
const url = `${proto}://${location.host}/api/cameras/${cam.id}/flv?stream=${useMain ? "main" : "sub"}`;
|
||||||
const box = document.createElement("div");
|
const box = document.createElement("div");
|
||||||
box.className = "hevc-player";
|
box.className = "hevc-player";
|
||||||
const startWasm = () => { // встроенный плеер: hevc.js WASM в Web Worker
|
box.dataset.kind = "player";
|
||||||
box.dataset.kind = "wasm";
|
// встроенный плеер: автодетект кодека (H.265/H.265+ → WASM, H.264/H.264+ → WebCodecs браузера)
|
||||||
box._hevc = new HevcWasmPlayer(box, url, useMain, () => {
|
box._smart = new SmartFlvPlayer(box, url, useMain, () => {
|
||||||
if (box.isConnected) box.innerHTML = '<div class="empty">плеер не запустился</div>';
|
if (box.isConnected) box.innerHTML = '<div class="empty">плеер не запустился</div>';
|
||||||
});
|
});
|
||||||
};
|
|
||||||
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();
|
|
||||||
return box;
|
return box;
|
||||||
},
|
},
|
||||||
|
|
||||||
// уничтожить плеер-инстансы внутри ячейки (WASM-воркер/WebCodecs держат worker/decoder)
|
// уничтожить плеер-инстансы внутри ячейки (WASM-воркер/WebCodecs держат worker/decoder)
|
||||||
destroyTilePlayers(tile) {
|
destroyTilePlayers(tile) {
|
||||||
tile.querySelectorAll(".hevc-player").forEach((b) => {
|
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._wc) { try { b._wc.destroy(); } catch (e) { /* ignore */ } b._wc = null; }
|
||||||
if (b._hevc) { try { b._hevc.destroy(); } catch (e) { /* ignore */ } b._hevc = 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(d, tile.firstChild); return;
|
||||||
}
|
}
|
||||||
tile.insertBefore(p, tile.firstChild);
|
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() {
|
renderGrid() {
|
||||||
@@ -841,6 +835,7 @@ const Live = {
|
|||||||
resizePlayers() { // пересчитать canvas плееров после смены размера ячейки
|
resizePlayers() { // пересчитать canvas плееров после смены размера ячейки
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
document.querySelectorAll(".hevc-player").forEach((b) => {
|
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._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) { /**/ } }
|
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);
|
row.innerHTML = this.trRowHtml(pr);
|
||||||
// режим: проставить выбранный radio
|
// режим: проставить выбранный radio
|
||||||
const radios = block.querySelectorAll(`input[name="mode-${which}"]`);
|
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 syncMode = () => {
|
||||||
const sel = block.querySelector(`input[name="mode-${which}"]:checked`);
|
const sel = block.querySelector(`input[name="mode-${which}"]:checked`);
|
||||||
|
|||||||
Reference in New Issue
Block a user