From 75b887d2957d3e57d32b239a19b0734f20c119ae Mon Sep 17 00:00:00 2001 From: kim Date: Thu, 24 Sep 2026 07:45:29 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9E=91=EC=97=85=EC=8B=A4=20=EC=9E=A5?= =?UTF-8?q?=EB=B9=84=20=ED=83=AD=20K2=20=EC=B9=B4=EB=93=9C=EC=97=90=20?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=20=EB=B7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ▶ 라이브 버튼 → 카드 안에서 상시 WebRTC 스트리밍(k2-app.html의 검증된 연결 로직 이식: STUN 필수·오퍼 null-candidate/1.5초 중 빠른 쪽 퀴크 포함) - 라이브 중엔 20초 스냅샷 그랩(WebRTC 풀협상)을 건너뛴다 — 비디오가 이미 실시간이고 짧은 연결이 간섭하므로. 상태 조회(Moonraker)는 계속, 상태줄에 "● 실시간" 표시 - 장비 탭을 벗어나면 stopEquipTimers가 라이브 연결을 닫는다(안 보는 동안 자원 안 쓰기), ICE failed면 자동 종료 후 안내문 Co-Authored-By: Claude Code --- web-ui/html/workshop-app.html | 101 ++++++++++++++++++++++++++++++++-- 1 file changed, 95 insertions(+), 6 deletions(-) diff --git a/web-ui/html/workshop-app.html b/web-ui/html/workshop-app.html index 85963de..1590fb9 100644 --- a/web-ui/html/workshop-app.html +++ b/web-ui/html/workshop-app.html @@ -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 🖨️ K2 콤보 -
불러오는 중...
+
+ + + 불러오는 중... + +
-
@@ -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'); - grabK2Frame().then(dataUrl=>{ - if(dataUrl){img.src=dataUrl;img.style.display='block';placeholder.style.display='none';} - else if(img.style.display==='none'){placeholder.textContent='카메라를 불러올 수 없습니다';} - }); + // 라이브 연결이 살아있으면 프레임 그랩(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+='
진행률: '+d.progress+'% · 경과: '+fmtK2Duration(d.printDurationSec)+''; } + if(k2LivePC)html+=' · ● 실시간'; statusEl.innerHTML=html; }catch{ document.getElementById('k2-status-text').textContent='상태 조회 실패';