From 53fa19e83481da385cd65064b8c948fbef946ccf Mon Sep 17 00:00:00 2001 From: user Date: Tue, 17 Feb 2026 22:35:38 +0900 Subject: [PATCH] =?UTF-8?q?Improve:=20=EC=B9=B4=EB=A9=94=EB=9D=BC=20?= =?UTF-8?q?=ED=94=84=EB=A6=AC=EB=B7=B0=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20?= =?UTF-8?q?=EB=8C=80=EC=9D=91=20+=20WebSocket=20=EC=95=88=EC=A0=95?= =?UTF-8?q?=EC=84=B1=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../StreamingleDashboard/dashboard_script.txt | 4 +- .../StreamingleDashboard/dashboard_style.txt | 4 +- .../Streamdeck/StreamDeckServerManager.cs | 167 ++++++++++++++++-- .../Streamdeck/StreamingleDashboardServer.cs | 2 +- 4 files changed, 154 insertions(+), 23 deletions(-) diff --git a/Assets/Resources/StreamingleDashboard/dashboard_script.txt b/Assets/Resources/StreamingleDashboard/dashboard_script.txt index 9e96bda7..af1b3946 100644 --- a/Assets/Resources/StreamingleDashboard/dashboard_script.txt +++ b/Assets/Resources/StreamingleDashboard/dashboard_script.txt @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:697e059bd011a2f9550c44ed20f8fe4a52e2c144b05404db9808d8880229a907 -size 64731 +oid sha256:059ac85fede20a79fe0403d71a144085818ec97e5a802a4c7e31a050a1536390 +size 73083 diff --git a/Assets/Resources/StreamingleDashboard/dashboard_style.txt b/Assets/Resources/StreamingleDashboard/dashboard_style.txt index f445cf81..04bbe41c 100644 --- a/Assets/Resources/StreamingleDashboard/dashboard_style.txt +++ b/Assets/Resources/StreamingleDashboard/dashboard_style.txt @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4d1e55f127ed375ad926cd02e1ccd12f8398fbe037708898fa134346d605cdad -size 23863 +oid sha256:85e8067eb6ef5159c0b6435b1e882eb8e987497e26277d04186aa9729ed3aaa3 +size 25339 diff --git a/Assets/Scripts/Streamdeck/StreamDeckServerManager.cs b/Assets/Scripts/Streamdeck/StreamDeckServerManager.cs index b2f361bc..be7303cf 100644 --- a/Assets/Scripts/Streamdeck/StreamDeckServerManager.cs +++ b/Assets/Scripts/Streamdeck/StreamDeckServerManager.cs @@ -43,6 +43,10 @@ public class StreamDeckServerManager : MonoBehaviour 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; @@ -108,6 +112,13 @@ public class StreamDeckServerManager : MonoBehaviour } } } + + // WebSocket 프로토콜 레벨 ping — WiFi 라우터의 유휴 연결 드롭 방지 + if (Time.time - lastPingTime >= WsPingInterval) + { + lastPingTime = Time.time; + BroadcastWsPing(); + } } void LateUpdate() @@ -133,6 +144,8 @@ public class StreamDeckServerManager : MonoBehaviour { // 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} (모든 인터페이스)"); @@ -143,6 +156,12 @@ public class StreamDeckServerManager : MonoBehaviour } } + private void BroadcastWsPing() + { + if (server == null || !server.IsListening) return; + BroadcastMessage("{\"type\":\"keepalive\"}"); + } + private void StopServer() { if (server != null) @@ -404,7 +423,6 @@ public class StreamDeckServerManager : MonoBehaviour case "ping": SendJson(service, new { type = "pong", timestamp = DateTime.UtcNow.ToString("o") }); break; - // SystemController 명령어들 case "toggle_optitrack_markers": case "show_optitrack_markers": @@ -433,7 +451,17 @@ public class StreamDeckServerManager : MonoBehaviour // 카메라 프리뷰 case "subscribe_preview": - PreviewSubscribe(service); + { + 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); @@ -868,22 +896,18 @@ public class StreamDeckServerManager : MonoBehaviour }); } + 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[] nameBytes = System.Text.Encoding.UTF8.GetBytes(name); - byte nameLen = (byte)Math.Min(nameBytes.Length, 255); - - byte[] packet = new byte[4 + nameLen + jpegData.Length]; - packet[0] = (byte)index; - packet[1] = (byte)totalCameras; - packet[2] = (byte)(isActive ? 1 : 0); - packet[3] = nameLen; - Buffer.BlockCopy(nameBytes, 0, packet, 4, nameLen); - Buffer.BlockCopy(jpegData, 0, packet, 4 + nameLen, jpegData.Length); + // 바이너리 패킷 (필요 시 생성) + byte[] binaryPacket = null; + // Base64 텍스트 JSON (필요 시 생성) + string base64Json = null; StreamDeckService[] clients; lock (previewSubscriberLock) @@ -893,22 +917,61 @@ public class StreamDeckServerManager : MonoBehaviour foreach (var client in clients) { - try - { - client.SendBinary(packet); - } - catch (Exception) + 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) + private void PreviewSubscribe(StreamDeckService client, bool useBase64 = false) { + client.previewSendBusy = false; + client.previewFailCount = 0; + client.previewUseBase64 = useBase64; lock (previewSubscriberLock) { previewSubscribers.Add(client); @@ -1042,6 +1105,11 @@ 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; @@ -1054,14 +1122,24 @@ public class StreamDeckService : WebSocketBehavior 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); @@ -1074,6 +1152,7 @@ public class StreamDeckService : WebSocketBehavior public void SendBinary(byte[] data) { + if (!IsConnected) return; try { Send(data); @@ -1084,6 +1163,58 @@ public class StreamDeckService : WebSocketBehavior } } + /// + /// 프리뷰 프레임 비동기 전송 (바이너리). 이전 프레임 전송 중이면 스킵 (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) diff --git a/Assets/Scripts/Streamdeck/StreamingleDashboardServer.cs b/Assets/Scripts/Streamdeck/StreamingleDashboardServer.cs index 7743dd41..89435e5d 100644 --- a/Assets/Scripts/Streamdeck/StreamingleDashboardServer.cs +++ b/Assets/Scripts/Streamdeck/StreamingleDashboardServer.cs @@ -211,7 +211,7 @@ public class StreamingleDashboardServer byte[] buffer = Encoding.UTF8.GetBytes(responseContent); context.Response.ContentType = contentType; context.Response.ContentLength64 = buffer.Length; - context.Response.AddHeader("Cache-Control", "no-cache"); + context.Response.AddHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0"); context.Response.OutputStream.Write(buffer, 0, buffer.Length); context.Response.Close(); }