// ============================================================ // DASHBOARD - Variables & State // ============================================================ var ws = null; var gsGrid = null; var isLocked = false; var state = { connected: false, cameras: null, currentCamera: null, items: null, currentItem: null, events: null, currentEvent: null, avatars: null, currentAvatar: null, system: null }; var statusInterval = null; var STORAGE_KEY = 'streamingle-dashboard-layout'; var currentTab = 'dashboard'; // ============================================================ // RETARGETING - Variables & State // ============================================================ var rtWs = null; var rtCharactersData = {}; var rtPresets = []; var rtConnected = false; var rtInitialized = false; // ============================================================ // PREVIEW - Variables & State // ============================================================ var previewImages = {}; var previewSubscribed = false; var previewFullscreenIndex = -1; var previewLastFrameTime = 0; var previewStaleCheckInterval = null; var previewDecodeStartTime = {}; // 카메라별 디코드 시작 시간 (0이면 idle, >0이면 디코딩 중) var lastWsMessageTime = 0; // 마지막 WS 메시지 수신 시각 (하트비트 감지용) var heartbeatCheckInterval = null; var clientPingInterval = null; // 클라이언트→서버 keepalive ping (iOS WiFi 절전 방지) var reconnectDelay = 300; // 재연결 딜레이 (지수 백오프) var reconnectCount = 0; // 연속 재연결 횟수 // iOS/iPadOS 감지 (Chrome on iPad도 WebKit 엔진 사용) var isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); // ============================================================ // TAB SWITCHING // ============================================================ function switchTab(tab) { currentTab = tab; // Toggle all 3 tabs ['dashboard', 'retargeting', 'preview'].forEach(function(t) { var btnId = 'tabBtn' + t.charAt(0).toUpperCase() + t.slice(1); var contentId = 'tab' + t.charAt(0).toUpperCase() + t.slice(1); var btn = document.getElementById(btnId); var content = document.getElementById(contentId); if (btn) btn.className = 'tab-btn' + (tab === t ? ' active' : ''); if (content) content.className = 'tab-content' + (tab === t ? ' active' : ''); }); // Toggle header controls document.getElementById('dashboardControls').style.display = (tab === 'dashboard') ? '' : 'none'; document.getElementById('retargetingControls').style.display = (tab === 'retargeting') ? '' : 'none'; document.getElementById('previewControls').style.display = (tab === 'preview') ? '' : 'none'; // Preview subscribe/unsubscribe if (tab === 'preview') { previewSubscribe(); initPreviewGrid(); } else { previewUnsubscribe(); } // Lazy connect retargeting WS if (tab === 'retargeting' && !rtInitialized) { rtInitialized = true; rtConnectWebSocket(); } } // ============================================================ // DASHBOARD - Widget Definitions // ============================================================ var WIDGET_DEFS = { cameras: { title: 'Cameras', badgeId: 'camera-badge', defaultPos: { x: 0, y: 0, w: 6, h: 4, minW: 3, minH: 2 }, content: function() { return '
'; } }, items: { title: 'Items', badgeId: 'item-badge', defaultPos: { x: 6, y: 0, w: 6, h: 4, minW: 3, minH: 2 }, content: function() { return '
' + '
' + '' + '' + '
'; } }, events: { title: 'Events', badgeId: 'event-badge', defaultPos: { x: 0, y: 4, w: 6, h: 3, minW: 3, minH: 2 }, content: function() { return '
'; } }, avatars: { title: 'Avatars', badgeId: 'avatar-badge', defaultPos: { x: 6, y: 4, w: 6, h: 4, minW: 3, minH: 2 }, content: function() { return '
'; } }, system: { title: 'System', badgeId: null, defaultPos: { x: 0, y: 8, w: 12, h: 5, minW: 4, minH: 3 }, content: function() { return '
' + '
Screenshot
' + '
' + '' + '' + '' + '
' + '
' + '
Motion Recording
' + '
' + '' + '' + '
' + '
' + '
Reconnect
' + '
' + '' + '' + '
' + '
' + '
Cloth / Markers
' + '
' + '' + '' + '
' + '
' + '
Retargeting Remote
' + '
' + '' + '' + '
'; } } }; var PANEL_ORDER = ['cameras', 'items', 'events', 'avatars', 'system']; // ============================================================ // DASHBOARD - GridStack Init // ============================================================ function initGrid() { gsGrid = GridStack.init({ column: 12, cellHeight: 70, margin: 6, animate: true, float: false, handle: '.widget-header', disableResize: false, disableDrag: false }, '#dashboardGrid'); var saved = loadLayout(); if (saved && saved.items && saved.items.length > 0) { for (var i = 0; i < saved.items.length; i++) { var item = saved.items[i]; if (WIDGET_DEFS[item.id]) { addWidget(item.id, item); } } if (saved.locked) { toggleLock(); } } else { for (var j = 0; j < PANEL_ORDER.length; j++) { var id = PANEL_ORDER[j]; addWidget(id, WIDGET_DEFS[id].defaultPos); } } gsGrid.on('change', function() { saveLayout(); }); var updateColumns = function() { if (!gsGrid) return; gsGrid.column(window.innerWidth <= 768 ? 1 : 12); }; updateColumns(); window.addEventListener('resize', updateColumns); window.addEventListener('orientationchange', function() { setTimeout(updateColumns, 150); }); } function addWidget(id, pos) { var def = WIDGET_DEFS[id]; if (!def) return; var badgeHtml = def.badgeId ? ' 0' : ''; var contentHtml = '
' + '
' + '' + def.title + badgeHtml + '' + '' + '
' + '
' + def.content() + '
' + '
'; gsGrid.addWidget({ id: id, x: pos.x, y: pos.y, w: pos.w, h: pos.h, minW: def.defaultPos.minW, minH: def.defaultPos.minH, content: contentHtml }); } // ============================================================ // DASHBOARD - Layout Persistence // ============================================================ function saveLayout() { if (!gsGrid) return; var items = gsGrid.save(false); var data = { items: items, locked: isLocked }; try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } catch(e) {} } function loadLayout() { try { var raw = localStorage.getItem(STORAGE_KEY); return raw ? JSON.parse(raw) : null; } catch(e) { return null; } } function resetLayout() { localStorage.removeItem(STORAGE_KEY); if (isLocked) toggleLock(); gsGrid.removeAll(); for (var i = 0; i < PANEL_ORDER.length; i++) { addWidget(PANEL_ORDER[i], WIDGET_DEFS[PANEL_ORDER[i]].defaultPos); } saveLayout(); renderAll(); hideSettings(); showToast('레이아웃 초기화됨', 'success'); } // ============================================================ // DASHBOARD - Panel Show/Hide // ============================================================ function hidePanel(id) { var el = document.querySelector('.grid-stack-item[gs-id="' + id + '"]'); if (el) gsGrid.removeWidget(el); saveLayout(); updateSettingsToggles(); } function showPanel(id) { if (document.querySelector('.grid-stack-item[gs-id="' + id + '"]')) return; addWidget(id, WIDGET_DEFS[id].defaultPos); rerenderWidget(id); saveLayout(); updateSettingsToggles(); } function getVisiblePanels() { if (!gsGrid) return []; var items = gsGrid.save(false); return items.map(function(item) { return item.id; }); } function rerenderWidget(id) { switch(id) { case 'cameras': renderCameras(); break; case 'items': renderItems(); break; case 'events': renderEvents(); break; case 'avatars': renderAvatars(); break; case 'system': renderHealth(); break; } } // ============================================================ // DASHBOARD - Lock/Unlock // ============================================================ function toggleLock() { isLocked = !isLocked; if (isLocked) { gsGrid.disable(); } else { gsGrid.enable(); } var btn = document.getElementById('btnLock'); btn.innerHTML = isLocked ? '🔒' : '🔓'; btn.className = 'btn-icon' + (isLocked ? ' locked' : ''); saveLayout(); } // ============================================================ // DASHBOARD - Settings Panel // ============================================================ function showSettings() { var body = document.getElementById('settingsBody'); body.innerHTML = ''; var visible = getVisiblePanels(); for (var i = 0; i < PANEL_ORDER.length; i++) { var id = PANEL_ORDER[i]; var def = WIDGET_DEFS[id]; var label = document.createElement('label'); var cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = visible.indexOf(id) !== -1; cb.setAttribute('data-panel', id); cb.onchange = function() { var panelId = this.getAttribute('data-panel'); if (this.checked) { showPanel(panelId); } else { hidePanel(panelId); } }; label.appendChild(cb); label.appendChild(document.createTextNode(def.title)); body.appendChild(label); } document.getElementById('settingsOverlay').classList.add('show'); } function hideSettings() { document.getElementById('settingsOverlay').classList.remove('show'); } function onSettingsOverlayClick(e) { if (e.target === document.getElementById('settingsOverlay')) { hideSettings(); } } function updateSettingsToggles() { var overlay = document.getElementById('settingsOverlay'); if (!overlay.classList.contains('show')) return; var visible = getVisiblePanels(); var checkboxes = overlay.querySelectorAll('input[data-panel]'); for (var i = 0; i < checkboxes.length; i++) { checkboxes[i].checked = visible.indexOf(checkboxes[i].getAttribute('data-panel')) !== -1; } } // ============================================================ // DASHBOARD - WebSocket // ============================================================ function connectWebSocket() { var wsUrl = 'ws://' + window.location.hostname + ':{{WS_PORT}}/'; ws = new WebSocket(wsUrl); ws.binaryType = 'arraybuffer'; ws.onopen = function() { state.connected = true; lastWsMessageTime = Date.now(); reconnectDelay = 300; reconnectCount = 0; updateConnectionStatus(true); startPolling(); startClientPing(); startHeartbeatCheck(); if (currentTab === 'preview') { // 재연결 직후 프리뷰 구독 지연 — 연결 안정화 대기 setTimeout(function() { if (state.connected && currentTab === 'preview') previewSubscribe(); }, isIOS ? 1500 : 300); } }; ws.onclose = function() { state.connected = false; previewUnsubscribe(); stopHeartbeatCheck(); stopClientPing(); updateConnectionStatus(false); stopPolling(); reconnectCount++; reconnectDelay = Math.min(reconnectDelay * 1.5, 5000); setTimeout(connectWebSocket, reconnectDelay); }; ws.onerror = function(e) { console.error('WS Error:', e); }; ws.onmessage = function(e) { lastWsMessageTime = Date.now(); if (e.data instanceof ArrayBuffer) { handlePreviewBinary(e.data); } else { try { handleMessage(JSON.parse(e.data)); } catch(err) { console.error('Parse error:', err); } } }; } function send(data) { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(data)); } } function startPolling() { stopPolling(); statusInterval = setInterval(function() { send({ type: 'get_system_status' }); }, 5000); send({ type: 'get_system_status' }); } function stopPolling() { if (statusInterval) { clearInterval(statusInterval); statusInterval = null; } } // 클라이언트→서버 keepalive (iOS WiFi 절전으로 인한 연결 끊김 방지) // iOS는 클라이언트→서버 방향 트래픽이 없으면 WiFi를 절전 모드로 전환하여 WS 연결을 끊음 function startClientPing() { stopClientPing(); clientPingInterval = setInterval(function() { if (ws && ws.readyState === WebSocket.OPEN) { ws.send('{"type":"client_ping"}'); } }, 3000); } function stopClientPing() { if (clientPingInterval) { clearInterval(clientPingInterval); clientPingInterval = null; } } // iPad 등에서 WebSocket이 죽어도 onclose가 안 뜨는 경우 감지 → 강제 재연결 function startHeartbeatCheck() { stopHeartbeatCheck(); heartbeatCheckInterval = setInterval(function() { if (!state.connected) return; // 12초간 어떤 메시지도 수신 못 했으면 → 연결 죽은 것 // (서버 keepalive 5초 + 클라이언트 ping/pong 3초 — 여유 있게 12초) if (Date.now() - lastWsMessageTime > 12000) { console.log('[WS] 하트비트 타임아웃 — 강제 재연결'); forceReconnect(); } }, 3000); } function stopHeartbeatCheck() { if (heartbeatCheckInterval) { clearInterval(heartbeatCheckInterval); heartbeatCheckInterval = null; } } function forceReconnect() { stopHeartbeatCheck(); stopClientPing(); stopPolling(); previewUnsubscribe(); state.connected = false; updateConnectionStatus(false); try { ws.close(); } catch(e) {} ws = null; reconnectCount++; reconnectDelay = Math.min(reconnectDelay * 1.5, 5000); setTimeout(connectWebSocket, reconnectDelay); } // ============================================================ // DASHBOARD - Connection Status // ============================================================ function updateConnectionStatus(c) { var s = document.getElementById('connectionStatus'); s.textContent = c ? '연결됨' : '연결 끊김'; s.className = 'connection-status ' + (c ? 'connected' : 'disconnected'); } // ============================================================ // DASHBOARD - Message Handling // ============================================================ function handleMessage(msg) { switch (msg.type) { case 'connection_established': handleInitialState(msg.data); break; case 'camera_changed': state.cameras = msg.data.camera_data; state.currentCamera = msg.data.current_camera; renderCameras(); break; case 'item_changed': state.items = msg.data.item_data; state.currentItem = msg.data.current_item; renderItems(); break; case 'event_changed': state.events = msg.data.event_data; state.currentEvent = msg.data.current_event; renderEvents(); break; case 'avatar_outfit_changed': state.avatars = msg.data.avatar_outfit_data; state.currentAvatar = msg.data.current_avatar_outfit; renderAvatars(); break; case 'system_status_response': state.system = msg.data; renderHealth(); break; case 'pong': case 'keepalive': case 'client_pong': break; case 'camera_preview_frame': handlePreviewBase64(msg.data); break; case 'drone_state_response': showToast(msg.data.is_drone_mode ? 'Drone Mode ON' : 'Drone Mode OFF', 'success'); break; } } function handleInitialState(data) { state.cameras = data.camera_data; state.currentCamera = data.current_camera; state.items = data.item_data; state.currentItem = data.current_item; state.events = data.event_data; state.currentEvent = data.current_event; state.avatars = data.avatar_outfit_data; state.currentAvatar = data.current_avatar_outfit; renderAll(); showToast('연결됨', 'success'); } // ============================================================ // DASHBOARD - Render Functions // ============================================================ function renderAll() { renderCameras(); renderItems(); renderEvents(); renderAvatars(); renderHealth(); } function renderCameras() { var grid = document.getElementById('cameraGrid'); var badge = document.getElementById('camera-badge'); if (!grid) return; if (!state.cameras || !state.cameras.presets) { grid.innerHTML = '
카메라 없음
'; if (badge) badge.textContent = '0'; return; } if (badge) badge.textContent = state.cameras.camera_count; grid.innerHTML = ''; for (var i = 0; i < state.cameras.presets.length; i++) { var p = state.cameras.presets[i]; var btn = document.createElement('button'); btn.className = 'preset-btn' + (p.isActive ? ' active' : ''); btn.textContent = p.name; btn.setAttribute('data-index', p.index); btn.onclick = function() { send({ type: 'switch_camera', data: { camera_index: parseInt(this.getAttribute('data-index')) } }); }; grid.appendChild(btn); } } function renderItems() { var grid = document.getElementById('itemGrid'); var badge = document.getElementById('item-badge'); if (!grid) return; if (!state.items || !state.items.items) { grid.innerHTML = '
아이템 없음
'; if (badge) badge.textContent = '0'; return; } if (badge) badge.textContent = state.items.item_count; grid.innerHTML = ''; for (var i = 0; i < state.items.items.length; i++) { var item = state.items.items[i]; var btn = document.createElement('button'); btn.className = 'preset-btn' + (item.isActive ? ' item-active' : ''); btn.textContent = (item.isActive ? '\u2611 ' : '\u2610 ') + item.name; btn.setAttribute('data-index', item.index); btn.onclick = function() { send({ type: 'toggle_item', data: { item_index: parseInt(this.getAttribute('data-index')) } }); }; grid.appendChild(btn); } } function renderEvents() { var grid = document.getElementById('eventGrid'); var badge = document.getElementById('event-badge'); if (!grid) return; if (!state.events || !state.events.events) { grid.innerHTML = '
이벤트 없음
'; if (badge) badge.textContent = '0'; return; } if (badge) badge.textContent = state.events.event_count; grid.innerHTML = ''; for (var i = 0; i < state.events.events.length; i++) { var evt = state.events.events[i]; var btn = document.createElement('button'); btn.className = 'preset-btn event-btn'; btn.textContent = '\u25B6 ' + evt.name; btn.setAttribute('data-index', evt.index); btn.onclick = function() { var idx = parseInt(this.getAttribute('data-index')); send({ type: 'execute_event', data: { event_index: idx } }); showToast('Event: ' + this.textContent.substring(2), 'success'); }; grid.appendChild(btn); } } function renderAvatars() { var container = document.getElementById('avatarContainer'); var badge = document.getElementById('avatar-badge'); if (!container) return; if (!state.avatars || !state.avatars.avatars) { container.innerHTML = '
아바타 없음
'; if (badge) badge.textContent = '0'; return; } if (badge) badge.textContent = state.avatars.avatar_count; container.innerHTML = ''; for (var a = 0; a < state.avatars.avatars.length; a++) { var avatar = state.avatars.avatars[a]; var group = document.createElement('div'); group.className = 'avatar-group'; var nameDiv = document.createElement('div'); nameDiv.className = 'avatar-name'; nameDiv.textContent = avatar.name; group.appendChild(nameDiv); var outfitGrid = document.createElement('div'); outfitGrid.className = 'outfit-grid'; if (avatar.outfits) { for (var o = 0; o < avatar.outfits.length; o++) { var outfit = avatar.outfits[o]; var btn = document.createElement('button'); btn.className = 'preset-btn' + (outfit.index === avatar.current_outfit_index ? ' active' : ''); btn.textContent = outfit.name; btn.setAttribute('data-avatar', avatar.index); btn.setAttribute('data-outfit', outfit.index); btn.onclick = function() { send({ type: 'set_avatar_outfit', data: { avatar_index: parseInt(this.getAttribute('data-avatar')), outfit_index: parseInt(this.getAttribute('data-outfit')) } }); }; outfitGrid.appendChild(btn); } } group.appendChild(outfitGrid); container.appendChild(group); } } function renderHealth() { if (!state.system) return; var s = state.system; setHealth('health-optitrack', s.optitrack && s.optitrack.connected, 'OptiTrack'); var facialCount = (s.facial_motion && s.facial_motion.client_count) || 0; setHealth('health-facial', facialCount > 0, 'Facial(' + facialCount + ')'); var isRec = s.recording && s.recording.is_recording; var recEl = document.getElementById('health-recording'); if (recEl) { var dot = recEl.querySelector('.health-dot'); dot.className = 'health-dot ' + (isRec ? 'recording' : 'offline'); recEl.querySelector('span:last-child').textContent = isRec ? 'REC' : 'IDLE'; } var remoteRunning = s.retargeting_remote && s.retargeting_remote.is_running; setHealth('health-remote', remoteRunning, 'Remote'); var clientCount = (s.websocket && s.websocket.connected_clients) || 0; setHealth('health-clients', true, 'WS(' + clientCount + ')'); var btnRec = document.getElementById('btnStartRec'); if (btnRec) { btnRec.textContent = isRec ? 'Recording...' : 'Start Rec'; btnRec.className = isRec ? 'btn btn-danger btn-sm' : 'btn btn-primary btn-sm'; } } function setHealth(elementId, online, label) { var el = document.getElementById(elementId); if (!el) return; var dot = el.querySelector('.health-dot'); dot.className = 'health-dot ' + (online ? 'online' : 'offline'); el.querySelector('span:last-child').textContent = label; } // ============================================================ // DASHBOARD - Commands // ============================================================ function sendCommand(cmd) { send({ type: cmd }); showToast(cmd.replace(/_/g, ' '), 'success'); } function requestFullState() { send({ type: 'get_full_state' }); send({ type: 'get_system_status' }); showToast('Refreshing...', 'success'); } // ============================================================ // SHARED - Toast // ============================================================ function showToast(msg, type) { var t = document.getElementById('toast'); t.textContent = msg; t.className = 'toast show ' + (type || ''); setTimeout(function() { t.className = 'toast'; }, 2000); } // ============================================================ // RETARGETING - WebSocket // ============================================================ function rtConnectWebSocket() { var rtWsPort = '{{RETARGETING_WS_PORT}}'; if (!rtWsPort || rtWsPort === '0') { rtUpdateConnectionStatus(false, '리타게팅 포트 미설정'); return; } var wsUrl = 'ws://' + window.location.hostname + ':' + rtWsPort + '/retargeting'; rtWs = new WebSocket(wsUrl); rtWs.onopen = function() { rtConnected = true; rtUpdateConnectionStatus(true); rtSend({ action: 'refresh' }); }; rtWs.onclose = function() { rtConnected = false; rtUpdateConnectionStatus(false); setTimeout(function() { if (rtInitialized) rtConnectWebSocket(); }, 3000); }; rtWs.onerror = function(e) { console.error('RT WS Error:', e); }; rtWs.onmessage = function(e) { try { rtHandleMessage(JSON.parse(e.data)); } catch(err) { console.error('RT Parse error:', err); } }; } function rtSend(data) { if (rtWs && rtWs.readyState === WebSocket.OPEN) { rtWs.send(JSON.stringify(data)); } } function rtUpdateConnectionStatus(c, msg) { var s = document.getElementById('rtConnectionStatus'); if (!s) return; s.textContent = msg || (c ? '리타게팅 연결됨' : '리타게팅 연결 끊김'); s.className = 'rt-connection-status ' + (c ? 'connected' : 'disconnected'); } // ============================================================ // RETARGETING - Message Handling // ============================================================ function rtHandleMessage(d) { if (d.type === 'characterList') rtUpdateCharacterList(d.characters); else if (d.type === 'characterData') rtUpdateCharacterData(d); else if (d.type === 'handPosePresets') { rtPresets = d.presets; rtUpdateAllPresetGrids(); } else if (d.type === 'status') showToast(d.message, d.success ? 'success' : 'error'); } function rtUpdateCharacterList(chars) { var container = document.getElementById('charactersContainer'); container.innerHTML = ''; if (chars.length === 0) { container.innerHTML = '
등록된 캐릭터가 없습니다
'; return; } for (var i = 0; i < chars.length; i++) { var charId = chars[i]; container.appendChild(rtCreateCharacterPanel(charId)); rtSend({ action: 'getCharacterData', characterId: charId }); } } // ============================================================ // RETARGETING - Character Panel Creation // ============================================================ function rtCreateCharacterPanel(charId) { var panel = document.createElement('div'); panel.className = 'character-panel'; panel.id = 'panel_' + charId; panel.innerHTML = '
' + '

