diff --git a/Assets/Scripts/Streamdeck/StreamDeckServerManager.cs b/Assets/Scripts/Streamdeck/StreamDeckServerManager.cs
index 06f132eb2..29df9a779 100644
--- a/Assets/Scripts/Streamdeck/StreamDeckServerManager.cs
+++ b/Assets/Scripts/Streamdeck/StreamDeckServerManager.cs
@@ -439,6 +439,9 @@ public class StreamDeckServerManager : MonoBehaviour
case "get_drone_state":
HandleGetDroneState(service);
break;
+ case "toggle_default_blend":
+ HandleToggleDefaultBlend();
+ break;
// 아이템
case "toggle_item":
@@ -642,6 +645,13 @@ public class StreamDeckServerManager : MonoBehaviour
HandleGetDroneState(service);
}
+ private void HandleToggleDefaultBlend()
+ {
+ if (cameraManager == null) return;
+ cameraManager.ToggleDefaultBlend();
+ // ToggleDefaultBlend 내부에서 NotifyCameraChanged를 호출하여 모든 클라이언트에 브로드캐스트됩니다.
+ }
+
private void HandleGetDroneState(StreamDeckService service)
{
if (cameraManager == null) return;
diff --git a/Assets/Scripts/Streamingle/StreamingleControl/Controllers/CameraController.cs b/Assets/Scripts/Streamingle/StreamingleControl/Controllers/CameraController.cs
index 5b455ed71..2205fab30 100644
--- a/Assets/Scripts/Streamingle/StreamingleControl/Controllers/CameraController.cs
+++ b/Assets/Scripts/Streamingle/StreamingleControl/Controllers/CameraController.cs
@@ -365,6 +365,61 @@ public class CameraManager : MonoBehaviour, IController
streamDeckManager.NotifyCameraChanged();
}
}
+
+ ///
+ /// Main Camera에 달린 CinemachineBrain의 Default Blend 스타일을 Cut ↔ EaseInOut 로 토글합니다.
+ ///
+ public void ToggleDefaultBlend()
+ {
+ var brain = GetCinemachineBrain();
+ if (brain == null)
+ {
+ Debug.LogWarning("[CameraManager] CinemachineBrain을 찾을 수 없습니다. Main Camera에 추가되어 있어야 합니다.");
+ return;
+ }
+
+ var blend = brain.DefaultBlend;
+ blend.Style = (blend.Style == CinemachineBlendDefinition.Styles.Cut)
+ ? CinemachineBlendDefinition.Styles.EaseInOut
+ : CinemachineBlendDefinition.Styles.Cut;
+ brain.DefaultBlend = blend;
+
+ NotifyStreamDeckCameraStateChanged();
+ }
+
+ public bool IsDefaultBlendCut
+ {
+ get
+ {
+ var brain = GetCinemachineBrain();
+ return brain != null && brain.DefaultBlend.Style == CinemachineBlendDefinition.Styles.Cut;
+ }
+ }
+
+ public string DefaultBlendStyleName
+ {
+ get
+ {
+ var brain = GetCinemachineBrain();
+ return brain != null ? brain.DefaultBlend.Style.ToString() : "Unknown";
+ }
+ }
+
+ private CinemachineBrain cachedBrain;
+
+ public CinemachineBrain GetCinemachineBrain()
+ {
+ if (cachedBrain != null) return cachedBrain;
+
+ var mainCam = Camera.main;
+ if (mainCam != null)
+ {
+ cachedBrain = mainCam.GetComponent();
+ if (cachedBrain != null) return cachedBrain;
+ }
+ cachedBrain = FindFirstObjectByType();
+ return cachedBrain;
+ }
#endregion
#region Initialization
@@ -1175,7 +1230,9 @@ public class CameraManager : MonoBehaviour, IController
camera_name = currentPreset.virtualCamera?.gameObject.name ?? "Unknown",
preset_name = currentPreset.presetName,
total_cameras = cameraPresets.Count,
- is_drone_mode = IsDroneModeActive
+ is_drone_mode = IsDroneModeActive,
+ default_blend_style = DefaultBlendStyleName,
+ is_default_blend_cut = IsDefaultBlendCut
};
}
@@ -1203,6 +1260,8 @@ public class CameraManager : MonoBehaviour, IController
public string preset_name;
public int total_cameras;
public bool is_drone_mode;
+ public string default_blend_style;
+ public bool is_default_blend_cut;
}
#endregion
@@ -1253,6 +1312,10 @@ public class CameraManager : MonoBehaviour, IController
// 드론 상태는 GetCurrentCameraState()의 is_drone_mode로 반환됨
break;
+ case "toggle_default_blend":
+ ToggleDefaultBlend();
+ break;
+
default:
Debug.LogWarning($"[CameraManager] 알 수 없는 액션: {actionId}");
break;
diff --git a/Assets/Scripts/Streamingle/StreamingleControl/Controllers/System/RuntimeControlPanelManager.cs b/Assets/Scripts/Streamingle/StreamingleControl/Controllers/System/RuntimeControlPanelManager.cs
index 46d4fc91b..eebb9af60 100644
--- a/Assets/Scripts/Streamingle/StreamingleControl/Controllers/System/RuntimeControlPanelManager.cs
+++ b/Assets/Scripts/Streamingle/StreamingleControl/Controllers/System/RuntimeControlPanelManager.cs
@@ -213,6 +213,23 @@ public class RuntimeControlPanelManager
return;
}
+ // Default Blend 토글 (Cinemachine Brain)
+ var blendRow = new VisualElement();
+ blendRow.AddToClassList("action-row");
+
+ bool isCut = cam.IsDefaultBlendCut;
+ string styleName = cam.DefaultBlendStyleName;
+ var blendBtn = MakeButton(
+ $"Blend: {styleName} ▶ {(isCut ? "EaseInOut" : "Cut")}로 전환",
+ isCut ? "action-btn--secondary" : "action-btn--success");
+ blendBtn.clicked += () =>
+ {
+ cam.ToggleDefaultBlend();
+ SwitchCategory("camera");
+ };
+ blendRow.Add(blendBtn);
+ actionList.Add(blendRow);
+
var data = cam.GetCameraListData();
if (data?.presets == null) return;
diff --git a/Assets/Scripts/Streamingle/StreamingleControl/Editor/CameraManagerEditor.cs b/Assets/Scripts/Streamingle/StreamingleControl/Editor/CameraManagerEditor.cs
index d0efa35c4..abbce54d2 100644
--- a/Assets/Scripts/Streamingle/StreamingleControl/Editor/CameraManagerEditor.cs
+++ b/Assets/Scripts/Streamingle/StreamingleControl/Editor/CameraManagerEditor.cs
@@ -1,3 +1,4 @@
+using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
using UnityEngine.UIElements;
@@ -11,8 +12,13 @@ public class CameraManagerEditor : Editor
private const string UssPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/CameraManagerEditor.uss";
private const string CommonUssPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/StreamingleCommon.uss";
+ private VisualElement presetsSection;
private VisualElement presetsContainer;
+ private VisualElement dropZone;
private Label presetsTitleLabel;
+ private Button defaultBlendButton;
+ private Label defaultBlendStatusLabel;
+ private HelpBox brainNotFoundWarning;
private CameraManager manager;
public override VisualElement CreateInspectorGUI()
@@ -36,6 +42,9 @@ public class CameraManagerEditor : Editor
SetupRotationTargetLogic(root);
SetupBlendTransitionLogic(root);
+ // Cinemachine Brain default blend toggle
+ SetupDefaultBlendSection(root);
+
// Preset list (dynamic)
BuildPresetsSection(root);
@@ -162,12 +171,104 @@ public class CameraManagerEditor : Editor
#endregion
+ #region Cinemachine Brain
+
+ private void SetupDefaultBlendSection(VisualElement root)
+ {
+ var group = root.Q("defaultBlendGroup");
+ brainNotFoundWarning = root.Q("brainNotFoundWarning");
+ if (group == null) return;
+
+ var row = new VisualElement();
+ row.AddToClassList("blend-toggle-row");
+
+ defaultBlendStatusLabel = new Label("현재 Default Blend:");
+ defaultBlendStatusLabel.AddToClassList("blend-toggle-label");
+ row.Add(defaultBlendStatusLabel);
+
+ defaultBlendButton = new Button(ToggleEditorDefaultBlend) { text = "-" };
+ defaultBlendButton.AddToClassList("blend-toggle-btn");
+ row.Add(defaultBlendButton);
+
+ group.Add(row);
+
+ // 인스펙터가 포커스를 가질 때 주기적으로 UI 동기화 (씬에서 직접 편집한 경우 반영)
+ root.schedule.Execute(UpdateDefaultBlendUI).Every(500);
+ UpdateDefaultBlendUI();
+ }
+
+ private CinemachineBrain FindBrain()
+ {
+ var mainCam = Camera.main;
+ if (mainCam != null)
+ {
+ var brain = mainCam.GetComponent();
+ if (brain != null) return brain;
+ }
+ return FindFirstObjectByType();
+ }
+
+ private void UpdateDefaultBlendUI()
+ {
+ if (defaultBlendButton == null) return;
+
+ var brain = FindBrain();
+ if (brain == null)
+ {
+ defaultBlendButton.text = "Brain 없음";
+ defaultBlendButton.SetEnabled(false);
+ defaultBlendButton.RemoveFromClassList("blend-toggle-btn--cut");
+ defaultBlendButton.RemoveFromClassList("blend-toggle-btn--ease");
+
+ if (defaultBlendStatusLabel != null)
+ defaultBlendStatusLabel.text = "CinemachineBrain을 찾을 수 없음";
+
+ if (brainNotFoundWarning != null)
+ brainNotFoundWarning.style.display = DisplayStyle.Flex;
+ return;
+ }
+
+ if (brainNotFoundWarning != null)
+ brainNotFoundWarning.style.display = DisplayStyle.None;
+
+ var style = brain.DefaultBlend.Style;
+ bool isCut = style == CinemachineBlendDefinition.Styles.Cut;
+ bool isEase = style == CinemachineBlendDefinition.Styles.EaseInOut;
+
+ if (defaultBlendStatusLabel != null)
+ defaultBlendStatusLabel.text = $"현재 Default Blend: {style}";
+
+ defaultBlendButton.SetEnabled(true);
+ defaultBlendButton.text = isCut ? "▶ EaseInOut 으로 전환" : "▶ Cut 으로 전환";
+
+ defaultBlendButton.EnableInClassList("blend-toggle-btn--cut", isCut);
+ defaultBlendButton.EnableInClassList("blend-toggle-btn--ease", isEase);
+ }
+
+ private void ToggleEditorDefaultBlend()
+ {
+ var brain = FindBrain();
+ if (brain == null) return;
+
+ Undo.RecordObject(brain, "Toggle CinemachineBrain Default Blend");
+ var blend = brain.DefaultBlend;
+ blend.Style = (blend.Style == CinemachineBlendDefinition.Styles.Cut)
+ ? CinemachineBlendDefinition.Styles.EaseInOut
+ : CinemachineBlendDefinition.Styles.Cut;
+ brain.DefaultBlend = blend;
+ EditorUtility.SetDirty(brain);
+
+ UpdateDefaultBlendUI();
+ }
+
+ #endregion
+
#region Preset List
private void BuildPresetsSection(VisualElement root)
{
- var section = root.Q("presetsSection");
- if (section == null) return;
+ presetsSection = root.Q("presetsSection");
+ if (presetsSection == null) return;
// Header
var header = new VisualElement();
@@ -177,19 +278,88 @@ public class CameraManagerEditor : Editor
presetsTitleLabel.AddToClassList("presets-title");
header.Add(presetsTitleLabel);
- var addBtn = new Button(AddPreset) { text = "+ 프리셋 추가" };
+ var addBtn = new Button(AddPreset)
+ {
+ text = "+ 프리셋 추가",
+ tooltip = "씬의 첫 CinemachineCamera를 추가합니다. 아래 영역으로 드래그해서 추가할 수도 있습니다."
+ };
addBtn.AddToClassList("preset-add-btn");
header.Add(addBtn);
- section.Add(header);
+ presetsSection.Add(header);
// Container
presetsContainer = new VisualElement();
- section.Add(presetsContainer);
+ presetsSection.Add(presetsContainer);
+
+ // Drop zone
+ dropZone = new Label("CinemachineCamera를 여기로 드래그하여 추가");
+ dropZone.AddToClassList("preset-drop-zone");
+ presetsSection.Add(dropZone);
+
+ SetupDragAndDrop(presetsSection);
RebuildPresetList();
}
+ private void SetupDragAndDrop(VisualElement target)
+ {
+ target.RegisterCallback(evt =>
+ {
+ if (GetDraggedCameras().Count > 0)
+ {
+ DragAndDrop.visualMode = DragAndDropVisualMode.Copy;
+ dropZone?.AddToClassList("preset-drop-zone--active");
+ evt.StopPropagation();
+ }
+ });
+
+ target.RegisterCallback(_ =>
+ {
+ dropZone?.RemoveFromClassList("preset-drop-zone--active");
+ });
+
+ target.RegisterCallback(_ =>
+ {
+ dropZone?.RemoveFromClassList("preset-drop-zone--active");
+ });
+
+ target.RegisterCallback(evt =>
+ {
+ var cams = GetDraggedCameras();
+ dropZone?.RemoveFromClassList("preset-drop-zone--active");
+ if (cams.Count == 0) return;
+
+ DragAndDrop.AcceptDrag();
+ Undo.RecordObject(this.target, "Add Camera Preset (Drop)");
+ foreach (var cam in cams)
+ {
+ manager.cameraPresets.Add(new CameraManager.CameraPreset(cam));
+ }
+ EditorUtility.SetDirty(this.target);
+ RebuildPresetList();
+ evt.StopPropagation();
+ });
+ }
+
+ private List GetDraggedCameras()
+ {
+ var list = new List();
+ foreach (var obj in DragAndDrop.objectReferences)
+ {
+ if (obj is CinemachineCamera cc)
+ {
+ if (!list.Contains(cc)) list.Add(cc);
+ }
+ else if (obj is GameObject go)
+ {
+ var cam = go.GetComponent();
+ if (cam != null && !list.Contains(cam)) list.Add(cam);
+ }
+ }
+ return list;
+ }
+
private void RebuildPresetList()
{
if (presetsContainer == null || manager == null) return;
@@ -226,14 +396,32 @@ public class CameraManagerEditor : Editor
var headerRow = new VisualElement();
headerRow.AddToClassList("preset-item-header");
- var indexLabel = new Label($"{index + 1}");
- indexLabel.AddToClassList("preset-index");
- headerRow.Add(indexLabel);
+ int idx = index;
+
+ var indexField = new IntegerField
+ {
+ value = index + 1,
+ isDelayed = true,
+ tooltip = "카메라 순서 번호. 숫자를 입력 후 Enter를 누르면 해당 위치로 이동합니다."
+ };
+ indexField.AddToClassList("preset-index-field");
+ indexField.RegisterValueChangedCallback(evt =>
+ {
+ int newIndex = Mathf.Clamp(evt.newValue - 1, 0, manager.cameraPresets.Count - 1);
+ if (newIndex != idx)
+ {
+ MovePreset(idx, newIndex);
+ }
+ else if (evt.newValue != idx + 1)
+ {
+ indexField.SetValueWithoutNotify(idx + 1);
+ }
+ });
+ headerRow.Add(indexField);
var nameField = new TextField();
nameField.value = preset.presetName;
nameField.AddToClassList("preset-name-field");
- int idx = index;
nameField.RegisterValueChangedCallback(evt =>
{
Undo.RecordObject(target, "Rename Camera Preset");
@@ -250,19 +438,24 @@ public class CameraManagerEditor : Editor
}
// Up button
- var upBtn = new Button(() => SwapPresets(index, index - 1)) { text = "\u25B2" };
+ var upBtn = new Button(() => SwapPresets(index, index - 1)) { text = "\u25B2", tooltip = "위로 이동" };
upBtn.AddToClassList("preset-reorder-btn");
upBtn.SetEnabled(index > 0);
headerRow.Add(upBtn);
// Down button
- var downBtn = new Button(() => SwapPresets(index, index + 1)) { text = "\u25BC" };
+ var downBtn = new Button(() => SwapPresets(index, index + 1)) { text = "\u25BC", tooltip = "아래로 이동" };
downBtn.AddToClassList("preset-reorder-btn");
downBtn.SetEnabled(index < manager.cameraPresets.Count - 1);
headerRow.Add(downBtn);
+ // Duplicate button
+ var duplicateBtn = new Button(() => DuplicatePreset(index)) { text = "\u29C9", tooltip = "프리셋 복제" };
+ duplicateBtn.AddToClassList("preset-duplicate-btn");
+ headerRow.Add(duplicateBtn);
+
// Delete button
- var deleteBtn = new Button(() => DeletePreset(index)) { text = "X" };
+ var deleteBtn = new Button(() => DeletePreset(index)) { text = "X", tooltip = "프리셋 삭제" };
deleteBtn.AddToClassList("preset-delete-btn");
headerRow.Add(deleteBtn);
@@ -278,11 +471,10 @@ public class CameraManagerEditor : Editor
allowSceneObjects = true,
value = preset.virtualCamera
};
- int ci = index;
cameraField.RegisterValueChangedCallback(evt =>
{
Undo.RecordObject(target, "Change Camera Preset Camera");
- manager.cameraPresets[ci].virtualCamera = evt.newValue as CinemachineCamera;
+ manager.cameraPresets[idx].virtualCamera = evt.newValue as CinemachineCamera;
EditorUtility.SetDirty(target);
});
fields.Add(cameraField);
@@ -292,11 +484,10 @@ public class CameraManagerEditor : Editor
tooltip = "이 카메라에서 마우스 조작(회전, 팬, 줌)을 허용할지 여부",
value = preset.allowMouseControl
};
- int mi = index;
mouseToggle.RegisterValueChangedCallback(evt =>
{
Undo.RecordObject(target, "Toggle Mouse Control");
- manager.cameraPresets[mi].allowMouseControl = evt.newValue;
+ manager.cameraPresets[idx].allowMouseControl = evt.newValue;
EditorUtility.SetDirty(target);
});
fields.Add(mouseToggle);
@@ -337,6 +528,36 @@ public class CameraManagerEditor : Editor
RebuildPresetList();
}
+ private void MovePreset(int from, int to)
+ {
+ if (from == to) return;
+ if (from < 0 || from >= manager.cameraPresets.Count) return;
+ if (to < 0 || to >= manager.cameraPresets.Count) return;
+
+ Undo.RecordObject(target, "Move Camera Preset");
+ var preset = manager.cameraPresets[from];
+ manager.cameraPresets.RemoveAt(from);
+ manager.cameraPresets.Insert(to, preset);
+ EditorUtility.SetDirty(target);
+ RebuildPresetList();
+ }
+
+ private void DuplicatePreset(int index)
+ {
+ if (index < 0 || index >= manager.cameraPresets.Count) return;
+
+ Undo.RecordObject(target, "Duplicate Camera Preset");
+ var source = manager.cameraPresets[index];
+ var copy = new CameraManager.CameraPreset(source.virtualCamera)
+ {
+ presetName = $"{source.presetName} (복사)",
+ allowMouseControl = source.allowMouseControl
+ };
+ manager.cameraPresets.Insert(index + 1, copy);
+ EditorUtility.SetDirty(target);
+ RebuildPresetList();
+ }
+
#endregion
#region Play Mode State
diff --git a/Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/CameraManagerEditor.uss b/Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/CameraManagerEditor.uss
index 426b38d8c..455f0ae59 100644
--- a/Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/CameraManagerEditor.uss
+++ b/Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/CameraManagerEditor.uss
@@ -1,5 +1,54 @@
/* Camera Manager Editor styles */
+.blend-toggle-row {
+ flex-direction: row;
+ align-items: center;
+ padding: 4px 2px;
+}
+
+.blend-toggle-label {
+ flex-grow: 1;
+ font-size: 11px;
+ color: #d4d4d4;
+}
+
+.blend-toggle-btn {
+ min-width: 160px;
+ padding: 4px 10px;
+ border-radius: 4px;
+ border-width: 0;
+ font-size: 11px;
+ -unity-font-style: bold;
+ background-color: rgba(255, 255, 255, 0.1);
+ color: #e5e7eb;
+}
+
+.blend-toggle-btn:hover {
+ background-color: rgba(255, 255, 255, 0.2);
+}
+
+.blend-toggle-btn:disabled {
+ opacity: 0.5;
+}
+
+.blend-toggle-btn--cut {
+ background-color: rgba(234, 179, 8, 0.25);
+ color: #fde68a;
+}
+
+.blend-toggle-btn--cut:hover {
+ background-color: rgba(234, 179, 8, 0.4);
+}
+
+.blend-toggle-btn--ease {
+ background-color: rgba(34, 197, 94, 0.25);
+ color: #bbf7d0;
+}
+
+.blend-toggle-btn--ease:hover {
+ background-color: rgba(34, 197, 94, 0.4);
+}
+
.presets-section {
margin-top: 12px;
}
@@ -63,6 +112,18 @@
color: #94a3b8;
}
+.preset-index-field {
+ width: 42px;
+ margin-right: 4px;
+}
+
+.preset-index-field > .unity-integer-field__input {
+ -unity-text-align: middle-center;
+ -unity-font-style: bold;
+ font-size: 11px;
+ padding: 0 2px;
+}
+
.preset-name-field {
flex-grow: 1;
margin-left: 4px;
@@ -97,6 +158,24 @@
opacity: 0.3;
}
+.preset-duplicate-btn {
+ width: 24px;
+ height: 20px;
+ padding: 0;
+ margin: 0 1px;
+ border-radius: 3px;
+ border-width: 0;
+ background-color: rgba(99, 102, 241, 0.15);
+ color: #a5b4fc;
+ font-size: 12px;
+ -unity-text-align: middle-center;
+}
+
+.preset-duplicate-btn:hover {
+ background-color: rgba(99, 102, 241, 0.35);
+ color: #c7d2fe;
+}
+
.preset-delete-btn {
width: 24px;
height: 20px;
@@ -126,3 +205,22 @@
font-size: 11px;
-unity-font-style: italic;
}
+
+.preset-drop-zone {
+ margin-top: 8px;
+ padding: 12px;
+ border-width: 1px;
+ border-color: rgba(255, 255, 255, 0.15);
+ border-radius: 6px;
+ background-color: rgba(255, 255, 255, 0.03);
+ -unity-text-align: middle-center;
+ color: #94a3b8;
+ font-size: 11px;
+}
+
+.preset-drop-zone--active {
+ border-color: #6366f1;
+ background-color: rgba(99, 102, 241, 0.15);
+ color: #c7d2fe;
+ -unity-font-style: bold;
+}
diff --git a/Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/CameraManagerEditor.uxml b/Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/CameraManagerEditor.uxml
index 4bbcc0e49..8214e526f 100644
--- a/Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/CameraManagerEditor.uxml
+++ b/Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/CameraManagerEditor.uxml
@@ -60,6 +60,14 @@
+
+
+
+
+
+
+
+