Fix : 의상 컨트롤러 추가 및 오프셋 트랜스퍼 및 본 렌더러 기능 추가

This commit is contained in:
KINDNICK 2025-09-01 23:24:52 +09:00
parent 8f02ce37a3
commit 7e10a3d83d
74 changed files with 2352 additions and 13204 deletions

View File

@ -0,0 +1,305 @@
using UnityEngine;
using UnityEditor;
using UnityEngine.Animations.Rigging;
using System.Collections.Generic;
using System.Linq;
namespace EditorTools
{
public static class BoneRendererContextMenu
{
[MenuItem("CONTEXT/BoneRenderer/휴머노이드 본 자동 추가")]
private static void AddHumanoidBones(MenuCommand command)
{
BoneRenderer boneRenderer = command.context as BoneRenderer;
if (boneRenderer == null) return;
Animator animator = boneRenderer.GetComponent<Animator>();
if (animator == null || !animator.isHuman)
{
EditorUtility.DisplayDialog("오류", "휴머노이드 Animator가 필요합니다.", "확인");
return;
}
Undo.RecordObject(boneRenderer, "Add Humanoid Bones");
List<Transform> humanoidBones = GetAvailableHumanoidBones(animator);
boneRenderer.transforms = humanoidBones.ToArray();
EditorUtility.SetDirty(boneRenderer);
Debug.Log($"{humanoidBones.Count}개의 휴머노이드 본이 BoneRenderer에 추가되었습니다.");
}
[MenuItem("CONTEXT/BoneRenderer/본 목록 초기화")]
private static void ClearBones(MenuCommand command)
{
BoneRenderer boneRenderer = command.context as BoneRenderer;
if (boneRenderer == null) return;
if (!EditorUtility.DisplayDialog("확인", "본 목록을 초기화하시겠습니까?", "예", "아니오"))
return;
Undo.RecordObject(boneRenderer, "Clear Bones");
boneRenderer.transforms = new Transform[0];
EditorUtility.SetDirty(boneRenderer);
Debug.Log("BoneRenderer의 본 목록이 초기화되었습니다.");
}
private static readonly HumanBodyBones[] HumanoidBoneTypes = new HumanBodyBones[]
{
HumanBodyBones.Hips, HumanBodyBones.Spine, HumanBodyBones.Chest, HumanBodyBones.UpperChest,
HumanBodyBones.Neck, HumanBodyBones.Head,
HumanBodyBones.LeftShoulder, HumanBodyBones.LeftUpperArm, HumanBodyBones.LeftLowerArm, HumanBodyBones.LeftHand,
HumanBodyBones.LeftThumbProximal, HumanBodyBones.LeftThumbIntermediate, HumanBodyBones.LeftThumbDistal,
HumanBodyBones.LeftIndexProximal, HumanBodyBones.LeftIndexIntermediate, HumanBodyBones.LeftIndexDistal,
HumanBodyBones.LeftMiddleProximal, HumanBodyBones.LeftMiddleIntermediate, HumanBodyBones.LeftMiddleDistal,
HumanBodyBones.LeftRingProximal, HumanBodyBones.LeftRingIntermediate, HumanBodyBones.LeftRingDistal,
HumanBodyBones.LeftLittleProximal, HumanBodyBones.LeftLittleIntermediate, HumanBodyBones.LeftLittleDistal,
HumanBodyBones.RightShoulder, HumanBodyBones.RightUpperArm, HumanBodyBones.RightLowerArm, HumanBodyBones.RightHand,
HumanBodyBones.RightThumbProximal, HumanBodyBones.RightThumbIntermediate, HumanBodyBones.RightThumbDistal,
HumanBodyBones.RightIndexProximal, HumanBodyBones.RightIndexIntermediate, HumanBodyBones.RightIndexDistal,
HumanBodyBones.RightMiddleProximal, HumanBodyBones.RightMiddleIntermediate, HumanBodyBones.RightMiddleDistal,
HumanBodyBones.RightRingProximal, HumanBodyBones.RightRingIntermediate, HumanBodyBones.RightRingDistal,
HumanBodyBones.RightLittleProximal, HumanBodyBones.RightLittleIntermediate, HumanBodyBones.RightLittleDistal,
HumanBodyBones.LeftUpperLeg, HumanBodyBones.LeftLowerLeg, HumanBodyBones.LeftFoot, HumanBodyBones.LeftToes,
HumanBodyBones.RightUpperLeg, HumanBodyBones.RightLowerLeg, HumanBodyBones.RightFoot, HumanBodyBones.RightToes
};
private static List<Transform> GetAvailableHumanoidBones(Animator animator)
{
List<Transform> humanoidBones = new List<Transform>();
foreach (HumanBodyBones boneType in HumanoidBoneTypes)
{
Transform bone = animator.GetBoneTransform(boneType);
if (bone != null)
{
humanoidBones.Add(bone);
}
}
return humanoidBones;
}
}
[CustomEditor(typeof(BoneRenderer))]
[CanEditMultipleObjects]
public class BoneRendererEditorExtension : Editor
{
static readonly GUIContent k_BoneSizeLabel = new GUIContent("Bone Size");
static readonly GUIContent k_BoneColorLabel = new GUIContent("Color");
static readonly GUIContent k_BoneShapeLabel = new GUIContent("Shape");
static readonly GUIContent k_TripodSizeLabel = new GUIContent("Tripod Size");
SerializedProperty m_DrawBones;
SerializedProperty m_BoneShape;
SerializedProperty m_BoneSize;
SerializedProperty m_BoneColor;
SerializedProperty m_DrawTripods;
SerializedProperty m_TripodSize;
SerializedProperty m_Transforms;
// 휴머노이드 본 타입 정의
private static readonly HumanBodyBones[] HumanoidBoneTypes = new HumanBodyBones[]
{
// 스파인 체인
HumanBodyBones.Hips,
HumanBodyBones.Spine,
HumanBodyBones.Chest,
HumanBodyBones.UpperChest,
HumanBodyBones.Neck,
HumanBodyBones.Head,
// 왼쪽 팔
HumanBodyBones.LeftShoulder,
HumanBodyBones.LeftUpperArm,
HumanBodyBones.LeftLowerArm,
HumanBodyBones.LeftHand,
// 왼쪽 손가락
HumanBodyBones.LeftThumbProximal,
HumanBodyBones.LeftThumbIntermediate,
HumanBodyBones.LeftThumbDistal,
HumanBodyBones.LeftIndexProximal,
HumanBodyBones.LeftIndexIntermediate,
HumanBodyBones.LeftIndexDistal,
HumanBodyBones.LeftMiddleProximal,
HumanBodyBones.LeftMiddleIntermediate,
HumanBodyBones.LeftMiddleDistal,
HumanBodyBones.LeftRingProximal,
HumanBodyBones.LeftRingIntermediate,
HumanBodyBones.LeftRingDistal,
HumanBodyBones.LeftLittleProximal,
HumanBodyBones.LeftLittleIntermediate,
HumanBodyBones.LeftLittleDistal,
// 오른쪽 팔
HumanBodyBones.RightShoulder,
HumanBodyBones.RightUpperArm,
HumanBodyBones.RightLowerArm,
HumanBodyBones.RightHand,
// 오른쪽 손가락
HumanBodyBones.RightThumbProximal,
HumanBodyBones.RightThumbIntermediate,
HumanBodyBones.RightThumbDistal,
HumanBodyBones.RightIndexProximal,
HumanBodyBones.RightIndexIntermediate,
HumanBodyBones.RightIndexDistal,
HumanBodyBones.RightMiddleProximal,
HumanBodyBones.RightMiddleIntermediate,
HumanBodyBones.RightMiddleDistal,
HumanBodyBones.RightRingProximal,
HumanBodyBones.RightRingIntermediate,
HumanBodyBones.RightRingDistal,
HumanBodyBones.RightLittleProximal,
HumanBodyBones.RightLittleIntermediate,
HumanBodyBones.RightLittleDistal,
// 왼쪽 다리
HumanBodyBones.LeftUpperLeg,
HumanBodyBones.LeftLowerLeg,
HumanBodyBones.LeftFoot,
HumanBodyBones.LeftToes,
// 오른쪽 다리
HumanBodyBones.RightUpperLeg,
HumanBodyBones.RightLowerLeg,
HumanBodyBones.RightFoot,
HumanBodyBones.RightToes
};
public void OnEnable()
{
m_DrawBones = serializedObject.FindProperty("drawBones");
m_BoneSize = serializedObject.FindProperty("boneSize");
m_BoneShape = serializedObject.FindProperty("boneShape");
m_BoneColor = serializedObject.FindProperty("boneColor");
m_DrawTripods = serializedObject.FindProperty("drawTripods");
m_TripodSize = serializedObject.FindProperty("tripodSize");
m_Transforms = serializedObject.FindProperty("m_Transforms");
}
public override void OnInspectorGUI()
{
serializedObject.Update();
// 기본 BoneRenderer UI
EditorGUILayout.BeginHorizontal();
EditorGUILayout.PropertyField(m_DrawBones, k_BoneSizeLabel);
using (new EditorGUI.DisabledScope(!m_DrawBones.boolValue))
EditorGUILayout.PropertyField(m_BoneSize, GUIContent.none);
EditorGUILayout.EndHorizontal();
using (new EditorGUI.DisabledScope(!m_DrawBones.boolValue))
{
EditorGUI.indentLevel++;
EditorGUILayout.PropertyField(m_BoneShape, k_BoneShapeLabel);
EditorGUILayout.PropertyField(m_BoneColor, k_BoneColorLabel);
EditorGUI.indentLevel--;
}
EditorGUILayout.BeginHorizontal();
EditorGUILayout.PropertyField(m_DrawTripods, k_TripodSizeLabel);
using (new EditorGUI.DisabledScope(!m_DrawTripods.boolValue))
EditorGUILayout.PropertyField(m_TripodSize, GUIContent.none);
EditorGUILayout.EndHorizontal();
bool isDragPerformed = Event.current.type == EventType.DragPerform;
EditorGUI.BeginChangeCheck();
EditorGUILayout.PropertyField(m_Transforms, true);
bool boneRendererDirty = EditorGUI.EndChangeCheck();
boneRendererDirty |= Event.current.type == EventType.ValidateCommand && Event.current.commandName == "UndoRedoPerformed";
boneRendererDirty |= Event.current.type == EventType.Used && isDragPerformed;
// 휴머노이드 본 자동 설정 섹션
EditorGUILayout.Space();
EditorGUILayout.LabelField("휴머노이드 본 자동 설정", EditorStyles.boldLabel);
BoneRenderer boneRenderer = target as BoneRenderer;
GameObject targetObject = boneRenderer.gameObject;
// Animator 확인 및 상태 표시
Animator animator = targetObject.GetComponent<Animator>();
if (animator == null)
{
EditorGUILayout.HelpBox("Animator 컴포넌트가 필요합니다.", MessageType.Warning);
}
else if (!animator.isHuman)
{
EditorGUILayout.HelpBox("휴머노이드 아바타로 설정되지 않았습니다.", MessageType.Warning);
}
else
{
// 사용 가능한 본 수 표시
List<Transform> availableBones = GetAvailableHumanoidBones(animator);
EditorGUILayout.HelpBox($"사용 가능한 휴머노이드 본: {availableBones.Count}개", MessageType.Info);
// 버튼들
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("휴머노이드 본 추가"))
{
AddHumanoidBones(boneRenderer, animator);
}
if (GUILayout.Button("본 목록 초기화"))
{
ClearBones(boneRenderer);
}
EditorGUILayout.EndHorizontal();
}
serializedObject.ApplyModifiedProperties();
if (boneRendererDirty)
{
for (int i = 0; i < targets.Length; i++)
{
var renderer = targets[i] as BoneRenderer;
renderer.ExtractBones();
}
}
}
private List<Transform> GetAvailableHumanoidBones(Animator animator)
{
List<Transform> humanoidBones = new List<Transform>();
foreach (HumanBodyBones boneType in HumanoidBoneTypes)
{
Transform bone = animator.GetBoneTransform(boneType);
if (bone != null)
{
humanoidBones.Add(bone);
}
}
return humanoidBones;
}
private void AddHumanoidBones(BoneRenderer boneRenderer, Animator animator)
{
Undo.RecordObject(boneRenderer, "Add Humanoid Bones");
List<Transform> humanoidBones = GetAvailableHumanoidBones(animator);
boneRenderer.transforms = humanoidBones.ToArray();
EditorUtility.SetDirty(boneRenderer);
Debug.Log($"{humanoidBones.Count}개의 휴머노이드 본이 BoneRenderer에 추가되었습니다.");
}
private void ClearBones(BoneRenderer boneRenderer)
{
Undo.RecordObject(boneRenderer, "Clear Bones");
boneRenderer.transforms = new Transform[0];
EditorUtility.SetDirty(boneRenderer);
Debug.Log("BoneRenderer의 본 목록이 초기화되었습니다.");
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c2ae7891ff94b484aa3afa02e62e8792

View File

@ -9,6 +9,7 @@ namespace Streamingle.Editor
private bool createItemController = true;
private bool createEventController = true;
private bool createStreamDeckManager = true;
private bool createAvatarOutfitController = true;
private string parentObjectName = "Streamingle 컨트롤러들";
private bool createParentObject = true;
@ -24,6 +25,7 @@ namespace Streamingle.Editor
private CameraManager existingCameraManager;
private ItemController existingItemController;
private EventController existingEventController;
private AvatarOutfitController existingAvatarOutfitController;
[MenuItem("Tools/Streamingle/고급 컨트롤러 설정 도구")]
public static void ShowWindow()
@ -66,6 +68,7 @@ namespace Streamingle.Editor
createCameraManager = EditorGUILayout.Toggle("카메라 매니저", createCameraManager);
createItemController = EditorGUILayout.Toggle("아이템 컨트롤러", createItemController);
createEventController = EditorGUILayout.Toggle("이벤트 컨트롤러", createEventController);
createAvatarOutfitController = EditorGUILayout.Toggle("아바타 의상 컨트롤러", createAvatarOutfitController);
EditorGUILayout.EndVertical();
@ -183,6 +186,19 @@ namespace Streamingle.Editor
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.BeginHorizontal();
GUILayout.Label("아바타 의상 컨트롤러:", GUILayout.Width(200));
if (existingAvatarOutfitController != null)
{
string parentInfo = GetParentInfo(existingAvatarOutfitController.transform);
EditorGUILayout.LabelField($"✓ 발견됨 {parentInfo}", EditorStyles.boldLabel);
}
else
{
EditorGUILayout.LabelField("✗ 발견되지 않음", EditorStyles.boldLabel);
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.EndVertical();
}
@ -204,6 +220,7 @@ namespace Streamingle.Editor
existingCameraManager = FindObjectOfType<CameraManager>();
existingItemController = FindObjectOfType<ItemController>();
existingEventController = FindObjectOfType<EventController>();
existingAvatarOutfitController = FindObjectOfType<AvatarOutfitController>();
}
private void CreateControllers()
@ -241,6 +258,12 @@ namespace Streamingle.Editor
CreateEventController(parentObject);
}
// Avatar Outfit Controller 생성
if (createAvatarOutfitController && existingAvatarOutfitController == null)
{
CreateAvatarOutfitController(parentObject);
}
// 기존 컨트롤러들을 부모 하위로 이동
if (moveExistingControllersToParent && parentObject != null)
{
@ -307,6 +330,14 @@ namespace Streamingle.Editor
UnityEngine.Debug.Log($"이벤트 컨트롤러를 {parent.name} 하위로 이동");
}
// Avatar Outfit Controller 이동
if (existingAvatarOutfitController != null && existingAvatarOutfitController.transform.parent != parent.transform)
{
existingAvatarOutfitController.transform.SetParent(parent.transform);
movedCount++;
UnityEngine.Debug.Log($"아바타 의상 컨트롤러를 {parent.name} 하위로 이동");
}
if (movedCount > 0)
{
UnityEngine.Debug.Log($"{movedCount}개의 컨트롤러를 {parent.name} 하위로 이동했습니다.");
@ -363,6 +394,16 @@ namespace Streamingle.Editor
}
}
// Avatar Outfit Controller 연결
if (existingAvatarOutfitController != null)
{
var avatarOutfitControllerProperty = serializedObject.FindProperty("avatarOutfitController");
if (avatarOutfitControllerProperty != null)
{
avatarOutfitControllerProperty.objectReferenceValue = existingAvatarOutfitController;
}
}
serializedObject.ApplyModifiedProperties();
UnityEngine.Debug.Log("기존 컨트롤러들을 StreamDeck 서버 매니저에 연결했습니다!");
@ -489,5 +530,41 @@ namespace Streamingle.Editor
UnityEngine.Debug.Log("이벤트 컨트롤러 생성됨");
}
private void CreateAvatarOutfitController(GameObject parent)
{
GameObject avatarOutfitObject = new GameObject("아바타 의상 컨트롤러");
if (parent != null)
{
avatarOutfitObject.transform.SetParent(parent.transform);
}
// AvatarOutfitController 스크립트 추가
var avatarOutfitController = avatarOutfitObject.AddComponent<AvatarOutfitController>();
// 기본 설정
SerializedObject serializedObject = new SerializedObject(avatarOutfitController);
serializedObject.Update();
// 아바타 리스트 초기화
var avatarsProperty = serializedObject.FindProperty("avatars");
if (avatarsProperty != null)
{
avatarsProperty.ClearArray();
avatarsProperty.arraySize = 0;
}
// 자동 찾기 비활성화
var autoFindAvatarsProperty = serializedObject.FindProperty("autoFindAvatars");
if (autoFindAvatarsProperty != null)
{
autoFindAvatarsProperty.boolValue = false;
}
serializedObject.ApplyModifiedProperties();
UnityEngine.Debug.Log("아바타 의상 컨트롤러 생성됨");
}
}
}

View File

@ -0,0 +1,136 @@
using UnityEngine;
[DefaultExecutionOrder(16001)]
public class SimplePoseTransfer : MonoBehaviour
{
[Header("Pose Transfer Settings")]
public Animator sourceBone;
public Animator[] targetBones;
// 캐싱된 Transform들
private Transform[] cachedSourceBones;
private Transform[,] cachedTargetBones;
// 각 targetBone의 초기 회전 차이를 저장
private Quaternion[,] boneRotationDifferences;
private void Start()
{
Init();
}
public void Init()
{
if (targetBones == null || targetBones.Length == 0)
{
Debug.LogError("Target bones are null or empty");
return;
}
if (sourceBone == null)
{
Debug.LogError("Source bone is null");
return;
}
InitializeTargetBones();
CacheAllBoneTransforms();
Debug.Log($"SimplePoseTransfer initialized with {targetBones.Length} targets");
}
private void InitializeTargetBones()
{
boneRotationDifferences = new Quaternion[targetBones.Length, 55];
for (int i = 0; i < targetBones.Length; i++)
{
if (targetBones[i] == null)
{
Debug.LogError($"targetBones[{i}] is null");
continue;
}
// 55개의 휴머노이드 본에 대해 회전 차이 계산
for (int j = 0; j < 55; j++)
{
Transform sourceBoneTransform = sourceBone.GetBoneTransform((HumanBodyBones)j);
Transform targetBoneTransform = targetBones[i].GetBoneTransform((HumanBodyBones)j);
if (sourceBoneTransform != null && targetBoneTransform != null)
{
boneRotationDifferences[i, j] = Quaternion.Inverse(sourceBoneTransform.rotation) * targetBoneTransform.rotation;
}
}
}
}
private void CacheAllBoneTransforms()
{
// 소스 본 캐싱
cachedSourceBones = new Transform[55];
for (int i = 0; i < 55; i++)
{
cachedSourceBones[i] = sourceBone.GetBoneTransform((HumanBodyBones)i);
}
// 타겟 본 캐싱
cachedTargetBones = new Transform[targetBones.Length, 55];
for (int t = 0; t < targetBones.Length; t++)
{
for (int i = 0; i < 55; i++)
{
cachedTargetBones[t, i] = targetBones[t].GetBoneTransform((HumanBodyBones)i);
}
}
}
private void LateUpdate()
{
TransferPoses();
}
private void TransferPoses()
{
if (sourceBone == null)
{
return;
}
for (int i = 0; i < targetBones.Length; i++)
{
if (targetBones[i] != null && targetBones[i].gameObject.activeInHierarchy)
{
TransferPoseToTarget(i);
}
}
}
private void TransferPoseToTarget(int targetIndex)
{
Animator targetBone = targetBones[targetIndex];
// 루트 회전 동기화
targetBone.transform.rotation = sourceBone.transform.rotation;
// 모든 본에 대해 포즈 전송
for (int i = 0; i < 55; i++)
{
Transform targetBoneTransform = cachedTargetBones[targetIndex, i];
Transform sourceBoneTransform = cachedSourceBones[i];
if (targetBoneTransform != null && sourceBoneTransform != null)
{
// 회전 적용
targetBoneTransform.rotation = sourceBoneTransform.rotation * boneRotationDifferences[targetIndex, i];
// 펠비스 위치 동기화 (HumanBodyBones.Hips = 0)
if (i == 0)
{
targetBoneTransform.position = sourceBoneTransform.position;
}
}
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: aa7ade46b95571a4a850f9b5d32c6886

View File

@ -15,6 +15,7 @@ public class StreamDeckServerManager : MonoBehaviour
public CameraManager cameraManager { get; private set; }
public ItemController itemController { get; private set; }
public EventController eventController { get; private set; }
public AvatarOutfitController avatarOutfitController { get; private set; }
// 싱글톤 패턴으로 StreamDeckService에서 접근 가능하도록
public static StreamDeckServerManager Instance { get; private set; }
@ -52,6 +53,13 @@ public class StreamDeckServerManager : MonoBehaviour
Debug.LogWarning("[StreamDeckServerManager] EventController를 찾을 수 없습니다. 이벤트 컨트롤 기능이 비활성화됩니다.");
}
// AvatarOutfitController 찾기
avatarOutfitController = FindObjectOfType<AvatarOutfitController>();
if (avatarOutfitController == null)
{
Debug.LogWarning("[StreamDeckServerManager] AvatarOutfitController를 찾을 수 없습니다. 아바타 의상 컨트롤 기능이 비활성화됩니다.");
}
StartServer();
}
@ -162,7 +170,9 @@ public class StreamDeckServerManager : MonoBehaviour
item_data = itemController?.GetItemListData(),
current_item = itemController?.GetCurrentItemState(),
event_data = eventController?.GetEventListData(),
current_event = eventController?.GetCurrentEventState()
current_event = eventController?.GetCurrentEventState(),
avatar_outfit_data = avatarOutfitController?.GetAvatarOutfitListData(),
current_avatar_outfit = avatarOutfitController?.GetCurrentAvatarOutfitState()
}
};
@ -253,6 +263,28 @@ public class StreamDeckServerManager : MonoBehaviour
Debug.Log("[StreamDeckServerManager] 이벤트 변경 알림 전송됨");
}
// 아바타 의상 변경 시 모든 클라이언트에게 알림
public void NotifyAvatarOutfitChanged()
{
if (avatarOutfitController == null) return;
var updateMessage = 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()
}
};
string json = JsonConvert.SerializeObject(updateMessage);
BroadcastMessage(json);
Debug.Log("[StreamDeckServerManager] 아바타 의상 변경 알림 전송됨");
}
// 메시지 처리 로직을 여기로 이동
private void ProcessMessage(string messageData, StreamDeckService service)
{
@ -294,6 +326,15 @@ public class StreamDeckServerManager : MonoBehaviour
HandleGetEventList(service);
break;
case "set_avatar_outfit":
HandleSetAvatarOutfit(message);
break;
case "get_avatar_outfit_list":
HandleGetAvatarOutfitList(service);
break;
case "test":
// 테스트 메시지 에코 응답
var response = new
@ -827,6 +868,129 @@ public class StreamDeckServerManager : MonoBehaviour
string json = JsonConvert.SerializeObject(response);
service.SendMessage(json);
}
private void HandleSetAvatarOutfit(Dictionary<string, object> message)
{
Debug.Log($"[StreamDeckServerManager] 아바타 의상 설정 요청 수신");
if (avatarOutfitController == null)
{
Debug.LogError("[StreamDeckServerManager] avatarOutfitController가 null입니다!");
return;
}
try
{
if (message.ContainsKey("data"))
{
var dataObject = message["data"];
if (dataObject is Newtonsoft.Json.Linq.JObject jObject)
{
if (jObject.ContainsKey("avatar_index") && jObject.ContainsKey("outfit_index"))
{
var avatarIndexToken = jObject["avatar_index"];
var outfitIndexToken = jObject["outfit_index"];
if (int.TryParse(avatarIndexToken?.ToString(), out int avatarIndex) &&
int.TryParse(outfitIndexToken?.ToString(), out int outfitIndex))
{
if (avatarIndex >= 0 && avatarIndex < (avatarOutfitController.avatars?.Count ?? 0))
{
Debug.Log($"[StreamDeckServerManager] 아바타 {avatarIndex}번 의상을 {outfitIndex}번으로 설정");
avatarOutfitController.SetAvatarOutfit(avatarIndex, outfitIndex);
}
else
{
Debug.LogError($"[StreamDeckServerManager] 잘못된 아바타 인덱스: {avatarIndex}, 유효 범위: 0-{(avatarOutfitController.avatars?.Count ?? 0) - 1}");
}
}
else
{
Debug.LogError($"[StreamDeckServerManager] 인덱스 파싱 실패: avatar_index={avatarIndexToken}, outfit_index={outfitIndexToken}");
}
}
else
{
Debug.LogError("[StreamDeckServerManager] data에 'avatar_index' 또는 'outfit_index' 키가 없습니다");
}
}
else if (dataObject is Dictionary<string, object> data)
{
if (data.ContainsKey("avatar_index") && data.ContainsKey("outfit_index"))
{
var avatarIndexObj = data["avatar_index"];
var outfitIndexObj = data["outfit_index"];
if (int.TryParse(avatarIndexObj?.ToString(), out int avatarIndex) &&
int.TryParse(outfitIndexObj?.ToString(), out int outfitIndex))
{
if (avatarIndex >= 0 && avatarIndex < (avatarOutfitController.avatars?.Count ?? 0))
{
Debug.Log($"[StreamDeckServerManager] 아바타 {avatarIndex}번 의상을 {outfitIndex}번으로 설정");
avatarOutfitController.SetAvatarOutfit(avatarIndex, outfitIndex);
}
else
{
Debug.LogError($"[StreamDeckServerManager] 잘못된 아바타 인덱스: {avatarIndex}, 유효 범위: 0-{(avatarOutfitController.avatars?.Count ?? 0) - 1}");
}
}
else
{
Debug.LogError($"[StreamDeckServerManager] 인덱스 파싱 실패: avatar_index={avatarIndexObj}, outfit_index={outfitIndexObj}");
}
}
else
{
Debug.LogError("[StreamDeckServerManager] data에 'avatar_index' 또는 'outfit_index' 키가 없습니다");
}
}
else
{
Debug.LogError($"[StreamDeckServerManager] data가 예상된 타입이 아닙니다: {dataObject?.GetType().Name}");
}
}
else
{
Debug.LogError("[StreamDeckServerManager] 메시지에 'data' 키가 없습니다");
}
}
catch (Exception ex)
{
Debug.LogError($"[StreamDeckServerManager] 아바타 의상 설정 실패: {ex.Message}");
}
}
private void HandleGetAvatarOutfitList(StreamDeckService service)
{
Debug.Log("[StreamDeckServerManager] 아바타 의상 목록 요청 처리 시작");
if (avatarOutfitController == null)
{
Debug.LogError("[StreamDeckServerManager] avatarOutfitController가 null입니다!");
return;
}
var avatarData = avatarOutfitController.GetAvatarOutfitListData();
Debug.Log($"[StreamDeckServerManager] 아바타 데이터: {JsonConvert.SerializeObject(avatarData)}");
var response = new
{
type = "avatar_outfit_list_response",
timestamp = DateTime.UtcNow.ToString("o"),
version = "1.0",
data = new
{
avatar_outfit_data = avatarData,
current_avatar_outfit = avatarOutfitController.GetCurrentAvatarOutfitState()
}
};
string json = JsonConvert.SerializeObject(response);
Debug.Log($"[StreamDeckServerManager] 아바타 목록 응답 전송: {json}");
service.SendMessage(json);
}
}
public class StreamDeckService : WebSocketBehavior

View File

@ -0,0 +1,376 @@
using UnityEngine;
using System;
using System.Collections.Generic;
using System.Linq;
using Streamingle;
using Newtonsoft.Json;
public class AvatarOutfitController : MonoBehaviour, IController
{
#region Classes
[System.Serializable]
public class AvatarData
{
[Header("Avatar Settings")]
public string avatarName = "New Avatar";
public GameObject avatarObject;
[Header("Outfit Settings")]
public OutfitData[] outfits = new OutfitData[0];
// 현재 착용 중인 의상 인덱스
[SerializeField] private int currentOutfitIndex = 0;
public int CurrentOutfitIndex => currentOutfitIndex;
public OutfitData CurrentOutfit => outfits != null && outfits.Length > currentOutfitIndex ? outfits[currentOutfitIndex] : null;
public AvatarData(string name)
{
avatarName = name;
outfits = new OutfitData[0];
}
public void SetOutfit(int outfitIndex)
{
if (outfits == null || outfitIndex < 0 || outfitIndex >= outfits.Length)
{
Debug.LogWarning($"[AvatarData] 잘못된 의상 인덱스: {outfitIndex}");
return;
}
// 현재 의상 제거
if (CurrentOutfit != null)
{
CurrentOutfit.RemoveOutfit();
}
// 새 의상 적용
currentOutfitIndex = outfitIndex;
if (CurrentOutfit != null)
{
CurrentOutfit.ApplyOutfit();
Debug.Log($"[AvatarData] {avatarName} 의상 변경: {CurrentOutfit.outfitName}");
}
}
public override string ToString() => avatarName;
}
[System.Serializable]
public class OutfitData
{
[Header("Outfit Info")]
public string outfitName = "New Outfit";
[Header("Clothing GameObjects")]
public GameObject[] clothingObjects = new GameObject[0]; // 활성화할 의상 오브젝트들
public GameObject[] hideObjects = new GameObject[0]; // 비활성화할 오브젝트들 (기본 의상 등)
public void ApplyOutfit()
{
// 의상 오브젝트들 활성화
foreach (var obj in clothingObjects)
{
if (obj != null) obj.SetActive(true);
}
// 숨겨야 할 오브젝트들 비활성화
foreach (var obj in hideObjects)
{
if (obj != null) obj.SetActive(false);
}
}
public void RemoveOutfit()
{
// 의상 오브젝트들 비활성화
foreach (var obj in clothingObjects)
{
if (obj != null) obj.SetActive(false);
}
// 숨겨진 오브젝트들 다시 활성화
foreach (var obj in hideObjects)
{
if (obj != null) obj.SetActive(true);
}
}
}
#endregion
#region Events
public delegate void AvatarOutfitChangedEventHandler(AvatarData avatar, OutfitData oldOutfit, OutfitData newOutfit);
public event AvatarOutfitChangedEventHandler OnAvatarOutfitChanged;
#endregion
#region Fields
[SerializeField] public List<AvatarData> avatars = new List<AvatarData>();
[Header("Avatar Control Settings")]
[SerializeField] private bool autoFindAvatars = false;
[SerializeField] private string avatarTag = "Avatar";
private AvatarData currentAvatar;
private StreamDeckServerManager streamDeckManager;
#endregion
#region Properties
public AvatarData CurrentAvatar => currentAvatar;
public int CurrentAvatarIndex => avatars.IndexOf(currentAvatar);
#endregion
#region Unity Messages
private void Awake()
{
InitializeAvatars();
// StreamDeckServerManager 찾기
streamDeckManager = FindObjectOfType<StreamDeckServerManager>();
if (streamDeckManager == null)
{
Debug.LogWarning("[AvatarOutfitController] StreamDeckServerManager를 찾을 수 없습니다. 스트림덱 연동이 비활성화됩니다.");
}
}
#endregion
#region Initialization
public void InitializeAvatars()
{
if (avatars == null)
{
avatars = new List<AvatarData>();
}
if (autoFindAvatars && avatars.Count == 0)
{
// "Avatar" 태그가 붙은 모든 오브젝트를 찾아서 아바타로 등록
var avatarObjects = GameObject.FindGameObjectsWithTag(avatarTag);
foreach (var avatarObj in avatarObjects)
{
var avatar = new AvatarData(avatarObj.name);
avatar.avatarObject = avatarObj;
avatars.Add(avatar);
}
Debug.Log($"[AvatarOutfitController] {avatars.Count}개의 아바타를 자동으로 찾았습니다.");
}
// 유효하지 않은 아바타 제거
avatars.RemoveAll(avatar => avatar == null || avatar.avatarObject == null);
// 첫 번째 아바타를 기본으로 설정
if (avatars.Count > 0 && currentAvatar == null)
{
currentAvatar = avatars[0];
}
Debug.Log($"[AvatarOutfitController] 총 {avatars.Count}개의 아바타가 등록되었습니다.");
}
#endregion
#region Public Methods
public void Set(int index)
{
// IController 인터페이스 구현 - 첫 번째 아바타의 특정 의상으로 설정
if (avatars.Count > 0)
{
SetAvatarOutfit(0, index);
}
else
{
Debug.LogWarning("[AvatarOutfitController] 설정할 아바타가 없습니다.");
}
}
public void SetAvatarOutfit(int avatarIndex, int outfitIndex)
{
if (avatarIndex < 0 || avatarIndex >= avatars.Count)
{
Debug.LogWarning($"[AvatarOutfitController] 잘못된 아바타 인덱스: {avatarIndex}");
return;
}
var avatar = avatars[avatarIndex];
var oldOutfit = avatar.CurrentOutfit;
avatar.SetOutfit(outfitIndex);
// 이벤트 발생
OnAvatarOutfitChanged?.Invoke(avatar, oldOutfit, avatar.CurrentOutfit);
// StreamDeck에 알림
NotifyAvatarOutfitChanged(avatar);
Debug.Log($"[AvatarOutfitController] 아바타 의상 변경: {avatar.avatarName} -> {avatar.CurrentOutfit?.outfitName}");
}
public void AddAvatar(string avatarName, GameObject avatarObject = null)
{
var newAvatar = new AvatarData(avatarName);
if (avatarObject != null)
{
newAvatar.avatarObject = avatarObject;
}
avatars.Add(newAvatar);
NotifyAvatarOutfitChanged(newAvatar);
Debug.Log($"[AvatarOutfitController] 아바타 추가: {avatarName}");
}
public void RemoveAvatar(int index)
{
if (index < 0 || index >= avatars.Count) return;
var removedAvatar = avatars[index];
avatars.RemoveAt(index);
// 현재 아바타가 제거된 경우 첫 번째 아바타로 변경
if (removedAvatar == currentAvatar)
{
currentAvatar = avatars.Count > 0 ? avatars[0] : null;
}
Debug.Log($"[AvatarOutfitController] 아바타 제거: {removedAvatar.avatarName}");
}
#endregion
#region StreamDeck Integration
private void NotifyAvatarOutfitChanged(AvatarData avatar)
{
if (streamDeckManager != null)
{
streamDeckManager.NotifyAvatarOutfitChanged();
}
}
public AvatarOutfitListData GetAvatarOutfitListData()
{
return new AvatarOutfitListData
{
avatar_count = avatars.Count,
avatars = avatars.Select((a, i) => new AvatarPresetData
{
index = i,
name = a.avatarName,
current_outfit_index = a.CurrentOutfitIndex,
current_outfit_name = a.CurrentOutfit?.outfitName ?? "없음",
outfits = a.outfits?.Select((o, oi) => new OutfitPresetData
{
index = oi,
name = o.outfitName
}).ToArray() ?? new OutfitPresetData[0],
hotkey = "스트림덱 전용"
}).ToArray(),
current_avatar_index = CurrentAvatarIndex
};
}
public AvatarOutfitStateData GetCurrentAvatarOutfitState()
{
if (currentAvatar == null) return null;
return new AvatarOutfitStateData
{
current_avatar_index = CurrentAvatarIndex,
avatar_name = currentAvatar.avatarName,
current_outfit_index = currentAvatar.CurrentOutfitIndex,
current_outfit_name = currentAvatar.CurrentOutfit?.outfitName ?? "없음",
total_avatars = avatars.Count
};
}
public string GetAvatarOutfitListJson()
{
return JsonConvert.SerializeObject(GetAvatarOutfitListData());
}
public string GetAvatarOutfitStateJson()
{
return JsonConvert.SerializeObject(GetCurrentAvatarOutfitState());
}
#endregion
#region Data Classes
[System.Serializable]
public class OutfitPresetData
{
public int index;
public string name;
}
[System.Serializable]
public class AvatarPresetData
{
public int index;
public string name;
public int current_outfit_index;
public string current_outfit_name;
public OutfitPresetData[] outfits;
public string hotkey;
}
[System.Serializable]
public class AvatarOutfitListData
{
public int avatar_count;
public AvatarPresetData[] avatars;
public int current_avatar_index;
}
[System.Serializable]
public class AvatarOutfitStateData
{
public int current_avatar_index;
public string avatar_name;
public int current_outfit_index;
public string current_outfit_name;
public int total_avatars;
}
#endregion
#region IController Implementation
public string GetControllerId()
{
return "avatar_outfit_controller";
}
public string GetControllerName()
{
return "Avatar Outfit Controller";
}
public object GetControllerData()
{
return GetAvatarOutfitListData();
}
public void ExecuteAction(string actionId, object parameters)
{
switch (actionId)
{
case "set_avatar_outfit":
if (parameters is Dictionary<string, object> setParams)
{
if (setParams.ContainsKey("avatar_index") && setParams.ContainsKey("outfit_index"))
{
int avatarIndex = Convert.ToInt32(setParams["avatar_index"]);
int outfitIndex = Convert.ToInt32(setParams["outfit_index"]);
SetAvatarOutfit(avatarIndex, outfitIndex);
}
}
break;
default:
Debug.LogWarning($"[AvatarOutfitController] 알 수 없는 액션: {actionId}");
break;
}
}
#endregion
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 22f54f611910ea3468bd422844360c12

Binary file not shown.

View File

@ -1,288 +0,0 @@
#!/usr/bin/env node
/**
* Streamingle Camera Plugin Build Script
* StreamDock 플러그인 폴더에 자동 복사 기능 포함
*/
const fs = require('fs');
const path = require('path');
// 설정
const PLUGIN_NAME = 'com.mirabox.streamingle.sdPlugin';
const STREAMDOCK_PLUGINS_PATH = 'C:\\Users\\qscft\\AppData\\Roaming\\HotSpot\\StreamDock\\plugins';
const CURRENT_DIR = __dirname;
// 색상 출력 함수
function log(message, color = 'white') {
const colors = {
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m',
white: '\x1b[37m',
reset: '\x1b[0m'
};
console.log(`${colors[color]}${message}${colors.reset}`);
}
// 디렉토리 복사 함수 (제외할 파일/폴더 필터링 포함)
function copyDirectory(src, dest) {
// 제외할 파일/폴더 목록
const excludePatterns = [
'dist',
'dist-dev',
'node_modules',
'.git',
'.gitignore',
'build.js',
'build.log',
'README.md'
// package.json과 package-lock.json은 제외하지 않음 (MiraBox StreamDock에서 필요할 수 있음)
];
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true });
}
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
// 제외할 파일/폴더 체크
if (excludePatterns.includes(entry.name)) {
console.log(`⏭️ 제외: ${entry.name}`);
continue;
}
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
copyDirectory(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
// 디렉토리 삭제 함수
function removeDirectory(dirPath) {
if (fs.existsSync(dirPath)) {
fs.rmSync(dirPath, { recursive: true, force: true });
}
}
// 빌드 함수 - StreamDock 플러그인 폴더에 직접 빌드
function build() {
log('🔨 Streamingle Camera Plugin 빌드 시작...', 'cyan');
const buildDir = path.join(STREAMDOCK_PLUGINS_PATH, PLUGIN_NAME);
const pluginDir = CURRENT_DIR;
// StreamDock 플러그인 폴더 확인
if (!fs.existsSync(STREAMDOCK_PLUGINS_PATH)) {
log('❌ StreamDock 플러그인 폴더를 찾을 수 없습니다', 'red');
log(`📁 예상 경로: ${STREAMDOCK_PLUGINS_PATH}`, 'yellow');
return false;
}
// 기존 플러그인 삭제
if (fs.existsSync(buildDir)) {
removeDirectory(buildDir);
log('🗑️ 기존 플러그인 삭제 완료', 'yellow');
}
// 빌드 폴더 생성
fs.mkdirSync(buildDir, { recursive: true });
// 플러그인 폴더 복사
if (fs.existsSync(pluginDir)) {
copyDirectory(pluginDir, buildDir);
log('✅ 플러그인 파일 복사 완료', 'green');
} else {
log('❌ 플러그인 폴더를 찾을 수 없습니다', 'red');
return false;
}
log('🎉 빌드 완료!', 'green');
log(`📁 빌드 위치: ${buildDir}`, 'blue');
log('💡 StreamDock을 재시작하면 플러그인이 활성화됩니다', 'cyan');
return true;
}
// 개발 빌드 함수
function buildDev() {
log('🔨 Streamingle Camera Plugin 개발 빌드 시작...', 'cyan');
const buildDir = path.join(CURRENT_DIR, 'dist-dev');
const pluginDir = CURRENT_DIR;
// 기존 빌드 폴더 정리
if (fs.existsSync(buildDir)) {
removeDirectory(buildDir);
}
// 빌드 폴더 생성
fs.mkdirSync(buildDir, { recursive: true });
// 플러그인 폴더 복사
if (fs.existsSync(pluginDir)) {
copyDirectory(pluginDir, path.join(buildDir, PLUGIN_NAME));
log('✅ 플러그인 파일 복사 완료', 'green');
} else {
log('❌ 플러그인 폴더를 찾을 수 없습니다', 'red');
return false;
}
log('🎉 개발 빌드 완료!', 'green');
log(`📁 빌드 위치: ${buildDir}`, 'blue');
return true;
}
// StreamDock에 배포 함수
function deploy() {
log('🚀 StreamDock에 플러그인 배포 시작...', 'magenta');
const pluginDir = CURRENT_DIR;
const streamdockPluginPath = path.join(STREAMDOCK_PLUGINS_PATH, PLUGIN_NAME);
// StreamDock 플러그인 폴더 확인
if (!fs.existsSync(STREAMDOCK_PLUGINS_PATH)) {
log('❌ StreamDock 플러그인 폴더를 찾을 수 없습니다', 'red');
log(`📁 예상 경로: ${STREAMDOCK_PLUGINS_PATH}`, 'yellow');
return false;
}
// 기존 플러그인 삭제
if (fs.existsSync(streamdockPluginPath)) {
removeDirectory(streamdockPluginPath);
log('🗑️ 기존 플러그인 삭제 완료', 'yellow');
}
// 새 플러그인 복사
if (fs.existsSync(pluginDir)) {
copyDirectory(pluginDir, streamdockPluginPath);
log('✅ 플러그인 복사 완료', 'green');
} else {
log('❌ 플러그인 폴더를 찾을 수 없습니다', 'red');
return false;
}
log('🎉 StreamDock 배포 완료!', 'green');
log(`📁 배포 위치: ${streamdockPluginPath}`, 'blue');
log('💡 StreamDock을 재시작하면 플러그인이 활성화됩니다', 'cyan');
return true;
}
// 개발용 배포 함수 (빌드 후 배포)
function deployDev() {
log('🚀 개발용 StreamDock 배포 시작...', 'magenta');
// 먼저 빌드
if (!buildDev()) {
return false;
}
const buildDir = path.join(CURRENT_DIR, 'dist-dev', PLUGIN_NAME);
const streamdockPluginPath = path.join(STREAMDOCK_PLUGINS_PATH, PLUGIN_NAME);
// StreamDock 플러그인 폴더 확인
if (!fs.existsSync(STREAMDOCK_PLUGINS_PATH)) {
log('❌ StreamDock 플러그인 폴더를 찾을 수 없습니다', 'red');
log(`📁 예상 경로: ${STREAMDOCK_PLUGINS_PATH}`, 'yellow');
return false;
}
// 기존 플러그인 삭제
if (fs.existsSync(streamdockPluginPath)) {
removeDirectory(streamdockPluginPath);
log('🗑️ 기존 플러그인 삭제 완료', 'yellow');
}
// 새 플러그인 복사
if (fs.existsSync(buildDir)) {
copyDirectory(buildDir, streamdockPluginPath);
log('✅ 플러그인 복사 완료', 'green');
} else {
log('❌ 빌드된 플러그인 폴더를 찾을 수 없습니다', 'red');
return false;
}
log('🎉 개발용 StreamDock 배포 완료!', 'green');
log(`📁 배포 위치: ${streamdockPluginPath}`, 'blue');
log('💡 StreamDock을 재시작하면 플러그인이 활성화됩니다', 'cyan');
return true;
}
// 정리 함수
function clean() {
log('🧹 빌드 폴더 정리 시작...', 'yellow');
const buildDir = path.join(CURRENT_DIR, 'dist');
const buildDevDir = path.join(CURRENT_DIR, 'dist-dev');
if (fs.existsSync(buildDir)) {
removeDirectory(buildDir);
log('✅ dist 폴더 삭제 완료', 'green');
}
if (fs.existsSync(buildDevDir)) {
removeDirectory(buildDevDir);
log('✅ dist-dev 폴더 삭제 완료', 'green');
}
log('🎉 정리 완료!', 'green');
}
// 도움말 함수
function help() {
log('📖 Streamingle Camera Plugin 빌드 도구', 'cyan');
log('', 'white');
log('사용법:', 'yellow');
log(' node build.js <명령>', 'white');
log('', 'white');
log('명령:', 'yellow');
log(' build - 프로덕션 빌드 (dist 폴더에 생성)', 'white');
log(' build:dev - 개발 빌드 (dist-dev 폴더에 생성)', 'white');
log(' deploy - StreamDock에 직접 배포', 'white');
log(' deploy:dev - 개발 빌드 후 StreamDock에 배포', 'white');
log(' clean - 빌드 폴더 정리', 'white');
log(' help - 이 도움말 표시', 'white');
log('', 'white');
log('예시:', 'yellow');
log(' node build.js deploy # 바로 StreamDock에 배포', 'white');
log(' node build.js deploy:dev # 개발 빌드 후 배포', 'white');
}
// 메인 실행
const command = process.argv[2];
switch (command) {
case 'build':
build();
break;
case 'build:dev':
buildDev();
break;
case 'deploy':
deploy();
break;
case 'deploy:dev':
deployDev();
break;
case 'clean':
clean();
break;
case 'help':
case '--help':
case '-h':
help();
break;
default:
log('❌ 알 수 없는 명령입니다', 'red');
log('', 'white');
help();
break;
}

View File

@ -1,133 +0,0 @@
const fs = require('fs');
const path = require('path');
console.log('=== Streamingle 플러그인 디버깅 도구 ===');
// 1. 플러그인 파일 확인
console.log('\n1. 플러그인 파일 확인:');
const pluginDir = __dirname;
const requiredFiles = [
'manifest.json',
'plugin.js',
'package.json',
'propertyinspector.html'
];
requiredFiles.forEach(file => {
const filePath = path.join(pluginDir, file);
if (fs.existsSync(filePath)) {
const stats = fs.statSync(filePath);
console.log(`${file} - ${stats.size} bytes`);
} else {
console.log(`${file} - 파일 없음`);
}
});
// 2. manifest.json 내용 확인
console.log('\n2. manifest.json 내용:');
try {
const manifestPath = path.join(pluginDir, 'manifest.json');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
console.log('✅ manifest.json 파싱 성공');
console.log(` SDK 버전: ${manifest.SDKVersion}`);
console.log(` 코드 경로: ${manifest.CodePath}`);
console.log(` 플러그인 이름: ${manifest.Name}`);
console.log(` 액션 UUID: ${manifest.Actions[0].UUID}`);
} catch (error) {
console.log(`❌ manifest.json 파싱 실패: ${error.message}`);
}
// 3. package.json 내용 확인
console.log('\n3. package.json 내용:');
try {
const packagePath = path.join(pluginDir, 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
console.log('✅ package.json 파싱 성공');
console.log(` 이름: ${packageJson.name}`);
console.log(` 버전: ${packageJson.version}`);
console.log(` 메인 파일: ${packageJson.main}`);
console.log(` 의존성: ${Object.keys(packageJson.dependencies || {}).join(', ')}`);
} catch (error) {
console.log(`❌ package.json 파싱 실패: ${error.message}`);
}
// 4. WebSocket 연결 테스트
console.log('\n4. WebSocket 연결 테스트:');
const WebSocket = require('ws');
function testWebSocketConnection() {
return new Promise((resolve) => {
console.log(' 연결 시도 중...');
const ws = new WebSocket('ws://127.0.0.1:10701/');
const timeout = setTimeout(() => {
console.log(' ❌ 연결 타임아웃 (5초)');
ws.close();
resolve(false);
}, 5000);
ws.on('open', () => {
console.log(' ✅ WebSocket 연결 성공!');
clearTimeout(timeout);
ws.close();
resolve(true);
});
ws.on('error', (error) => {
console.log(` ❌ WebSocket 연결 실패: ${error.message}`);
clearTimeout(timeout);
resolve(false);
});
ws.on('close', (code, reason) => {
console.log(` 🔌 연결 종료 - 코드: ${code}, 이유: ${reason || '알 수 없음'}`);
});
});
}
testWebSocketConnection().then((connected) => {
console.log(`\n결과: ${connected ? '✅ 연결 가능' : '❌ 연결 불가'}`);
// 5. 포트 상태 확인
console.log('\n5. 포트 상태 확인:');
const { exec } = require('child_process');
exec('netstat -an | findstr :10701', (error, stdout, stderr) => {
if (error) {
console.log(` ❌ netstat 실행 실패: ${error.message}`);
return;
}
if (stdout.trim()) {
console.log(' ✅ 포트 10701 사용 중:');
stdout.split('\n').forEach(line => {
if (line.trim()) {
console.log(` ${line.trim()}`);
}
});
} else {
console.log(' ❌ 포트 10701에서 서비스 없음');
}
});
});
// 6. StreamDock 플러그인 경로 확인
console.log('\n6. StreamDock 플러그인 경로:');
const streamDockPath = 'C:\\Users\\qscft\\AppData\\Roaming\\HotSpot\\StreamDock\\plugins\\com.mirabox.streamingle.sdPlugin';
if (fs.existsSync(streamDockPath)) {
console.log(` ✅ StreamDock 플러그인 폴더 존재: ${streamDockPath}`);
const files = fs.readdirSync(streamDockPath);
console.log(` 📁 파일 개수: ${files.length}`);
files.forEach(file => {
const filePath = path.join(streamDockPath, file);
const stats = fs.statSync(filePath);
const type = stats.isDirectory() ? '📁' : '📄';
console.log(` ${type} ${file} - ${stats.size} bytes`);
});
} else {
console.log(` ❌ StreamDock 플러그인 폴더 없음: ${streamDockPath}`);
}
console.log('\n=== 디버깅 완료 ===');

View File

@ -1,314 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>Streamingle Plugin Main</title>
</head>
<body>
<div style="color: #666; text-align: center; padding: 20px;">
플러그인 메인 - 버튼 클릭 처리<br>
Property Inspector에서 설정 관리
</div>
<script>
console.log('📄 플러그인 메인 로드됨');
// Global variables
let websocket = null;
let buttonContexts = new Map(); // 각 버튼의 컨텍스트별 설정 저장
let unitySocket = null;
let isUnityConnected = false;
let cameraList = []; // 카메라 목록 저장
// StreamDeck SDK 연결
function connectElgatoStreamDeckSocket(inPort, inUUID, inEvent, inInfo, inActionInfo) {
console.log('🔌 StreamDeck 연결 (메인)');
// Parse action info to get settings
try {
const actionInfo = JSON.parse(inActionInfo);
const context = actionInfo.context;
const settings = actionInfo.payload.settings || {};
// 각 컨텍스트별로 설정 저장
buttonContexts.set(context, settings);
console.log('⚙️ 초기 컨텍스트별 설정 저장');
console.log('📍 컨텍스트:', context);
console.log('📋 설정:', settings);
console.log('📹 카메라 인덱스:', settings.cameraIndex, '(타입:', typeof settings.cameraIndex, ')');
// 즉시 제목 업데이트 시도 (카메라 데이터가 있으면)
if (cameraList.length > 0) {
updateButtonTitle(context);
}
} catch (error) {
console.error('❌ ActionInfo 파싱 오류:', error);
}
// 첫 번째 연결인 경우에만 WebSocket 초기화
if (!websocket) {
websocket = new WebSocket('ws://localhost:' + inPort);
websocket.onopen = function() {
console.log('✅ StreamDeck 연결됨 (메인)');
websocket.send(JSON.stringify({
event: inEvent,
uuid: inUUID
}));
// Unity 연결 시도
setTimeout(() => {
connectToUnity();
}, 1000);
};
websocket.onmessage = function(evt) {
try {
const jsonObj = JSON.parse(evt.data);
console.log('📨 메인에서 메시지 수신:', jsonObj);
switch(jsonObj.event) {
case 'didReceiveSettings':
if (jsonObj.payload && jsonObj.context) {
const newSettings = jsonObj.payload.settings || {};
buttonContexts.set(jsonObj.context, newSettings);
console.log('⚙️ 설정 업데이트됨');
console.log('📍 컨텍스트:', jsonObj.context);
console.log('📋 새 설정:', newSettings);
console.log('📹 카메라 인덱스:', newSettings.cameraIndex, '(타입:', typeof newSettings.cameraIndex, ')');
// 버튼 제목 즉시 업데이트
updateButtonTitle(jsonObj.context);
// 조금 후에 한 번 더 시도 (StreamDock 응답 지연 대응)
setTimeout(() => {
updateButtonTitle(jsonObj.context);
console.log('🔄 플러그인 메인에서 제목 재시도 업데이트');
}, 150);
}
break;
case 'willAppear':
console.log('👀 버튼 나타남:', jsonObj.context);
// 초기 설정이 있으면 적용
if (jsonObj.payload && jsonObj.payload.settings) {
buttonContexts.set(jsonObj.context, jsonObj.payload.settings);
updateButtonTitle(jsonObj.context);
}
break;
case 'keyDown':
console.log('🔘 버튼 눌림');
break;
case 'keyUp':
console.log('🔘 버튼 클릭됨! 카메라 전환 처리');
handleButtonClick(jsonObj.context);
break;
}
} catch (error) {
console.error('❌ 메시지 파싱 오류:', error);
}
};
websocket.onclose = function() {
console.log('❌ StreamDock 연결 끊어짐 (메인)');
websocket = null;
};
}
}
// Unity 연결
function connectToUnity() {
console.log('🔌 Unity 연결 시도 (메인)...');
if (unitySocket) {
unitySocket.close();
}
try {
unitySocket = new WebSocket('ws://localhost:10701');
unitySocket.onopen = function() {
isUnityConnected = true;
console.log('✅ Unity 연결 성공 (메인)!');
// 카메라 목록 요청
setTimeout(() => {
const message = JSON.stringify({ type: 'get_camera_list' });
unitySocket.send(message);
console.log('📋 카메라 목록 요청 (메인):', message);
}, 100);
};
unitySocket.onmessage = function(event) {
try {
const data = JSON.parse(event.data);
console.log('📨 Unity 메시지 수신 (메인):', data.type);
handleUnityMessage(data);
} catch (error) {
console.error('❌ Unity 메시지 파싱 오류:', error);
}
};
unitySocket.onclose = function() {
isUnityConnected = false;
console.log('❌ Unity 연결 끊어짐 (메인)');
};
unitySocket.onerror = function(error) {
console.error('❌ Unity 연결 오류 (메인):', error);
};
} catch (error) {
console.error('❌ Unity 연결 설정 오류:', error);
}
}
// 버튼 클릭 처리
function handleButtonClick(context) {
console.log('🎯 버튼 클릭 처리 시작');
console.log('📍 컨텍스트:', context);
console.log('📋 전체 버튼 컨텍스트:', Array.from(buttonContexts.keys()));
console.log('⚙️ 현재 설정:', getCurrentSettings(context));
console.log('🔌 Unity 연결 상태:', isUnityConnected);
if (!isUnityConnected || !unitySocket) {
console.error('❌ Unity 연결되지 않음');
return;
}
// 설정에서 카메라 인덱스 가져오기
const settings = getCurrentSettings(context);
let cameraIndex = settings.cameraIndex;
// 카메라 인덱스가 설정되지 않았으면 0 사용
if (typeof cameraIndex !== 'number') {
cameraIndex = 0;
console.log('⚠️ 카메라 인덱스가 설정되지 않음, 기본값 0 사용');
}
console.log('📹 전환할 카메라 인덱스:', cameraIndex, '(타입:', typeof cameraIndex, ')');
// Unity 예상 구조에 맞게 data 객체로 래핑
const message = JSON.stringify({
type: 'switch_camera',
data: {
camera_index: cameraIndex
}
});
unitySocket.send(message);
console.log('📤 Unity에 카메라 전환 요청 전송:', message);
}
// Unity 메시지 처리
function handleUnityMessage(data) {
switch (data.type) {
case 'connection_established':
console.log('🎉 Unity 연결 확인됨 (메인)');
// 연결 시 카메라 데이터 저장
if (data.data && data.data.camera_data && data.data.camera_data.presets) {
cameraList = data.data.camera_data.presets;
console.log('📹 카메라 목록 저장됨 (메인):', cameraList.length, '개');
updateAllButtonTitles();
}
break;
case 'camera_list_response':
console.log('📹 카메라 목록 수신 (메인)');
if (data.data && data.data.camera_data && data.data.camera_data.presets) {
cameraList = data.data.camera_data.presets;
console.log('📹 카메라 목록 업데이트됨 (메인):', cameraList.length, '개');
updateAllButtonTitles();
}
break;
case 'camera_changed':
console.log('🎯 카메라 변경 알림 (메인)');
// 카메라 변경 시에는 제목 업데이트 안함 (각 버튼은 독립적)
break;
default:
console.log('❓ 알 수 없는 Unity 메시지 타입 (메인):', data.type);
}
}
// 모든 버튼의 제목 업데이트
function updateAllButtonTitles() {
for (const context of buttonContexts.keys()) {
updateButtonTitle(context);
}
}
// StreamDock 버튼 제목 업데이트
function updateButtonTitle(context) {
if (!websocket || !context) {
console.log('🚫 WebSocket 또는 context 없음 - 제목 업데이트 건너뜀');
return;
}
const cameraIndex = getCurrentSettings(context).cameraIndex || 0;
let title = `카메라 ${cameraIndex + 1}`;
// 카메라 목록에서 이름 찾기
if (cameraList && cameraList.length > cameraIndex) {
const camera = cameraList[cameraIndex];
if (camera && camera.name) {
// 카메라 이름에서 불필요한 부분 제거하고 짧게 만들기
let shortName = camera.name
.replace('Cam0', '')
.replace('Cam', '')
.replace('_', ' ')
.substring(0, 10); // 최대 10글자
title = shortName || `카메라 ${cameraIndex + 1}`;
}
}
// StreamDock에 제목 업데이트 요청
const message = {
event: 'setTitle',
context: context,
payload: {
title: title,
target: 0, // hardware and software
titleParameters: {
fontSize: 24, // 기본 12에서 24로 증가 (2배)
showTitle: true,
titleAlignment: "middle"
}
}
};
websocket.send(JSON.stringify(message));
console.log('🏷️ 버튼 제목 업데이트:', title);
}
// 설정 변경 시 제목 업데이트
function updateSettingsAndTitle(context, newSettings) {
updateCurrentSettings(context, newSettings);
updateButtonTitle(context);
}
// 설정 관리 헬퍼 함수들
function getCurrentSettings(context) {
if (!context) return {};
return buttonContexts.get(context) || {};
}
function setCurrentSettings(context, newSettings) {
if (!context) return;
buttonContexts.set(context, newSettings);
}
function updateCurrentSettings(context, partialSettings) {
if (!context) return;
const currentSettings = getCurrentSettings(context);
setCurrentSettings(context, { ...currentSettings, ...partialSettings });
}
</script>
</body>
</html>

View File

@ -1,664 +0,0 @@
/**
* Streamingle Camera Controller Plugin for StreamDeck
* 외부 패키지 없이 동작하도록 단순화
*/
// Global variables
let websocket = null;
let uuid = null;
let actionInfo = {};
let connectionInfo = {};
// Context 관리
const buttonContexts = new Map();
// Unity 연결 관리자
let unityManager = null;
// Unity 연결 관리 클래스 (내장 WebSocket만 사용)
class UnityConnectionManager {
constructor() {
this.isConnected = false;
this.socket = null;
this.reconnectInterval = null;
this.healthCheckInterval = null;
this.connectionAttempts = 0;
this.maxConsecutiveFailures = 10;
this.isShuttingDown = false;
this.cameraData = null;
this.currentCamera = 0;
this.eventListeners = new Map();
this.startHealthCheck();
}
on(event, callback) {
if (!this.eventListeners.has(event)) {
this.eventListeners.set(event, []);
}
this.eventListeners.get(event).push(callback);
}
emit(event, data) {
if (this.eventListeners.has(event)) {
this.eventListeners.get(event).forEach(callback => {
try {
callback(data);
} catch (error) {
console.log(`❌ Event listener error (${event}): ${error.message}`);
}
});
}
}
startHealthCheck() {
if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval);
}
this.healthCheckInterval = setInterval(() => {
console.log(`🩺 연결 상태 확인 - Unity 연결됨: ${this.isConnected}, 종료 중: ${this.isShuttingDown}, 재연결 중: ${!!this.reconnectInterval}`);
if (!this.isConnected && !this.isShuttingDown && !this.reconnectInterval) {
console.log('🩺 연결 상태 확인 - Unity 서버 재연결 시도');
this.connectionAttempts = 0;
this.connect();
}
}, 30000);
console.log('🩺 Unity 연결 상태 주기적 확인 시작 (30초 간격)');
}
connect() {
if (this.isShuttingDown) {
console.log('🛑 종료 중이므로 연결 시도 안함');
return;
}
if (this.socket && this.socket.readyState === 0) {
console.log('⏳ 이미 연결 시도 중...');
return;
}
if (this.socket) {
console.log('🔌 기존 소켓 닫기');
this.socket.close();
}
try {
console.log('🔌 Unity WebSocket 생성 시도: ws://localhost:10701');
this.socket = new window.WebSocket('ws://localhost:10701');
console.log(`🔌 Unity 연결 시도... (시도 ${this.connectionAttempts + 1}회)`);
this.socket.onopen = () => {
this.isConnected = true;
this.connectionAttempts = 0;
console.log('✅ Unity 연결 성공! WebSocket 연결됨');
if (this.reconnectInterval) {
clearTimeout(this.reconnectInterval);
this.reconnectInterval = null;
}
this.emit('connectionChange', this.isConnected);
// 연결 상태 변경을 모든 Property Inspector에 알림
broadcastToPropertyInspectors('connection_status', {
connected: true
});
this.requestCameraList();
};
this.socket.onmessage = (event) => {
try {
console.log('📨 Unity 원본 메시지:', event.data);
const data = JSON.parse(event.data);
console.log(`📨 Unity 메시지 파싱 완료: ${data.type || 'unknown'}`);
this.handleUnityMessage(data);
} catch (error) {
console.log(`❌ Unity 메시지 파싱 오류: ${error.message}`);
console.log(`❌ 원본 메시지: ${event.data}`);
}
};
this.socket.onclose = (event) => {
const wasConnected = this.isConnected;
this.isConnected = false;
if (wasConnected) {
console.log(`❌ Unity 연결 끊어짐 (코드: ${event.code}, 이유: ${event.reason || '알 수 없음'})`);
this.emit('connectionChange', this.isConnected);
} else {
console.log(`❌ Unity 연결 실패 (코드: ${event.code}, 이유: ${event.reason || '알 수 없음'})`);
}
if (!this.isShuttingDown) {
this.scheduleReconnect();
}
};
this.socket.onerror = (error) => {
console.log(`❌ Unity 연결 오류: ${error}`);
};
} catch (error) {
console.log(`❌ Unity 연결 설정 오류: ${error.message}`);
if (!this.isShuttingDown) {
this.scheduleReconnect();
}
}
}
scheduleReconnect() {
if (this.isShuttingDown || this.reconnectInterval) return;
this.connectionAttempts++;
let delay;
if (this.connectionAttempts <= 3) {
delay = 2000;
} else if (this.connectionAttempts <= 10) {
delay = 5000;
} else {
delay = 10000;
}
console.log(`🔄 ${delay/1000}초 후 Unity 재연결 시도 (${this.connectionAttempts}/${this.maxConsecutiveFailures})`);
this.reconnectInterval = setTimeout(() => {
this.reconnectInterval = null;
this.connect();
}, delay);
}
handleUnityMessage(data) {
console.log('📨 Unity 메시지 수신:', data);
switch (data.type) {
case 'cameraList':
this.cameraData = data;
this.currentCamera = data.currentCamera || 0;
console.log(`📹 카메라 목록 수신: ${data.cameras?.length || 0}`);
console.log('📹 카메라 데이터 상세:', data);
// 카메라 목록을 받았다는 것은 Unity 연결이 성공했다는 의미
if (!this.isConnected) {
console.log('🔄 Unity 연결 상태 업데이트: 연결됨 (카메라 목록 수신으로 확인)');
this.isConnected = true;
this.connectionAttempts = 0;
this.emit('connectionChange', this.isConnected);
}
// Property Inspector들에게 카메라 데이터 전송
console.log('📤 Property Inspector들에게 카메라 데이터 전송');
broadcastToPropertyInspectors('camera_data', {
camera_data: data,
current_camera: this.currentCamera
});
updateAllButtons();
break;
case 'cameraSwitched':
this.currentCamera = data.cameraIndex;
console.log(`📹 카메라 전환 완료: ${data.cameraIndex}`);
break;
default:
console.log(`📨 알 수 없는 Unity 메시지 타입: ${data.type}`);
}
}
requestCameraList() {
if (!this.isConnected || !this.socket) {
console.log('❌ Unity 연결 안됨 - 카메라 목록 요청 불가');
console.log(`❌ 연결 상태: ${this.isConnected}, 소켓 상태: ${this.socket ? this.socket.readyState : 'null'}`);
return;
}
try {
const message = {
type: 'getCameraList',
requestId: Date.now().toString()
};
console.log('📤 Unity에 카메라 목록 요청:', message);
this.socket.send(JSON.stringify(message));
console.log('✅ 카메라 목록 요청 전송 완료');
} catch (error) {
console.log(`❌ 카메라 목록 요청 실패: ${error.message}`);
}
}
switchCamera(cameraIndex) {
if (!this.isConnected) {
console.log('❌ Unity 연결 안됨 - 카메라 전환 불가');
return false;
}
try {
const message = {
type: 'switchCamera',
cameraIndex: cameraIndex
};
this.socket.send(JSON.stringify(message));
console.log(`📤 Unity에 카메라 전환 요청: ${cameraIndex}`);
return true;
} catch (error) {
console.log(`❌ 카메라 전환 요청 실패: ${error.message}`);
return false;
}
}
forceReconnect() {
console.log('🔄 수동 재연결 요청');
this.connectionAttempts = 0;
if (this.reconnectInterval) {
clearTimeout(this.reconnectInterval);
this.reconnectInterval = null;
}
this.connect();
}
getConnectionStatus() {
return {
isConnected: this.isConnected,
cameraData: this.cameraData,
currentCamera: this.currentCamera
};
}
disconnect() {
this.isShuttingDown = true;
if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval);
this.healthCheckInterval = null;
}
if (this.reconnectInterval) {
clearTimeout(this.reconnectInterval);
this.reconnectInterval = null;
}
if (this.socket) {
this.socket.close();
this.socket = null;
}
this.isConnected = false;
console.log('🔌 Unity 연결 종료');
}
}
// Property Inspector로 메시지 전송 (단순화)
function sendToPropertyInspector(context, type, data) {
if (!websocket || !context) {
console.log(`⚠️ Property Inspector 전송 실패: ${!websocket ? 'websocket 없음' : 'context 없음'}`);
return;
}
try {
const message = {
type: type,
...data
};
websocket.send(JSON.stringify({
event: 'sendToPropertyInspector',
context: context,
payload: message
}));
console.log(`📤 Property Inspector 전송: ${type} -> ${context.substring(0, 8)}...`);
} catch (error) {
console.log(`❌ Property Inspector 전송 실패: ${error.message}`);
}
}
function broadcastToPropertyInspectors(type, data) {
buttonContexts.forEach((buttonData, context) => {
sendToPropertyInspector(context, type, data);
});
}
function updateButtonTitle(context, cameraName = null, cameraIndex = null) {
if (!websocket || !context) return;
if (!cameraName) {
const buttonData = buttonContexts.get(context);
if (buttonData && buttonData.settings && buttonData.settings.cameraList) {
const index = cameraIndex !== null ? cameraIndex : buttonData.settings.cameraIndex;
if (typeof index === 'number' && buttonData.settings.cameraList[index]) {
cameraName = buttonData.settings.cameraList[index].name;
}
}
}
let title = cameraName || '카메라\n선택';
if (title.length > 8) {
const underscoreIndex = title.indexOf('_');
if (underscoreIndex !== -1 && underscoreIndex > 0) {
const firstLine = title.substring(0, underscoreIndex);
const secondLine = title.substring(underscoreIndex + 1);
const maxLineLength = 8;
let line1 = firstLine.length > maxLineLength ? firstLine.substring(0, maxLineLength - 1) + '.' : firstLine;
let line2 = secondLine.length > maxLineLength ? secondLine.substring(0, maxLineLength - 1) + '.' : secondLine;
title = line1 + '\n' + line2;
} else {
const midPoint = Math.ceil(title.length / 2);
const firstLine = title.substring(0, midPoint);
const secondLine = title.substring(midPoint);
title = firstLine + '\n' + secondLine;
}
}
websocket.send(JSON.stringify({
event: 'setTitle',
context: context,
payload: {
title: title,
target: 0,
titleParameters: {
fontSize: 18,
showTitle: true,
titleAlignment: "middle"
}
}
}));
console.log(`🏷️ 버튼 제목 업데이트: "${title.replace('\n', '\\n')}"`);
}
function updateButtonUI(context, cameraIndex, isConnected) {
if (!websocket) return;
try {
const svgData = createSvgIcon(cameraIndex, isConnected);
const base64Data = btoa(svgData);
websocket.send(JSON.stringify({
event: 'setImage',
context: context,
payload: {
image: `data:image/svg+xml;base64,${base64Data}`,
target: 0,
state: 0
}
}));
updateButtonTitle(context, null, cameraIndex);
} catch (error) {
console.log(`❌ 버튼 UI 업데이트 실패: ${error.message}`);
}
}
function createSvgIcon(cameraIndex, isConnected) {
const bgColor = isConnected ? '#4CAF50' : '#F44336';
const textColor = '#FFFFFF';
return `<svg width="144" height="144" xmlns="http://www.w3.org/2000/svg">
<rect width="144" height="144" fill="${bgColor}" rx="12"/>
<circle cx="72" cy="60" r="25" fill="none" stroke="${textColor}" stroke-width="3"/>
<rect x="58" y="46" width="28" height="28" fill="none" stroke="${textColor}" stroke-width="2" rx="4"/>
<text x="72" y="120" font-family="Arial, sans-serif" font-size="24" font-weight="bold"
fill="${textColor}" text-anchor="middle">${cameraIndex + 1}</text>
</svg>`;
}
function updateAllButtons() {
buttonContexts.forEach((buttonData, context) => {
updateButtonUI(context, buttonData.cameraIndex, unityManager ? unityManager.isConnected : false);
});
}
const streamDeckEventHandlers = {
willAppear: function(data) {
const { context, payload } = data;
const settings = payload.settings || {};
const cameraIndex = settings.cameraIndex || 0;
buttonContexts.set(context, {
cameraIndex: cameraIndex,
settings: settings
});
console.log(`📱 버튼 추가됨: context=${context.substring(0, 8)}..., camera=${cameraIndex}`);
updateButtonUI(context, cameraIndex, unityManager ? unityManager.isConnected : false);
if (unityManager) {
const status = unityManager.getConnectionStatus();
sendToPropertyInspector(context, 'connection_status', {
connected: status.isConnected
});
if (status.cameraData) {
sendToPropertyInspector(context, 'camera_data', {
camera_data: status.cameraData,
current_camera: status.currentCamera
});
}
}
},
willDisappear: function(data) {
const { context } = data;
buttonContexts.delete(context);
console.log(`👋 버튼 제거됨: ${context.substring(0, 8)}... (남은 버튼: ${buttonContexts.size}개)`);
},
keyUp: function(data) {
const { context, payload } = data;
const buttonData = buttonContexts.get(context);
if (!buttonData) {
console.log(`❌ 버튼 데이터 없음: ${context.substring(0, 8)}...`);
return;
}
const cameraIndex = buttonData.settings.cameraIndex || 0;
console.log(`🔘 버튼 클릭: context=${context.substring(0, 8)}..., camera=${cameraIndex}`);
if (!unityManager || !unityManager.isConnected) {
console.log('❌ Unity 연결 안됨 - 카메라 전환 불가');
return;
}
const success = unityManager.switchCamera(cameraIndex);
if (success) {
console.log(`✅ 카메라 전환 요청 성공: ${cameraIndex}`);
} else {
console.log(`❌ 카메라 전환 요청 실패: ${cameraIndex}`);
}
},
didReceiveSettings: function(data) {
const { context, payload } = data;
const settings = payload.settings || {};
console.log(`📥 설정 수신: context=${context.substring(0, 8)}..., cameraIndex=${settings.cameraIndex}`);
const buttonData = buttonContexts.get(context);
if (buttonData) {
// 기존 설정 유지하면서 카메라 인덱스만 업데이트
const updatedSettings = { ...buttonData.settings };
// 카메라 인덱스 업데이트
if (typeof settings.cameraIndex === 'number') {
updatedSettings.cameraIndex = settings.cameraIndex;
buttonData.cameraIndex = settings.cameraIndex;
console.log(`🎯 카메라 인덱스 업데이트: ${settings.cameraIndex}`);
}
// 카메라 목록 업데이트 (있는 경우만)
if (settings.cameraList && settings.cameraList.length > 0) {
updatedSettings.cameraList = settings.cameraList;
console.log(`📹 카메라 목록 업데이트: ${settings.cameraList.length}`);
}
buttonData.settings = updatedSettings;
// 버튼 UI 업데이트
updateButtonUI(context, buttonData.cameraIndex, unityManager ? unityManager.isConnected : false);
console.log(`✅ 버튼 설정 업데이트 완료: cameraIndex=${buttonData.cameraIndex}`);
}
},
sendToPlugin: function(data) {
const { context, payload } = data;
try {
const message = typeof payload === 'string' ? JSON.parse(payload) : payload;
console.log(`📨 Property Inspector 메시지: ${message.command || message.type}`);
switch (message.command) {
case 'requestCameraList':
if (unityManager && unityManager.isConnected) {
unityManager.requestCameraList();
} else {
console.log('❌ Unity 연결 안됨 - 카메라 목록 요청 불가');
}
break;
case 'forceReconnect':
if (unityManager) {
unityManager.forceReconnect();
}
break;
case 'getInitialSettings':
console.log('📥 Property Inspector에서 초기 설정 요청');
// 현재 Unity 연결 상태와 카메라 데이터를 Property Inspector에 전송
const isConnected = unityManager ? unityManager.isConnected : false;
sendToPropertyInspector(context, 'connection_status', {
connected: isConnected
});
if (unityManager && isConnected) {
const status = unityManager.getConnectionStatus();
if (status.cameraData) {
sendToPropertyInspector(context, 'camera_data', {
camera_data: status.cameraData,
current_camera: status.currentCamera
});
}
}
break;
default:
console.log(`❓ 알 수 없는 Property Inspector 명령: ${message.command}`);
}
} catch (error) {
console.log(`❌ Property Inspector 메시지 파싱 오류: ${error.message}`);
}
},
// Property Inspector가 열릴 때 자동으로 호출되는 핵심 이벤트
propertyInspectorDidAppear: function(data) {
const { context } = data;
console.log(`📋 Property Inspector 열림: ${context.substring(0, 8)}...`);
// 현재 버튼의 설정을 PI로 전송
const buttonData = buttonContexts.get(context);
if (buttonData) {
console.log(`📤 PI에 현재 설정 전송: cameraIndex=${buttonData.settings.cameraIndex}`);
// Unity 연결 강제 확인 및 시도
if (unityManager) {
console.log(`🔍 Unity 연결 상태 강제 확인`);
const currentStatus = unityManager.getConnectionStatus();
console.log(`🔍 현재 Unity 연결 상태: ${currentStatus.isConnected}`);
// 연결되지 않았다면 연결 시도
if (!currentStatus.isConnected) {
console.log(`🔄 Unity 연결 시도 중...`);
unityManager.connect();
}
}
// Unity 연결 상태 확인
const isUnityConnected = unityManager ? unityManager.isConnected : false;
console.log(`🔍 Unity 연결 상태 확인: ${isUnityConnected}`);
// 현재 설정 전송 (Unity 연결 상태 포함)
sendToPropertyInspector(context, 'current_settings', {
cameraIndex: buttonData.settings.cameraIndex || 0,
cameraList: buttonData.settings.cameraList || [],
isUnityConnected: isUnityConnected
});
// Unity 연결 상태 별도 전송
sendToPropertyInspector(context, 'connection_status', {
connected: isUnityConnected
});
// Unity가 연결되어 있고 카메라 데이터가 있으면 전송
if (unityManager && isUnityConnected) {
const status = unityManager.getConnectionStatus();
if (status.cameraData) {
sendToPropertyInspector(context, 'camera_data', {
camera_data: status.cameraData,
current_camera: status.currentCamera
});
}
}
} else {
console.log(`❌ 버튼 데이터 없음: ${context.substring(0, 8)}...`);
}
},
// Property Inspector가 닫힐 때 호출
propertyInspectorDidDisappear: function(data) {
const { context } = data;
console.log(`📋 Property Inspector 닫힘: ${context.substring(0, 8)}...`);
},
// getSettings 이벤트 처리 (PI에서 설정 요청 시)
getSettings: function(data) {
const { context } = data;
console.log(`📥 설정 요청: ${context.substring(0, 8)}...`);
const buttonData = buttonContexts.get(context);
if (buttonData) {
// 현재 설정을 PI로 전송
sendToPropertyInspector(context, 'current_settings', {
cameraIndex: buttonData.settings.cameraIndex || 0,
cameraList: buttonData.settings.cameraList || [],
isUnityConnected: unityManager ? unityManager.isConnected : false
});
}
}
};
function setupUnityEventListeners() {
if (!unityManager) return;
unityManager.on('connectionChange', (isConnected) => {
console.log(`🔄 Unity 연결 상태 변경: ${isConnected}`);
updateAllButtons();
// Property Inspector들에게 연결 상태 변경 알림
broadcastToPropertyInspectors('connection_status', {
connected: isConnected
});
// 연결이 되면 카메라 목록 요청
if (isConnected) {
console.log('📹 Unity 연결됨 - 카메라 목록 요청');
unityManager.requestCameraList();
}
});
}
function connectElgatoStreamDeckSocket(inPort, inUUID, inEvent, inInfo, inActionInfo) {
uuid = inUUID;
actionInfo = inActionInfo;
connectionInfo = inInfo;
console.log(`🔌 StreamDeck 소켓 연결: port=${inPort}, uuid=${uuid.substring(0, 8)}...`);
websocket = new window.WebSocket('ws://localhost:' + inPort);
websocket.onopen = function() {
console.log('✅ StreamDeck 소켓 연결 성공');
const json = {
event: inEvent,
uuid: inUUID
};
websocket.send(JSON.stringify(json));
if (!unityManager) {
unityManager = new UnityConnectionManager();
setupUnityEventListeners();
// 즉시 Unity 연결 시도
console.log('🚀 StreamDeck 연결 완료 - Unity 연결 시도');
unityManager.connect();
}
};
websocket.onmessage = function(evt) {
try {
const jsonObj = JSON.parse(evt.data);
const event = jsonObj.event;
const context = jsonObj.context;
console.log(`📨 StreamDeck 이벤트: ${event} (context: ${context ? context.substring(0, 8) + '...' : 'N/A'})`);
console.log(`📨 전체 메시지:`, jsonObj);
if (streamDeckEventHandlers[event]) {
streamDeckEventHandlers[event](jsonObj);
} else {
console.log(`❓ 알 수 없는 StreamDeck 이벤트: ${event}`);
}
} catch (error) {
console.log(`❌ StreamDeck 메시지 파싱 오류: ${error.message}`);
console.log(`❌ 원본 메시지: ${evt.data}`);
}
};
websocket.onerror = function(evt) {
console.log(`❌ StreamDeck 소켓 오류: ${evt}`);
};
websocket.onclose = function(evt) {
console.log(`🔌 StreamDeck 소켓 연결 종료: ${evt.code} - ${evt.reason}`);
if (unityManager) {
unityManager.disconnect();
}
};
}
// 프로세스 종료 시 정리
if (typeof process !== 'undefined' && process.on) {
process.on('SIGINT', function() {
console.log('🛑 프로세스 종료 신호 수신');
if (unityManager) {
unityManager.disconnect();
}
if (websocket) {
websocket.close();
}
process.exit(0);
});
}
// 모듈 내보내기 (테스트/호환용)
if (typeof module !== 'undefined') {
module.exports = {
connectElgatoStreamDeckSocket
};
}

View File

@ -1,213 +0,0 @@
// 配置日志文件
const now = new Date();
const log = require('log4js').configure({
appenders: {
file: { type: 'file', filename: `./log/${now.getFullYear()}.${now.getMonth() + 1}.${now.getDate()}.log` }
},
categories: {
default: { appenders: ['file'], level: 'info' }
}
}).getLogger();
//##################################################
//##################全局异常捕获#####################
process.on('uncaughtException', (error) => {
log.error('Uncaught Exception:', error);
});
process.on('unhandledRejection', (reason) => {
log.error('Unhandled Rejection:', reason);
});
//##################################################
//##################################################
// 插件类
const ws = require('ws');
class Plugins {
static language = process.argv[9] ? JSON.parse(process.argv[9]).application.language : 'en';
static globalSettings = {};
getGlobalSettingsFlag = true;
constructor() {
if (Plugins.instance) {
return Plugins.instance;
}
// log.info("process.argv", process.argv);
this.ws = new ws("ws://127.0.0.1:" + process.argv[3]);
this.ws.on('open', () => this.ws.send(JSON.stringify({ uuid: process.argv[5], event: process.argv[7] })));
this.ws.on('close', process.exit);
this.ws.on('message', e => {
if (this.getGlobalSettingsFlag) {
// 只获取一次
this.getGlobalSettingsFlag = false;
this.getGlobalSettings();
}
const data = JSON.parse(e.toString());
const action = data.action?.split('.').pop();
this[action]?.[data.event]?.(data);
if (data.event === 'didReceiveGlobalSettings') {
Plugins.globalSettings = data.payload.settings;
}
this[data.event]?.(data);
});
Plugins.instance = this;
}
setGlobalSettings(payload) {
Plugins.globalSettings = payload;
this.ws.send(JSON.stringify({
event: "setGlobalSettings",
context: process.argv[5], payload
}));
}
getGlobalSettings() {
this.ws.send(JSON.stringify({
event: "getGlobalSettings",
context: process.argv[5],
}));
}
// 设置标题
setTitle(context, str, row = 0, num = 6) {
let newStr = null;
if (row && str) {
let nowRow = 1, strArr = str.split('');
strArr.forEach((item, index) => {
if (nowRow < row && index >= nowRow * num) { nowRow++; newStr += '\n'; }
if (nowRow <= row && index < nowRow * num) { newStr += item; }
});
if (strArr.length > row * num) { newStr = newStr.substring(0, newStr.length - 1); newStr += '..'; }
}
this.ws.send(JSON.stringify({
event: "setTitle",
context, payload: {
target: 0,
title: newStr || str + ''
}
}));
}
// 设置背景
setImage(context, url) {
this.ws.send(JSON.stringify({
event: "setImage",
context, payload: {
target: 0,
image: url
}
}));
}
// 设置状态
setState(context, state) {
this.ws.send(JSON.stringify({
event: "setState",
context, payload: { state }
}));
}
// 保存持久化数据
setSettings(context, payload) {
this.ws.send(JSON.stringify({
event: "setSettings",
context, payload
}));
}
// 在按键上展示警告
showAlert(context) {
this.ws.send(JSON.stringify({
event: "showAlert",
context
}));
}
// 在按键上展示成功
showOk(context) {
this.ws.send(JSON.stringify({
event: "showOk",
context
}));
}
// 发送给属性检测器
sendToPropertyInspector(payload) {
this.ws.send(JSON.stringify({
action: Actions.currentAction,
context: Actions.currentContext,
payload, event: "sendToPropertyInspector"
}));
}
// 用默认浏览器打开网页
openUrl(url) {
this.ws.send(JSON.stringify({
event: "openUrl",
payload: { url }
}));
}
};
// 操作类
class Actions {
constructor(data) {
this.data = {};
this.default = {};
Object.assign(this, data);
}
// 属性检查器显示时
static currentAction = null;
static currentContext = null;
static actions = {};
propertyInspectorDidAppear(data) {
Actions.currentAction = data.action;
Actions.currentContext = data.context;
this._propertyInspectorDidAppear?.(data);
}
// 初始化数据
willAppear(data) {
Plugins.globalContext = data.context;
Actions.actions[data.context] = data.action
const { context, payload: { settings } } = data;
this.data[context] = Object.assign({ ...this.default }, settings);
this._willAppear?.(data);
}
didReceiveSettings(data) {
this.data[data.context] = data.payload.settings;
this._didReceiveSettings?.(data);
}
// 行动销毁
willDisappear(data) {
this._willDisappear?.(data);
delete this.data[data.context];
}
}
class EventEmitter {
constructor() {
this.events = {};
}
// 订阅事件
subscribe(event, listener) {
if (!this.events[event]) {
this.events[event] = [];
}
this.events[event].push(listener);
}
// 取消订阅
unsubscribe(event, listenerToRemove) {
if (!this.events[event]) return;
this.events[event] = this.events[event].filter(listener => listener !== listenerToRemove);
}
// 发布事件
emit(event, data) {
if (!this.events[event]) return;
this.events[event].forEach(listener => listener(data));
}
}
module.exports = {
log,
Plugins,
Actions,
EventEmitter
};

View File

@ -1,187 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>Streamingle Camera Inspector</title>
<style>
body {
background: #222;
color: #fff;
font-family: Arial, sans-serif;
font-size: 13px;
margin: 0;
padding: 16px;
min-height: 300px;
}
.status {
display: flex;
align-items: center;
margin-bottom: 12px;
padding: 8px;
background: #333;
border-radius: 4px;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 8px;
transition: background-color 0.3s;
}
.dot.green { background: #28a745; }
.dot.red { background: #dc3545; }
.section {
margin-bottom: 16px;
padding: 8px;
background: #333;
border-radius: 4px;
}
label {
display: block;
margin-bottom: 4px;
font-weight: bold;
color: #ddd;
}
select, input[type="checkbox"] {
margin-right: 8px;
background: #444;
color: #fff;
border: 1px solid #555;
border-radius: 3px;
padding: 4px;
}
select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.current {
margin-top: 8px;
color: #17a2b8;
font-weight: bold;
}
button {
padding: 6px 12px;
background: #007bff;
color: #fff;
border: none;
border-radius: 3px;
cursor: pointer;
transition: background-color 0.3s;
}
button:hover {
background: #0056b3;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
background: #666;
}
.connected { color: #28a745; font-weight: bold; }
.disconnected { color: #dc3545; font-weight: bold; }
/* 로그 영역 스타일 */
.log-section {
margin-top: 16px;
border-top: 1px solid #444;
padding-top: 12px;
}
.log-toggle {
background: #555;
color: #fff;
border: none;
padding: 4px 8px;
border-radius: 3px;
cursor: pointer;
font-size: 11px;
margin-bottom: 8px;
}
.log-area {
background: #111;
color: #0f0;
font-family: 'Courier New', monospace;
font-size: 11px;
padding: 8px;
border-radius: 3px;
height: 120px;
overflow-y: auto;
display: none;
border: 1px solid #333;
}
.log-area.show {
display: block;
}
/* 로딩 상태 */
.loading {
color: #ffc107;
font-style: italic;
}
/* 카메라 목록 스타일 */
.camera-list {
max-height: 150px;
overflow-y: auto;
}
</style>
</head>
<body>
<!-- 연결 상태 -->
<div class="status">
<div id="statusDot" class="dot red"></div>
<span id="connection-status" class="disconnected">Unity 연결 안됨</span>
</div>
<!-- 카메라 선택 섹션 -->
<div class="section">
<label for="camera-select">카메라 선택</label>
<div class="camera-list">
<select id="camera-select" disabled>
<option value="">카메라 목록 로딩 중...</option>
</select>
</div>
<div class="current" id="current-camera">현재 카메라: -</div>
</div>
<!-- 설정 섹션 -->
<div class="section">
<input type="checkbox" id="autoSwitch" checked>
<label for="autoSwitch" style="display:inline; font-weight:normal;">자동 전환</label>
</div>
<!-- 액션 섹션 -->
<div class="section">
<button id="refresh-button" disabled>카메라 목록 새로고침</button>
</div>
<!-- 디버그 로그 섹션 -->
<div class="log-section">
<button class="log-toggle" onclick="toggleLog()">📋 디버그 로그 보기</button>
<div id="logArea" class="log-area"></div>
</div>
<script>
// 로그 토글 함수
function toggleLog() {
const logArea = document.getElementById('logArea');
logArea.classList.toggle('show');
}
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', function() {
console.log('📋 Property Inspector HTML 로드됨');
// 초기 상태 설정
const statusDot = document.getElementById('statusDot');
const connectionStatus = document.getElementById('connection-status');
const cameraSelect = document.getElementById('camera-select');
const refreshButton = document.getElementById('refresh-button');
if (statusDot) console.log('✅ statusDot 요소 찾음');
if (connectionStatus) console.log('✅ connection-status 요소 찾음');
if (cameraSelect) console.log('✅ camera-select 요소 찾음');
if (refreshButton) console.log('✅ refresh-button 요소 찾음');
});
</script>
<script src="index.js"></script>
</body>
</html>

View File

@ -1,778 +0,0 @@
/**
* Streamingle Camera Controller - Property Inspector
* 엘가토 공식 구조 기반 단순화 버전
*/
// Global variables
let websocket = null;
let uuid = null;
let actionContext = null; // 현재 액션의 컨텍스트
let settings = {};
// Context별 설정 관리
const contextSettings = new Map();
let currentActionContext = null;
// Unity 연결 상태 (Plugin Main에서 받아옴)
let isUnityConnected = false;
let cameraData = [];
let currentCamera = 0;
// DOM elements
let statusDot = null;
let connectionStatus = null;
let cameraSelect = null;
let currentCameraDisplay = null;
let refreshButton = null;
// 화면에 로그를 표시하는 함수
function logToScreen(msg, color = "#fff") {
let logDiv = document.getElementById('logArea');
if (!logDiv) {
logDiv = document.createElement('div');
logDiv.id = 'logArea';
logDiv.style.background = '#111';
logDiv.style.color = '#fff';
logDiv.style.fontSize = '11px';
logDiv.style.padding = '8px';
logDiv.style.marginTop = '16px';
logDiv.style.height = '120px';
logDiv.style.overflowY = 'auto';
document.body.appendChild(logDiv);
}
const line = document.createElement('div');
line.style.color = color;
line.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
logDiv.appendChild(line);
logDiv.scrollTop = logDiv.scrollHeight;
}
// 기존 console.log/console.error를 화면에도 출력
const origLog = console.log;
console.log = function(...args) {
origLog.apply(console, args);
logToScreen(args.map(a => (typeof a === 'object' ? JSON.stringify(a) : a)).join(' '), '#0f0');
};
const origErr = console.error;
console.error = function(...args) {
origErr.apply(console, args);
logToScreen(args.map(a => (typeof a === 'object' ? JSON.stringify(a) : a)).join(' '), '#f55');
};
console.log('🔧 Property Inspector script loaded');
// Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', function() {
console.log('📋 Property Inspector 초기화');
initializePropertyInspector();
});
// Initialize Property Inspector
function initializePropertyInspector() {
// Get DOM elements
statusDot = document.getElementById('statusDot');
connectionStatus = document.getElementById('connection-status');
cameraSelect = document.getElementById('camera-select');
currentCameraDisplay = document.getElementById('current-camera');
refreshButton = document.getElementById('refresh-button');
// Setup event listeners
if (cameraSelect) {
cameraSelect.addEventListener('change', onCameraSelectionChanged);
}
if (refreshButton) {
refreshButton.addEventListener('click', onRefreshClicked);
}
console.log('✅ Property Inspector 준비 완료');
}
// Send message to plugin
function sendToPlugin(command, data = {}) {
if (!websocket) {
console.error('❌ WebSocket not available');
return;
}
try {
const message = {
command: command,
context: uuid,
...data
};
// StreamDeck SDK 표준 방식 - sendToPlugin 이벤트 사용
const payload = {
event: 'sendToPlugin',
context: uuid,
payload: message
};
websocket.send(JSON.stringify(payload));
console.log('📤 Message sent to plugin:', command, data);
} catch (error) {
console.error('❌ Failed to send message to plugin:', error);
}
}
// Update connection status display
function updateConnectionStatus(isConnected) {
console.log('🔄 Connection status update:', isConnected);
// 전역 변수도 업데이트
isUnityConnected = isConnected;
if (statusDot) {
statusDot.className = `dot ${isConnected ? 'green' : 'red'}`;
}
if (connectionStatus) {
connectionStatus.textContent = isConnected ? 'Unity 연결됨' : 'Unity 연결 안됨';
connectionStatus.className = isConnected ? 'connected' : 'disconnected';
}
if (cameraSelect) {
cameraSelect.disabled = !isConnected;
}
if (refreshButton) {
refreshButton.disabled = !isConnected;
}
}
// Update camera data display
function updateCameraData(cameraDataParam, currentCamera) {
console.log('📹 Camera data update:', cameraDataParam, currentCamera);
if (cameraSelect && cameraDataParam) {
// Clear existing options
cameraSelect.innerHTML = '';
// cameraDataParam이 직접 배열인지 확인
let cameras = cameraDataParam;
if (cameraDataParam.cameras) {
cameras = cameraDataParam.cameras;
} else if (Array.isArray(cameraDataParam)) {
cameras = cameraDataParam;
}
console.log('📹 처리할 카메라 배열:', cameras);
if (cameras && cameras.length > 0) {
// 전역 변수에 카메라 데이터 저장
cameraData = cameras;
console.log('💾 전역 cameraData 저장됨:', cameraData.length + '개');
// Add camera options
cameras.forEach((camera, index) => {
const option = document.createElement('option');
option.value = index;
option.textContent = `카메라 ${index + 1}`;
if (camera.name) {
option.textContent += ` (${camera.name})`;
}
cameraSelect.appendChild(option);
});
// Set current selection
if (typeof currentCamera === 'number') {
cameraSelect.value = currentCamera;
}
cameraSelect.disabled = false;
console.log('✅ 카메라 목록 업데이트 완료:', cameras.length + '개');
} else {
console.log('⚠️ 카메라 데이터가 없거나 빈 배열');
cameraSelect.disabled = true;
}
}
// Update current camera display
updateCurrentCameraDisplay(currentCamera);
}
// Update current camera display
function updateCurrentCameraDisplay(currentCamera) {
if (currentCameraDisplay) {
if (typeof currentCamera === 'number') {
currentCameraDisplay.textContent = `현재 카메라: ${currentCamera + 1}`;
} else {
currentCameraDisplay.textContent = '현재 카메라: -';
}
}
}
// Handle camera selection change
function onCameraSelectionChanged() {
if (!cameraSelect || !currentActionContext) return;
const selectedIndex = parseInt(cameraSelect.value, 10);
if (isNaN(selectedIndex)) return;
console.log('🎯 카메라 선택 변경:', selectedIndex);
console.log('📋 현재 cameraData:', cameraData);
console.log('📋 cameraData 길이:', cameraData ? cameraData.length : 'undefined');
// StreamDeck에 설정 저장 (Plugin Main에서 didReceiveSettings 이벤트 발생)
if (websocket) {
const setSettingsMessage = {
event: 'setSettings',
context: currentActionContext,
payload: {
cameraIndex: selectedIndex,
cameraList: cameraData // 현재 카메라 목록 포함
}
};
console.log('📤 Plugin Main으로 전송할 데이터:', setSettingsMessage.payload);
websocket.send(JSON.stringify(setSettingsMessage));
console.log('💾 설정 저장됨 - Plugin Main에서 버튼 제목 업데이트됨');
}
// UI 업데이트
updateCurrentCameraDisplay(selectedIndex);
}
// Handle refresh button click
function onRefreshClicked() {
console.log('🔄 새로고침 버튼 클릭 - Plugin Main에 카메라 목록 요청');
// Plugin Main에 카메라 목록 요청
sendToPlugin('requestCameraList');
}
// Unity 연결은 Plugin Main에서만 처리
function startUnityAutoReconnect() {
console.log('🩺 Property Inspector에서는 Unity 연결을 직접 관리하지 않음 - Plugin Main에서 처리됨');
}
// Unity 재연결 시도 (제거)
function attemptUnityReconnect() {
if (isShuttingDown || isConnecting || unityReconnectInterval) return;
unityConnectionAttempts++;
// 재연결 간격 조정
let delay;
if (unityConnectionAttempts <= 3) {
delay = 2000; // 처음 3번은 2초 간격
} else if (unityConnectionAttempts <= 10) {
delay = 5000; // 4-10번은 5초 간격
} else {
delay = 30000; // 그 이후는 30초 간격
}
console.log(`🔄 [Property Inspector] ${delay/1000}초 후 Unity 재연결 시도... (${unityConnectionAttempts}번째 시도)`);
unityReconnectInterval = setTimeout(() => {
unityReconnectInterval = null;
connectToUnity().catch(error => {
console.error(`❌ [Property Inspector] Unity 재연결 실패:`, error);
// 실패해도 계속 시도
if (!isShuttingDown) {
attemptUnityReconnect();
}
});
}, delay);
}
// Unity WebSocket 연결 (개선된 버전)
function connectToUnity() {
return new Promise((resolve, reject) => {
// 글로벌 상태 확인
if (window.sharedUnityConnected && window.sharedUnitySocket) {
console.log('✅ [Property Inspector] 기존 Unity 연결 재사용');
isUnityConnected = true;
unitySocket = window.sharedUnitySocket;
updateConnectionStatus(true);
resolve();
return;
}
if (isUnityConnected) {
console.log('✅ [Property Inspector] Unity 이미 연결됨');
resolve();
return;
}
if (isConnecting) {
console.log('⏳ [Property Inspector] Unity 연결 중... 대기');
reject(new Error('이미 연결 중'));
return;
}
isConnecting = true;
window.sharedIsConnecting = true;
console.log(`🔌 [Property Inspector] Unity 연결 시도... (시도 ${unityConnectionAttempts + 1}회)`);
try {
unitySocket = new WebSocket('ws://localhost:10701');
const connectionTimeout = setTimeout(() => {
isConnecting = false;
window.sharedIsConnecting = false;
console.log('⏰ [Property Inspector] Unity 연결 타임아웃');
if (unitySocket) {
unitySocket.close();
}
reject(new Error('연결 타임아웃'));
}, 5000);
unitySocket.onopen = function() {
clearTimeout(connectionTimeout);
isConnecting = false;
isUnityConnected = true;
unityConnectionAttempts = 0; // 성공 시 재시도 카운터 리셋
// 재연결 타이머 정리
if (unityReconnectInterval) {
clearTimeout(unityReconnectInterval);
unityReconnectInterval = null;
}
// 글로벌 상태 저장
window.sharedUnitySocket = unitySocket;
window.sharedUnityConnected = true;
window.sharedIsConnecting = false;
console.log('✅ [Property Inspector] Unity 연결 성공!');
updateConnectionStatus(true);
resolve();
};
unitySocket.onmessage = function(event) {
try {
const message = JSON.parse(event.data);
handleUnityMessage(message);
} catch (error) {
console.error('❌ [Property Inspector] Unity 메시지 파싱 오류:', error);
}
};
unitySocket.onclose = function(event) {
clearTimeout(connectionTimeout);
const wasConnected = isUnityConnected;
isConnecting = false;
isUnityConnected = false;
// 글로벌 상태 정리
window.sharedUnitySocket = null;
window.sharedUnityConnected = false;
window.sharedIsConnecting = false;
if (wasConnected) {
console.log(`❌ [Property Inspector] Unity 연결 끊어짐 (코드: ${event.code}, 이유: ${event.reason || '알 수 없음'})`);
}
updateConnectionStatus(false);
unitySocket = null;
if (!event.wasClean) {
reject(new Error('연결 실패'));
}
// 자동 재연결 시도
if (!isShuttingDown) {
attemptUnityReconnect();
}
};
unitySocket.onerror = function(error) {
clearTimeout(connectionTimeout);
isConnecting = false;
window.sharedIsConnecting = false;
console.error('❌ [Property Inspector] Unity 연결 오류:', error);
isUnityConnected = false;
updateConnectionStatus(false);
reject(error);
};
} catch (error) {
isConnecting = false;
window.sharedIsConnecting = false;
console.error('❌ [Property Inspector] Unity WebSocket 생성 실패:', error);
reject(error);
}
});
}
// Unity 메시지 처리
function handleUnityMessage(message) {
const messageType = message.type;
if (messageType === 'connection_established') {
console.log('🎉 Unity 연결 확인됨');
if (message.data && message.data.camera_data) {
console.log('📹 연결 시 카메라 데이터 수신 (초기 로드)');
updateCameraUI(message.data.camera_data.presets, message.data.camera_data.current_index);
// 이미 카메라 데이터를 받았으므로 추가 요청하지 않음
cameraData = message.data.camera_data.presets; // 글로벌 변수에 저장
window.sharedCameraData = cameraData; // 브라우저 세션에서 공유
}
} else if (messageType === 'camera_list_response') {
console.log('📹 카메라 목록 응답 수신 (요청에 대한 응답)');
if (message.data && message.data.camera_data) {
updateCameraUI(message.data.camera_data.presets, message.data.camera_data.current_index);
cameraData = message.data.camera_data.presets; // 글로벌 변수에 저장
window.sharedCameraData = cameraData; // 브라우저 세션에서 공유
}
} else if (messageType === 'camera_changed') {
console.log('📹 카메라 변경 알림 수신');
if (message.data && typeof message.data.camera_index === 'number') {
updateCurrentCamera(message.data.camera_index);
}
}
}
function updateCameraUI(cameras, currentIndex) {
if (!cameras || !Array.isArray(cameras)) {
console.error('❌ 잘못된 카메라 데이터');
return;
}
console.log('📹 카메라 UI 업데이트:', cameras.length + '개');
const cameraSelect = document.getElementById('camera-select');
const currentCameraDisplay = document.getElementById('current-camera');
if (!cameraSelect) {
console.error('❌ camera-select 요소를 찾을 수 없음');
return;
}
// 카메라 목록 업데이트
cameraSelect.innerHTML = '<option value="">카메라 선택...</option>';
cameras.forEach((camera, index) => {
const option = document.createElement('option');
option.value = index;
option.textContent = `${index + 1}. ${camera.name}`;
cameraSelect.appendChild(option);
});
// 현재 컨텍스트의 설정 가져오기
const currentSettings = getContextSettings(currentActionContext);
// 카메라 목록 저장
const newSettings = {
...currentSettings,
cameraList: cameras
};
saveContextSettings(currentActionContext, newSettings);
// 이미 설정된 카메라가 있으면 선택
if (currentSettings && typeof currentSettings.cameraIndex === 'number') {
cameraSelect.value = currentSettings.cameraIndex;
// 설정된 카메라의 이름으로 현재 표시만 업데이트 (버튼 제목은 Plugin Main에서 처리)
const selectedCamera = cameras[currentSettings.cameraIndex];
if (selectedCamera) {
currentCameraDisplay.textContent = `현재: ${selectedCamera.name}`;
console.log('📋 기존 카메라 설정 복원:', selectedCamera.name);
}
} else {
// 설정이 없으면 Unity의 현재 카메라 사용
if (typeof currentIndex === 'number' && currentIndex >= 0 && cameras[currentIndex]) {
cameraSelect.value = currentIndex;
currentCameraDisplay.textContent = `현재: ${cameras[currentIndex].name}`;
} else {
currentCameraDisplay.textContent = '현재: 없음';
}
}
console.log('✅ 카메라 UI 업데이트 완료');
}
// Unity에서 카메라 목록 요청
function requestCameraListFromUnity() {
if (isUnityConnected && unitySocket) {
const message = JSON.stringify({ type: 'get_camera_list' });
unitySocket.send(message);
console.log('📤 Unity에 카메라 목록 요청:', message);
}
}
// Unity에서 카메라 전환
function switchCameraInUnity(cameraIndex) {
if (isUnityConnected && unitySocket) {
const message = JSON.stringify({
type: 'switch_camera',
data: {
camera_index: cameraIndex
}
});
unitySocket.send(message);
console.log('📤 Unity에 카메라 전환 요청:', message);
}
}
// Unity에서 받은 카메라 데이터로 UI 업데이트
function updateCameraDataFromUnity() {
console.log('📹 Unity 카메라 데이터로 UI 업데이트:', cameraData);
if (cameraSelect && cameraData && cameraData.length > 0) {
// Clear existing options
cameraSelect.innerHTML = '';
// Add camera options
cameraData.forEach((camera, index) => {
const option = document.createElement('option');
option.value = index;
option.textContent = camera.name || `카메라 ${index + 1}`;
cameraSelect.appendChild(option);
});
// Set current selection
cameraSelect.value = currentCamera;
cameraSelect.disabled = false;
console.log('✅ 카메라 선택 목록 업데이트 완료');
}
// Update current camera display
updateCurrentCameraDisplay(currentCamera);
}
// Handle messages from plugin
function handleMessage(jsonObj) {
console.log('📨 Message received:', jsonObj);
try {
if (jsonObj.event === 'sendToPropertyInspector') {
// payload는 직접 객체로 전달됨
const payload = jsonObj.payload;
console.log('📋 Parsed payload:', payload);
switch (payload.type || payload.command) {
case 'connection_status':
console.log('📡 연결 상태 업데이트:', payload.connected);
updateConnectionStatus(payload.connected);
break;
case 'camera_data':
console.log('📹 카메라 데이터 업데이트 수신:', payload);
console.log('📹 카메라 데이터 상세:', payload.camera_data);
updateCameraData(payload.camera_data, payload.current_camera);
break;
case 'current_settings':
console.log('⚙️ 현재 설정 수신:', payload);
// 현재 설정을 컨텍스트에 저장
if (currentActionContext) {
const newSettings = {
cameraIndex: payload.cameraIndex || 0,
cameraList: payload.cameraList || [],
isUnityConnected: payload.isUnityConnected || false
};
saveContextSettings(currentActionContext, newSettings);
// UI 업데이트
updateConnectionStatus(payload.isUnityConnected);
if (payload.cameraList && payload.cameraList.length > 0) {
updateCameraData({ cameras: payload.cameraList }, payload.cameraIndex);
}
}
break;
case 'camera_changed':
console.log('📹 카메라 변경:', payload.current_camera);
updateCurrentCameraDisplay(payload.current_camera);
break;
default:
console.log('❓ Unknown message type:', payload.type || payload.command);
}
} else if (jsonObj.event === 'didReceiveSettings') {
// didReceiveSettings 이벤트 처리 추가
console.log('⚙️ didReceiveSettings 이벤트 수신:', jsonObj);
const settings = jsonObj.payload.settings || {};
console.log('📋 설정 데이터:', settings);
// 컨텍스트에 설정 저장
if (currentActionContext) {
saveContextSettings(currentActionContext, settings);
// UI 업데이트
if (settings.cameraList && settings.cameraList.length > 0) {
console.log('📹 카메라 목록으로 UI 업데이트:', settings.cameraList.length + '개');
updateCameraData(settings.cameraList, settings.cameraIndex || 0);
// 카메라 목록이 있다면 Unity가 연결되어 있다고 판단
console.log('🔍 카메라 목록 존재 - Unity 연결됨으로 판단');
updateConnectionStatus(true);
}
// 연결 상태 업데이트 (카메라 목록이 없을 때만 설정값 사용)
if (typeof settings.isUnityConnected === 'boolean' && (!settings.cameraList || settings.cameraList.length === 0)) {
updateConnectionStatus(settings.isUnityConnected);
}
}
}
} catch (error) {
console.error('❌ Failed to handle message:', error);
}
}
// StreamDeck SDK connection
function connectElgatoStreamDeckSocket(inPort, inPropertyInspectorUUID, inRegisterEvent, inInfo, inActionInfo) {
uuid = inPropertyInspectorUUID;
console.log('🔌 StreamDeck 연결 중...');
// Parse info
try {
const info = JSON.parse(inInfo);
const actionInfo = JSON.parse(inActionInfo);
actionContext = actionInfo.context; // 액션 컨텍스트 저장
currentActionContext = actionInfo.context; // 현재 액션 컨텍스트 설정
settings = actionInfo.payload.settings || {};
// 컨텍스트별 설정 초기화
saveContextSettings(currentActionContext, settings);
console.log('📋 컨텍스트 설정 완료:', currentActionContext);
} catch (error) {
console.error('❌ 정보 파싱 실패:', error);
}
// Connect to StreamDock
websocket = new WebSocket('ws://127.0.0.1:' + inPort);
websocket.onopen = function() {
console.log('✅ StreamDeck 연결됨');
// Register
const json = {
event: inRegisterEvent,
uuid: uuid
};
websocket.send(JSON.stringify(json));
// Plugin Main에 초기 설정 요청
console.log('📤 Plugin Main에 초기 설정 요청');
sendToPlugin('getInitialSettings');
};
websocket.onmessage = function(evt) {
try {
const jsonObj = JSON.parse(evt.data);
handleMessage(jsonObj);
} catch (error) {
console.error('❌ Failed to parse message:', error);
}
};
websocket.onclose = function() {
console.log('❌ StreamDeck WebSocket closed');
isShuttingDown = true; // 종료 플래그 설정
// Unity 자동 재연결 정리
if (unityReconnectInterval) {
clearTimeout(unityReconnectInterval);
unityReconnectInterval = null;
}
if (unityHealthCheckInterval) {
clearInterval(unityHealthCheckInterval);
unityHealthCheckInterval = null;
}
websocket = null;
};
websocket.onerror = function(error) {
console.error('❌ StreamDeck WebSocket error:', error);
};
}
// 컨텍스트별 설정 관리 함수들
function getContextSettings(context) {
if (!context) return {};
return contextSettings.get(context) || {};
}
function saveContextSettings(context, newSettings) {
if (!context) return;
contextSettings.set(context, { ...newSettings });
console.log('💾 컨텍스트 설정 저장:', context, newSettings);
}
// 현재 컨텍스트의 설정에서 카메라 이름을 가져오는 공통 함수
function getCurrentCameraName(context, cameraIndex = null) {
if (!context) return '카메라\n선택';
const settings = getContextSettings(context);
if (!settings || !settings.cameraList) return '카메라\n선택';
// cameraIndex가 제공되면 그것을 사용, 아니면 설정에서 가져옴
const index = cameraIndex !== null ? cameraIndex : settings.cameraIndex;
if (typeof index !== 'number' || !settings.cameraList[index]) return '카메라\n선택';
return settings.cameraList[index].name || '카메라\n선택';
}
// 버튼 제목 업데이트 공통 함수 (Plugin Main과 동일한 로직)
function updateButtonTitle(context, cameraName = null, cameraIndex = null) {
if (!websocket || !context) return;
// cameraName이 제공되지 않으면 현재 설정에서 가져옴
if (!cameraName) {
cameraName = getCurrentCameraName(context, cameraIndex);
console.log(`🔍 [Property Inspector] getCurrentCameraName 결과: "${cameraName}"`);
// 디버깅을 위해 현재 설정 상태도 출력
const settings = getContextSettings(context);
console.log(`🔍 [Property Inspector] 컨텍스트 설정:`, settings);
console.log(`🔍 [Property Inspector] 카메라 인덱스: ${cameraIndex !== null ? cameraIndex : settings.cameraIndex}, 목록 길이: ${settings.cameraList ? settings.cameraList.length : 0}`);
} else {
console.log(`🔍 [Property Inspector] 직접 제공된 카메라 이름: "${cameraName}"`);
}
// 기본값 설정
let title = cameraName || '카메라\n선택';
console.log(`🔍 [Property Inspector] 최종 사용할 제목 (가공 전): "${title}"`);
// 긴 텍스트를 두 줄로 나누기 (Plugin Main과 동일한 로직)
if (title.length > 8) {
const underscoreIndex = title.indexOf('_');
if (underscoreIndex !== -1 && underscoreIndex > 0) {
// 언더스코어가 있으면 그 위치에서 분할하고 언더스코어는 제거
const firstLine = title.substring(0, underscoreIndex);
const secondLine = title.substring(underscoreIndex + 1); // +1로 언더스코어 제거
// 각 줄이 너무 길면 적절히 자르기
const maxLineLength = 8;
let line1 = firstLine.length > maxLineLength ? firstLine.substring(0, maxLineLength - 1) + '.' : firstLine;
let line2 = secondLine.length > maxLineLength ? secondLine.substring(0, maxLineLength - 1) + '.' : secondLine;
title = line1 + '\n' + line2;
} else {
// 언더스코어가 없으면 중간 지점에서 분할
const midPoint = Math.ceil(title.length / 2);
const firstLine = title.substring(0, midPoint);
const secondLine = title.substring(midPoint);
title = firstLine + '\n' + secondLine;
}
}
// 버튼 제목 설정 (Plugin Main과 완전히 동일한 매개변수)
const message = {
event: 'setTitle',
context: context,
payload: {
title: title,
target: 0, // 하드웨어와 소프트웨어 모두
titleParameters: {
fontSize: 18,
showTitle: true,
titleAlignment: "middle"
}
}
};
websocket.send(JSON.stringify(message));
console.log('🏷️ [Property Inspector] 버튼 제목 업데이트:', title.replace('\n', '\\n'));
}

View File

@ -1,157 +0,0 @@
let $websocket, $uuid, $action, $context, $settings, $lang, $FileID = '';
WebSocket.prototype.setGlobalSettings = function(payload) {
this.send(JSON.stringify({
event: "setGlobalSettings",
context: $uuid, payload
}));
}
WebSocket.prototype.getGlobalSettings = function() {
this.send(JSON.stringify({
event: "getGlobalSettings",
context: $uuid,
}));
}
// 与插件通信
WebSocket.prototype.sendToPlugin = function (payload) {
this.send(JSON.stringify({
event: "sendToPlugin",
action: $action,
context: $uuid,
payload
}));
};
//设置标题
WebSocket.prototype.setTitle = function (str, row = 0, num = 6) {
console.log(str);
let newStr = '';
if (row) {
let nowRow = 1, strArr = str.split('');
strArr.forEach((item, index) => {
if (nowRow < row && index >= nowRow * num) { nowRow++; newStr += '\n'; }
if (nowRow <= row && index < nowRow * num) { newStr += item; }
});
if (strArr.length > row * num) { newStr = newStr.substring(0, newStr.length - 1); newStr += '..'; }
}
this.send(JSON.stringify({
event: "setTitle",
context: $context,
payload: {
target: 0,
title: newStr || str
}
}));
}
// 设置状态
WebSocket.prototype.setState = function (state) {
this.send(JSON.stringify({
event: "setState",
context: $context,
payload: { state }
}));
};
// 设置背景
WebSocket.prototype.setImage = function (url) {
let image = new Image();
image.src = url;
image.onload = () => {
let canvas = document.createElement("canvas");
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
let ctx = canvas.getContext("2d");
ctx.drawImage(image, 0, 0);
this.send(JSON.stringify({
event: "setImage",
context: $context,
payload: {
target: 0,
image: canvas.toDataURL("image/png")
}
}));
};
};
// 打开网页
WebSocket.prototype.openUrl = function (url) {
this.send(JSON.stringify({
event: "openUrl",
payload: { url }
}));
};
// 保存持久化数据
WebSocket.prototype.saveData = $.debounce(function (payload) {
this.send(JSON.stringify({
event: "setSettings",
context: $uuid,
payload
}));
});
// StreamDock 软件入口函数
const connectSocket = connectElgatoStreamDeckSocket;
async function connectElgatoStreamDeckSocket(port, uuid, event, app, info) {
info = JSON.parse(info);
$uuid = uuid; $action = info.action;
$context = info.context;
$websocket = new WebSocket('ws://127.0.0.1:' + port);
$websocket.onopen = () => $websocket.send(JSON.stringify({ event, uuid }));
// 持久数据代理
$websocket.onmessage = e => {
let data = JSON.parse(e.data);
if (data.event === 'didReceiveSettings') {
$settings = new Proxy(data.payload.settings, {
get(target, property) {
return target[property];
},
set(target, property, value) {
target[property] = value;
$websocket.saveData(data.payload.settings);
}
});
if (!$back) $dom.main.style.display = 'block';
}
$propEvent[data.event]?.(data.payload);
};
// 自动翻译页面
if (!$local) return;
$lang = await new Promise(resolve => {
const req = new XMLHttpRequest();
req.open('GET', `../../${JSON.parse(app).application.language}.json`);
req.send();
req.onreadystatechange = () => {
if (req.readyState === 4) {
resolve(JSON.parse(req.responseText).Localization);
}
};
});
// 遍历文本节点并翻译所有文本节点
const walker = document.createTreeWalker($dom.main, NodeFilter.SHOW_TEXT, (e) => {
return e.data.trim() && NodeFilter.FILTER_ACCEPT;
});
while (walker.nextNode()) {
console.log(walker.currentNode.data);
walker.currentNode.data = $lang[walker.currentNode.data];
}
// placeholder 特殊处理
const translate = item => {
if (item.placeholder?.trim()) {
console.log(item.placeholder);
item.placeholder = $lang[item.placeholder];
}
};
$('input', true).forEach(translate);
$('textarea', true).forEach(translate);
}
// StreamDock 文件路径回调
Array.from($('input[type="file"]', true)).forEach(item => item.addEventListener('click', () => $FileID = item.id));
const onFilePickerReturn = (url) => $emit.send(`File-${$FileID}`, JSON.parse(url));

View File

@ -1,81 +0,0 @@
// 自定义事件类
class EventPlus {
constructor() {
this.event = new EventTarget();
}
on(name, callback) {
this.event.addEventListener(name, e => callback(e.detail));
}
send(name, data) {
this.event.dispatchEvent(new CustomEvent(name, {
detail: data,
bubbles: false,
cancelable: false
}));
}
}
// 补零
String.prototype.fill = function () {
return this >= 10 ? this : '0' + this;
};
// unicode编码转换字符串
String.prototype.uTs = function () {
return eval('"' + Array.from(this).join('') + '"');
};
// 字符串转换unicode编码
String.prototype.sTu = function (str = '') {
Array.from(this).forEach(item => str += `\\u${item.charCodeAt(0).toString(16)}`);
return str;
};
// 全局变量/方法
const $emit = new EventPlus(), $ = (selector, isAll = false) => {
const element = document.querySelector(selector), methods = {
on: function (event, callback) {
this.addEventListener(event, callback);
},
attr: function (name, value = '') {
value && this.setAttribute(name, value);
return this;
}
};
if (!isAll && element) {
return Object.assign(element, methods);
} else if (!isAll && !element) {
throw `HTML没有 ${selector} 元素! 请检查是否拼写错误`;
}
return Array.from(document.querySelectorAll(selector)).map(item => Object.assign(item, methods));
};
// 节流函数
$.throttle = (fn, delay) => {
let Timer = null;
return function () {
if (Timer) return;
Timer = setTimeout(() => {
fn.apply(this, arguments);
Timer = null;
}, delay);
};
};
// 防抖函数
$.debounce = (fn, delay) => {
let Timer = null;
return function () {
clearTimeout(Timer);
Timer = setTimeout(() => fn.apply(this, arguments), delay);
};
};
// 绑定限制数字方法
Array.from($('input[type="num"]', true)).forEach(item => {
item.addEventListener('input', function limitNum() {
if (!item.value || /^\d+$/.test(item.value)) return;
item.value = item.value.slice(0, -1);
limitNum(item);
});
});

View File

@ -1,9 +0,0 @@
[2025-07-03T00:52:25.811] [INFO] default - 🚀 Streamingle Camera Controller 플러그인 시작됨
[2025-07-03T00:58:26.107] [INFO] default - 🚀 Streamingle Camera Controller 플러그인 시작됨
[2025-07-03T00:58:37.479] [INFO] default - 🚀 Streamingle Camera Controller 플러그인 시작됨
[2025-07-03T00:58:50.582] [INFO] default - 🚀 Streamingle Camera Controller 플러그인 시작됨
[2025-07-03T00:59:04.912] [INFO] default - 🚀 Streamingle Camera Controller 플러그인 시작됨
[2025-07-03T01:00:19.924] [INFO] default - 🚀 Streamingle Camera Controller 플러그인 시작됨
[2025-07-03T01:00:32.402] [INFO] default - 🚀 Streamingle Camera Controller 플러그인 시작됨
[2025-07-03T01:01:32.400] [INFO] default - 🚀 Streamingle Camera Controller 플러그인 시작됨
[2025-07-03T01:02:32.414] [INFO] default - 🚀 Streamingle Camera Controller 플러그인 시작됨

View File

@ -1,65 +0,0 @@
const StreaminglePlugin = require('./plugin.js');
console.log('=== Streamingle 플러그인 테스트 시작 ===');
const plugin = new StreaminglePlugin();
// 연결 상태 모니터링
let connectionCheckInterval = setInterval(() => {
const status = plugin.getStatus();
console.log(`📊 연결 상태: ${status.isConnected ? '✅ 연결됨' : '❌ 연결 안됨'}`);
if (status.isConnected) {
console.log(`📷 카메라 개수: ${status.cameraCount}`);
console.log(`🎯 현재 카메라: ${status.currentCamera >= 0 ? status.currentCamera : '없음'}`);
if (status.cameraList && status.cameraList.length > 0) {
console.log('📋 카메라 목록:');
status.cameraList.forEach((camera, index) => {
console.log(` ${index}: ${camera.name} ${camera.isActive ? '[활성]' : '[비활성]'}`);
});
}
}
console.log('---');
}, 5000);
// 3초 후 카메라 목록 요청
setTimeout(() => {
console.log('🔍 카메라 목록 요청...');
plugin.requestCameraList();
}, 3000);
// 8초 후 첫 번째 카메라로 전환
setTimeout(() => {
console.log('🎬 첫 번째 카메라로 전환...');
plugin.switchCamera(0);
}, 8000);
// 13초 후 두 번째 카메라로 전환
setTimeout(() => {
console.log('🎬 두 번째 카메라로 전환...');
plugin.switchCamera(1);
}, 13000);
// 18초 후 세 번째 카메라로 전환 (있다면)
setTimeout(() => {
console.log('🎬 세 번째 카메라로 전환...');
plugin.switchCamera(2);
}, 18000);
// 25초 후 종료
setTimeout(() => {
console.log('🛑 테스트 종료...');
clearInterval(connectionCheckInterval);
plugin.disconnect();
process.exit(0);
}, 25000);
// 프로세스 종료 시 정리
process.on('SIGINT', () => {
console.log('🛑 테스트 중단...');
clearInterval(connectionCheckInterval);
plugin.disconnect();
process.exit(0);
});

View File

@ -1,133 +0,0 @@
const fs = require('fs');
const path = require('path');
console.log('=== Streamingle 플러그인 디버깅 도구 ===');
// 1. 플러그인 파일 확인
console.log('\n1. 플러그인 파일 확인:');
const pluginDir = __dirname;
const requiredFiles = [
'manifest.json',
'plugin.js',
'package.json',
'propertyinspector.html'
];
requiredFiles.forEach(file => {
const filePath = path.join(pluginDir, file);
if (fs.existsSync(filePath)) {
const stats = fs.statSync(filePath);
console.log(`${file} - ${stats.size} bytes`);
} else {
console.log(`${file} - 파일 없음`);
}
});
// 2. manifest.json 내용 확인
console.log('\n2. manifest.json 내용:');
try {
const manifestPath = path.join(pluginDir, 'manifest.json');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
console.log('✅ manifest.json 파싱 성공');
console.log(` SDK 버전: ${manifest.SDKVersion}`);
console.log(` 코드 경로: ${manifest.CodePath}`);
console.log(` 플러그인 이름: ${manifest.Name}`);
console.log(` 액션 UUID: ${manifest.Actions[0].UUID}`);
} catch (error) {
console.log(`❌ manifest.json 파싱 실패: ${error.message}`);
}
// 3. package.json 내용 확인
console.log('\n3. package.json 내용:');
try {
const packagePath = path.join(pluginDir, 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
console.log('✅ package.json 파싱 성공');
console.log(` 이름: ${packageJson.name}`);
console.log(` 버전: ${packageJson.version}`);
console.log(` 메인 파일: ${packageJson.main}`);
console.log(` 의존성: ${Object.keys(packageJson.dependencies || {}).join(', ')}`);
} catch (error) {
console.log(`❌ package.json 파싱 실패: ${error.message}`);
}
// 4. WebSocket 연결 테스트
console.log('\n4. WebSocket 연결 테스트:');
const WebSocket = require('ws');
function testWebSocketConnection() {
return new Promise((resolve) => {
console.log(' 연결 시도 중...');
const ws = new WebSocket('ws://127.0.0.1:10701/');
const timeout = setTimeout(() => {
console.log(' ❌ 연결 타임아웃 (5초)');
ws.close();
resolve(false);
}, 5000);
ws.on('open', () => {
console.log(' ✅ WebSocket 연결 성공!');
clearTimeout(timeout);
ws.close();
resolve(true);
});
ws.on('error', (error) => {
console.log(` ❌ WebSocket 연결 실패: ${error.message}`);
clearTimeout(timeout);
resolve(false);
});
ws.on('close', (code, reason) => {
console.log(` 🔌 연결 종료 - 코드: ${code}, 이유: ${reason || '알 수 없음'}`);
});
});
}
testWebSocketConnection().then((connected) => {
console.log(`\n결과: ${connected ? '✅ 연결 가능' : '❌ 연결 불가'}`);
// 5. 포트 상태 확인
console.log('\n5. 포트 상태 확인:');
const { exec } = require('child_process');
exec('netstat -an | findstr :10701', (error, stdout, stderr) => {
if (error) {
console.log(` ❌ netstat 실행 실패: ${error.message}`);
return;
}
if (stdout.trim()) {
console.log(' ✅ 포트 10701 사용 중:');
stdout.split('\n').forEach(line => {
if (line.trim()) {
console.log(` ${line.trim()}`);
}
});
} else {
console.log(' ❌ 포트 10701에서 서비스 없음');
}
});
});
// 6. StreamDock 플러그인 경로 확인
console.log('\n6. StreamDock 플러그인 경로:');
const streamDockPath = 'C:\\Users\\qscft\\AppData\\Roaming\\HotSpot\\StreamDock\\plugins\\com.mirabox.streamingle.sdPlugin';
if (fs.existsSync(streamDockPath)) {
console.log(` ✅ StreamDock 플러그인 폴더 존재: ${streamDockPath}`);
const files = fs.readdirSync(streamDockPath);
console.log(` 📁 파일 개수: ${files.length}`);
files.forEach(file => {
const filePath = path.join(streamDockPath, file);
const stats = fs.statSync(filePath);
const type = stats.isDirectory() ? '📁' : '📄';
console.log(` ${type} ${file} - ${stats.size} bytes`);
});
} else {
console.log(` ❌ StreamDock 플러그인 폴더 없음: ${streamDockPath}`);
}
console.log('\n=== 디버깅 완료 ===');

View File

@ -1,314 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>Streamingle Plugin Main</title>
</head>
<body>
<div style="color: #666; text-align: center; padding: 20px;">
플러그인 메인 - 버튼 클릭 처리<br>
Property Inspector에서 설정 관리
</div>
<script>
console.log('📄 플러그인 메인 로드됨');
// Global variables
let websocket = null;
let buttonContexts = new Map(); // 각 버튼의 컨텍스트별 설정 저장
let unitySocket = null;
let isUnityConnected = false;
let cameraList = []; // 카메라 목록 저장
// StreamDeck SDK 연결
function connectElgatoStreamDeckSocket(inPort, inUUID, inEvent, inInfo, inActionInfo) {
console.log('🔌 StreamDeck 연결 (메인)');
// Parse action info to get settings
try {
const actionInfo = JSON.parse(inActionInfo);
const context = actionInfo.context;
const settings = actionInfo.payload.settings || {};
// 각 컨텍스트별로 설정 저장
buttonContexts.set(context, settings);
console.log('⚙️ 초기 컨텍스트별 설정 저장');
console.log('📍 컨텍스트:', context);
console.log('📋 설정:', settings);
console.log('📹 카메라 인덱스:', settings.cameraIndex, '(타입:', typeof settings.cameraIndex, ')');
// 즉시 제목 업데이트 시도 (카메라 데이터가 있으면)
if (cameraList.length > 0) {
updateButtonTitle(context);
}
} catch (error) {
console.error('❌ ActionInfo 파싱 오류:', error);
}
// 첫 번째 연결인 경우에만 WebSocket 초기화
if (!websocket) {
websocket = new WebSocket('ws://localhost:' + inPort);
websocket.onopen = function() {
console.log('✅ StreamDeck 연결됨 (메인)');
websocket.send(JSON.stringify({
event: inEvent,
uuid: inUUID
}));
// Unity 연결 시도
setTimeout(() => {
connectToUnity();
}, 1000);
};
websocket.onmessage = function(evt) {
try {
const jsonObj = JSON.parse(evt.data);
console.log('📨 메인에서 메시지 수신:', jsonObj);
switch(jsonObj.event) {
case 'didReceiveSettings':
if (jsonObj.payload && jsonObj.context) {
const newSettings = jsonObj.payload.settings || {};
buttonContexts.set(jsonObj.context, newSettings);
console.log('⚙️ 설정 업데이트됨');
console.log('📍 컨텍스트:', jsonObj.context);
console.log('📋 새 설정:', newSettings);
console.log('📹 카메라 인덱스:', newSettings.cameraIndex, '(타입:', typeof newSettings.cameraIndex, ')');
// 버튼 제목 즉시 업데이트
updateButtonTitle(jsonObj.context);
// 조금 후에 한 번 더 시도 (StreamDock 응답 지연 대응)
setTimeout(() => {
updateButtonTitle(jsonObj.context);
console.log('🔄 플러그인 메인에서 제목 재시도 업데이트');
}, 150);
}
break;
case 'willAppear':
console.log('👀 버튼 나타남:', jsonObj.context);
// 초기 설정이 있으면 적용
if (jsonObj.payload && jsonObj.payload.settings) {
buttonContexts.set(jsonObj.context, jsonObj.payload.settings);
updateButtonTitle(jsonObj.context);
}
break;
case 'keyDown':
console.log('🔘 버튼 눌림');
break;
case 'keyUp':
console.log('🔘 버튼 클릭됨! 카메라 전환 처리');
handleButtonClick(jsonObj.context);
break;
}
} catch (error) {
console.error('❌ 메시지 파싱 오류:', error);
}
};
websocket.onclose = function() {
console.log('❌ StreamDock 연결 끊어짐 (메인)');
websocket = null;
};
}
}
// Unity 연결
function connectToUnity() {
console.log('🔌 Unity 연결 시도 (메인)...');
if (unitySocket) {
unitySocket.close();
}
try {
unitySocket = new WebSocket('ws://localhost:10701');
unitySocket.onopen = function() {
isUnityConnected = true;
console.log('✅ Unity 연결 성공 (메인)!');
// 카메라 목록 요청
setTimeout(() => {
const message = JSON.stringify({ type: 'get_camera_list' });
unitySocket.send(message);
console.log('📋 카메라 목록 요청 (메인):', message);
}, 100);
};
unitySocket.onmessage = function(event) {
try {
const data = JSON.parse(event.data);
console.log('📨 Unity 메시지 수신 (메인):', data.type);
handleUnityMessage(data);
} catch (error) {
console.error('❌ Unity 메시지 파싱 오류:', error);
}
};
unitySocket.onclose = function() {
isUnityConnected = false;
console.log('❌ Unity 연결 끊어짐 (메인)');
};
unitySocket.onerror = function(error) {
console.error('❌ Unity 연결 오류 (메인):', error);
};
} catch (error) {
console.error('❌ Unity 연결 설정 오류:', error);
}
}
// 버튼 클릭 처리
function handleButtonClick(context) {
console.log('🎯 버튼 클릭 처리 시작');
console.log('📍 컨텍스트:', context);
console.log('📋 전체 버튼 컨텍스트:', Array.from(buttonContexts.keys()));
console.log('⚙️ 현재 설정:', getCurrentSettings(context));
console.log('🔌 Unity 연결 상태:', isUnityConnected);
if (!isUnityConnected || !unitySocket) {
console.error('❌ Unity 연결되지 않음');
return;
}
// 설정에서 카메라 인덱스 가져오기
const settings = getCurrentSettings(context);
let cameraIndex = settings.cameraIndex;
// 카메라 인덱스가 설정되지 않았으면 0 사용
if (typeof cameraIndex !== 'number') {
cameraIndex = 0;
console.log('⚠️ 카메라 인덱스가 설정되지 않음, 기본값 0 사용');
}
console.log('📹 전환할 카메라 인덱스:', cameraIndex, '(타입:', typeof cameraIndex, ')');
// Unity 예상 구조에 맞게 data 객체로 래핑
const message = JSON.stringify({
type: 'switch_camera',
data: {
camera_index: cameraIndex
}
});
unitySocket.send(message);
console.log('📤 Unity에 카메라 전환 요청 전송:', message);
}
// Unity 메시지 처리
function handleUnityMessage(data) {
switch (data.type) {
case 'connection_established':
console.log('🎉 Unity 연결 확인됨 (메인)');
// 연결 시 카메라 데이터 저장
if (data.data && data.data.camera_data && data.data.camera_data.presets) {
cameraList = data.data.camera_data.presets;
console.log('📹 카메라 목록 저장됨 (메인):', cameraList.length, '개');
updateAllButtonTitles();
}
break;
case 'camera_list_response':
console.log('📹 카메라 목록 수신 (메인)');
if (data.data && data.data.camera_data && data.data.camera_data.presets) {
cameraList = data.data.camera_data.presets;
console.log('📹 카메라 목록 업데이트됨 (메인):', cameraList.length, '개');
updateAllButtonTitles();
}
break;
case 'camera_changed':
console.log('🎯 카메라 변경 알림 (메인)');
// 카메라 변경 시에는 제목 업데이트 안함 (각 버튼은 독립적)
break;
default:
console.log('❓ 알 수 없는 Unity 메시지 타입 (메인):', data.type);
}
}
// 모든 버튼의 제목 업데이트
function updateAllButtonTitles() {
for (const context of buttonContexts.keys()) {
updateButtonTitle(context);
}
}
// StreamDock 버튼 제목 업데이트
function updateButtonTitle(context) {
if (!websocket || !context) {
console.log('🚫 WebSocket 또는 context 없음 - 제목 업데이트 건너뜀');
return;
}
const cameraIndex = getCurrentSettings(context).cameraIndex || 0;
let title = `카메라 ${cameraIndex + 1}`;
// 카메라 목록에서 이름 찾기
if (cameraList && cameraList.length > cameraIndex) {
const camera = cameraList[cameraIndex];
if (camera && camera.name) {
// 카메라 이름에서 불필요한 부분 제거하고 짧게 만들기
let shortName = camera.name
.replace('Cam0', '')
.replace('Cam', '')
.replace('_', ' ')
.substring(0, 10); // 최대 10글자
title = shortName || `카메라 ${cameraIndex + 1}`;
}
}
// StreamDock에 제목 업데이트 요청
const message = {
event: 'setTitle',
context: context,
payload: {
title: title,
target: 0, // hardware and software
titleParameters: {
fontSize: 24, // 기본 12에서 24로 증가 (2배)
showTitle: true,
titleAlignment: "middle"
}
}
};
websocket.send(JSON.stringify(message));
console.log('🏷️ 버튼 제목 업데이트:', title);
}
// 설정 변경 시 제목 업데이트
function updateSettingsAndTitle(context, newSettings) {
updateCurrentSettings(context, newSettings);
updateButtonTitle(context);
}
// 설정 관리 헬퍼 함수들
function getCurrentSettings(context) {
if (!context) return {};
return buttonContexts.get(context) || {};
}
function setCurrentSettings(context, newSettings) {
if (!context) return;
buttonContexts.set(context, newSettings);
}
function updateCurrentSettings(context, partialSettings) {
if (!context) return;
const currentSettings = getCurrentSettings(context);
setCurrentSettings(context, { ...currentSettings, ...partialSettings });
}
</script>
</body>
</html>

View File

@ -1,228 +0,0 @@
/**
* Streamingle Camera Controller Plugin for StreamDeck
* StreamDeck SDK 표준 구조로 수정
*/
const { Plugins, Actions, log } = require('./utils/plugin');
const WebSocket = require('ws');
// 플러그인 인스턴스 생성
const plugin = new Plugins('streamingle');
// Unity WebSocket 연결 관리
let unityWebSocket = null;
let reconnectTimer = null;
let isUnityConnected = false;
let cameraData = null;
let currentCamera = 0;
// 로그 함수
function writeLog(message) {
const timestamp = new Date().toISOString();
log.info(`[${timestamp}] ${message}`);
}
// Unity WebSocket 서버 연결 함수
function connectToUnity() {
if (unityWebSocket) {
unityWebSocket.close();
}
writeLog('🔌 Unity WebSocket 연결 시도: ws://localhost:10701');
unityWebSocket = new WebSocket('ws://localhost:10701');
unityWebSocket.on('open', function() {
writeLog('✅ Unity WebSocket 서버에 연결됨');
isUnityConnected = true;
requestCameraList();
});
unityWebSocket.on('message', function(data) {
try {
writeLog('📨 Unity 메시지 수신: ' + data.toString());
const message = JSON.parse(data.toString());
handleUnityMessage(message);
} catch (error) {
writeLog('❌ Unity 메시지 파싱 오류: ' + error.message);
}
});
unityWebSocket.on('error', function(err) {
writeLog('❌ Unity WebSocket 연결 오류: ' + err.message);
isUnityConnected = false;
scheduleReconnect();
});
unityWebSocket.on('close', function() {
writeLog('🔌 Unity WebSocket 연결 종료');
isUnityConnected = false;
scheduleReconnect();
});
}
// Unity 메시지 처리
function handleUnityMessage(message) {
switch (message.type) {
case 'cameraList':
cameraData = message;
currentCamera = message.currentCamera || 0;
writeLog('📹 카메라 목록 수신: ' + JSON.stringify(message));
break;
case 'cameraSwitched':
currentCamera = message.cameraIndex;
writeLog('📹 카메라 전환 완료: ' + message.cameraIndex);
break;
default:
writeLog('📨 알 수 없는 Unity 메시지: ' + message.type);
}
}
// 카메라 목록 요청
function requestCameraList() {
if (!isUnityConnected || !unityWebSocket) {
writeLog('❌ Unity 연결 안됨 - 카메라 목록 요청 불가');
return;
}
const message = {
type: 'getCameraList',
requestId: Date.now().toString()
};
writeLog('📤 Unity에 카메라 목록 요청: ' + JSON.stringify(message));
unityWebSocket.send(JSON.stringify(message));
}
// 카메라 전환
function switchCamera(cameraIndex) {
if (!isUnityConnected || !unityWebSocket) {
writeLog('❌ Unity 연결 안됨 - 카메라 전환 불가');
return false;
}
const message = {
type: 'switchCamera',
cameraIndex: cameraIndex
};
writeLog('📤 Unity에 카메라 전환 요청: ' + JSON.stringify(message));
unityWebSocket.send(JSON.stringify(message));
return true;
}
// 재연결 스케줄링
function scheduleReconnect() {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
}
writeLog('🔄 3초 후 Unity 재연결 시도');
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connectToUnity();
}, 3000);
}
// 플러그인 설정 수신 시 Unity 연결
plugin.didReceiveGlobalSettings = ({ payload: { settings } }) => {
log.info('didReceiveGlobalSettings', settings);
writeLog('플러그인 설정 수신됨');
// 플러그인 시작 시 Unity 연결
connectToUnity();
};
// Streamingle 액션 클래스
plugin.streamingle = new Actions({
default: {
cameraIndex: 0,
autoSwitch: true,
isUnityConnected: false,
cameraList: [],
currentCamera: null
},
// 버튼이 나타날 때
async _willAppear({ context, payload }) {
writeLog(`🎯 버튼 나타남: ${context.substring(0, 8)}...`);
// Unity 연결 시도 (첫 번째 버튼에서만)
if (!unityWebSocket) {
writeLog('🚀 첫 번째 버튼 - Unity 연결 시도');
connectToUnity();
}
},
// 버튼이 사라질 때
_willDisappear({ context }) {
writeLog(`🎯 버튼 사라짐: ${context.substring(0, 8)}...`);
},
// Property Inspector가 열릴 때
_propertyInspectorDidAppear({ context }) {
writeLog(`📋 Property Inspector 열림: ${context.substring(0, 8)}...`);
// Unity 연결 강제 확인
if (!isUnityConnected) {
writeLog('🔄 Unity 연결 시도 중...');
connectToUnity();
}
},
// Property Inspector에서 메시지 수신
sendToPlugin({ payload, context }) {
writeLog(`📨 Property Inspector 메시지 수신: ${context.substring(0, 8)}...`);
try {
const message = JSON.parse(payload);
writeLog('📨 파싱된 메시지: ' + JSON.stringify(message));
switch (message.command) {
case 'refreshCameraList':
requestCameraList();
break;
case 'forceReconnect':
connectToUnity();
break;
case 'getInitialSettings':
writeLog('📥 Property Inspector에서 초기 설정 요청');
plugin.sendToPropertyInspector({
type: 'connection_status',
connected: isUnityConnected
});
if (isUnityConnected && cameraData) {
plugin.sendToPropertyInspector({
type: 'camera_data',
camera_data: cameraData,
current_camera: currentCamera
});
}
break;
default:
writeLog('❓ 알 수 없는 Property Inspector 명령: ' + message.command);
}
} catch (error) {
writeLog('❌ Property Inspector 메시지 파싱 오류: ' + error.message);
}
},
// 버튼 클릭 시
keyUp({ context, payload }) {
writeLog(`🎯 버튼 클릭: ${context.substring(0, 8)}...`);
// 설정에서 카메라 인덱스 가져오기
const settings = this.data[context] || {};
const cameraIndex = settings.cameraIndex || 0;
writeLog(`📹 카메라 전환 시도: ${cameraIndex}`);
if (switchCamera(cameraIndex)) {
writeLog(`✅ 카메라 전환 요청 완료: ${cameraIndex}`);
} else {
writeLog(`❌ 카메라 전환 요청 실패: ${cameraIndex}`);
}
}
});
writeLog('🚀 Streamingle 플러그인 초기화 완료');

View File

@ -1,223 +0,0 @@
// 配置日志文件
const now = new Date();
const log = require('log4js').configure({
appenders: {
file: { type: 'file', filename: `./log/${now.getFullYear()}.${now.getMonth() + 1}.${now.getDate()}.log` }
},
categories: {
default: { appenders: ['file'], level: 'info' }
}
}).getLogger();
//##################################################
//##################全局异常捕获#####################
process.on('uncaughtException', (error) => {
log.error('Uncaught Exception:', error);
});
process.on('unhandledRejection', (reason) => {
log.error('Unhandled Rejection:', reason);
});
//##################################################
//##################################################
// 插件类
const ws = require('ws');
class Plugins {
static language = (() => {
try {
if (process.argv[9] && process.argv[9] !== 'undefined') {
const parsed = JSON.parse(process.argv[9]);
return parsed.application?.language || 'en';
}
return 'en';
} catch (error) {
return 'en';
}
})();
static globalSettings = {};
getGlobalSettingsFlag = true;
constructor() {
if (Plugins.instance) {
return Plugins.instance;
}
// log.info("process.argv", process.argv);
this.ws = new ws("ws://127.0.0.1:" + process.argv[3]);
this.ws.on('open', () => this.ws.send(JSON.stringify({ uuid: process.argv[5], event: process.argv[7] })));
this.ws.on('close', process.exit);
this.ws.on('message', e => {
if (this.getGlobalSettingsFlag) {
// 只获取一次
this.getGlobalSettingsFlag = false;
this.getGlobalSettings();
}
const data = JSON.parse(e.toString());
const action = data.action?.split('.').pop();
this[action]?.[data.event]?.(data);
if (data.event === 'didReceiveGlobalSettings') {
Plugins.globalSettings = data.payload.settings;
}
this[data.event]?.(data);
});
Plugins.instance = this;
}
setGlobalSettings(payload) {
Plugins.globalSettings = payload;
this.ws.send(JSON.stringify({
event: "setGlobalSettings",
context: process.argv[5], payload
}));
}
getGlobalSettings() {
this.ws.send(JSON.stringify({
event: "getGlobalSettings",
context: process.argv[5],
}));
}
// 设置标题
setTitle(context, str, row = 0, num = 6) {
let newStr = null;
if (row && str) {
let nowRow = 1, strArr = str.split('');
strArr.forEach((item, index) => {
if (nowRow < row && index >= nowRow * num) { nowRow++; newStr += '\n'; }
if (nowRow <= row && index < nowRow * num) { newStr += item; }
});
if (strArr.length > row * num) { newStr = newStr.substring(0, newStr.length - 1); newStr += '..'; }
}
this.ws.send(JSON.stringify({
event: "setTitle",
context, payload: {
target: 0,
title: newStr || str + ''
}
}));
}
// 设置背景
setImage(context, url) {
this.ws.send(JSON.stringify({
event: "setImage",
context, payload: {
target: 0,
image: url
}
}));
}
// 设置状态
setState(context, state) {
this.ws.send(JSON.stringify({
event: "setState",
context, payload: { state }
}));
}
// 保存持久化数据
setSettings(context, payload) {
this.ws.send(JSON.stringify({
event: "setSettings",
context, payload
}));
}
// 在按键上展示警告
showAlert(context) {
this.ws.send(JSON.stringify({
event: "showAlert",
context
}));
}
// 在按键上展示成功
showOk(context) {
this.ws.send(JSON.stringify({
event: "showOk",
context
}));
}
// 发送给属性检测器
sendToPropertyInspector(payload) {
this.ws.send(JSON.stringify({
action: Actions.currentAction,
context: Actions.currentContext,
payload, event: "sendToPropertyInspector"
}));
}
// 用默认浏览器打开网页
openUrl(url) {
this.ws.send(JSON.stringify({
event: "openUrl",
payload: { url }
}));
}
};
// 操作类
class Actions {
constructor(data) {
this.data = {};
this.default = {};
Object.assign(this, data);
}
// 属性检查器显示时
static currentAction = null;
static currentContext = null;
static actions = {};
propertyInspectorDidAppear(data) {
Actions.currentAction = data.action;
Actions.currentContext = data.context;
this._propertyInspectorDidAppear?.(data);
}
// 初始化数据
willAppear(data) {
Plugins.globalContext = data.context;
Actions.actions[data.context] = data.action
const { context, payload: { settings } } = data;
this.data[context] = Object.assign({ ...this.default }, settings);
this._willAppear?.(data);
}
didReceiveSettings(data) {
this.data[data.context] = data.payload.settings;
this._didReceiveSettings?.(data);
}
// 行动销毁
willDisappear(data) {
this._willDisappear?.(data);
delete this.data[data.context];
}
}
class EventEmitter {
constructor() {
this.events = {};
}
// 订阅事件
subscribe(event, listener) {
if (!this.events[event]) {
this.events[event] = [];
}
this.events[event].push(listener);
}
// 取消订阅
unsubscribe(event, listenerToRemove) {
if (!this.events[event]) return;
this.events[event] = this.events[event].filter(listener => listener !== listenerToRemove);
}
// 发布事件
emit(event, data) {
if (!this.events[event]) return;
this.events[event].forEach(listener => listener(data));
}
}
module.exports = {
log,
Plugins,
Actions,
EventEmitter
};

View File

@ -1,187 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>Streamingle Camera Inspector</title>
<style>
body {
background: #222;
color: #fff;
font-family: Arial, sans-serif;
font-size: 13px;
margin: 0;
padding: 16px;
min-height: 300px;
}
.status {
display: flex;
align-items: center;
margin-bottom: 12px;
padding: 8px;
background: #333;
border-radius: 4px;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 8px;
transition: background-color 0.3s;
}
.dot.green { background: #28a745; }
.dot.red { background: #dc3545; }
.section {
margin-bottom: 16px;
padding: 8px;
background: #333;
border-radius: 4px;
}
label {
display: block;
margin-bottom: 4px;
font-weight: bold;
color: #ddd;
}
select, input[type="checkbox"] {
margin-right: 8px;
background: #444;
color: #fff;
border: 1px solid #555;
border-radius: 3px;
padding: 4px;
}
select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.current {
margin-top: 8px;
color: #17a2b8;
font-weight: bold;
}
button {
padding: 6px 12px;
background: #007bff;
color: #fff;
border: none;
border-radius: 3px;
cursor: pointer;
transition: background-color 0.3s;
}
button:hover {
background: #0056b3;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
background: #666;
}
.connected { color: #28a745; font-weight: bold; }
.disconnected { color: #dc3545; font-weight: bold; }
/* 로그 영역 스타일 */
.log-section {
margin-top: 16px;
border-top: 1px solid #444;
padding-top: 12px;
}
.log-toggle {
background: #555;
color: #fff;
border: none;
padding: 4px 8px;
border-radius: 3px;
cursor: pointer;
font-size: 11px;
margin-bottom: 8px;
}
.log-area {
background: #111;
color: #0f0;
font-family: 'Courier New', monospace;
font-size: 11px;
padding: 8px;
border-radius: 3px;
height: 120px;
overflow-y: auto;
display: none;
border: 1px solid #333;
}
.log-area.show {
display: block;
}
/* 로딩 상태 */
.loading {
color: #ffc107;
font-style: italic;
}
/* 카메라 목록 스타일 */
.camera-list {
max-height: 150px;
overflow-y: auto;
}
</style>
</head>
<body>
<!-- 연결 상태 -->
<div class="status">
<div id="statusDot" class="dot red"></div>
<span id="connection-status" class="disconnected">Unity 연결 안됨</span>
</div>
<!-- 카메라 선택 섹션 -->
<div class="section">
<label for="camera-select">카메라 선택</label>
<div class="camera-list">
<select id="camera-select" disabled>
<option value="">카메라 목록 로딩 중...</option>
</select>
</div>
<div class="current" id="current-camera">현재 카메라: -</div>
</div>
<!-- 설정 섹션 -->
<div class="section">
<input type="checkbox" id="autoSwitch" checked>
<label for="autoSwitch" style="display:inline; font-weight:normal;">자동 전환</label>
</div>
<!-- 액션 섹션 -->
<div class="section">
<button id="refresh-button" disabled>카메라 목록 새로고침</button>
</div>
<!-- 디버그 로그 섹션 -->
<div class="log-section">
<button class="log-toggle" onclick="toggleLog()">📋 디버그 로그 보기</button>
<div id="logArea" class="log-area"></div>
</div>
<script>
// 로그 토글 함수
function toggleLog() {
const logArea = document.getElementById('logArea');
logArea.classList.toggle('show');
}
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', function() {
console.log('📋 Property Inspector HTML 로드됨');
// 초기 상태 설정
const statusDot = document.getElementById('statusDot');
const connectionStatus = document.getElementById('connection-status');
const cameraSelect = document.getElementById('camera-select');
const refreshButton = document.getElementById('refresh-button');
if (statusDot) console.log('✅ statusDot 요소 찾음');
if (connectionStatus) console.log('✅ connection-status 요소 찾음');
if (cameraSelect) console.log('✅ camera-select 요소 찾음');
if (refreshButton) console.log('✅ refresh-button 요소 찾음');
});
</script>
<script src="index.js"></script>
</body>
</html>

View File

@ -1,778 +0,0 @@
/**
* Streamingle Camera Controller - Property Inspector
* 엘가토 공식 구조 기반 단순화 버전
*/
// Global variables
let websocket = null;
let uuid = null;
let actionContext = null; // 현재 액션의 컨텍스트
let settings = {};
// Context별 설정 관리
const contextSettings = new Map();
let currentActionContext = null;
// Unity 연결 상태 (Plugin Main에서 받아옴)
let isUnityConnected = false;
let cameraData = [];
let currentCamera = 0;
// DOM elements
let statusDot = null;
let connectionStatus = null;
let cameraSelect = null;
let currentCameraDisplay = null;
let refreshButton = null;
// 화면에 로그를 표시하는 함수
function logToScreen(msg, color = "#fff") {
let logDiv = document.getElementById('logArea');
if (!logDiv) {
logDiv = document.createElement('div');
logDiv.id = 'logArea';
logDiv.style.background = '#111';
logDiv.style.color = '#fff';
logDiv.style.fontSize = '11px';
logDiv.style.padding = '8px';
logDiv.style.marginTop = '16px';
logDiv.style.height = '120px';
logDiv.style.overflowY = 'auto';
document.body.appendChild(logDiv);
}
const line = document.createElement('div');
line.style.color = color;
line.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
logDiv.appendChild(line);
logDiv.scrollTop = logDiv.scrollHeight;
}
// 기존 console.log/console.error를 화면에도 출력
const origLog = console.log;
console.log = function(...args) {
origLog.apply(console, args);
logToScreen(args.map(a => (typeof a === 'object' ? JSON.stringify(a) : a)).join(' '), '#0f0');
};
const origErr = console.error;
console.error = function(...args) {
origErr.apply(console, args);
logToScreen(args.map(a => (typeof a === 'object' ? JSON.stringify(a) : a)).join(' '), '#f55');
};
console.log('🔧 Property Inspector script loaded');
// Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', function() {
console.log('📋 Property Inspector 초기화');
initializePropertyInspector();
});
// Initialize Property Inspector
function initializePropertyInspector() {
// Get DOM elements
statusDot = document.getElementById('statusDot');
connectionStatus = document.getElementById('connection-status');
cameraSelect = document.getElementById('camera-select');
currentCameraDisplay = document.getElementById('current-camera');
refreshButton = document.getElementById('refresh-button');
// Setup event listeners
if (cameraSelect) {
cameraSelect.addEventListener('change', onCameraSelectionChanged);
}
if (refreshButton) {
refreshButton.addEventListener('click', onRefreshClicked);
}
console.log('✅ Property Inspector 준비 완료');
}
// Send message to plugin
function sendToPlugin(command, data = {}) {
if (!websocket) {
console.error('❌ WebSocket not available');
return;
}
try {
const message = {
command: command,
context: uuid,
...data
};
// StreamDeck SDK 표준 방식 - sendToPlugin 이벤트 사용
const payload = {
event: 'sendToPlugin',
context: uuid,
payload: message
};
websocket.send(JSON.stringify(payload));
console.log('📤 Message sent to plugin:', command, data);
} catch (error) {
console.error('❌ Failed to send message to plugin:', error);
}
}
// Update connection status display
function updateConnectionStatus(isConnected) {
console.log('🔄 Connection status update:', isConnected);
// 전역 변수도 업데이트
isUnityConnected = isConnected;
if (statusDot) {
statusDot.className = `dot ${isConnected ? 'green' : 'red'}`;
}
if (connectionStatus) {
connectionStatus.textContent = isConnected ? 'Unity 연결됨' : 'Unity 연결 안됨';
connectionStatus.className = isConnected ? 'connected' : 'disconnected';
}
if (cameraSelect) {
cameraSelect.disabled = !isConnected;
}
if (refreshButton) {
refreshButton.disabled = !isConnected;
}
}
// Update camera data display
function updateCameraData(cameraDataParam, currentCamera) {
console.log('📹 Camera data update:', cameraDataParam, currentCamera);
if (cameraSelect && cameraDataParam) {
// Clear existing options
cameraSelect.innerHTML = '';
// cameraDataParam이 직접 배열인지 확인
let cameras = cameraDataParam;
if (cameraDataParam.cameras) {
cameras = cameraDataParam.cameras;
} else if (Array.isArray(cameraDataParam)) {
cameras = cameraDataParam;
}
console.log('📹 처리할 카메라 배열:', cameras);
if (cameras && cameras.length > 0) {
// 전역 변수에 카메라 데이터 저장
cameraData = cameras;
console.log('💾 전역 cameraData 저장됨:', cameraData.length + '개');
// Add camera options
cameras.forEach((camera, index) => {
const option = document.createElement('option');
option.value = index;
option.textContent = `카메라 ${index + 1}`;
if (camera.name) {
option.textContent += ` (${camera.name})`;
}
cameraSelect.appendChild(option);
});
// Set current selection
if (typeof currentCamera === 'number') {
cameraSelect.value = currentCamera;
}
cameraSelect.disabled = false;
console.log('✅ 카메라 목록 업데이트 완료:', cameras.length + '개');
} else {
console.log('⚠️ 카메라 데이터가 없거나 빈 배열');
cameraSelect.disabled = true;
}
}
// Update current camera display
updateCurrentCameraDisplay(currentCamera);
}
// Update current camera display
function updateCurrentCameraDisplay(currentCamera) {
if (currentCameraDisplay) {
if (typeof currentCamera === 'number') {
currentCameraDisplay.textContent = `현재 카메라: ${currentCamera + 1}`;
} else {
currentCameraDisplay.textContent = '현재 카메라: -';
}
}
}
// Handle camera selection change
function onCameraSelectionChanged() {
if (!cameraSelect || !currentActionContext) return;
const selectedIndex = parseInt(cameraSelect.value, 10);
if (isNaN(selectedIndex)) return;
console.log('🎯 카메라 선택 변경:', selectedIndex);
console.log('📋 현재 cameraData:', cameraData);
console.log('📋 cameraData 길이:', cameraData ? cameraData.length : 'undefined');
// StreamDeck에 설정 저장 (Plugin Main에서 didReceiveSettings 이벤트 발생)
if (websocket) {
const setSettingsMessage = {
event: 'setSettings',
context: currentActionContext,
payload: {
cameraIndex: selectedIndex,
cameraList: cameraData // 현재 카메라 목록 포함
}
};
console.log('📤 Plugin Main으로 전송할 데이터:', setSettingsMessage.payload);
websocket.send(JSON.stringify(setSettingsMessage));
console.log('💾 설정 저장됨 - Plugin Main에서 버튼 제목 업데이트됨');
}
// UI 업데이트
updateCurrentCameraDisplay(selectedIndex);
}
// Handle refresh button click
function onRefreshClicked() {
console.log('🔄 새로고침 버튼 클릭 - Plugin Main에 카메라 목록 요청');
// Plugin Main에 카메라 목록 요청
sendToPlugin('requestCameraList');
}
// Unity 연결은 Plugin Main에서만 처리
function startUnityAutoReconnect() {
console.log('🩺 Property Inspector에서는 Unity 연결을 직접 관리하지 않음 - Plugin Main에서 처리됨');
}
// Unity 재연결 시도 (제거)
function attemptUnityReconnect() {
if (isShuttingDown || isConnecting || unityReconnectInterval) return;
unityConnectionAttempts++;
// 재연결 간격 조정
let delay;
if (unityConnectionAttempts <= 3) {
delay = 2000; // 처음 3번은 2초 간격
} else if (unityConnectionAttempts <= 10) {
delay = 5000; // 4-10번은 5초 간격
} else {
delay = 30000; // 그 이후는 30초 간격
}
console.log(`🔄 [Property Inspector] ${delay/1000}초 후 Unity 재연결 시도... (${unityConnectionAttempts}번째 시도)`);
unityReconnectInterval = setTimeout(() => {
unityReconnectInterval = null;
connectToUnity().catch(error => {
console.error(`❌ [Property Inspector] Unity 재연결 실패:`, error);
// 실패해도 계속 시도
if (!isShuttingDown) {
attemptUnityReconnect();
}
});
}, delay);
}
// Unity WebSocket 연결 (개선된 버전)
function connectToUnity() {
return new Promise((resolve, reject) => {
// 글로벌 상태 확인
if (window.sharedUnityConnected && window.sharedUnitySocket) {
console.log('✅ [Property Inspector] 기존 Unity 연결 재사용');
isUnityConnected = true;
unitySocket = window.sharedUnitySocket;
updateConnectionStatus(true);
resolve();
return;
}
if (isUnityConnected) {
console.log('✅ [Property Inspector] Unity 이미 연결됨');
resolve();
return;
}
if (isConnecting) {
console.log('⏳ [Property Inspector] Unity 연결 중... 대기');
reject(new Error('이미 연결 중'));
return;
}
isConnecting = true;
window.sharedIsConnecting = true;
console.log(`🔌 [Property Inspector] Unity 연결 시도... (시도 ${unityConnectionAttempts + 1}회)`);
try {
unitySocket = new WebSocket('ws://localhost:10701');
const connectionTimeout = setTimeout(() => {
isConnecting = false;
window.sharedIsConnecting = false;
console.log('⏰ [Property Inspector] Unity 연결 타임아웃');
if (unitySocket) {
unitySocket.close();
}
reject(new Error('연결 타임아웃'));
}, 5000);
unitySocket.onopen = function() {
clearTimeout(connectionTimeout);
isConnecting = false;
isUnityConnected = true;
unityConnectionAttempts = 0; // 성공 시 재시도 카운터 리셋
// 재연결 타이머 정리
if (unityReconnectInterval) {
clearTimeout(unityReconnectInterval);
unityReconnectInterval = null;
}
// 글로벌 상태 저장
window.sharedUnitySocket = unitySocket;
window.sharedUnityConnected = true;
window.sharedIsConnecting = false;
console.log('✅ [Property Inspector] Unity 연결 성공!');
updateConnectionStatus(true);
resolve();
};
unitySocket.onmessage = function(event) {
try {
const message = JSON.parse(event.data);
handleUnityMessage(message);
} catch (error) {
console.error('❌ [Property Inspector] Unity 메시지 파싱 오류:', error);
}
};
unitySocket.onclose = function(event) {
clearTimeout(connectionTimeout);
const wasConnected = isUnityConnected;
isConnecting = false;
isUnityConnected = false;
// 글로벌 상태 정리
window.sharedUnitySocket = null;
window.sharedUnityConnected = false;
window.sharedIsConnecting = false;
if (wasConnected) {
console.log(`❌ [Property Inspector] Unity 연결 끊어짐 (코드: ${event.code}, 이유: ${event.reason || '알 수 없음'})`);
}
updateConnectionStatus(false);
unitySocket = null;
if (!event.wasClean) {
reject(new Error('연결 실패'));
}
// 자동 재연결 시도
if (!isShuttingDown) {
attemptUnityReconnect();
}
};
unitySocket.onerror = function(error) {
clearTimeout(connectionTimeout);
isConnecting = false;
window.sharedIsConnecting = false;
console.error('❌ [Property Inspector] Unity 연결 오류:', error);
isUnityConnected = false;
updateConnectionStatus(false);
reject(error);
};
} catch (error) {
isConnecting = false;
window.sharedIsConnecting = false;
console.error('❌ [Property Inspector] Unity WebSocket 생성 실패:', error);
reject(error);
}
});
}
// Unity 메시지 처리
function handleUnityMessage(message) {
const messageType = message.type;
if (messageType === 'connection_established') {
console.log('🎉 Unity 연결 확인됨');
if (message.data && message.data.camera_data) {
console.log('📹 연결 시 카메라 데이터 수신 (초기 로드)');
updateCameraUI(message.data.camera_data.presets, message.data.camera_data.current_index);
// 이미 카메라 데이터를 받았으므로 추가 요청하지 않음
cameraData = message.data.camera_data.presets; // 글로벌 변수에 저장
window.sharedCameraData = cameraData; // 브라우저 세션에서 공유
}
} else if (messageType === 'camera_list_response') {
console.log('📹 카메라 목록 응답 수신 (요청에 대한 응답)');
if (message.data && message.data.camera_data) {
updateCameraUI(message.data.camera_data.presets, message.data.camera_data.current_index);
cameraData = message.data.camera_data.presets; // 글로벌 변수에 저장
window.sharedCameraData = cameraData; // 브라우저 세션에서 공유
}
} else if (messageType === 'camera_changed') {
console.log('📹 카메라 변경 알림 수신');
if (message.data && typeof message.data.camera_index === 'number') {
updateCurrentCamera(message.data.camera_index);
}
}
}
function updateCameraUI(cameras, currentIndex) {
if (!cameras || !Array.isArray(cameras)) {
console.error('❌ 잘못된 카메라 데이터');
return;
}
console.log('📹 카메라 UI 업데이트:', cameras.length + '개');
const cameraSelect = document.getElementById('camera-select');
const currentCameraDisplay = document.getElementById('current-camera');
if (!cameraSelect) {
console.error('❌ camera-select 요소를 찾을 수 없음');
return;
}
// 카메라 목록 업데이트
cameraSelect.innerHTML = '<option value="">카메라 선택...</option>';
cameras.forEach((camera, index) => {
const option = document.createElement('option');
option.value = index;
option.textContent = `${index + 1}. ${camera.name}`;
cameraSelect.appendChild(option);
});
// 현재 컨텍스트의 설정 가져오기
const currentSettings = getContextSettings(currentActionContext);
// 카메라 목록 저장
const newSettings = {
...currentSettings,
cameraList: cameras
};
saveContextSettings(currentActionContext, newSettings);
// 이미 설정된 카메라가 있으면 선택
if (currentSettings && typeof currentSettings.cameraIndex === 'number') {
cameraSelect.value = currentSettings.cameraIndex;
// 설정된 카메라의 이름으로 현재 표시만 업데이트 (버튼 제목은 Plugin Main에서 처리)
const selectedCamera = cameras[currentSettings.cameraIndex];
if (selectedCamera) {
currentCameraDisplay.textContent = `현재: ${selectedCamera.name}`;
console.log('📋 기존 카메라 설정 복원:', selectedCamera.name);
}
} else {
// 설정이 없으면 Unity의 현재 카메라 사용
if (typeof currentIndex === 'number' && currentIndex >= 0 && cameras[currentIndex]) {
cameraSelect.value = currentIndex;
currentCameraDisplay.textContent = `현재: ${cameras[currentIndex].name}`;
} else {
currentCameraDisplay.textContent = '현재: 없음';
}
}
console.log('✅ 카메라 UI 업데이트 완료');
}
// Unity에서 카메라 목록 요청
function requestCameraListFromUnity() {
if (isUnityConnected && unitySocket) {
const message = JSON.stringify({ type: 'get_camera_list' });
unitySocket.send(message);
console.log('📤 Unity에 카메라 목록 요청:', message);
}
}
// Unity에서 카메라 전환
function switchCameraInUnity(cameraIndex) {
if (isUnityConnected && unitySocket) {
const message = JSON.stringify({
type: 'switch_camera',
data: {
camera_index: cameraIndex
}
});
unitySocket.send(message);
console.log('📤 Unity에 카메라 전환 요청:', message);
}
}
// Unity에서 받은 카메라 데이터로 UI 업데이트
function updateCameraDataFromUnity() {
console.log('📹 Unity 카메라 데이터로 UI 업데이트:', cameraData);
if (cameraSelect && cameraData && cameraData.length > 0) {
// Clear existing options
cameraSelect.innerHTML = '';
// Add camera options
cameraData.forEach((camera, index) => {
const option = document.createElement('option');
option.value = index;
option.textContent = camera.name || `카메라 ${index + 1}`;
cameraSelect.appendChild(option);
});
// Set current selection
cameraSelect.value = currentCamera;
cameraSelect.disabled = false;
console.log('✅ 카메라 선택 목록 업데이트 완료');
}
// Update current camera display
updateCurrentCameraDisplay(currentCamera);
}
// Handle messages from plugin
function handleMessage(jsonObj) {
console.log('📨 Message received:', jsonObj);
try {
if (jsonObj.event === 'sendToPropertyInspector') {
// payload는 직접 객체로 전달됨
const payload = jsonObj.payload;
console.log('📋 Parsed payload:', payload);
switch (payload.type || payload.command) {
case 'connection_status':
console.log('📡 연결 상태 업데이트:', payload.connected);
updateConnectionStatus(payload.connected);
break;
case 'camera_data':
console.log('📹 카메라 데이터 업데이트 수신:', payload);
console.log('📹 카메라 데이터 상세:', payload.camera_data);
updateCameraData(payload.camera_data, payload.current_camera);
break;
case 'current_settings':
console.log('⚙️ 현재 설정 수신:', payload);
// 현재 설정을 컨텍스트에 저장
if (currentActionContext) {
const newSettings = {
cameraIndex: payload.cameraIndex || 0,
cameraList: payload.cameraList || [],
isUnityConnected: payload.isUnityConnected || false
};
saveContextSettings(currentActionContext, newSettings);
// UI 업데이트
updateConnectionStatus(payload.isUnityConnected);
if (payload.cameraList && payload.cameraList.length > 0) {
updateCameraData({ cameras: payload.cameraList }, payload.cameraIndex);
}
}
break;
case 'camera_changed':
console.log('📹 카메라 변경:', payload.current_camera);
updateCurrentCameraDisplay(payload.current_camera);
break;
default:
console.log('❓ Unknown message type:', payload.type || payload.command);
}
} else if (jsonObj.event === 'didReceiveSettings') {
// didReceiveSettings 이벤트 처리 추가
console.log('⚙️ didReceiveSettings 이벤트 수신:', jsonObj);
const settings = jsonObj.payload.settings || {};
console.log('📋 설정 데이터:', settings);
// 컨텍스트에 설정 저장
if (currentActionContext) {
saveContextSettings(currentActionContext, settings);
// UI 업데이트
if (settings.cameraList && settings.cameraList.length > 0) {
console.log('📹 카메라 목록으로 UI 업데이트:', settings.cameraList.length + '개');
updateCameraData(settings.cameraList, settings.cameraIndex || 0);
// 카메라 목록이 있다면 Unity가 연결되어 있다고 판단
console.log('🔍 카메라 목록 존재 - Unity 연결됨으로 판단');
updateConnectionStatus(true);
}
// 연결 상태 업데이트 (카메라 목록이 없을 때만 설정값 사용)
if (typeof settings.isUnityConnected === 'boolean' && (!settings.cameraList || settings.cameraList.length === 0)) {
updateConnectionStatus(settings.isUnityConnected);
}
}
}
} catch (error) {
console.error('❌ Failed to handle message:', error);
}
}
// StreamDeck SDK connection
function connectElgatoStreamDeckSocket(inPort, inPropertyInspectorUUID, inRegisterEvent, inInfo, inActionInfo) {
uuid = inPropertyInspectorUUID;
console.log('🔌 StreamDeck 연결 중...');
// Parse info
try {
const info = JSON.parse(inInfo);
const actionInfo = JSON.parse(inActionInfo);
actionContext = actionInfo.context; // 액션 컨텍스트 저장
currentActionContext = actionInfo.context; // 현재 액션 컨텍스트 설정
settings = actionInfo.payload.settings || {};
// 컨텍스트별 설정 초기화
saveContextSettings(currentActionContext, settings);
console.log('📋 컨텍스트 설정 완료:', currentActionContext);
} catch (error) {
console.error('❌ 정보 파싱 실패:', error);
}
// Connect to StreamDock
websocket = new WebSocket('ws://127.0.0.1:' + inPort);
websocket.onopen = function() {
console.log('✅ StreamDeck 연결됨');
// Register
const json = {
event: inRegisterEvent,
uuid: uuid
};
websocket.send(JSON.stringify(json));
// Plugin Main에 초기 설정 요청
console.log('📤 Plugin Main에 초기 설정 요청');
sendToPlugin('getInitialSettings');
};
websocket.onmessage = function(evt) {
try {
const jsonObj = JSON.parse(evt.data);
handleMessage(jsonObj);
} catch (error) {
console.error('❌ Failed to parse message:', error);
}
};
websocket.onclose = function() {
console.log('❌ StreamDeck WebSocket closed');
isShuttingDown = true; // 종료 플래그 설정
// Unity 자동 재연결 정리
if (unityReconnectInterval) {
clearTimeout(unityReconnectInterval);
unityReconnectInterval = null;
}
if (unityHealthCheckInterval) {
clearInterval(unityHealthCheckInterval);
unityHealthCheckInterval = null;
}
websocket = null;
};
websocket.onerror = function(error) {
console.error('❌ StreamDeck WebSocket error:', error);
};
}
// 컨텍스트별 설정 관리 함수들
function getContextSettings(context) {
if (!context) return {};
return contextSettings.get(context) || {};
}
function saveContextSettings(context, newSettings) {
if (!context) return;
contextSettings.set(context, { ...newSettings });
console.log('💾 컨텍스트 설정 저장:', context, newSettings);
}
// 현재 컨텍스트의 설정에서 카메라 이름을 가져오는 공통 함수
function getCurrentCameraName(context, cameraIndex = null) {
if (!context) return '카메라\n선택';
const settings = getContextSettings(context);
if (!settings || !settings.cameraList) return '카메라\n선택';
// cameraIndex가 제공되면 그것을 사용, 아니면 설정에서 가져옴
const index = cameraIndex !== null ? cameraIndex : settings.cameraIndex;
if (typeof index !== 'number' || !settings.cameraList[index]) return '카메라\n선택';
return settings.cameraList[index].name || '카메라\n선택';
}
// 버튼 제목 업데이트 공통 함수 (Plugin Main과 동일한 로직)
function updateButtonTitle(context, cameraName = null, cameraIndex = null) {
if (!websocket || !context) return;
// cameraName이 제공되지 않으면 현재 설정에서 가져옴
if (!cameraName) {
cameraName = getCurrentCameraName(context, cameraIndex);
console.log(`🔍 [Property Inspector] getCurrentCameraName 결과: "${cameraName}"`);
// 디버깅을 위해 현재 설정 상태도 출력
const settings = getContextSettings(context);
console.log(`🔍 [Property Inspector] 컨텍스트 설정:`, settings);
console.log(`🔍 [Property Inspector] 카메라 인덱스: ${cameraIndex !== null ? cameraIndex : settings.cameraIndex}, 목록 길이: ${settings.cameraList ? settings.cameraList.length : 0}`);
} else {
console.log(`🔍 [Property Inspector] 직접 제공된 카메라 이름: "${cameraName}"`);
}
// 기본값 설정
let title = cameraName || '카메라\n선택';
console.log(`🔍 [Property Inspector] 최종 사용할 제목 (가공 전): "${title}"`);
// 긴 텍스트를 두 줄로 나누기 (Plugin Main과 동일한 로직)
if (title.length > 8) {
const underscoreIndex = title.indexOf('_');
if (underscoreIndex !== -1 && underscoreIndex > 0) {
// 언더스코어가 있으면 그 위치에서 분할하고 언더스코어는 제거
const firstLine = title.substring(0, underscoreIndex);
const secondLine = title.substring(underscoreIndex + 1); // +1로 언더스코어 제거
// 각 줄이 너무 길면 적절히 자르기
const maxLineLength = 8;
let line1 = firstLine.length > maxLineLength ? firstLine.substring(0, maxLineLength - 1) + '.' : firstLine;
let line2 = secondLine.length > maxLineLength ? secondLine.substring(0, maxLineLength - 1) + '.' : secondLine;
title = line1 + '\n' + line2;
} else {
// 언더스코어가 없으면 중간 지점에서 분할
const midPoint = Math.ceil(title.length / 2);
const firstLine = title.substring(0, midPoint);
const secondLine = title.substring(midPoint);
title = firstLine + '\n' + secondLine;
}
}
// 버튼 제목 설정 (Plugin Main과 완전히 동일한 매개변수)
const message = {
event: 'setTitle',
context: context,
payload: {
title: title,
target: 0, // 하드웨어와 소프트웨어 모두
titleParameters: {
fontSize: 18,
showTitle: true,
titleAlignment: "middle"
}
}
};
websocket.send(JSON.stringify(message));
console.log('🏷️ [Property Inspector] 버튼 제목 업데이트:', title.replace('\n', '\\n'));
}

View File

@ -1,157 +0,0 @@
let $websocket, $uuid, $action, $context, $settings, $lang, $FileID = '';
WebSocket.prototype.setGlobalSettings = function(payload) {
this.send(JSON.stringify({
event: "setGlobalSettings",
context: $uuid, payload
}));
}
WebSocket.prototype.getGlobalSettings = function() {
this.send(JSON.stringify({
event: "getGlobalSettings",
context: $uuid,
}));
}
// 与插件通信
WebSocket.prototype.sendToPlugin = function (payload) {
this.send(JSON.stringify({
event: "sendToPlugin",
action: $action,
context: $uuid,
payload
}));
};
//设置标题
WebSocket.prototype.setTitle = function (str, row = 0, num = 6) {
console.log(str);
let newStr = '';
if (row) {
let nowRow = 1, strArr = str.split('');
strArr.forEach((item, index) => {
if (nowRow < row && index >= nowRow * num) { nowRow++; newStr += '\n'; }
if (nowRow <= row && index < nowRow * num) { newStr += item; }
});
if (strArr.length > row * num) { newStr = newStr.substring(0, newStr.length - 1); newStr += '..'; }
}
this.send(JSON.stringify({
event: "setTitle",
context: $context,
payload: {
target: 0,
title: newStr || str
}
}));
}
// 设置状态
WebSocket.prototype.setState = function (state) {
this.send(JSON.stringify({
event: "setState",
context: $context,
payload: { state }
}));
};
// 设置背景
WebSocket.prototype.setImage = function (url) {
let image = new Image();
image.src = url;
image.onload = () => {
let canvas = document.createElement("canvas");
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
let ctx = canvas.getContext("2d");
ctx.drawImage(image, 0, 0);
this.send(JSON.stringify({
event: "setImage",
context: $context,
payload: {
target: 0,
image: canvas.toDataURL("image/png")
}
}));
};
};
// 打开网页
WebSocket.prototype.openUrl = function (url) {
this.send(JSON.stringify({
event: "openUrl",
payload: { url }
}));
};
// 保存持久化数据
WebSocket.prototype.saveData = $.debounce(function (payload) {
this.send(JSON.stringify({
event: "setSettings",
context: $uuid,
payload
}));
});
// StreamDock 软件入口函数
const connectSocket = connectElgatoStreamDeckSocket;
async function connectElgatoStreamDeckSocket(port, uuid, event, app, info) {
info = JSON.parse(info);
$uuid = uuid; $action = info.action;
$context = info.context;
$websocket = new WebSocket('ws://127.0.0.1:' + port);
$websocket.onopen = () => $websocket.send(JSON.stringify({ event, uuid }));
// 持久数据代理
$websocket.onmessage = e => {
let data = JSON.parse(e.data);
if (data.event === 'didReceiveSettings') {
$settings = new Proxy(data.payload.settings, {
get(target, property) {
return target[property];
},
set(target, property, value) {
target[property] = value;
$websocket.saveData(data.payload.settings);
}
});
if (!$back) $dom.main.style.display = 'block';
}
$propEvent[data.event]?.(data.payload);
};
// 自动翻译页面
if (!$local) return;
$lang = await new Promise(resolve => {
const req = new XMLHttpRequest();
req.open('GET', `../../${JSON.parse(app).application.language}.json`);
req.send();
req.onreadystatechange = () => {
if (req.readyState === 4) {
resolve(JSON.parse(req.responseText).Localization);
}
};
});
// 遍历文本节点并翻译所有文本节点
const walker = document.createTreeWalker($dom.main, NodeFilter.SHOW_TEXT, (e) => {
return e.data.trim() && NodeFilter.FILTER_ACCEPT;
});
while (walker.nextNode()) {
console.log(walker.currentNode.data);
walker.currentNode.data = $lang[walker.currentNode.data];
}
// placeholder 特殊处理
const translate = item => {
if (item.placeholder?.trim()) {
console.log(item.placeholder);
item.placeholder = $lang[item.placeholder];
}
};
$('input', true).forEach(translate);
$('textarea', true).forEach(translate);
}
// StreamDock 文件路径回调
Array.from($('input[type="file"]', true)).forEach(item => item.addEventListener('click', () => $FileID = item.id));
const onFilePickerReturn = (url) => $emit.send(`File-${$FileID}`, JSON.parse(url));

View File

@ -1,81 +0,0 @@
// 自定义事件类
class EventPlus {
constructor() {
this.event = new EventTarget();
}
on(name, callback) {
this.event.addEventListener(name, e => callback(e.detail));
}
send(name, data) {
this.event.dispatchEvent(new CustomEvent(name, {
detail: data,
bubbles: false,
cancelable: false
}));
}
}
// 补零
String.prototype.fill = function () {
return this >= 10 ? this : '0' + this;
};
// unicode编码转换字符串
String.prototype.uTs = function () {
return eval('"' + Array.from(this).join('') + '"');
};
// 字符串转换unicode编码
String.prototype.sTu = function (str = '') {
Array.from(this).forEach(item => str += `\\u${item.charCodeAt(0).toString(16)}`);
return str;
};
// 全局变量/方法
const $emit = new EventPlus(), $ = (selector, isAll = false) => {
const element = document.querySelector(selector), methods = {
on: function (event, callback) {
this.addEventListener(event, callback);
},
attr: function (name, value = '') {
value && this.setAttribute(name, value);
return this;
}
};
if (!isAll && element) {
return Object.assign(element, methods);
} else if (!isAll && !element) {
throw `HTML没有 ${selector} 元素! 请检查是否拼写错误`;
}
return Array.from(document.querySelectorAll(selector)).map(item => Object.assign(item, methods));
};
// 节流函数
$.throttle = (fn, delay) => {
let Timer = null;
return function () {
if (Timer) return;
Timer = setTimeout(() => {
fn.apply(this, arguments);
Timer = null;
}, delay);
};
};
// 防抖函数
$.debounce = (fn, delay) => {
let Timer = null;
return function () {
clearTimeout(Timer);
Timer = setTimeout(() => fn.apply(this, arguments), delay);
};
};
// 绑定限制数字方法
Array.from($('input[type="num"]', true)).forEach(item => {
item.addEventListener('input', function limitNum() {
if (!item.value || /^\d+$/.test(item.value)) return;
item.value = item.value.slice(0, -1);
limitNum(item);
});
});

View File

@ -1,9 +0,0 @@
[2025-07-03T00:52:25.811] [INFO] default - 🚀 Streamingle Camera Controller 플러그인 시작됨
[2025-07-03T00:58:26.107] [INFO] default - 🚀 Streamingle Camera Controller 플러그인 시작됨
[2025-07-03T00:58:37.479] [INFO] default - 🚀 Streamingle Camera Controller 플러그인 시작됨
[2025-07-03T00:58:50.582] [INFO] default - 🚀 Streamingle Camera Controller 플러그인 시작됨
[2025-07-03T00:59:04.912] [INFO] default - 🚀 Streamingle Camera Controller 플러그인 시작됨
[2025-07-03T01:00:19.924] [INFO] default - 🚀 Streamingle Camera Controller 플러그인 시작됨
[2025-07-03T01:00:32.402] [INFO] default - 🚀 Streamingle Camera Controller 플러그인 시작됨
[2025-07-03T01:01:32.400] [INFO] default - 🚀 Streamingle Camera Controller 플러그인 시작됨
[2025-07-03T01:02:32.414] [INFO] default - 🚀 Streamingle Camera Controller 플러그인 시작됨

View File

@ -1,65 +0,0 @@
const StreaminglePlugin = require('./plugin.js');
console.log('=== Streamingle 플러그인 테스트 시작 ===');
const plugin = new StreaminglePlugin();
// 연결 상태 모니터링
let connectionCheckInterval = setInterval(() => {
const status = plugin.getStatus();
console.log(`📊 연결 상태: ${status.isConnected ? '✅ 연결됨' : '❌ 연결 안됨'}`);
if (status.isConnected) {
console.log(`📷 카메라 개수: ${status.cameraCount}`);
console.log(`🎯 현재 카메라: ${status.currentCamera >= 0 ? status.currentCamera : '없음'}`);
if (status.cameraList && status.cameraList.length > 0) {
console.log('📋 카메라 목록:');
status.cameraList.forEach((camera, index) => {
console.log(` ${index}: ${camera.name} ${camera.isActive ? '[활성]' : '[비활성]'}`);
});
}
}
console.log('---');
}, 5000);
// 3초 후 카메라 목록 요청
setTimeout(() => {
console.log('🔍 카메라 목록 요청...');
plugin.requestCameraList();
}, 3000);
// 8초 후 첫 번째 카메라로 전환
setTimeout(() => {
console.log('🎬 첫 번째 카메라로 전환...');
plugin.switchCamera(0);
}, 8000);
// 13초 후 두 번째 카메라로 전환
setTimeout(() => {
console.log('🎬 두 번째 카메라로 전환...');
plugin.switchCamera(1);
}, 13000);
// 18초 후 세 번째 카메라로 전환 (있다면)
setTimeout(() => {
console.log('🎬 세 번째 카메라로 전환...');
plugin.switchCamera(2);
}, 18000);
// 25초 후 종료
setTimeout(() => {
console.log('🛑 테스트 종료...');
clearInterval(connectionCheckInterval);
plugin.disconnect();
process.exit(0);
}, 25000);
// 프로세스 종료 시 정리
process.on('SIGINT', () => {
console.log('🛑 테스트 중단...');
clearInterval(connectionCheckInterval);
plugin.disconnect();
process.exit(0);
});

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,20 +0,0 @@
Copyright (c) 2011 Einar Otto Stangvik <einaros@gmail.com>
Copyright (c) 2013 Arnout Kazemier and contributors
Copyright (c) 2016 Luigi Pinca and contributors
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

BIN
Streamdeck/com.mirabox.streamingle.sdPlugin/node_modules/ws/README.md (Stored with Git LFS) generated vendored

Binary file not shown.

View File

@ -1,8 +0,0 @@
'use strict';
module.exports = function () {
throw new Error(
'ws does not work in the browser. Browser clients must use the native ' +
'WebSocket object'
);
};

View File

@ -1,13 +0,0 @@
'use strict';
const WebSocket = require('./lib/websocket');
WebSocket.createWebSocketStream = require('./lib/stream');
WebSocket.Server = require('./lib/websocket-server');
WebSocket.Receiver = require('./lib/receiver');
WebSocket.Sender = require('./lib/sender');
WebSocket.WebSocket = WebSocket;
WebSocket.WebSocketServer = WebSocket.Server;
module.exports = WebSocket;

View File

@ -1,131 +0,0 @@
'use strict';
const { EMPTY_BUFFER } = require('./constants');
const FastBuffer = Buffer[Symbol.species];
/**
* Merges an array of buffers into a new buffer.
*
* @param {Buffer[]} list The array of buffers to concat
* @param {Number} totalLength The total length of buffers in the list
* @return {Buffer} The resulting buffer
* @public
*/
function concat(list, totalLength) {
if (list.length === 0) return EMPTY_BUFFER;
if (list.length === 1) return list[0];
const target = Buffer.allocUnsafe(totalLength);
let offset = 0;
for (let i = 0; i < list.length; i++) {
const buf = list[i];
target.set(buf, offset);
offset += buf.length;
}
if (offset < totalLength) {
return new FastBuffer(target.buffer, target.byteOffset, offset);
}
return target;
}
/**
* Masks a buffer using the given mask.
*
* @param {Buffer} source The buffer to mask
* @param {Buffer} mask The mask to use
* @param {Buffer} output The buffer where to store the result
* @param {Number} offset The offset at which to start writing
* @param {Number} length The number of bytes to mask.
* @public
*/
function _mask(source, mask, output, offset, length) {
for (let i = 0; i < length; i++) {
output[offset + i] = source[i] ^ mask[i & 3];
}
}
/**
* Unmasks a buffer using the given mask.
*
* @param {Buffer} buffer The buffer to unmask
* @param {Buffer} mask The mask to use
* @public
*/
function _unmask(buffer, mask) {
for (let i = 0; i < buffer.length; i++) {
buffer[i] ^= mask[i & 3];
}
}
/**
* Converts a buffer to an `ArrayBuffer`.
*
* @param {Buffer} buf The buffer to convert
* @return {ArrayBuffer} Converted buffer
* @public
*/
function toArrayBuffer(buf) {
if (buf.length === buf.buffer.byteLength) {
return buf.buffer;
}
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.length);
}
/**
* Converts `data` to a `Buffer`.
*
* @param {*} data The data to convert
* @return {Buffer} The buffer
* @throws {TypeError}
* @public
*/
function toBuffer(data) {
toBuffer.readOnly = true;
if (Buffer.isBuffer(data)) return data;
let buf;
if (data instanceof ArrayBuffer) {
buf = new FastBuffer(data);
} else if (ArrayBuffer.isView(data)) {
buf = new FastBuffer(data.buffer, data.byteOffset, data.byteLength);
} else {
buf = Buffer.from(data);
toBuffer.readOnly = false;
}
return buf;
}
module.exports = {
concat,
mask: _mask,
toArrayBuffer,
toBuffer,
unmask: _unmask
};
/* istanbul ignore else */
if (!process.env.WS_NO_BUFFER_UTIL) {
try {
const bufferUtil = require('bufferutil');
module.exports.mask = function (source, mask, output, offset, length) {
if (length < 48) _mask(source, mask, output, offset, length);
else bufferUtil.mask(source, mask, output, offset, length);
};
module.exports.unmask = function (buffer, mask) {
if (buffer.length < 32) _unmask(buffer, mask);
else bufferUtil.unmask(buffer, mask);
};
} catch (e) {
// Continue regardless of the error.
}
}

View File

@ -1,18 +0,0 @@
'use strict';
const BINARY_TYPES = ['nodebuffer', 'arraybuffer', 'fragments'];
const hasBlob = typeof Blob !== 'undefined';
if (hasBlob) BINARY_TYPES.push('blob');
module.exports = {
BINARY_TYPES,
EMPTY_BUFFER: Buffer.alloc(0),
GUID: '258EAFA5-E914-47DA-95CA-C5AB0DC85B11',
hasBlob,
kForOnEventAttribute: Symbol('kIsForOnEventAttribute'),
kListener: Symbol('kListener'),
kStatusCode: Symbol('status-code'),
kWebSocket: Symbol('websocket'),
NOOP: () => {}
};

View File

@ -1,292 +0,0 @@
'use strict';
const { kForOnEventAttribute, kListener } = require('./constants');
const kCode = Symbol('kCode');
const kData = Symbol('kData');
const kError = Symbol('kError');
const kMessage = Symbol('kMessage');
const kReason = Symbol('kReason');
const kTarget = Symbol('kTarget');
const kType = Symbol('kType');
const kWasClean = Symbol('kWasClean');
/**
* Class representing an event.
*/
class Event {
/**
* Create a new `Event`.
*
* @param {String} type The name of the event
* @throws {TypeError} If the `type` argument is not specified
*/
constructor(type) {
this[kTarget] = null;
this[kType] = type;
}
/**
* @type {*}
*/
get target() {
return this[kTarget];
}
/**
* @type {String}
*/
get type() {
return this[kType];
}
}
Object.defineProperty(Event.prototype, 'target', { enumerable: true });
Object.defineProperty(Event.prototype, 'type', { enumerable: true });
/**
* Class representing a close event.
*
* @extends Event
*/
class CloseEvent extends Event {
/**
* Create a new `CloseEvent`.
*
* @param {String} type The name of the event
* @param {Object} [options] A dictionary object that allows for setting
* attributes via object members of the same name
* @param {Number} [options.code=0] The status code explaining why the
* connection was closed
* @param {String} [options.reason=''] A human-readable string explaining why
* the connection was closed
* @param {Boolean} [options.wasClean=false] Indicates whether or not the
* connection was cleanly closed
*/
constructor(type, options = {}) {
super(type);
this[kCode] = options.code === undefined ? 0 : options.code;
this[kReason] = options.reason === undefined ? '' : options.reason;
this[kWasClean] = options.wasClean === undefined ? false : options.wasClean;
}
/**
* @type {Number}
*/
get code() {
return this[kCode];
}
/**
* @type {String}
*/
get reason() {
return this[kReason];
}
/**
* @type {Boolean}
*/
get wasClean() {
return this[kWasClean];
}
}
Object.defineProperty(CloseEvent.prototype, 'code', { enumerable: true });
Object.defineProperty(CloseEvent.prototype, 'reason', { enumerable: true });
Object.defineProperty(CloseEvent.prototype, 'wasClean', { enumerable: true });
/**
* Class representing an error event.
*
* @extends Event
*/
class ErrorEvent extends Event {
/**
* Create a new `ErrorEvent`.
*
* @param {String} type The name of the event
* @param {Object} [options] A dictionary object that allows for setting
* attributes via object members of the same name
* @param {*} [options.error=null] The error that generated this event
* @param {String} [options.message=''] The error message
*/
constructor(type, options = {}) {
super(type);
this[kError] = options.error === undefined ? null : options.error;
this[kMessage] = options.message === undefined ? '' : options.message;
}
/**
* @type {*}
*/
get error() {
return this[kError];
}
/**
* @type {String}
*/
get message() {
return this[kMessage];
}
}
Object.defineProperty(ErrorEvent.prototype, 'error', { enumerable: true });
Object.defineProperty(ErrorEvent.prototype, 'message', { enumerable: true });
/**
* Class representing a message event.
*
* @extends Event
*/
class MessageEvent extends Event {
/**
* Create a new `MessageEvent`.
*
* @param {String} type The name of the event
* @param {Object} [options] A dictionary object that allows for setting
* attributes via object members of the same name
* @param {*} [options.data=null] The message content
*/
constructor(type, options = {}) {
super(type);
this[kData] = options.data === undefined ? null : options.data;
}
/**
* @type {*}
*/
get data() {
return this[kData];
}
}
Object.defineProperty(MessageEvent.prototype, 'data', { enumerable: true });
/**
* This provides methods for emulating the `EventTarget` interface. It's not
* meant to be used directly.
*
* @mixin
*/
const EventTarget = {
/**
* Register an event listener.
*
* @param {String} type A string representing the event type to listen for
* @param {(Function|Object)} handler The listener to add
* @param {Object} [options] An options object specifies characteristics about
* the event listener
* @param {Boolean} [options.once=false] A `Boolean` indicating that the
* listener should be invoked at most once after being added. If `true`,
* the listener would be automatically removed when invoked.
* @public
*/
addEventListener(type, handler, options = {}) {
for (const listener of this.listeners(type)) {
if (
!options[kForOnEventAttribute] &&
listener[kListener] === handler &&
!listener[kForOnEventAttribute]
) {
return;
}
}
let wrapper;
if (type === 'message') {
wrapper = function onMessage(data, isBinary) {
const event = new MessageEvent('message', {
data: isBinary ? data : data.toString()
});
event[kTarget] = this;
callListener(handler, this, event);
};
} else if (type === 'close') {
wrapper = function onClose(code, message) {
const event = new CloseEvent('close', {
code,
reason: message.toString(),
wasClean: this._closeFrameReceived && this._closeFrameSent
});
event[kTarget] = this;
callListener(handler, this, event);
};
} else if (type === 'error') {
wrapper = function onError(error) {
const event = new ErrorEvent('error', {
error,
message: error.message
});
event[kTarget] = this;
callListener(handler, this, event);
};
} else if (type === 'open') {
wrapper = function onOpen() {
const event = new Event('open');
event[kTarget] = this;
callListener(handler, this, event);
};
} else {
return;
}
wrapper[kForOnEventAttribute] = !!options[kForOnEventAttribute];
wrapper[kListener] = handler;
if (options.once) {
this.once(type, wrapper);
} else {
this.on(type, wrapper);
}
},
/**
* Remove an event listener.
*
* @param {String} type A string representing the event type to remove
* @param {(Function|Object)} handler The listener to remove
* @public
*/
removeEventListener(type, handler) {
for (const listener of this.listeners(type)) {
if (listener[kListener] === handler && !listener[kForOnEventAttribute]) {
this.removeListener(type, listener);
break;
}
}
}
};
module.exports = {
CloseEvent,
ErrorEvent,
Event,
EventTarget,
MessageEvent
};
/**
* Call an event listener
*
* @param {(Function|Object)} listener The listener to call
* @param {*} thisArg The value to use as `this`` when calling the listener
* @param {Event} event The event to pass to the listener
* @private
*/
function callListener(listener, thisArg, event) {
if (typeof listener === 'object' && listener.handleEvent) {
listener.handleEvent.call(listener, event);
} else {
listener.call(thisArg, event);
}
}

View File

@ -1,203 +0,0 @@
'use strict';
const { tokenChars } = require('./validation');
/**
* Adds an offer to the map of extension offers or a parameter to the map of
* parameters.
*
* @param {Object} dest The map of extension offers or parameters
* @param {String} name The extension or parameter name
* @param {(Object|Boolean|String)} elem The extension parameters or the
* parameter value
* @private
*/
function push(dest, name, elem) {
if (dest[name] === undefined) dest[name] = [elem];
else dest[name].push(elem);
}
/**
* Parses the `Sec-WebSocket-Extensions` header into an object.
*
* @param {String} header The field value of the header
* @return {Object} The parsed object
* @public
*/
function parse(header) {
const offers = Object.create(null);
let params = Object.create(null);
let mustUnescape = false;
let isEscaping = false;
let inQuotes = false;
let extensionName;
let paramName;
let start = -1;
let code = -1;
let end = -1;
let i = 0;
for (; i < header.length; i++) {
code = header.charCodeAt(i);
if (extensionName === undefined) {
if (end === -1 && tokenChars[code] === 1) {
if (start === -1) start = i;
} else if (
i !== 0 &&
(code === 0x20 /* ' ' */ || code === 0x09) /* '\t' */
) {
if (end === -1 && start !== -1) end = i;
} else if (code === 0x3b /* ';' */ || code === 0x2c /* ',' */) {
if (start === -1) {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
if (end === -1) end = i;
const name = header.slice(start, end);
if (code === 0x2c) {
push(offers, name, params);
params = Object.create(null);
} else {
extensionName = name;
}
start = end = -1;
} else {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
} else if (paramName === undefined) {
if (end === -1 && tokenChars[code] === 1) {
if (start === -1) start = i;
} else if (code === 0x20 || code === 0x09) {
if (end === -1 && start !== -1) end = i;
} else if (code === 0x3b || code === 0x2c) {
if (start === -1) {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
if (end === -1) end = i;
push(params, header.slice(start, end), true);
if (code === 0x2c) {
push(offers, extensionName, params);
params = Object.create(null);
extensionName = undefined;
}
start = end = -1;
} else if (code === 0x3d /* '=' */ && start !== -1 && end === -1) {
paramName = header.slice(start, i);
start = end = -1;
} else {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
} else {
//
// The value of a quoted-string after unescaping must conform to the
// token ABNF, so only token characters are valid.
// Ref: https://tools.ietf.org/html/rfc6455#section-9.1
//
if (isEscaping) {
if (tokenChars[code] !== 1) {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
if (start === -1) start = i;
else if (!mustUnescape) mustUnescape = true;
isEscaping = false;
} else if (inQuotes) {
if (tokenChars[code] === 1) {
if (start === -1) start = i;
} else if (code === 0x22 /* '"' */ && start !== -1) {
inQuotes = false;
end = i;
} else if (code === 0x5c /* '\' */) {
isEscaping = true;
} else {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
} else if (code === 0x22 && header.charCodeAt(i - 1) === 0x3d) {
inQuotes = true;
} else if (end === -1 && tokenChars[code] === 1) {
if (start === -1) start = i;
} else if (start !== -1 && (code === 0x20 || code === 0x09)) {
if (end === -1) end = i;
} else if (code === 0x3b || code === 0x2c) {
if (start === -1) {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
if (end === -1) end = i;
let value = header.slice(start, end);
if (mustUnescape) {
value = value.replace(/\\/g, '');
mustUnescape = false;
}
push(params, paramName, value);
if (code === 0x2c) {
push(offers, extensionName, params);
params = Object.create(null);
extensionName = undefined;
}
paramName = undefined;
start = end = -1;
} else {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
}
}
if (start === -1 || inQuotes || code === 0x20 || code === 0x09) {
throw new SyntaxError('Unexpected end of input');
}
if (end === -1) end = i;
const token = header.slice(start, end);
if (extensionName === undefined) {
push(offers, token, params);
} else {
if (paramName === undefined) {
push(params, token, true);
} else if (mustUnescape) {
push(params, paramName, token.replace(/\\/g, ''));
} else {
push(params, paramName, token);
}
push(offers, extensionName, params);
}
return offers;
}
/**
* Builds the `Sec-WebSocket-Extensions` header field value.
*
* @param {Object} extensions The map of extensions and parameters to format
* @return {String} A string representing the given object
* @public
*/
function format(extensions) {
return Object.keys(extensions)
.map((extension) => {
let configurations = extensions[extension];
if (!Array.isArray(configurations)) configurations = [configurations];
return configurations
.map((params) => {
return [extension]
.concat(
Object.keys(params).map((k) => {
let values = params[k];
if (!Array.isArray(values)) values = [values];
return values
.map((v) => (v === true ? k : `${k}=${v}`))
.join('; ');
})
)
.join('; ');
})
.join(', ');
})
.join(', ');
}
module.exports = { format, parse };

View File

@ -1,55 +0,0 @@
'use strict';
const kDone = Symbol('kDone');
const kRun = Symbol('kRun');
/**
* A very simple job queue with adjustable concurrency. Adapted from
* https://github.com/STRML/async-limiter
*/
class Limiter {
/**
* Creates a new `Limiter`.
*
* @param {Number} [concurrency=Infinity] The maximum number of jobs allowed
* to run concurrently
*/
constructor(concurrency) {
this[kDone] = () => {
this.pending--;
this[kRun]();
};
this.concurrency = concurrency || Infinity;
this.jobs = [];
this.pending = 0;
}
/**
* Adds a job to the queue.
*
* @param {Function} job The job to run
* @public
*/
add(job) {
this.jobs.push(job);
this[kRun]();
}
/**
* Removes a job from the queue and runs it if possible.
*
* @private
*/
[kRun]() {
if (this.pending === this.concurrency) return;
if (this.jobs.length) {
const job = this.jobs.shift();
this.pending++;
job(this[kDone]);
}
}
}
module.exports = Limiter;

View File

@ -1,528 +0,0 @@
'use strict';
const zlib = require('zlib');
const bufferUtil = require('./buffer-util');
const Limiter = require('./limiter');
const { kStatusCode } = require('./constants');
const FastBuffer = Buffer[Symbol.species];
const TRAILER = Buffer.from([0x00, 0x00, 0xff, 0xff]);
const kPerMessageDeflate = Symbol('permessage-deflate');
const kTotalLength = Symbol('total-length');
const kCallback = Symbol('callback');
const kBuffers = Symbol('buffers');
const kError = Symbol('error');
//
// We limit zlib concurrency, which prevents severe memory fragmentation
// as documented in https://github.com/nodejs/node/issues/8871#issuecomment-250915913
// and https://github.com/websockets/ws/issues/1202
//
// Intentionally global; it's the global thread pool that's an issue.
//
let zlibLimiter;
/**
* permessage-deflate implementation.
*/
class PerMessageDeflate {
/**
* Creates a PerMessageDeflate instance.
*
* @param {Object} [options] Configuration options
* @param {(Boolean|Number)} [options.clientMaxWindowBits] Advertise support
* for, or request, a custom client window size
* @param {Boolean} [options.clientNoContextTakeover=false] Advertise/
* acknowledge disabling of client context takeover
* @param {Number} [options.concurrencyLimit=10] The number of concurrent
* calls to zlib
* @param {(Boolean|Number)} [options.serverMaxWindowBits] Request/confirm the
* use of a custom server window size
* @param {Boolean} [options.serverNoContextTakeover=false] Request/accept
* disabling of server context takeover
* @param {Number} [options.threshold=1024] Size (in bytes) below which
* messages should not be compressed if context takeover is disabled
* @param {Object} [options.zlibDeflateOptions] Options to pass to zlib on
* deflate
* @param {Object} [options.zlibInflateOptions] Options to pass to zlib on
* inflate
* @param {Boolean} [isServer=false] Create the instance in either server or
* client mode
* @param {Number} [maxPayload=0] The maximum allowed message length
*/
constructor(options, isServer, maxPayload) {
this._maxPayload = maxPayload | 0;
this._options = options || {};
this._threshold =
this._options.threshold !== undefined ? this._options.threshold : 1024;
this._isServer = !!isServer;
this._deflate = null;
this._inflate = null;
this.params = null;
if (!zlibLimiter) {
const concurrency =
this._options.concurrencyLimit !== undefined
? this._options.concurrencyLimit
: 10;
zlibLimiter = new Limiter(concurrency);
}
}
/**
* @type {String}
*/
static get extensionName() {
return 'permessage-deflate';
}
/**
* Create an extension negotiation offer.
*
* @return {Object} Extension parameters
* @public
*/
offer() {
const params = {};
if (this._options.serverNoContextTakeover) {
params.server_no_context_takeover = true;
}
if (this._options.clientNoContextTakeover) {
params.client_no_context_takeover = true;
}
if (this._options.serverMaxWindowBits) {
params.server_max_window_bits = this._options.serverMaxWindowBits;
}
if (this._options.clientMaxWindowBits) {
params.client_max_window_bits = this._options.clientMaxWindowBits;
} else if (this._options.clientMaxWindowBits == null) {
params.client_max_window_bits = true;
}
return params;
}
/**
* Accept an extension negotiation offer/response.
*
* @param {Array} configurations The extension negotiation offers/reponse
* @return {Object} Accepted configuration
* @public
*/
accept(configurations) {
configurations = this.normalizeParams(configurations);
this.params = this._isServer
? this.acceptAsServer(configurations)
: this.acceptAsClient(configurations);
return this.params;
}
/**
* Releases all resources used by the extension.
*
* @public
*/
cleanup() {
if (this._inflate) {
this._inflate.close();
this._inflate = null;
}
if (this._deflate) {
const callback = this._deflate[kCallback];
this._deflate.close();
this._deflate = null;
if (callback) {
callback(
new Error(
'The deflate stream was closed while data was being processed'
)
);
}
}
}
/**
* Accept an extension negotiation offer.
*
* @param {Array} offers The extension negotiation offers
* @return {Object} Accepted configuration
* @private
*/
acceptAsServer(offers) {
const opts = this._options;
const accepted = offers.find((params) => {
if (
(opts.serverNoContextTakeover === false &&
params.server_no_context_takeover) ||
(params.server_max_window_bits &&
(opts.serverMaxWindowBits === false ||
(typeof opts.serverMaxWindowBits === 'number' &&
opts.serverMaxWindowBits > params.server_max_window_bits))) ||
(typeof opts.clientMaxWindowBits === 'number' &&
!params.client_max_window_bits)
) {
return false;
}
return true;
});
if (!accepted) {
throw new Error('None of the extension offers can be accepted');
}
if (opts.serverNoContextTakeover) {
accepted.server_no_context_takeover = true;
}
if (opts.clientNoContextTakeover) {
accepted.client_no_context_takeover = true;
}
if (typeof opts.serverMaxWindowBits === 'number') {
accepted.server_max_window_bits = opts.serverMaxWindowBits;
}
if (typeof opts.clientMaxWindowBits === 'number') {
accepted.client_max_window_bits = opts.clientMaxWindowBits;
} else if (
accepted.client_max_window_bits === true ||
opts.clientMaxWindowBits === false
) {
delete accepted.client_max_window_bits;
}
return accepted;
}
/**
* Accept the extension negotiation response.
*
* @param {Array} response The extension negotiation response
* @return {Object} Accepted configuration
* @private
*/
acceptAsClient(response) {
const params = response[0];
if (
this._options.clientNoContextTakeover === false &&
params.client_no_context_takeover
) {
throw new Error('Unexpected parameter "client_no_context_takeover"');
}
if (!params.client_max_window_bits) {
if (typeof this._options.clientMaxWindowBits === 'number') {
params.client_max_window_bits = this._options.clientMaxWindowBits;
}
} else if (
this._options.clientMaxWindowBits === false ||
(typeof this._options.clientMaxWindowBits === 'number' &&
params.client_max_window_bits > this._options.clientMaxWindowBits)
) {
throw new Error(
'Unexpected or invalid parameter "client_max_window_bits"'
);
}
return params;
}
/**
* Normalize parameters.
*
* @param {Array} configurations The extension negotiation offers/reponse
* @return {Array} The offers/response with normalized parameters
* @private
*/
normalizeParams(configurations) {
configurations.forEach((params) => {
Object.keys(params).forEach((key) => {
let value = params[key];
if (value.length > 1) {
throw new Error(`Parameter "${key}" must have only a single value`);
}
value = value[0];
if (key === 'client_max_window_bits') {
if (value !== true) {
const num = +value;
if (!Number.isInteger(num) || num < 8 || num > 15) {
throw new TypeError(
`Invalid value for parameter "${key}": ${value}`
);
}
value = num;
} else if (!this._isServer) {
throw new TypeError(
`Invalid value for parameter "${key}": ${value}`
);
}
} else if (key === 'server_max_window_bits') {
const num = +value;
if (!Number.isInteger(num) || num < 8 || num > 15) {
throw new TypeError(
`Invalid value for parameter "${key}": ${value}`
);
}
value = num;
} else if (
key === 'client_no_context_takeover' ||
key === 'server_no_context_takeover'
) {
if (value !== true) {
throw new TypeError(
`Invalid value for parameter "${key}": ${value}`
);
}
} else {
throw new Error(`Unknown parameter "${key}"`);
}
params[key] = value;
});
});
return configurations;
}
/**
* Decompress data. Concurrency limited.
*
* @param {Buffer} data Compressed data
* @param {Boolean} fin Specifies whether or not this is the last fragment
* @param {Function} callback Callback
* @public
*/
decompress(data, fin, callback) {
zlibLimiter.add((done) => {
this._decompress(data, fin, (err, result) => {
done();
callback(err, result);
});
});
}
/**
* Compress data. Concurrency limited.
*
* @param {(Buffer|String)} data Data to compress
* @param {Boolean} fin Specifies whether or not this is the last fragment
* @param {Function} callback Callback
* @public
*/
compress(data, fin, callback) {
zlibLimiter.add((done) => {
this._compress(data, fin, (err, result) => {
done();
callback(err, result);
});
});
}
/**
* Decompress data.
*
* @param {Buffer} data Compressed data
* @param {Boolean} fin Specifies whether or not this is the last fragment
* @param {Function} callback Callback
* @private
*/
_decompress(data, fin, callback) {
const endpoint = this._isServer ? 'client' : 'server';
if (!this._inflate) {
const key = `${endpoint}_max_window_bits`;
const windowBits =
typeof this.params[key] !== 'number'
? zlib.Z_DEFAULT_WINDOWBITS
: this.params[key];
this._inflate = zlib.createInflateRaw({
...this._options.zlibInflateOptions,
windowBits
});
this._inflate[kPerMessageDeflate] = this;
this._inflate[kTotalLength] = 0;
this._inflate[kBuffers] = [];
this._inflate.on('error', inflateOnError);
this._inflate.on('data', inflateOnData);
}
this._inflate[kCallback] = callback;
this._inflate.write(data);
if (fin) this._inflate.write(TRAILER);
this._inflate.flush(() => {
const err = this._inflate[kError];
if (err) {
this._inflate.close();
this._inflate = null;
callback(err);
return;
}
const data = bufferUtil.concat(
this._inflate[kBuffers],
this._inflate[kTotalLength]
);
if (this._inflate._readableState.endEmitted) {
this._inflate.close();
this._inflate = null;
} else {
this._inflate[kTotalLength] = 0;
this._inflate[kBuffers] = [];
if (fin && this.params[`${endpoint}_no_context_takeover`]) {
this._inflate.reset();
}
}
callback(null, data);
});
}
/**
* Compress data.
*
* @param {(Buffer|String)} data Data to compress
* @param {Boolean} fin Specifies whether or not this is the last fragment
* @param {Function} callback Callback
* @private
*/
_compress(data, fin, callback) {
const endpoint = this._isServer ? 'server' : 'client';
if (!this._deflate) {
const key = `${endpoint}_max_window_bits`;
const windowBits =
typeof this.params[key] !== 'number'
? zlib.Z_DEFAULT_WINDOWBITS
: this.params[key];
this._deflate = zlib.createDeflateRaw({
...this._options.zlibDeflateOptions,
windowBits
});
this._deflate[kTotalLength] = 0;
this._deflate[kBuffers] = [];
this._deflate.on('data', deflateOnData);
}
this._deflate[kCallback] = callback;
this._deflate.write(data);
this._deflate.flush(zlib.Z_SYNC_FLUSH, () => {
if (!this._deflate) {
//
// The deflate stream was closed while data was being processed.
//
return;
}
let data = bufferUtil.concat(
this._deflate[kBuffers],
this._deflate[kTotalLength]
);
if (fin) {
data = new FastBuffer(data.buffer, data.byteOffset, data.length - 4);
}
//
// Ensure that the callback will not be called again in
// `PerMessageDeflate#cleanup()`.
//
this._deflate[kCallback] = null;
this._deflate[kTotalLength] = 0;
this._deflate[kBuffers] = [];
if (fin && this.params[`${endpoint}_no_context_takeover`]) {
this._deflate.reset();
}
callback(null, data);
});
}
}
module.exports = PerMessageDeflate;
/**
* The listener of the `zlib.DeflateRaw` stream `'data'` event.
*
* @param {Buffer} chunk A chunk of data
* @private
*/
function deflateOnData(chunk) {
this[kBuffers].push(chunk);
this[kTotalLength] += chunk.length;
}
/**
* The listener of the `zlib.InflateRaw` stream `'data'` event.
*
* @param {Buffer} chunk A chunk of data
* @private
*/
function inflateOnData(chunk) {
this[kTotalLength] += chunk.length;
if (
this[kPerMessageDeflate]._maxPayload < 1 ||
this[kTotalLength] <= this[kPerMessageDeflate]._maxPayload
) {
this[kBuffers].push(chunk);
return;
}
this[kError] = new RangeError('Max payload size exceeded');
this[kError].code = 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH';
this[kError][kStatusCode] = 1009;
this.removeListener('data', inflateOnData);
//
// The choice to employ `zlib.reset()` over `zlib.close()` is dictated by the
// fact that in Node.js versions prior to 13.10.0, the callback for
// `zlib.flush()` is not called if `zlib.close()` is used. Utilizing
// `zlib.reset()` ensures that either the callback is invoked or an error is
// emitted.
//
this.reset();
}
/**
* The listener of the `zlib.InflateRaw` stream `'error'` event.
*
* @param {Error} err The emitted error
* @private
*/
function inflateOnError(err) {
//
// There is no need to call `Zlib#close()` as the handle is automatically
// closed when an error is emitted.
//
this[kPerMessageDeflate]._inflate = null;
if (this[kError]) {
this[kCallback](this[kError]);
return;
}
err[kStatusCode] = 1007;
this[kCallback](err);
}

View File

@ -1,706 +0,0 @@
'use strict';
const { Writable } = require('stream');
const PerMessageDeflate = require('./permessage-deflate');
const {
BINARY_TYPES,
EMPTY_BUFFER,
kStatusCode,
kWebSocket
} = require('./constants');
const { concat, toArrayBuffer, unmask } = require('./buffer-util');
const { isValidStatusCode, isValidUTF8 } = require('./validation');
const FastBuffer = Buffer[Symbol.species];
const GET_INFO = 0;
const GET_PAYLOAD_LENGTH_16 = 1;
const GET_PAYLOAD_LENGTH_64 = 2;
const GET_MASK = 3;
const GET_DATA = 4;
const INFLATING = 5;
const DEFER_EVENT = 6;
/**
* HyBi Receiver implementation.
*
* @extends Writable
*/
class Receiver extends Writable {
/**
* Creates a Receiver instance.
*
* @param {Object} [options] Options object
* @param {Boolean} [options.allowSynchronousEvents=true] Specifies whether
* any of the `'message'`, `'ping'`, and `'pong'` events can be emitted
* multiple times in the same tick
* @param {String} [options.binaryType=nodebuffer] The type for binary data
* @param {Object} [options.extensions] An object containing the negotiated
* extensions
* @param {Boolean} [options.isServer=false] Specifies whether to operate in
* client or server mode
* @param {Number} [options.maxPayload=0] The maximum allowed message length
* @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or
* not to skip UTF-8 validation for text and close messages
*/
constructor(options = {}) {
super();
this._allowSynchronousEvents =
options.allowSynchronousEvents !== undefined
? options.allowSynchronousEvents
: true;
this._binaryType = options.binaryType || BINARY_TYPES[0];
this._extensions = options.extensions || {};
this._isServer = !!options.isServer;
this._maxPayload = options.maxPayload | 0;
this._skipUTF8Validation = !!options.skipUTF8Validation;
this[kWebSocket] = undefined;
this._bufferedBytes = 0;
this._buffers = [];
this._compressed = false;
this._payloadLength = 0;
this._mask = undefined;
this._fragmented = 0;
this._masked = false;
this._fin = false;
this._opcode = 0;
this._totalPayloadLength = 0;
this._messageLength = 0;
this._fragments = [];
this._errored = false;
this._loop = false;
this._state = GET_INFO;
}
/**
* Implements `Writable.prototype._write()`.
*
* @param {Buffer} chunk The chunk of data to write
* @param {String} encoding The character encoding of `chunk`
* @param {Function} cb Callback
* @private
*/
_write(chunk, encoding, cb) {
if (this._opcode === 0x08 && this._state == GET_INFO) return cb();
this._bufferedBytes += chunk.length;
this._buffers.push(chunk);
this.startLoop(cb);
}
/**
* Consumes `n` bytes from the buffered data.
*
* @param {Number} n The number of bytes to consume
* @return {Buffer} The consumed bytes
* @private
*/
consume(n) {
this._bufferedBytes -= n;
if (n === this._buffers[0].length) return this._buffers.shift();
if (n < this._buffers[0].length) {
const buf = this._buffers[0];
this._buffers[0] = new FastBuffer(
buf.buffer,
buf.byteOffset + n,
buf.length - n
);
return new FastBuffer(buf.buffer, buf.byteOffset, n);
}
const dst = Buffer.allocUnsafe(n);
do {
const buf = this._buffers[0];
const offset = dst.length - n;
if (n >= buf.length) {
dst.set(this._buffers.shift(), offset);
} else {
dst.set(new Uint8Array(buf.buffer, buf.byteOffset, n), offset);
this._buffers[0] = new FastBuffer(
buf.buffer,
buf.byteOffset + n,
buf.length - n
);
}
n -= buf.length;
} while (n > 0);
return dst;
}
/**
* Starts the parsing loop.
*
* @param {Function} cb Callback
* @private
*/
startLoop(cb) {
this._loop = true;
do {
switch (this._state) {
case GET_INFO:
this.getInfo(cb);
break;
case GET_PAYLOAD_LENGTH_16:
this.getPayloadLength16(cb);
break;
case GET_PAYLOAD_LENGTH_64:
this.getPayloadLength64(cb);
break;
case GET_MASK:
this.getMask();
break;
case GET_DATA:
this.getData(cb);
break;
case INFLATING:
case DEFER_EVENT:
this._loop = false;
return;
}
} while (this._loop);
if (!this._errored) cb();
}
/**
* Reads the first two bytes of a frame.
*
* @param {Function} cb Callback
* @private
*/
getInfo(cb) {
if (this._bufferedBytes < 2) {
this._loop = false;
return;
}
const buf = this.consume(2);
if ((buf[0] & 0x30) !== 0x00) {
const error = this.createError(
RangeError,
'RSV2 and RSV3 must be clear',
true,
1002,
'WS_ERR_UNEXPECTED_RSV_2_3'
);
cb(error);
return;
}
const compressed = (buf[0] & 0x40) === 0x40;
if (compressed && !this._extensions[PerMessageDeflate.extensionName]) {
const error = this.createError(
RangeError,
'RSV1 must be clear',
true,
1002,
'WS_ERR_UNEXPECTED_RSV_1'
);
cb(error);
return;
}
this._fin = (buf[0] & 0x80) === 0x80;
this._opcode = buf[0] & 0x0f;
this._payloadLength = buf[1] & 0x7f;
if (this._opcode === 0x00) {
if (compressed) {
const error = this.createError(
RangeError,
'RSV1 must be clear',
true,
1002,
'WS_ERR_UNEXPECTED_RSV_1'
);
cb(error);
return;
}
if (!this._fragmented) {
const error = this.createError(
RangeError,
'invalid opcode 0',
true,
1002,
'WS_ERR_INVALID_OPCODE'
);
cb(error);
return;
}
this._opcode = this._fragmented;
} else if (this._opcode === 0x01 || this._opcode === 0x02) {
if (this._fragmented) {
const error = this.createError(
RangeError,
`invalid opcode ${this._opcode}`,
true,
1002,
'WS_ERR_INVALID_OPCODE'
);
cb(error);
return;
}
this._compressed = compressed;
} else if (this._opcode > 0x07 && this._opcode < 0x0b) {
if (!this._fin) {
const error = this.createError(
RangeError,
'FIN must be set',
true,
1002,
'WS_ERR_EXPECTED_FIN'
);
cb(error);
return;
}
if (compressed) {
const error = this.createError(
RangeError,
'RSV1 must be clear',
true,
1002,
'WS_ERR_UNEXPECTED_RSV_1'
);
cb(error);
return;
}
if (
this._payloadLength > 0x7d ||
(this._opcode === 0x08 && this._payloadLength === 1)
) {
const error = this.createError(
RangeError,
`invalid payload length ${this._payloadLength}`,
true,
1002,
'WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH'
);
cb(error);
return;
}
} else {
const error = this.createError(
RangeError,
`invalid opcode ${this._opcode}`,
true,
1002,
'WS_ERR_INVALID_OPCODE'
);
cb(error);
return;
}
if (!this._fin && !this._fragmented) this._fragmented = this._opcode;
this._masked = (buf[1] & 0x80) === 0x80;
if (this._isServer) {
if (!this._masked) {
const error = this.createError(
RangeError,
'MASK must be set',
true,
1002,
'WS_ERR_EXPECTED_MASK'
);
cb(error);
return;
}
} else if (this._masked) {
const error = this.createError(
RangeError,
'MASK must be clear',
true,
1002,
'WS_ERR_UNEXPECTED_MASK'
);
cb(error);
return;
}
if (this._payloadLength === 126) this._state = GET_PAYLOAD_LENGTH_16;
else if (this._payloadLength === 127) this._state = GET_PAYLOAD_LENGTH_64;
else this.haveLength(cb);
}
/**
* Gets extended payload length (7+16).
*
* @param {Function} cb Callback
* @private
*/
getPayloadLength16(cb) {
if (this._bufferedBytes < 2) {
this._loop = false;
return;
}
this._payloadLength = this.consume(2).readUInt16BE(0);
this.haveLength(cb);
}
/**
* Gets extended payload length (7+64).
*
* @param {Function} cb Callback
* @private
*/
getPayloadLength64(cb) {
if (this._bufferedBytes < 8) {
this._loop = false;
return;
}
const buf = this.consume(8);
const num = buf.readUInt32BE(0);
//
// The maximum safe integer in JavaScript is 2^53 - 1. An error is returned
// if payload length is greater than this number.
//
if (num > Math.pow(2, 53 - 32) - 1) {
const error = this.createError(
RangeError,
'Unsupported WebSocket frame: payload length > 2^53 - 1',
false,
1009,
'WS_ERR_UNSUPPORTED_DATA_PAYLOAD_LENGTH'
);
cb(error);
return;
}
this._payloadLength = num * Math.pow(2, 32) + buf.readUInt32BE(4);
this.haveLength(cb);
}
/**
* Payload length has been read.
*
* @param {Function} cb Callback
* @private
*/
haveLength(cb) {
if (this._payloadLength && this._opcode < 0x08) {
this._totalPayloadLength += this._payloadLength;
if (this._totalPayloadLength > this._maxPayload && this._maxPayload > 0) {
const error = this.createError(
RangeError,
'Max payload size exceeded',
false,
1009,
'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'
);
cb(error);
return;
}
}
if (this._masked) this._state = GET_MASK;
else this._state = GET_DATA;
}
/**
* Reads mask bytes.
*
* @private
*/
getMask() {
if (this._bufferedBytes < 4) {
this._loop = false;
return;
}
this._mask = this.consume(4);
this._state = GET_DATA;
}
/**
* Reads data bytes.
*
* @param {Function} cb Callback
* @private
*/
getData(cb) {
let data = EMPTY_BUFFER;
if (this._payloadLength) {
if (this._bufferedBytes < this._payloadLength) {
this._loop = false;
return;
}
data = this.consume(this._payloadLength);
if (
this._masked &&
(this._mask[0] | this._mask[1] | this._mask[2] | this._mask[3]) !== 0
) {
unmask(data, this._mask);
}
}
if (this._opcode > 0x07) {
this.controlMessage(data, cb);
return;
}
if (this._compressed) {
this._state = INFLATING;
this.decompress(data, cb);
return;
}
if (data.length) {
//
// This message is not compressed so its length is the sum of the payload
// length of all fragments.
//
this._messageLength = this._totalPayloadLength;
this._fragments.push(data);
}
this.dataMessage(cb);
}
/**
* Decompresses data.
*
* @param {Buffer} data Compressed data
* @param {Function} cb Callback
* @private
*/
decompress(data, cb) {
const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName];
perMessageDeflate.decompress(data, this._fin, (err, buf) => {
if (err) return cb(err);
if (buf.length) {
this._messageLength += buf.length;
if (this._messageLength > this._maxPayload && this._maxPayload > 0) {
const error = this.createError(
RangeError,
'Max payload size exceeded',
false,
1009,
'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'
);
cb(error);
return;
}
this._fragments.push(buf);
}
this.dataMessage(cb);
if (this._state === GET_INFO) this.startLoop(cb);
});
}
/**
* Handles a data message.
*
* @param {Function} cb Callback
* @private
*/
dataMessage(cb) {
if (!this._fin) {
this._state = GET_INFO;
return;
}
const messageLength = this._messageLength;
const fragments = this._fragments;
this._totalPayloadLength = 0;
this._messageLength = 0;
this._fragmented = 0;
this._fragments = [];
if (this._opcode === 2) {
let data;
if (this._binaryType === 'nodebuffer') {
data = concat(fragments, messageLength);
} else if (this._binaryType === 'arraybuffer') {
data = toArrayBuffer(concat(fragments, messageLength));
} else if (this._binaryType === 'blob') {
data = new Blob(fragments);
} else {
data = fragments;
}
if (this._allowSynchronousEvents) {
this.emit('message', data, true);
this._state = GET_INFO;
} else {
this._state = DEFER_EVENT;
setImmediate(() => {
this.emit('message', data, true);
this._state = GET_INFO;
this.startLoop(cb);
});
}
} else {
const buf = concat(fragments, messageLength);
if (!this._skipUTF8Validation && !isValidUTF8(buf)) {
const error = this.createError(
Error,
'invalid UTF-8 sequence',
true,
1007,
'WS_ERR_INVALID_UTF8'
);
cb(error);
return;
}
if (this._state === INFLATING || this._allowSynchronousEvents) {
this.emit('message', buf, false);
this._state = GET_INFO;
} else {
this._state = DEFER_EVENT;
setImmediate(() => {
this.emit('message', buf, false);
this._state = GET_INFO;
this.startLoop(cb);
});
}
}
}
/**
* Handles a control message.
*
* @param {Buffer} data Data to handle
* @return {(Error|RangeError|undefined)} A possible error
* @private
*/
controlMessage(data, cb) {
if (this._opcode === 0x08) {
if (data.length === 0) {
this._loop = false;
this.emit('conclude', 1005, EMPTY_BUFFER);
this.end();
} else {
const code = data.readUInt16BE(0);
if (!isValidStatusCode(code)) {
const error = this.createError(
RangeError,
`invalid status code ${code}`,
true,
1002,
'WS_ERR_INVALID_CLOSE_CODE'
);
cb(error);
return;
}
const buf = new FastBuffer(
data.buffer,
data.byteOffset + 2,
data.length - 2
);
if (!this._skipUTF8Validation && !isValidUTF8(buf)) {
const error = this.createError(
Error,
'invalid UTF-8 sequence',
true,
1007,
'WS_ERR_INVALID_UTF8'
);
cb(error);
return;
}
this._loop = false;
this.emit('conclude', code, buf);
this.end();
}
this._state = GET_INFO;
return;
}
if (this._allowSynchronousEvents) {
this.emit(this._opcode === 0x09 ? 'ping' : 'pong', data);
this._state = GET_INFO;
} else {
this._state = DEFER_EVENT;
setImmediate(() => {
this.emit(this._opcode === 0x09 ? 'ping' : 'pong', data);
this._state = GET_INFO;
this.startLoop(cb);
});
}
}
/**
* Builds an error object.
*
* @param {function(new:Error|RangeError)} ErrorCtor The error constructor
* @param {String} message The error message
* @param {Boolean} prefix Specifies whether or not to add a default prefix to
* `message`
* @param {Number} statusCode The status code
* @param {String} errorCode The exposed error code
* @return {(Error|RangeError)} The error
* @private
*/
createError(ErrorCtor, message, prefix, statusCode, errorCode) {
this._loop = false;
this._errored = true;
const err = new ErrorCtor(
prefix ? `Invalid WebSocket frame: ${message}` : message
);
Error.captureStackTrace(err, this.createError);
err.code = errorCode;
err[kStatusCode] = statusCode;
return err;
}
}
module.exports = Receiver;

View File

@ -1,602 +0,0 @@
/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex" }] */
'use strict';
const { Duplex } = require('stream');
const { randomFillSync } = require('crypto');
const PerMessageDeflate = require('./permessage-deflate');
const { EMPTY_BUFFER, kWebSocket, NOOP } = require('./constants');
const { isBlob, isValidStatusCode } = require('./validation');
const { mask: applyMask, toBuffer } = require('./buffer-util');
const kByteLength = Symbol('kByteLength');
const maskBuffer = Buffer.alloc(4);
const RANDOM_POOL_SIZE = 8 * 1024;
let randomPool;
let randomPoolPointer = RANDOM_POOL_SIZE;
const DEFAULT = 0;
const DEFLATING = 1;
const GET_BLOB_DATA = 2;
/**
* HyBi Sender implementation.
*/
class Sender {
/**
* Creates a Sender instance.
*
* @param {Duplex} socket The connection socket
* @param {Object} [extensions] An object containing the negotiated extensions
* @param {Function} [generateMask] The function used to generate the masking
* key
*/
constructor(socket, extensions, generateMask) {
this._extensions = extensions || {};
if (generateMask) {
this._generateMask = generateMask;
this._maskBuffer = Buffer.alloc(4);
}
this._socket = socket;
this._firstFragment = true;
this._compress = false;
this._bufferedBytes = 0;
this._queue = [];
this._state = DEFAULT;
this.onerror = NOOP;
this[kWebSocket] = undefined;
}
/**
* Frames a piece of data according to the HyBi WebSocket protocol.
*
* @param {(Buffer|String)} data The data to frame
* @param {Object} options Options object
* @param {Boolean} [options.fin=false] Specifies whether or not to set the
* FIN bit
* @param {Function} [options.generateMask] The function used to generate the
* masking key
* @param {Boolean} [options.mask=false] Specifies whether or not to mask
* `data`
* @param {Buffer} [options.maskBuffer] The buffer used to store the masking
* key
* @param {Number} options.opcode The opcode
* @param {Boolean} [options.readOnly=false] Specifies whether `data` can be
* modified
* @param {Boolean} [options.rsv1=false] Specifies whether or not to set the
* RSV1 bit
* @return {(Buffer|String)[]} The framed data
* @public
*/
static frame(data, options) {
let mask;
let merge = false;
let offset = 2;
let skipMasking = false;
if (options.mask) {
mask = options.maskBuffer || maskBuffer;
if (options.generateMask) {
options.generateMask(mask);
} else {
if (randomPoolPointer === RANDOM_POOL_SIZE) {
/* istanbul ignore else */
if (randomPool === undefined) {
//
// This is lazily initialized because server-sent frames must not
// be masked so it may never be used.
//
randomPool = Buffer.alloc(RANDOM_POOL_SIZE);
}
randomFillSync(randomPool, 0, RANDOM_POOL_SIZE);
randomPoolPointer = 0;
}
mask[0] = randomPool[randomPoolPointer++];
mask[1] = randomPool[randomPoolPointer++];
mask[2] = randomPool[randomPoolPointer++];
mask[3] = randomPool[randomPoolPointer++];
}
skipMasking = (mask[0] | mask[1] | mask[2] | mask[3]) === 0;
offset = 6;
}
let dataLength;
if (typeof data === 'string') {
if (
(!options.mask || skipMasking) &&
options[kByteLength] !== undefined
) {
dataLength = options[kByteLength];
} else {
data = Buffer.from(data);
dataLength = data.length;
}
} else {
dataLength = data.length;
merge = options.mask && options.readOnly && !skipMasking;
}
let payloadLength = dataLength;
if (dataLength >= 65536) {
offset += 8;
payloadLength = 127;
} else if (dataLength > 125) {
offset += 2;
payloadLength = 126;
}
const target = Buffer.allocUnsafe(merge ? dataLength + offset : offset);
target[0] = options.fin ? options.opcode | 0x80 : options.opcode;
if (options.rsv1) target[0] |= 0x40;
target[1] = payloadLength;
if (payloadLength === 126) {
target.writeUInt16BE(dataLength, 2);
} else if (payloadLength === 127) {
target[2] = target[3] = 0;
target.writeUIntBE(dataLength, 4, 6);
}
if (!options.mask) return [target, data];
target[1] |= 0x80;
target[offset - 4] = mask[0];
target[offset - 3] = mask[1];
target[offset - 2] = mask[2];
target[offset - 1] = mask[3];
if (skipMasking) return [target, data];
if (merge) {
applyMask(data, mask, target, offset, dataLength);
return [target];
}
applyMask(data, mask, data, 0, dataLength);
return [target, data];
}
/**
* Sends a close message to the other peer.
*
* @param {Number} [code] The status code component of the body
* @param {(String|Buffer)} [data] The message component of the body
* @param {Boolean} [mask=false] Specifies whether or not to mask the message
* @param {Function} [cb] Callback
* @public
*/
close(code, data, mask, cb) {
let buf;
if (code === undefined) {
buf = EMPTY_BUFFER;
} else if (typeof code !== 'number' || !isValidStatusCode(code)) {
throw new TypeError('First argument must be a valid error code number');
} else if (data === undefined || !data.length) {
buf = Buffer.allocUnsafe(2);
buf.writeUInt16BE(code, 0);
} else {
const length = Buffer.byteLength(data);
if (length > 123) {
throw new RangeError('The message must not be greater than 123 bytes');
}
buf = Buffer.allocUnsafe(2 + length);
buf.writeUInt16BE(code, 0);
if (typeof data === 'string') {
buf.write(data, 2);
} else {
buf.set(data, 2);
}
}
const options = {
[kByteLength]: buf.length,
fin: true,
generateMask: this._generateMask,
mask,
maskBuffer: this._maskBuffer,
opcode: 0x08,
readOnly: false,
rsv1: false
};
if (this._state !== DEFAULT) {
this.enqueue([this.dispatch, buf, false, options, cb]);
} else {
this.sendFrame(Sender.frame(buf, options), cb);
}
}
/**
* Sends a ping message to the other peer.
*
* @param {*} data The message to send
* @param {Boolean} [mask=false] Specifies whether or not to mask `data`
* @param {Function} [cb] Callback
* @public
*/
ping(data, mask, cb) {
let byteLength;
let readOnly;
if (typeof data === 'string') {
byteLength = Buffer.byteLength(data);
readOnly = false;
} else if (isBlob(data)) {
byteLength = data.size;
readOnly = false;
} else {
data = toBuffer(data);
byteLength = data.length;
readOnly = toBuffer.readOnly;
}
if (byteLength > 125) {
throw new RangeError('The data size must not be greater than 125 bytes');
}
const options = {
[kByteLength]: byteLength,
fin: true,
generateMask: this._generateMask,
mask,
maskBuffer: this._maskBuffer,
opcode: 0x09,
readOnly,
rsv1: false
};
if (isBlob(data)) {
if (this._state !== DEFAULT) {
this.enqueue([this.getBlobData, data, false, options, cb]);
} else {
this.getBlobData(data, false, options, cb);
}
} else if (this._state !== DEFAULT) {
this.enqueue([this.dispatch, data, false, options, cb]);
} else {
this.sendFrame(Sender.frame(data, options), cb);
}
}
/**
* Sends a pong message to the other peer.
*
* @param {*} data The message to send
* @param {Boolean} [mask=false] Specifies whether or not to mask `data`
* @param {Function} [cb] Callback
* @public
*/
pong(data, mask, cb) {
let byteLength;
let readOnly;
if (typeof data === 'string') {
byteLength = Buffer.byteLength(data);
readOnly = false;
} else if (isBlob(data)) {
byteLength = data.size;
readOnly = false;
} else {
data = toBuffer(data);
byteLength = data.length;
readOnly = toBuffer.readOnly;
}
if (byteLength > 125) {
throw new RangeError('The data size must not be greater than 125 bytes');
}
const options = {
[kByteLength]: byteLength,
fin: true,
generateMask: this._generateMask,
mask,
maskBuffer: this._maskBuffer,
opcode: 0x0a,
readOnly,
rsv1: false
};
if (isBlob(data)) {
if (this._state !== DEFAULT) {
this.enqueue([this.getBlobData, data, false, options, cb]);
} else {
this.getBlobData(data, false, options, cb);
}
} else if (this._state !== DEFAULT) {
this.enqueue([this.dispatch, data, false, options, cb]);
} else {
this.sendFrame(Sender.frame(data, options), cb);
}
}
/**
* Sends a data message to the other peer.
*
* @param {*} data The message to send
* @param {Object} options Options object
* @param {Boolean} [options.binary=false] Specifies whether `data` is binary
* or text
* @param {Boolean} [options.compress=false] Specifies whether or not to
* compress `data`
* @param {Boolean} [options.fin=false] Specifies whether the fragment is the
* last one
* @param {Boolean} [options.mask=false] Specifies whether or not to mask
* `data`
* @param {Function} [cb] Callback
* @public
*/
send(data, options, cb) {
const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName];
let opcode = options.binary ? 2 : 1;
let rsv1 = options.compress;
let byteLength;
let readOnly;
if (typeof data === 'string') {
byteLength = Buffer.byteLength(data);
readOnly = false;
} else if (isBlob(data)) {
byteLength = data.size;
readOnly = false;
} else {
data = toBuffer(data);
byteLength = data.length;
readOnly = toBuffer.readOnly;
}
if (this._firstFragment) {
this._firstFragment = false;
if (
rsv1 &&
perMessageDeflate &&
perMessageDeflate.params[
perMessageDeflate._isServer
? 'server_no_context_takeover'
: 'client_no_context_takeover'
]
) {
rsv1 = byteLength >= perMessageDeflate._threshold;
}
this._compress = rsv1;
} else {
rsv1 = false;
opcode = 0;
}
if (options.fin) this._firstFragment = true;
const opts = {
[kByteLength]: byteLength,
fin: options.fin,
generateMask: this._generateMask,
mask: options.mask,
maskBuffer: this._maskBuffer,
opcode,
readOnly,
rsv1
};
if (isBlob(data)) {
if (this._state !== DEFAULT) {
this.enqueue([this.getBlobData, data, this._compress, opts, cb]);
} else {
this.getBlobData(data, this._compress, opts, cb);
}
} else if (this._state !== DEFAULT) {
this.enqueue([this.dispatch, data, this._compress, opts, cb]);
} else {
this.dispatch(data, this._compress, opts, cb);
}
}
/**
* Gets the contents of a blob as binary data.
*
* @param {Blob} blob The blob
* @param {Boolean} [compress=false] Specifies whether or not to compress
* the data
* @param {Object} options Options object
* @param {Boolean} [options.fin=false] Specifies whether or not to set the
* FIN bit
* @param {Function} [options.generateMask] The function used to generate the
* masking key
* @param {Boolean} [options.mask=false] Specifies whether or not to mask
* `data`
* @param {Buffer} [options.maskBuffer] The buffer used to store the masking
* key
* @param {Number} options.opcode The opcode
* @param {Boolean} [options.readOnly=false] Specifies whether `data` can be
* modified
* @param {Boolean} [options.rsv1=false] Specifies whether or not to set the
* RSV1 bit
* @param {Function} [cb] Callback
* @private
*/
getBlobData(blob, compress, options, cb) {
this._bufferedBytes += options[kByteLength];
this._state = GET_BLOB_DATA;
blob
.arrayBuffer()
.then((arrayBuffer) => {
if (this._socket.destroyed) {
const err = new Error(
'The socket was closed while the blob was being read'
);
//
// `callCallbacks` is called in the next tick to ensure that errors
// that might be thrown in the callbacks behave like errors thrown
// outside the promise chain.
//
process.nextTick(callCallbacks, this, err, cb);
return;
}
this._bufferedBytes -= options[kByteLength];
const data = toBuffer(arrayBuffer);
if (!compress) {
this._state = DEFAULT;
this.sendFrame(Sender.frame(data, options), cb);
this.dequeue();
} else {
this.dispatch(data, compress, options, cb);
}
})
.catch((err) => {
//
// `onError` is called in the next tick for the same reason that
// `callCallbacks` above is.
//
process.nextTick(onError, this, err, cb);
});
}
/**
* Dispatches a message.
*
* @param {(Buffer|String)} data The message to send
* @param {Boolean} [compress=false] Specifies whether or not to compress
* `data`
* @param {Object} options Options object
* @param {Boolean} [options.fin=false] Specifies whether or not to set the
* FIN bit
* @param {Function} [options.generateMask] The function used to generate the
* masking key
* @param {Boolean} [options.mask=false] Specifies whether or not to mask
* `data`
* @param {Buffer} [options.maskBuffer] The buffer used to store the masking
* key
* @param {Number} options.opcode The opcode
* @param {Boolean} [options.readOnly=false] Specifies whether `data` can be
* modified
* @param {Boolean} [options.rsv1=false] Specifies whether or not to set the
* RSV1 bit
* @param {Function} [cb] Callback
* @private
*/
dispatch(data, compress, options, cb) {
if (!compress) {
this.sendFrame(Sender.frame(data, options), cb);
return;
}
const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName];
this._bufferedBytes += options[kByteLength];
this._state = DEFLATING;
perMessageDeflate.compress(data, options.fin, (_, buf) => {
if (this._socket.destroyed) {
const err = new Error(
'The socket was closed while data was being compressed'
);
callCallbacks(this, err, cb);
return;
}
this._bufferedBytes -= options[kByteLength];
this._state = DEFAULT;
options.readOnly = false;
this.sendFrame(Sender.frame(buf, options), cb);
this.dequeue();
});
}
/**
* Executes queued send operations.
*
* @private
*/
dequeue() {
while (this._state === DEFAULT && this._queue.length) {
const params = this._queue.shift();
this._bufferedBytes -= params[3][kByteLength];
Reflect.apply(params[0], this, params.slice(1));
}
}
/**
* Enqueues a send operation.
*
* @param {Array} params Send operation parameters.
* @private
*/
enqueue(params) {
this._bufferedBytes += params[3][kByteLength];
this._queue.push(params);
}
/**
* Sends a frame.
*
* @param {(Buffer | String)[]} list The frame to send
* @param {Function} [cb] Callback
* @private
*/
sendFrame(list, cb) {
if (list.length === 2) {
this._socket.cork();
this._socket.write(list[0]);
this._socket.write(list[1], cb);
this._socket.uncork();
} else {
this._socket.write(list[0], cb);
}
}
}
module.exports = Sender;
/**
* Calls queued callbacks with an error.
*
* @param {Sender} sender The `Sender` instance
* @param {Error} err The error to call the callbacks with
* @param {Function} [cb] The first callback
* @private
*/
function callCallbacks(sender, err, cb) {
if (typeof cb === 'function') cb(err);
for (let i = 0; i < sender._queue.length; i++) {
const params = sender._queue[i];
const callback = params[params.length - 1];
if (typeof callback === 'function') callback(err);
}
}
/**
* Handles a `Sender` error.
*
* @param {Sender} sender The `Sender` instance
* @param {Error} err The error
* @param {Function} [cb] The first pending callback
* @private
*/
function onError(sender, err, cb) {
callCallbacks(sender, err, cb);
sender.onerror(err);
}

View File

@ -1,161 +0,0 @@
/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^WebSocket$" }] */
'use strict';
const WebSocket = require('./websocket');
const { Duplex } = require('stream');
/**
* Emits the `'close'` event on a stream.
*
* @param {Duplex} stream The stream.
* @private
*/
function emitClose(stream) {
stream.emit('close');
}
/**
* The listener of the `'end'` event.
*
* @private
*/
function duplexOnEnd() {
if (!this.destroyed && this._writableState.finished) {
this.destroy();
}
}
/**
* The listener of the `'error'` event.
*
* @param {Error} err The error
* @private
*/
function duplexOnError(err) {
this.removeListener('error', duplexOnError);
this.destroy();
if (this.listenerCount('error') === 0) {
// Do not suppress the throwing behavior.
this.emit('error', err);
}
}
/**
* Wraps a `WebSocket` in a duplex stream.
*
* @param {WebSocket} ws The `WebSocket` to wrap
* @param {Object} [options] The options for the `Duplex` constructor
* @return {Duplex} The duplex stream
* @public
*/
function createWebSocketStream(ws, options) {
let terminateOnDestroy = true;
const duplex = new Duplex({
...options,
autoDestroy: false,
emitClose: false,
objectMode: false,
writableObjectMode: false
});
ws.on('message', function message(msg, isBinary) {
const data =
!isBinary && duplex._readableState.objectMode ? msg.toString() : msg;
if (!duplex.push(data)) ws.pause();
});
ws.once('error', function error(err) {
if (duplex.destroyed) return;
// Prevent `ws.terminate()` from being called by `duplex._destroy()`.
//
// - If the `'error'` event is emitted before the `'open'` event, then
// `ws.terminate()` is a noop as no socket is assigned.
// - Otherwise, the error is re-emitted by the listener of the `'error'`
// event of the `Receiver` object. The listener already closes the
// connection by calling `ws.close()`. This allows a close frame to be
// sent to the other peer. If `ws.terminate()` is called right after this,
// then the close frame might not be sent.
terminateOnDestroy = false;
duplex.destroy(err);
});
ws.once('close', function close() {
if (duplex.destroyed) return;
duplex.push(null);
});
duplex._destroy = function (err, callback) {
if (ws.readyState === ws.CLOSED) {
callback(err);
process.nextTick(emitClose, duplex);
return;
}
let called = false;
ws.once('error', function error(err) {
called = true;
callback(err);
});
ws.once('close', function close() {
if (!called) callback(err);
process.nextTick(emitClose, duplex);
});
if (terminateOnDestroy) ws.terminate();
};
duplex._final = function (callback) {
if (ws.readyState === ws.CONNECTING) {
ws.once('open', function open() {
duplex._final(callback);
});
return;
}
// If the value of the `_socket` property is `null` it means that `ws` is a
// client websocket and the handshake failed. In fact, when this happens, a
// socket is never assigned to the websocket. Wait for the `'error'` event
// that will be emitted by the websocket.
if (ws._socket === null) return;
if (ws._socket._writableState.finished) {
callback();
if (duplex._readableState.endEmitted) duplex.destroy();
} else {
ws._socket.once('finish', function finish() {
// `duplex` is not destroyed here because the `'end'` event will be
// emitted on `duplex` after this `'finish'` event. The EOF signaling
// `null` chunk is, in fact, pushed when the websocket emits `'close'`.
callback();
});
ws.close();
}
};
duplex._read = function () {
if (ws.isPaused) ws.resume();
};
duplex._write = function (chunk, encoding, callback) {
if (ws.readyState === ws.CONNECTING) {
ws.once('open', function open() {
duplex._write(chunk, encoding, callback);
});
return;
}
ws.send(chunk, callback);
};
duplex.on('end', duplexOnEnd);
duplex.on('error', duplexOnError);
return duplex;
}
module.exports = createWebSocketStream;

View File

@ -1,62 +0,0 @@
'use strict';
const { tokenChars } = require('./validation');
/**
* Parses the `Sec-WebSocket-Protocol` header into a set of subprotocol names.
*
* @param {String} header The field value of the header
* @return {Set} The subprotocol names
* @public
*/
function parse(header) {
const protocols = new Set();
let start = -1;
let end = -1;
let i = 0;
for (i; i < header.length; i++) {
const code = header.charCodeAt(i);
if (end === -1 && tokenChars[code] === 1) {
if (start === -1) start = i;
} else if (
i !== 0 &&
(code === 0x20 /* ' ' */ || code === 0x09) /* '\t' */
) {
if (end === -1 && start !== -1) end = i;
} else if (code === 0x2c /* ',' */) {
if (start === -1) {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
if (end === -1) end = i;
const protocol = header.slice(start, end);
if (protocols.has(protocol)) {
throw new SyntaxError(`The "${protocol}" subprotocol is duplicated`);
}
protocols.add(protocol);
start = end = -1;
} else {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
}
if (start === -1 || end !== -1) {
throw new SyntaxError('Unexpected end of input');
}
const protocol = header.slice(start, i);
if (protocols.has(protocol)) {
throw new SyntaxError(`The "${protocol}" subprotocol is duplicated`);
}
protocols.add(protocol);
return protocols;
}
module.exports = { parse };

View File

@ -1,152 +0,0 @@
'use strict';
const { isUtf8 } = require('buffer');
const { hasBlob } = require('./constants');
//
// Allowed token characters:
//
// '!', '#', '$', '%', '&', ''', '*', '+', '-',
// '.', 0-9, A-Z, '^', '_', '`', a-z, '|', '~'
//
// tokenChars[32] === 0 // ' '
// tokenChars[33] === 1 // '!'
// tokenChars[34] === 0 // '"'
// ...
//
// prettier-ignore
const tokenChars = [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0 - 15
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 16 - 31
0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 1, 0, // 32 - 47
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, // 48 - 63
0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 64 - 79
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, // 80 - 95
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 96 - 111
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 0 // 112 - 127
];
/**
* Checks if a status code is allowed in a close frame.
*
* @param {Number} code The status code
* @return {Boolean} `true` if the status code is valid, else `false`
* @public
*/
function isValidStatusCode(code) {
return (
(code >= 1000 &&
code <= 1014 &&
code !== 1004 &&
code !== 1005 &&
code !== 1006) ||
(code >= 3000 && code <= 4999)
);
}
/**
* Checks if a given buffer contains only correct UTF-8.
* Ported from https://www.cl.cam.ac.uk/%7Emgk25/ucs/utf8_check.c by
* Markus Kuhn.
*
* @param {Buffer} buf The buffer to check
* @return {Boolean} `true` if `buf` contains only correct UTF-8, else `false`
* @public
*/
function _isValidUTF8(buf) {
const len = buf.length;
let i = 0;
while (i < len) {
if ((buf[i] & 0x80) === 0) {
// 0xxxxxxx
i++;
} else if ((buf[i] & 0xe0) === 0xc0) {
// 110xxxxx 10xxxxxx
if (
i + 1 === len ||
(buf[i + 1] & 0xc0) !== 0x80 ||
(buf[i] & 0xfe) === 0xc0 // Overlong
) {
return false;
}
i += 2;
} else if ((buf[i] & 0xf0) === 0xe0) {
// 1110xxxx 10xxxxxx 10xxxxxx
if (
i + 2 >= len ||
(buf[i + 1] & 0xc0) !== 0x80 ||
(buf[i + 2] & 0xc0) !== 0x80 ||
(buf[i] === 0xe0 && (buf[i + 1] & 0xe0) === 0x80) || // Overlong
(buf[i] === 0xed && (buf[i + 1] & 0xe0) === 0xa0) // Surrogate (U+D800 - U+DFFF)
) {
return false;
}
i += 3;
} else if ((buf[i] & 0xf8) === 0xf0) {
// 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
if (
i + 3 >= len ||
(buf[i + 1] & 0xc0) !== 0x80 ||
(buf[i + 2] & 0xc0) !== 0x80 ||
(buf[i + 3] & 0xc0) !== 0x80 ||
(buf[i] === 0xf0 && (buf[i + 1] & 0xf0) === 0x80) || // Overlong
(buf[i] === 0xf4 && buf[i + 1] > 0x8f) ||
buf[i] > 0xf4 // > U+10FFFF
) {
return false;
}
i += 4;
} else {
return false;
}
}
return true;
}
/**
* Determines whether a value is a `Blob`.
*
* @param {*} value The value to be tested
* @return {Boolean} `true` if `value` is a `Blob`, else `false`
* @private
*/
function isBlob(value) {
return (
hasBlob &&
typeof value === 'object' &&
typeof value.arrayBuffer === 'function' &&
typeof value.type === 'string' &&
typeof value.stream === 'function' &&
(value[Symbol.toStringTag] === 'Blob' ||
value[Symbol.toStringTag] === 'File')
);
}
module.exports = {
isBlob,
isValidStatusCode,
isValidUTF8: _isValidUTF8,
tokenChars
};
if (isUtf8) {
module.exports.isValidUTF8 = function (buf) {
return buf.length < 24 ? _isValidUTF8(buf) : isUtf8(buf);
};
} /* istanbul ignore else */ else if (!process.env.WS_NO_UTF_8_VALIDATE) {
try {
const isValidUTF8 = require('utf-8-validate');
module.exports.isValidUTF8 = function (buf) {
return buf.length < 32 ? _isValidUTF8(buf) : isValidUTF8(buf);
};
} catch (e) {
// Continue regardless of the error.
}
}

View File

@ -1,550 +0,0 @@
/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex$", "caughtErrors": "none" }] */
'use strict';
const EventEmitter = require('events');
const http = require('http');
const { Duplex } = require('stream');
const { createHash } = require('crypto');
const extension = require('./extension');
const PerMessageDeflate = require('./permessage-deflate');
const subprotocol = require('./subprotocol');
const WebSocket = require('./websocket');
const { GUID, kWebSocket } = require('./constants');
const keyRegex = /^[+/0-9A-Za-z]{22}==$/;
const RUNNING = 0;
const CLOSING = 1;
const CLOSED = 2;
/**
* Class representing a WebSocket server.
*
* @extends EventEmitter
*/
class WebSocketServer extends EventEmitter {
/**
* Create a `WebSocketServer` instance.
*
* @param {Object} options Configuration options
* @param {Boolean} [options.allowSynchronousEvents=true] Specifies whether
* any of the `'message'`, `'ping'`, and `'pong'` events can be emitted
* multiple times in the same tick
* @param {Boolean} [options.autoPong=true] Specifies whether or not to
* automatically send a pong in response to a ping
* @param {Number} [options.backlog=511] The maximum length of the queue of
* pending connections
* @param {Boolean} [options.clientTracking=true] Specifies whether or not to
* track clients
* @param {Function} [options.handleProtocols] A hook to handle protocols
* @param {String} [options.host] The hostname where to bind the server
* @param {Number} [options.maxPayload=104857600] The maximum allowed message
* size
* @param {Boolean} [options.noServer=false] Enable no server mode
* @param {String} [options.path] Accept only connections matching this path
* @param {(Boolean|Object)} [options.perMessageDeflate=false] Enable/disable
* permessage-deflate
* @param {Number} [options.port] The port where to bind the server
* @param {(http.Server|https.Server)} [options.server] A pre-created HTTP/S
* server to use
* @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or
* not to skip UTF-8 validation for text and close messages
* @param {Function} [options.verifyClient] A hook to reject connections
* @param {Function} [options.WebSocket=WebSocket] Specifies the `WebSocket`
* class to use. It must be the `WebSocket` class or class that extends it
* @param {Function} [callback] A listener for the `listening` event
*/
constructor(options, callback) {
super();
options = {
allowSynchronousEvents: true,
autoPong: true,
maxPayload: 100 * 1024 * 1024,
skipUTF8Validation: false,
perMessageDeflate: false,
handleProtocols: null,
clientTracking: true,
verifyClient: null,
noServer: false,
backlog: null, // use default (511 as implemented in net.js)
server: null,
host: null,
path: null,
port: null,
WebSocket,
...options
};
if (
(options.port == null && !options.server && !options.noServer) ||
(options.port != null && (options.server || options.noServer)) ||
(options.server && options.noServer)
) {
throw new TypeError(
'One and only one of the "port", "server", or "noServer" options ' +
'must be specified'
);
}
if (options.port != null) {
this._server = http.createServer((req, res) => {
const body = http.STATUS_CODES[426];
res.writeHead(426, {
'Content-Length': body.length,
'Content-Type': 'text/plain'
});
res.end(body);
});
this._server.listen(
options.port,
options.host,
options.backlog,
callback
);
} else if (options.server) {
this._server = options.server;
}
if (this._server) {
const emitConnection = this.emit.bind(this, 'connection');
this._removeListeners = addListeners(this._server, {
listening: this.emit.bind(this, 'listening'),
error: this.emit.bind(this, 'error'),
upgrade: (req, socket, head) => {
this.handleUpgrade(req, socket, head, emitConnection);
}
});
}
if (options.perMessageDeflate === true) options.perMessageDeflate = {};
if (options.clientTracking) {
this.clients = new Set();
this._shouldEmitClose = false;
}
this.options = options;
this._state = RUNNING;
}
/**
* Returns the bound address, the address family name, and port of the server
* as reported by the operating system if listening on an IP socket.
* If the server is listening on a pipe or UNIX domain socket, the name is
* returned as a string.
*
* @return {(Object|String|null)} The address of the server
* @public
*/
address() {
if (this.options.noServer) {
throw new Error('The server is operating in "noServer" mode');
}
if (!this._server) return null;
return this._server.address();
}
/**
* Stop the server from accepting new connections and emit the `'close'` event
* when all existing connections are closed.
*
* @param {Function} [cb] A one-time listener for the `'close'` event
* @public
*/
close(cb) {
if (this._state === CLOSED) {
if (cb) {
this.once('close', () => {
cb(new Error('The server is not running'));
});
}
process.nextTick(emitClose, this);
return;
}
if (cb) this.once('close', cb);
if (this._state === CLOSING) return;
this._state = CLOSING;
if (this.options.noServer || this.options.server) {
if (this._server) {
this._removeListeners();
this._removeListeners = this._server = null;
}
if (this.clients) {
if (!this.clients.size) {
process.nextTick(emitClose, this);
} else {
this._shouldEmitClose = true;
}
} else {
process.nextTick(emitClose, this);
}
} else {
const server = this._server;
this._removeListeners();
this._removeListeners = this._server = null;
//
// The HTTP/S server was created internally. Close it, and rely on its
// `'close'` event.
//
server.close(() => {
emitClose(this);
});
}
}
/**
* See if a given request should be handled by this server instance.
*
* @param {http.IncomingMessage} req Request object to inspect
* @return {Boolean} `true` if the request is valid, else `false`
* @public
*/
shouldHandle(req) {
if (this.options.path) {
const index = req.url.indexOf('?');
const pathname = index !== -1 ? req.url.slice(0, index) : req.url;
if (pathname !== this.options.path) return false;
}
return true;
}
/**
* Handle a HTTP Upgrade request.
*
* @param {http.IncomingMessage} req The request object
* @param {Duplex} socket The network socket between the server and client
* @param {Buffer} head The first packet of the upgraded stream
* @param {Function} cb Callback
* @public
*/
handleUpgrade(req, socket, head, cb) {
socket.on('error', socketOnError);
const key = req.headers['sec-websocket-key'];
const upgrade = req.headers.upgrade;
const version = +req.headers['sec-websocket-version'];
if (req.method !== 'GET') {
const message = 'Invalid HTTP method';
abortHandshakeOrEmitwsClientError(this, req, socket, 405, message);
return;
}
if (upgrade === undefined || upgrade.toLowerCase() !== 'websocket') {
const message = 'Invalid Upgrade header';
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message);
return;
}
if (key === undefined || !keyRegex.test(key)) {
const message = 'Missing or invalid Sec-WebSocket-Key header';
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message);
return;
}
if (version !== 13 && version !== 8) {
const message = 'Missing or invalid Sec-WebSocket-Version header';
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message, {
'Sec-WebSocket-Version': '13, 8'
});
return;
}
if (!this.shouldHandle(req)) {
abortHandshake(socket, 400);
return;
}
const secWebSocketProtocol = req.headers['sec-websocket-protocol'];
let protocols = new Set();
if (secWebSocketProtocol !== undefined) {
try {
protocols = subprotocol.parse(secWebSocketProtocol);
} catch (err) {
const message = 'Invalid Sec-WebSocket-Protocol header';
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message);
return;
}
}
const secWebSocketExtensions = req.headers['sec-websocket-extensions'];
const extensions = {};
if (
this.options.perMessageDeflate &&
secWebSocketExtensions !== undefined
) {
const perMessageDeflate = new PerMessageDeflate(
this.options.perMessageDeflate,
true,
this.options.maxPayload
);
try {
const offers = extension.parse(secWebSocketExtensions);
if (offers[PerMessageDeflate.extensionName]) {
perMessageDeflate.accept(offers[PerMessageDeflate.extensionName]);
extensions[PerMessageDeflate.extensionName] = perMessageDeflate;
}
} catch (err) {
const message =
'Invalid or unacceptable Sec-WebSocket-Extensions header';
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message);
return;
}
}
//
// Optionally call external client verification handler.
//
if (this.options.verifyClient) {
const info = {
origin:
req.headers[`${version === 8 ? 'sec-websocket-origin' : 'origin'}`],
secure: !!(req.socket.authorized || req.socket.encrypted),
req
};
if (this.options.verifyClient.length === 2) {
this.options.verifyClient(info, (verified, code, message, headers) => {
if (!verified) {
return abortHandshake(socket, code || 401, message, headers);
}
this.completeUpgrade(
extensions,
key,
protocols,
req,
socket,
head,
cb
);
});
return;
}
if (!this.options.verifyClient(info)) return abortHandshake(socket, 401);
}
this.completeUpgrade(extensions, key, protocols, req, socket, head, cb);
}
/**
* Upgrade the connection to WebSocket.
*
* @param {Object} extensions The accepted extensions
* @param {String} key The value of the `Sec-WebSocket-Key` header
* @param {Set} protocols The subprotocols
* @param {http.IncomingMessage} req The request object
* @param {Duplex} socket The network socket between the server and client
* @param {Buffer} head The first packet of the upgraded stream
* @param {Function} cb Callback
* @throws {Error} If called more than once with the same socket
* @private
*/
completeUpgrade(extensions, key, protocols, req, socket, head, cb) {
//
// Destroy the socket if the client has already sent a FIN packet.
//
if (!socket.readable || !socket.writable) return socket.destroy();
if (socket[kWebSocket]) {
throw new Error(
'server.handleUpgrade() was called more than once with the same ' +
'socket, possibly due to a misconfiguration'
);
}
if (this._state > RUNNING) return abortHandshake(socket, 503);
const digest = createHash('sha1')
.update(key + GUID)
.digest('base64');
const headers = [
'HTTP/1.1 101 Switching Protocols',
'Upgrade: websocket',
'Connection: Upgrade',
`Sec-WebSocket-Accept: ${digest}`
];
const ws = new this.options.WebSocket(null, undefined, this.options);
if (protocols.size) {
//
// Optionally call external protocol selection handler.
//
const protocol = this.options.handleProtocols
? this.options.handleProtocols(protocols, req)
: protocols.values().next().value;
if (protocol) {
headers.push(`Sec-WebSocket-Protocol: ${protocol}`);
ws._protocol = protocol;
}
}
if (extensions[PerMessageDeflate.extensionName]) {
const params = extensions[PerMessageDeflate.extensionName].params;
const value = extension.format({
[PerMessageDeflate.extensionName]: [params]
});
headers.push(`Sec-WebSocket-Extensions: ${value}`);
ws._extensions = extensions;
}
//
// Allow external modification/inspection of handshake headers.
//
this.emit('headers', headers, req);
socket.write(headers.concat('\r\n').join('\r\n'));
socket.removeListener('error', socketOnError);
ws.setSocket(socket, head, {
allowSynchronousEvents: this.options.allowSynchronousEvents,
maxPayload: this.options.maxPayload,
skipUTF8Validation: this.options.skipUTF8Validation
});
if (this.clients) {
this.clients.add(ws);
ws.on('close', () => {
this.clients.delete(ws);
if (this._shouldEmitClose && !this.clients.size) {
process.nextTick(emitClose, this);
}
});
}
cb(ws, req);
}
}
module.exports = WebSocketServer;
/**
* Add event listeners on an `EventEmitter` using a map of <event, listener>
* pairs.
*
* @param {EventEmitter} server The event emitter
* @param {Object.<String, Function>} map The listeners to add
* @return {Function} A function that will remove the added listeners when
* called
* @private
*/
function addListeners(server, map) {
for (const event of Object.keys(map)) server.on(event, map[event]);
return function removeListeners() {
for (const event of Object.keys(map)) {
server.removeListener(event, map[event]);
}
};
}
/**
* Emit a `'close'` event on an `EventEmitter`.
*
* @param {EventEmitter} server The event emitter
* @private
*/
function emitClose(server) {
server._state = CLOSED;
server.emit('close');
}
/**
* Handle socket errors.
*
* @private
*/
function socketOnError() {
this.destroy();
}
/**
* Close the connection when preconditions are not fulfilled.
*
* @param {Duplex} socket The socket of the upgrade request
* @param {Number} code The HTTP response status code
* @param {String} [message] The HTTP response body
* @param {Object} [headers] Additional HTTP response headers
* @private
*/
function abortHandshake(socket, code, message, headers) {
//
// The socket is writable unless the user destroyed or ended it before calling
// `server.handleUpgrade()` or in the `verifyClient` function, which is a user
// error. Handling this does not make much sense as the worst that can happen
// is that some of the data written by the user might be discarded due to the
// call to `socket.end()` below, which triggers an `'error'` event that in
// turn causes the socket to be destroyed.
//
message = message || http.STATUS_CODES[code];
headers = {
Connection: 'close',
'Content-Type': 'text/html',
'Content-Length': Buffer.byteLength(message),
...headers
};
socket.once('finish', socket.destroy);
socket.end(
`HTTP/1.1 ${code} ${http.STATUS_CODES[code]}\r\n` +
Object.keys(headers)
.map((h) => `${h}: ${headers[h]}`)
.join('\r\n') +
'\r\n\r\n' +
message
);
}
/**
* Emit a `'wsClientError'` event on a `WebSocketServer` if there is at least
* one listener for it, otherwise call `abortHandshake()`.
*
* @param {WebSocketServer} server The WebSocket server
* @param {http.IncomingMessage} req The request object
* @param {Duplex} socket The socket of the upgrade request
* @param {Number} code The HTTP response status code
* @param {String} message The HTTP response body
* @param {Object} [headers] The HTTP response headers
* @private
*/
function abortHandshakeOrEmitwsClientError(
server,
req,
socket,
code,
message,
headers
) {
if (server.listenerCount('wsClientError')) {
const err = new Error(message);
Error.captureStackTrace(err, abortHandshakeOrEmitwsClientError);
server.emit('wsClientError', err, socket, req);
} else {
abortHandshake(socket, code, message, headers);
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@ -1,8 +0,0 @@
import createWebSocketStream from './lib/stream.js';
import Receiver from './lib/receiver.js';
import Sender from './lib/sender.js';
import WebSocket from './lib/websocket.js';
import WebSocketServer from './lib/websocket-server.js';
export { createWebSocketStream, Receiver, Sender, WebSocket, WebSocketServer };
export default WebSocket;

View File

@ -9,18 +9,8 @@ let isUnityConnected = false;
let cameraList = []; // 카메라 목록 저장
let itemList = []; // 아이템 목록 저장
let eventList = []; // 이벤트 목록 저장
let avatarOutfitList = []; // 아바타 의상 목록 저장
const fs = require('fs');
function getBase64Image(filePath) {
try {
const data = fs.readFileSync(filePath);
return 'data:image/png;base64,' + data.toString('base64');
} catch (e) {
console.error('이미지 파일 읽기 실패:', filePath, e);
return '';
}
}
// StreamDock 연결 함수 (브라우저 기반)
function connectElgatoStreamDeckSocket(inPort, inUUID, inEvent, inInfo, inActionInfo) {
@ -98,6 +88,9 @@ function connectElgatoStreamDeckSocket(inPort, inUUID, inEvent, inInfo, inAction
} else if (jsonObj.action === 'com.mirabox.streamingle.event') {
settings.actionType = 'event';
console.log('🎯 이벤트 컨트롤러 등록:', jsonObj.context);
} else if (jsonObj.action === 'com.mirabox.streamingle.avatar_outfit') {
settings.actionType = 'avatar_outfit';
console.log('👗 아바타 의상 컨트롤러 등록:', jsonObj.context);
} else {
settings.actionType = 'camera';
console.log('📹 카메라 컨트롤러 등록:', jsonObj.context);
@ -117,9 +110,31 @@ function connectElgatoStreamDeckSocket(inPort, inUUID, inEvent, inInfo, inAction
actionUUID = 'com.mirabox.streamingle.item';
} else if (actionType === 'event') {
actionUUID = 'com.mirabox.streamingle.event';
} else if (actionType === 'avatar_outfit') {
actionUUID = 'com.mirabox.streamingle.avatar_outfit';
}
console.log('📤 willAppear에서 Unity 연결 상태 전송:', actionType, actionUUID);
sendToPropertyInspector(jsonObj.context, 'unity_connected', { connected: true }, actionUUID);
// 아바타 의상 컨트롤러인 경우 데이터도 함께 전송
if (actionType === 'avatar_outfit') {
console.log('📤 willAppear에서 아바타 데이터 전송:', avatarOutfitList.length, '개');
if (avatarOutfitList.length > 0) {
sendToPropertyInspector(jsonObj.context, 'avatar_outfit_list', {
avatars: avatarOutfitList,
currentIndex: 0
}, actionUUID);
} else {
// 아바타 목록이 없으면 Unity에 요청
console.log('📤 willAppear에서 아바타 목록 요청');
if (unitySocket && unitySocket.readyState === WebSocket.OPEN) {
const message = JSON.stringify({ type: 'get_avatar_outfit_list' });
unitySocket.send(message);
console.log('📤 Unity에 아바타 목록 요청 전송 (willAppear)');
}
}
}
}
break;
@ -129,6 +144,10 @@ function connectElgatoStreamDeckSocket(inPort, inUUID, inEvent, inInfo, inAction
break;
case 'sendToPlugin':
console.log('📨 sendToPlugin 이벤트 수신');
console.log(' - context:', jsonObj.context);
console.log(' - action:', jsonObj.action);
console.log(' - payload:', jsonObj.payload);
handlePropertyInspectorMessage(jsonObj.payload, jsonObj.context, jsonObj.action);
break;
@ -204,6 +223,13 @@ function connectToUnity() {
unitySocket.send(message);
console.log('📋 이벤트 목록 요청:', message);
}, 300);
// 아바타 의상 목록 요청
setTimeout(() => {
const message = JSON.stringify({ type: 'get_avatar_outfit_list' });
unitySocket.send(message);
console.log('📋 아바타 의상 목록 요청:', message);
}, 400);
};
unitySocket.onmessage = function(event) {
@ -219,6 +245,7 @@ function connectToUnity() {
unitySocket.onclose = function() {
isUnityConnected = false;
unitySocket = null; // 소켓을 null로 설정
console.log('❌ Unity 연결 끊어짐');
// Property Inspector들에게 Unity 연결 해제 알림
@ -231,12 +258,16 @@ function connectToUnity() {
actionUUID = 'com.mirabox.streamingle.item';
} else if (actionType === 'event') {
actionUUID = 'com.mirabox.streamingle.event';
} else if (actionType === 'avatar_outfit') {
actionUUID = 'com.mirabox.streamingle.avatar_outfit';
}
sendToPropertyInspector(context, 'unity_disconnected', { connected: false }, actionUUID);
sendToPropertyInspector(context, 'unity_connected', { connected: false }, actionUUID);
}
};
unitySocket.onerror = function(error) {
isUnityConnected = false;
unitySocket = null; // 소켓을 null로 설정
console.error('❌ Unity 연결 오류:', error);
console.log('🔍 Unity가 실행 중인지 확인하세요 (포트 10701)');
};
@ -252,7 +283,16 @@ function handleButtonClick(context) {
console.log('🔌 Unity 연결 상태:', isUnityConnected);
if (!isUnityConnected || !unitySocket) {
console.error('❌ Unity 연결되지 않음');
console.log('🔄 Unity 연결되지 않음, 재연결 시도...');
connectToUnity();
// 연결 후 잠시 대기한 후 다시 시도
setTimeout(() => {
if (isUnityConnected && unitySocket) {
handleButtonClick(context);
} else {
console.error('❌ Unity 재연결 실패');
}
}, 1000);
return;
}
@ -272,6 +312,9 @@ function handleButtonClick(context) {
case 'event':
handleEventAction(settings);
break;
case 'avatar_outfit':
handleAvatarOutfitAction(settings);
break;
default:
console.log('⚠️ 알 수 없는 액션 타입:', actionType);
// 기본적으로 카메라 액션으로 처리
@ -308,6 +351,8 @@ function handleCameraAction(settings) {
console.log('✅ 메시지 전송 완료');
} else {
console.error('❌ Unity 소켓이 연결되지 않음');
console.log('🔄 Unity 재연결 시도...');
connectToUnity();
}
}
@ -346,6 +391,8 @@ function handleItemAction(settings) {
console.log('✅ 메시지 전송 완료');
} else {
console.error('❌ Unity 소켓이 연결되지 않음');
console.log('🔄 Unity 재연결 시도...');
connectToUnity();
}
}
@ -384,64 +431,162 @@ function handleEventAction(settings) {
console.log('✅ 메시지 전송 완료');
} else {
console.error('❌ Unity 소켓이 연결되지 않음');
console.log('🔄 Unity 재연결 시도...');
connectToUnity();
}
}
// 아바타 의상 액션 처리
function handleAvatarOutfitAction(settings) {
let avatarIndex = settings.avatarIndex;
let outfitIndex = settings.outfitIndex;
// 설정값들 검증
if (typeof avatarIndex !== 'number') {
avatarIndex = 0;
console.log('⚠️ 아바타 인덱스가 설정되지 않음, 기본값 0 사용');
}
if (typeof outfitIndex !== 'number') {
outfitIndex = 0;
console.log('⚠️ 의상 인덱스가 설정되지 않음, 기본값 0 사용');
}
console.log('👗 아바타 의상 전환:', { avatarIndex, outfitIndex });
// Unity에 아바타 의상 변경 요청
const message = JSON.stringify({
type: 'set_avatar_outfit',
data: {
avatar_index: avatarIndex,
outfit_index: outfitIndex
}
});
console.log('📤 Unity에 아바타 의상 변경 요청 전송:', message);
console.log('🔍 Unity 연결 상태:', isUnityConnected);
console.log('🔍 Unity 소켓 상태:', !!unitySocket);
if (unitySocket && unitySocket.readyState === WebSocket.OPEN) {
unitySocket.send(message);
console.log('✅ 메시지 전송 완료');
} else {
console.error('❌ Unity 소켓이 연결되지 않음');
console.log('🔄 Unity 재연결 시도...');
connectToUnity();
}
}
// Property Inspector 메시지 처리
function handlePropertyInspectorMessage(payload, context, actionUUID) {
const command = payload.command;
console.log('📤 Property Inspector 명령 처리:', command, 'action:', actionUUID);
console.log('📤 Property Inspector 명령 처리 시작');
console.log(' - payload:', payload);
console.log(' - context:', context);
console.log(' - actionUUID:', actionUUID);
console.log(' - command:', command);
switch (command) {
case 'get_unity_status':
console.log('📡 Unity 상태 요청 - context:', context, 'action:', actionUUID);
console.log('📡 Unity 연결 상태:', isUnityConnected);
console.log('📡 카메라 목록 길이:', cameraList.length);
console.log('📡 아이템 목록 길이:', itemList.length);
console.log('📡 아바타 목록 길이:', avatarOutfitList.length);
sendToPropertyInspector(context, 'unity_connected', { connected: isUnityConnected }, actionUUID);
// Unity 연결된 상태라면 무조건 아바타 목록 요청
if (isUnityConnected && unitySocket && unitySocket.readyState === WebSocket.OPEN) {
console.log('📡 Unity 연결됨 - 모든 데이터 요청 시작');
// 아바타 목록이 비어있으면 무조건 요청
if (avatarOutfitList.length === 0) {
console.log('📡 아바타 목록 비어있음 - Unity에 요청');
const avatarMessage = JSON.stringify({ type: 'get_avatar_outfit_list' });
unitySocket.send(avatarMessage);
console.log('📤 Unity에 아바타 의상 목록 강제 요청 전송');
}
}
if (isUnityConnected) {
if (cameraList.length > 0) {
// 현재 활성 카메라 인덱스 찾기
const currentCameraIndex = cameraList.findIndex(cam => cam.isActive) || 0;
console.log('📡 현재 활성 카메라 인덱스:', currentCameraIndex);
sendToPropertyInspector(context, 'camera_list', {
cameras: cameraList,
currentIndex: currentCameraIndex
}, actionUUID);
}
if (itemList.length > 0) {
// 현재 활성 아이템 인덱스 찾기
const currentItemIndex = itemList.findIndex(item => item.isActive) || 0;
console.log('📡 현재 활성 아이템 인덱스:', currentItemIndex);
sendToPropertyInspector(context, 'item_list', {
items: itemList,
currentIndex: currentItemIndex
}, actionUUID);
}
if (eventList.length > 0) {
// 현재 활성 이벤트 인덱스 찾기
const currentEventIndex = eventList.findIndex(event => event.isActive) || 0;
console.log('📡 현재 활성 이벤트 인덱스:', currentEventIndex);
sendToPropertyInspector(context, 'event_list', {
events: eventList,
currentIndex: currentEventIndex
}, actionUUID);
// actionUUID에 따라 해당하는 데이터만 전송
console.log('📡 ActionUUID에 따른 데이터 전송:', actionUUID);
if (actionUUID === 'com.mirabox.streamingle.camera') {
if (cameraList.length > 0) {
// 현재 활성 카메라 인덱스 찾기
const currentCameraIndex = cameraList.findIndex(cam => cam.isActive) || 0;
console.log('📡 현재 활성 카메라 인덱스:', currentCameraIndex);
sendToPropertyInspector(context, 'camera_list', {
cameras: cameraList,
currentIndex: currentCameraIndex
}, actionUUID);
}
} else if (actionUUID === 'com.mirabox.streamingle.item') {
if (itemList.length > 0) {
// 현재 활성 아이템 인덱스 찾기
const currentItemIndex = itemList.findIndex(item => item.isActive) || 0;
console.log('📡 현재 활성 아이템 인덱스:', currentItemIndex);
sendToPropertyInspector(context, 'item_list', {
items: itemList,
currentIndex: currentItemIndex
}, actionUUID);
}
} else if (actionUUID === 'com.mirabox.streamingle.event') {
if (eventList.length > 0) {
// 현재 활성 이벤트 인덱스 찾기
const currentEventIndex = eventList.findIndex(event => event.isActive) || 0;
console.log('📡 현재 활성 이벤트 인덱스:', currentEventIndex);
sendToPropertyInspector(context, 'event_list', {
events: eventList,
currentIndex: currentEventIndex
}, actionUUID);
}
} else if (actionUUID === 'com.mirabox.streamingle.avatar_outfit') {
if (avatarOutfitList.length > 0) {
// 현재 활성 아바타 인덱스 찾기
const currentAvatarIndex = avatarOutfitList.findIndex(avatar => avatar.current_outfit_index >= 0) || 0;
console.log('📡 현재 아바타 목록:', avatarOutfitList.length, '개');
console.log('📡 현재 활성 아바타 인덱스:', currentAvatarIndex);
sendToPropertyInspector(context, 'avatar_outfit_list', {
avatars: avatarOutfitList,
currentIndex: currentAvatarIndex
}, actionUUID);
} else {
console.log('📡 아바타 목록이 비어있음, Unity에 요청');
// 아바타 목록이 없으면 Unity에 요청
if (unitySocket && unitySocket.readyState === WebSocket.OPEN) {
const message = JSON.stringify({ type: 'get_avatar_outfit_list' });
unitySocket.send(message);
console.log('📤 Unity에 아바타 의상 목록 자동 요청 전송');
}
}
}
} else {
// Unity가 연결되지 않은 경우에도 빈 목록 전송
console.log('📡 Unity 연결 안됨 - 빈 목록 전송');
sendToPropertyInspector(context, 'camera_list', {
cameras: [],
currentIndex: 0
}, actionUUID);
sendToPropertyInspector(context, 'item_list', {
items: [],
currentIndex: 0
}, actionUUID);
sendToPropertyInspector(context, 'event_list', {
events: [],
currentIndex: 0
}, actionUUID);
// Unity가 연결되지 않은 경우에도 해당 컨트롤러에 맞는 빈 목록만 전송
console.log('📡 Unity 연결 안됨 - ActionUUID에 따른 빈 목록 전송:', actionUUID);
if (actionUUID === 'com.mirabox.streamingle.camera') {
sendToPropertyInspector(context, 'camera_list', {
cameras: [],
currentIndex: 0
}, actionUUID);
} else if (actionUUID === 'com.mirabox.streamingle.item') {
sendToPropertyInspector(context, 'item_list', {
items: [],
currentIndex: 0
}, actionUUID);
} else if (actionUUID === 'com.mirabox.streamingle.event') {
sendToPropertyInspector(context, 'event_list', {
events: [],
currentIndex: 0
}, actionUUID);
} else if (actionUUID === 'com.mirabox.streamingle.avatar_outfit') {
sendToPropertyInspector(context, 'avatar_outfit_list', {
avatars: [],
currentIndex: 0
}, actionUUID);
}
}
break;
case 'get_camera_list':
@ -547,10 +692,58 @@ function handlePropertyInspectorMessage(payload, context, actionUUID) {
console.log('📤 Unity에 이벤트 목록 요청 전송');
}
break;
case 'refresh_avatar_outfit_list':
console.log('🔄 아바타 의상 목록 새로고침 요청');
if (!isUnityConnected || !unitySocket) {
console.log('🔄 Unity 연결되지 않음, 재연결 시도...');
connectToUnity();
} else {
const message = JSON.stringify({ type: 'get_avatar_outfit_list' });
if (unitySocket && unitySocket.readyState === WebSocket.OPEN) {
unitySocket.send(message);
console.log('📤 Unity에 아바타 의상 목록 요청 전송');
} else {
console.error('❌ Unity 소켓이 연결되지 않음');
console.log('🔄 Unity 재연결 시도...');
connectToUnity();
}
}
break;
case 'set_avatar_outfit':
console.log('👗 아바타 의상 설정 요청:', payload);
if (isUnityConnected && unitySocket) {
const message = JSON.stringify({
type: 'set_avatar_outfit',
data: {
avatar_index: payload.avatarIndex,
outfit_index: payload.outfitIndex
}
});
unitySocket.send(message);
console.log('📤 Unity에 아바타 의상 설정 요청 전송');
}
break;
case 'update_title':
console.log('🏷️ 버튼 제목 업데이트 요청');
console.log('🏷️ 요청 payload:', payload);
console.log('🏷️ 현재 Plugin의 avatarOutfitList:', avatarOutfitList);
updateButtonTitle(context);
break;
case 'debug_test':
console.log('🔍 디버그 테스트 메시지 수신!');
console.log(' - context:', context);
console.log(' - actionUUID:', actionUUID);
console.log(' - payload:', payload);
console.log(' - 현재 등록된 컨텍스트들:', Array.from(buttonContexts.keys()));
console.log(' - 이 컨텍스트의 설정:', getCurrentSettings(context));
// 응답을 보내서 Property Inspector가 받을 수 있는지 확인
sendToPropertyInspector(context, 'debug_response', {
message: 'Plugin received debug test',
receivedContext: context,
registeredContexts: Array.from(buttonContexts.keys())
}, actionUUID);
break;
default:
console.log('❓ 알 수 없는 Property Inspector 명령:', command);
@ -559,7 +752,7 @@ function handlePropertyInspectorMessage(payload, context, actionUUID) {
// Property Inspector로 메시지 전송 (action UUID 지정 가능)
function sendToPropertyInspector(context, event, payload, actionUUID = null) {
console.log('📤 Property Inspector로 메시지 전송 시작 - context:', context, 'event:', event);
console.log('📤 Property Inspector로 메시지 전송 시작 - context:', context, 'event:', event, 'actionUUID:', actionUUID);
if (!websocket || !context) {
console.log('🚫 WebSocket 또는 context 없음 - Property Inspector 메시지 전송 건너뜀');
@ -585,6 +778,8 @@ function sendToPropertyInspector(context, event, payload, actionUUID = null) {
action = 'com.mirabox.streamingle.item';
} else if (actionType === 'event') {
action = 'com.mirabox.streamingle.event';
} else if (actionType === 'avatar_outfit') {
action = 'com.mirabox.streamingle.avatar_outfit';
}
}
@ -652,6 +847,22 @@ function handleUnityMessage(data) {
}
}
// 아바타 의상 데이터 처리
if (data.data.avatar_outfit_data) {
let avatars = data.data.avatar_outfit_data.avatars || data.data.avatar_outfit_data;
if (Array.isArray(avatars)) {
avatarOutfitList = avatars;
console.log('👗 아바타 의상 목록 저장됨:', avatarOutfitList.length, '개');
} else {
avatarOutfitList = [];
console.log('⚠️ 아바타 의상 데이터가 배열이 아님:', avatars);
}
} else {
// 아바타 의상 데이터가 없어도 빈 배열로 초기화
console.log('⚠️ 아바타 의상 데이터가 없음, 빈 배열로 초기화');
avatarOutfitList = [];
}
updateAllButtonTitles();
// Property Inspector들에게 Unity 연결 상태 알림
@ -673,6 +884,8 @@ function handleUnityMessage(data) {
actionUUID = 'com.mirabox.streamingle.item';
} else if (actionType === 'event') {
actionUUID = 'com.mirabox.streamingle.event';
} else if (actionType === 'avatar_outfit') {
actionUUID = 'com.mirabox.streamingle.avatar_outfit';
}
console.log('🔍 컨텍스트 분석:', context, 'Action Type:', actionType, 'Action UUID:', actionUUID);
@ -712,6 +925,19 @@ function handleUnityMessage(data) {
events: eventList,
currentIndex: currentEventIndex
}, actionUUID);
} else if (actionUUID === 'com.mirabox.streamingle.avatar_outfit') {
// 아바타 의상 컨트롤러에는 아바타 의상 데이터만 전송
let currentAvatarIndex = 0;
if (typeof data.data.avatar_outfit_data?.current_avatar_index === 'number' && data.data.avatar_outfit_data.current_avatar_index >= 0) {
currentAvatarIndex = data.data.avatar_outfit_data.current_avatar_index;
}
console.log('👗 아바타 의상 컨트롤러에 아바타 데이터 전송:', context, '아바타 수:', avatarOutfitList.length);
// 아바타 목록이 비어있더라도 연결 상태와 함께 전송
sendToPropertyInspector(context, 'avatar_outfit_list', {
avatars: avatarOutfitList || [],
currentIndex: currentAvatarIndex
}, actionUUID);
}
}
console.log('✅ Property Inspector들에게 Unity 연결 알림 전송 완료');
@ -942,6 +1168,76 @@ function handleUnityMessage(data) {
}
break;
case 'avatar_outfit_changed':
console.log('👗 아바타 의상 변경 알림');
if (data.data && data.data.avatar_outfit_data) {
let avatars = data.data.avatar_outfit_data.avatars || data.data.avatar_outfit_data;
if (Array.isArray(avatars)) {
avatarOutfitList = avatars;
console.log('👗 아바타 의상 목록 업데이트됨:', avatarOutfitList.length, '개');
updateAllButtonTitles();
// Property Inspector들에게 아바타 의상 목록 전송 (아바타 의상 컨트롤러만)
for (const context of buttonContexts.keys()) {
const settings = getCurrentSettings(context);
const actionType = settings.actionType || 'camera';
if (actionType === 'avatar_outfit') {
sendToPropertyInspector(context, 'avatar_outfit_changed', {
avatars: avatarOutfitList,
currentIndex: data.data.avatar_outfit_data?.current_avatar_index || 0
}, 'com.mirabox.streamingle.avatar_outfit');
}
}
} else {
console.log('⚠️ 아바타 의상 변경 응답에서 아바타 데이터가 배열이 아님');
console.log('📋 avatars:', avatars);
}
}
break;
case 'avatar_outfit_list_response':
console.log('👗 아바타 의상 목록 응답');
if (data.data && data.data.avatar_outfit_data) {
let avatars = data.data.avatar_outfit_data.avatars || data.data.avatar_outfit_data;
let currentIndex = data.data.avatar_outfit_data?.current_avatar_index || 0;
if (Array.isArray(avatars)) {
avatarOutfitList = avatars;
console.log('👗 아바타 의상 목록 응답 처리됨:', avatarOutfitList.length, '개');
// 아바타 데이터 상세 로그
avatarOutfitList.forEach((avatar, index) => {
console.log(`👗 아바타 ${index}:`, {
name: avatar.name,
nameType: typeof avatar.name,
current_outfit_name: avatar.current_outfit_name,
outfits: avatar.outfits ? avatar.outfits.length : 'no outfits'
});
});
updateAllButtonTitles();
// Property Inspector들에게 아바타 의상 목록 전송
for (const context of buttonContexts.keys()) {
const settings = getCurrentSettings(context);
const actionType = settings.actionType || 'camera';
if (actionType === 'avatar_outfit') {
sendToPropertyInspector(context, 'avatar_outfit_list', {
avatars: avatarOutfitList,
currentIndex: currentIndex
}, 'com.mirabox.streamingle.avatar_outfit');
}
}
} else {
console.log('⚠️ 아바타 의상 목록 응답에서 아바타 데이터가 배열이 아님');
console.log('📋 avatars:', avatars);
}
}
break;
default:
console.log('❓ 알 수 없는 Unity 메시지 타입:', data.type);
}
@ -1036,6 +1332,82 @@ function updateButtonTitle(context) {
title = shortName || `이벤트 ${eventIndex + 1}`;
}
}
} else if (actionType === 'avatar_outfit') {
const avatarIndex = typeof settings.avatarIndex === 'number' ? settings.avatarIndex : 0;
const outfitIndex = typeof settings.outfitIndex === 'number' ? settings.outfitIndex : 0;
title = `아바타 ${avatarIndex + 1}`;
console.log('👗 아바타 제목 업데이트 시작');
console.log('👗 Context:', context);
console.log('👗 현재 설정:', settings);
console.log('👗 아바타 인덱스:', avatarIndex, '의상 인덱스:', outfitIndex);
console.log('👗 Plugin의 avatarOutfitList:', avatarOutfitList);
console.log('👗 avatarOutfitList 길이:', avatarOutfitList ? avatarOutfitList.length : 'null');
// 버튼별 설정 확인
console.log('👗 [DEBUG] 모든 버튼 컨텍스트와 설정:');
for (const [ctx, ctxSettings] of buttonContexts.entries()) {
if (ctxSettings.actionType === 'avatar_outfit') {
console.log(`👗 [DEBUG] Context: ${ctx} -> avatarIndex: ${ctxSettings.avatarIndex}, outfitIndex: ${ctxSettings.outfitIndex}`);
}
}
if (avatarOutfitList && avatarOutfitList.length > avatarIndex) {
const avatar = avatarOutfitList[avatarIndex];
console.log('👗 선택된 아바타:', avatar);
if (avatar && avatar.name) {
console.log('👗 원본 아바타 이름:', avatar.name);
console.log('👗 아바타 이름 타입:', typeof avatar.name);
// 아바타 이름 안전하게 처리
let avatarName = String(avatar.name);
console.log('👗 문자열 변환된 이름:', avatarName);
// 아바타 이름 짧게 만들기
let shortName = avatarName
.replace('Avatar', '')
.replace('Character', '')
.replace('_', ' ')
.substring(0, 8); // 최대 8글자
console.log('👗 처리된 아바타 이름:', shortName);
// 버튼에 설정된 의상 정보 추가
let outfitInfo = '';
if (avatar.outfits && avatar.outfits.length > outfitIndex) {
const outfit = avatar.outfits[outfitIndex];
console.log('👗 버튼에 설정된 의상:', outfit);
if (outfit && outfit.name) {
outfitInfo = '\n' + String(outfit.name).substring(0, 6);
console.log('👗 설정된 의상 이름:', outfit.name);
}
} else {
console.log('👗 의상 인덱스가 범위를 벗어남 또는 의상 목록이 없음');
console.log('👗 outfitIndex:', outfitIndex, 'outfits 길이:', avatar.outfits ? avatar.outfits.length : 'null');
}
title = (shortName || `아바타${avatarIndex + 1}`) + outfitInfo;
console.log('👗 최종 제목:', title);
// 아바타 의상 활성화 상태 확인
if (avatar.outfits && avatar.outfits.length > outfitIndex) {
const outfit = avatar.outfits[outfitIndex];
// 현재 활성화된 의상이 설정된 의상과 같으면 활성 상태
isActive = (avatar.current_outfit_index === outfitIndex);
console.log('👗 아바타 의상 활성 상태:', isActive,
'current:', avatar.current_outfit_index, 'target:', outfitIndex);
} else {
isActive = false;
console.log('👗 의상을 찾을 수 없어 비활성 상태로 설정');
}
} else {
console.log('👗 아바타 이름이 없음:', avatar);
isActive = false;
}
} else {
console.log('👗 아바타 목록이 없거나 인덱스가 범위를 벗어남');
console.log('👗 목록 길이:', avatarOutfitList ? avatarOutfitList.length : 'null');
}
}
// StreamDock에 제목 업데이트 요청
@ -1056,10 +1428,10 @@ function updateButtonTitle(context) {
websocket.send(JSON.stringify(message));
console.log('🏷️ 버튼 제목 업데이트:', title, '(액션 타입:', actionType, ', 활성:', isActive, ')');
// 아이템이나 카메라가 비활성화되어 있으면 아이콘을 어둡게 표시 (이벤트는 제외)
if ((actionType === 'item' || actionType === 'camera') && !isActive) {
// 아이템, 카메라, 아바타 의상이 비활성화되어 있으면 아이콘을 어둡게 표시 (이벤트는 제외)
if ((actionType === 'item' || actionType === 'camera' || actionType === 'avatar_outfit') && !isActive) {
setButtonState(context, false); // 비활성 상태로 설정
} else if (actionType === 'item' || actionType === 'camera') {
} else if (actionType === 'item' || actionType === 'camera' || actionType === 'avatar_outfit') {
setButtonState(context, true); // 활성 상태로 설정
}
// 이벤트 컨트롤러는 활성/비활성 상태 변경 없음 (항상 활성)
@ -1075,8 +1447,8 @@ function setButtonState(context, isActive) {
const settings = getCurrentSettings(context);
const actionType = settings.actionType || 'camera';
// 아이템 컨트롤러와 카메라 컨트롤러만 상태 변경 적용 (이벤트 컨트롤러는 제외)
if (actionType === 'item' || actionType === 'camera') {
// 아이템, 카메라, 아바타 의상 컨트롤러만 상태 변경 적용 (이벤트 컨트롤러는 제외)
if (actionType === 'item' || actionType === 'camera' || actionType === 'avatar_outfit') {
// 방법 1: setState 이벤트 사용
const stateMessage = {
event: 'setState',
@ -1090,30 +1462,7 @@ function setButtonState(context, isActive) {
websocket.send(JSON.stringify(stateMessage));
console.log('🎨 버튼 상태 업데이트 (setState):', context, '(활성:', isActive, ', 상태:', isActive ? 0 : 1, ')');
// 방법 2: setImage 이벤트로 아이콘 직접 변경 (base64)
let imagePath;
if (actionType === 'item') {
imagePath = isActive ? 'images/item_icon.png' : 'images/item_icon_inactive.png';
} else if (actionType === 'camera') {
imagePath = isActive ? 'images/camera_icon.png' : 'images/camera_icon_inactive.png';
}
const imageBase64 = getBase64Image(imagePath);
const imageMessage = {
event: 'setImage',
context: context,
payload: {
image: imageBase64,
target: 0 // hardware and software
}
};
websocket.send(JSON.stringify(imageMessage));
console.log('🖼️ 버튼 아이콘 업데이트 (setImage):', context, '(활성:', isActive, ', 이미지:', imagePath, ')');
// 추가 디버깅을 위한 로그
setTimeout(() => {
console.log('🔍 상태 변경 후 확인 - Context:', context, 'Action Type:', actionType, '활성:', isActive);
}, 100);
console.log('🎨 버튼 상태 업데이트 완료:', context, '(활성:', isActive, ')');
}
}

View File

@ -0,0 +1,254 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>Streamingle Avatar Outfit Inspector</title>
<link rel="stylesheet" href="../utils/bootstrap.min.css">
<link rel="stylesheet" href="../utils/bootstrap-icons.css">
<style>
body {
background: #2d2d30;
color: #cccccc;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 13px;
margin: 0;
padding: 16px;
min-height: 350px;
}
.status-indicator {
display: flex;
align-items: center;
margin-bottom: 15px;
padding: 10px;
background: #383838;
border-radius: 5px;
border-left: 3px solid #dc3545;
}
.status-indicator.connected {
border-left-color: #28a745;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 10px;
background: #dc3545;
}
.status-dot.connected {
background: #28a745;
}
.form-section {
margin-bottom: 20px;
padding: 15px;
background: #383838;
border-radius: 5px;
}
.form-section h5 {
margin: 0 0 10px 0;
color: #ffffff;
font-size: 14px;
font-weight: 600;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
color: #cccccc;
font-size: 12px;
font-weight: 500;
}
.form-control {
width: 100%;
padding: 8px 10px;
background: #2d2d30;
border: 1px solid #555555;
border-radius: 3px;
color: #ffffff;
font-size: 12px;
box-sizing: border-box;
}
.form-control:focus {
border-color: #007acc;
outline: none;
}
.form-control:disabled {
background: #1e1e1e;
color: #666666;
cursor: not-allowed;
}
.btn {
padding: 8px 15px;
border: none;
border-radius: 3px;
cursor: pointer;
font-size: 12px;
transition: background-color 0.2s;
}
.btn-primary {
background: #007acc;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #005a9e;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.form-check {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.form-check-input {
margin-right: 8px;
}
.form-check-label {
color: #cccccc;
font-size: 12px;
margin: 0;
}
.outfit-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.current-status {
font-size: 11px;
color: #17a2b8;
font-style: italic;
margin-top: 5px;
}
.loading {
color: #ffc107;
font-style: italic;
}
.text-muted {
color: #666666 !important;
}
/* 디버그 로그 섹션 스타일 */
.log-section {
margin-top: 16px;
border-top: 1px solid #555555;
padding-top: 12px;
}
.log-toggle {
background: #555555;
color: #ffffff;
border: none;
border-radius: 3px;
padding: 6px 12px;
cursor: pointer;
font-size: 11px;
margin-bottom: 8px;
width: 100%;
}
.log-toggle:hover {
background: #666666;
}
.log-area {
background: #1a1a1a;
color: #00ff00;
font-family: 'Courier New', monospace;
font-size: 10px;
padding: 8px;
max-height: 200px;
overflow-y: auto;
white-space: pre-wrap;
display: none;
border: 1px solid #333333;
border-radius: 3px;
}
.log-area.show {
display: block;
}
</style>
</head>
<body>
<!-- Connection Status -->
<div id="connection-status" class="status-indicator">
<div id="status-dot" class="status-dot"></div>
<div>
<div id="status-text">Unity 서버 연결 대기중...</div>
<div class="current-status" id="connection-detail">연결을 확인하고 있습니다</div>
</div>
</div>
<!-- Avatar Selection -->
<div class="form-section">
<h5>👤 아바타 선택</h5>
<div class="form-group">
<label for="avatar-select">사용할 아바타</label>
<select id="avatar-select" class="form-control" disabled>
<option value="">Unity 연결 대기중...</option>
</select>
<div class="current-status" id="current-avatar">현재: 선택되지 않음</div>
</div>
</div>
<!-- Outfit Selection -->
<div class="form-section">
<h5>👗 의상 선택</h5>
<div class="form-group">
<label for="outfit-select">변경할 의상</label>
<select id="outfit-select" class="form-control" disabled>
<option value="">아바타를 먼저 선택하세요</option>
</select>
</div>
<div class="current-status" id="current-outfit">현재 의상: 정보 없음</div>
</div>
<!-- Actions -->
<div class="form-section">
<h5>🔧 동작</h5>
<button id="refresh-button" class="btn btn-primary" disabled>
<i class="bi bi-arrow-clockwise"></i> 아바타 목록 새로고침
</button>
</div>
<!-- 디버그 로그 섹션 -->
<div class="log-section">
<button class="log-toggle" onclick="toggleLog()">📋 디버그 로그 보기</button>
<div id="logArea" class="log-area"></div>
</div>
<script>
// 로그 토글 함수
function toggleLog() {
const logArea = document.getElementById('logArea');
logArea.classList.toggle('show');
}
</script>
<script src="../utils/common.js"></script>
<script src="index.js"></script>
</body>
</html>

View File

@ -0,0 +1,576 @@
console.log('👗 Avatar Outfit Property Inspector 로드됨');
// 화면에 로그를 표시하는 함수
function logToScreen(msg, color = "#00ff00") {
let logDiv = document.getElementById('logArea');
if (!logDiv) {
return; // 로그 영역이 아직 없으면 무시
}
const line = document.createElement('div');
line.style.color = color;
line.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
logDiv.appendChild(line);
logDiv.scrollTop = logDiv.scrollHeight;
// 로그가 너무 많아지면 오래된 것 제거 (최대 100줄)
if (logDiv.children.length > 100) {
logDiv.removeChild(logDiv.firstChild);
}
}
// 기존 console.log/console.error를 화면에도 출력
const origLog = console.log;
console.log = function(...args) {
origLog.apply(console, args);
const msg = args.map(a => (typeof a === 'object' ? JSON.stringify(a, null, 2) : a)).join(' ');
logToScreen(msg, '#00ff00');
};
const origError = console.error;
console.error = function(...args) {
origError.apply(console, args);
const msg = args.map(a => (typeof a === 'object' ? JSON.stringify(a, null, 2) : a)).join(' ');
logToScreen(msg, '#ff5555');
};
const origWarn = console.warn;
console.warn = function(...args) {
origWarn.apply(console, args);
const msg = args.map(a => (typeof a === 'object' ? JSON.stringify(a, null, 2) : a)).join(' ');
logToScreen(msg, '#ffaa00');
};
// 전역 변수
let websocket = null;
let uuid = null; // Property Inspector UUID
let actionContext = null; // 버튼별 고유 컨텍스트
let actionInfo = null;
let avatarList = [];
let isUnityConnected = false;
let settings = {};
// Stream Deck 연결
function connectElgatoStreamDeckSocket(inPort, inPropertyInspectorUUID, inRegisterEvent, inInfo, inActionInfo) {
console.log('🔌 Avatar Outfit Property Inspector 연결 중...');
console.log('📡 포트:', inPort, 'UUID:', inPropertyInspectorUUID);
uuid = inPropertyInspectorUUID;
// Parse action info - 버튼별 고유 컨텍스트와 설정 추출
try {
if (inActionInfo) {
console.log('🔍 Raw actionInfo:', inActionInfo);
actionInfo = JSON.parse(inActionInfo);
actionContext = actionInfo.context; // 버튼별 고유 컨텍스트
settings = actionInfo.payload?.settings || {};
// actionType이 없으면 강제로 설정
if (!settings.actionType) {
settings.actionType = 'avatar_outfit';
console.log('🔧 actionType이 없어서 강제로 설정:', settings.actionType);
}
console.log('⚙️ Property Inspector UUID:', uuid);
console.log('⚙️ 버튼 컨텍스트:', actionContext);
console.log('⚙️ 초기 설정:', settings);
console.log('⚙️ actionInfo 전체:', actionInfo);
} else {
console.error('❌ actionInfo가 없습니다!');
}
} catch (error) {
console.error('❌ ActionInfo 파싱 오류:', error);
console.log('🔍 파싱 시도한 문자열:', inActionInfo);
}
websocket = new WebSocket('ws://localhost:' + inPort);
websocket.onopen = function() {
console.log('✅ Property Inspector 연결됨');
// StreamDeck에 등록 (카메라와 동일한 패턴)
websocket.send(JSON.stringify({
event: inRegisterEvent,
uuid: inPropertyInspectorUUID
}));
// Unity 상태 요청 (카메라와 동일한 패턴)
setTimeout(() => {
console.log('🔍 Plugin에 디버그 메시지 전송 시작');
// actionType이 설정되었으면 즉시 Plugin에 저장
if (settings.actionType === 'avatar_outfit') {
const actionTypeMessage = {
action: 'com.mirabox.streamingle.avatar_outfit',
event: 'setSettings',
context: uuid,
payload: settings
};
websocket.send(JSON.stringify(actionTypeMessage));
console.log('🔧 초기 actionType 설정을 Plugin에 저장:', settings);
}
// 먼저 Plugin이 이 버튼을 인식하는지 확인
sendToPlugin('debug_test', {
message: 'Avatar outfit controller test',
context: actionContext,
action: 'com.mirabox.streamingle.avatar_outfit'
});
// Unity 상태 요청
sendToPlugin('get_unity_status');
}, 500);
// 초기 설정 로드
loadInitialSettings();
};
websocket.onmessage = function(evt) {
try {
const jsonObj = JSON.parse(evt.data);
handleMessage(jsonObj);
} catch (error) {
console.error('❌ 메시지 파싱 오류:', error);
}
};
websocket.onclose = function() {
console.log('❌ Property Inspector 연결 끊어짐');
websocket = null;
// 연결 상태를 비연결로 표시
handleUnityConnected(false);
};
websocket.onerror = function(error) {
console.error('❌ Property Inspector 연결 오류:', error);
websocket = null;
// 연결 상태를 비연결로 표시
handleUnityConnected(false);
};
}
// 메시지 처리
function handleMessage(jsonObj) {
console.log('📨 메시지 수신:', jsonObj.event);
console.log('📨 전체 메시지:', JSON.stringify(jsonObj, null, 2));
// sendToPropertyInspector 이벤트 처리 (카메라 방식과 동일)
if (jsonObj.event === 'sendToPropertyInspector' && jsonObj.payload && jsonObj.payload.event) {
const innerEvent = jsonObj.payload.event;
console.log('📨 내부 이벤트:', innerEvent, 'payload:', jsonObj.payload);
switch (innerEvent) {
case 'unity_connected':
console.log('✅ Unity 연결 상태 업데이트');
handleUnityConnected(jsonObj.payload.connected);
break;
case 'avatar_outfit_list':
console.log('👗 아바타 의상 목록 수신');
handleAvatarOutfitList(jsonObj.payload);
break;
case 'avatar_outfit_changed':
console.log('🎭 아바타 의상 변경 알림');
handleAvatarOutfitChanged(jsonObj.payload);
break;
case 'camera_list':
console.log('⚠️ 카메라 데이터를 받았지만 아바타 컨트롤러에서는 무시함');
break;
case 'item_list':
console.log('⚠️ 아이템 데이터를 받았지만 아바타 컨트롤러에서는 무시함');
break;
case 'event_list':
console.log('⚠️ 이벤트 데이터를 받았지만 아바타 컨트롤러에서는 무시함');
break;
case 'debug_response':
console.log('✅ Plugin으로부터 디버그 응답 받음!');
console.log('📨 응답 데이터:', jsonObj.payload);
break;
default:
console.log('❓ 알 수 없는 내부 이벤트:', innerEvent);
break;
}
return;
}
switch(jsonObj.event) {
case 'sendToPropertyInspector':
if (jsonObj.payload) {
handlePluginMessage(jsonObj.payload);
}
break;
case 'didReceiveSettings':
if (jsonObj.payload && jsonObj.payload.settings) {
updateUIFromSettings(jsonObj.payload.settings);
}
break;
// 기존 방식도 유지 (호환성)
case 'unity_connected':
console.log('✅ Unity 연결 상태 업데이트 (기존 방식)');
handleUnityConnected(jsonObj.payload?.connected);
break;
case 'avatar_outfit_list':
console.log('👗 아바타 의상 목록 수신 (기존 방식)');
handleAvatarOutfitList(jsonObj.payload);
break;
case 'avatar_outfit_changed':
console.log('🎭 아바타 의상 변경 알림 (기존 방식)');
handleAvatarOutfitChanged(jsonObj.payload);
break;
}
}
// 플러그인 메시지 처리
function handlePluginMessage(payload) {
console.log('📨 플러그인 메시지:', payload.event, payload);
switch(payload.event) {
case 'unity_connected':
console.log('🔗 Unity 연결 이벤트 수신:', payload);
handleUnityConnected(payload.connected);
break;
case 'avatar_outfit_list':
console.log('👗 아바타 의상 목록 이벤트 수신:', payload);
handleAvatarOutfitList(payload);
break;
case 'avatar_outfit_changed':
console.log('🎭 아바타 의상 변경 이벤트 수신:', payload);
handleAvatarOutfitChanged(payload);
break;
default:
console.log('❓ 알 수 없는 이벤트:', payload.event);
break;
}
}
// Unity 연결 상태 처리
function handleUnityConnected(connected) {
console.log('🔗 Unity 연결 상태:', connected);
isUnityConnected = connected;
const statusIndicator = document.getElementById('connection-status');
const statusDot = document.getElementById('status-dot');
const statusText = document.getElementById('status-text');
const connectionDetail = document.getElementById('connection-detail');
const avatarSelect = document.getElementById('avatar-select');
const refreshButton = document.getElementById('refresh-button');
if (connected) {
statusIndicator.classList.add('connected');
statusDot.classList.add('connected');
statusText.textContent = 'Unity 서버 연결됨';
connectionDetail.textContent = '아바타 데이터를 불러오는 중...';
avatarSelect.disabled = false;
refreshButton.disabled = false;
avatarSelect.innerHTML = '<option value="">아바타 목록을 불러오는 중...</option>';
// 연결 즉시 아바타 목록 요청
console.log('🔄 Unity 연결됨 - 아바타 목록 요청');
setTimeout(() => {
refreshAvatarList();
}, 500);
} else {
statusIndicator.classList.remove('connected');
statusDot.classList.remove('connected');
statusText.textContent = 'Unity 서버 연결 안됨';
connectionDetail.textContent = 'Unity를 실행하고 AvatarOutfitController가 있는지 확인하세요';
avatarSelect.disabled = true;
refreshButton.disabled = true;
avatarSelect.innerHTML = '<option value="">Unity가 연결되지 않음</option>';
updateOutfitSelect(-1);
}
}
// 아바타 의상 목록 처리
function handleAvatarOutfitList(payload) {
console.log('👗 아바타 의상 목록 수신:', payload);
console.log('👗 payload.avatars:', payload.avatars);
console.log('👗 Array.isArray(payload.avatars):', Array.isArray(payload.avatars));
if (payload.avatars && Array.isArray(payload.avatars)) {
avatarList = payload.avatars;
console.log('👗 아바타 목록 저장됨:', avatarList.length, '개');
console.log('👗 아바타 목록 상세:', avatarList);
updateAvatarSelect();
// 현재 설정된 아바타 확인
const avatarSelect = document.getElementById('avatar-select');
if (avatarSelect.value !== '') {
const selectedIndex = parseInt(avatarSelect.value);
if (!isNaN(selectedIndex)) {
updateOutfitSelect(selectedIndex);
}
}
} else {
console.log('❌ 아바타 데이터가 올바르지 않음:', payload.avatars);
avatarList = [];
updateAvatarSelect();
}
updateCurrentStatus(payload.currentIndex);
}
// 아바타 의상 변경 처리
function handleAvatarOutfitChanged(payload) {
console.log('🎭 아바타 의상 변경:', payload);
handleAvatarOutfitList(payload); // 같은 방식으로 처리
}
// 아바타 선택 업데이트
function updateAvatarSelect() {
const avatarSelect = document.getElementById('avatar-select');
const connectionDetail = document.getElementById('connection-detail');
avatarSelect.innerHTML = '';
if (avatarList.length === 0) {
avatarSelect.innerHTML = '<option value="">등록된 아바타가 없음</option>';
connectionDetail.textContent = 'Unity에서 AvatarOutfitController에 아바타를 추가하세요';
return;
}
avatarSelect.innerHTML = '<option value="">아바타를 선택하세요...</option>';
avatarList.forEach((avatar, index) => {
const option = document.createElement('option');
option.value = index;
option.textContent = `${avatar.name} (${avatar.outfits ? avatar.outfits.length : 0}개 의상)`;
avatarSelect.appendChild(option);
});
connectionDetail.textContent = `${avatarList.length}개의 아바타를 발견했습니다`;
}
// 의상 선택 업데이트
function updateOutfitSelect(avatarIndex) {
const outfitSelect = document.getElementById('outfit-select');
outfitSelect.innerHTML = '';
if (avatarIndex < 0 || avatarIndex >= avatarList.length) {
outfitSelect.innerHTML = '<option value="">먼저 아바타를 선택하세요</option>';
outfitSelect.disabled = true;
return;
}
const avatar = avatarList[avatarIndex];
if (!avatar.outfits || avatar.outfits.length === 0) {
outfitSelect.innerHTML = '<option value="">등록된 의상이 없음</option>';
outfitSelect.disabled = true;
return;
}
// 기본 선택 옵션 추가
outfitSelect.innerHTML = '<option value="">의상을 선택하세요...</option>';
// 의상 목록 추가
avatar.outfits.forEach((outfit, index) => {
const option = document.createElement('option');
option.value = index;
option.textContent = outfit.name;
outfitSelect.appendChild(option);
});
outfitSelect.disabled = false;
// 기본값 설정 (첫 번째 의상)
if (avatar.outfits.length > 0) {
outfitSelect.value = 0;
}
}
// 현재 상태 업데이트
function updateCurrentStatus(currentAvatarIndex) {
const currentAvatar = document.getElementById('current-avatar');
const currentOutfit = document.getElementById('current-outfit');
if (currentAvatarIndex >= 0 && currentAvatarIndex < avatarList.length) {
const avatar = avatarList[currentAvatarIndex];
currentAvatar.textContent = `현재: ${avatar.name}`;
currentOutfit.textContent = `현재 의상: ${avatar.current_outfit_name || '정보 없음'}`;
} else {
currentAvatar.textContent = '현재: 선택되지 않음';
currentOutfit.textContent = '현재 의상: 정보 없음';
}
}
// Send message to plugin
function sendToPlugin(command, data = {}) {
console.log('🚀 sendToPlugin 호출:', command, data);
console.log('🔍 WebSocket 상태:', websocket ? 'available' : 'null');
console.log('🔍 actionContext 상태:', actionContext);
if (!websocket) {
console.error('❌ WebSocket not available');
return;
}
if (!uuid) {
console.error('❌ UUID not available');
return;
}
try {
const message = {
action: 'com.mirabox.streamingle.avatar_outfit', // manifest.json의 Action UUID
event: 'sendToPlugin',
context: uuid, // Property Inspector UUID (Stream Deck 표준)
payload: {
command,
buttonContext: actionContext, // 버튼의 실제 context 정보도 함께 전송
...data
}
};
websocket.send(JSON.stringify(message));
console.log('📤 Message sent to plugin:', command, data, 'context:', uuid);
} catch (error) {
console.error('❌ Failed to send message to plugin:', error);
}
}
// Unity 상태 요청
function requestUnityStatus() {
sendToPlugin('get_unity_status');
}
// 아바타 목록 새로고침
function refreshAvatarList() {
sendToPlugin('refresh_avatar_outfit_list');
}
// 설정 저장
function saveSettings() {
if (!uuid) {
console.error('❌ No uuid available for saving settings');
return;
}
if (!websocket) {
console.log('⚠️ WebSocket이 연결되지 않음, 설정 저장 건너뜀');
return;
}
const avatarSelect = document.getElementById('avatar-select');
const outfitSelect = document.getElementById('outfit-select');
const newSettings = {
avatarIndex: parseInt(avatarSelect.value || '0'),
outfitIndex: parseInt(outfitSelect.value || '0'),
actionType: 'avatar_outfit' // 🔥 중요: actionType 추가!
};
// 전역 설정도 업데이트
settings = { ...settings, ...newSettings };
const message = {
action: 'com.mirabox.streamingle.avatar_outfit',
event: 'setSettings',
context: uuid, // Property Inspector UUID 사용 (Stream Deck 표준)
payload: newSettings
};
try {
websocket.send(JSON.stringify(message));
console.log('📤 설정 저장됨 - Context:', uuid, 'Settings:', newSettings);
// 설정이 저장되면 Plugin에게 아바타 의상 변경 요청도 보냄
sendToPlugin('set_avatar_outfit', {
avatarIndex: newSettings.avatarIndex,
outfitIndex: newSettings.outfitIndex
});
// 버튼 제목 업데이트 요청
sendToPlugin('update_title', {
avatarIndex: newSettings.avatarIndex,
outfitIndex: newSettings.outfitIndex
});
} catch (error) {
console.error('❌ 설정 저장 실패:', error);
websocket = null;
handleUnityConnected(false);
}
}
// 초기 설정 로드
function loadInitialSettings() {
console.log('🔧 초기 설정 로드 시작');
console.log('🔧 현재 settings:', settings);
// 초기화가 완료된 후 UI 업데이트
setTimeout(() => {
updateUIFromSettings(settings);
}, 100);
}
// 설정에서 UI 업데이트
function updateUIFromSettings(settingsToApply) {
console.log('🎨 UI 업데이트 시작:', settingsToApply);
const avatarSelect = document.getElementById('avatar-select');
const outfitSelect = document.getElementById('outfit-select');
if (settingsToApply.avatarIndex !== undefined && avatarSelect) {
console.log('👤 아바타 인덱스 적용:', settingsToApply.avatarIndex);
avatarSelect.value = settingsToApply.avatarIndex;
updateOutfitSelect(settingsToApply.avatarIndex);
}
if (settingsToApply.outfitIndex !== undefined && outfitSelect) {
console.log('👗 의상 인덱스 적용:', settingsToApply.outfitIndex);
outfitSelect.value = settingsToApply.outfitIndex;
}
console.log('✅ UI 업데이트 완료');
}
// 이벤트 리스너
document.addEventListener('DOMContentLoaded', function() {
console.log('👗 Avatar Outfit Property Inspector DOM 준비됨');
// 아바타 선택 변경
const avatarSelect = document.getElementById('avatar-select');
if (avatarSelect) {
avatarSelect.addEventListener('change', function() {
const selectedIndex = parseInt(this.value);
if (!isNaN(selectedIndex)) {
updateOutfitSelect(selectedIndex);
} else {
updateOutfitSelect(-1);
}
saveSettings();
});
}
// 의상 선택 변경
const outfitSelect = document.getElementById('outfit-select');
if (outfitSelect) {
outfitSelect.addEventListener('change', saveSettings);
}
// 새로고침 버튼
const refreshButton = document.getElementById('refresh-button');
if (refreshButton) {
refreshButton.addEventListener('click', function() {
console.log('🔄 새로고침 버튼 클릭됨');
// Unity 상태를 다시 확인하고 아바타 목록도 새로고침
requestUnityStatus();
refreshAvatarList();
// 강제로 연결 상태를 true로 설정 (다른 컨트롤러가 연결되어 있다면 Unity는 작동 중)
setTimeout(() => {
console.log('🔧 새로고침 후 강제로 Unity 연결 상태를 true로 설정');
handleUnityConnected(true);
}, 1000);
});
}
});
console.log('✅ Avatar Outfit Property Inspector 스크립트 로드 완료');

View File

@ -87,11 +87,15 @@ function sendToPlugin(command, data = {}) {
console.error('❌ WebSocket not available');
return;
}
if (!uuid) {
console.error('❌ UUID not available');
return;
}
try {
const message = {
action: 'com.mirabox.streamingle.camera', // manifest.json의 Action UUID
event: 'sendToPlugin',
context: uuid, // connectElgatoStreamDeckSocket에서 받은 uuid
context: uuid, // 아이템과 동일한 패턴
payload: {
command,
...data
@ -239,8 +243,16 @@ function handleMessage(jsonObj) {
updateCurrentCameraDisplay(jsonObj.payload.cameraIndex);
break;
case 'item_list':
console.log('🎯 아이템 목록 수신 (카메라 Property Inspector에서는 무시)');
console.log('⚠️ 카메라 컨트롤러에서 아이템 데이터를 받았습니다. 이는 잘못된 데이터 전송입니다.');
console.log('⚠️ 아이템 데이터를 받았지만 카메라 컨트롤러에서는 무시함');
break;
case 'event_list':
console.log('⚠️ 이벤트 데이터를 받았지만 카메라 컨트롤러에서는 무시함');
break;
case 'avatar_outfit_list':
console.log('⚠️ 아바타 데이터를 받았지만 카메라 컨트롤러에서는 무시함');
break;
case 'avatar_outfit_changed':
console.log('⚠️ 아바타 변경 데이터를 받았지만 카메라 컨트롤러에서는 무시함');
break;
default:
console.log('❓ 알 수 없는 내부 이벤트:', innerEvent);
@ -399,7 +411,7 @@ function initializePropertyInspector() {
}
// 현재 액션의 컨텍스트가 있으면 설정 요청
if (typeof uuid === 'string') {
if (uuid) {
requestSettings();
}

Binary file not shown.