' + charId + '

' + '
' + '
' + '
' + rtCreateSectionsHTML(charId) + '
' + '
'; return panel; } function rtCreateSectionsHTML(charId) { var html = ''; // 1. Weight Settings html += '
' + '' + '' + '
'; // 2. Hip Position html += '
' + '' + '' + '
'; // 3. Knee Position html += '
' + '' + '' + '
'; // 4. Foot IK html += '
' + '' + '' + '
'; // 5. Hand Pose Presets html += '
' + '' + '' + '
'; // 6. Finger Copy html += '
' + '' + '' + '
'; // 7. Motion Settings html += '
' + '' + '' + '
'; // 8. Floor Height html += '
' + '' + '' + '
'; // 9. Avatar/Head Scale html += '
' + '' + '' + '
'; // 10. Head Rotation Offset html += '
' + '' + '' + '
'; // 11. Calibration html += '
' + '' + '' + '
'; return html; } // ============================================================ // RETARGETING - Slider Helpers // ============================================================ function rtCreateSlider(charId, id, label, min, max, step) { var fullId = id + '_' + charId; return '
' + '
' + '' + '' + '
' + '
' + '' + '' + '' + '
' + '
'; } function rtCreateRangeSlider(charId, minId, maxId, label, min, max, step) { var minFullId = minId + '_' + charId; var maxFullId = maxId + '_' + charId; var fillId = 'rangeFill_' + minId + '_' + charId; return '
' + '
' + '' + '
' + '' + '~' + '' + '
' + '
' + '
' + '
' + '
' + '' + '' + '
' + '
'; } // ============================================================ // RETARGETING - Range Slider Logic // ============================================================ function rtRangeSliderInput(charId, minId, maxId, which) { var minSlider = document.getElementById(minId + '_' + charId); var maxSlider = document.getElementById(maxId + '_' + charId); var minInput = document.getElementById(minId + '_' + charId + 'Input'); var maxInput = document.getElementById(maxId + '_' + charId + 'Input'); var fill = document.getElementById('rangeFill_' + minId + '_' + charId); var minVal = parseFloat(minSlider.value); var maxVal = parseFloat(maxSlider.value); if (which === 'min' && minVal > maxVal) { minSlider.value = maxVal; minVal = maxVal; } if (which === 'max' && maxVal < minVal) { maxSlider.value = minVal; maxVal = minVal; } minInput.value = minVal.toFixed(2); maxInput.value = maxVal.toFixed(2); var rangeMin = parseFloat(minSlider.min); var rangeMax = parseFloat(minSlider.max); var leftPercent = ((minVal - rangeMin) / (rangeMax - rangeMin)) * 100; var rightPercent = ((maxVal - rangeMin) / (rangeMax - rangeMin)) * 100; fill.style.left = leftPercent + '%'; fill.style.width = (rightPercent - leftPercent) + '%'; if (which === 'min') { rtSend({ action: 'updateValueRealtime', characterId: charId, property: minId, value: minVal }); } else { rtSend({ action: 'updateValueRealtime', characterId: charId, property: maxId, value: maxVal }); } } function rtRangeSliderChange(charId, id) { var slider = document.getElementById(id + '_' + charId); rtSend({ action: 'updateValueRealtime', characterId: charId, property: id, value: parseFloat(slider.value) }); } function rtRangeInputChange(charId, minId, maxId, which) { var minInput = document.getElementById(minId + '_' + charId + 'Input'); var maxInput = document.getElementById(maxId + '_' + charId + 'Input'); var minSlider = document.getElementById(minId + '_' + charId); var maxSlider = document.getElementById(maxId + '_' + charId); var minVal = parseFloat(minInput.value); var maxVal = parseFloat(maxInput.value); if (which === 'min' && minVal > maxVal) { minVal = maxVal; minInput.value = minVal.toFixed(2); } if (which === 'max' && maxVal < minVal) { maxVal = minVal; maxInput.value = maxVal.toFixed(2); } minSlider.value = minVal; maxSlider.value = maxVal; rtRangeSliderInput(charId, minId, maxId, which); } function rtUpdateRangeSlider(charId, minId, maxId, minVal, maxVal) { var minSlider = document.getElementById(minId + '_' + charId); var maxSlider = document.getElementById(maxId + '_' + charId); var minInput = document.getElementById(minId + '_' + charId + 'Input'); var maxInput = document.getElementById(maxId + '_' + charId + 'Input'); var fill = document.getElementById('rangeFill_' + minId + '_' + charId); if (!minSlider || !maxSlider) return; minSlider.value = minVal; maxSlider.value = maxVal; minInput.value = minVal.toFixed(2); maxInput.value = maxVal.toFixed(2); var rangeMin = parseFloat(minSlider.min); var rangeMax = parseFloat(minSlider.max); var leftPercent = ((minVal - rangeMin) / (rangeMax - rangeMin)) * 100; var rightPercent = ((maxVal - rangeMin) / (rangeMax - rangeMin)) * 100; fill.style.left = leftPercent + '%'; fill.style.width = (rightPercent - leftPercent) + '%'; } // ============================================================ // RETARGETING - Section Toggle // ============================================================ function rtToggleSection(header) { header.classList.toggle('collapsed'); header.nextElementSibling.classList.toggle('hidden'); } function rtExpandAll() { var headers = document.querySelectorAll('#tabRetargeting .rt-section-header'); headers.forEach(function(h) { h.classList.remove('collapsed'); h.nextElementSibling.classList.remove('hidden'); }); } function rtCollapseAll() { var headers = document.querySelectorAll('#tabRetargeting .rt-section-header'); headers.forEach(function(h) { h.classList.add('collapsed'); h.nextElementSibling.classList.add('hidden'); }); } // ============================================================ // RETARGETING - Character Data Update // ============================================================ function rtUpdateCharacterData(r) { var charId = r.characterId; var data = r.data; rtCharactersData[charId] = data; var fields = ['hipsVertical','hipsForward','hipsHorizontal','kneeFrontBackWeight','kneeInOutWeight', 'feetForwardBackward','feetNarrow','floorHeight','avatarScale','headScale', 'weightSmoothSpeed','chairSeatHeightOffset', 'filterBufferSize','bodyRoughness', 'headRotationOffsetX','headRotationOffsetY','headRotationOffsetZ']; for (var i = 0; i < fields.length; i++) { rtSetSliderValue(charId, fields[i], data[fields[i]]); } rtUpdateRangeSlider(charId, 'limbMinDistance', 'limbMaxDistance', data.limbMinDistance, data.limbMaxDistance); rtUpdateRangeSlider(charId, 'hipsMinDistance', 'hipsMaxDistance', data.hipsMinDistance, data.hipsMaxDistance); rtUpdateRangeSlider(charId, 'groundHipsMinHeight', 'groundHipsMaxHeight', data.groundHipsMinHeight, data.groundHipsMaxHeight); rtUpdateRangeSlider(charId, 'footHeightMinThreshold', 'footHeightMaxThreshold', data.footHeightMinThreshold, data.footHeightMaxThreshold); var fingerMode = document.getElementById('fingerCopyMode_' + charId); if (fingerMode) { fingerMode.value = data.fingerCopyMode; rtUpdateMingleSettingsVisibility(charId); } rtSetCheckbox(charId, 'handPoseEnabled', data.handPoseEnabled); rtSetCheckbox(charId, 'leftHandEnabled', data.leftHandEnabled); rtSetCheckbox(charId, 'rightHandEnabled', data.rightHandEnabled); rtSetCheckbox(charId, 'useMotionFilter', data.useMotionFilter); rtSetCheckbox(charId, 'useBodyRoughMotion', data.useBodyRoughMotion); var cs = document.getElementById('calibrationStatus_' + charId); if (cs) { cs.textContent = data.hasCalibrationData ? '캘리브레이션 데이터가 저장되어 있습니다.' : '저장된 캘리브레이션 데이터가 없습니다.'; cs.className = 'rt-calibration-status' + (data.hasCalibrationData ? ' has-data' : ''); } rtUpdatePresetGrid(charId); } function rtSetSliderValue(charId, id, val) { var sl = document.getElementById(id + '_' + charId); var inp = document.getElementById(id + '_' + charId + 'Input'); if (sl && val !== undefined && val !== null) sl.value = val; if (inp && val !== undefined && val !== null) inp.value = Number(val).toFixed(3); } function rtSetCheckbox(charId, id, checked) { var cb = document.getElementById(id + '_' + charId); if (cb) cb.checked = checked; } // ============================================================ // RETARGETING - Slider Interactions // ============================================================ function rtSliderRealtime(charId, id) { var sl = document.getElementById(id + '_' + charId); var inp = document.getElementById(id + '_' + charId + 'Input'); if (inp) inp.value = Number(sl.value).toFixed(3); rtSend({ action: 'updateValueRealtime', characterId: charId, property: id, value: parseFloat(sl.value) }); } function rtInputValueChange(charId, id) { var sl = document.getElementById(id + '_' + charId); var inp = document.getElementById(id + '_' + charId + 'Input'); if (sl && inp) { var val = parseFloat(inp.value); if (!isNaN(val)) { val = Math.max(parseFloat(sl.min), Math.min(parseFloat(sl.max), val)); sl.value = val; inp.value = Number(val).toFixed(3); rtSend({ action: 'updateValueRealtime', characterId: charId, property: id, value: val }); } } } function rtInputKeyDown(event, charId, id) { if (event.key === 'Enter') { event.preventDefault(); rtInputValueChange(charId, id); event.target.blur(); } } function rtSliderFinish(charId, id) { // drag complete } function rtFineAdjust(charId, id, delta) { var sl = document.getElementById(id + '_' + charId); var inp = document.getElementById(id + '_' + charId + 'Input'); var nv = Math.max(parseFloat(sl.min), Math.min(parseFloat(sl.max), parseFloat(sl.value) + delta)); sl.value = nv; if (inp) inp.value = Number(nv).toFixed(3); rtSend({ action: 'updateValueRealtime', characterId: charId, property: id, value: nv }); } // ============================================================ // RETARGETING - Toggles & Controls // ============================================================ function rtUpdateToggle(charId, prop, checked) { rtSend({ action: 'updateValueRealtime', characterId: charId, property: prop, value: checked ? 1 : 0 }); if ((prop === 'leftHandEnabled' || prop === 'rightHandEnabled') && checked) { var scriptCheckbox = document.getElementById('handPoseEnabled_' + charId); if (scriptCheckbox) scriptCheckbox.checked = true; } } function rtUpdateHandPoseEnabled(charId, checked) { rtSend({ action: 'updateValueRealtime', characterId: charId, property: 'handPoseEnabled', value: checked ? 1 : 0 }); } function rtUpdateFingerMode(charId, val) { rtSend({ action: 'updateValueRealtime', characterId: charId, property: 'fingerCopyMode', value: parseFloat(val) }); rtUpdateMingleSettingsVisibility(charId); } function rtUpdateMingleSettingsVisibility(charId) { var mingleDiv = document.getElementById('mingleSettings_' + charId); var fingerMode = document.getElementById('fingerCopyMode_' + charId); if (mingleDiv && fingerMode) { mingleDiv.style.display = fingerMode.value === '3' ? 'block' : 'none'; } } // ============================================================ // RETARGETING - Calibration & Reset // ============================================================ function rtCalibrateIPose(charId) { rtSend({ action: 'calibrateIPose', characterId: charId }); } function rtResetCalibration(charId) { rtSend({ action: 'resetCalibration', characterId: charId }); } function rtCalibrateMingleOpen(charId) { rtSend({ action: 'calibrateMingleOpen', characterId: charId }); showToast('펼침 상태 기록됨', 'success'); } function rtCalibrateMingleClose(charId) { rtSend({ action: 'calibrateMingleClose', characterId: charId }); showToast('모음 상태 기록됨', 'success'); } function rtResetHips(charId) { ['hipsVertical','hipsForward','hipsHorizontal'].forEach(function(id) { rtSetSliderValue(charId, id, 0); rtSend({ action: 'updateValueRealtime', characterId: charId, property: id, value: 0 }); }); } function rtResetHeadRotation(charId) { ['headRotationOffsetX','headRotationOffsetY','headRotationOffsetZ'].forEach(function(id) { rtSetSliderValue(charId, id, 0); rtSend({ action: 'updateValueRealtime', characterId: charId, property: id, value: 0 }); }); } function rtCalibrateHeadForward(charId) { rtSend({ action: 'calibrateHeadForward', characterId: charId }); showToast('정면 캘리브레이션 실행됨', 'success'); setTimeout(function() { rtSend({ action: 'getCharacterData', characterId: charId }); }, 100); } // ============================================================ // RETARGETING - Preset Grid // ============================================================ function rtUpdateAllPresetGrids() { for (var charId in rtCharactersData) { rtUpdatePresetGrid(charId); } } function rtUpdatePresetGrid(charId) { var grid = document.getElementById('presetGrid_' + charId); if (!grid) return; grid.innerHTML = ''; for (var i = 0; i < rtPresets.length; i++) { var btn = document.createElement('button'); btn.className = 'rt-preset-btn'; btn.textContent = rtPresets[i]; btn.setAttribute('data-name', rtPresets[i]); btn.setAttribute('data-char', charId); btn.onclick = function() { rtApplyPreset(this.getAttribute('data-char'), this.getAttribute('data-name'), 'leftHandPreset'); }; btn.oncontextmenu = function(e) { e.preventDefault(); rtApplyPreset(this.getAttribute('data-char'), this.getAttribute('data-name'), 'rightHandPreset'); }; grid.appendChild(btn); } } function rtApplyPreset(charId, name, hand) { rtSend({ action: 'setHandPosePreset', characterId: charId, property: hand, stringValue: name }); var scriptCheckbox = document.getElementById('handPoseEnabled_' + charId); if (scriptCheckbox) scriptCheckbox.checked = true; var handCheckbox = document.getElementById((hand === 'leftHandPreset' ? 'leftHandEnabled' : 'rightHandEnabled') + '_' + charId); if (handCheckbox) handCheckbox.checked = true; } // ============================================================ // RETARGETING - Refresh // ============================================================ function rtRefresh() { if (!rtConnected) { rtConnectWebSocket(); return; } rtSend({ action: 'refresh' }); showToast('새로고침...', 'success'); } // ============================================================ // CAMERA PREVIEW - Functions // ============================================================ function initPreviewGrid() { var grid = document.getElementById('previewGrid'); if (!grid || !state.cameras || !state.cameras.presets) return; grid.innerHTML = ''; previewImages = {}; for (var i = 0; i < state.cameras.presets.length; i++) { var p = state.cameras.presets[i]; previewImages[p.index] = { name: p.name, isActive: p.isActive }; var card = createPreviewCard(p.index, p.name, p.isActive); grid.appendChild(card); } var info = document.getElementById('previewInfo'); if (info) info.textContent = '카메라 ' + state.cameras.camera_count + '개'; } function createPreviewCard(index, name, isActive) { var card = document.createElement('div'); card.className = 'preview-card' + (isActive ? ' active' : ''); card.id = 'previewCard-' + index; var placeholder = document.createElement('div'); placeholder.className = 'preview-placeholder'; placeholder.id = 'previewPlaceholder-' + index; placeholder.textContent = '프리뷰 대기중...'; // Canvas 사용 — Blob URL 생성 없이 직접 그리기 var canvas = document.createElement('canvas'); canvas.id = 'previewCanvas-' + index; canvas.className = 'preview-canvas'; canvas.width = 320; canvas.height = 180; canvas.style.display = 'none'; var label = document.createElement('div'); label.className = 'preview-card-label'; var nameSpan = document.createElement('span'); nameSpan.className = 'preview-card-name'; nameSpan.textContent = name; label.appendChild(nameSpan); if (isActive) { var badge = document.createElement('span'); badge.className = 'preview-card-badge'; badge.textContent = 'LIVE'; label.appendChild(badge); } var expandBtn = document.createElement('button'); expandBtn.className = 'preview-card-expand'; expandBtn.innerHTML = '⛶'; expandBtn.title = '풀스크린'; expandBtn.onclick = function(e) { e.stopPropagation(); showPreviewFullscreen(index); }; label.appendChild(expandBtn); card.appendChild(placeholder); if (isIOS) { // iOS: 태그 사용 (base64 텍스트 모드) var img = document.createElement('img'); img.id = 'previewImg-' + index; img.style.width = '100%'; img.style.aspectRatio = '16/9'; img.style.objectFit = 'cover'; img.style.display = 'none'; img.style.background = '#111'; card.appendChild(img); } else { card.appendChild(canvas); } card.appendChild(label); card.onclick = function() { send({ type: 'switch_camera', data: { camera_index: index } }); showToast(name + ' 전환', 'success'); }; return card; } // 바이너리 WebSocket 프레임 파싱 + Canvas 렌더링 // 프로토콜: [index(1)][total(1)][flags(1)][nameLen(1)][name(N)][jpeg...] // Blob URL 대신 createImageBitmap + Canvas로 직접 그리기 → Network 탭 오염 없음 function handlePreviewBinary(arrayBuffer) { previewLastFrameTime = Date.now(); var buf = new Uint8Array(arrayBuffer); if (buf.length < 5) return; var cameraIndex = buf[0]; var totalCameras = buf[1]; var isActive = (buf[2] & 1) === 1; var nameLen = buf[3]; var cameraName = new TextDecoder().decode(buf.subarray(4, 4 + nameLen)); var jpegData = buf.subarray(4 + nameLen); // 디코드 잠금: 이전 디코딩 중이면 스킵, 단 3초 이상 걸리면 타임아웃으로 간주 var now = Date.now(); var decodeStart = previewDecodeStartTime[cameraIndex] || 0; if (decodeStart > 0 && now - decodeStart < 3000) return; previewDecodeStartTime[cameraIndex] = now; var blob = new Blob([jpegData], { type: 'image/jpeg' }); createImageBitmap(blob).then(function(bitmap) { previewDecodeStartTime[cameraIndex] = 0; var canvas = document.getElementById('previewCanvas-' + cameraIndex); if (canvas) { canvas.width = bitmap.width; canvas.height = bitmap.height; var ctx = canvas.getContext('2d'); ctx.drawImage(bitmap, 0, 0); canvas.style.display = 'block'; } var placeholder = document.getElementById('previewPlaceholder-' + cameraIndex); if (placeholder) placeholder.style.display = 'none'; if (previewFullscreenIndex === cameraIndex) { var fsCanvas = document.getElementById('previewFullscreenCanvas'); if (fsCanvas) { fsCanvas.width = bitmap.width; fsCanvas.height = bitmap.height; var fsCtx = fsCanvas.getContext('2d'); fsCtx.drawImage(bitmap, 0, 0); } } bitmap.close(); }).catch(function() { previewDecodeStartTime[cameraIndex] = 0; }); // 상태 저장 (비동기 디코딩 전에 저장) previewImages[cameraIndex] = { name: cameraName, isActive: isActive }; // active 카드 스타일 + LIVE 배지 업데이트 var card = document.getElementById('previewCard-' + cameraIndex); if (card) { card.className = 'preview-card' + (isActive ? ' active' : ''); var badge = card.querySelector('.preview-card-badge'); var label = card.querySelector('.preview-card-label'); if (isActive && !badge && label) { var b = document.createElement('span'); b.className = 'preview-card-badge'; b.textContent = 'LIVE'; label.appendChild(b); } else if (!isActive && badge) { badge.remove(); } } // 그리드가 없으면 생성 if (!document.getElementById('previewCard-' + cameraIndex)) { initPreviewGrid(); } } // iOS용 base64 텍스트 프리뷰 프레임 처리 (바이너리 WS 대신 텍스트 JSON으로 수신) // createImageBitmap + canvas 대신 src에 직접 data URL 설정 → iOS WebKit에서 안정적 function handlePreviewBase64(data) { previewLastFrameTime = Date.now(); var cameraIndex = data.camera_index; var cameraName = data.camera_name; var isActive = data.is_active; var totalCameras = data.total_cameras; var img = document.getElementById('previewImg-' + cameraIndex); if (img) { img.src = 'data:image/jpeg;base64,' + data.image; img.style.display = 'block'; } var placeholder = document.getElementById('previewPlaceholder-' + cameraIndex); if (placeholder) placeholder.style.display = 'none'; // 풀스크린 업데이트 if (previewFullscreenIndex === cameraIndex) { var fsImg = document.getElementById('previewFullscreenImg'); if (fsImg) fsImg.src = 'data:image/jpeg;base64,' + data.image; } // 상태 저장 previewImages[cameraIndex] = { name: cameraName, isActive: isActive }; // active 카드 스타일 + LIVE 배지 var card = document.getElementById('previewCard-' + cameraIndex); if (card) { card.className = 'preview-card' + (isActive ? ' active' : ''); var badge = card.querySelector('.preview-card-badge'); var label = card.querySelector('.preview-card-label'); if (isActive && !badge && label) { var b = document.createElement('span'); b.className = 'preview-card-badge'; b.textContent = 'LIVE'; label.appendChild(b); } else if (!isActive && badge) { badge.remove(); } } if (!document.getElementById('previewCard-' + cameraIndex)) { initPreviewGrid(); } } function updatePreviewSettings() { var res = document.getElementById('previewResolution').value.split('x'); var quality = parseInt(document.getElementById('previewQuality').value); var renderInterval = parseInt(document.getElementById('previewRenderInterval').value); send({ type: 'update_preview_settings', data: { width: parseInt(res[0]), height: parseInt(res[1]), quality: quality, render_interval: renderInterval } }); showToast('프리뷰 설정 변경', 'success'); } function updatePreviewColumns() { var cols = parseInt(document.getElementById('previewColumns').value); var grid = document.getElementById('previewGrid'); if (!grid) return; // 480px 이하에서는 커스텀 열 무시, 1열 고정 if (window.innerWidth <= 480) { grid.style.gridTemplateColumns = ''; grid.classList.add('mobile-single-col'); } else { grid.classList.remove('mobile-single-col'); if (cols === 0) { grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(300px, 1fr))'; } else { grid.style.gridTemplateColumns = 'repeat(' + cols + ', 1fr)'; } } try { localStorage.setItem('streamingle-preview-columns', cols); } catch(e) {} } // 저장된 열 설정 복원 + 리사이즈 대응 (function() { try { var saved = localStorage.getItem('streamingle-preview-columns'); if (saved) { var sel = document.getElementById('previewColumns'); if (sel) { sel.value = saved; updatePreviewColumns(); } } } catch(e) {} var resizeTimer; window.addEventListener('resize', function() { clearTimeout(resizeTimer); resizeTimer = setTimeout(updatePreviewColumns, 150); }); })(); function showPreviewFullscreen(index) { previewFullscreenIndex = index; var data = previewImages[index]; if (!data) return; var overlay = document.createElement('div'); overlay.className = 'preview-fullscreen'; overlay.id = 'previewFullscreen'; var closeBtn = document.createElement('button'); closeBtn.className = 'preview-fullscreen-close'; closeBtn.innerHTML = '✕'; closeBtn.onclick = closePreviewFullscreen; var mediaEl; if (isIOS) { // iOS: 태그 사용 (base64 모드) mediaEl = document.createElement('img'); mediaEl.id = 'previewFullscreenImg'; mediaEl.style.maxWidth = '95vw'; mediaEl.style.maxHeight = '85vh'; mediaEl.style.objectFit = 'contain'; var srcImg = document.getElementById('previewImg-' + index); if (srcImg) mediaEl.src = srcImg.src; } else { // PC/Android: Canvas 사용 (바이너리 모드) mediaEl = document.createElement('canvas'); mediaEl.id = 'previewFullscreenCanvas'; mediaEl.className = 'preview-fullscreen-canvas'; mediaEl.width = 320; mediaEl.height = 180; var srcCanvas = document.getElementById('previewCanvas-' + index); if (srcCanvas && srcCanvas.width > 0) { mediaEl.width = srcCanvas.width; mediaEl.height = srcCanvas.height; var ctx = mediaEl.getContext('2d'); ctx.drawImage(srcCanvas, 0, 0); } } var label = document.createElement('div'); label.className = 'preview-fullscreen-label'; label.textContent = data.name; overlay.appendChild(closeBtn); overlay.appendChild(mediaEl); overlay.appendChild(label); overlay.onclick = function(e) { if (e.target === overlay) closePreviewFullscreen(); }; document.body.appendChild(overlay); } function closePreviewFullscreen() { previewFullscreenIndex = -1; var el = document.getElementById('previewFullscreen'); if (el) el.remove(); } // ---- 프리뷰 구독 관리 (중앙화) ---- function previewSubscribe() { if (!state.connected || !ws || ws.readyState !== WebSocket.OPEN) return; // iOS: base64 텍스트 모드 요청 (WebKit 바이너리 WS 불안정 회피) if (isIOS) { send({ type: 'subscribe_preview', data: { format: 'base64' } }); } else { send({ type: 'subscribe_preview' }); } previewSubscribed = true; previewLastFrameTime = Date.now(); startPreviewStaleCheck(); } function previewUnsubscribe() { stopPreviewStaleCheck(); if (previewSubscribed && state.connected && ws && ws.readyState === WebSocket.OPEN) { send({ type: 'unsubscribe_preview' }); } previewSubscribed = false; } // 5초간 프레임 수신이 없으면 서버가 구독을 해제한 것으로 판단 → 자동 재구독 function startPreviewStaleCheck() { stopPreviewStaleCheck(); previewStaleCheckInterval = setInterval(function() { if (!previewSubscribed || currentTab !== 'preview') { stopPreviewStaleCheck(); return; } if (!state.connected || !ws || ws.readyState !== WebSocket.OPEN) return; if (Date.now() - previewLastFrameTime > 5000) { console.log('[Preview] 프레임 수신 정체 감지 — 재구독'); send({ type: 'subscribe_preview' }); previewLastFrameTime = Date.now(); } }, 3000); } function stopPreviewStaleCheck() { if (previewStaleCheckInterval) { clearInterval(previewStaleCheckInterval); previewStaleCheckInterval = null; } } // Page Visibility API - 탭 비활성 시 프리뷰 구독 해제, 복귀 시 재구독 document.addEventListener('visibilitychange', function() { if (document.hidden) { previewUnsubscribe(); } else if (currentTab === 'preview') { // iOS: 탭 복귀 시 약간의 딜레이 후 구독 (WiFi 복구 대기) if (isIOS) { setTimeout(function() { if (!document.hidden && currentTab === 'preview') previewSubscribe(); }, 1500); } else { previewSubscribe(); } } }); // ============================================================ // INIT // ============================================================ window.onload = function() { initGrid(); connectWebSocket(); // Prevent pull-to-refresh on mobile when scrolling within the page document.body.addEventListener('touchmove', function(e) { if (e.target.closest('.widget-body, .characters-container, .character-content, .settings-body, .preview-grid')) return; }, { passive: true }); };