중간 과정 진행 후 커밋

This commit is contained in:
2026-04-09 17:49:31 +09:00
parent 05762c0e33
commit 666d757ff6
19 changed files with 2026 additions and 278 deletions

View File

@@ -226,3 +226,56 @@ IDLE → READY → SENDING → ON_AIR → NEXT
- 컷 단위 송출
- 스케줄 큐 구조
- 상태 머신 기반 제어
---
## 15. 인코딩 검증 규칙
- 한글 문자열이 포함된 파일을 수정한 뒤에는 반드시 인코딩 깨짐 여부를 다시 확인한다.
- UI 문구, 로그 문구, 기본값 문자열은 저장 직후 한글이 정상 표시되는지 우선 점검한다.
- `?`, `<60>`, 비정상 한자 형태의 모지바케가 보이면 즉시 수정 대상으로 간주한다.
- 텍스트 파일은 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 콜백 로그와 앱 내부 로그는 같은 로그 시스템에 합쳐서 표시한다.

View File

@@ -1,4 +1,4 @@
<UserControl
<UserControl
x:Class="Tornado3_2026Election.Controls.ChannelSchedulePanel"
x:Name="Root"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
@@ -44,6 +44,10 @@
<TextBlock
Style="{StaticResource ConsoleBodyTextStyle}"
Text="{x:Bind ViewModel.OperatorQuickSummary, Mode=OneWay}" />
<TextBlock
Style="{StaticResource ConsoleLabelTextStyle}"
Text="{x:Bind ViewModel.CgStatusSummary, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
</StackPanel>
<StackPanel
@@ -169,7 +173,7 @@
<Button
Grid.Column="1"
Command="{x:Bind ViewModel.AddFormatCommand}"
Content="포맷 추가"
Content=" 추가"
Style="{StaticResource ConsolePrimaryButtonStyle}" />
<ToggleSwitch
@@ -184,11 +188,26 @@
ItemsSource="{x:Bind ViewModel.EmptyBehaviorOptions, Mode=OneWay}"
SelectedItem="{x:Bind ViewModel.SelectedEmptyBehaviorOption, Mode=TwoWay}" />
<TextBlock
<Button
Grid.Column="4"
Width="22"
Height="22"
MinWidth="22"
MinHeight="22"
Padding="0"
VerticalAlignment="Center"
Style="{StaticResource ConsoleBodyTextStyle}"
Text="빈 스케줄 처리, 강제 전환, 순서 변경까지 이 패널에서 즉시 수행합니다." />
Content="?"
FontFamily="Bahnschrift SemiBold"
FontSize="11">
<Button.Flyout>
<Flyout>
<TextBlock
MaxWidth="240"
Text="스케줄이 비었을 때의 동작입니다. 루프 유지, 정지, 대기 방식 중 하나를 선택합니다."
TextWrapping="WrapWholeWords" />
</Flyout>
</Button.Flyout>
</Button>
</Grid>
<StackPanel
@@ -208,7 +227,7 @@
Style="{StaticResource ConsoleGhostButtonStyle}" />
<Button
Command="{x:Bind ViewModel.ResetQueueCommand}"
Content="루프 초기화"
Content=" 초기화"
Style="{StaticResource ConsoleGhostButtonStyle}" />
</StackPanel>
</StackPanel>
@@ -227,22 +246,51 @@
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock
Style="{StaticResource ConsoleSectionTitleTextStyle}"
Text="스케줄 목록" />
Text="대기 중 목록" />
<Button
Width="22"
Height="22"
MinWidth="22"
MinHeight="22"
Padding="0"
VerticalAlignment="Center"
Content="?"
FontFamily="Bahnschrift SemiBold"
FontSize="11">
<Button.Flyout>
<Flyout>
<TextBlock
MaxWidth="280"
Text="빨강은 현재 송출 중, 노랑은 다음 송출 예정입니다. 다음은 순서를 바꾸지 않고 다음 예약만 이동합니다."
TextWrapping="WrapWholeWords" />
</Flyout>
</Button.Flyout>
</Button>
</StackPanel>
<TextBlock
Grid.Column="1"
Style="{StaticResource ConsoleLabelTextStyle}"
Text="큐 엔진" />
Text="실행 순서" />
</Grid>
<ListView
x:Name="QueueListView"
ItemsSource="{x:Bind ViewModel.Queue, Mode=OneWay}"
SelectionMode="None">
<ListView.ItemContainerTransitions>
<TransitionCollection>
<AddDeleteThemeTransition />
<ReorderThemeTransition />
</TransitionCollection>
</ListView.ItemContainerTransitions>
<ListView.ItemTemplate>
<DataTemplate x:DataType="domain:ChannelScheduleItem">
<Border
Margin="0,0,0,10"
Opacity="{x:Bind CardOpacity, Mode=OneWay}"
Padding="14"
Background="#122033"
BorderBrush="#27405F"
@@ -257,7 +305,7 @@
</Grid.ColumnDefinitions>
<Border
Background="{x:Bind StateBrush}"
Background="{x:Bind StateBrush, Mode=OneWay}"
CornerRadius="4" />
<StackPanel Grid.Column="1" Spacing="6">
@@ -267,11 +315,11 @@
CornerRadius="12">
<TextBlock
Style="{StaticResource MiniSignalTextStyle}"
Text="{x:Bind StateLabel}" />
Text="{x:Bind StateLabel, Mode=OneWay}" />
</Border>
<TextBlock
Style="{StaticResource ConsoleLabelTextStyle}"
Text="{x:Bind LastPlayedLabel}" />
Text="{x:Bind LastPlayedLabel, Mode=OneWay}" />
</StackPanel>
<StackPanel Grid.Column="2" Spacing="6">
@@ -286,8 +334,7 @@
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}">
<Run Text="컷 " />
<Run Text="{x:Bind TotalCuts}" />
<Run Text=" | " />
<Run Text="기본 " />
<Run Text=" | 기본 " />
<Run Text="{x:Bind DefaultCutDurationSeconds}" />
<Run Text="초" />
</TextBlock>
@@ -299,24 +346,20 @@
Spacing="8"
VerticalAlignment="Center">
<Button
Command="{Binding ViewModel.PromoteToNextCommand, ElementName=Root}"
CommandParameter="{Binding}"
Click="PromoteToNextButton_Click"
Content="다음"
Style="{StaticResource PanelCommandButtonStyle}" />
<Button
Command="{Binding ViewModel.MoveUpCommand, ElementName=Root}"
CommandParameter="{Binding}"
Click="MoveUpButton_Click"
Content="위"
Style="{StaticResource PanelCommandButtonStyle}" />
<Button
Command="{Binding ViewModel.MoveDownCommand, ElementName=Root}"
CommandParameter="{Binding}"
Click="MoveDownButton_Click"
Content="아래"
Style="{StaticResource PanelCommandButtonStyle}" />
<Button
Command="{Binding ViewModel.RemoveItemCommand, ElementName=Root}"
CommandParameter="{Binding}"
Content="삭제"
Click="RemoveItemButton_Click"
Content="제거"
Style="{StaticResource PanelCommandButtonStyle}" />
</StackPanel>
</Grid>
@@ -329,3 +372,4 @@
</StackPanel>
</Border>
</UserControl>

