- iOS/iPadOS 감지 → base64 텍스트 JSON 프리뷰 전송 (바이너리 WS 회피) - Android/PC는 기존 바이너리 패킷 유지 (클라이언트별 포맷 분기) - 양방향 WebSocket keepalive (클라이언트 ping 3초 + 서버 keepalive 5초) - websocket-sharp KeepClean + WaitTime 설정 - 하트비트 타임아웃 완화 (12초) + 재연결 지수 백오프 - 재연결 후 프리뷰 구독 딜레이 (iOS 1.5초) - SendMessage/SendBinary에 연결 상태 체크 추가 - 모바일 CSS 반응형 (태블릿/폰 터치 타겟, 그리드, 풀스크린 safe-area) - 대시보드 Cache-Control 강화 (no-store) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1779 lines
71 KiB (Stored with Git LFS)
Plaintext
1779 lines
71 KiB (Stored with Git LFS)
Plaintext
// ============================================================
|
|
// 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 '<div class="button-grid" id="cameraGrid"></div>';
|
|
}
|
|
},
|
|
items: {
|
|
title: 'Items',
|
|
badgeId: 'item-badge',
|
|
defaultPos: { x: 6, y: 0, w: 6, h: 4, minW: 3, minH: 2 },
|
|
content: function() {
|
|
return '<div class="button-grid" id="itemGrid"></div>' +
|
|
'<div class="action-row">' +
|
|
'<button class="btn btn-secondary btn-sm" onclick="sendCommand(\'activate_all_items\')">All On</button>' +
|
|
'<button class="btn btn-secondary btn-sm" onclick="sendCommand(\'deactivate_all_items\')">All Off</button>' +
|
|
'</div>';
|
|
}
|
|
},
|
|
events: {
|
|
title: 'Events',
|
|
badgeId: 'event-badge',
|
|
defaultPos: { x: 0, y: 4, w: 6, h: 3, minW: 3, minH: 2 },
|
|
content: function() {
|
|
return '<div class="button-grid" id="eventGrid"></div>';
|
|
}
|
|
},
|
|
avatars: {
|
|
title: 'Avatars',
|
|
badgeId: 'avatar-badge',
|
|
defaultPos: { x: 6, y: 4, w: 6, h: 4, minW: 3, minH: 2 },
|
|
content: function() {
|
|
return '<div id="avatarContainer"></div>';
|
|
}
|
|
},
|
|
system: {
|
|
title: 'System',
|
|
badgeId: null,
|
|
defaultPos: { x: 0, y: 8, w: 12, h: 5, minW: 4, minH: 3 },
|
|
content: function() {
|
|
return '<div class="system-group">' +
|
|
'<div class="system-group-title">Screenshot</div>' +
|
|
'<div class="action-row">' +
|
|
'<button class="btn btn-primary btn-sm" onclick="sendCommand(\'capture_screenshot\')">Screenshot</button>' +
|
|
'<button class="btn btn-primary btn-sm" onclick="sendCommand(\'capture_alpha_screenshot\')">Alpha</button>' +
|
|
'<button class="btn btn-secondary btn-sm" onclick="sendCommand(\'open_screenshot_folder\')">Open Folder</button>' +
|
|
'</div></div>' +
|
|
'<div class="system-group">' +
|
|
'<div class="system-group-title">Motion Recording</div>' +
|
|
'<div class="action-row">' +
|
|
'<button class="btn btn-danger btn-sm" id="btnStartRec" onclick="sendCommand(\'start_motion_recording\')">Start Rec</button>' +
|
|
'<button class="btn btn-secondary btn-sm" onclick="sendCommand(\'stop_motion_recording\')">Stop Rec</button>' +
|
|
'</div></div>' +
|
|
'<div class="system-group">' +
|
|
'<div class="system-group-title">Reconnect</div>' +
|
|
'<div class="action-row">' +
|
|
'<button class="btn btn-secondary btn-sm" onclick="sendCommand(\'reconnect_optitrack\')">OptiTrack</button>' +
|
|
'<button class="btn btn-secondary btn-sm" onclick="sendCommand(\'reconnect_facial_motion\')">Facial</button>' +
|
|
'</div></div>' +
|
|
'<div class="system-group">' +
|
|
'<div class="system-group-title">Cloth / Markers</div>' +
|
|
'<div class="action-row">' +
|
|
'<button class="btn btn-secondary btn-sm" onclick="sendCommand(\'reset_magica_cloth\')">Reset Cloth</button>' +
|
|
'<button class="btn btn-secondary btn-sm" onclick="sendCommand(\'toggle_optitrack_markers\')">Toggle Markers</button>' +
|
|
'</div></div>' +
|
|
'<div class="system-group">' +
|
|
'<div class="system-group-title">Retargeting Remote</div>' +
|
|
'<div class="action-row">' +
|
|
'<button class="btn btn-secondary btn-sm" onclick="sendCommand(\'toggle_retargeting_remote\')">Toggle Remote</button>' +
|
|
'<button class="btn btn-secondary btn-sm" onclick="sendCommand(\'refresh_retargeting_characters\')">Refresh Characters</button>' +
|
|
'</div></div>';
|
|
}
|
|
}
|
|
};
|
|
|
|
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
|
|
? ' <span class="section-badge" id="' + def.badgeId + '">0</span>'
|
|
: '';
|
|
|
|
var contentHtml =
|
|
'<div class="widget">' +
|
|
'<div class="widget-header">' +
|
|
'<span class="widget-title">' + def.title + badgeHtml + '</span>' +
|
|
'<button class="widget-close" onclick="hidePanel(\'' + id + '\')">×</button>' +
|
|
'</div>' +
|
|
'<div class="widget-body">' + def.content() + '</div>' +
|
|
'</div>';
|
|
|
|
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 = '<div style="color:var(--text-muted);font-size:0.8em">카메라 없음</div>';
|
|
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 = '<div style="color:var(--text-muted);font-size:0.8em">아이템 없음</div>';
|
|
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 = '<div style="color:var(--text-muted);font-size:0.8em">이벤트 없음</div>';
|
|
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 = '<div style="color:var(--text-muted);font-size:0.8em">아바타 없음</div>';
|
|
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 = '<div class="loading-message">등록된 캐릭터가 없습니다</div>';
|
|
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 =
|
|
'<div class="character-header">' +
|
|
'<h2>' + charId + '</h2>' +
|
|
'</div>' +
|
|
'<div class="character-content" id="content_' + charId + '">' +
|
|
'<div class="character-inner">' +
|
|
rtCreateSectionsHTML(charId) +
|
|
'</div>' +
|
|
'</div>';
|
|
|
|
return panel;
|
|
}
|
|
|
|
function rtCreateSectionsHTML(charId) {
|
|
var html = '';
|
|
|
|
// 1. Weight Settings
|
|
html += '<div class="rt-section">' +
|
|
'<div class="rt-section-header collapsed" onclick="rtToggleSection(this)">\u2696\uFE0F 가중치 설정</div>' +
|
|
'<div class="rt-section-content hidden">' +
|
|
'<div class="rt-sub-section-title">손과 프랍과의 범위 (가중치 1\u21920)</div>' +
|
|
rtCreateRangeSlider(charId, 'limbMinDistance', 'limbMaxDistance', '거리 범위', 0, 1, 0.01) +
|
|
'<div class="rt-sub-section-title">의자와 허리 거리 범위 (가중치 1\u21920)</div>' +
|
|
rtCreateRangeSlider(charId, 'hipsMinDistance', 'hipsMaxDistance', '거리 범위', 0, 1, 0.01) +
|
|
'<div class="rt-sub-section-title">바닥과 허리 높이 블렌딩 (가중치 0\u21921)</div>' +
|
|
rtCreateRangeSlider(charId, 'groundHipsMinHeight', 'groundHipsMaxHeight', '높이 범위', 0, 2, 0.01) +
|
|
'<div class="rt-sub-section-title">발 높이 IK 블렌딩 (가중치 1\u21920)</div>' +
|
|
rtCreateRangeSlider(charId, 'footHeightMinThreshold', 'footHeightMaxThreshold', '높이 범위', 0.1, 1, 0.01) +
|
|
'<div class="rt-sub-section-title">가중치 보간</div>' +
|
|
rtCreateSlider(charId, 'weightSmoothSpeed', '변화 속도', 0.1, 20, 0.5) +
|
|
'</div>' +
|
|
'</div>';
|
|
|
|
// 2. Hip Position
|
|
html += '<div class="rt-section">' +
|
|
'<div class="rt-section-header collapsed" onclick="rtToggleSection(this)">\uD83E\uDDB4 힙 위치 보정 (로컬)</div>' +
|
|
'<div class="rt-section-content hidden">' +
|
|
rtCreateSlider(charId, 'hipsHorizontal', '\u2190 좌우 \u2192', -1, 1, 0.001) +
|
|
rtCreateSlider(charId, 'hipsVertical', '\u2193 상하 \u2191', -1, 1, 0.001) +
|
|
rtCreateSlider(charId, 'hipsForward', '\u2190 앞뒤 \u2192', -1, 1, 0.001) +
|
|
'<div class="rt-sub-section-title">의자 앉기 높이</div>' +
|
|
rtCreateSlider(charId, 'chairSeatHeightOffset', '높이 오프셋', -1, 1, 0.01) +
|
|
'<button class="btn btn-secondary" onclick="rtResetHips(\'' + charId + '\')">힙 위치 리셋</button>' +
|
|
'</div>' +
|
|
'</div>';
|
|
|
|
// 3. Knee Position
|
|
html += '<div class="rt-section">' +
|
|
'<div class="rt-section-header collapsed" onclick="rtToggleSection(this)">\uD83E\uDDB5 무릎 위치 조정</div>' +
|
|
'<div class="rt-section-content hidden">' +
|
|
rtCreateSlider(charId, 'kneeFrontBackWeight', '무릎 앞/뒤 가중치', -1, 1, 0.01) +
|
|
rtCreateSlider(charId, 'kneeInOutWeight', '무릎 안/밖 가중치', -1, 1, 0.01) +
|
|
'</div>' +
|
|
'</div>';
|
|
|
|
// 4. Foot IK
|
|
html += '<div class="rt-section">' +
|
|
'<div class="rt-section-header collapsed" onclick="rtToggleSection(this)">\uD83D\uDC5F 발 IK 위치 조정</div>' +
|
|
'<div class="rt-section-content hidden">' +
|
|
rtCreateSlider(charId, 'feetForwardBackward', '발 앞/뒤 오프셋', -1, 1, 0.01) +
|
|
rtCreateSlider(charId, 'feetNarrow', '발 벌리기/모으기', -1, 1, 0.01) +
|
|
'</div>' +
|
|
'</div>';
|
|
|
|
// 5. Hand Pose Presets
|
|
html += '<div class="rt-section">' +
|
|
'<div class="rt-section-header collapsed" onclick="rtToggleSection(this)">\uD83D\uDD90\uFE0F 손가락 제어 설정</div>' +
|
|
'<div class="rt-section-content hidden">' +
|
|
'<div class="rt-toggle-row">' +
|
|
'<label class="rt-toggle-label"><input type="checkbox" id="handPoseEnabled_' + charId + '" onchange="rtUpdateHandPoseEnabled(\'' + charId + '\', this.checked)"> <strong>스크립트 활성화</strong></label>' +
|
|
'</div>' +
|
|
'<div class="rt-toggle-row">' +
|
|
'<label class="rt-toggle-label"><input type="checkbox" id="leftHandEnabled_' + charId + '" onchange="rtUpdateToggle(\'' + charId + '\', \'leftHandEnabled\', this.checked)"> 왼손</label>' +
|
|
'<label class="rt-toggle-label"><input type="checkbox" id="rightHandEnabled_' + charId + '" onchange="rtUpdateToggle(\'' + charId + '\', \'rightHandEnabled\', this.checked)"> 오른손</label>' +
|
|
'</div>' +
|
|
'<div class="rt-preset-hint">클릭: 왼손 | 우클릭: 오른손</div>' +
|
|
'<div class="rt-preset-grid" id="presetGrid_' + charId + '"></div>' +
|
|
'</div>' +
|
|
'</div>';
|
|
|
|
// 6. Finger Copy
|
|
html += '<div class="rt-section">' +
|
|
'<div class="rt-section-header collapsed" onclick="rtToggleSection(this)">\u270B 손가락 복제 설정</div>' +
|
|
'<div class="rt-section-content hidden">' +
|
|
'<div class="rt-control-group">' +
|
|
'<label style="font-size:0.8em;color:var(--text-muted)">복제 방식</label>' +
|
|
'<select id="fingerCopyMode_' + charId + '" onchange="rtUpdateFingerMode(\'' + charId + '\', this.value)">' +
|
|
'<option value="0">없음 (None)</option>' +
|
|
'<option value="1">Muscle Data</option>' +
|
|
'<option value="2">Rotation</option>' +
|
|
'<option value="3">Mingle</option>' +
|
|
'</select>' +
|
|
'</div>' +
|
|
'<div id="mingleSettings_' + charId + '" class="mingle-settings" style="display:none;">' +
|
|
'<div class="rt-sub-section-title">Mingle 캘리브레이션</div>' +
|
|
'<p class="setting-description">소스 아바타의 손가락 회전 범위를 캘리브레이션하여 타겟에 적용합니다.</p>' +
|
|
'<div class="rt-button-group">' +
|
|
'<button class="btn btn-secondary" onclick="rtCalibrateMingleOpen(\'' + charId + '\')">펼침 기록 (Open)</button>' +
|
|
'<button class="btn btn-secondary" onclick="rtCalibrateMingleClose(\'' + charId + '\')">모음 기록 (Close)</button>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'</div>';
|
|
|
|
// 7. Motion Settings
|
|
html += '<div class="rt-section">' +
|
|
'<div class="rt-section-header collapsed" onclick="rtToggleSection(this)">\uD83D\uDCAA 모션 설정</div>' +
|
|
'<div class="rt-section-content hidden">' +
|
|
'<div class="rt-toggle-row">' +
|
|
'<label class="rt-toggle-label"><input type="checkbox" id="useMotionFilter_' + charId + '" onchange="rtUpdateToggle(\'' + charId + '\', \'useMotionFilter\', this.checked)"> 모션 필터 사용</label>' +
|
|
'</div>' +
|
|
'<div id="motionFilterSettings_' + charId + '">' +
|
|
rtCreateSlider(charId, 'filterBufferSize', '필터 버퍼 크기', 1, 20, 1) +
|
|
'</div>' +
|
|
'<div class="rt-toggle-row" style="margin-top:10px;">' +
|
|
'<label class="rt-toggle-label"><input type="checkbox" id="useBodyRoughMotion_' + charId + '" onchange="rtUpdateToggle(\'' + charId + '\', \'useBodyRoughMotion\', this.checked)"> 몸 러프 모션 사용</label>' +
|
|
'</div>' +
|
|
'<div id="bodyRoughSettings_' + charId + '">' +
|
|
rtCreateSlider(charId, 'bodyRoughness', '몸 러프니스', 0, 20, 0.5) +
|
|
'</div>' +
|
|
'</div>' +
|
|
'</div>';
|
|
|
|
// 8. Floor Height
|
|
html += '<div class="rt-section">' +
|
|
'<div class="rt-section-header collapsed" onclick="rtToggleSection(this)">\uD83C\uDFE0 바닥 높이 설정</div>' +
|
|
'<div class="rt-section-content hidden">' +
|
|
rtCreateSlider(charId, 'floorHeight', '바닥 높이 (-1 ~ 1)', -1, 1, 0.01) +
|
|
'</div>' +
|
|
'</div>';
|
|
|
|
// 9. Avatar/Head Scale
|
|
html += '<div class="rt-section">' +
|
|
'<div class="rt-section-header collapsed" onclick="rtToggleSection(this)">\uD83D\uDCCF 아바타 크기 설정</div>' +
|
|
'<div class="rt-section-content hidden">' +
|
|
rtCreateSlider(charId, 'avatarScale', '아바타 크기', 0.1, 3, 0.01) +
|
|
rtCreateSlider(charId, 'headScale', '머리 크기', 0.1, 3, 0.01) +
|
|
'</div>' +
|
|
'</div>';
|
|
|
|
// 10. Head Rotation Offset
|
|
html += '<div class="rt-section">' +
|
|
'<div class="rt-section-header collapsed" onclick="rtToggleSection(this)">\uD83D\uDDE3\uFE0F 머리 회전 오프셋</div>' +
|
|
'<div class="rt-section-content hidden">' +
|
|
rtCreateSlider(charId, 'headRotationOffsetX', 'X (Roll) - 좌우 기울기', -180, 180, 1) +
|
|
rtCreateSlider(charId, 'headRotationOffsetY', 'Y (Yaw) - 좌우 회전', -180, 180, 1) +
|
|
rtCreateSlider(charId, 'headRotationOffsetZ', 'Z (Pitch) - 상하 회전', -180, 180, 1) +
|
|
'<div class="rt-button-group">' +
|
|
'<button class="btn btn-secondary" onclick="rtResetHeadRotation(\'' + charId + '\')">회전 초기화</button>' +
|
|
'<button class="btn btn-primary" onclick="rtCalibrateHeadForward(\'' + charId + '\')">정면 캘리브레이션</button>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'</div>';
|
|
|
|
// 11. Calibration
|
|
html += '<div class="rt-section">' +
|
|
'<div class="rt-section-header collapsed" onclick="rtToggleSection(this)">\uD83D\uDCD0 캘리브레이션</div>' +
|
|
'<div class="rt-section-content hidden">' +
|
|
'<div class="rt-calibration-status" id="calibrationStatus_' + charId + '">데이터 없음</div>' +
|
|
'<div class="rt-button-group">' +
|
|
'<button class="btn btn-primary" onclick="rtCalibrateIPose(\'' + charId + '\')">I-포즈 캘리브레이션</button>' +
|
|
'<button class="btn btn-danger" onclick="rtResetCalibration(\'' + charId + '\')">캘리브레이션 초기화</button>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'</div>';
|
|
|
|
return html;
|
|
}
|
|
|
|
// ============================================================
|
|
// RETARGETING - Slider Helpers
|
|
// ============================================================
|
|
function rtCreateSlider(charId, id, label, min, max, step) {
|
|
var fullId = id + '_' + charId;
|
|
return '<div class="rt-control-group">' +
|
|
'<div class="rt-slider-header">' +
|
|
'<label>' + label + '</label>' +
|
|
'<input type="number" class="rt-value-input" id="' + fullId + 'Input" min="' + min + '" max="' + max + '" step="' + step + '" value="0" ' +
|
|
'onchange="rtInputValueChange(\'' + charId + '\', \'' + id + '\')" ' +
|
|
'onkeydown="rtInputKeyDown(event, \'' + charId + '\', \'' + id + '\')">' +
|
|
'</div>' +
|
|
'<div class="rt-slider-row">' +
|
|
'<button class="rt-btn-fine" onclick="rtFineAdjust(\'' + charId + '\', \'' + id + '\', ' + (-step) + ')">-</button>' +
|
|
'<input type="range" id="' + fullId + '" min="' + min + '" max="' + max + '" step="' + step + '" value="0" ' +
|
|
'oninput="rtSliderRealtime(\'' + charId + '\', \'' + id + '\')" ' +
|
|
'onchange="rtSliderFinish(\'' + charId + '\', \'' + id + '\')">' +
|
|
'<button class="rt-btn-fine" onclick="rtFineAdjust(\'' + charId + '\', \'' + id + '\', ' + step + ')">+</button>' +
|
|
'</div>' +
|
|
'</div>';
|
|
}
|
|
|
|
function rtCreateRangeSlider(charId, minId, maxId, label, min, max, step) {
|
|
var minFullId = minId + '_' + charId;
|
|
var maxFullId = maxId + '_' + charId;
|
|
var fillId = 'rangeFill_' + minId + '_' + charId;
|
|
return '<div class="rt-range-slider-group">' +
|
|
'<div class="rt-range-slider-header">' +
|
|
'<label>' + label + '</label>' +
|
|
'<div class="rt-range-slider-values">' +
|
|
'<input type="number" id="' + minFullId + 'Input" min="' + min + '" max="' + max + '" step="' + step + '" ' +
|
|
'onchange="rtRangeInputChange(\'' + charId + '\', \'' + minId + '\', \'' + maxId + '\', \'min\')">' +
|
|
'<span>~</span>' +
|
|
'<input type="number" id="' + maxFullId + 'Input" min="' + min + '" max="' + max + '" step="' + step + '" ' +
|
|
'onchange="rtRangeInputChange(\'' + charId + '\', \'' + minId + '\', \'' + maxId + '\', \'max\')">' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'<div class="rt-range-slider-container">' +
|
|
'<div class="rt-range-slider-track"></div>' +
|
|
'<div class="rt-range-slider-fill" id="' + fillId + '"></div>' +
|
|
'<input type="range" id="' + minFullId + '" min="' + min + '" max="' + max + '" step="' + step + '" value="' + min + '" ' +
|
|
'oninput="rtRangeSliderInput(\'' + charId + '\', \'' + minId + '\', \'' + maxId + '\', \'min\')" ' +
|
|
'onchange="rtRangeSliderChange(\'' + charId + '\', \'' + minId + '\')">' +
|
|
'<input type="range" id="' + maxFullId + '" min="' + min + '" max="' + max + '" step="' + step + '" value="' + max + '" ' +
|
|
'oninput="rtRangeSliderInput(\'' + charId + '\', \'' + minId + '\', \'' + maxId + '\', \'max\')" ' +
|
|
'onchange="rtRangeSliderChange(\'' + charId + '\', \'' + maxId + '\')">' +
|
|
'</div>' +
|
|
'</div>';
|
|
}
|
|
|
|
// ============================================================
|
|
// 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: <img> 태그 사용 (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 대신 <img> 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: <img> 태그 사용 (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 });
|
|
};
|