using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; using Unity.Collections; using WebSocketSharp.Server; using System.Collections.Generic; using Newtonsoft.Json; using System; using System.Linq; public class StreamDeckServerManager : MonoBehaviour { [Header("WebSocket 서버 설정")] public int port = 64211; [Header("대시보드 설정")] public int dashboardPort = 64210; public bool enableDashboard = true; [Header("카메라 프리뷰 설정")] public int previewWidth = 320; public int previewHeight = 180; [Range(10, 100)] public int jpegQuality = 50; [Range(1, 5)] public int camerasPerFrame = 1; [Range(1, 10)] [Tooltip("N프레임마다 1회 렌더링. 높을수록 GPU 부하 감소, 프리뷰 갱신 느려짐.")] public int renderInterval = 3; private WebSocketServer server; private StreamingleDashboardServer dashboardServer; private List connectedClients = new List(); public CameraManager cameraManager { get; private set; } public ItemController itemController { get; private set; } public EventController eventController { get; private set; } public AvatarOutfitController avatarOutfitController { get; private set; } public SystemController systemController { get; private set; } public static StreamDeckServerManager Instance { get; private set; } private readonly Queue mainThreadActions = new Queue(); private readonly object lockObject = new object(); // WebSocket keep-alive private float lastPingTime = 0f; private const float WsPingInterval = 5f; // 카메라 프리뷰 내부 상태 private readonly List previewCameraPool = new List(); private RenderTexture previewRT; private Texture2D previewReadbackTexture; private int currentPreviewIndex = 0; private int previewFrameCounter = 0; private readonly Dictionary previewPendingCaptures = new Dictionary(); private readonly HashSet previewSubscribers = new HashSet(); private readonly object previewSubscriberLock = new object(); public int ConnectedClientCount => connectedClients.Count; void Awake() { Instance = this; } void Start() { cameraManager = FindObjectOfType(); if (cameraManager == null) { Debug.LogError("[StreamDeckServerManager] CameraManager를 찾을 수 없습니다!"); return; } itemController = FindObjectOfType(); if (itemController == null) Debug.LogWarning("[StreamDeckServerManager] ItemController를 찾을 수 없습니다. 아이템 컨트롤 기능이 비활성화됩니다."); eventController = FindObjectOfType(); if (eventController == null) Debug.LogWarning("[StreamDeckServerManager] EventController를 찾을 수 없습니다. 이벤트 컨트롤 기능이 비활성화됩니다."); avatarOutfitController = FindObjectOfType(); if (avatarOutfitController == null) Debug.LogWarning("[StreamDeckServerManager] AvatarOutfitController를 찾을 수 없습니다. 아바타 의상 컨트롤 기능이 비활성화됩니다."); systemController = FindObjectOfType(); if (systemController == null) Debug.LogWarning("[StreamDeckServerManager] SystemController를 찾을 수 없습니다. 시스템 컨트롤 기능이 비활성화됩니다."); InitializePreview(); StartServer(); StartDashboardServer(); } void Update() { lock (lockObject) { while (mainThreadActions.Count > 0) { var action = mainThreadActions.Dequeue(); try { action?.Invoke(); } catch (Exception ex) { Debug.LogError($"[StreamDeckServerManager] 메인 스레드 작업 실행 오류: {ex.Message}"); } } } // WebSocket 프로토콜 레벨 ping — WiFi 라우터의 유휴 연결 드롭 방지 if (Time.time - lastPingTime >= WsPingInterval) { lastPingTime = Time.time; BroadcastWsPing(); } } void LateUpdate() { PreviewLateUpdate(); } void OnDestroy() { CleanupPreview(); } void OnApplicationQuit() { StopServer(); StopDashboardServer(); } #region Server Lifecycle private void StartServer() { try { // 0.0.0.0 으로 바인딩하여 LAN 내 다른 기기에서도 접속 가능 server = new WebSocketServer(port); server.KeepClean = true; // 비활성 세션 자동 정리 server.WaitTime = TimeSpan.FromSeconds(3); // ping-pong 응답 대기 시간 server.AddWebSocketService("/"); server.Start(); Debug.Log($"[StreamDeckServerManager] WebSocket 서버 시작됨, 포트: {port} (모든 인터페이스)"); } catch (Exception e) { Debug.LogError($"[StreamDeckServerManager] 서버 시작 실패: {e.Message}"); } } private void BroadcastWsPing() { if (server == null || !server.IsListening) return; BroadcastMessage("{\"type\":\"keepalive\"}"); } private void StopServer() { if (server != null) { server.Stop(); server = null; Debug.Log("[StreamDeckServerManager] WebSocket 서버 중지됨"); } } private void StartDashboardServer() { if (!enableDashboard) return; int retargetingWsPort = systemController?.retargetingRemote?.retargetingWsPort ?? 0; dashboardServer = new StreamingleDashboardServer(dashboardPort, port, retargetingWsPort); dashboardServer.Start(); } private void StopDashboardServer() { dashboardServer?.Stop(); dashboardServer = null; } #endregion #region Client Management public void OnClientConnected(StreamDeckService service) { lock (lockObject) { mainThreadActions.Enqueue(() => { connectedClients.Add(service); Debug.Log($"[StreamDeckServerManager] 클라이언트 연결됨. 총 연결: {connectedClients.Count}"); SendInitialData(service); }); } } public void ProcessMessageOnMainThread(string messageData, StreamDeckService service) { lock (lockObject) { mainThreadActions.Enqueue(() => { try { ProcessMessage(messageData, service); } catch (Exception ex) { Debug.LogError($"[StreamDeckServerManager] 메시지 처리 오류: {ex.Message}"); } }); } } public void OnClientDisconnected(StreamDeckService service) { connectedClients.Remove(service); PreviewUnsubscribe(service); Debug.Log($"[StreamDeckServerManager] 클라이언트 연결 해제됨. 총 연결: {connectedClients.Count}"); } public void BroadcastMessage(string message) { foreach (var client in connectedClients.ToArray()) { try { client.SendMessage(message); } catch (Exception e) { Debug.LogError($"[StreamDeckServerManager] 메시지 전송 실패: {e.Message}"); connectedClients.Remove(client); } } } #endregion #region Initial Data private void SendInitialData(StreamDeckService service) { if (cameraManager == null) return; var initialData = new { type = "connection_established", timestamp = DateTime.UtcNow.ToString("o"), version = "1.0", data = new { session_id = Guid.NewGuid().ToString(), message = "유니티 서버에 연결되었습니다!", camera_data = cameraManager.GetCameraListData(), current_camera = cameraManager.GetCurrentCameraState(), item_data = itemController?.GetItemListData(), current_item = itemController?.GetCurrentItemState(), event_data = eventController?.GetEventListData(), current_event = eventController?.GetCurrentEventState(), avatar_outfit_data = avatarOutfitController?.GetAvatarOutfitListData(), current_avatar_outfit = avatarOutfitController?.GetCurrentAvatarOutfitState() } }; string json = JsonConvert.SerializeObject(initialData); service.SendMessage(json); Debug.Log("[StreamDeckServerManager] 초기 데이터 전송됨"); } #endregion #region Broadcast Notifications public void NotifyCameraChanged() { if (cameraManager == null) return; BroadcastJson(new { type = "camera_changed", timestamp = DateTime.UtcNow.ToString("o"), version = "1.0", data = new { camera_data = cameraManager.GetCameraListData(), current_camera = cameraManager.GetCurrentCameraState() } }); } public void NotifyItemChanged() { if (itemController == null) return; BroadcastJson(new { type = "item_changed", timestamp = DateTime.UtcNow.ToString("o"), version = "1.0", data = new { item_data = itemController.GetItemListData(), current_item = itemController.GetCurrentItemState() } }); } public void NotifyEventChanged() { if (eventController == null) return; BroadcastJson(new { type = "event_changed", timestamp = DateTime.UtcNow.ToString("o"), version = "1.0", data = new { event_data = eventController.GetEventListData(), current_event = eventController.GetCurrentEventState() } }); } public void NotifyAvatarOutfitChanged() { if (avatarOutfitController == null) return; BroadcastJson(new { type = "avatar_outfit_changed", timestamp = DateTime.UtcNow.ToString("o"), version = "1.0", data = new { avatar_outfit_data = avatarOutfitController.GetAvatarOutfitListData(), current_avatar_outfit = avatarOutfitController.GetCurrentAvatarOutfitState() } }); } private void BroadcastJson(object data) { string json = JsonConvert.SerializeObject(data); BroadcastMessage(json); } #endregion #region Message Processing private void ProcessMessage(string messageData, StreamDeckService service) { var message = JsonConvert.DeserializeObject>(messageData); string messageType = message.ContainsKey("type") ? message["type"].ToString() : null; switch (messageType) { // 카메라 case "switch_camera": HandleWithIndex(message, "camera_index", cameraManager, "cameraManager", cameraManager?.cameraPresets?.Count ?? 0, idx => { cameraManager.Set(idx); }); break; case "get_camera_list": HandleGetCameraList(service); break; case "toggle_drone_mode": HandleToggleDroneMode(service); break; case "get_drone_state": HandleGetDroneState(service); break; // 아이템 case "toggle_item": HandleWithIndex(message, "item_index", itemController, "itemController", itemController?.itemGroups?.Count ?? 0, idx => { itemController.ToggleGroup(idx); NotifyItemChanged(); }); break; case "set_item": HandleWithIndex(message, "item_index", itemController, "itemController", itemController?.itemGroups?.Count ?? 0, idx => { itemController.Set(idx); NotifyItemChanged(); }); break; case "get_item_list": HandleGetItemList(service); break; // 이벤트 case "execute_event": HandleWithIndex(message, "event_index", eventController, "eventController", eventController?.eventGroups?.Count ?? 0, idx => { eventController.ExecuteEvent(idx); }); break; case "set_event": HandleWithIndex(message, "event_index", eventController, "eventController", eventController?.eventGroups?.Count ?? 0, idx => { eventController.Set(idx); }); break; case "get_event_list": HandleGetEventList(service); break; // 아바타 의상 case "set_avatar_outfit": HandleSetAvatarOutfit(message); break; case "get_avatar_outfit_list": HandleGetAvatarOutfitList(service); break; // 시스템 상태 (대시보드용 신규) case "get_system_status": HandleGetSystemStatus(service); break; case "get_full_state": SendInitialData(service); break; case "ping": SendJson(service, new { type = "pong", timestamp = DateTime.UtcNow.ToString("o") }); break; // SystemController 명령어들 case "toggle_optitrack_markers": case "show_optitrack_markers": case "hide_optitrack_markers": case "reconnect_optitrack": case "spawn_optitrack_client": case "reconnect_facial_motion": case "refresh_facial_motion_clients": case "start_motion_recording": case "stop_motion_recording": case "toggle_motion_recording": case "refresh_motion_recorders": case "capture_screenshot": case "capture_alpha_screenshot": case "open_screenshot_folder": case "refresh_avatar_head_colliders": case "refresh_magica_cloth": case "reset_magica_cloth": case "reset_magica_cloth_keep_pose": case "start_retargeting_remote": case "stop_retargeting_remote": case "toggle_retargeting_remote": case "refresh_retargeting_characters": HandleSystemCommand(message); break; // 카메라 프리뷰 case "subscribe_preview": { bool useBase64 = false; if (message.ContainsKey("data")) { if (message["data"] is Newtonsoft.Json.Linq.JObject jData && jData.ContainsKey("format")) useBase64 = jData["format"]?.ToString() == "base64"; else if (message["data"] is Dictionary dData && dData.ContainsKey("format")) useBase64 = dData["format"]?.ToString() == "base64"; } PreviewSubscribe(service, useBase64); } break; case "unsubscribe_preview": PreviewUnsubscribe(service); break; case "update_preview_settings": HandleUpdatePreviewSettings(message); break; case "test": SendJson(service, new { type = "echo", timestamp = DateTime.UtcNow.ToString("o"), data = new { received_message = messageData } }); break; default: Debug.Log($"[StreamDeckServerManager] 알 수 없는 메시지 타입: {messageType}"); break; } } #endregion #region Index Extraction Helper /// /// 메시지에서 인덱스를 추출합니다. JObject와 Dictionary 모두 지원합니다. /// private int? ExtractIndex(Dictionary message, string key) { if (!message.ContainsKey("data")) return null; var dataObject = message["data"]; string rawValue = null; if (dataObject is Newtonsoft.Json.Linq.JObject jObject) { if (jObject.ContainsKey(key)) rawValue = jObject[key]?.ToString(); } else if (dataObject is Dictionary data) { if (data.ContainsKey(key)) rawValue = data[key]?.ToString(); } if (rawValue != null && int.TryParse(rawValue, out int index)) return index; return null; } /// /// 인덱스 기반 핸들러를 간결하게 처리합니다. /// private void HandleWithIndex(Dictionary message, string indexKey, object controller, string controllerName, int maxCount, Action action) { if (controller == null) { Debug.LogError($"[StreamDeckServerManager] {controllerName}가 null입니다!"); return; } int? index = ExtractIndex(message, indexKey); if (index == null) { Debug.LogError($"[StreamDeckServerManager] '{indexKey}' 파싱 실패"); return; } if (index.Value < 0 || index.Value >= maxCount) { Debug.LogError($"[StreamDeckServerManager] 잘못된 인덱스: {index.Value}, 유효 범위: 0-{maxCount - 1}"); return; } action(index.Value); } #endregion #region Response Helpers private void SendJson(StreamDeckService service, object data) { string json = JsonConvert.SerializeObject(data); service.SendMessage(json); } #endregion #region Camera Handlers private void HandleGetCameraList(StreamDeckService service) { if (cameraManager == null) return; SendJson(service, new { type = "camera_list_response", timestamp = DateTime.UtcNow.ToString("o"), version = "1.0", data = new { camera_data = cameraManager.GetCameraListData(), current_camera = cameraManager.GetCurrentCameraState() } }); } private void HandleToggleDroneMode(StreamDeckService service) { if (cameraManager == null) return; cameraManager.ToggleDroneMode(); HandleGetDroneState(service); } private void HandleGetDroneState(StreamDeckService service) { if (cameraManager == null) return; SendJson(service, new { type = "drone_state_response", timestamp = DateTime.UtcNow.ToString("o"), version = "1.0", data = new { is_drone_mode = cameraManager.IsDroneModeActive, current_camera = cameraManager.GetCurrentCameraState() } }); } #endregion #region Item/Event/Avatar Handlers private void HandleGetItemList(StreamDeckService service) { if (itemController == null) return; SendJson(service, new { type = "item_list_response", timestamp = DateTime.UtcNow.ToString("o"), version = "1.0", data = new { item_data = itemController.GetItemListData(), current_item = itemController.GetCurrentItemState() } }); } private void HandleGetEventList(StreamDeckService service) { if (eventController == null) return; SendJson(service, new { type = "event_list_response", timestamp = DateTime.UtcNow.ToString("o"), version = "1.0", data = new { event_data = eventController.GetEventListData(), current_event = eventController.GetCurrentEventState() } }); } private void HandleSetAvatarOutfit(Dictionary message) { if (avatarOutfitController == null) { Debug.LogError("[StreamDeckServerManager] avatarOutfitController가 null입니다!"); return; } int? avatarIndex = ExtractIndex(message, "avatar_index"); int? outfitIndex = ExtractIndex(message, "outfit_index"); if (avatarIndex == null || outfitIndex == null) { Debug.LogError("[StreamDeckServerManager] avatar_index 또는 outfit_index 파싱 실패"); return; } if (avatarIndex.Value < 0 || avatarIndex.Value >= (avatarOutfitController.avatars?.Count ?? 0)) { Debug.LogError($"[StreamDeckServerManager] 잘못된 아바타 인덱스: {avatarIndex.Value}"); return; } Debug.Log($"[StreamDeckServerManager] 아바타 {avatarIndex.Value}번 의상을 {outfitIndex.Value}번으로 설정"); avatarOutfitController.SetAvatarOutfit(avatarIndex.Value, outfitIndex.Value); } private void HandleGetAvatarOutfitList(StreamDeckService service) { if (avatarOutfitController == null) { Debug.LogError("[StreamDeckServerManager] avatarOutfitController가 null입니다!"); return; } SendJson(service, new { type = "avatar_outfit_list_response", timestamp = DateTime.UtcNow.ToString("o"), version = "1.0", data = new { avatar_outfit_data = avatarOutfitController.GetAvatarOutfitListData(), current_avatar_outfit = avatarOutfitController.GetCurrentAvatarOutfitState() } }); } #endregion #region System Command Handler private void HandleSystemCommand(Dictionary message) { string messageType = message.ContainsKey("type") ? message["type"].ToString() : null; if (systemController == null) { Debug.LogError("[StreamDeckServerManager] SystemController가 null입니다!"); return; } try { Dictionary parameters = new Dictionary(); if (message.ContainsKey("data")) { var dataObject = message["data"]; if (dataObject is Newtonsoft.Json.Linq.JObject jObject) { foreach (var prop in jObject.Properties()) { parameters[prop.Name] = prop.Value.ToString(); } } } systemController.ExecuteCommand(messageType, parameters); } catch (Exception ex) { Debug.LogError($"[StreamDeckServerManager] 시스템 명령어 실행 실패: {ex.Message}"); } } #endregion #region Camera Preview private void InitializePreview() { if (cameraManager == null) return; CreatePreviewRenderTextures(); EnsurePreviewCameraPoolSize(); RenderPipelineManager.endCameraRendering += OnEndPreviewCameraRendering; } private Camera CreatePreviewCamera(int poolIndex) { Camera mainCamera = Camera.main; if (mainCamera == null) { Debug.LogError("[StreamDeckServerManager] 프리뷰: 메인 카메라를 찾을 수 없습니다!"); return null; } GameObject camObj = new GameObject($"CameraPreview_{poolIndex}"); camObj.transform.SetParent(transform); Camera cam = camObj.AddComponent(); cam.CopyFrom(mainCamera); cam.targetTexture = previewRT; cam.depth = mainCamera.depth - 10 - poolIndex; cam.enabled = false; var previewCameraData = cam.GetUniversalAdditionalCameraData(); if (previewCameraData != null) { previewCameraData.renderPostProcessing = false; previewCameraData.antialiasing = AntialiasingMode.None; previewCameraData.renderShadows = false; previewCameraData.dithering = false; previewCameraData.stopNaN = false; } return cam; } private void EnsurePreviewCameraPoolSize() { while (previewCameraPool.Count < camerasPerFrame) { Camera cam = CreatePreviewCamera(previewCameraPool.Count); if (cam != null) previewCameraPool.Add(cam); else break; } for (int i = camerasPerFrame; i < previewCameraPool.Count; i++) { previewCameraPool[i].enabled = false; } } private void CreatePreviewRenderTextures() { if (previewRT != null) { previewRT.Release(); Destroy(previewRT); } if (previewReadbackTexture != null) { Destroy(previewReadbackTexture); } previewRT = new RenderTexture(previewWidth, previewHeight, 24, RenderTextureFormat.ARGB32); previewRT.name = "CameraPreviewRT"; previewRT.Create(); previewReadbackTexture = new Texture2D(previewWidth, previewHeight, TextureFormat.RGB24, false); foreach (var cam in previewCameraPool) { if (cam != null) cam.targetTexture = previewRT; } } private void PreviewLateUpdate() { if (cameraManager == null) return; int subscriberCount; lock (previewSubscriberLock) { subscriberCount = previewSubscribers.Count; } if (subscriberCount == 0) { for (int i = 0; i < previewCameraPool.Count; i++) previewCameraPool[i].enabled = false; return; } previewFrameCounter++; if (previewFrameCounter % renderInterval != 0) return; var presets = cameraManager.cameraPresets; if (presets == null || presets.Count == 0) return; EnsurePreviewCameraPoolSize(); previewPendingCaptures.Clear(); int toRender = Mathf.Min(camerasPerFrame, Mathf.Min(presets.Count, previewCameraPool.Count)); int rendered = 0; int scanned = 0; while (rendered < toRender && scanned < presets.Count) { currentPreviewIndex = currentPreviewIndex % presets.Count; var preset = presets[currentPreviewIndex]; currentPreviewIndex++; scanned++; if (preset?.virtualCamera == null) continue; Camera cam = previewCameraPool[rendered]; int presetIdx = currentPreviewIndex - 1; Camera mainCam = Camera.main; if (mainCam != null && presetIdx == cameraManager.GetCameraListData().current_index) { cam.transform.SetPositionAndRotation( mainCam.transform.position, mainCam.transform.rotation); cam.fieldOfView = mainCam.fieldOfView; } else { cam.transform.SetPositionAndRotation( preset.virtualCamera.transform.position, preset.virtualCamera.transform.rotation); cam.fieldOfView = preset.virtualCamera.Lens.FieldOfView; } cam.enabled = true; previewPendingCaptures[cam] = presetIdx; rendered++; } for (int i = rendered; i < previewCameraPool.Count; i++) { previewCameraPool[i].enabled = false; } } private void OnEndPreviewCameraRendering(ScriptableRenderContext context, Camera camera) { if (!previewPendingCaptures.TryGetValue(camera, out int presetIndex)) return; var presets = cameraManager.cameraPresets; if (presetIndex < 0 || presetIndex >= presets.Count) return; camera.enabled = false; previewPendingCaptures.Remove(camera); string presetName = presets[presetIndex].presetName; int capturedPresetIndex = presetIndex; AsyncGPUReadback.Request(camera.targetTexture, 0, TextureFormat.RGB24, (request) => { if (request.hasError || this == null) return; NativeArray data = request.GetData(); previewReadbackTexture.LoadRawTextureData(data); previewReadbackTexture.Apply(false); byte[] jpg = previewReadbackTexture.EncodeToJPG(jpegQuality); BroadcastPreviewBinary(capturedPresetIndex, presetName, jpg); }); } private const int PreviewMaxFailCount = 5; private void BroadcastPreviewBinary(int index, string name, byte[] jpegData) { int currentIndex = cameraManager.GetCameraListData().current_index; bool isActive = (currentIndex == index); int totalCameras = cameraManager.cameraPresets.Count; // 바이너리 패킷 (필요 시 생성) byte[] binaryPacket = null; // Base64 텍스트 JSON (필요 시 생성) string base64Json = null; StreamDeckService[] clients; lock (previewSubscriberLock) { clients = previewSubscribers.ToArray(); } foreach (var client in clients) { if (client.previewFailCount >= PreviewMaxFailCount) { lock (previewSubscriberLock) { previewSubscribers.Remove(client); } Debug.Log($"[StreamDeckServerManager] 프리뷰 구독 제거 (연속 {PreviewMaxFailCount}회 전송 실패)"); continue; } if (client.previewUseBase64) { // iOS 등: base64 텍스트 JSON으로 전송 (WebSocket 바이너리 프레임 불안정 기기용) if (base64Json == null) { base64Json = JsonConvert.SerializeObject(new { type = "camera_preview_frame", data = new { camera_index = index, camera_name = name, image = Convert.ToBase64String(jpegData), total_cameras = totalCameras, is_active = isActive } }); } client.SendPreviewTextAsync(base64Json); } else { // Android/PC: 바이너리 패킷 (효율적) if (binaryPacket == null) { byte[] nameBytes = System.Text.Encoding.UTF8.GetBytes(name); byte nameLen = (byte)Math.Min(nameBytes.Length, 255); binaryPacket = new byte[4 + nameLen + jpegData.Length]; binaryPacket[0] = (byte)index; binaryPacket[1] = (byte)totalCameras; binaryPacket[2] = (byte)(isActive ? 1 : 0); binaryPacket[3] = nameLen; Buffer.BlockCopy(nameBytes, 0, binaryPacket, 4, nameLen); Buffer.BlockCopy(jpegData, 0, binaryPacket, 4 + nameLen, jpegData.Length); } client.SendPreviewAsync(binaryPacket); } } } private void PreviewSubscribe(StreamDeckService client, bool useBase64 = false) { client.previewSendBusy = false; client.previewFailCount = 0; client.previewUseBase64 = useBase64; lock (previewSubscriberLock) { previewSubscribers.Add(client); } Debug.Log($"[StreamDeckServerManager] 프리뷰 구독 추가. 총 구독자: {previewSubscribers.Count}"); } private void PreviewUnsubscribe(StreamDeckService client) { lock (previewSubscriberLock) { previewSubscribers.Remove(client); } Debug.Log($"[StreamDeckServerManager] 프리뷰 구독 해제. 총 구독자: {previewSubscribers.Count}"); } private void HandleUpdatePreviewSettings(Dictionary message) { var dataObject = message.ContainsKey("data") ? message["data"] : null; if (dataObject == null) return; int width = previewWidth, height = previewHeight, quality = jpegQuality, interval = renderInterval; if (dataObject is Newtonsoft.Json.Linq.JObject jObject) { if (jObject.ContainsKey("width")) int.TryParse(jObject["width"]?.ToString(), out width); if (jObject.ContainsKey("height")) int.TryParse(jObject["height"]?.ToString(), out height); if (jObject.ContainsKey("quality")) int.TryParse(jObject["quality"]?.ToString(), out quality); if (jObject.ContainsKey("render_interval")) int.TryParse(jObject["render_interval"]?.ToString(), out interval); } else if (dataObject is Dictionary data) { if (data.ContainsKey("width")) int.TryParse(data["width"]?.ToString(), out width); if (data.ContainsKey("height")) int.TryParse(data["height"]?.ToString(), out height); if (data.ContainsKey("quality")) int.TryParse(data["quality"]?.ToString(), out quality); if (data.ContainsKey("render_interval")) int.TryParse(data["render_interval"]?.ToString(), out interval); } bool resChanged = (width != previewWidth || height != previewHeight); previewWidth = Mathf.Clamp(width, 160, 960); previewHeight = Mathf.Clamp(height, 90, 540); jpegQuality = Mathf.Clamp(quality, 10, 100); renderInterval = Mathf.Clamp(interval, 1, 10); if (resChanged) { CreatePreviewRenderTextures(); } Debug.Log($"[StreamDeckServerManager] 프리뷰 설정 변경: {previewWidth}x{previewHeight}, 품질={jpegQuality}, 렌더링간격={renderInterval}"); } private void CleanupPreview() { RenderPipelineManager.endCameraRendering -= OnEndPreviewCameraRendering; if (previewRT != null) { previewRT.Release(); Destroy(previewRT); } if (previewReadbackTexture != null) { Destroy(previewReadbackTexture); } foreach (var cam in previewCameraPool) { if (cam != null) Destroy(cam.gameObject); } previewCameraPool.Clear(); } #endregion #region System Status (Dashboard) private void HandleGetSystemStatus(StreamDeckService service) { var statusData = new Dictionary(); // OptiTrack if (systemController != null) { statusData["optitrack"] = new { connected = systemController.optiTrack.IsOptitrackConnected(), status = systemController.optiTrack.GetOptitrackConnectionStatus() }; statusData["facial_motion"] = new { client_count = systemController.facialMotion.facialMotionClients?.Count ?? 0 }; statusData["recording"] = new { is_recording = systemController.IsRecording() }; statusData["retargeting_remote"] = new { is_running = systemController.IsRetargetingRemoteRunning(), url = systemController.GetRetargetingRemoteUrl() }; } statusData["websocket"] = new { connected_clients = connectedClients.Count, port = port }; statusData["dashboard"] = new { enabled = enableDashboard, port = dashboardPort, urls = dashboardServer?.BoundAddresses?.ToArray() ?? new string[0] }; SendJson(service, new { type = "system_status_response", timestamp = DateTime.UtcNow.ToString("o"), version = "1.0", data = statusData }); } #endregion } public class StreamDeckService : WebSocketBehavior { private StreamDeckServerManager serverManager; // 프리뷰 비동기 전송 상태 public volatile bool previewSendBusy; public int previewFailCount; public bool previewUseBase64; // iOS 등 바이너리 WS 불안정 기기용 텍스트 모드 protected override void OnOpen() { serverManager = StreamDeckServerManager.Instance; if (serverManager != null) { serverManager.OnClientConnected(this); } Debug.Log("[StreamDeckService] WebSocket 연결 열림"); } protected override void OnMessage(WebSocketSharp.MessageEventArgs e) { // client_ping은 메인 스레드를 거치지 않고 즉시 응답 (iOS WiFi keepalive용) if (e.Data != null && e.Data.Contains("\"client_ping\"")) { try { Send("{\"type\":\"client_pong\"}"); } catch { } return; } if (serverManager != null) { serverManager.ProcessMessageOnMainThread(e.Data, this); } } public bool IsConnected => ReadyState == WebSocketSharp.WebSocketState.Open; public void SendMessage(string message) { if (!IsConnected) return; try { Send(message); } catch (Exception ex) { Debug.LogError($"[StreamDeckService] 메시지 전송 실패: {ex.Message}"); } } public void SendBinary(byte[] data) { if (!IsConnected) return; try { Send(data); } catch (Exception ex) { Debug.LogError($"[StreamDeckService] 바이너리 전송 실패: {ex.Message}"); } } /// /// 프리뷰 프레임 비동기 전송 (바이너리). 이전 프레임 전송 중이면 스킵 (backpressure 방지). /// public void SendPreviewAsync(byte[] data) { if (previewSendBusy || !IsConnected) return; previewSendBusy = true; try { SendAsync(data, completed => { previewSendBusy = false; if (completed) previewFailCount = 0; else previewFailCount++; }); } catch (Exception) { previewSendBusy = false; previewFailCount++; } } /// /// 프리뷰 프레임 비동기 전송 (텍스트/Base64). iOS 등 바이너리 WS 불안정 기기용. /// public void SendPreviewTextAsync(string json) { if (previewSendBusy || !IsConnected) return; previewSendBusy = true; try { SendAsync(json, completed => { previewSendBusy = false; if (completed) previewFailCount = 0; else previewFailCount++; }); } catch (Exception) { previewSendBusy = false; previewFailCount++; } } protected override void OnClose(WebSocketSharp.CloseEventArgs e) { if (serverManager != null) { serverManager.OnClientDisconnected(this); } Debug.Log($"[StreamDeckService] WebSocket 연결 닫힘: {e.Reason}"); } protected override void OnError(WebSocketSharp.ErrorEventArgs e) { Debug.LogError($"[StreamDeckService] WebSocket 오류: {e.Message}"); } }