diff --git a/SYSTEM_SPEC.md b/SYSTEM_SPEC.md
index eef127f..7f2cbfb 100644
--- a/SYSTEM_SPEC.md
+++ b/SYSTEM_SPEC.md
@@ -225,4 +225,57 @@ IDLE → READY → SENDING → ON_AIR → NEXT
- 포맷 기반
- 컷 단위 송출
- 스케줄 큐 구조
-- 상태 머신 기반 제어
\ No newline at end of file
+- 상태 머신 기반 제어
+---
+
+## 15. 인코딩 검증 규칙
+
+- 한글 문자열이 포함된 파일을 수정한 뒤에는 반드시 인코딩 깨짐 여부를 다시 확인한다.
+- UI 문구, 로그 문구, 기본값 문자열은 저장 직후 한글이 정상 표시되는지 우선 점검한다.
+- `?`, `�`, 비정상 한자 형태의 모지바케가 보이면 즉시 수정 대상으로 간주한다.
+- 텍스트 파일은 UTF-8 기준으로 관리한다.
+---
+
+## 16. Karisma / Tornado3 연동 기준
+
+- CG 연동 라이브러리는 `Interop.KAsyncEngineLib.dll`을 사용한다.
+- 기본 접속 대상은 `127.0.0.1:30001`이다.
+- `TORNADO_KARISMA_HOST`가 있으면 기본 호스트 대신 사용한다.
+- `TORNADO_KARISMA_PORT`가 있으면 기본 포트 대신 사용한다.
+- 앱 시작 시 `T3_Cut 경로`가 유효하지 않으면 실CG 대신 Mock Adapter로 폴백한다.
+- 현재 구현 기준으로는 시작 시 Mock으로 결정된 경우, 설정 변경 후 실CG 재연결을 위해 앱 재시작이 필요할 수 있다.
+- 채널 기본 바인딩은 `노멀=0:0`, `좌상단=0:1`, `하단=0:2`, `비디오월=1:0`이다.
+- 환경변수 `TORNADO_KARISMA_BIND_NORMAL`, `TORNADO_KARISMA_BIND_TOPLEFT`, `TORNADO_KARISMA_BIND_BOTTOM`, `TORNADO_KARISMA_BIND_VIDEOWALL`로 채널 바인딩을 덮어쓸 수 있다.
+
+## 17. T3_Cut 운영 규칙
+
+- 사용자 설정 명칭은 `이미지 루트 경로`가 아니라 `T3_Cut 경로`로 표기한다.
+- 송출에 사용하는 컷 파일 확장자는 `.tscn`이다.
+- 컷 파일은 `T3_Cut` 루트 아래의 고정된 포맷 구조를 기준으로 사용한다.
+- 포맷 목록은 폴더 스캔으로 동적 생성하지 않고 하드코딩된 목록으로 관리한다.
+- 같은 컷 이름에 `_loop.tscn` 파일이 있으면 반복 송출 컷으로 사용한다.
+- 최초 송출 시에는 기본 컷 파일을 사용한다.
+- 이미 송출 중인 상태에서 같은 컷을 다시 사용할 때는 `_loop.tscn`이 있으면 우선 사용한다.
+- `_loop.tscn`이 없으면 기본 `.tscn` 파일로 폴백한다.
+- 예시: `1-2위_광역단체장.tscn`은 최초 송출용, `1-2위_광역단체장_loop.tscn`은 반복 송출용으로 간주한다.
+
+## 18. CG 연동 상태 UI 표기 기준
+
+- 메인 화면 상단에는 `CG 연동 상태`를 표시한다.
+- 사용자는 UI에서 현재 어댑터가 `실CG`인지 `Mock`인지 즉시 식별할 수 있어야 한다.
+- 상단 상태 영역에는 실CG 연동 여부, 연결 대상, 채널 정상 상태 요약을 함께 표시한다.
+- 채널 패널별로도 해당 채널이 어떤 백엔드를 사용하는지 표시한다.
+- 실제 Karisma 사용 시 연결 대상 예시는 `127.0.0.1:30001` 형식으로 표시한다.
+
+## 19. CG Return Value / Callback 로그 정책
+
+- CG 시스템으로부터 오는 Return Value 관련 결과는 `로그` 탭에서 확인할 수 있어야 한다.
+- 즉시 반환되는 값과 비동기 콜백 결과를 모두 로그로 남긴다.
+- `Connect()` 호출 직후의 반환값은 즉시 로그로 기록한다.
+- `LoadScene()` 및 `LoadSceneForce()` 호출 결과도 즉시 로그로 기록한다.
+- `KAEventHandler` 기반 콜백 결과를 `LogService`를 통해 공용 로그에 남긴다.
+- 로그에는 콜백 이름, 결과 enum 이름, 숫자 코드, 추가 정보(scene, object, output, layer 등)를 함께 남긴다.
+- `OnConnect(int ErrorCode)`는 `0`을 성공으로 간주하고, `0`이 아닌 값은 실패로 기록한다.
+- `eKResult.RESULT_SUCCESS`는 정보 로그로 남기고, 그 외 결과는 경고 로그로 남긴다.
+- 현재 로깅 대상에는 `OnConnect`, `OnClose`, `OnLogMessage`, `OnMessageNo`, `OnLoadScene`, `OnLoadSceneForce`, `OnBeginTransaction`, `OnEndTransaction`, `OnHeartBeat`, `OnSetValue`, `OnScenePrepare`, `OnScenePrepareEx`, `OnPlay`, `OnPlayOut`, `OnPause`, `OnResume`, `OnStop`, `OnStopAll`, `OnCutIn`, `OnCutOut`, `OnTrigger`, `OnTriggerObject`, `OnQueryIsOnAir`, `OnQueryLayerCount`, `OnScenePlayingStarted`, `OnScenePlayed`, `OnSceneAnimationPlayed`, `OnScenePaused`를 포함한다.
+- CG 콜백 로그와 앱 내부 로그는 같은 로그 시스템에 합쳐서 표시한다.
\ No newline at end of file
diff --git a/Tornado3_2026Election/Controls/ChannelSchedulePanel.xaml b/Tornado3_2026Election/Controls/ChannelSchedulePanel.xaml
index c77f1c9..df94453 100644
--- a/Tornado3_2026Election/Controls/ChannelSchedulePanel.xaml
+++ b/Tornado3_2026Election/Controls/ChannelSchedulePanel.xaml
@@ -1,4 +1,4 @@
-
+
-
+ Content="?"
+ FontFamily="Bahnschrift SemiBold"
+ FontSize="11">
+
+
+
+
+
+
@@ -227,22 +246,51 @@
-
+
+
+
+
+ Text="실행 순서" />
+
+
+
+
+
+
@@ -267,11 +315,11 @@
CornerRadius="12">
+ Text="{x:Bind StateLabel, Mode=OneWay}" />
+ Text="{x:Bind LastPlayedLabel, Mode=OneWay}" />
@@ -286,8 +334,7 @@
-
-
+
@@ -299,24 +346,20 @@
Spacing="8"
VerticalAlignment="Center">
@@ -329,3 +372,4 @@
+
diff --git a/Tornado3_2026Election/Controls/ChannelSchedulePanel.xaml.cs b/Tornado3_2026Election/Controls/ChannelSchedulePanel.xaml.cs
index 889bf5d..ef66f7f 100644
--- a/Tornado3_2026Election/Controls/ChannelSchedulePanel.xaml.cs
+++ b/Tornado3_2026Election/Controls/ChannelSchedulePanel.xaml.cs
@@ -1,5 +1,11 @@
+using System;
+using System.Threading.Tasks;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Media.Animation;
+using Windows.Foundation;
+using Tornado3_2026Election.Domain;
using Tornado3_2026Election.ViewModels;
namespace Tornado3_2026Election.Controls;
@@ -23,4 +29,133 @@ public sealed partial class ChannelSchedulePanel : UserControl
get => (ChannelScheduleViewModel?)GetValue(ViewModelProperty);
set => SetValue(ViewModelProperty, value);
}
+
+ private void PromoteToNextButton_Click(object sender, RoutedEventArgs e)
+ {
+ var item = GetItem(sender);
+ var command = ViewModel?.PromoteToNextCommand;
+ if (item is null || command is null || !command.CanExecute(item))
+ {
+ return;
+ }
+
+ command.Execute(item);
+ }
+
+ private void MoveUpButton_Click(object sender, RoutedEventArgs e)
+ {
+ _ = AnimateSwapAsync(sender, moveUp: true);
+ }
+
+ private void MoveDownButton_Click(object sender, RoutedEventArgs e)
+ {
+ _ = AnimateSwapAsync(sender, moveUp: false);
+ }
+
+ private void RemoveItemButton_Click(object sender, RoutedEventArgs e)
+ {
+ var item = GetItem(sender);
+ var command = ViewModel?.RemoveItemCommand;
+ if (item is null || command is null || !command.CanExecute(item))
+ {
+ return;
+ }
+
+ command.Execute(item);
+ }
+
+ private static ChannelScheduleItem? GetItem(object sender)
+ {
+ return (sender as FrameworkElement)?.DataContext as ChannelScheduleItem;
+ }
+
+ private async Task AnimateSwapAsync(object sender, bool moveUp)
+ {
+ if (ViewModel is null)
+ {
+ return;
+ }
+
+ var item = GetItem(sender);
+ if (item is null)
+ {
+ return;
+ }
+
+ var currentIndex = ViewModel.Queue.IndexOf(item);
+ var targetIndex = currentIndex + (moveUp ? -1 : 1);
+ if (currentIndex < 0 || targetIndex < 0 || targetIndex >= ViewModel.Queue.Count)
+ {
+ return;
+ }
+
+ var swappedItem = ViewModel.Queue[targetIndex];
+ var movingContainer = QueueListView.ContainerFromItem(item) as ListViewItem;
+ var swappedContainer = QueueListView.ContainerFromItem(swappedItem) as ListViewItem;
+ var movingTop = movingContainer is null ? (double?)null : GetTop(movingContainer);
+ var swappedTop = swappedContainer is null ? (double?)null : GetTop(swappedContainer);
+
+ var command = moveUp ? ViewModel.MoveUpCommand : ViewModel.MoveDownCommand;
+ if (!command.CanExecute(item))
+ {
+ return;
+ }
+
+ command.Execute(item);
+
+ await Task.Yield();
+ QueueListView.UpdateLayout();
+
+ if (movingTop.HasValue)
+ {
+ AnimateToCurrentPosition(QueueListView.ContainerFromItem(item) as ListViewItem, movingTop.Value);
+ }
+
+ if (swappedTop.HasValue)
+ {
+ AnimateToCurrentPosition(QueueListView.ContainerFromItem(swappedItem) as ListViewItem, swappedTop.Value);
+ }
+ }
+
+ private double GetTop(FrameworkElement element)
+ {
+ return element.TransformToVisual(QueueListView).TransformPoint(new Point(0, 0)).Y;
+ }
+
+ private void AnimateToCurrentPosition(ListViewItem? container, double previousTop)
+ {
+ if (container is null)
+ {
+ return;
+ }
+
+ var currentTop = GetTop(container);
+ var delta = previousTop - currentTop;
+ if (Math.Abs(delta) < 0.5)
+ {
+ return;
+ }
+
+ var transform = container.RenderTransform as TranslateTransform;
+ if (transform is null)
+ {
+ transform = new TranslateTransform();
+ container.RenderTransform = transform;
+ }
+
+ transform.Y = delta;
+
+ var animation = new DoubleAnimation
+ {
+ To = 0,
+ Duration = new Duration(TimeSpan.FromMilliseconds(180)),
+ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }
+ };
+
+ var storyboard = new Storyboard();
+ storyboard.Children.Add(animation);
+ Storyboard.SetTarget(animation, transform);
+ Storyboard.SetTargetProperty(animation, nameof(TranslateTransform.Y));
+ storyboard.Begin();
+ }
}
diff --git a/Tornado3_2026Election/Domain/ChannelScheduleItem.cs b/Tornado3_2026Election/Domain/ChannelScheduleItem.cs
index 5254ce0..846b782 100644
--- a/Tornado3_2026Election/Domain/ChannelScheduleItem.cs
+++ b/Tornado3_2026Election/Domain/ChannelScheduleItem.cs
@@ -1,8 +1,8 @@
using System;
using System.Linq;
using System.Text.Json.Serialization;
-using Microsoft.UI.Xaml.Media;
using Microsoft.UI;
+using Microsoft.UI.Xaml.Media;
using Tornado3_2026Election.Common;
namespace Tornado3_2026Election.Domain;
@@ -38,6 +38,7 @@ public sealed class ChannelScheduleItem : ObservableObject
{
OnPropertyChanged(nameof(StateLabel));
OnPropertyChanged(nameof(StateBrush));
+ OnPropertyChanged(nameof(CardOpacity));
OnPropertyChanged(nameof(CanDelete));
}
}
@@ -60,7 +61,7 @@ public sealed class ChannelScheduleItem : ObservableObject
{
ScheduleQueueItemState.Next => "다음",
ScheduleQueueItemState.Sending => "준비",
- ScheduleQueueItemState.OnAir => "송출중",
+ ScheduleQueueItemState.OnAir => "송출 중",
ScheduleQueueItemState.Completed => "완료",
ScheduleQueueItemState.Error => "오류",
_ => "대기"
@@ -69,19 +70,19 @@ public sealed class ChannelScheduleItem : ObservableObject
[JsonIgnore]
public SolidColorBrush StateBrush => new(State switch
{
- ScheduleQueueItemState.Next => ColorHelper.FromArgb(255, 255, 145, 77),
- ScheduleQueueItemState.Sending => ColorHelper.FromArgb(255, 255, 191, 0),
- ScheduleQueueItemState.OnAir => ColorHelper.FromArgb(255, 202, 52, 52),
- ScheduleQueueItemState.Completed => ColorHelper.FromArgb(255, 68, 104, 77),
- ScheduleQueueItemState.Error => ColorHelper.FromArgb(255, 110, 39, 39),
- _ => ColorHelper.FromArgb(255, 80, 90, 110)
+ ScheduleQueueItemState.Next => ColorHelper.FromArgb(255, 245, 158, 11),
+ ScheduleQueueItemState.OnAir => ColorHelper.FromArgb(255, 239, 68, 68),
+ _ => ColorHelper.FromArgb(255, 100, 116, 139)
});
+ [JsonIgnore]
+ public double CardOpacity => State == ScheduleQueueItemState.Completed ? 0.45 : 1.0;
+
[JsonIgnore]
public bool CanDelete => State is not ScheduleQueueItemState.OnAir and not ScheduleQueueItemState.Sending;
[JsonIgnore]
- public string LastPlayedLabel => LastPlayedAt?.ToString("HH:mm:ss") ?? "not played";
+ public string LastPlayedLabel => LastPlayedAt?.ToString("HH:mm:ss") ?? "아직 송출 안 함";
public static ChannelScheduleItem FromTemplate(FormatTemplateDefinition template)
{
diff --git a/Tornado3_2026Election/Domain/FormatTemplateDefinition.cs b/Tornado3_2026Election/Domain/FormatTemplateDefinition.cs
index b91aba3..f23099f 100644
--- a/Tornado3_2026Election/Domain/FormatTemplateDefinition.cs
+++ b/Tornado3_2026Election/Domain/FormatTemplateDefinition.cs
@@ -14,7 +14,23 @@ public sealed class FormatTemplateDefinition
public required bool RequiresImage { get; init; }
+ public required bool SupportsPreElection { get; init; }
+
+ public required bool SupportsCounting { get; init; }
+
+ public required bool RequiresCandidateData { get; init; }
+
public required LoopMode LoopMode { get; init; }
public required IReadOnlyList Cuts { get; init; }
+
+ public bool IsAvailableInPhase(BroadcastPhase phase)
+ {
+ return phase switch
+ {
+ BroadcastPhase.PreElection => SupportsPreElection,
+ BroadcastPhase.Counting => SupportsCounting,
+ _ => false
+ };
+ }
}
diff --git a/Tornado3_2026Election/MainWindow.xaml b/Tornado3_2026Election/MainWindow.xaml
index c43898b..e5c40d9 100644
--- a/Tornado3_2026Election/MainWindow.xaml
+++ b/Tornado3_2026Election/MainWindow.xaml
@@ -1,4 +1,4 @@
-
-
-
-
+
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
-
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -195,8 +208,7 @@
-
-
+
@@ -205,8 +217,7 @@
-
-
+
@@ -214,22 +225,13 @@
-
-
+
-
-
-
-
-
-
-
-
@@ -244,10 +246,10 @@
-
-
-
-
+
+
+
+
@@ -270,9 +272,9 @@
-
+
-
+
@@ -294,7 +296,7 @@
-
@@ -319,8 +321,27 @@
BorderThickness="1"
CornerRadius="18">
-
-
+
+
+
+
+
@@ -353,7 +374,7 @@
SelectedValue="{Binding ManualJudgement, Mode=TwoWay}"
SelectedValuePath="Value" />
-
+
@@ -375,10 +396,32 @@
-
+ Text="{x:Bind ViewModel.Settings.ImageRootPath, Mode=OneWay}">
+
+
+
+
+
+
+
-
-
-
+
+
+
+
-
-
+
@@ -420,7 +480,7 @@
-
+
@@ -428,8 +488,7 @@
-
-
+
@@ -476,7 +535,7 @@
-
+
@@ -492,7 +551,6 @@
-
@@ -518,3 +576,5 @@
+
+
diff --git a/Tornado3_2026Election/Persistence/AppState.cs b/Tornado3_2026Election/Persistence/AppState.cs
index df7e1ac..acd373b 100644
--- a/Tornado3_2026Election/Persistence/AppState.cs
+++ b/Tornado3_2026Election/Persistence/AppState.cs
@@ -1,4 +1,4 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
namespace Tornado3_2026Election.Persistence;
@@ -10,7 +10,7 @@ public sealed class AppState
public string SelectedStationId { get; set; } = "KNN";
- public string ImageRootPath { get; set; } = @"C:\ElectionImages";
+ public string ImageRootPath { get; set; } = @"C:\Users\y2keu\Downloads\T3_Cut";
public bool AutoRestoreSchedules { get; set; } = true;
@@ -48,3 +48,4 @@ public sealed class AppState
public Dictionary StationRegionFilters { get; set; } = [];
}
+
diff --git a/Tornado3_2026Election/Services/ChannelScheduleEngine.cs b/Tornado3_2026Election/Services/ChannelScheduleEngine.cs
index 649ca5a..9994ed1 100644
--- a/Tornado3_2026Election/Services/ChannelScheduleEngine.cs
+++ b/Tornado3_2026Election/Services/ChannelScheduleEngine.cs
@@ -19,6 +19,7 @@ public sealed class ChannelScheduleEngine
private readonly SemaphoreSlim _executionLock = new(1, 1);
private CancellationTokenSource? _playbackCts;
private TaskCompletionSource? _advanceSignal;
+ private Guid? _preferredNextItemId;
public ChannelScheduleEngine(
BroadcastChannel channel,
@@ -82,6 +83,7 @@ public sealed class ChannelScheduleEngine
item.State = ScheduleQueueItemState.Queued;
}
+ _preferredNextItemId = null;
IsRunning = false;
RefreshQueueMarkers();
QueueChanged?.Invoke(this, EventArgs.Empty);
@@ -89,6 +91,7 @@ public sealed class ChannelScheduleEngine
public void Reset()
{
+ _preferredNextItemId = null;
foreach (var item in Queue)
{
item.State = ScheduleQueueItemState.Queued;
@@ -106,6 +109,15 @@ public sealed class ChannelScheduleEngine
}
await _adapter.OutAsync(Channel, CancellationToken.None).ConfigureAwait(false);
+ var activeItem = Queue.FirstOrDefault(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending);
+ if (activeItem is not null)
+ {
+ activeItem.State = ScheduleQueueItemState.Completed;
+ activeItem.LastError = string.Empty;
+ }
+
+ RefreshQueueMarkers();
+ QueueChanged?.Invoke(this, EventArgs.Empty);
_advanceSignal?.TrySetResult(true);
}
@@ -119,6 +131,11 @@ public sealed class ChannelScheduleEngine
var removed = Queue.Remove(item);
if (removed)
{
+ if (_preferredNextItemId == item.Id)
+ {
+ _preferredNextItemId = null;
+ }
+
RefreshQueueMarkers();
}
@@ -138,26 +155,17 @@ public sealed class ChannelScheduleEngine
public bool PromoteToNext(ChannelScheduleItem? item)
{
- if (item is null)
+ if (item is null || item.State is not (ScheduleQueueItemState.Queued or ScheduleQueueItemState.Next))
{
return false;
}
- var currentIndex = Queue.IndexOf(item);
- if (currentIndex < 0)
+ if (_preferredNextItemId == item.Id && item.State == ScheduleQueueItemState.Next)
{
return false;
}
- var onAirIndex = Queue.ToList().FindIndex(entry => entry.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending);
- var targetIndex = onAirIndex >= 0 ? onAirIndex + 1 : 0;
-
- if (targetIndex == currentIndex)
- {
- return false;
- }
-
- Queue.Move(currentIndex, Math.Min(targetIndex, Queue.Count - 1));
+ _preferredNextItemId = item.Id;
RefreshQueueMarkers();
return true;
}
@@ -300,13 +308,23 @@ public sealed class ChannelScheduleEngine
private ChannelScheduleItem? GetNextPlayableItem()
{
- return Queue.FirstOrDefault(item => item.State is ScheduleQueueItemState.Queued or ScheduleQueueItemState.Next);
+ return Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Next)
+ ?? Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Queued);
}
public void RefreshQueueMarkers()
{
var activeItem = Queue.FirstOrDefault(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending);
- var nextItem = Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Queued);
+ var pendingItems = Queue
+ .Where(item => item != activeItem && item.State is ScheduleQueueItemState.Queued or ScheduleQueueItemState.Next)
+ .ToArray();
+
+ var nextItem = pendingItems.FirstOrDefault(item => _preferredNextItemId == item.Id);
+ if (nextItem is null)
+ {
+ _preferredNextItemId = null;
+ nextItem = pendingItems.FirstOrDefault();
+ }
foreach (var item in Queue)
{
diff --git a/Tornado3_2026Election/Services/FormatCatalogService.cs b/Tornado3_2026Election/Services/FormatCatalogService.cs
index 9c8b6dd..818a663 100644
--- a/Tornado3_2026Election/Services/FormatCatalogService.cs
+++ b/Tornado3_2026Election/Services/FormatCatalogService.cs
@@ -1,4 +1,6 @@
+using System;
using System.Collections.Generic;
+using System.IO;
using System.Linq;
using Tornado3_2026Election.Domain;
@@ -6,75 +8,20 @@ namespace Tornado3_2026Election.Services;
public sealed class FormatCatalogService
{
- private readonly IReadOnlyList _formats =
- [
- new FormatTemplateDefinition
- {
- Id = "normal-national-summary",
- Name = "노멀 전국 요약",
- Description = "개표율과 상위 후보 요약을 2컷으로 송출합니다.",
- RecommendedChannel = BroadcastChannel.Normal,
- RequiresImage = false,
- LoopMode = LoopMode.None,
- Cuts =
- [
- new FormatCutDefinition { Name = "오프닝 컷", DurationSeconds = 5 },
- new FormatCutDefinition { Name = "상위 후보 컷", DurationSeconds = 7 }
- ]
- },
- new FormatTemplateDefinition
- {
- Id = "normal-regional-loop",
- Name = "노멀 시도 루프",
- Description = "방송사 지역 필터 기준으로 시도별 컷을 반복 송출합니다.",
- RecommendedChannel = BroadcastChannel.Normal,
- RequiresImage = false,
- LoopMode = LoopMode.StationRegions,
- Cuts =
- [
- new FormatCutDefinition { Name = "지역 루프 컷", DurationSeconds = 4 }
- ]
- },
- new FormatTemplateDefinition
- {
- Id = "top-left-status",
- Name = "좌상단 현황",
- Description = "좌상단 영역에 간단한 현황 템플릿을 송출합니다.",
- RecommendedChannel = BroadcastChannel.TopLeft,
- RequiresImage = false,
- LoopMode = LoopMode.None,
- Cuts =
- [
- new FormatCutDefinition { Name = "좌상단 단일 컷", DurationSeconds = 6 }
- ]
- },
- new FormatTemplateDefinition
- {
- Id = "bottom-candidate-bar",
- Name = "하단 후보 바",
- Description = "하단 라인에 후보별 득표 상황을 표시합니다.",
- RecommendedChannel = BroadcastChannel.Bottom,
- RequiresImage = false,
- LoopMode = LoopMode.None,
- Cuts =
- [
- new FormatCutDefinition { Name = "하단 바 컷", DurationSeconds = 8 }
- ]
- },
- new FormatTemplateDefinition
- {
- Id = "videowall-winner-card",
- Name = "비디오월 당선 카드",
- Description = "당선/확실 후보를 사진 포함 카드로 송출합니다.",
- RecommendedChannel = BroadcastChannel.VideoWall,
- RequiresImage = true,
- LoopMode = LoopMode.None,
- Cuts =
- [
- new FormatCutDefinition { Name = "당선 카드 컷", DurationSeconds = 9 }
- ]
- }
- ];
+ private static readonly HashSet MediaWallFormats = new(StringComparer.Ordinal)
+ {
+ "민방_타이틀",
+ "사전_역대투표율_loop",
+ "투표율_시도별",
+ "1-2위_광역단체장_5760",
+ "1-3위_기초단체장_5760",
+ "모든후보_광역단체장_5760",
+ "이시각1위_광역단체장",
+ "판세_기초단체장_5760",
+ "광역의원표"
+ };
+
+ private readonly IReadOnlyList _formats = BuildFormats();
public IReadOnlyList GetAll() => _formats;
@@ -85,6 +32,201 @@ public sealed class FormatCatalogService
public FormatTemplateDefinition? FindById(string formatId)
{
- return _formats.FirstOrDefault(format => string.Equals(format.Id, formatId, System.StringComparison.Ordinal));
+ return _formats.FirstOrDefault(format => string.Equals(format.Id, formatId, StringComparison.Ordinal));
+ }
+
+ private static IReadOnlyList BuildFormats()
+ {
+ List formats = [];
+
+ formats.AddRange(CreateFormats(
+ BroadcastChannel.Bottom,
+ "Elect2026_Bottom_민방",
+ 8,
+ "1-2위_광역단체장",
+ "1-2위_기초단체장",
+ "1-3위_광역단체장",
+ "1-3위_기초단체장",
+ "1위_광역단체장",
+ "1위_기초단체장",
+ "당선_광역단체장",
+ "당선_광역의원",
+ "당선_기초단체장",
+ "당선_기초의원",
+ "사전투표율",
+ "전후보_광역단체장",
+ "전후보_교육감",
+ "전후보_기초단체장",
+ "투표율"));
+
+ formats.AddRange(CreateFormats(
+ BroadcastChannel.Normal,
+ "Elect2026_Normal_민방",
+ 10,
+ "1-2위_ani_광역단체장",
+ "1-2위_ani_기초단체장",
+ "1-2위_ani_기초단체장_5760",
+ "1-2위_ani_기초단체장_L",
+ "1-2위_광역단체장",
+ "1-2위_광역단체장_5760",
+ "1-2위_광역단체장_L",
+ "1-2위_광역단체장_시도별영상",
+ "1-2위_교육감",
+ "1-2위_기초단체장",
+ "1-2위_기초단체장_시도별영상",
+ "1-2위_보궐선거",
+ "1-3위_ani_광역단체장",
+ "1-3위_ani_기초단체장",
+ "1-3위_기초단체장_5760",
+ "1-3위_기초단체장_L",
+ "1-3위_기초단체장_L_1",
+ "1-3위_보궐선거",
+ "경력_광역단체장_in",
+ "경력_기초단체장_in",
+ "광역의원표",
+ "광역의원표_HD",
+ "광역의원표_L",
+ "광역의원표_L_1",
+ "기초의원표",
+ "기초의원표_HD",
+ "기초의원표_L",
+ "기초의원표_L_1",
+ "당선_광역단체장",
+ "당선_광역단체장_HD",
+ "당선_광역단체장_L",
+ "당선_광역의원",
+ "당선_광역의원_HD",
+ "당선_광역의원_L",
+ "당선_교육감",
+ "당선_교육감_HD",
+ "당선_교육감_L",
+ "당선_기초단체장",
+ "당선_기초단체장_HD",
+ "당선_기초단체장_L",
+ "당선_기초의원",
+ "당선_기초의원_HD",
+ "당선_기초의원_L",
+ "모든후보_광역단체장",
+ "모든후보_광역단체장_5760",
+ "모든후보_광역단체장_5760_END",
+ "모든후보_광역단체장_END",
+ "모든후보_광역단체장_L",
+ "모든후보_광역단체장_L_END",
+ "모든후보_교육감",
+ "모든후보_교육감_5760",
+ "모든후보_교육감_5760_END",
+ "모든후보_교육감_END",
+ "모든후보_교육감_L",
+ "모든후보_교육감_L_END",
+ "모든후보_기초단체장",
+ "모든후보_기초단체장_5760",
+ "모든후보_기초단체장_5760_END",
+ "모든후보_기초단체장_END",
+ "모든후보_기초단체장_L",
+ "모든후보_기초단체장_L_END",
+ "민방_타이틀",
+ "민방_타이틀_1920",
+ "민방_타이틀_1920_notext",
+ "민방_타이틀_5760_nologo",
+ "민방_타이틀_L",
+ "민방_타이틀_L_nologo",
+ "사전_역대당선자",
+ "사전_역대당선자_교육감",
+ "사전_역대당선자_기초단체장",
+ "사전_역대투표율",
+ "사전_역대투표율_loop",
+ "사전_역대투표율_5760",
+ "사전_역대투표율_L",
+ "사전_역대투표율_L_1",
+ "역대시도판세_광역단체장",
+ "역대시도판세_기초단체장",
+ "이시각1위_광역단체장",
+ "이시각1위_광역단체장_HD",
+ "이시각1위_광역단체장_L",
+ "이시각1위_기초단체장",
+ "이시각1위_기초단체장_HD",
+ "이시각1위_기초단체장_L",
+ "접전_광역단체장",
+ "접전_기초단체장",
+ "초접전_광역단체장",
+ "초접전_기초단체장",
+ "투표율_사진",
+ "투표율_선거구별 사전",
+ "투표율_시도별",
+ "투표율_시도별_L",
+ "투표율_영상",
+ "판세_광역단체장",
+ "판세_기초단체장",
+ "판세_기초단체장_5760",
+ "판세_기초단체장_7680"));
+
+ formats.AddRange(CreateFormats(
+ BroadcastChannel.TopLeft,
+ "Elect2026_Top_민방",
+ 6,
+ "광역단체장_2인",
+ "광역단체장_2인_텍스트",
+ "기초단체장_2인",
+ "기초단체장_2인_텍스트",
+ "투표율",
+ "투표율_선거구별",
+ "판세_광역단체장",
+ "판세_광역의원",
+ "판세_교육감",
+ "판세_기초단체장",
+ "판세_기초의원"));
+
+ return formats;
+ }
+
+ private static IEnumerable CreateFormats(
+ BroadcastChannel channel,
+ string relativeFolder,
+ double defaultCutDurationSeconds,
+ params string[] baseNames)
+ {
+ foreach (var baseName in baseNames)
+ {
+ var isAvailableInBothPhases = IsAvailableInBothPhases(baseName);
+ var isPreElectionOnlyFormat = !isAvailableInBothPhases && IsPreElectionOnlyFormat(baseName);
+
+ yield return new FormatTemplateDefinition
+ {
+ Id = Path.Combine(relativeFolder, baseName),
+ Name = baseName,
+ Description = $"{relativeFolder} 컷",
+ RecommendedChannel = ResolveRecommendedChannel(channel, baseName),
+ RequiresImage = false,
+ SupportsPreElection = isAvailableInBothPhases || isPreElectionOnlyFormat,
+ SupportsCounting = isAvailableInBothPhases || !isPreElectionOnlyFormat,
+ RequiresCandidateData = !isPreElectionOnlyFormat,
+ LoopMode = LoopMode.None,
+ Cuts =
+ [
+ new FormatCutDefinition
+ {
+ Name = baseName,
+ DurationSeconds = defaultCutDurationSeconds
+ }
+ ]
+ };
+ }
+ }
+
+ private static bool IsPreElectionOnlyFormat(string baseName)
+ {
+ return baseName.Contains("투표율", StringComparison.Ordinal);
+ }
+
+ private static bool IsAvailableInBothPhases(string baseName)
+ {
+ return baseName.StartsWith("사전_역대당선자", StringComparison.Ordinal);
+ }
+
+ private static BroadcastChannel ResolveRecommendedChannel(BroadcastChannel fallbackChannel, string baseName)
+ {
+ return MediaWallFormats.Contains(baseName)
+ ? BroadcastChannel.VideoWall
+ : fallbackChannel;
}
}
diff --git a/Tornado3_2026Election/Services/ITornado3Adapter.cs b/Tornado3_2026Election/Services/ITornado3Adapter.cs
index 9346345..84e6c30 100644
--- a/Tornado3_2026Election/Services/ITornado3Adapter.cs
+++ b/Tornado3_2026Election/Services/ITornado3Adapter.cs
@@ -7,10 +7,20 @@ namespace Tornado3_2026Election.Services;
public interface ITornado3Adapter
{
+ string BackendName { get; }
+
+ bool IsLiveCg { get; }
+
+ bool IsConnected { get; }
+
+ string ConnectionTarget { get; }
+
TornadoConnectionState State { get; }
event EventHandler? StateChanged;
+ event EventHandler? ConnectionChanged;
+
Task EnsureConnectedAsync(CancellationToken cancellationToken);
Task ApplyCutAsync(
diff --git a/Tornado3_2026Election/Services/KarismaEventHandler.cs b/Tornado3_2026Election/Services/KarismaEventHandler.cs
new file mode 100644
index 0000000..a02baef
--- /dev/null
+++ b/Tornado3_2026Election/Services/KarismaEventHandler.cs
@@ -0,0 +1,267 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+using KAsyncEngineLib;
+
+namespace Tornado3_2026Election.Services;
+
+public class KarismaEventHandler : KAEventHandler
+{
+ private readonly LogService _logService;
+ private readonly Action? _onConnect;
+ private readonly Action? _onClose;
+
+ public KarismaEventHandler(LogService logService, Action? onConnect = null, Action? onClose = null)
+ {
+ _logService = logService;
+ _onConnect = onConnect;
+ _onClose = onClose;
+ }
+
+ private void LogResult(string callbackName, eKResult result, string? details = null)
+ {
+ var message = string.IsNullOrWhiteSpace(details)
+ ? $"CG callback {callbackName}: {result} ({(int)result})"
+ : $"CG callback {callbackName}: {result} ({(int)result}) / {details}";
+
+ if (result == eKResult.RESULT_SUCCESS)
+ {
+ _logService.Info(message);
+ }
+ else
+ {
+ _logService.Warning(message);
+ }
+ }
+
+ private void LogEvent(string eventName, string? details = null)
+ {
+ var message = string.IsNullOrWhiteSpace(details)
+ ? $"CG event {eventName}"
+ : $"CG event {eventName}: {details}";
+ _logService.Info(message);
+ }
+ public void OnLoadScene(eKResult Result, string SceneName) => LogResult(nameof(OnLoadScene), Result, $"scene={SceneName}");
+ public void OnLoadSceneForce(eKResult Result, string SceneName) => LogResult(nameof(OnLoadSceneForce), Result, $"scene={SceneName}");
+ public void OnLogMessage(string LogMessage) => LogEvent(nameof(OnLogMessage), LogMessage);
+ public void OnMessageNo(uint MessageNo) => LogEvent(nameof(OnMessageNo), $"messageNo={MessageNo}");
+ public void OnConnect(int ErrorCode) { if (ErrorCode == 0) { _logService.Info("CG callback OnConnect: success (errorCode=0)"); } else { _logService.Error($"CG callback OnConnect: failed (errorCode={ErrorCode})"); } _onConnect?.Invoke(ErrorCode); }
+ public void OnClose(int ErrorCode) { if (ErrorCode == 0) { _logService.Info("CG callback OnClose: closed cleanly (errorCode=0)"); } else { _logService.Warning($"CG callback OnClose: closed with errorCode={ErrorCode}"); } _onClose?.Invoke(ErrorCode); }
+ public void OnBeginTransaction(eKResult Result) => LogResult(nameof(OnBeginTransaction), Result);
+ public void OnEndTransaction(eKResult Result) => LogResult(nameof(OnEndTransaction), Result);
+ public void OnHeartBeat(eKResult Result) => LogResult(nameof(OnHeartBeat), Result);
+ virtual public void OnUnloadAll(eKResult Result) { }
+ virtual public void OnSetTrialPlayoutMode(eKResult Result) { }
+ virtual public void OnCheckVersion(eKResult Result, string ServerVersion, string SDKVersion) { }
+ virtual public void OnSetAudioOutput(eKResult Result) { }
+ public void OnScenePrepare(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnScenePrepare), Result, $"output={OutputChannelIndex} layer={LayerNo}");
+ public void OnScenePrepareEx(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnScenePrepareEx), Result, $"output={OutputChannelIndex} layer={LayerNo}");
+ public void OnPlay(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnPlay), Result, $"output={OutputChannelIndex} layer={LayerNo}");
+ public void OnPlayOut(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnPlayOut), Result, $"output={OutputChannelIndex} layer={LayerNo}");
+ public void OnStop(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnStop), Result, $"output={OutputChannelIndex} layer={LayerNo}");
+ public void OnStopAll(eKResult Result) => LogResult(nameof(OnStopAll), Result);
+ public void OnPause(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnPause), Result, $"output={OutputChannelIndex} layer={LayerNo}");
+ public void OnResume(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnResume), Result, $"output={OutputChannelIndex} layer={LayerNo}");
+ public void OnQueryIsOnAir(eKResult Result, int OutputChannelIndex, int LayerNo, int bOnAir) => LogResult(nameof(OnQueryIsOnAir), Result, $"output={OutputChannelIndex} layer={LayerNo} onAir={(bOnAir != 0)}");
+ public void OnTrigger(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnTrigger), Result, $"output={OutputChannelIndex} layer={LayerNo}");
+ public void OnScenePlayingStarted(string SceneName, int OutputChannelIndex, int LayerNo) => LogEvent(nameof(OnScenePlayingStarted), $"scene={SceneName} output={OutputChannelIndex} layer={LayerNo}");
+ public void OnScenePlayed(string SceneName, int OutputChannelIndex, int LayerNo) => LogEvent(nameof(OnScenePlayed), $"scene={SceneName} output={OutputChannelIndex} layer={LayerNo}");
+ public void OnSceneAnimationPlayed(string SceneName, int OutputChannelIndex, int LayerNo, string AnimationName) => LogEvent(nameof(OnSceneAnimationPlayed), $"scene={SceneName} output={OutputChannelIndex} layer={LayerNo} animation={AnimationName}");
+ public void OnScenePaused(string SceneName, int OutputChannelIndex, int LayerNo, int bLastPause) => LogEvent(nameof(OnScenePaused), $"scene={SceneName} output={OutputChannelIndex} layer={LayerNo} lastPause={(bLastPause != 0)}");
+ virtual public void OnSceneSaved(eKResult Result, string FileName) { }
+ public void OnTriggerObject(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnTriggerObject), Result, $"output={OutputChannelIndex} layer={LayerNo}");
+ virtual public void OnResumeBackground(eKResult Result, int OutputChannelIndex, int LayerNo) { }
+ virtual public void OnSaveMixedPreviewImage(eKResult Result, int OutputChannelIndex, int LayerNo) { }
+ public void OnPlayDirect(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnPlayDirect), Result, $"output={OutputChannelIndex} layer={LayerNo}");
+ public void OnCutIn(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnCutIn), Result, $"output={OutputChannelIndex} layer={LayerNo}");
+ public void OnCutOut(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnCutOut), Result, $"output={OutputChannelIndex} layer={LayerNo}");
+ public void OnClearNextPreview(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnClearNextPreview), Result, $"output={OutputChannelIndex} layer={LayerNo}");
+ public void OnPlayRange(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnPlayRange), Result, $"output={OutputChannelIndex} layer={LayerNo}");
+ virtual public void OnQueryPlaybackRangeCount(eKResult Result, string SceneName, int PlaybackRangeCount) { }
+ virtual public void OnQueryPlaybackRange(eKResult Result, string SceneName, int PlaybackRangeNo, int Start, int End) { }
+ virtual public void OnQueryOutputChannelIndex(eKResult Result, string SceneName, int OutputChannelIndex) { }
+ public void OnPlayInNextPreview(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnPlayInNextPreview), Result, $"output={OutputChannelIndex} layer={LayerNo}");
+ public void OnPlayOutNextPreview(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnPlayOutNextPreview), Result, $"output={OutputChannelIndex} layer={LayerNo}");
+ virtual public void OnSetBackgroundFill(eKResult Result, string SceneName) { }
+ virtual public void OnSetBackgroundTexture(eKResult Result, string SceneName) { }
+ virtual public void OnSetBackgroundVideo(eKResult Result, string SceneName) { }
+ virtual public void OnSetBackgroundLiveIn(eKResult Result, string SceneName) { }
+ virtual public void OnUseBackground(eKResult Result, string SceneName) { }
+ virtual public void OnSetBackgroundVideoPlayInfo(eKResult Result, string SceneName) { }
+ virtual public void OnQueryBackgroundVideoPlayInfo(eKResult Result, string SceneName, ref sKVideoPlayInfo pVideoPlayInfo) { }
+ virtual public void OnSetSceneEffectType(eKResult Result, string SceneName) { }
+ virtual public void OnSaveSceneImage(eKResult Result, string SceneName) { }
+ virtual public void OnSaveScene(eKResult Result, string SceneName) { }
+ virtual public void OnUnloadScene(eKResult Result, string SceneName) { }
+ virtual public void OnReloadScene(eKResult Result, string SceneName) { }
+ virtual public void OnUpdateTextures(eKResult Result, string SceneName) { }
+ virtual public void OnSetSceneAudioFile(eKResult Result, string SceneName) { }
+ virtual public void OnEnableSceneAudio(eKResult Result, string SceneName) { }
+ virtual public void OnSetSceneDuration(eKResult Result, string SceneName) { }
+ virtual public void OnSetBackgroundPauseType(eKResult Result, string SceneName) { }
+ virtual public void OnSetBackgroundChangeType(eKResult Result, string SceneName) { }
+ virtual public void OnSetBackgroundPauseAtZeroFrameAsStandBy(eKResult Result, string SceneName) { }
+ virtual public void OnResetDuration(eKResult Result, string SceneName) { }
+ virtual public void OnSetDuration(eKResult Result, string SceneName) { }
+ virtual public void OnAddObject(eKResult Result, string SceneName) { }
+ virtual public void OnAddCloneObject(eKResult Result, string SceneName) { }
+ virtual public void OnUpdateThumbnail(eKResult Result, string SceneName) { }
+ virtual public void OnExportVideo(eKResult Result, string SceneName) { }
+ virtual public void OnStopVideoExporting(eKResult Result) { }
+ virtual public void OnQueryVideoExportingProgress(eKResult Result, string TargetName, int CurrentFrame, int TotalFrame) { }
+ virtual public void OnFinishedVideoExporting(eKResult Result, string FileName) { }
+ virtual public void OnAddPause(eKResult Result, string SceneName) { }
+ virtual public void OnDeletePause(eKResult Result, string SceneName) { }
+ virtual public void OnSetPause(eKResult Result, string SceneName) { }
+ virtual public void OnSetPauseWithIndex(eKResult Result, string SceneName) { }
+ virtual public void OnDeletePauseWithIndex(eKResult Result, string SceneName) { }
+ virtual public void OnQueryPauseCount(eKResult Result, string SceneName, int PauseCount) { }
+ virtual public void OnQueryObjectInfos(eKResult Result, string SceneName, KAObjectInfos pObjectInfos) { }
+ virtual public void OnQueryAnimationNames(eKResult Result, string SceneName, KAStrings pAnimationNames) { }
+ virtual public void OnQueryAnimationCount(eKResult Result, string SceneName, int AnimationCount) { }
+ virtual public void OnQueryObjectInfosByScreenPoint(eKResult Result, KAObjectInfos pObjectInfos) { }
+ virtual public void OnQuerySceneEffectType(eKResult Result, string SceneName, int bInEffect, eKEffectType EffectType, int Duration) { }
+ virtual public void OnQueryDuration(eKResult Result, string SceneName, string AnimationName, int Duration) { }
+ virtual public void OnQueryContentsOfTextObjects(eKResult Result, string SceneName, KAStrings pTexts) { }
+ virtual public void OnSetStyleColor(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetStyleTexture(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetFaceTextColor(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetEdgeTextColor(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetShadowTextColor(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetVisible(eKResult Result, string SceneName, string ObjectName) { }
+ public void OnSetValue(eKResult Result, string SceneName, string ObjectName) => LogResult(nameof(OnSetValue), Result, $"scene={SceneName} object={ObjectName}");
+ virtual public void OnAddText(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnStoreTextStyle(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetTextStyle(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnEditText(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetFont(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetTextRange(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnResetTextRange(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnQueryObjectType(eKResult Result, string SceneName, string ObjectName, eKObjectType ObjectType) { }
+ virtual public void OnSetChartCSVFile(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetChartCellData(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnQueryChartDataTable(eKResult Result, string SceneName, string ObjectName, KAChartDataTable Table) { }
+ virtual public void OnQuerySize(eKResult Result, string SceneName, string ObjectName, float Width, float Height) { }
+ virtual public void OnSetSize(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetCounterNumberKey(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetPositionKey(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetRotationKey(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetScaleKey(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetCylinderAngleKey(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetSphereAngleKey(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetCircleAngleKey(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetCropKey(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetCountDown(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetPosition(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetRotation(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetScale(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnAddPathPoint(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnClearPathPoints(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnAddPathShapePoint(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnClearPathShapePoints(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnQueryScrollRemainingDistance(eKResult Result, string SceneName, string ObjectName, int ScrollRemainingDistance) { }
+ virtual public void OnQueryScrollChildRemainingDistance(eKResult Result, string SceneName, string ObjectName, string ChildName, int ScrollRemainingDistance) { }
+ virtual public void OnAddScrollObject(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnAdjustScrollSpeed(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetScrollSpeed(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetVariableName(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetLoftPositionKey(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetChangeOut(eKResult Result, string SceneName) { }
+ virtual public void OnModifyPathPoint(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnInitScrollObject(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetCounterInfo(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetCounterNumber(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetCounterRange(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetCounterRemainingTime(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetCounterElapsedTime(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSaveObjectImage(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetPositionOfPathAnimation(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetPositionKeyOfPathAnimation(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetStartFrame(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetObjectEffectType(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetObjectOutEffectDelay(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetColor(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetColorKey(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetEmissiveColor(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetEmissiveColorKey(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetTransparencyOpacity(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetTransparencyOpacityKey(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetExposure(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetExposureKey(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetMaterialTextureType(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetMaterialTextureFile(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetMaterialTextureOffset(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetMaterialTextureOffsetKey(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetMaterialTextureTiling(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetMaterialTextureTilingKey(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetMaterialTextureRotation(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetMaterialTextureRotationKey(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetMaterialTextureOpacity(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetMaterialTextureOpacityKey(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnQueryGroupType(eKResult Result, string SceneName, string ObjectName, eKGroupType GroupType) { }
+ virtual public void OnQueryImageType(eKResult Result, string SceneName, string ObjectName, eKImageType ImageType) { }
+ virtual public void OnSetVideoPlayInfo(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnQueryVideoPlayInfo(eKResult Result, string SceneName, string ObjectName, ref sKVideoPlayInfo pVideoPlayInfo) { }
+ virtual public void OnQueryIs3D(eKResult Result, string SceneName, string ObjectName, int b3D) { }
+ virtual public void OnQueryPosition(eKResult Result, string SceneName, string ObjectName, float X, float Y, float Z) { }
+ virtual public void OnSetImageType(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetMemo(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnQueryMemo(eKResult Result, string SceneName, string ObjectName, string Memo) { }
+ virtual public void OnQueryFont(eKResult Result, string SceneName, string ObjectName, ref sKFont Param) { }
+ virtual public void OnSetImageOriginalSize(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnApplyChangeEffectLibrary(eKResult Result, string SceneName) { }
+ virtual public void OnApplyObjectLibrary(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnApplyTextureEffectLibrary(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetTableValue(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetTableColor(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnQueryTableValue(eKResult Result, string SceneName, string ObjectName, int Row, int Column, string Value) { }
+ virtual public void OnQueryTableValues(eKResult Result, string SceneName, string ObjectName, KATableValues pValues) { }
+ virtual public void OnSetPathShapeOutlineThickness(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnEnablePathShapeOutline(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetPlaybackCamera(eKResult Result, string SceneName) { }
+ virtual public void OnSetMaterialTextureVideoPlayInfo(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnQueryMaterialTextureVideoPlayInfo(eKResult Result, string SceneName, string ObjectName, ref sKVideoPlayInfo VideoPlayInfo) { }
+ virtual public void OnQueryVideoFormat(eKResult Result, ref sKVideoFormat VideoFormat) { }
+ virtual public void OnQueryLiveStreamingStatus(eKResult Result, string StreamingURI, eKLiveStreamingStatus Status) { }
+ virtual public void OnPreloadLiveStreaming(eKResult Result, string StreamingURI) { }
+ virtual public void OnReleaseLiveStreaming(eKResult Result, string StreamingURI) { }
+ public void OnUpdateImageResource(eKResult Result) => LogResult(nameof(OnUpdateImageResource), Result);
+ public void OnQueryLayerCount(eKResult Result, int LayerCount) => LogResult(nameof(OnQueryLayerCount), Result, $"layerCount={LayerCount}");
+ virtual public void OnSetLayerViewportRate(eKResult Result, int OutputChannelIndex, int LayerNo) { }
+ virtual public void OnSetLayerViewportRateEx(eKResult Result, int OutputChannelIndex, int LayerNo) { }
+ virtual public void OnSetFitting(eKResult Result, string SceneName) { }
+ virtual public void OnSetFittingOffset(eKResult Result, string SceneName) { }
+ virtual public void OnSetFittingScale(eKResult Result, string SceneName) { }
+ virtual public void OnSetLightColor(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnEnableLight(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetDirectionalLight(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetPointLight(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetSpotLight(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetInfinitePointLight(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetMaterialTextureLiveStreamingURI(eKResult Result, string SceneName, string ObjectName) { }
+ virtual public void OnSetBackgroundLiveStreamingURI(eKResult Result, string SceneName) { }
+ virtual public void OnLoadProject(eKResult Result, string FilePath, string AliasName) { }
+ virtual public void OnNewProject(eKResult Result, string AliasName) { }
+ virtual public void OnUnloadAllProject(eKResult Result) { }
+ virtual public void OnSaveProject(eKResult Result, string AliasName) { }
+ virtual public void OnQuerySceneItemCount(eKResult Result, string AliasName, int SceneItemCount) { }
+ virtual public void OnQuerySceneItemInfos(eKResult Result, string AliasName, KASceneItemInfos SceneItemInfos) { }
+ virtual public void OnAddSceneItem(eKResult Result, string AliasName, int Index) { }
+ virtual public void OnInsertSceneItem(eKResult Result, string AliasName) { }
+ virtual public void OnDeleteSceneItem(eKResult Result, string AliasNAme) { }
+ virtual public void OnQueryProjectFormat(eKResult Result, ref sKVideoFormat ProjectFormat) { }
+ virtual public void OnSetTimecode(eKResult Result, string AliasName) { }
+ virtual public void OnSetTimecodeInOut(eKResult Result, string AliasName) { }
+ virtual public void OnSetTimecodeTrack(eKResult Result, string AliasName) { }
+ virtual public void OnSetTimecodeInOutType(eKResult Result, string AliasName) { }
+ virtual public void OnDeleteTimecode(eKResult Result, string AliasName) { }
+ virtual public void OnQueryTimecode(eKResult Result, int TrackNo, int In, int Out, int bOnTrack) { }
+ virtual public void OnUnloadProject(eKResult Result, string AliasName) { }
+ virtual public void OnEnableSyncWithSceneEffect(eKResult Result, string AliasName) { }
+ virtual public void OnExportProjectVideo(eKResult Result, string AliasName) { }
+ virtual public void OnExportSceneImage(eKResult Result, string SceneName) { }
+ virtual public void OnStartVideoCapture(eKResult Result) { }
+ virtual public void OnStopVideoCapture(eKResult Result) { }
+ virtual public void OnCaptureImage(eKResult Result) { }
+
+}
diff --git a/Tornado3_2026Election/Services/KarismaTornado3Adapter.cs b/Tornado3_2026Election/Services/KarismaTornado3Adapter.cs
new file mode 100644
index 0000000..69ccc7b
--- /dev/null
+++ b/Tornado3_2026Election/Services/KarismaTornado3Adapter.cs
@@ -0,0 +1,393 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Tornado3_2026Election.Domain;
+
+namespace Tornado3_2026Election.Services;
+
+public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
+{
+ private const int DefaultKarismaPort = 30001;
+
+ private readonly TornadoManager _manager;
+ private readonly LogService _logService;
+ private readonly Func _t3CutPathProvider;
+ private readonly IReadOnlyDictionary _bindings;
+ private readonly string _connectionTarget;
+ private readonly Dictionary _pendingScenes = new();
+ private readonly Dictionary _channelOnAir = new();
+ private TornadoConnectionState _state = TornadoConnectionState.Idle;
+ private bool _disposed;
+
+ private KarismaTornado3Adapter(
+ TornadoManager manager,
+ LogService logService,
+ Func t3CutPathProvider,
+ string connectionTarget,
+ IReadOnlyDictionary bindings)
+ {
+ _manager = manager;
+ _logService = logService;
+ _t3CutPathProvider = t3CutPathProvider;
+ _connectionTarget = connectionTarget;
+ _bindings = bindings;
+ _manager.ConnectionChanged += (_, _) => ConnectionChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ public string BackendName => "Karisma CG";
+
+ public bool IsLiveCg => true;
+
+ public bool IsConnected => _manager.IsConnected;
+
+ public string ConnectionTarget => _connectionTarget;
+
+ public TornadoConnectionState State
+ {
+ get => _state;
+ private set
+ {
+ if (_state == value)
+ {
+ return;
+ }
+
+ _state = value;
+ StateChanged?.Invoke(this, value);
+ }
+ }
+
+ public event EventHandler? StateChanged;
+
+ public event EventHandler? ConnectionChanged;
+
+ public static ITornado3Adapter CreateOrFallback(LogService logService, Func t3CutPathProvider)
+ {
+ return TryCreate(logService, t3CutPathProvider, out var adapter)
+ ? adapter
+ : new MockTornado3Adapter(logService);
+ }
+
+ public static bool TryCreate(LogService logService, Func t3CutPathProvider, out ITornado3Adapter adapter)
+ {
+ var host = Environment.GetEnvironmentVariable("TORNADO_KARISMA_HOST");
+ if (string.IsNullOrWhiteSpace(host))
+ {
+ host = "127.0.0.1";
+ }
+
+ if (!int.TryParse(Environment.GetEnvironmentVariable("TORNADO_KARISMA_PORT"), out var port) || port <= 0)
+ {
+ port = DefaultKarismaPort;
+ logService.Info($"Karisma adapter using default port {DefaultKarismaPort}.");
+ }
+
+ var t3CutPath = t3CutPathProvider();
+ if (string.IsNullOrWhiteSpace(t3CutPath) || !Directory.Exists(t3CutPath))
+ {
+ logService.Warning("Karisma adapter disabled: set a valid T3_Cut path in Settings.");
+ adapter = new MockTornado3Adapter(logService);
+ return false;
+ }
+
+ var manager = new TornadoManager(host, port, logService);
+ adapter = new KarismaTornado3Adapter(
+ manager,
+ logService,
+ t3CutPathProvider,
+ $"{host}:{port}",
+ BuildBindings());
+ return true;
+ }
+
+ public async Task EnsureConnectedAsync(CancellationToken cancellationToken)
+ {
+ await ExecuteAsync(
+ async () =>
+ {
+ await _manager.EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
+ State = TornadoConnectionState.Ready;
+ },
+ "connect",
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ public async Task ApplyCutAsync(
+ BroadcastChannel channel,
+ FormatTemplateDefinition template,
+ FormatCutDefinition cut,
+ ElectionDataSnapshot snapshot,
+ BroadcastStationProfile station,
+ string imageRootPath,
+ CancellationToken cancellationToken)
+ {
+ await ExecuteAsync(
+ async () =>
+ {
+ var binding = ResolveBinding(channel);
+ var t3CutPath = ResolveT3CutPath();
+ var resolvedScene = ResolveScene(template, t3CutPath, IsChannelOnAir(channel));
+ var values = BuildObjectValues(template, cut, snapshot, station, t3CutPath);
+
+ State = TornadoConnectionState.Sending;
+ await _manager.EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
+ await _manager.LoadSceneAsync(resolvedScene.Path, resolvedScene.Alias, cancellationToken).ConfigureAwait(false);
+ await _manager.ApplyValuesAsync(resolvedScene.Alias, values, cancellationToken).ConfigureAwait(false);
+
+ _pendingScenes[channel] = resolvedScene.Alias;
+ _logService.Info($"[{channel}] Karisma scene prepared alias={resolvedScene.Alias} output={binding.OutputChannelIndex}:{binding.LayerNo}");
+ },
+ $"apply {template.Id}/{cut.Name}",
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ public async Task PrepareAsync(BroadcastChannel channel, CancellationToken cancellationToken)
+ {
+ await ExecuteAsync(
+ async () =>
+ {
+ if (!_pendingScenes.TryGetValue(channel, out var sceneAlias))
+ {
+ _logService.Warning($"[{channel}] No Karisma scene pending for Prepare.");
+ return;
+ }
+
+ var binding = ResolveBinding(channel);
+ await _manager.PrepareAsync(binding.OutputChannelIndex, binding.LayerNo, sceneAlias, cancellationToken).ConfigureAwait(false);
+ State = TornadoConnectionState.Ready;
+ },
+ $"prepare {channel}",
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ public async Task TakeAsync(BroadcastChannel channel, CancellationToken cancellationToken)
+ {
+ await ExecuteAsync(
+ async () =>
+ {
+ var binding = ResolveBinding(channel);
+ await _manager.PlayAsync(binding.OutputChannelIndex, binding.LayerNo, cutIn: false, cancellationToken).ConfigureAwait(false);
+ _channelOnAir[channel] = true;
+ State = TornadoConnectionState.OnAir;
+ },
+ $"take {channel}",
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ public async Task OutAsync(BroadcastChannel channel, CancellationToken cancellationToken)
+ {
+ await ExecuteAsync(
+ async () =>
+ {
+ var binding = ResolveBinding(channel);
+ await _manager.PlayOutAsync(binding.OutputChannelIndex, binding.LayerNo, cutOut: false, cancellationToken).ConfigureAwait(false);
+ _channelOnAir[channel] = false;
+ State = TornadoConnectionState.Idle;
+ },
+ $"out {channel}",
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ _manager.Dispose();
+ }
+
+ private async Task ExecuteAsync(Func action, string actionName, CancellationToken cancellationToken)
+ {
+ try
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ await action().ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ State = TornadoConnectionState.Error;
+ _logService.Error($"Karisma adapter failed to {actionName}: {ex.Message}");
+ }
+ }
+
+ private KarismaChannelBinding ResolveBinding(BroadcastChannel channel)
+ {
+ return _bindings.TryGetValue(channel, out var binding)
+ ? binding
+ : throw new InvalidOperationException($"No Karisma binding configured for channel: {channel}");
+ }
+
+ private string ResolveT3CutPath()
+ {
+ var path = _t3CutPathProvider();
+ if (string.IsNullOrWhiteSpace(path) || !Directory.Exists(path))
+ {
+ throw new DirectoryNotFoundException("T3_Cut path is not configured or does not exist.");
+ }
+
+ return path;
+ }
+
+ private bool IsChannelOnAir(BroadcastChannel channel)
+ {
+ return _channelOnAir.TryGetValue(channel, out var isOnAir) && isOnAir;
+ }
+
+ private static ResolvedScene ResolveScene(FormatTemplateDefinition template, string t3CutPath, bool useLoop)
+ {
+ var baseScenePath = Path.Combine(t3CutPath, template.Id + ".tscn");
+ var loopScenePath = Path.Combine(t3CutPath, template.Id + "_loop.tscn");
+
+ string selectedPath;
+ if (useLoop && File.Exists(loopScenePath))
+ {
+ selectedPath = loopScenePath;
+ }
+ else if (File.Exists(baseScenePath))
+ {
+ selectedPath = baseScenePath;
+ }
+ else if (File.Exists(loopScenePath))
+ {
+ selectedPath = loopScenePath;
+ }
+ else
+ {
+ throw new FileNotFoundException($"Karisma cut file was not found for '{template.Id}'.", baseScenePath);
+ }
+
+ return new ResolvedScene(
+ selectedPath,
+ template.Id.Replace('\\', '_').Replace('/', '_'));
+ }
+
+ private static IReadOnlyDictionary BuildBindings()
+ {
+ return new Dictionary
+ {
+ [BroadcastChannel.Normal] = ParseBinding("TORNADO_KARISMA_BIND_NORMAL", new KarismaChannelBinding(0, 0)),
+ [BroadcastChannel.TopLeft] = ParseBinding("TORNADO_KARISMA_BIND_TOPLEFT", new KarismaChannelBinding(0, 1)),
+ [BroadcastChannel.Bottom] = ParseBinding("TORNADO_KARISMA_BIND_BOTTOM", new KarismaChannelBinding(0, 2)),
+ [BroadcastChannel.VideoWall] = ParseBinding("TORNADO_KARISMA_BIND_VIDEOWALL", new KarismaChannelBinding(1, 0))
+ };
+ }
+
+ private static KarismaChannelBinding ParseBinding(string environmentVariableName, KarismaChannelBinding fallback)
+ {
+ var raw = Environment.GetEnvironmentVariable(environmentVariableName);
+ if (string.IsNullOrWhiteSpace(raw))
+ {
+ return fallback;
+ }
+
+ var parts = raw.Split(':', ',', ';');
+ return parts.Length >= 2 &&
+ int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var outputChannelIndex) &&
+ int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var layerNo)
+ ? new KarismaChannelBinding(outputChannelIndex, layerNo)
+ : fallback;
+ }
+
+ private static Dictionary BuildObjectValues(
+ FormatTemplateDefinition template,
+ FormatCutDefinition cut,
+ ElectionDataSnapshot snapshot,
+ BroadcastStationProfile station,
+ string t3CutPath)
+ {
+ var values = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["TemplateId"] = template.Id,
+ ["TemplateName"] = template.Name,
+ ["CutName"] = cut.Name,
+ ["Title"] = template.Name,
+ ["StationId"] = station.Id,
+ ["StationName"] = station.Name,
+ ["StationLogoPath"] = station.LogoAssetPath,
+ ["StationRegions"] = string.Join(", ", station.RegionFilters),
+ ["BroadcastPhase"] = snapshot.BroadcastPhase.ToString(),
+ ["ElectionType"] = snapshot.ElectionType,
+ ["DistrictName"] = snapshot.DistrictName,
+ ["DistrictCode"] = snapshot.DistrictCode,
+ ["TotalExpectedVotes"] = snapshot.TotalExpectedVotes.ToString(CultureInfo.InvariantCulture),
+ ["TotalExpectedVotesDisplay"] = snapshot.TotalExpectedVotes.ToString("N0", CultureInfo.InvariantCulture),
+ ["TurnoutVotes"] = snapshot.TurnoutVotes.ToString(CultureInfo.InvariantCulture),
+ ["TurnoutVotesDisplay"] = snapshot.TurnoutVotes.ToString("N0", CultureInfo.InvariantCulture),
+ ["TurnoutRate"] = snapshot.TurnoutRate.ToString("0.0", CultureInfo.InvariantCulture),
+ ["CountedVotes"] = snapshot.CountedVotes.ToString(CultureInfo.InvariantCulture),
+ ["CountedVotesDisplay"] = snapshot.CountedVotes.ToString("N0", CultureInfo.InvariantCulture),
+ ["RemainingVotes"] = snapshot.RemainingVotes.ToString(CultureInfo.InvariantCulture),
+ ["RemainingVotesDisplay"] = snapshot.RemainingVotes.ToString("N0", CultureInfo.InvariantCulture),
+ ["ImageRootPath"] = t3CutPath,
+ ["T3CutPath"] = t3CutPath,
+ ["Timestamp"] = snapshot.ReceivedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
+ };
+
+ var orderedCandidates = snapshot.Candidates
+ .OrderByDescending(candidate => candidate.VoteCount)
+ .ThenBy(candidate => candidate.Name, StringComparer.Ordinal)
+ .ToArray();
+
+ for (var index = 0; index < orderedCandidates.Length; index++)
+ {
+ var candidate = orderedCandidates[index];
+ var slot = index + 1;
+ values[$"Candidate{slot}Code"] = candidate.CandidateCode;
+ values[$"Candidate{slot}Name"] = candidate.Name;
+ values[$"Candidate{slot}Party"] = candidate.Party;
+ values[$"Candidate{slot}VoteCount"] = candidate.VoteCount.ToString(CultureInfo.InvariantCulture);
+ values[$"Candidate{slot}VoteCountDisplay"] = candidate.VoteCount.ToString("N0", CultureInfo.InvariantCulture);
+ values[$"Candidate{slot}VoteRate"] = candidate.VoteRate.ToString("0.0", CultureInfo.InvariantCulture);
+ values[$"Candidate{slot}Judgement"] = candidate.EffectiveJudgementLabel;
+ values[$"Candidate{slot}ImagePath"] = ResolveCandidateImagePath(t3CutPath, candidate);
+ }
+
+ if (orderedCandidates.FirstOrDefault() is { } leader)
+ {
+ values["LeaderCode"] = leader.CandidateCode;
+ values["LeaderName"] = leader.Name;
+ values["LeaderParty"] = leader.Party;
+ values["LeaderVoteCount"] = leader.VoteCount.ToString(CultureInfo.InvariantCulture);
+ values["LeaderVoteCountDisplay"] = leader.VoteCount.ToString("N0", CultureInfo.InvariantCulture);
+ values["LeaderVoteRate"] = leader.VoteRate.ToString("0.0", CultureInfo.InvariantCulture);
+ values["LeaderJudgement"] = leader.EffectiveJudgementLabel;
+ values["LeaderImagePath"] = ResolveCandidateImagePath(t3CutPath, leader);
+ }
+
+ return values;
+ }
+
+ private static string ResolveCandidateImagePath(string t3CutPath, CandidateEntry candidate)
+ {
+ if (!candidate.HasImage || string.IsNullOrWhiteSpace(t3CutPath) || string.IsNullOrWhiteSpace(candidate.CandidateCode))
+ {
+ return string.Empty;
+ }
+
+ foreach (var extension in new[] { ".png", ".jpg", ".jpeg", ".webp" })
+ {
+ var path = Path.Combine(t3CutPath, candidate.CandidateCode + extension);
+ if (File.Exists(path))
+ {
+ return path;
+ }
+ }
+
+ return string.Empty;
+ }
+
+ private readonly record struct KarismaChannelBinding(int OutputChannelIndex, int LayerNo);
+
+ private readonly record struct ResolvedScene(string Path, string Alias);
+}
diff --git a/Tornado3_2026Election/Services/MockTornado3Adapter.cs b/Tornado3_2026Election/Services/MockTornado3Adapter.cs
index 400b0c8..d2256eb 100644
--- a/Tornado3_2026Election/Services/MockTornado3Adapter.cs
+++ b/Tornado3_2026Election/Services/MockTornado3Adapter.cs
@@ -16,6 +16,14 @@ public sealed class MockTornado3Adapter : ITornado3Adapter
_logService = logService;
}
+ public string BackendName => "Mock Adapter";
+
+ public bool IsLiveCg => false;
+
+ public bool IsConnected => false;
+
+ public string ConnectionTarget => "실CG 미연동";
+
public TornadoConnectionState State
{
get => _state;
@@ -33,6 +41,8 @@ public sealed class MockTornado3Adapter : ITornado3Adapter
public event EventHandler? StateChanged;
+ public event EventHandler? ConnectionChanged;
+
public async Task EnsureConnectedAsync(CancellationToken cancellationToken)
{
await ExecuteWithTimeoutAsync(async () =>
diff --git a/Tornado3_2026Election/Services/TornadoManager.cs b/Tornado3_2026Election/Services/TornadoManager.cs
new file mode 100644
index 0000000..c43dcae
--- /dev/null
+++ b/Tornado3_2026Election/Services/TornadoManager.cs
@@ -0,0 +1,443 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using KAsyncEngineLib;
+
+namespace Tornado3_2026Election.Services;
+
+public sealed class TornadoManager : IDisposable
+{
+ private static readonly TimeSpan AutoReconnectInterval = TimeSpan.FromSeconds(5);
+
+ private readonly string _host;
+ private readonly int _port;
+ private readonly LogService _logService;
+ private readonly StaDispatcher _dispatcher;
+ private readonly Timer _reconnectTimer;
+ private readonly Dictionary _scenes = new(StringComparer.OrdinalIgnoreCase);
+ private readonly Dictionary _scenePaths = new(StringComparer.OrdinalIgnoreCase);
+ private readonly Dictionary _scenePlayers = new();
+
+ private IKAEngine? _engine;
+ private KAEventHandler? _eventHandler;
+ private bool _connected;
+ private bool _isTcpConnected;
+ private bool _autoReconnectEnabled;
+ private bool _disposed;
+ private int _reconnectInProgress;
+
+ public bool IsConnected => _isTcpConnected;
+
+ public event EventHandler? ConnectionChanged;
+
+ public TornadoManager(string host, int port, LogService logService)
+ {
+ _host = string.IsNullOrWhiteSpace(host)
+ ? throw new ArgumentException("Host is required.", nameof(host))
+ : host;
+ _port = port > 0
+ ? port
+ : throw new ArgumentOutOfRangeException(nameof(port), "Port must be a positive integer.");
+ _logService = logService;
+ _dispatcher = new StaDispatcher("Karisma Async Engine");
+ _reconnectTimer = new Timer(OnReconnectTimerTick, null, AutoReconnectInterval, AutoReconnectInterval);
+ }
+
+ public Task EnsureConnectedAsync(CancellationToken cancellationToken)
+ {
+ return _dispatcher.InvokeAsync(() => EnsureConnectedCore(), cancellationToken);
+ }
+
+ public Task LoadSceneAsync(string scenePath, string sceneAlias, CancellationToken cancellationToken)
+ {
+ return _dispatcher.InvokeAsync(() =>
+ {
+ ThrowIfDisposed();
+ EnsureConnectedCore();
+
+ if (string.IsNullOrWhiteSpace(scenePath))
+ {
+ throw new ArgumentException("Scene path is required.", nameof(scenePath));
+ }
+
+ if (string.IsNullOrWhiteSpace(sceneAlias))
+ {
+ throw new ArgumentException("Scene alias is required.", nameof(sceneAlias));
+ }
+
+ if (_scenePaths.TryGetValue(sceneAlias, out var existingPath) &&
+ string.Equals(existingPath, scenePath, StringComparison.OrdinalIgnoreCase))
+ {
+ return sceneAlias;
+ }
+
+ var forceReload = _scenePaths.ContainsKey(sceneAlias);
+ var scene = forceReload
+ ? _engine!.LoadSceneForce(scenePath, sceneAlias)
+ : _engine!.LoadScene(scenePath, sceneAlias);
+
+ _logService.Info(
+ $"Karisma {(forceReload ? "LoadSceneForce" : "LoadScene")}() return={(scene is null ? "null" : "scene-handle")} alias={sceneAlias} path={scenePath}");
+
+ _scenes[sceneAlias] = scene ?? throw new InvalidOperationException($"Failed to load Karisma scene: {scenePath}");
+ _scenePaths[sceneAlias] = scenePath;
+ _logService.Info($"Karisma scene loaded: alias={sceneAlias} path={scenePath}");
+ return sceneAlias;
+ }, cancellationToken);
+ }
+
+ public Task ApplyValuesAsync(string sceneAlias, IReadOnlyDictionary values, CancellationToken cancellationToken)
+ {
+ return _dispatcher.InvokeAsync(() =>
+ {
+ ThrowIfDisposed();
+ EnsureConnectedCore();
+
+ var scene = GetSceneCore(sceneAlias);
+ _engine!.BeginTransaction();
+ try
+ {
+ foreach (var pair in values)
+ {
+ if (string.IsNullOrWhiteSpace(pair.Key))
+ {
+ continue;
+ }
+
+ try
+ {
+ var sceneObject = scene.GetObject(pair.Key);
+ if (sceneObject is null)
+ {
+ continue;
+ }
+
+ sceneObject.SetValue(pair.Value ?? string.Empty);
+ }
+ catch (Exception ex)
+ {
+ _logService.Warning($"Karisma object update skipped: scene={sceneAlias} object={pair.Key} reason={ex.Message}");
+ }
+ }
+ }
+ finally
+ {
+ _engine!.EndTransaction();
+ }
+ }, cancellationToken);
+ }
+
+ public Task PrepareAsync(int outputChannelIndex, int layerNo, string sceneAlias, CancellationToken cancellationToken)
+ {
+ return _dispatcher.InvokeAsync(() =>
+ {
+ ThrowIfDisposed();
+ EnsureConnectedCore();
+ GetScenePlayerCore(outputChannelIndex).Prepare(layerNo, GetSceneCore(sceneAlias));
+ }, cancellationToken);
+ }
+
+ public Task PlayAsync(int outputChannelIndex, int layerNo, bool cutIn, CancellationToken cancellationToken)
+ {
+ return _dispatcher.InvokeAsync(() =>
+ {
+ ThrowIfDisposed();
+ EnsureConnectedCore();
+
+ var scenePlayer = GetScenePlayerCore(outputChannelIndex);
+ if (cutIn)
+ {
+ scenePlayer.CutIn(layerNo);
+ return;
+ }
+
+ scenePlayer.Play(layerNo);
+ }, cancellationToken);
+ }
+
+ public Task PlayOutAsync(int outputChannelIndex, int layerNo, bool cutOut, CancellationToken cancellationToken)
+ {
+ return _dispatcher.InvokeAsync(() =>
+ {
+ ThrowIfDisposed();
+ EnsureConnectedCore();
+
+ var scenePlayer = GetScenePlayerCore(outputChannelIndex);
+ if (cutOut)
+ {
+ scenePlayer.CutOut(layerNo);
+ return;
+ }
+
+ scenePlayer.PlayOut(layerNo);
+ }, cancellationToken);
+ }
+
+ public Task TriggerAsync(int outputChannelIndex, int layerNo, string animationName, CancellationToken cancellationToken)
+ {
+ return _dispatcher.InvokeAsync(() =>
+ {
+ ThrowIfDisposed();
+ EnsureConnectedCore();
+
+ if (string.IsNullOrWhiteSpace(animationName))
+ {
+ return;
+ }
+
+ GetScenePlayerCore(outputChannelIndex).Trigger(layerNo, animationName);
+ }, cancellationToken);
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ _autoReconnectEnabled = false;
+ _reconnectTimer.Dispose();
+
+ try
+ {
+ _dispatcher.InvokeAsync(() =>
+ {
+ foreach (var scene in _scenes.Values)
+ {
+ try
+ {
+ scene.UnloadScene();
+ }
+ catch
+ {
+ }
+ }
+
+ try
+ {
+ if (_connected)
+ {
+ _engine?.Disconnect();
+ }
+ }
+ catch
+ {
+ }
+
+ _scenes.Clear();
+ _scenePaths.Clear();
+ _scenePlayers.Clear();
+ _engine = null;
+ _eventHandler = null;
+ _connected = false;
+ }, CancellationToken.None).GetAwaiter().GetResult();
+ }
+ finally
+ {
+ _dispatcher.Dispose();
+ }
+ }
+
+ private void EnsureConnectedCore()
+ {
+ ThrowIfDisposed();
+
+ if (_connected)
+ {
+ return;
+ }
+
+ _engine ??= (IKAEngine)new KAEngineClass();
+ _eventHandler ??= new KarismaEventHandler(_logService, HandleConnect, HandleClose);
+ _autoReconnectEnabled = true;
+
+ var requested = _engine.Connect(_host, _port, _eventHandler);
+ _logService.Info($"Karisma Connect() return={(requested != 0 ? "TRUE" : "FALSE")} raw={requested} target={_host}:{_port}");
+ if (requested == 0)
+ {
+ throw new InvalidOperationException($"Karisma Async Engine connection request failed: {_host}:{_port}");
+ }
+
+ _connected = true;
+ _logService.Info($"Karisma Async Engine connect requested: {_host}:{_port}");
+ }
+
+ private void HandleConnect(int errorCode)
+ {
+ if (errorCode == 0)
+ {
+ UpdateTcpConnectionState(true);
+ return;
+ }
+
+ _connected = false;
+ UpdateTcpConnectionState(false);
+ }
+
+ private void HandleClose(int errorCode)
+ {
+ _connected = false;
+ UpdateTcpConnectionState(false);
+ }
+
+ private void UpdateTcpConnectionState(bool isConnected)
+ {
+ if (_isTcpConnected == isConnected)
+ {
+ return;
+ }
+
+ _isTcpConnected = isConnected;
+ ConnectionChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void OnReconnectTimerTick(object? state)
+ {
+ if (_disposed || !_autoReconnectEnabled || _connected)
+ {
+ return;
+ }
+
+ if (Interlocked.Exchange(ref _reconnectInProgress, 1) == 1)
+ {
+ return;
+ }
+
+ _ = TryAutoReconnectAsync();
+ }
+
+ private async Task TryAutoReconnectAsync()
+ {
+ try
+ {
+ if (_disposed || !_autoReconnectEnabled || _connected)
+ {
+ return;
+ }
+
+ await _dispatcher.InvokeAsync(() =>
+ {
+ if (_disposed || _connected)
+ {
+ return;
+ }
+
+ try
+ {
+ EnsureConnectedCore();
+ }
+ catch (Exception ex)
+ {
+ _logService.Warning($"Karisma auto reconnect attempt failed: {ex.Message}");
+ }
+ }, CancellationToken.None).ConfigureAwait(false);
+ }
+ finally
+ {
+ Interlocked.Exchange(ref _reconnectInProgress, 0);
+ }
+ }
+
+ private KAScene GetSceneCore(string sceneAlias)
+ {
+ if (_scenes.TryGetValue(sceneAlias, out var scene))
+ {
+ return scene;
+ }
+
+ throw new InvalidOperationException($"Karisma scene is not loaded: {sceneAlias}");
+ }
+
+ private KAScenePlayer GetScenePlayerCore(int outputChannelIndex)
+ {
+ if (_scenePlayers.TryGetValue(outputChannelIndex, out var scenePlayer))
+ {
+ return scenePlayer;
+ }
+
+ scenePlayer = _engine!.GetScenePlayer(outputChannelIndex)
+ ?? throw new InvalidOperationException($"Karisma scene player is unavailable: output={outputChannelIndex}");
+ _scenePlayers[outputChannelIndex] = scenePlayer;
+ return scenePlayer;
+ }
+
+ private void ThrowIfDisposed()
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ }
+
+ private sealed class StaDispatcher : IDisposable
+ {
+ private readonly BlockingCollection _queue = new();
+ private readonly Thread _thread;
+ private bool _disposed;
+
+ public StaDispatcher(string threadName)
+ {
+ _thread = new Thread(Run)
+ {
+ IsBackground = true,
+ Name = threadName
+ };
+ _thread.SetApartmentState(ApartmentState.STA);
+ _thread.Start();
+ }
+
+ public Task InvokeAsync(Action action, CancellationToken cancellationToken)
+ {
+ return InvokeAsync(() =>
+ {
+ action();
+ return true;
+ }, cancellationToken);
+ }
+
+ public Task InvokeAsync(Func action, CancellationToken cancellationToken)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ _queue.Add(() =>
+ {
+ try
+ {
+ completion.SetResult(action());
+ }
+ catch (Exception ex)
+ {
+ completion.SetException(ex);
+ }
+ });
+
+ return cancellationToken.CanBeCanceled
+ ? completion.Task.WaitAsync(cancellationToken)
+ : completion.Task;
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ _queue.CompleteAdding();
+ _thread.Join();
+ _queue.Dispose();
+ }
+
+ private void Run()
+ {
+ foreach (var action in _queue.GetConsumingEnumerable())
+ {
+ action();
+ }
+ }
+ }
+}
diff --git a/Tornado3_2026Election/Tornado3_2026Election.csproj b/Tornado3_2026Election/Tornado3_2026Election.csproj
index c844ad3..193f0aa 100644
--- a/Tornado3_2026Election/Tornado3_2026Election.csproj
+++ b/Tornado3_2026Election/Tornado3_2026Election.csproj
@@ -8,13 +8,28 @@
Assets\AppIcon.ico
x86;x64;ARM64
win-x86;win-x64;win-arm64
- win-$(Platform).pubxml
true
false
true
enable
+
+ win-x64
+
+
+
+ win-x86
+
+
+
+ win-arm64
+
+
+
+ win-$(Platform).pubxml
+
+
diff --git a/Tornado3_2026Election/ViewModels/ChannelScheduleViewModel.cs b/Tornado3_2026Election/ViewModels/ChannelScheduleViewModel.cs
index d91d72d..40a659d 100644
--- a/Tornado3_2026Election/ViewModels/ChannelScheduleViewModel.cs
+++ b/Tornado3_2026Election/ViewModels/ChannelScheduleViewModel.cs
@@ -1,5 +1,6 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.Collections.ObjectModel;
+using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using Tornado3_2026Election.Common;
@@ -12,7 +13,9 @@ public sealed class ChannelScheduleViewModel : ObservableObject
{
private readonly ChannelScheduleEngine _engine;
private readonly ITornado3Adapter _adapter;
+ private readonly DataViewModel _data;
private readonly LogService _logService;
+ private readonly IReadOnlyList _allFormats;
private FormatTemplateDefinition? _selectedFormat;
private SelectionOption? _selectedEmptyBehaviorOption;
private bool _loopEnabled;
@@ -22,16 +25,19 @@ public sealed class ChannelScheduleViewModel : ObservableObject
BroadcastChannel channel,
string title,
IReadOnlyList formats,
+ DataViewModel data,
ITornado3Adapter adapter,
ChannelScheduleEngine engine,
LogService logService)
{
Channel = channel;
Title = title;
+ _data = data;
_adapter = adapter;
_engine = engine;
_logService = logService;
- AvailableFormats = new ObservableCollection(formats);
+ _allFormats = formats.ToArray();
+ AvailableFormats = new ObservableCollection();
EmptyBehaviorOptions =
[
new SelectionOption(EmptyScheduleBehavior.ImmediateOut, "즉시 아웃"),
@@ -42,17 +48,20 @@ public sealed class ChannelScheduleViewModel : ObservableObject
StartCommand = new AsyncRelayCommand(StartAsync);
StopCommand = new AsyncRelayCommand(StopAsync);
ForceNextCommand = new AsyncRelayCommand(ForceNextAsync);
- AddFormatCommand = new RelayCommand(AddFormat, () => SelectedFormat is not null);
+ AddFormatCommand = new RelayCommand(AddFormat, CanAddFormat);
ResetQueueCommand = new RelayCommand(ResetQueue);
RemoveItemCommand = new RelayCommand(RemoveItem);
MoveUpCommand = new RelayCommand(MoveUp);
MoveDownCommand = new RelayCommand(MoveDown);
PromoteToNextCommand = new RelayCommand(PromoteToNext);
- SelectedFormat = AvailableFormats.FirstOrDefault();
SelectedEmptyBehaviorOption = FindEmptyBehaviorOption(_emptyScheduleBehavior);
_engine.QueueChanged += (_, _) => RefreshSummary();
_adapter.StateChanged += (_, _) => RefreshSummary();
+ _adapter.ConnectionChanged += (_, _) => RefreshSummary();
+ _data.PropertyChanged += Data_PropertyChanged;
+
+ RebuildAvailableFormats();
RefreshSummary();
}
@@ -60,6 +69,18 @@ public sealed class ChannelScheduleViewModel : ObservableObject
public string Title { get; }
+ public bool IsLiveCg => _adapter.IsLiveCg;
+
+ public bool IsCgConnected => _adapter.IsConnected;
+
+ public string CgBackendName => _adapter.BackendName;
+
+ public string CgConnectionTarget => _adapter.ConnectionTarget;
+
+ public string CgStatusSummary => IsLiveCg
+ ? $"{(IsCgConnected ? "Connected" : "Disconnected")} / {CgBackendName} / {CgConnectionTarget}"
+ : $"Disconnected / {CgBackendName} / {CgConnectionTarget}";
+
public ObservableCollection AvailableFormats { get; }
public IReadOnlyList> EmptyBehaviorOptions { get; }
@@ -162,11 +183,11 @@ public sealed class ChannelScheduleViewModel : ObservableObject
public string CurrentItemName => Queue.FirstOrDefault(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending)?.FormatName ?? "대기 화면";
- public string NextItemName => Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Next)?.FormatName ?? "다음 포맷 없음";
+ public string NextItemName => Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Next)?.FormatName ?? "다음 컷 없음";
public int QueuedItemCount => Queue.Count(item => item.State == ScheduleQueueItemState.Queued);
- public string QueueFootnote => $"대기 {QueuedItemCount}건 · 프리셋 {AvailableFormats.Count}개";
+ public string QueueFootnote => $"대기 {QueuedItemCount}건 / 컷 {AvailableFormats.Count}개";
public string QueueSummary
{
@@ -178,31 +199,31 @@ public sealed class ChannelScheduleViewModel : ObservableObject
}
}
- public string LoopSummary => LoopEnabled ? "전체 반복 켜짐" : "한 번 재생";
+ public string LoopSummary => LoopEnabled ? "반복 재생" : "1회 재생";
public string EmptyBehaviorLabel => SelectedEmptyBehaviorOption?.Label ?? "즉시 아웃";
- public string OperatorQuickSummary => $"{AdapterStateLabel} · {LoopSummary} · 빈 스케줄 {EmptyBehaviorLabel}";
+ public string OperatorQuickSummary => $"{AdapterStateLabel} / {LoopSummary} / 빈 스케줄 {EmptyBehaviorLabel}";
private async Task StartAsync()
{
await _engine.StartAsync().ConfigureAwait(false);
RefreshSummary();
- _logService.Info($"[{Title}] 스케줄 시작");
+ _logService.Info($"[{Title}] 큐를 시작");
}
private async Task StopAsync()
{
await _engine.StopAsync().ConfigureAwait(false);
RefreshSummary();
- _logService.Info($"[{Title}] 스케줄 종료");
+ _logService.Info($"[{Title}] 큐를 종료");
}
private async Task ForceNextAsync()
{
await _engine.ForceNextAsync().ConfigureAwait(false);
RefreshSummary();
- _logService.Info($"[{Title}] 현재 포맷 강제 중지 후 전환");
+ _logService.Info($"[{Title}] 현재 컷을 강제로 전환");
}
private void AddFormat()
@@ -212,6 +233,18 @@ public sealed class ChannelScheduleViewModel : ObservableObject
return;
}
+ if (!SelectedFormat.IsAvailableInPhase(_data.BroadcastPhase))
+ {
+ _logService.Warning($"[{Title}] 현재 단계에서는 '{SelectedFormat.Name}' 컷을 추가할 수 없습니다.");
+ return;
+ }
+
+ if (!_data.ValidateForFormat(SelectedFormat, out var validationError))
+ {
+ _logService.Warning($"[{Title}] {validationError}");
+ return;
+ }
+
_engine.Enqueue(ChannelScheduleItem.FromTemplate(SelectedFormat));
RefreshSummary();
}
@@ -220,14 +253,14 @@ public sealed class ChannelScheduleViewModel : ObservableObject
{
_engine.Reset();
RefreshSummary();
- _logService.Info($"[{Title}] 스케줄을 첫 포맷부터 다시 시작하도록 초기화했습니다.");
+ _logService.Info($"[{Title}] 큐를 첫 컷부터 다시 시작하도록 초기화했습니다.");
}
private void RemoveItem(ChannelScheduleItem? item)
{
if (!_engine.Remove(item))
{
- _logService.Warning($"[{Title}] 송출 중 포맷은 삭제할 수 없습니다.");
+ _logService.Warning($"[{Title}] 송출 중 항목은 제거할 수 없습니다.");
return;
}
@@ -265,11 +298,50 @@ public sealed class ChannelScheduleViewModel : ObservableObject
nameof(QueuedItemCount),
nameof(QueueFootnote),
nameof(QueueSummary),
+ nameof(IsCgConnected),
+ nameof(CgStatusSummary),
nameof(LoopSummary),
nameof(EmptyBehaviorLabel),
nameof(OperatorQuickSummary));
}
+ private bool CanAddFormat()
+ {
+ return SelectedFormat is not null && SelectedFormat.IsAvailableInPhase(_data.BroadcastPhase);
+ }
+
+ private void Data_PropertyChanged(object? sender, PropertyChangedEventArgs args)
+ {
+ if (args.PropertyName != nameof(DataViewModel.BroadcastPhase))
+ {
+ return;
+ }
+
+ RebuildAvailableFormats();
+ RefreshSummary();
+ }
+
+ private void RebuildAvailableFormats()
+ {
+ var filteredFormats = _allFormats
+ .Where(format => format.IsAvailableInPhase(_data.BroadcastPhase))
+ .ToArray();
+
+ AvailableFormats.Clear();
+ foreach (var format in filteredFormats)
+ {
+ AvailableFormats.Add(format);
+ }
+
+ if (SelectedFormat is null || !AvailableFormats.Contains(SelectedFormat))
+ {
+ SelectedFormat = AvailableFormats.FirstOrDefault();
+ }
+
+ AddFormatCommand.NotifyCanExecuteChanged();
+ OnPropertyChanged(nameof(QueueFootnote));
+ }
+
private SelectionOption? FindEmptyBehaviorOption(EmptyScheduleBehavior behavior)
{
return EmptyBehaviorOptions.FirstOrDefault(option => option.Value == behavior);
diff --git a/Tornado3_2026Election/ViewModels/DataViewModel.cs b/Tornado3_2026Election/ViewModels/DataViewModel.cs
index 8e26f44..dfd6dbf 100644
--- a/Tornado3_2026Election/ViewModels/DataViewModel.cs
+++ b/Tornado3_2026Election/ViewModels/DataViewModel.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
@@ -728,3 +728,4 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
options.Add(new SelectionOption(value, value));
}
}
+
diff --git a/Tornado3_2026Election/ViewModels/MainViewModel.cs b/Tornado3_2026Election/ViewModels/MainViewModel.cs
index 71b9b5e..7ca616f 100644
--- a/Tornado3_2026Election/ViewModels/MainViewModel.cs
+++ b/Tornado3_2026Election/ViewModels/MainViewModel.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
@@ -6,6 +6,7 @@ using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
+using Microsoft.UI;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Imaging;
@@ -18,13 +19,15 @@ namespace Tornado3_2026Election.ViewModels;
public sealed class MainViewModel : ObservableObject
{
+ private static readonly Brush ConnectedStatusBrush = new SolidColorBrush(Colors.LimeGreen);
+ private static readonly Brush DisconnectedStatusBrush = new SolidColorBrush(Colors.OrangeRed);
private readonly FormatCatalogService _formatCatalogService;
private readonly AppStateStore _stateStore;
private readonly LogService _logService;
private readonly SemaphoreSlim _stateSaveLock = new(1, 1);
private AppPage _currentPage = AppPage.IntegratedSchedule;
private ChannelOperationMode _operationMode = ChannelOperationMode.General;
- private bool _isSituationRoomExpanded = true;
+ private bool _isSituationRoomExpanded;
private bool _suppressAutomaticSave;
private int? _windowX;
private int? _windowY;
@@ -136,6 +139,8 @@ public sealed class MainViewModel : ObservableObject
nameof(BottomVisibility),
nameof(VideoWallVisibility),
nameof(HeaderStatus),
+ nameof(CgIntegrationSummary),
+ nameof(CgIntegrationDetail),
nameof(TornadoConnectionSummary),
nameof(TornadoConnectionDetail));
QueueAutomaticSave();
@@ -265,6 +270,56 @@ public sealed class MainViewModel : ObservableObject
public string LogFilterSummary => $"표시 {FilteredLogs.Count}건 / 전체 {Logs.Count}건";
+ public string CgIntegrationSummary => IsCgConnected ? "Connected" : "Disconnected";
+
+ public Brush CgIntegrationBrush => IsCgConnected ? ConnectedStatusBrush : DisconnectedStatusBrush;
+
+ public string CgIntegrationDetail
+ {
+ get
+ {
+ var activeChannels = GetActiveChannels().ToArray();
+ if (activeChannels.Length == 0)
+ {
+ return "운영 대상 없음";
+ }
+
+ var liveChannels = activeChannels
+ .Where(channel => channel.IsLiveCg)
+ .ToArray();
+
+ if (liveChannels.Length == 0)
+ {
+ return "Mock Adapter";
+ }
+
+ var targets = liveChannels
+ .Select(channel => channel.CgConnectionTarget)
+ .Distinct(StringComparer.Ordinal)
+ .ToArray();
+
+ return $"{liveChannels[0].CgBackendName} / {string.Join(", ", targets)}";
+ }
+ }
+
+ public bool IsCgConnected
+ {
+ get
+ {
+ var activeChannels = GetActiveChannels().ToArray();
+ if (activeChannels.Length == 0)
+ {
+ return false;
+ }
+
+ var liveChannels = activeChannels
+ .Where(channel => channel.IsLiveCg)
+ .ToArray();
+
+ return liveChannels.Length > 0 && liveChannels.All(channel => channel.IsCgConnected);
+ }
+ }
+
public string TornadoConnectionSummary
{
get
@@ -309,7 +364,7 @@ public sealed class MainViewModel : ObservableObject
}
}
- public string HeaderStatus => $"{Settings.SelectedStation.Name} / {OperationModeLabel} / {Data.DistrictName} / {Data.HeaderMetricSummary}";
+ public string HeaderStatus => $"{Settings.SelectedStation.Name} / {CurrentPageTitle} / {Data.BroadcastPhaseBadgeText} / {OperationModeLabel}";
public void Navigate(string tag)
{
@@ -383,7 +438,7 @@ public sealed class MainViewModel : ObservableObject
_suppressAutomaticSave = false;
}
- _logService.Info("저장된 시작 옵션에 따라 상태를 자동 복원했습니다.");
+ _logService.Info("저장한 시작 옵션에 따라 상태를 자동 복원했습니다.");
}
public async Task RestoreAsync()
@@ -495,9 +550,17 @@ public sealed class MainViewModel : ObservableObject
private void Channel_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
- if (e.PropertyName is nameof(ChannelScheduleViewModel.AdapterState) or nameof(ChannelScheduleViewModel.AdapterStateLabel))
+ if (e.PropertyName is nameof(ChannelScheduleViewModel.AdapterState)
+ or nameof(ChannelScheduleViewModel.AdapterStateLabel)
+ or nameof(ChannelScheduleViewModel.IsCgConnected))
{
- OnPropertyChanged(nameof(TornadoConnectionSummary), nameof(TornadoConnectionDetail));
+ OnPropertyChanged(
+ nameof(IsCgConnected),
+ nameof(CgIntegrationSummary),
+ nameof(CgIntegrationBrush),
+ nameof(CgIntegrationDetail),
+ nameof(TornadoConnectionSummary),
+ nameof(TornadoConnectionDetail));
}
}
@@ -633,7 +696,7 @@ public sealed class MainViewModel : ObservableObject
private ChannelScheduleViewModel CreateChannelViewModel(BroadcastChannel channel, string title)
{
- var adapter = new MockTornado3Adapter(_logService);
+ var adapter = KarismaTornado3Adapter.CreateOrFallback(_logService, () => Settings.ImageRootPath);
var queue = new ObservableCollection();
var engine = new ChannelScheduleEngine(
channel,
@@ -649,6 +712,7 @@ public sealed class MainViewModel : ObservableObject
channel,
title,
_formatCatalogService.GetByChannel(channel),
+ Data,
adapter,
engine,
_logService);
@@ -752,3 +816,5 @@ public sealed class MainViewModel : ObservableObject
channelViewModel.RefreshSummary();
}
}
+
+
diff --git a/Tornado3_2026Election/ViewModels/SettingsViewModel.cs b/Tornado3_2026Election/ViewModels/SettingsViewModel.cs
index 0c53647..33e0562 100644
--- a/Tornado3_2026Election/ViewModels/SettingsViewModel.cs
+++ b/Tornado3_2026Election/ViewModels/SettingsViewModel.cs
@@ -1,4 +1,4 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using Tornado3_2026Election.Common;
@@ -9,7 +9,7 @@ namespace Tornado3_2026Election.ViewModels;
public sealed class SettingsViewModel : ObservableObject
{
private string _selectedStationId = "KNN";
- private string _imageRootPath = @"C:\ElectionImages";
+ private string _imageRootPath = @"C:\Users\y2keu\Downloads\T3_Cut";
public SettingsViewModel(IEnumerable stations)
{
@@ -67,3 +67,4 @@ public sealed class SettingsViewModel : ObservableObject
return SelectedStation.ToProfile();
}
}
+