Compare commits

...

1 Commits

Author SHA1 Message Date
git_admin aa290a51b7 v160 2026-06-03 16:09:49 +05:00
3 changed files with 131 additions and 143 deletions
+1 -1
View File
@@ -1 +1 @@
0.0.158
0.0.160
+6 -14
View File
@@ -153,23 +153,18 @@
<div class="card">
<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">
Для каждого потока выберите способ воспроизведения: <b>как есть</b>
поток отдаётся без обработки (ffmpeg -c copy), браузер декодирует HEVC сам
(нужна аппаратная поддержка); <b>встроенный плеер</b> — декодирует H.265
в браузере софтом (WASM hevc.js, в Web Worker), тянет и основной поток
(3200×1800, включая тайлы), и субпоток, без нагрузки на сервер (декод
нагружает CPU клиента); <b>WebCodecs (железо)</b> — аппаратный декод H.265 на GPU клиента,
тянет основной поток 3200×1800 при 0% CPU сервера (рекомендуется для
основного потока); <b>перекодировать</b> — сервер транскодирует в H.264 /
VP9 (нагрузка на CPU сервера, для слабых клиентов).
Для каждого потока выберите способ воспроизведения: <b>встроенный плеер</b>
декодирует поток прямо в браузере без нагрузки на сервер (ffmpeg -c copy):
H.265/H.265+ — софтом (WASM hevc.js в Web Worker), H.264/H.264+ — средствами
браузера; тянет и основной поток (3200×1800, включая тайлы), и субпоток
(декод нагружает CPU клиента); <b>перекодировать</b> — сервер транскодирует
в H.264 / VP9 (нагрузка на CPU сервера, для слабых клиентов).
</div>
<!-- 3 независимых профиля: 1 поток / 2 поток / архив -->
<div class="tr-block" data-tr="1">
<h3>1 поток (основной)</h3>
<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="webcodecs"><span>WebCodecs (железо)</span></label>
<label><input type="radio" name="mode-1" value="transcode"><span>Перекодировать</span></label>
</div>
<div class="tr-row"></div>
@@ -178,9 +173,7 @@
<div class="tr-block" data-tr="2">
<h3>2 поток (суб)</h3>
<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="webcodecs"><span>WebCodecs (железо)</span></label>
<label><input type="radio" name="mode-2" value="transcode"><span>Перекодировать</span></label>
</div>
<div class="tr-row"></div>
@@ -189,7 +182,6 @@
<div class="tr-block" data-tr="a">
<h3>Архив</h3>
<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="transcode"><span>Перекодировать</span></label>
</div>
+121 -125
View File
@@ -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;
_config(rec) {
if (this.gotConfig) return;
if (this.codecType === "avc") {
if (rec.length < 7) return;
this.gotConfig = true;
this.onConfig(hvcC.slice(), hevcCodecFromHvcC(hvcC));
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) → <canvas>
// Декодирует и 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, () => {
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 = '<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;
},
// уничтожить плеер-инстансы внутри ячейки (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`);