Files
homeclaw/web-ui/html/k2-app.html
T
kimandClaude Sonnet 5 a8149c136a feat: 프린터실 카메라 웹코덱(WebRTC/MSE) 전환 + K2 콤보 카메라 프록시, 옛 3D프린터 앱 제거
- go2rtc가 CORS/Origin 체크로 브라우저 직접접속을 막던 문제 해결: 클라이언트
  스크립트를 벤더링해 같은 오리진으로 서빙하고, WS는 게이트웨이 프록시
  (attachGo2rtcWsProxy)가 Origin을 다시 써서 우회. CCTV 앱의 프린터실 카메라
  라이브뷰가 MJPEG 대신 WebRTC/MSE를 쓰도록 전환(대역폭 절감).
- go2rtc mjpeg 트랜스코더가 멈추는 경우를 감지해 자동 재기동하는
  /api/nvr/mjpeg-health 엔드포인트 추가.
- 신규 3D프린터(K2 콤보, Klipper 스탠드얼론)의 내장 WebRTC 카메라 시그널링을
  프록시하는 /api/k2/webrtc-signal 추가 — 홈클로가 HTTPS로 서빙되는데 카메라가
  평문 HTTP만 지원해 브라우저 mixed-content 차단을 우회하기 위함.
- 옛 3D프린터(단종된 Anet A8 Plus, OctoPrint) 앱과 홈 드롭다운 링크 제거.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PppKcQmeTJDQCpfcgKietu
2026-09-18 22:40:10 +09:00

