"use strict"; // ── общие хелперы ─────────────────────────────────────────────── async function api(path) { const r = await fetch(path); if (r.status === 401) { location.href = "/login"; throw new Error("unauthorized"); } if (!r.ok) throw new Error(path + " -> " + r.status); return r.json(); } function post(path) { return fetch(path, { method: "POST" }); } // ── клиентские настройки (этот браузер), localStorage ─────────── const PREFS_KEY = "corevision.prefs"; // mode: "player" — свой плеер (клиентский WASM-декод H.265), "transcode" — серверный транскод const PROFILE_DEFAULT = { mode: "player", codec: "h264", profile: "main", bitrate: "2000k", preset: "superfast", keyint: 2, rc: "vbr", qp: 23 }; const PROF_KEY = { "1": "prof1", "2": "prof2", "a": "profA" }; // 1 поток / 2 поток / архив // общие пресеты перекодирования — выставляют все параметры профиля разом const TR_PRESETS = { SOFT: { codec: "vp9", profile: "baseline", bitrate: "500k", keyint: 2, preset: "ultrafast" }, MEDIUM: { codec: "h264", profile: "baseline", bitrate: "8000k", keyint: 2, preset: "ultrafast" }, HARD: { codec: "h264", profile: "main", bitrate: "16000k", keyint: 1, preset: "superfast" }, }; const TR_PRESET_COLORS = { SOFT: "#e03131", MEDIUM: "#e9c46a", HARD: "#ffffff" }; // цветомаркировка профилей function matchPreset(pr) { // имя пресета, совпадающего с параметрами return Object.keys(TR_PRESETS).find((k) => { const p = TR_PRESETS[k]; return p.codec === pr.codec && p.bitrate === pr.bitrate && +p.keyint === +pr.keyint && p.preset === pr.preset && (pr.codec === "vp9" || p.profile === pr.profile); }) || ""; } const PREFS_DEFAULT = { prof1: { ...PROFILE_DEFAULT, mode: "player" }, // 1 поток (основной) prof2: { ...PROFILE_DEFAULT, mode: "player" }, // 2 поток (суб) profA: { ...PROFILE_DEFAULT, mode: "transcode" }, // архив }; // ── текущий пользователь / роли ───────────────────────────────── const ROLE_RANK = { operator: 1, admin: 2, superadmin: 3 }; const ROLE_RU = { operator: "оператор", admin: "админ", superadmin: "суперадмин" }; let CURRENT_USER = null; async function fetchMe() { try { CURRENT_USER = await api("/api/me"); } catch (e) { CURRENT_USER = null; } return CURRENT_USER; } function roleAtLeast(min) { const r = CURRENT_USER && CURRENT_USER.role; return !!r && (ROLE_RANK[r] || 0) >= (ROLE_RANK[min] || 99); } function clientPrefs() { try { return { ...PREFS_DEFAULT, ...(JSON.parse(localStorage.getItem(PREFS_KEY)) || {}) }; } catch (e) { return { ...PREFS_DEFAULT }; } } function setClientPref(key, val) { const p = clientPrefs(); p[key] = val; localStorage.setItem(PREFS_KEY, JSON.stringify(p)); } function transcodeProfile(which) { // which: "1" | "2" | "a" const p = clientPrefs(); return { ...PROFILE_DEFAULT, ...(p[PROF_KEY[which]] || {}) }; } function setProfilePref(which, patch) { const p = clientPrefs(); p[PROF_KEY[which]] = { ...PROFILE_DEFAULT, ...(p[PROF_KEY[which]] || {}), ...patch }; localStorage.setItem(PREFS_KEY, JSON.stringify(p)); } function transcodeQueryFor(which) { // строка запроса под профиль const e = encodeURIComponent, pr = transcodeProfile(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 getLayout(dim) { try { return (JSON.parse(localStorage.getItem("corevision.layout")) || {})[dim] || null; } catch (e) { return null; } } function setLayout(dim, arr) { let m; try { m = JSON.parse(localStorage.getItem("corevision.layout")) || {}; } catch (e) { m = {}; } m[dim] = arr; localStorage.setItem("corevision.layout", JSON.stringify(m)); } const pad = (n) => String(n).padStart(2, "0"); function fmtTime(ts) { const d = new Date(ts * 1000); return pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds()); } function fmtDur(s) { if (!s) return "—"; const m = Math.floor(s / 60), sec = Math.round(s % 60); return m + ":" + pad(sec); } function fmtSize(b) { if (!b) return "—"; const mb = b / 1048576; return mb >= 1024 ? (mb / 1024).toFixed(1) + " GB" : mb.toFixed(0) + " MB"; } function fmtUptime(s) { const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60); if (d) return `${d}д ${h}ч ${m}м`; if (h) return `${h}ч ${m}м`; return `${m}м`; } const STATE_RU = { online: "запись", retrying: "переподключение", offline: "выключена", starting: "запуск" }; function dotClass(state) { return "dot " + (state || "offline"); } // ── шапка: часы + статистика + выход ──────────────────────────── function startClock() { const el = document.getElementById("clock"); if (!el) return; const tick = () => { const d = new Date(); el.textContent = pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds()); }; tick(); setInterval(tick, 1000); } function ensureVersionEl() { const aside = document.querySelector("aside"); if (aside && !document.getElementById("app-version")) { const d = document.createElement("div"); d.id = "app-version"; d.className = "app-version"; aside.appendChild(d); } } async function refreshStats() { const el = document.getElementById("sys-stats"); try { const s = await api("/api/system"); if (el) el.innerHTML = `CPU ${s.cpu_percent}%  RAM ${s.ram_percent}%` + `  Диск ${s.disk.used_gb}/${s.disk.total_gb} GB`; const v = document.getElementById("app-version"); if (v && s.version) v.textContent = "v" + s.version; } catch (e) { /* нет связи / 401 — молчим */ } } // ограничение подменю раскладок (режим 16+) — на любой странице с .subnav async function applyGridMenuLimit() { const items = document.querySelectorAll(".subnav .subitem[data-grid]"); if (!items.length) return; let maxDim = 4; try { const opt = await api("/api/optimization"); maxDim = opt.mode_16plus ? 8 : 4; } catch (e) { /* нет связи — лимит 16 */ } items.forEach((a) => { a.hidden = +a.dataset.grid > maxDim; }); } function bindLogout() { const btn = document.getElementById("logout"); if (!btn) return; btn.onclick = async () => { await post("/api/logout"); location.href = "/login"; }; } // ── статусный WebSocket (общий) ───────────────────────────────── function connectStatusWS(onCameras) { const proto = location.protocol === "https:" ? "wss" : "ws"; const ws = new WebSocket(`${proto}://${location.host}/api/ws`); ws.onmessage = (ev) => { const msg = JSON.parse(ev.data); if (msg.type === "status") onCameras(msg.cameras); else if (msg.type === "camera") onCameras([msg.camera]); }; 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). const HEVC_WORKER_URL = "/static/vendor/hevcjs/hevc-player-worker.js"; 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) { this.box = box; this.wsUrl = wsUrl; this.onFail = onFail || null; this.closed = false; this.worker = null; this.ws = null; const cv = document.createElement("canvas"); cv.className = "wc-canvas"; // размер буфера рендера: main крупнее (для разворота на весь экран), sub мелкий cv.width = useMain ? 1600 : 640; cv.height = useMain ? 900 : 360; box.appendChild(cv); this.canvas = cv; let off; try { off = cv.transferControlToOffscreen(); } catch (e) { this._fail("offscreen"); return; } 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); }; this.worker.onerror = (e) => { console.warn("HEVC worker.onerror:", e && e.message); this._fail("worker-error"); }; this.worker.postMessage( { type: "init", canvas: off, wasmUrl: HEVC_WASM_JS, wasmBinaryUrl: HEVC_WASM_BIN }, [off]); } _openWS() { if (this.closed) return; try { this.ws = new WebSocket(this.wsUrl); this.ws.binaryType = "arraybuffer"; this.ws.onmessage = (ev) => { if (!this.closed && this.worker) this.worker.postMessage({ type: "data", buf: ev.data }, [ev.data]); }; this.ws.onerror = () => { /**/ }; this.ws.onclose = () => { /**/ }; } catch (e) { /**/ } } _fail(reason) { if (this.closed) return; const f = this.onFail; this.onFail = null; this.destroy(); if (f) f(reason); } resize() { /* canvas масштабируется CSS (object-fit), буфер рендера фиксирован */ } 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.worker) { try { this.worker.postMessage({ type: "stop" }); } catch (e) { /**/ } try { this.worker.terminate(); } catch (e) { /**/ } this.worker = null; } } } function toggleFullscreen(el) { const fsEl = document.fullscreenElement || document.webkitFullscreenElement; if (fsEl) { (document.exitFullscreen || document.webkitExitFullscreen).call(document); } else if (el.requestFullscreen || el.webkitRequestFullscreen) { (el.requestFullscreen || el.webkitRequestFullscreen).call(el); } } // умеет ли браузер декодировать 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; } // строка кодека из HEVCDecoderConfigurationRecord (hvcC) function hevcCodecFromHvcC(hvcC) { try { const profileIdc = hvcC[1] & 0x1f; const tier = ((hvcC[1] >> 5) & 1) ? "H" : "L"; const level = hvcC[12] || 153; return `hvc1.${profileIdc || 1}.6.${tier}${level}.B0`; } catch (e) { return "hvc1.1.6.L153.B0"; } } // демуксер FLV (enhanced-RTMP) → hvcC + length-prefixed HEVC-кадры class FlvHevcDemuxer { constructor({ onConfig, onSample }) { this.onConfig = onConfig; this.onSample = onSample; this.buf = new Uint8Array(0); this.headerDone = false; this.gotConfig = false; } push(ab) { const inc = new Uint8Array(ab); if (this.buf.length === 0) { this.buf = inc; } else { const m = new Uint8Array(this.buf.length + inc.length); m.set(this.buf, 0); m.set(inc, this.buf.length); this.buf = m; } this._parse(); } _u32(o) { return ((this.buf[o] << 24) | (this.buf[o + 1] << 16) | (this.buf[o + 2] << 8) | this.buf[o + 3]) >>> 0; } _parse() { const b = this.buf, len = b.length; if (!this.headerDone) { if (len < 9) return; const dataOffset = this._u32(5); // обычно 9 this.buf = b.slice(dataOffset); this.headerDone = true; return this._parse(); } let o = 0; while (true) { if (o + 4 + 11 > len) break; // 4 байта PrevTagSize + 11 байт TagHeader const ts = o + 4; const tagType = b[ts] & 0x1f; const dataSize = (b[ts + 1] << 16) | (b[ts + 2] << 8) | b[ts + 3]; const bodyStart = ts + 11, bodyEnd = bodyStart + dataSize; if (bodyEnd > len) break; // тег ещё не дочитан if (tagType === 9) { const tsMs = ((b[ts + 7] << 24) >>> 0) | (b[ts + 4] << 16) | (b[ts + 5] << 8) | b[ts + 6]; this._videoTag(b.subarray(bodyStart, bodyEnd), tsMs); } o = bodyEnd; } if (o > 0) this.buf = b.slice(o); } _videoTag(body, tsMs) { if (body.length < 5) return; const b0 = body[0], isEx = (b0 & 0x80) !== 0; 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; let off = 5; if (packetType === 1) off += 3; // CompositionTime const payload = body.subarray(off); if (packetType === 0) this._config(payload); 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 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)); } _frames(payload, isKey, tsMs) { if (!this.gotConfig || payload.length === 0) return; this.onSample({ data: payload.slice(), type: isKey ? "key" : "delta", timestampUs: tsMs * 1000 }); } } // плеер: FLV(ws) → FlvHevcDemuxer → VideoDecoder (HW) → class WebCodecsHevcPlayer { constructor(box, wsUrl, onFail) { this.box = box; this.wsUrl = wsUrl; this.onFail = onFail || null; this.closed = false; this.configured = false; this.pending = null; this.rafId = 0; this.canvas = document.createElement("canvas"); this.canvas.className = "wc-canvas"; box.appendChild(this.canvas); this.ctx = this.canvas.getContext("2d", { alpha: false, desynchronized: true }); this._start(); } _fail(reason) { if (this.closed) return; const f = this.onFail; this.onFail = null; this.destroy(); if (f) f(reason); } _start() { if (!("VideoDecoder" in window)) { this._fail("no-webcodecs"); return; } try { this.decoder = new VideoDecoder({ output: (frame) => { if (this.closed) { frame.close(); return; } if (this.pending) this.pending.close(); // живое видео — держим только последний кадр this.pending = frame; if (!this.rafId) this.rafId = requestAnimationFrame(() => this._paint()); }, error: (e) => { console.warn("VideoDecoder", e && e.message); this._fail("decode-error"); }, }); } catch (e) { this._fail("ctor"); return; } this.demux = new FlvHevcDemuxer({ onConfig: (hvcC, codec) => { console.log("WC: config received codec=", codec, "hvcClen=", hvcC.length); try { this.decoder.configure({ codec, description: hvcC, 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 || "")); } }, onSample: ({ data, type, timestampUs }) => { if (!this.configured || this.closed || this.decoder.state !== "configured") return; // отстаём → ждём ближайший ключевой кадр (чистая граница GOP, без битых ссылок) if (this.decoder.decodeQueueSize > 6) this.waitKey = true; if (this.waitKey) { if (type !== "key") return; this.waitKey = false; } try { this.decoder.decode(new EncodedVideoChunk({ type, timestamp: timestampUs, data })); if (!this._fed) { this._fed = true; console.log("WC: first decode() sent, type=", type, "bytes=", data.length); } } catch (e) { console.warn("WC: decode threw:", e && (e.message || e)); } }, }); try { this.ws = new WebSocket(this.wsUrl); this.ws.binaryType = "arraybuffer"; this.ws.onmessage = (ev) => { if (!this.closed) { try { this.demux.push(ev.data); } catch (e) { /**/ } } }; this.ws.onerror = () => { /**/ }; this.ws.onclose = () => { if (!this.closed && !this.configured) this._fail("ws-closed"); }; } catch (e) { this._fail("ws"); } } _paint() { this.rafId = 0; const f = this.pending; this.pending = null; if (!f) return; if (this.canvas.width !== f.displayWidth) this.canvas.width = f.displayWidth; if (this.canvas.height !== f.displayHeight) this.canvas.height = f.displayHeight; try { this.ctx.drawImage(f, 0, 0); } catch (e) { /**/ } f.close(); } resize() { /* canvas масштабируется CSS object-fit, явный ресайз не нужен */ } destroy() { this.closed = true; if (this.rafId) { cancelAnimationFrame(this.rafId); this.rafId = 0; } if (this.pending) { try { this.pending.close(); } catch (e) { /**/ } this.pending = null; } try { if (this.ws) { this.ws.onmessage = null; this.ws.onclose = null; this.ws.close(); } } catch (e) { /**/ } try { if (this.decoder && this.decoder.state !== "closed") this.decoder.close(); } catch (e) { /**/ } } } // ════════════════════════════════════════════════════════════════ // Страница LIVE VIEW // ════════════════════════════════════════════════════════════════ const Live = { cameras: [], dim: 2, layout: 4, transcode: false, go2rtcUrl: "", selected: [], singleMain: false, maxDim: 8, playing: true, async init() { const data = await api("/api/cameras"); this.cameras = data.cameras; // режим каждого потока (свой плеер / транскод) — настройка этого браузера this.mode1 = profileMode("1"); // 1 поток (основной) this.mode2 = profileMode("2"); // 2 поток (суб) // серверная политика (общая для всех клиентов) let opt = {}; try { opt = await api("/api/optimization"); } catch (e) { /* дефолты */ } 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); } } this.applyMaxDim(); this.setDim(this.initialDim(), false); this.bindSubnav(); this.bindFullscreenResize(); this.renderGrid(); connectStatusWS((cams) => { cams.forEach((u) => { const c = this.cameras.find((x) => x.id === u.camera_id); if (c) c.status = u; }); this.updateDots(); }); }, applyMaxDim() { // скрыть пункты подменю с числом больше лимита (режим 16+) document.querySelectorAll(".subnav .subitem[data-grid]").forEach((a) => { a.hidden = +a.dataset.grid > this.maxDim; }); }, initialDim() { const max = this.maxDim || 8; const q = parseInt(new URLSearchParams(location.search).get("grid"), 10); if (q >= 2 && q <= 8) return Math.min(q, max); const saved = parseInt(localStorage.getItem("corevision.grid"), 10); return saved >= 2 ? Math.min(saved, max) : 2; }, setDim(dim, rerender = true) { this.dim = Math.min(this.maxDim || 8, Math.max(2, dim)); this.layout = this.dim * this.dim; localStorage.setItem("corevision.grid", String(this.dim)); // приоритет — сохранённая раскладка (страница "Раскладка"); иначе автозаполнение const saved = getLayout(this.dim); if (Array.isArray(saved) && saved.length === this.layout) { this.selected = saved.slice(); } else { this.selected = this.selected.slice(0, this.layout); while (this.selected.length < this.layout) { const next = this.cameras.find((c) => !this.selected.includes(c.id)); if (!next) break; this.selected.push(next.id); } } document.querySelectorAll("#live-subnav .subitem, .subnav .subitem").forEach((a) => a.classList.toggle("active", +a.dataset.grid === this.dim)); if (rerender) this.renderGrid(); }, bindSubnav() { document.querySelectorAll(".subnav .subitem[data-grid]").forEach((a) => { a.onclick = (e) => { e.preventDefault(); // на странице live меняем без перезагрузки history.replaceState(null, "", "/?grid=" + a.dataset.grid); this.setDim(+a.dataset.grid); }; }); const fs = document.getElementById("fs-grid"); if (fs) fs.onclick = (e) => { e.preventDefault(); toggleFullscreen(document.getElementById("grid")); }; const pt = document.getElementById("play-toggle"); if (pt) { this.updatePlayBtn(); pt.onclick = (e) => { e.preventDefault(); this.setPlaying(!this.playing); }; } }, bindFullscreenResize() { if (this._fsBound) return; this._fsBound = true; const onFs = () => { // выход из фуллскрина (в т.ч. по ESC) — снять «соло», вернуться к сетке if (!(document.fullscreenElement || document.webkitFullscreenElement)) this.clearSolo(); this.resizePlayers(); }; document.addEventListener("fullscreenchange", onFs); document.addEventListener("webkitfullscreenchange", onFs); }, updatePlayBtn() { const pt = document.getElementById("play-toggle"); if (pt) { pt.textContent = this.playing ? "⏹ Стоп" : "▶ Просмотр"; pt.classList.toggle("active", !this.playing); } }, setPlaying(on) { this.playing = on; this.updatePlayBtn(); this.renderGrid(); // playing=false → плееры не создаются, сетка останавливается }, transcodeVideo(cam, useMain) { // серверный транскод H.264/VP9 →