feat: 작업실 장비 탭 K2 카드에 라이브 뷰

- ▶ 라이브 버튼 → 카드 안에서 상시 WebRTC 스트리밍(k2-app.html의 검증된 연결
  로직 이식: STUN 필수·오퍼 null-candidate/1.5초 중 빠른 쪽 퀴크 포함)
- 라이브 중엔 20초 스냅샷 그랩(WebRTC 풀협상)을 건너뛴다 — 비디오가 이미
  실시간이고 짧은 연결이 간섭하므로. 상태 조회(Moonraker)는 계속, 상태줄에
  "● 실시간" 표시
- 장비 탭을 벗어나면 stopEquipTimers가 라이브 연결을 닫는다(안 보는 동안
  자원 안 쓰기), ICE failed면 자동 종료 후 안내문

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-24 07:45:29 +09:00
co-authored by Claude Code
parent eb516e2b74
commit 75b887d295
+95 -6
View File
@@ -148,9 +148,12 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
.rb-equip-dot.off{background:#ef4444;} .rb-equip-dot.off{background:#ef4444;}
.rb-equip-refresh{margin-left:auto;background:none;border:none;cursor:pointer;font-size:12px;color:var(--muted);} .rb-equip-refresh{margin-left:auto;background:none;border:none;cursor:pointer;font-size:12px;color:var(--muted);}
.rb-equip-refresh:hover{color:#f59e0b;} .rb-equip-refresh:hover{color:#f59e0b;}
.rb-equip-frame-wrap{width:100%;aspect-ratio:16/9;background:#000;display:flex;align-items:center;justify-content:center;overflow:hidden;} .rb-equip-frame-wrap{width:100%;aspect-ratio:16/9;background:#000;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative;}
.rb-equip-frame-wrap img{width:100%;height:100%;object-fit:contain;} .rb-equip-frame-wrap img{width:100%;height:100%;object-fit:contain;}
.rb-equip-frame-wrap .rb-equip-placeholder{color:#666;font-size:11px;} .rb-equip-frame-wrap .rb-equip-placeholder{color:#666;font-size:11px;}
.rb-equip-live-btn{position:absolute;right:8px;bottom:8px;background:rgba(0,0,0,.55);color:#fff;border:1px solid rgba(255,255,255,.3);border-radius:6px;padding:4px 10px;font-size:11px;cursor:pointer;z-index:2;}
.rb-equip-live-btn:hover{background:rgba(245,158,11,.85);border-color:transparent;}
.rb-equip-live-btn.on{background:rgba(239,68,68,.8);border-color:transparent;}
.rb-equip-status{padding:8px 12px;font-size:11px;color:var(--muted);line-height:1.7;} .rb-equip-status{padding:8px 12px;font-size:11px;color:var(--muted);line-height:1.7;}
.rb-equip-status b{color:var(--text);} .rb-equip-status b{color:var(--text);}
@@ -241,7 +244,12 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
<span>🖨️ K2 콤보</span> <span>🖨️ K2 콤보</span>
<button class="rb-equip-refresh" onclick="refreshK2Widget()">↺</button> <button class="rb-equip-refresh" onclick="refreshK2Widget()">↺</button>
</div> </div>
<div class="rb-equip-frame-wrap"><img id="k2-frame" alt="" style="display:none"><span class="rb-equip-placeholder" id="k2-placeholder">불러오는 중...</span></div> <div class="rb-equip-frame-wrap">
<video id="k2-live" autoplay muted playsinline style="display:none;width:100%;height:100%;object-fit:contain"></video>
<img id="k2-frame" alt="" style="display:none">
<span class="rb-equip-placeholder" id="k2-placeholder">불러오는 중...</span>
<button class="rb-equip-live-btn" id="k2-live-btn" onclick="toggleK2Live()">▶ 라이브</button>
</div>
<div class="rb-equip-status" id="k2-status-text">-</div> <div class="rb-equip-status" id="k2-status-text">-</div>
</div> </div>
<div class="rb-equip-card"> <div class="rb-equip-card">
@@ -578,6 +586,7 @@ function startEquipTimers(){
function stopEquipTimers(){ function stopEquipTimers(){
clearInterval(equipTimer); clearInterval(equipTimer);
equipTimer=null; equipTimer=null;
stopK2Live(); // 장비 탭을 벗어나면 라이브 WebRTC 연결도 닫는다(안 보는 동안 자원 안 쓰기)
} }
function refreshMillWidget(){ function refreshMillWidget(){
@@ -653,6 +662,81 @@ function grabK2Frame(){
}); });
} }
// ── K2 라이브 뷰(2026-09-24) ─────────────────────────────────────────────────
// ▶ 라이브 버튼으로 카드 안에서 상시 WebRTC 스트리밍(k2-app.html의 검증된 연결 로직
// 이식). 라이브 중엔 20초 스냅샷 그랩을 멈추고(refreshK2Widget 참조), 장비 탭을
// 벗어나면 stopEquipTimers가 연결을 닫는다 — 안 보는 동안엔 자원을 안 쓴다.
let k2LivePC=null;
function k2Note(msg){
const ph=document.getElementById('k2-placeholder');
const img=document.getElementById('k2-frame');
const video=document.getElementById('k2-live');
video.style.display='none';
img.style.display='none';
ph.style.display='block';
ph.textContent=msg;
}
function toggleK2Live(){
if(k2LivePC)stopK2Live();else startK2Live();
}
function startK2Live(){
if(k2LivePC)return;
const video=document.getElementById('k2-live');
const btn=document.getElementById('k2-live-btn');
btn.classList.add('on');btn.textContent='⏹ 라이브 종료';
const ph=document.getElementById('k2-placeholder');
ph.style.display='none';
// STUN을 빼면(host candidate만) 이 프린터의 ICE 응답이 실제로는 안 옴(DTLS failed,
// bytesReceived 0) — k2-app.html에서 2026-09-18 확인된 퀴크라 그대로 유지.
const pc=new RTCPeerConnection({iceServers:[{urls:'stun:stun.l.google.com:19302'}]});
k2LivePC=pc;
pc.ontrack=(ev)=>{
if(k2LivePC!==pc)return;
video.srcObject=ev.streams[0];
video.play().catch(()=>{});
ph.style.display='none';
};
pc.oniceconnectionstatechange=()=>{
if(k2LivePC!==pc)return;
const st=pc.iceConnectionState;
if(st==='failed'){
stopK2Live();
k2Note('라이브 연결 실패 — ▶ 라이브를 다시 눌러주세요');
}else if(st==='disconnected'){
k2Note('라이브 신호 끊김(일시적) — 복구되면 그려집니다');
}
};
// 오퍼는 ICE gathering 완전종료(null candidate) 또는 1.5초 중 빠른 쪽에 보낸다 —
// 완전종료가 이 네트워크에서 20초+ 걸리는 k2-app.html의 실측 퀴크 그대로.
let offerSent=false;
const sendOfferOnce=()=>{
if(offerSent||k2LivePC!==pc)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'&&k2LivePC===pc)pc.setRemoteDescription(new RTCSessionDescription(res));
}).catch(()=>{if(k2LivePC===pc)stopK2Live();});
};
pc.onicecandidate=(ev)=>{if(ev.candidate===null)sendOfferOnce();};
pc.addTransceiver('video',{direction:'sendrecv'});
pc.createOffer().then(d=>pc.setLocalDescription(d)).then(()=>{setTimeout(sendOfferOnce,1500);}).catch(()=>{if(k2LivePC===pc)stopK2Live();});
}
function stopK2Live(){
if(!k2LivePC)return;
try{k2LivePC.close();}catch{}
k2LivePC=null;
const video=document.getElementById('k2-live');
try{video.pause();video.srcObject=null;}catch{}
video.style.display='none';
const btn=document.getElementById('k2-live-btn');
btn.classList.remove('on');btn.textContent='▶ 라이브';
// 스냅샷으로 즉시 복귀 — 단, 장비 탭을 벗어나며 stopEquipTimers가 부른 경우엔
// 안 그래도 된다(안 보는 탭에 프레임 그랩을 트리거하는 셈).
const pane=document.getElementById('pane-equip');
if(pane&&pane.classList.contains('active'))refreshK2Widget();
}
function fmtK2Duration(sec){ function fmtK2Duration(sec){
const h=Math.floor(sec/3600),m=Math.floor((sec%3600)/60); const h=Math.floor(sec/3600),m=Math.floor((sec%3600)/60);
if(h>0)return h+'시간 '+m+'분'; if(h>0)return h+'시간 '+m+'분';
@@ -663,10 +747,14 @@ const K2_STATE_LABEL={standby:'대기중',printing:'출력중',paused:'일시정
async function refreshK2Widget(){ async function refreshK2Widget(){
const img=document.getElementById('k2-frame'); const img=document.getElementById('k2-frame');
const placeholder=document.getElementById('k2-placeholder'); const placeholder=document.getElementById('k2-placeholder');
grabK2Frame().then(dataUrl=>{ // 라이브 연결이 살아있으면 프레임 그랩(WebRTC 풀협상)을 건너뛴다 — 비디오가 이미
if(dataUrl){img.src=dataUrl;img.style.display='block';placeholder.style.display='none';} // 실시간으로 흐르고 있고, 짧은 연결을 또 열면 둘이 간섭한다. 상태 조회는 계속한다.
else if(img.style.display==='none'){placeholder.textContent='카메라를 불러올 수 없습니다';} if(!k2LivePC){
}); grabK2Frame().then(dataUrl=>{
if(dataUrl){img.src=dataUrl;img.style.display='block';placeholder.style.display='none';}
else if(img.style.display==='none'){placeholder.textContent='카메라를 불러올 수 없습니다';}
});
}
try{ try{
const r=await fetch('/api/k2/status',{headers:authH()}); const r=await fetch('/api/k2/status',{headers:authH()});
const d=await r.json(); const d=await r.json();
@@ -684,6 +772,7 @@ async function refreshK2Widget(){
if(d.state==='printing'){ if(d.state==='printing'){
html+='<br>진행률: <b>'+d.progress+'%</b> · 경과: <b>'+fmtK2Duration(d.printDurationSec)+'</b>'; html+='<br>진행률: <b>'+d.progress+'%</b> · 경과: <b>'+fmtK2Duration(d.printDurationSec)+'</b>';
} }
if(k2LivePC)html+=' · <b style="color:#ef4444">● 실시간</b>';
statusEl.innerHTML=html; statusEl.innerHTML=html;
}catch{ }catch{
document.getElementById('k2-status-text').textContent='상태 조회 실패'; document.getElementById('k2-status-text').textContent='상태 조회 실패';