115 lines
5.8 KiB
HTML

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🖨️ K2 콤보</title>
<link rel="icon" type="image/png" sizes="64x64" href="/homeclaw_logo.png">
<link rel="stylesheet" href="../css/styles.css">
<script>try{var _t=localStorage.getItem('homeclaw_theme')||'dark';document.documentElement.setAttribute('data-theme',_t);}catch(e){}</script>
<style>
:root{--brand:#f97316;}
html,body{height:100%;margin:0;padding:0;}
body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;display:flex;flex-direction:column;overflow:hidden;height:100dvh;}
.hdr{display:flex;align-items:center;gap:8px;padding:6px 12px;background:var(--panel);border-bottom:1px solid var(--line);flex-shrink:0;}
.hdr h1{font-size:13px;font-weight:700;margin:0;}
.hdr-sp{flex:1;}
.hdr-btn{background:none;border:1.5px solid var(--line);border-radius:6px;padding:3px 9px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;transition:.15s;text-decoration:none;display:inline-flex;align-items:center;}
.hdr-btn:hover{border-color:var(--brand);color:var(--brand);}
.body-wrap{flex:1;min-height:0;display:flex;flex-direction:column;}
.cam-bar{flex-shrink:0;background:#000;display:flex;justify-content:center;position:relative;max-height:32vh;}
.cam-bar video{max-width:100%;max-height:32vh;display:block;background:#000;}
.cam-status{position:absolute;top:6px;right:10px;font-size:11px;color:#9ca3af;background:rgba(0,0,0,.5);padding:2px 8px;border-radius:6px;}
.cam-empty{color:var(--muted);font-size:12px;padding:16px;text-align:center;}
.frame-wrap{flex:1;min-height:0;position:relative;}
.frame-wrap iframe{width:100%;height:100%;border:none;display:block;}
.err{display:none;position:absolute;inset:0;align-items:center;justify-content:center;flex-direction:column;gap:10px;background:var(--bg);color:var(--muted);font-size:13px;text-align:center;padding:20px;}
.err.show{display:flex;}
.err a{color:var(--brand);}
</style>
</head>
<body>
<header class="hdr">
<a href="/" style="text-decoration:none;font-size:13px;color:var(--muted)">← 홈</a>
<h1>🖨️ K2 콤보</h1>
<span class="hdr-sp"></span>
<button class="hdr-btn" onclick="reconnectCam()">📷 카메라 재연결</button>
<button class="hdr-btn" onclick="reloadFrame()">↺ 새로고침</button>
<a class="hdr-btn" href="http://192.168.0.114:4408/" target="_blank">↗ 새 탭</a>
<button class="hdr-btn" onclick="toggleTheme()">🌙</button>
</header>
<div class="body-wrap">
<div class="cam-bar" id="cam-bar">
<video id="k2-cam" autoplay muted playsinline></video>
<span class="cam-status" id="cam-status">연결 중...</span>
</div>
<div class="frame-wrap">
<iframe id="k2-frame" src="http://192.168.0.114:4408/" title="Fluidd"></iframe>
<div class="err" id="k2-err">
<div>Fluidd를 불러오지 못했습니다 (K2 콤보, 192.168.0.114:4408).</div>
<div><a href="http://192.168.0.114:4408/" target="_blank">새 탭에서 직접 열기 ↗</a></div>
</div>
</div>
</div>
<script>
function toggleTheme(){const d=document.documentElement;const n=d.getAttribute('data-theme')==='dark'?'light':'dark';d.setAttribute('data-theme',n);try{localStorage.setItem('homeclaw_theme',n);}catch{}}
function reloadFrame(){const f=document.getElementById('k2-frame');f.src=f.src;}
let loaded=false;
document.getElementById('k2-frame').addEventListener('load',()=>{loaded=true;});
setTimeout(()=>{ if(!loaded) document.getElementById('k2-err').classList.add('show'); },8000);
// ── K2 콤보 내장 카메라(자체 WebRTC 시그널링, 프린터 펌웨어가 직접 서빙) ──
// 프린터 자체는 :8000/call/webrtc_local 로 SDP offer를 POST하면 answer를 돌려주는
// 평문 HTTP 시그널링(Moonraker webcam 목록엔 안 잡힘 — 크리얼리티 자체 프로토콜).
// 홈클로는 https://ai.applecherry.net 로 서빙되므로 브라우저에서 직접 http://로
// fetch(POST)하면 mixed-content로 막힘("주의 요함" + 카메라 검은 화면, 2026-09-18
// 발견) — 그래서 게이트웨이의 /api/k2/webrtc-signal(같은 오리진, HTTPS)을 거쳐
// 서버가 대신 그 요청을 해준다.
function authH(){const t=(function(){try{return sessionStorage.getItem('smallclaw_token')||localStorage.getItem('smallclaw_token')||'';}catch{return '';}})();return t?{'Authorization':'Bearer '+t}:{};}
let camPC=null;
function setStatus(t){const s=document.getElementById('cam-status'); if(s) s.textContent=t;}
function connectCam(){
if(camPC){ try{camPC.close();}catch(e){} }
setStatus('연결 중...');
// 같은 LAN 안이라 STUN 없이 host candidate만으로 충분 — 외부 STUN 서버 왕복을
// 기다릴 필요가 없고, 그 서버가 막혀있는 네트워크에서도 즉시 연결된다.
const pc=new RTCPeerConnection({iceServers:[]});
camPC=pc;
pc.ontrack=(ev)=>{
const v=document.getElementById('k2-cam');
v.srcObject=ev.streams[0];
setStatus('실시간');
};
pc.oniceconnectionstatechange=()=>{
if(pc===camPC && (pc.iceConnectionState==='failed'||pc.iceConnectionState==='disconnected')){
setStatus('끊김');
}
};
pc.onicecandidate=(ev)=>{
if(ev.candidate===null){
fetch('/api/k2/webrtc-signal',{
method:'POST',
headers:{'Content-Type':'text/plain', ...authH()},
body:btoa(JSON.stringify({type:'offer',sdp:pc.localDescription.sdp})),
}).then(r=>r.text()).then(txt=>{
const res=JSON.parse(atob(txt));
if(res.type==='answer' && pc===camPC){
pc.setRemoteDescription(new RTCSessionDescription(res));
}
}).catch(()=>{ setStatus('연결 실패'); });
}
};
pc.addTransceiver('video',{direction:'sendrecv'});
pc.createOffer().then(d=>pc.setLocalDescription(d)).catch(()=>{ setStatus('연결 실패'); });
}
function reconnectCam(){ connectCam(); }
connectCam();
</script>
</body>
</html>