CameraManager의 CinemachineCamera 프리셋을 라운드 로빈으로 렌더링하여 웹 대시보드에서 실시간 JPEG 프리뷰를 확인할 수 있는 시스템 추가. - StreamDeckServerManager에 프리뷰 렌더링 로직 통합 (카메라 풀, AsyncGPUReadback, 바이너리 WebSocket) - 대시보드 프리뷰 탭: 해상도/품질/갱신간격/그리드열 커스텀, 클릭→카메라 전환, 더블클릭→풀스크린 - 구독 기반 전송 + Page Visibility API로 불필요한 렌더링 방지 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1101 lines
35 KiB
C#
1101 lines
35 KiB
C#
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<StreamDeckService> connectedClients = new List<StreamDeckService>();
|
|
|
|
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<System.Action> mainThreadActions = new Queue<System.Action>();
|
|
private readonly object lockObject = new object();
|
|
|
|
// 카메라 프리뷰 내부 상태
|
|
private readonly List<Camera> previewCameraPool = new List<Camera>();
|
|
private RenderTexture previewRT;
|
|
private Texture2D previewReadbackTexture;
|
|
private int currentPreviewIndex = 0;
|
|
private int previewFrameCounter = 0;
|
|
private readonly Dictionary<Camera, int> previewPendingCaptures = new Dictionary<Camera, int>();
|
|
private readonly HashSet<StreamDeckService> previewSubscribers = new HashSet<StreamDeckService>();
|
|
private readonly object previewSubscriberLock = new object();
|
|
|
|
public int ConnectedClientCount => connectedClients.Count;
|
|
|
|
void Awake()
|
|
{
|
|
Instance = this;
|
|
}
|
|
|
|
void Start()
|
|
{
|
|
cameraManager = FindObjectOfType<CameraManager>();
|
|
if (cameraManager == null)
|
|
{
|
|
Debug.LogError("[StreamDeckServerManager] CameraManager를 찾을 수 없습니다!");
|
|
return;
|
|
}
|
|
|
|
itemController = FindObjectOfType<ItemController>();
|
|
if (itemController == null)
|
|
Debug.LogWarning("[StreamDeckServerManager] ItemController를 찾을 수 없습니다. 아이템 컨트롤 기능이 비활성화됩니다.");
|
|
|
|
eventController = FindObjectOfType<EventController>();
|
|
if (eventController == null)
|
|
Debug.LogWarning("[StreamDeckServerManager] EventController를 찾을 수 없습니다. 이벤트 컨트롤 기능이 비활성화됩니다.");
|
|
|
|
avatarOutfitController = FindObjectOfType<AvatarOutfitController>();
|
|
if (avatarOutfitController == null)
|
|
Debug.LogWarning("[StreamDeckServerManager] AvatarOutfitController를 찾을 수 없습니다. 아바타 의상 컨트롤 기능이 비활성화됩니다.");
|
|
|
|
systemController = FindObjectOfType<SystemController>();
|
|
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}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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.AddWebSocketService<StreamDeckService>("/");
|
|
server.Start();
|
|
Debug.Log($"[StreamDeckServerManager] WebSocket 서버 시작됨, 포트: {port} (모든 인터페이스)");
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Debug.LogError($"[StreamDeckServerManager] 서버 시작 실패: {e.Message}");
|
|
}
|
|
}
|
|
|
|
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<Dictionary<string, object>>(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":
|
|
PreviewSubscribe(service);
|
|
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
|
|
/// <summary>
|
|
/// 메시지에서 인덱스를 추출합니다. JObject와 Dictionary 모두 지원합니다.
|
|
/// </summary>
|
|
private int? ExtractIndex(Dictionary<string, object> 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<string, object> data)
|
|
{
|
|
if (data.ContainsKey(key))
|
|
rawValue = data[key]?.ToString();
|
|
}
|
|
|
|
if (rawValue != null && int.TryParse(rawValue, out int index))
|
|
return index;
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 인덱스 기반 핸들러를 간결하게 처리합니다.
|
|
/// </summary>
|
|
private void HandleWithIndex(Dictionary<string, object> message, string indexKey,
|
|
object controller, string controllerName, int maxCount, Action<int> 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<string, object> 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<string, object> message)
|
|
{
|
|
string messageType = message.ContainsKey("type") ? message["type"].ToString() : null;
|
|
|
|
if (systemController == null)
|
|
{
|
|
Debug.LogError("[StreamDeckServerManager] SystemController가 null입니다!");
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
Dictionary<string, object> parameters = new Dictionary<string, object>();
|
|
|
|
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<Camera>();
|
|
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<byte> data = request.GetData<byte>();
|
|
previewReadbackTexture.LoadRawTextureData(data);
|
|
previewReadbackTexture.Apply(false);
|
|
|
|
byte[] jpg = previewReadbackTexture.EncodeToJPG(jpegQuality);
|
|
BroadcastPreviewBinary(capturedPresetIndex, presetName, jpg);
|
|
});
|
|
}
|
|
|
|
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);
|
|
|
|
StreamDeckService[] clients;
|
|
lock (previewSubscriberLock)
|
|
{
|
|
clients = previewSubscribers.ToArray();
|
|
}
|
|
|
|
foreach (var client in clients)
|
|
{
|
|
try
|
|
{
|
|
client.SendBinary(packet);
|
|
}
|
|
catch (Exception)
|
|
{
|
|
lock (previewSubscriberLock)
|
|
{
|
|
previewSubscribers.Remove(client);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private void PreviewSubscribe(StreamDeckService client)
|
|
{
|
|
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<string, object> 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<string, object> 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<string, object>();
|
|
|
|
// 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;
|
|
|
|
protected override void OnOpen()
|
|
{
|
|
serverManager = StreamDeckServerManager.Instance;
|
|
if (serverManager != null)
|
|
{
|
|
serverManager.OnClientConnected(this);
|
|
}
|
|
Debug.Log("[StreamDeckService] WebSocket 연결 열림");
|
|
}
|
|
|
|
protected override void OnMessage(WebSocketSharp.MessageEventArgs e)
|
|
{
|
|
if (serverManager != null)
|
|
{
|
|
serverManager.ProcessMessageOnMainThread(e.Data, this);
|
|
}
|
|
}
|
|
|
|
public void SendMessage(string message)
|
|
{
|
|
try
|
|
{
|
|
Send(message);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Debug.LogError($"[StreamDeckService] 메시지 전송 실패: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
public void SendBinary(byte[] data)
|
|
{
|
|
try
|
|
{
|
|
Send(data);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Debug.LogError($"[StreamDeckService] 바이너리 전송 실패: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
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}");
|
|
}
|
|
}
|