diff --git a/.smallclaw/config.json b/.smallclaw/config.json index aa41b88..625bffd 100644 --- a/.smallclaw/config.json +++ b/.smallclaw/config.json @@ -388,7 +388,8 @@ "password": "vault:nvr.extraCameras.1000.password", "rtspPath": "/stream1", "ptz": true, - "onvifPort": 2020 + "onvifPort": 2020, + "go2rtcSrc": "cam" } ] }, diff --git a/src/gateway/routes/nvr.ts b/src/gateway/routes/nvr.ts index a142476..99ad7bc 100644 --- a/src/gateway/routes/nvr.ts +++ b/src/gateway/routes/nvr.ts @@ -1,5 +1,7 @@ import { Express, Request, Response } from 'express'; import { spawn, ChildProcess } from 'child_process'; +import http from 'http'; +import net from 'net'; import { getConfig } from '../../config/config.js'; // eslint-disable-next-line @typescript-eslint/no-var-requires const { Cam: OnvifCam } = require('onvif'); @@ -52,6 +54,7 @@ interface ExtraCamera { rotate180: boolean; ptz: boolean; onvifPort: number; + go2rtcSrc: string; } export function getExtraCameras(): ExtraCamera[] { const cm = getConfig(); @@ -73,6 +76,10 @@ export function getExtraCameras(): ExtraCamera[] { // MC510은 2020번, RTSP/HTTP 포트와 별개). WS-Security 인증은 RTSP와 같은 계정 사용(2026-07-23). ptz: c.ptz === true, onvifPort: Number(c.onvifPort) || 80, + // go2rtc(클로서버:1985)에 이 카메라가 별도 릴레이로 등록돼 있으면 스트림 이름(예: "cam"). + // 있으면 프런트에서 ffmpeg-MJPEG 대신 go2rtc의 video-stream(WebRTC→MSE→MJPEG 자동폴백)을 + // 써서 대역폭을 크게 줄인다(2026-09-18, 프린터실 카메라 원격뷰 대역폭 이슈). + go2rtcSrc: String(c.go2rtcSrc || ''), })); } @@ -313,6 +320,8 @@ export function registerNvrRoutes(app: Express): void { charging: false, standalone: true, rotate180: c.rotate180, + ptz: c.ptz, + go2rtcSrc: c.go2rtcSrc || undefined, })); channels.push(...extra); res.json({ channels, ...(nvrError ? { nvrError } : {}) }); @@ -874,3 +883,54 @@ export function registerNvrRoutes(app: Express): void { } }); } + +const GO2RTC_HOST = '127.0.0.1'; +const GO2RTC_PORT = 1985; +const GO2RTC_WS_PREFIX = '/api/nvr/go2rtc-ws'; + +// go2rtc의 WebSocket 엔드포인트(/api/ws)는 Origin이 자기 자신(호스트:포트)과 다르면 +// "request origin not allowed by Upgrader.CheckOrigin"로 거부한다 — 브라우저가 이 +// 게이트웨이(18789) 페이지에서 곧바로 go2rtc(1985)에 붙으면 항상 걸림. 그래서 같은 +// 오리진(게이트웨이)으로 먼저 붙게 하고, 여기서 백엔드로 넘길 때 Host/Origin을 +// go2rtc 자신의 주소로 다시 써서 그 검사를 통과시킨다(2026-09-18, video-stream.js +// WebRTC/MSE 도입 때 발견). +export function attachGo2rtcWsProxy(server: http.Server, getSessionUserFromUpgradeReq: (req: http.IncomingMessage) => any): void { + server.on('upgrade', (req, clientSocket, head) => { + const url = req.url || ''; + if (!url.startsWith(GO2RTC_WS_PREFIX)) return; // 우리 것 아니면 다른 upgrade 리스너에게 넘김 + + const session = getSessionUserFromUpgradeReq(req); + if (!session) { + clientSocket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); + clientSocket.destroy(); + return; + } + + const backendSocket = net.connect({ host: GO2RTC_HOST, port: GO2RTC_PORT }, () => { + const qs = url.includes('?') ? url.slice(url.indexOf('?')) : ''; + const backendHost = `${GO2RTC_HOST}:${GO2RTC_PORT}`; + const headerLines = [`${req.method} /api/ws${qs} HTTP/1.1`]; + for (let i = 0; i < req.rawHeaders.length; i += 2) { + const name = req.rawHeaders[i]; + if (/^host$/i.test(name)) { headerLines.push(`Host: ${backendHost}`); continue; } + if (/^origin$/i.test(name)) { headerLines.push(`Origin: http://${backendHost}`); continue; } + headerLines.push(`${name}: ${req.rawHeaders[i + 1]}`); + } + backendSocket.write(headerLines.join('\r\n') + '\r\n\r\n'); + if (head && head.length) backendSocket.write(head); + + clientSocket.pipe(backendSocket); + backendSocket.pipe(clientSocket); + + const cleanup = () => { + try { clientSocket.destroy(); } catch {} + try { backendSocket.destroy(); } catch {} + }; + clientSocket.on('close', cleanup); + clientSocket.on('error', cleanup); + backendSocket.on('close', cleanup); + backendSocket.on('error', cleanup); + }); + backendSocket.on('error', () => { try { clientSocket.destroy(); } catch {} }); + }); +} diff --git a/src/gateway/routes/routes-k2.ts b/src/gateway/routes/routes-k2.ts new file mode 100644 index 0000000..fb2c5f4 --- /dev/null +++ b/src/gateway/routes/routes-k2.ts @@ -0,0 +1,31 @@ +import express, { Express, Request, Response } from 'express'; + +// K2 콤보(Creality) 프린터 내장 카메라는 자체 WebRTC 시그널링을 평문 HTTP로만 +// 서빙한다(:8000/call/webrtc_local). 홈클로 자체는 https://ai.applecherry.net로 +// 서빙되므로, 그 HTTPS 페이지에서 이 HTTP 엔드포인트로 직접 fetch(POST)하면 +// 브라우저의 mixed-content 정책에 막혀 요청 자체가 안 나간다(주소창 "주의 요함" + +// 카메라 화면 검은 채로 멈춤, 2026-09-18 발견). 그래서 이 엔드포인트를 우리 서버가 +// 대신 호출해주는 얇은 프록시로 우회한다 — 서버-서버 호출은 스킴 제약이 없다. +const K2_CAM_HOST = '192.168.0.114'; +const K2_CAM_PORT = 8000; + +export function registerK2Routes(app: Express): void { + // 클라이언트가 'text/plain'으로 보내는데 서버 전역엔 json/urlencoded 파서만 등록돼 + // 있어(server.ts) req.body가 안 채워짐 — 이 라우트에서만 원본 바디를 텍스트로 받는다. + app.post('/api/k2/webrtc-signal', express.text({ type: () => true, limit: '2mb' }), async (req: Request, res: Response) => { + const user = (req as any).user; + if (!user) return res.status(401).json({ error: 'Unauthorized' }); + try { + const body = typeof req.body === 'string' ? req.body : String(req.body ?? ''); + const r = await fetch(`http://${K2_CAM_HOST}:${K2_CAM_PORT}/call/webrtc_local`, { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body, + }); + const text = await r.text(); + res.status(r.status).type('text/plain').send(text); + } catch (e: any) { + res.status(502).json({ error: String(e?.message || e) }); + } + }); +} diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 6a4a9b7..e3df8b0 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -131,7 +131,8 @@ import { summarizeSkillForApi } from '../tools/skills.js'; import { getToolRegistry } from '../tools/registry.js'; import { registerSettingsRoutes } from './routes/settings.js'; import { registerImagegenRoutes } from './routes/imagegen.js'; -import { registerNvrRoutes } from './routes/nvr.js'; +import { registerNvrRoutes, attachGo2rtcWsProxy } from './routes/nvr.js'; +import { registerK2Routes } from './routes/routes-k2.js'; import { browserOpen, browserSnapshot, @@ -3561,6 +3562,7 @@ async function runTaskHeartbeat(): Promise { registerSettingsRoutes(app); registerImagegenRoutes(app); registerNvrRoutes(app); +registerK2Routes(app); // Fetch available Ollama models (proxies Ollama /api/tags), with vision capability flag app.get('/api/ollama/models', async (_req, res) => { @@ -4646,6 +4648,7 @@ attachAndroidWsProxy(server, getSessionUser); attachAndroidConsoleWsProxy(server, getSessionUser); attachComfyUIWsProxy(server, getSessionUserFromUpgradeReq); attachVoiceRealtimeWsProxy(server, getSessionUserFromUpgradeReq); +attachGo2rtcWsProxy(server, getSessionUserFromUpgradeReq); wss.on('error', (err: any) => { if (err?.code === 'EADDRINUSE') { console.error(`[Gateway] Port ${HOST}:${PORT} is already in use.`); diff --git a/web-ui/html/index.html b/web-ui/html/index.html index 177e59c..63ee928 100644 --- a/web-ui/html/index.html +++ b/web-ui/html/index.html @@ -93,8 +93,6 @@
- -
diff --git a/web-ui/html/k2-app.html b/web-ui/html/k2-app.html new file mode 100644 index 0000000..bb90d56 --- /dev/null +++ b/web-ui/html/k2-app.html @@ -0,0 +1,114 @@ + + + + + +🖨️ K2 콤보 + + + + + + +
+ ← 홈 +