View File

@@ -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();
}
}

View File

@@ -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));
}
}
@@ -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)
{

View File

@@ -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<FormatCutDefinition> Cuts { get; init; }
public bool IsAvailableInPhase(BroadcastPhase phase)
{
return phase switch
{
BroadcastPhase.PreElection => SupportsPreElection,
BroadcastPhase.Counting => SupportsCounting,
_ => false
};
}
}

View File

@@ -1,4 +1,4 @@
<Window x:Class="Tornado3_2026Election.MainWindow"
<Window x:Class="Tornado3_2026Election.MainWindow"
x:Name="RootWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
@@ -68,122 +68,135 @@
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Border Padding="18"
<Border Padding="14"
Background="{StaticResource ControlRoomHeroBrush}"
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
BorderThickness="1"
CornerRadius="24">
<StackPanel Spacing="14">
<Grid ColumnSpacing="16">
<StackPanel Spacing="10">
<Grid ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Spacing="8">
<StackPanel Spacing="4">
<TextBlock Style="{StaticResource ConsoleHeroTitleTextStyle}"
FontSize="27"
FontSize="25"
Text="선거방송 송출 상황실" />
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}"
Text="{x:Bind ViewModel.HeaderStatus, Mode=OneWay}"
TextTrimming="CharacterEllipsis" />
</StackPanel>
<StackPanel Grid.Column="1"
Orientation="Horizontal"
Spacing="10"
VerticalAlignment="Top">
<TextBlock FontFamily="Consolas"
Foreground="{StaticResource ControlRoomSignalBlueBrush}"
Text="{x:Bind ViewModel.Data.PollingCountdownText, Mode=OneWay}"
VerticalAlignment="Center" />
<Button Command="{x:Bind ViewModel.Data.ManualRefreshCommand}"
Content="수동 수신"
Style="{StaticResource ConsoleGhostButtonStyle}" />
</StackPanel>
<Grid ColumnSpacing="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border Padding="10,9" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="14">
<StackPanel Spacing="2">
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="현재 메뉴" />
<TextBlock FontFamily="Bahnschrift SemiBold" FontSize="16" Foreground="{StaticResource ControlRoomSignalBlueBrush}" Text="{x:Bind ViewModel.CurrentPageTitle, Mode=OneWay}" />
</StackPanel>
</Border>
<Border Grid.Column="1" Padding="10,9" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="14">
<StackPanel Spacing="6">
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="방송 단계" />
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="단계" HorizontalAlignment="Center" />
<ToggleSwitch x:Name="BroadcastPhaseToggleSwitch"
OffContent="사전"
OnContent="개표"
IsOn="{x:Bind ViewModel.Data.IsCountingPhase, Mode=OneWay}"
Toggled="BroadcastPhaseToggleSwitch_Toggled" />
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="{x:Bind ViewModel.Data.BroadcastPhaseBadgeText, Mode=OneWay}" />
</StackPanel>
</Border>
<Border Grid.Column="2" Padding="10,9" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="14">
<StackPanel Spacing="6">
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="운영 모드" />
<StackPanel Spacing="2">
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="모드" HorizontalAlignment="Center" />
<ToggleSwitch x:Name="OperationModeToggleSwitch"
OffContent="일반"
OnContent="비디오월"
IsOn="{x:Bind ViewModel.IsVideoWallOperationMode, Mode=OneWay}"
Toggled="OperationModeToggleSwitch_Toggled" />
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="{x:Bind ViewModel.OperationModeBadgeText, Mode=OneWay}" />
</StackPanel>
</Border>
<Border Grid.Column="3" Padding="10,9" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="14">
<StackPanel Spacing="2">
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="Tornado 연결" />
<TextBlock FontFamily="Bahnschrift SemiBold" FontSize="16" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind ViewModel.TornadoConnectionSummary, Mode=OneWay}" />
</StackPanel>
</Border>
<Border Grid.Column="4" Padding="10,9" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="14">
<StackPanel Spacing="2">
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="마지막 수신" />
<TextBlock FontFamily="Consolas" FontSize="16" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind ViewModel.Data.LastRefreshDisplay, Mode=OneWay}" />
</StackPanel>
</Border>
</Grid>
</StackPanel>
<Button Grid.Column="1"
Command="{x:Bind ViewModel.ToggleSituationRoomCommand}"
Content="{x:Bind ViewModel.SituationRoomToggleText, Mode=OneWay}"
VerticalAlignment="Top"
<Button Command="{x:Bind ViewModel.Data.ManualRefreshCommand}"
Content="수동 갱신"
Style="{StaticResource ConsoleGhostButtonStyle}" />
<Button Command="{x:Bind ViewModel.ToggleSituationRoomCommand}"
Content="{x:Bind ViewModel.SituationRoomToggleText, Mode=OneWay}"
Style="{StaticResource ConsoleGhostButtonStyle}" />
</StackPanel>
</Grid>
<Grid Visibility="{x:Bind ViewModel.SituationRoomBodyVisibility, Mode=OneWay}"
ColumnSpacing="20">
<Grid ColumnSpacing="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1.8*" />
<ColumnDefinition Width="1.2*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<StackPanel Spacing="10">
<TextBlock FontFamily="Consolas" Foreground="{StaticResource ControlRoomTextSecondaryBrush}" Text="{x:Bind ViewModel.HeaderStatus, Mode=OneWay}" />
<Border Padding="10,8" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="14">
<StackPanel Spacing="2">
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="메뉴 / 방송사" />
<TextBlock FontFamily="Bahnschrift SemiBold"
FontSize="15"
Foreground="{StaticResource ControlRoomSignalBlueBrush}"
TextTrimming="CharacterEllipsis">
<Run Text="{x:Bind ViewModel.CurrentPageTitle, Mode=OneWay}" />
<Run Text=" / " />
<Run Text="{x:Bind ViewModel.Settings.SelectedStation.Name, Mode=OneWay}" />
</TextBlock>
</StackPanel>
</Border>
<Grid Grid.Column="1" ColumnSpacing="12" RowSpacing="12">
<Grid.RowDefinitions><RowDefinition Height="*" /><RowDefinition Height="*" /></Grid.RowDefinitions>
<Grid.ColumnDefinitions><ColumnDefinition Width="*" /><ColumnDefinition Width="*" /></Grid.ColumnDefinitions>
<Border Padding="16" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="18">
<StackPanel Spacing="4"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="방송사" /><TextBlock FontFamily="Bahnschrift SemiBold" FontSize="22" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind ViewModel.Settings.SelectedStation.Name, Mode=OneWay}" /></StackPanel>
</Border>
<Border Grid.Column="1" Padding="16" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="18">
<StackPanel Spacing="4"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="선거구" /><TextBlock FontFamily="Bahnschrift SemiBold" FontSize="22" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind ViewModel.Data.DistrictName, Mode=OneWay}" /></StackPanel>
</Border>
<Border Grid.Row="1" Padding="16" Background="#18263A" BorderBrush="#35506F" BorderThickness="1" CornerRadius="18">
<StackPanel Spacing="4"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="{x:Bind ViewModel.Data.SituationMetricPrimaryLabel, Mode=OneWay}" /><TextBlock FontFamily="Consolas" FontSize="24" Foreground="{StaticResource ControlRoomSignalGreenBrush}" Text="{x:Bind ViewModel.Data.SituationMetricPrimaryValue, Mode=OneWay}" /></StackPanel>
</Border>
<Border Grid.Row="1" Grid.Column="1" Padding="16" Background="#201B2F" BorderBrush="#60475A" BorderThickness="1" CornerRadius="18">
<StackPanel Spacing="4"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="{x:Bind ViewModel.Data.SituationMetricSecondaryLabel, Mode=OneWay}" /><TextBlock FontFamily="Consolas" FontSize="24" Foreground="{StaticResource ControlRoomSignalAmberBrush}" Text="{x:Bind ViewModel.Data.SituationMetricSecondaryValue, Mode=OneWay}" /></StackPanel>
<Border Grid.Column="1" Padding="10,8" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="14">
<StackPanel Spacing="2">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="CG 연결 상태" />
<Button Width="22"
Height="22"
MinWidth="22"
MinHeight="22"
Padding="0"
VerticalAlignment="Center"
Content="?"
FontFamily="Bahnschrift SemiBold"
FontSize="11">
<Button.Flyout>
<Flyout>
<TextBlock MaxWidth="260"
Text="Karisma TCP 30001 연결 상태입니다. 녹색은 실제 연결 성공, 빨간색은 끊김 상태입니다."
TextWrapping="WrapWholeWords" />
</Flyout>
</Button.Flyout>
</Button>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<Ellipse Width="10"
Height="10"
VerticalAlignment="Center"
Fill="{x:Bind ViewModel.CgIntegrationBrush, Mode=OneWay}" />
<TextBlock FontFamily="Bahnschrift SemiBold"
FontSize="15"
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
Text="{x:Bind ViewModel.CgIntegrationSummary, Mode=OneWay}" />
</StackPanel>
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}"
Text="{x:Bind ViewModel.CgIntegrationDetail, Mode=OneWay}"
TextTrimming="CharacterEllipsis" />
</StackPanel>
</Border>
</Grid>
</Grid>
<Border Visibility="{x:Bind ViewModel.SituationRoomBodyVisibility, Mode=OneWay}"
Padding="10,8"
Background="#101C2E"
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
BorderThickness="1"
CornerRadius="16">
<StackPanel Spacing="4">
<TextBlock FontFamily="Consolas"
Foreground="{StaticResource ControlRoomTextSecondaryBrush}"
Text="{x:Bind ViewModel.HeaderStatus, Mode=OneWay}"
TextTrimming="CharacterEllipsis" />
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}"
Text="{x:Bind ViewModel.TornadoConnectionDetail, Mode=OneWay}"
TextTrimming="CharacterEllipsis" />
</StackPanel>
</Border>
</StackPanel>
</Border>
@@ -195,8 +208,7 @@
<Border Padding="20" Background="{StaticResource ControlRoomPanelGradientBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="24">
<Grid>
<StackPanel Spacing="10" Visibility="{x:Bind ViewModel.GeneralIntegratedVisibility, Mode=OneWay}">
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="통합 송출 매트릭스" />
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="일반 모드에서는 노멀, 좌상단, 하단 채널을 통합 상황실에서 함께 운영합니다." />
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="통합 채널" />
<Grid ColumnSpacing="10">
<Grid.ColumnDefinitions><ColumnDefinition Width="*" /><ColumnDefinition Width="*" /><ColumnDefinition Width="*" /></Grid.ColumnDefinitions>
<Border Padding="14" Background="#16263A" BorderBrush="#284665" BorderThickness="1" CornerRadius="18"><StackPanel Spacing="4"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="노멀" /><TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="{x:Bind ViewModel.NormalChannel.TransmissionLabel, Mode=OneWay}" /><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="{x:Bind ViewModel.NormalChannel.CurrentItemName, Mode=OneWay}" /></StackPanel></Border>
@@ -205,8 +217,7 @@
</Grid>
</StackPanel>
<StackPanel Spacing="10" Visibility="{x:Bind ViewModel.VideoWallIntegratedVisibility, Mode=OneWay}">
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="통합 송출 매트릭스" />
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="비디오월 모드에서는 대형 화면 연출 채널만 단독으로 운영합니다." />
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="비디오월 채널" />
<Border Padding="14" Background="#16263A" BorderBrush="#284665" BorderThickness="1" CornerRadius="18"><StackPanel Spacing="4"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="비디오월" /><TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="{x:Bind ViewModel.VideoWallChannel.TransmissionLabel, Mode=OneWay}" /><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="{x:Bind ViewModel.VideoWallChannel.CurrentItemName, Mode=OneWay}" /></StackPanel></Border>
</StackPanel>
</Grid>
@@ -214,22 +225,13 @@
<StackPanel Grid.Column="1" Spacing="20">
<Border Padding="18" Background="{StaticResource ControlRoomPanelGradientBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="24">
<StackPanel Spacing="12">
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="빠른 실행" />
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="운영 중 저장과 복원을 빠르게 수행합니다." />
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="저장 / 복원" />
<StackPanel Orientation="Horizontal" Spacing="10">
<Button Command="{x:Bind ViewModel.SaveStateCommand}" Content="상태 저장" Style="{StaticResource ConsolePrimaryButtonStyle}" />
<Button Command="{x:Bind ViewModel.RestoreStateCommand}" Content="복원" Style="{StaticResource ConsoleGhostButtonStyle}" />
</StackPanel>
</StackPanel>
</Border>
<Border Padding="18" Background="{StaticResource ControlRoomPanelGradientBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="24">
<StackPanel Spacing="10">
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="운영 정책" />
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="• 갱신 중 송출 요청 시 갱신 완료 후 송출" />
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="• 수동 수신은 3초 이내 재요청 금지" />
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="• 이미지 필수 포맷은 사진 누락 시 송출 차단" />
</StackPanel>
</Border>
</StackPanel>
</Grid>
@@ -244,10 +246,10 @@
</StackPanel>
</ScrollViewer>
<ScrollViewer Visibility="{x:Bind ViewModel.NormalVisibility, Mode=OneWay}"><StackPanel Spacing="20"><Border Padding="18" Background="{StaticResource ControlRoomPanelGradientBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="24"><TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="노멀 채널은 메인 자막과 시도 루프의 핵심 송출 구간입니다." /></Border><controls:ChannelSchedulePanel ViewModel="{x:Bind ViewModel.NormalChannel, Mode=OneWay}" /></StackPanel></ScrollViewer>
<ScrollViewer Visibility="{x:Bind ViewModel.TopLeftVisibility, Mode=OneWay}"><StackPanel Spacing="20"><Border Padding="18" Background="{StaticResource ControlRoomPanelGradientBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="24"><TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="좌상단 채널은 간결한 보조 정보와 속보형 상태 표시를 담당합니다." /></Border><controls:ChannelSchedulePanel ViewModel="{x:Bind ViewModel.TopLeftChannel, Mode=OneWay}" /></StackPanel></ScrollViewer>
<ScrollViewer Visibility="{x:Bind ViewModel.BottomVisibility, Mode=OneWay}"><StackPanel Spacing="20"><Border Padding="18" Background="{StaticResource ControlRoomPanelGradientBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="24"><TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="하단 채널은 라인형 득표 바와 속보 자막을 위한 전용 런다운입니다." /></Border><controls:ChannelSchedulePanel ViewModel="{x:Bind ViewModel.BottomChannel, Mode=OneWay}" /></StackPanel></ScrollViewer>
<ScrollViewer Visibility="{x:Bind ViewModel.VideoWallVisibility, Mode=OneWay}"><StackPanel Spacing="20"><Border Padding="18" Background="{StaticResource ControlRoomPanelGradientBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="24"><TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="비디오월 채널은 사진과 당선 카드 중심의 대형 시각 연출을 담당합니다." /></Border><controls:ChannelSchedulePanel ViewModel="{x:Bind ViewModel.VideoWallChannel, Mode=OneWay}" /></StackPanel></ScrollViewer>
<ScrollViewer Visibility="{x:Bind ViewModel.NormalVisibility, Mode=OneWay}"><controls:ChannelSchedulePanel ViewModel="{x:Bind ViewModel.NormalChannel, Mode=OneWay}" /></ScrollViewer>
<ScrollViewer Visibility="{x:Bind ViewModel.TopLeftVisibility, Mode=OneWay}"><controls:ChannelSchedulePanel ViewModel="{x:Bind ViewModel.TopLeftChannel, Mode=OneWay}" /></ScrollViewer>
<ScrollViewer Visibility="{x:Bind ViewModel.BottomVisibility, Mode=OneWay}"><controls:ChannelSchedulePanel ViewModel="{x:Bind ViewModel.BottomChannel, Mode=OneWay}" /></ScrollViewer>
<ScrollViewer Visibility="{x:Bind ViewModel.VideoWallVisibility, Mode=OneWay}"><controls:ChannelSchedulePanel ViewModel="{x:Bind ViewModel.VideoWallChannel, Mode=OneWay}" /></ScrollViewer>
<ScrollViewer Visibility="{x:Bind ViewModel.DataVisibility, Mode=OneWay}">
<StackPanel Spacing="20">
@@ -270,9 +272,9 @@
<TextBox Grid.Column="2" Header="지역 코드" Text="{x:Bind ViewModel.Data.DistrictCode, Mode=TwoWay}" />
<NumberBox Grid.Column="3" Header="{x:Bind ViewModel.Data.TotalExpectedVotesLabel, Mode=OneWay}" Minimum="1" SpinButtonPlacementMode="Compact" Value="{x:Bind ViewModel.Data.TotalExpectedVotes, Mode=TwoWay}" />
<StackPanel Grid.Column="4" Orientation="Horizontal" Spacing="10" VerticalAlignment="Bottom">
<ToggleSwitch Header="API 자동 신" IsOn="{x:Bind ViewModel.Data.IsPollingEnabled, Mode=TwoWay}" />
<ToggleSwitch Header="API 자동 신" IsOn="{x:Bind ViewModel.Data.IsPollingEnabled, Mode=TwoWay}" />
<NumberBox Width="140" Header="주기(초)" Minimum="3" SpinButtonPlacementMode="Compact" Value="{x:Bind ViewModel.Data.PollingIntervalSeconds, Mode=TwoWay}" />
<Button Command="{x:Bind ViewModel.Data.ManualRefreshCommand}" Content="수동 신" Style="{StaticResource ConsolePrimaryButtonStyle}" />
<Button Command="{x:Bind ViewModel.Data.ManualRefreshCommand}" Content="수동 신" Style="{StaticResource ConsolePrimaryButtonStyle}" />
<Button Command="{x:Bind ViewModel.Data.AddCandidateCommand}" Content="후보 추가" Style="{StaticResource ConsoleGhostButtonStyle}" Visibility="{x:Bind ViewModel.Data.CountingActionsVisibility, Mode=OneWay}" />
<Button Command="{x:Bind ViewModel.Data.ResetManualJudgementsCommand}" Content="수동 판정 초기화" Style="{StaticResource ConsoleGhostButtonStyle}" Visibility="{x:Bind ViewModel.Data.CountingActionsVisibility, Mode=OneWay}" />
</StackPanel>
@@ -294,7 +296,7 @@
<ColumnDefinition Width="220" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<NumberBox Header="투표수"
<NumberBox Header="투표수"
Minimum="0"
SpinButtonPlacementMode="Compact"
Value="{x:Bind ViewModel.Data.TurnoutVotes, Mode=TwoWay}" />
@@ -319,8 +321,27 @@
BorderThickness="1"
CornerRadius="18">
<StackPanel Spacing="4">
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="수신 정책" />
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="사전 단계에서는 투표율과 투표자 수 중심으로 수신하며 후보 득표수 편집은 잠시 숨깁니다." />
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="개표 단계" />
<Button Width="22"
Height="22"
MinWidth="22"
MinHeight="22"
Padding="0"
VerticalAlignment="Center"
Content="?"
FontFamily="Bahnschrift SemiBold"
FontSize="11">
<Button.Flyout>
<Flyout>
<TextBlock MaxWidth="260"
Text="현재 송출 기준 단계입니다. 단계에 따라 표시와 갱신 항목이 달라집니다."
TextWrapping="WrapWholeWords" />
</Flyout>
</Button.Flyout>
</Button>
</StackPanel>
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="{x:Bind ViewModel.Data.BroadcastPhaseBadgeText, Mode=OneWay}" />
</StackPanel>
</Border>
</Grid>
@@ -353,7 +374,7 @@
SelectedValue="{Binding ManualJudgement, Mode=TwoWay}"
SelectedValuePath="Value" />
<StackPanel Grid.Column="6" Spacing="6"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="판정" /><TextBlock FontFamily="Bahnschrift SemiBold" FontSize="18" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind EffectiveJudgementLabel}" /><ToggleButton IsChecked="{Binding HasImage, Mode=TwoWay}" Content="사진" /></StackPanel>
<StackPanel Grid.Column="7" Orientation="Horizontal" Spacing="8" VerticalAlignment="Bottom"><Button Command="{Binding ViewModel.Data.RemoveCandidateCommand, ElementName=RootWindow}" CommandParameter="{Binding}" Content="제" Style="{StaticResource ConsoleGhostButtonStyle}" /></StackPanel>
<StackPanel Grid.Column="7" Orientation="Horizontal" Spacing="8" VerticalAlignment="Bottom"><Button Command="{Binding ViewModel.Data.RemoveCandidateCommand, ElementName=RootWindow}" CommandParameter="{Binding}" Content="제" Style="{StaticResource ConsoleGhostButtonStyle}" /></StackPanel>
</Grid>
</Border>
</DataTemplate>
@@ -375,10 +396,32 @@
<ComboBox DisplayMemberPath="Name" Header="방송사" ItemsSource="{x:Bind ViewModel.Settings.Stations, Mode=OneWay}" SelectedValue="{x:Bind ViewModel.Settings.SelectedStationId, Mode=TwoWay}" SelectedValuePath="Id" />
<Grid Grid.Column="1" ColumnSpacing="10">
<Grid.ColumnDefinitions><ColumnDefinition Width="*" /><ColumnDefinition Width="Auto" /></Grid.ColumnDefinitions>
<TextBox Header="이미지 루트 경로"
IsReadOnly="True"
<TextBox IsReadOnly="True"
IsSpellCheckEnabled="False"
Text="{x:Bind ViewModel.Settings.ImageRootPath, Mode=OneWay}" />
Text="{x:Bind ViewModel.Settings.ImageRootPath, Mode=OneWay}">
<TextBox.Header>
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="T3_Cut 경로" />
<Button Width="22"
Height="22"
MinWidth="22"
MinHeight="22"
Padding="0"
VerticalAlignment="Center"
Content="?"
FontFamily="Bahnschrift SemiBold"
FontSize="11">
<Button.Flyout>
<Flyout>
<TextBlock MaxWidth="260"
Text="송출에 사용할 .tscn 컷 폴더입니다. 폴더를 바꾸면 컷 검색 기준도 함께 바뀝니다."
TextWrapping="WrapWholeWords" />
</Flyout>
</Button.Flyout>
</Button>
</StackPanel>
</TextBox.Header>
</TextBox>
<Button Grid.Column="1"
Click="PickImageRootFolderButton_Click"
Content="폴더 선택"
@@ -386,18 +429,35 @@
Style="{StaticResource ConsoleGhostButtonStyle}" />
</Grid>
</Grid>
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="여기서는 현재 선택한 방송사만 편집합니다. 권역 루프에 들어갈 시도는 아래 17개 체크 항목으로 관리됩니다." />
</StackPanel>
</Border>
<Border Grid.Column="1" Padding="20" Background="{StaticResource ControlRoomPanelGradientBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="24">
<StackPanel Spacing="12">
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="시작 및 수신 옵션" />
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="첫 실행 때만 방송사를 묻고, 이후에는 이 옵션대로 자동 복원합니다. 변경 내용은 자동 저장됩니다." />
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="시작 및 복원 옵션" />
<Button Width="22"
Height="22"
MinWidth="22"
MinHeight="22"
Padding="0"
VerticalAlignment="Center"
Content="?"
FontFamily="Bahnschrift SemiBold"
FontSize="11">
<Button.Flyout>
<Flyout>
<TextBlock MaxWidth="260"
Text="앱 시작 시 이전 상태를 얼마나 복원할지 정합니다. 처음에는 기본값으로 두고 필요한 항목만 켜면 됩니다."
TextWrapping="WrapWholeWords" />
</Flyout>
</Button.Flyout>
</Button>
</StackPanel>
<CheckBox Content="스케줄 복원" IsChecked="{x:Bind ViewModel.RestoreSelection.RestoreSchedules, Mode=TwoWay}" />
<CheckBox Content="방송사 설정 복원" IsChecked="{x:Bind ViewModel.RestoreSelection.RestoreStations, Mode=TwoWay}" />
<CheckBox Content="상태값 복원" IsChecked="{x:Bind ViewModel.RestoreSelection.RestoreStatusValues, Mode=TwoWay}" />
<ToggleSwitch Header="API 자동 신" IsOn="{x:Bind ViewModel.Data.IsPollingEnabled, Mode=TwoWay}" />
<NumberBox Header="API 신 주기(초)"
<ToggleSwitch Header="API 자동 신" IsOn="{x:Bind ViewModel.Data.IsPollingEnabled, Mode=TwoWay}" />
<NumberBox Header="API 신 주기(초)"
Minimum="3"
SpinButtonPlacementMode="Compact"
Value="{x:Bind ViewModel.Data.PollingIntervalSeconds, Mode=TwoWay}" />
@@ -420,7 +480,7 @@
<Border Padding="18" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="18">
<StackPanel Spacing="6">
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="편집 대상" />
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="현재 대상" />
<TextBlock FontFamily="Bahnschrift SemiBold" FontSize="26" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind ViewModel.Settings.SelectedStation.Name, Mode=OneWay}" />
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="{x:Bind ViewModel.Settings.SelectedStationRegionSummary, Mode=OneWay}" />
</StackPanel>
@@ -428,8 +488,7 @@
<Border Grid.Column="1" Padding="18" Background="#101C2E" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="18">
<StackPanel Spacing="8" VerticalAlignment="Center">
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="권역 정책" />
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="선택된 방송사 한 곳만 수정됩니다. 체크된 시도만 채널별 권역 루프와 방송사 프로필에 반영됩니다." />
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="권역" />
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="{x:Bind ViewModel.Settings.SelectedStation.RegionFiltersText, Mode=OneWay}" />
</StackPanel>
</Border>
@@ -476,7 +535,7 @@
<StackPanel Spacing="14">
<Grid ColumnSpacing="16">
<Grid.ColumnDefinitions><ColumnDefinition Width="*" /><ColumnDefinition Width="Auto" /></Grid.ColumnDefinitions>
<StackPanel Spacing="6"><TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="시스템 이벤트 로그" /><TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="송출, 복원, 수신, 검증, 어댑터 동작 로그를 시간순으로 추적합니다." /></StackPanel>
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="시스템 이벤트 로그" />
<Button Grid.Column="1" Command="{x:Bind ViewModel.ClearLogsCommand}" Content="로그 비우기" Style="{StaticResource ConsoleGhostButtonStyle}" />
</Grid>
<Grid ColumnSpacing="12" RowSpacing="12">
@@ -492,7 +551,6 @@
<StackPanel Spacing="4">
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="로그 표시 현황" />
<TextBlock FontFamily="Bahnschrift SemiBold" FontSize="18" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind ViewModel.LogFilterSummary, Mode=OneWay}" />
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="정보·경고·오류 중 필요한 수준만 골라 운영 중 노이즈를 줄일 수 있습니다." />
</StackPanel>
</Border>
</Grid>
@@ -518,3 +576,5 @@
</Grid>
</NavigationView>
</Window>

