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:
@@ -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-refresh{margin-left:auto;background:none;border:none;cursor:pointer;font-size:12px;color:var(--muted);}
|
||||
.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 .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 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>
|
||||
<button class="rb-equip-refresh" onclick="refreshK2Widget()">↺</button>
|
||||
</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>
|
||||
<div class="rb-equip-card">
|
||||
@@ -578,6 +586,7 @@ function startEquipTimers(){
|
||||
function stopEquipTimers(){
|
||||
clearInterval(equipTimer);
|
||||
equipTimer=null;
|
||||
stopK2Live(); // 장비 탭을 벗어나면 라이브 WebRTC 연결도 닫는다(안 보는 동안 자원 안 쓰기)
|
||||
}
|
||||
|
||||
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){
|
||||
const h=Math.floor(sec/3600),m=Math.floor((sec%3600)/60);
|
||||
if(h>0)return h+'시간 '+m+'분';
|
||||
@@ -663,10 +747,14 @@ const K2_STATE_LABEL={standby:'대기중',printing:'출력중',paused:'일시정
|
||||
async function refreshK2Widget(){
|
||||
const img=document.getElementById('k2-frame');
|
||||
const placeholder=document.getElementById('k2-placeholder');
|
||||
// 라이브 연결이 살아있으면 프레임 그랩(WebRTC 풀협상)을 건너뛴다 — 비디오가 이미
|
||||
// 실시간으로 흐르고 있고, 짧은 연결을 또 열면 둘이 간섭한다. 상태 조회는 계속한다.
|
||||
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{
|
||||
const r=await fetch('/api/k2/status',{headers:authH()});
|
||||
const d=await r.json();
|
||||
@@ -684,6 +772,7 @@ async function refreshK2Widget(){
|
||||
if(d.state==='printing'){
|
||||
html+='<br>진행률: <b>'+d.progress+'%</b> · 경과: <b>'+fmtK2Duration(d.printDurationSec)+'</b>';
|
||||
}
|
||||
if(k2LivePC)html+=' · <b style="color:#ef4444">● 실시간</b>';
|
||||
statusEl.innerHTML=html;
|
||||
}catch{
|
||||
document.getElementById('k2-status-text').textContent='상태 조회 실패';
|
||||
|
||||
Reference in New Issue
Block a user