onicecandidate가 ev.candidate===null(전체 gathering 종료)까지 기다린 뒤에만 오퍼를 보내고 있었는데, 이 네트워크에서 그 신호가 20~30초씩 걸림(가상 인터페이스들 순회+ 타임아웃으로 추정). 실제 필요한 STUN srflx 후보는 1~2초면 잡히므로, 완전종료를 기다리지 않고 1.5초 타임아웃 뒤 그때까지 모인 후보로 바로 오퍼를 보내도록 변경. 브라우저 실측: 연결까지 20~30초 → 3초 이내로 단축.
127 lines
6.8 KiB
HTML
127 lines
6.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="https://print.applecherry.net/" 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="https://print.applecherry.net/" title="Fluidd"></iframe>
|
|
<div class="err" id="k2-err">
|
|
<div>Fluidd를 불러오지 못했습니다 (print.applecherry.net).</div>
|
|
<div><a href="https://print.applecherry.net/" 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('연결 중...');
|
|
// STUN을 빼면(host candidate만) 이 프린터의 ICE 응답이 실제로는 안 옴(DTLS
|
|
// failed, bytesReceived 0) — 크리얼리티 데모 코드 그대로 STUN 유지해야 정상
|
|
// 동작함(2026-09-18 확인, 원인 불명이나 재현됨).
|
|
const pc=new RTCPeerConnection({iceServers:[{urls:'stun:stun.l.google.com:19302'}]});
|
|
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('끊김');
|
|
}
|
|
};
|
|
// 원래는 ev.candidate===null(ICE gathering 완전 종료)까지 기다린 뒤에만 오퍼를
|
|
// 보냈는데, 이 네트워크에서 그 완전종료 신호가 20~30초씩 걸림(가상/VPN 인터페이스들을
|
|
// 하나씩 훑고 타임아웃되는 걸로 보임) — 정작 이 프린터가 필요로 하는 STUN srflx
|
|
// 후보 자체는 보통 1~2초 안에 잡힘. 그래서 완전종료를 기다리지 않고 짧은 타임아웃
|
|
// 뒤에 그때까지 모인 후보로 바로 보낸다(2026-09-19, 20초+ 연결지연 확인 후 수정).
|
|
let offerSent=false;
|
|
function sendOfferOnce(){
|
|
if(offerSent || pc!==camPC) return;
|
|
offerSent=true;
|
|
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(()=>{ if(pc===camPC) setStatus('연결 실패'); });
|
|
}
|
|
pc.onicecandidate=(ev)=>{
|
|
if(ev.candidate===null) sendOfferOnce(); // 빨리 끝나면 그대로 씀
|
|
};
|
|
pc.addTransceiver('video',{direction:'sendrecv'});
|
|
pc.createOffer().then(d=>pc.setLocalDescription(d)).then(()=>{
|
|
setTimeout(sendOfferOnce, 1500); // 안 끝나면 1.5초 뒤 그때까지 모인 후보로 진행
|
|
}).catch(()=>{ if(pc===camPC) setStatus('연결 실패'); });
|
|
}
|
|
function reconnectCam(){ connectCam(); }
|
|
connectCam();
|
|
</script>
|
|
</body>
|
|
</html>
|