View File

@@ -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<string, string> StationRegionFilters { get; set; } = [];
}

View File

@@ -19,6 +19,7 @@ public sealed class ChannelScheduleEngine
private readonly SemaphoreSlim _executionLock = new(1, 1);
private CancellationTokenSource? _playbackCts;
private TaskCompletionSource<bool>? _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)
{

View File

@@ -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<FormatTemplateDefinition> _formats =
[
new FormatTemplateDefinition
private static readonly HashSet<string> MediaWallFormats = new(StringComparer.Ordinal)
{
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 }
]
}
];
"민방_타이틀",
"사전_역대투표율_loop",
"투표율_시도별",
"1-2위_광역단체장_5760",
"1-3위_기초단체장_5760",
"모든후보_광역단체장_5760",
"이시각1위_광역단체장",
"판세_기초단체장_5760",
"광역의원표"
};
private readonly IReadOnlyList<FormatTemplateDefinition> _formats = BuildFormats();
public IReadOnlyList<FormatTemplateDefinition> 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<FormatTemplateDefinition> BuildFormats()
{
List<FormatTemplateDefinition> 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<FormatTemplateDefinition> 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;
}
}

View File

@@ -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<TornadoConnectionState>? StateChanged;
event EventHandler? ConnectionChanged;
Task EnsureConnectedAsync(CancellationToken cancellationToken);
Task ApplyCutAsync(

View File

@@ -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<int>? _onConnect;
private readonly Action<int>? _onClose;
public KarismaEventHandler(LogService logService, Action<int>? onConnect = null, Action<int>? 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) { }
}