🖨️ K2 콤보

+ + + + ↗ 새 탭 + +
+ +
+
+ + 연결 중... +
+
+ +
+
Fluidd를 불러오지 못했습니다 (K2 콤보, 192.168.0.114:4408).
+
새 탭에서 직접 열기 ↗
+
+
+
+ + + + diff --git a/web-ui/html/nvr-app.html b/web-ui/html/nvr-app.html index ef9bfad..2bcadb9 100644 --- a/web-ui/html/nvr-app.html +++ b/web-ui/html/nvr-app.html @@ -117,7 +117,9 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis .rec-table{width:100%;border-collapse:collapse;font-size:11px;} .rec-table th{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line);color:var(--muted);font-weight:700;} .rec-table td{padding:6px 8px;border-bottom:1px solid var(--line);} +video-stream{width:100%;height:100%;display:block;background:#000;} +
@@ -242,6 +244,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis +
@@ -290,6 +293,10 @@ function killImg(img,channelId,streamQ){ // multipart fetch 중단 + 서버 즉 } let currentLiveChannel=null; let currentLiveStream=0; +let currentLiveMode='mjpeg'; // 'mjpeg' | 'go2rtc' — go2rtcSrc 있는 카메라는 video-stream(WebRTC→MSE 자동폴백)으로 대역폭 절약 +// go2rtc(1985)에 브라우저가 직접 붙으면 Origin 불일치로 거부당해(CheckOrigin) 게이트웨이의 +// 프록시(/api/nvr/go2rtc-ws)를 거친다 — 같은 오리진으로 붙게 한 뒤 서버에서 Origin을 다시 씀. +const GO2RTC_WS='ws://192.168.0.5:18789/api/nvr/go2rtc-ws'; // 채널별로 강제 지정된 화질(preferredStream)이 있으면 그걸 쓰고, 없으면 null(화면 기본값 사용). // 신호 약한 카메라는 메인이 계속 끊겨서 서브로 고정해두는 용도(2026-07-21). function chnStreamOverride(channelId){ @@ -322,14 +329,29 @@ function openLive(channelId,btn){ const titleEl=document.getElementById('live-title'); errEl.style.display='none';errEl.textContent=''; titleEl.textContent=(btn?btn.getAttribute('data-name')||('채널 '+channelId):('채널 '+channelId))+(currentLiveStream?' (저화질 고정)':''); - img.style.display='block'; - img.onerror=()=>{ // 서버가 502 JSON을 보내거나 프레임이 안 오면 img가 깨짐 + const vs=document.getElementById('live-vs'); + const ch=lastChannels.find(x=>x.id===channelId); + if(ch && ch.go2rtcSrc){ + // go2rtc가 릴레이 중인 카메라 — WebRTC/MSE(브라우저 네이티브 H.264)로 재생, MJPEG보다 + // 대역폭이 훨씬 적게 듦(2026-09-18, 원격뷰 대역폭 이슈로 도입). ffmpeg 파이프라인은 안 씀. + currentLiveMode='go2rtc'; img.style.display='none'; - errEl.textContent='RTSP 영상을 불러오지 못했습니다. NVR 설정(호스트·계정·비밀번호)과 RTSP 경로를 확인하세요.'; - errEl.style.display='block'; - }; - // 캐시 방지 위해 채널 id + 타임스탵 쿼리(같은 src 재사용시 onerror 안 트리는 브라우저 대비) - img.src='/api/nvr/stream/'+channelId+(currentLiveStream?'?stream=1':''); + img.src=''; + vs.style.display='block'; + vs.src=GO2RTC_WS+'?src='+encodeURIComponent(ch.go2rtcSrc)+'&token='+encodeURIComponent(getToken()); + } else { + currentLiveMode='mjpeg'; + vs.style.display='none'; + vs.src=''; + img.style.display='block'; + img.onerror=()=>{ // 서버가 502 JSON을 보내거나 프레임이 안 오면 img가 깨짐 + img.style.display='none'; + errEl.textContent='RTSP 영상을 불러오지 못했습니다. NVR 설정(호스트·계정·비밀번호)과 RTSP 경로를 확인하세요.'; + errEl.style.display='block'; + }; + // 캐시 방지 위해 채널 id + 타임스탵 쿼리(같은 src 재사용시 onerror 안 트리는 브라우저 대비) + img.src='/api/nvr/stream/'+channelId+(currentLiveStream?'?stream=1':''); + } modal.classList.add('show'); } function closeLive(){ @@ -338,7 +360,13 @@ function closeLive(){ currentLiveChannel=null; const modal=document.getElementById('live-modal'); const img=document.getElementById('live-img'); - killImg(img,closingChannel,closingStream); // 연결 종료 + 서버 즉시-컷 + const vs=document.getElementById('live-vs'); + if(currentLiveMode==='go2rtc'){ + try{ vs.disconnectedCallback(); }catch(e){} // WS/WebRTC 연결 정리(라이브러리 내부 정리 로직 재사용) + vs.style.display='none'; + } else { + killImg(img,closingChannel,closingStream); // 연결 종료 + 서버 즉시-컷 + } modal.classList.remove('show'); // 모달을 위해 멈췄던 5분할 재개(채널 탭이 보이는 중이고 재생중이었으면). if(quadPlaying && isChnActive()){ startQuadStreams(); } diff --git a/web-ui/html/printer-app.html b/web-ui/html/printer-app.html deleted file mode 100644 index 661b7b8..0000000 --- a/web-ui/html/printer-app.html +++ /dev/null @@ -1,54 +0,0 @@ - - - - - -🖨️ 3D 프린터 - - - - - - -
- ← 홈 -

🖨️ 3D 프린터

- - - ↗ 새 탭 - -
- -
- -
-
OctoPrint를 불러오지 못했습니다 (2층 작업실 HP 프로데스크, 192.168.0.3:5000).
- -
-
- - - - diff --git a/web-ui/vendor/go2rtc/video-rtc.js b/web-ui/vendor/go2rtc/video-rtc.js new file mode 100644 index 0000000..953fdae --- /dev/null +++ b/web-ui/vendor/go2rtc/video-rtc.js @@ -0,0 +1,695 @@ +/** + * VideoRTC v1.6.0 - Video player for go2rtc streaming application. + * + * All modern web technologies are supported in almost any browser except Apple Safari. + * + * Support: + * - ECMAScript 2017 (ES8) = ES6 + async + * - RTCPeerConnection for Safari iOS 11.0+ + * - IntersectionObserver for Safari iOS 12.2+ + * - ManagedMediaSource for Safari 17+ + * + * Doesn't support: + * - MediaSource for Safari iOS + * - Customized built-in elements (extends HTMLVideoElement) because Safari + * - Autoplay for WebRTC in Safari + */ +export class VideoRTC extends HTMLElement { + constructor() { + super(); + + this.DISCONNECT_TIMEOUT = 5000; + this.RECONNECT_TIMEOUT = 15000; + + this.CODECS = [ + 'avc1.640029', // H.264 high 4.1 (Chromecast 1st and 2nd Gen) + 'avc1.64002A', // H.264 high 4.2 (Chromecast 3rd Gen) + 'avc1.640033', // H.264 high 5.1 (Chromecast with Google TV) + 'hvc1.1.6.L153.B0', // H.265 main 5.1 (Chromecast Ultra) + 'mp4a.40.2', // AAC LC + 'mp4a.40.5', // AAC HE + 'flac', // FLAC (PCM compatible) + 'opus', // OPUS Chrome, Firefox + ]; + + /** + * [config] Supported modes (webrtc, webrtc/tcp, mse, hls, mp4, mjpeg). + * @type {string} + */ + this.mode = 'webrtc,mse,hls,mjpeg'; + + /** + * [Config] Requested medias (video, audio, microphone). + * @type {string} + */ + this.media = 'video,audio'; + + /** + * [config] Run stream when not displayed on the screen. Default `false`. + * @type {boolean} + */ + this.background = false; + + /** + * [config] Run stream only when player in the viewport. Stop when user scroll out player. + * Value is percentage of visibility from `0` (not visible) to `1` (full visible). + * Default `0` - disable; + * @type {number} + */ + this.visibilityThreshold = 0; + + /** + * [config] Run stream only when browser page on the screen. Stop when user change browser + * tab or minimise browser windows. + * @type {boolean} + */ + this.visibilityCheck = true; + + /** + * [config] WebRTC configuration + * @type {RTCConfiguration} + */ + this.pcConfig = { + bundlePolicy: 'max-bundle', + iceServers: [{urls: ['stun:stun.cloudflare.com:3478', 'stun:stun.l.google.com:19302']}], + sdpSemantics: 'unified-plan', // important for Chromecast 1 + }; + + /** + * [info] WebSocket connection state. Values: CONNECTING, OPEN, CLOSED + * @type {number} + */ + this.wsState = WebSocket.CLOSED; + + /** + * [info] WebRTC connection state. + * @type {number} + */ + this.pcState = WebSocket.CLOSED; + + /** + * @type {HTMLVideoElement} + */ + this.video = null; + + /** + * @type {WebSocket} + */ + this.ws = null; + + /** + * @type {string|URL} + */ + this.wsURL = ''; + + /** + * @type {RTCPeerConnection} + */ + this.pc = null; + + /** + * @type {number} + */ + this.connectTS = 0; + + /** + * @type {string} + */ + this.mseCodecs = ''; + + /** + * [internal] Disconnect TimeoutID. + * @type {number} + */ + this.disconnectTID = 0; + + /** + * [internal] Reconnect TimeoutID. + * @type {number} + */ + this.reconnectTID = 0; + + /** + * [internal] Handler for receiving Binary from WebSocket. + * @type {Function} + */ + this.ondata = null; + + /** + * [internal] Handlers list for receiving JSON from WebSocket. + * @type {Object.} + */ + this.onmessage = null; + } + + /** + * Set video source (WebSocket URL). Support relative path. + * @param {string|URL} value + */ + set src(value) { + if (typeof value !== 'string') value = value.toString(); + if (value.startsWith('http')) { + value = 'ws' + value.substring(4); + } else if (value.startsWith('/')) { + value = 'ws' + location.origin.substring(4) + value; + } + + this.wsURL = value; + + this.onconnect(); + } + + /** + * Play video. Support automute when autoplay blocked. + * https://developer.chrome.com/blog/autoplay/ + */ + play() { + this.video.play().catch(() => { + if (!this.video.muted) { + this.video.muted = true; + this.video.play().catch(er => { + console.warn(er); + }); + } + }); + } + + /** + * Send message to server via WebSocket + * @param {Object} value + */ + send(value) { + if (this.ws) this.ws.send(JSON.stringify(value)); + } + + /** @param {Function} isSupported */ + codecs(isSupported) { + return this.CODECS + .filter(codec => this.media.includes(codec.includes('vc1') ? 'video' : 'audio')) + .filter(codec => isSupported(`video/mp4; codecs="${codec}"`)).join(); + } + + /** + * `CustomElement`. Invoked each time the custom element is appended into a + * document-connected element. + */ + connectedCallback() { + if (this.disconnectTID) { + clearTimeout(this.disconnectTID); + this.disconnectTID = 0; + } + + // because video autopause on disconnected from DOM + if (this.video) { + const seek = this.video.seekable; + if (seek.length > 0) { + this.video.currentTime = seek.end(seek.length - 1); + } + this.play(); + } else { + this.oninit(); + } + + this.onconnect(); + } + + /** + * `CustomElement`. Invoked each time the custom element is disconnected from the + * document's DOM. + */ + disconnectedCallback() { + if (this.background || this.disconnectTID) return; + if (this.wsState === WebSocket.CLOSED && this.pcState === WebSocket.CLOSED) return; + + this.disconnectTID = setTimeout(() => { + if (this.reconnectTID) { + clearTimeout(this.reconnectTID); + this.reconnectTID = 0; + } + + this.disconnectTID = 0; + + this.ondisconnect(); + }, this.DISCONNECT_TIMEOUT); + } + + /** + * Creates child DOM elements. Called automatically once on `connectedCallback`. + */ + oninit() { + this.video = document.createElement('video'); + this.video.controls = true; + this.video.playsInline = true; + this.video.preload = 'auto'; + + this.video.style.display = 'block'; // fix bottom margin 4px + this.video.style.width = '100%'; + this.video.style.height = '100%'; + + this.appendChild(this.video); + + this.video.addEventListener('error', ev => { + const err = this.video.error; + // https://developer.mozilla.org/en-US/docs/Web/API/MediaError/code + const MEDIA_ERRORS = { + 1: 'MEDIA_ERR_ABORTED', + 2: 'MEDIA_ERR_NETWORK', + 3: 'MEDIA_ERR_DECODE', + 4: 'MEDIA_ERR_SRC_NOT_SUPPORTED' + }; + console.error('[VideoRTC] Video error:', { + error: err ? MEDIA_ERRORS[err.code] : 'unknown', + message: err ? err.message : 'unknown', + codecs: this.mseCodecs || 'not set', + readyState: this.video.readyState, + networkState: this.video.networkState, + currentTime: this.video.currentTime + }); + if (this.ws) this.ws.close(); // run reconnect for broken MSE stream + }); + + // all Safari lies about supported audio codecs + const m = window.navigator.userAgent.match(/Version\/(\d+).+Safari/); + if (m) { + // AAC from v13, FLAC from v14, OPUS - unsupported + const skip = m[1] < '13' ? 'mp4a.40.2' : m[1] < '14' ? 'flac' : 'opus'; + this.CODECS.splice(this.CODECS.indexOf(skip)); + } + + if (this.background) return; + + if ('hidden' in document && this.visibilityCheck) { + document.addEventListener('visibilitychange', () => { + if (document.hidden) { + this.disconnectedCallback(); + } else if (this.isConnected) { + this.connectedCallback(); + } + }); + } + + if ('IntersectionObserver' in window && this.visibilityThreshold) { + const observer = new IntersectionObserver(entries => { + entries.forEach(entry => { + if (!entry.isIntersecting) { + this.disconnectedCallback(); + } else if (this.isConnected) { + this.connectedCallback(); + } + }); + }, {threshold: this.visibilityThreshold}); + observer.observe(this); + } + } + + /** + * Connect to WebSocket. Called automatically on `connectedCallback`. + * @return {boolean} true if the connection has started. + */ + onconnect() { + if (!this.isConnected || !this.wsURL || this.ws || this.pc) return false; + + // CLOSED or CONNECTING => CONNECTING + this.wsState = WebSocket.CONNECTING; + + this.connectTS = Date.now(); + + this.ws = new WebSocket(this.wsURL); + this.ws.binaryType = 'arraybuffer'; + this.ws.addEventListener('open', () => this.onopen()); + this.ws.addEventListener('close', () => this.onclose()); + + return true; + } + + ondisconnect() { + this.wsState = WebSocket.CLOSED; + if (this.ws) { + this.ws.close(); + this.ws = null; + } + + this.pcState = WebSocket.CLOSED; + if (this.pc) { + this.pc.getSenders().forEach(sender => { + if (sender.track) sender.track.stop(); + }); + this.pc.close(); + this.pc = null; + } + + this.video.src = ''; + this.video.srcObject = null; + } + + /** + * @returns {Array.} of modes (mse, webrtc, etc.) + */ + onopen() { + // CONNECTING => OPEN + this.wsState = WebSocket.OPEN; + + this.ws.addEventListener('message', ev => { + if (typeof ev.data === 'string') { + const msg = JSON.parse(ev.data); + for (const mode in this.onmessage) { + this.onmessage[mode](msg); + } + } else { + this.ondata(ev.data); + } + }); + + this.ondata = null; + this.onmessage = {}; + + const modes = []; + + if (this.mode.includes('mse') && ('MediaSource' in window || 'ManagedMediaSource' in window)) { + modes.push('mse'); + this.onmse(); + } else if (this.mode.includes('hls') && this.video.canPlayType('application/vnd.apple.mpegurl')) { + modes.push('hls'); + this.onhls(); + } else if (this.mode.includes('mp4')) { + modes.push('mp4'); + this.onmp4(); + } + + if (this.mode.includes('webrtc') && 'RTCPeerConnection' in window) { + modes.push('webrtc'); + this.onwebrtc(); + } + + if (this.mode.includes('mjpeg')) { + if (modes.length) { + this.onmessage['mjpeg'] = msg => { + if (msg.type !== 'error' || msg.value.indexOf(modes[0]) !== 0) return; + this.onmjpeg(); + }; + } else { + modes.push('mjpeg'); + this.onmjpeg(); + } + } + + return modes; + } + + /** + * @return {boolean} true if reconnection has started. + */ + onclose() { + if (this.wsState === WebSocket.CLOSED) return false; + + // CONNECTING, OPEN => CONNECTING + this.wsState = WebSocket.CONNECTING; + this.ws = null; + + // reconnect no more than once every X seconds + const delay = Math.max(this.RECONNECT_TIMEOUT - (Date.now() - this.connectTS), 0); + + this.reconnectTID = setTimeout(() => { + this.reconnectTID = 0; + this.onconnect(); + }, delay); + + return true; + } + + onmse() { + /** @type {MediaSource} */ + let ms; + + if ('ManagedMediaSource' in window) { + const MediaSource = window.ManagedMediaSource; + + ms = new MediaSource(); + ms.addEventListener('sourceopen', () => { + this.send({type: 'mse', value: this.codecs(MediaSource.isTypeSupported)}); + }, {once: true}); + + this.video.disableRemotePlayback = true; + this.video.srcObject = ms; + } else { + ms = new MediaSource(); + ms.addEventListener('sourceopen', () => { + URL.revokeObjectURL(this.video.src); + this.send({type: 'mse', value: this.codecs(MediaSource.isTypeSupported)}); + }, {once: true}); + + this.video.src = URL.createObjectURL(ms); + this.video.srcObject = null; + } + + this.play(); + + this.mseCodecs = ''; + + this.onmessage['mse'] = msg => { + if (msg.type !== 'mse') return; + + this.mseCodecs = msg.value; + + const sb = ms.addSourceBuffer(msg.value); + sb.mode = 'segments'; // segments or sequence + sb.addEventListener('updateend', () => { + if (!sb.updating && bufLen > 0) { + try { + const data = buf.slice(0, bufLen); + sb.appendBuffer(data); + bufLen = 0; + } catch (e) { + // console.debug(e); + } + } + + if (!sb.updating && sb.buffered && sb.buffered.length) { + const end = sb.buffered.end(sb.buffered.length - 1); + const start = end - 5; + const start0 = sb.buffered.start(0); + if (start > start0) { + sb.remove(start0, start); + ms.setLiveSeekableRange(start, end); + } + if (this.video.currentTime < start) { + this.video.currentTime = start; + } + const gap = end - this.video.currentTime; + this.video.playbackRate = gap > 0.1 ? gap : 0.1; + // console.debug('VideoRTC.buffered', gap, this.video.playbackRate, this.video.readyState); + } + }); + + const buf = new Uint8Array(2 * 1024 * 1024); + let bufLen = 0; + + this.ondata = data => { + if (sb.updating || bufLen > 0) { + const b = new Uint8Array(data); + buf.set(b, bufLen); + bufLen += b.byteLength; + // console.debug('VideoRTC.buffer', b.byteLength, bufLen); + } else { + try { + sb.appendBuffer(data); + } catch (e) { + // console.debug(e); + } + } + }; + }; + } + + onwebrtc() { + const pc = new RTCPeerConnection(this.pcConfig); + + pc.addEventListener('icecandidate', ev => { + if (ev.candidate && this.mode.includes('webrtc/tcp') && ev.candidate.protocol === 'udp') return; + + const candidate = ev.candidate ? ev.candidate.toJSON().candidate : ''; + this.send({type: 'webrtc/candidate', value: candidate}); + }); + + pc.addEventListener('connectionstatechange', () => { + if (pc.connectionState === 'connected') { + const tracks = pc.getTransceivers() + .filter(tr => tr.currentDirection === 'recvonly') // skip inactive + .map(tr => tr.receiver.track); + /** @type {HTMLVideoElement} */ + const video2 = document.createElement('video'); + video2.addEventListener('loadeddata', () => this.onpcvideo(video2), {once: true}); + video2.srcObject = new MediaStream(tracks); + } else if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') { + pc.close(); // stop next events + + this.pcState = WebSocket.CLOSED; + this.pc = null; + + this.onconnect(); + } + }); + + this.onmessage['webrtc'] = msg => { + switch (msg.type) { + case 'webrtc/candidate': + if (this.mode.includes('webrtc/tcp') && msg.value.includes(' udp ')) return; + + pc.addIceCandidate({candidate: msg.value, sdpMid: '0'}).catch(er => { + console.warn(er); + }); + break; + case 'webrtc/answer': + pc.setRemoteDescription({type: 'answer', sdp: msg.value}).catch(er => { + console.warn(er); + }); + break; + case 'error': + if (!msg.value.includes('webrtc/offer')) return; + pc.close(); + } + }; + + this.createOffer(pc).then(offer => { + this.send({type: 'webrtc/offer', value: offer.sdp}); + }); + + this.pcState = WebSocket.CONNECTING; + this.pc = pc; + } + + /** + * @param pc {RTCPeerConnection} + * @return {Promise} + */ + async createOffer(pc) { + try { + if (this.media.includes('microphone')) { + const media = await navigator.mediaDevices.getUserMedia({audio: true}); + media.getTracks().forEach(track => { + pc.addTransceiver(track, {direction: 'sendonly'}); + }); + } + } catch (e) { + console.warn(e); + } + + for (const kind of ['video', 'audio']) { + if (this.media.includes(kind)) { + pc.addTransceiver(kind, {direction: 'recvonly'}); + } + } + + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + return offer; + } + + /** + * @param video2 {HTMLVideoElement} + */ + onpcvideo(video2) { + if (this.pc) { + // Video+Audio > Video, H265 > H264, Video > Audio, WebRTC > MSE + let rtcPriority = 0, msePriority = 0; + + /** @type {MediaStream} */ + const stream = video2.srcObject; + if (stream.getVideoTracks().length > 0) { + // not the best, but a pretty simple way to check a codec + const isH265Supported = this.pc.remoteDescription.sdp.includes('H265/90000'); + rtcPriority += isH265Supported ? 0x240 : 0x220; + } + if (stream.getAudioTracks().length > 0) rtcPriority += 0x102; + + if (this.mseCodecs.includes('hvc1.')) msePriority += 0x230; + if (this.mseCodecs.includes('avc1.')) msePriority += 0x210; + if (this.mseCodecs.includes('mp4a.')) msePriority += 0x101; + + if (rtcPriority >= msePriority) { + this.video.srcObject = stream; + this.play(); + + this.pcState = WebSocket.OPEN; + + this.wsState = WebSocket.CLOSED; + if (this.ws) { + this.ws.close(); + this.ws = null; + } + } else { + this.pcState = WebSocket.CLOSED; + if (this.pc) { + this.pc.close(); + this.pc = null; + } + } + } + + video2.srcObject = null; + } + + onmjpeg() { + this.ondata = data => { + this.video.controls = false; + this.video.poster = 'data:image/jpeg;base64,' + VideoRTC.btoa(data); + }; + + this.send({type: 'mjpeg'}); + } + + onhls() { + this.onmessage['hls'] = msg => { + if (msg.type !== 'hls') return; + + const url = 'http' + this.wsURL.substring(2, this.wsURL.indexOf('/ws')) + '/hls/'; + const playlist = msg.value.replace('hls/', url); + this.video.src = 'data:application/vnd.apple.mpegurl;base64,' + btoa(playlist); + this.play(); + }; + + this.send({type: 'hls', value: this.codecs(type => this.video.canPlayType(type))}); + } + + onmp4() { + /** @type {HTMLCanvasElement} **/ + const canvas = document.createElement('canvas'); + /** @type {CanvasRenderingContext2D} */ + let context; + + /** @type {HTMLVideoElement} */ + const video2 = document.createElement('video'); + video2.autoplay = true; + video2.playsInline = true; + video2.muted = true; + + video2.addEventListener('loadeddata', () => { + if (!context) { + canvas.width = video2.videoWidth; + canvas.height = video2.videoHeight; + context = canvas.getContext('2d'); + } + + context.drawImage(video2, 0, 0, canvas.width, canvas.height); + + this.video.controls = false; + this.video.poster = canvas.toDataURL('image/jpeg'); + }); + + this.ondata = data => { + video2.src = 'data:video/mp4;base64,' + VideoRTC.btoa(data); + }; + + this.send({type: 'mp4', value: this.codecs(this.video.canPlayType)}); + } + + static btoa(buffer) { + const bytes = new Uint8Array(buffer); + const len = bytes.byteLength; + let binary = ''; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(bytes[i]); + } + return window.btoa(binary); + } +} diff --git a/web-ui/vendor/go2rtc/video-stream.js b/web-ui/vendor/go2rtc/video-stream.js new file mode 100644 index 0000000..5b7c1ea --- /dev/null +++ b/web-ui/vendor/go2rtc/video-stream.js @@ -0,0 +1,103 @@ +import {VideoRTC} from './video-rtc.js'; + +/** + * This is example, how you can extend VideoRTC player for your app. + * Also you can check this example: https://github.com/AlexxIT/WebRTC + */ +class VideoStream extends VideoRTC { + set divMode(value) { + this.querySelector('.mode').innerText = value; + this.querySelector('.status').innerText = ''; + } + + set divError(value) { + const state = this.querySelector('.mode').innerText; + if (state !== 'loading') return; + this.querySelector('.mode').innerText = 'error'; + this.querySelector('.status').innerText = value; + } + + /** + * Custom GUI + */ + oninit() { + console.debug('stream.oninit'); + super.oninit(); + + this.innerHTML = ` + +
+
+
+
+ `; + + const info = this.querySelector('.info'); + this.insertBefore(this.video, info); + } + + onconnect() { + console.debug('stream.onconnect'); + const result = super.onconnect(); + if (result) this.divMode = 'loading'; + return result; + } + + ondisconnect() { + console.debug('stream.ondisconnect'); + super.ondisconnect(); + } + + onopen() { + console.debug('stream.onopen'); + const result = super.onopen(); + + this.onmessage['stream'] = msg => { + console.debug('stream.onmessge', msg); + switch (msg.type) { + case 'error': + this.divError = msg.value; + break; + case 'mse': + case 'hls': + case 'mp4': + case 'mjpeg': + this.divMode = msg.type.toUpperCase(); + break; + } + }; + + return result; + } + + onclose() { + console.debug('stream.onclose'); + return super.onclose(); + } + + onpcvideo(ev) { + console.debug('stream.onpcvideo'); + super.onpcvideo(ev); + + if (this.pcState !== WebSocket.CLOSED) { + this.divMode = 'RTC'; + } + } +} + +customElements.define('video-stream', VideoStream);