View File

@@ -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<string> _t3CutPathProvider;
private readonly IReadOnlyDictionary<BroadcastChannel, KarismaChannelBinding> _bindings;
private readonly string _connectionTarget;
private readonly Dictionary<BroadcastChannel, string> _pendingScenes = new();
private readonly Dictionary<BroadcastChannel, bool> _channelOnAir = new();
private TornadoConnectionState _state = TornadoConnectionState.Idle;
private bool _disposed;
private KarismaTornado3Adapter(
TornadoManager manager,
LogService logService,
Func<string> t3CutPathProvider,
string connectionTarget,
IReadOnlyDictionary<BroadcastChannel, KarismaChannelBinding> 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<TornadoConnectionState>? StateChanged;
public event EventHandler? ConnectionChanged;
public static ITornado3Adapter CreateOrFallback(LogService logService, Func<string> t3CutPathProvider)
{
return TryCreate(logService, t3CutPathProvider, out var adapter)
? adapter
: new MockTornado3Adapter(logService);
}
public static bool TryCreate(LogService logService, Func<string> 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<Task> 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<BroadcastChannel, KarismaChannelBinding> BuildBindings()
{
return new Dictionary<BroadcastChannel, KarismaChannelBinding>
{
[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<string, string> BuildObjectValues(
FormatTemplateDefinition template,
FormatCutDefinition cut,
ElectionDataSnapshot snapshot,
BroadcastStationProfile station,
string t3CutPath)
{
var values = new Dictionary<string, string>(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);
}

View File

@@ -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<TornadoConnectionState>? StateChanged;
public event EventHandler? ConnectionChanged;
public async Task EnsureConnectedAsync(CancellationToken cancellationToken)
{
await ExecuteWithTimeoutAsync(async () =>

View File

@@ -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<string, KAScene> _scenes = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, string> _scenePaths = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<int, KAScenePlayer> _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<string> 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<string, string> 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<Action> _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<T> InvokeAsync<T>(Func<T> action, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
cancellationToken.ThrowIfCancellationRequested();
var completion = new TaskCompletionSource<T>(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();
}
}
}
}

View File

@@ -8,13 +8,28 @@
<ApplicationIcon>Assets\AppIcon.ico</ApplicationIcon>
<Platforms>x86;x64;ARM64</Platforms>
<RuntimeIdentifiers>win-x86;win-x64;win-arm64</RuntimeIdentifiers>
<PublishProfile>win-$(Platform).pubxml</PublishProfile>
<UseWinUI>true</UseWinUI>
<WinUISDKReferences>false</WinUISDKReferences>
<EnableMsixTooling>true</EnableMsixTooling>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup Condition="'$(RuntimeIdentifier)'=='' and ('$(Platform)'=='' or '$(Platform)'=='AnyCPU' or '$(Platform)'=='x64')">
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
</PropertyGroup>
<PropertyGroup Condition="'$(RuntimeIdentifier)'=='' and '$(Platform)'=='x86'">
<RuntimeIdentifier>win-x86</RuntimeIdentifier>
</PropertyGroup>
<PropertyGroup Condition="'$(RuntimeIdentifier)'=='' and '$(Platform)'=='ARM64'">
<RuntimeIdentifier>win-arm64</RuntimeIdentifier>
</PropertyGroup>
<PropertyGroup Condition="Exists('Properties\PublishProfiles\win-$(Platform).pubxml')">
<PublishProfile>win-$(Platform).pubxml</PublishProfile>
</PropertyGroup>
<ItemGroup>
<None Remove="Assets\Stations\**\*.*" />
<Content Include="Assets\AppIcon.ico">

View File

@@ -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<FormatTemplateDefinition> _allFormats;
private FormatTemplateDefinition? _selectedFormat;
private SelectionOption<EmptyScheduleBehavior>? _selectedEmptyBehaviorOption;
private bool _loopEnabled;
@@ -22,16 +25,19 @@ public sealed class ChannelScheduleViewModel : ObservableObject
BroadcastChannel channel,
string title,
IReadOnlyList<FormatTemplateDefinition> formats,
DataViewModel data,
ITornado3Adapter adapter,
ChannelScheduleEngine engine,
LogService logService)
{
Channel = channel;
Title = title;
_data = data;
_adapter = adapter;
_engine = engine;
_logService = logService;
AvailableFormats = new ObservableCollection<FormatTemplateDefinition>(formats);
_allFormats = formats.ToArray();
AvailableFormats = new ObservableCollection<FormatTemplateDefinition>();
EmptyBehaviorOptions =
[
new SelectionOption<EmptyScheduleBehavior>(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<ChannelScheduleItem>(RemoveItem);
MoveUpCommand = new RelayCommand<ChannelScheduleItem>(MoveUp);
MoveDownCommand = new RelayCommand<ChannelScheduleItem>(MoveDown);
PromoteToNextCommand = new RelayCommand<ChannelScheduleItem>(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<FormatTemplateDefinition> AvailableFormats { get; }
public IReadOnlyList<SelectionOption<EmptyScheduleBehavior>> 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<EmptyScheduleBehavior>? FindEmptyBehaviorOption(EmptyScheduleBehavior behavior)
{
return EmptyBehaviorOptions.FirstOrDefault(option => option.Value == behavior);

View File

@@ -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<string>(value, value));
}
}

View File

@@ -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<ChannelScheduleItem>();
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();
}
}

View File

@@ -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<BroadcastStationProfile> stations)
{
@@ -67,3 +67,4 @@ public sealed class SettingsViewModel : ObservableObject
return SelectedStation.ToProfile();
}
}