중간 저장

This commit is contained in:
2026-04-22 13:30:24 +09:00
parent 7cedeef5a9
commit 31857815d7
69 changed files with 7211 additions and 148 deletions

View File

@@ -0,0 +1,20 @@
{
"name": "tornado3-local",
"interface": {
"displayName": "Tornado3 Local Plugins"
},
"plugins": [
{
"name": "cut-design-debugger",
"source": {
"source": "local",
"path": "./plugins/cut-design-debugger"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Productivity"
}
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 79 KiB

View File

@@ -158,7 +158,7 @@
<Grid ColumnSpacing="16"> <Grid ColumnSpacing="16">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
<ColumnDefinition Width="320" /> <ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<StackPanel Spacing="14"> <StackPanel Spacing="14">
@@ -258,6 +258,308 @@
Content="큐 초기화" Content="큐 초기화"
Style="{StaticResource ConsoleGhostButtonStyle}" /> Style="{StaticResource ConsoleGhostButtonStyle}" />
</StackPanel> </StackPanel>
<Border
Padding="12"
Background="#101C2E"
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
BorderThickness="1"
CornerRadius="8">
<StackPanel Spacing="12">
<Grid ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Spacing="4">
<TextBlock
Style="{StaticResource ConsoleLabelTextStyle}"
Text="컷 디버그" />
<TextBlock
Style="{StaticResource ConsoleBodyTextStyle}"
Text="{x:Bind ViewModel.CutDebugSummary, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
<TextBlock
Style="{StaticResource ConsoleLabelTextStyle}"
Text="항목을 하나씩 끄고 다시 송출하면 어떤 값 묶음이 화면을 바꾸는지 바로 비교할 수 있습니다."
TextWrapping="WrapWholeWords" />
</StackPanel>
<ToggleSwitch
Grid.Column="1"
HorizontalAlignment="Right"
IsOn="{x:Bind ViewModel.CutDebug.IsEnabled, Mode=TwoWay}"
OffContent="OFF"
OnContent="ON" />
</Grid>
<Grid ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Spacing="2">
<TextBlock
FontFamily="Bahnschrift SemiBold"
FontSize="14"
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
Text="텍스트 값" />
<TextBlock
Style="{StaticResource ConsoleLabelTextStyle}"
Text="{x:Bind ViewModel.CutDebugTextTargets, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
</StackPanel>
<ToggleSwitch
Grid.Column="1"
IsOn="{x:Bind ViewModel.CutDebug.ApplyTextValues, Mode=TwoWay}"
OffContent="OFF"
OnContent="ON" />
</Grid>
<Grid ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Spacing="2">
<TextBlock
FontFamily="Bahnschrift SemiBold"
FontSize="14"
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
Text="이미지 값" />
<TextBlock
Style="{StaticResource ConsoleLabelTextStyle}"
Text="{x:Bind ViewModel.CutDebugImageTargets, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
</StackPanel>
<ToggleSwitch
Grid.Column="1"
IsOn="{x:Bind ViewModel.CutDebug.ApplyImageValues, Mode=TwoWay}"
OffContent="OFF"
OnContent="ON" />
</Grid>
<Grid ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Spacing="2">
<TextBlock
FontFamily="Bahnschrift SemiBold"
FontSize="14"
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
Text="표시/숨김" />
<TextBlock
Style="{StaticResource ConsoleLabelTextStyle}"
Text="{x:Bind ViewModel.CutDebugVisibilityTargets, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
</StackPanel>
<ToggleSwitch
Grid.Column="1"
IsOn="{x:Bind ViewModel.CutDebug.ApplyVisibilityValues, Mode=TwoWay}"
OffContent="OFF"
OnContent="ON" />
</Grid>
<Grid ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Spacing="2">
<TextBlock
FontFamily="Bahnschrift SemiBold"
FontSize="14"
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
Text="득표율 텍스트" />
<TextBlock
Style="{StaticResource ConsoleLabelTextStyle}"
Text="{x:Bind ViewModel.CutDebugVoteRateTextTargets, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
</StackPanel>
<ToggleSwitch
Grid.Column="1"
IsOn="{x:Bind ViewModel.CutDebug.ApplyVoteRateTextValues, Mode=TwoWay}"
OffContent="OFF"
OnContent="ON" />
</Grid>
<Grid ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Spacing="2">
<TextBlock
FontFamily="Bahnschrift SemiBold"
FontSize="14"
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
Text="득표율 카운터" />
<TextBlock
Style="{StaticResource ConsoleLabelTextStyle}"
Text="{x:Bind ViewModel.CutDebugVoteRateCounterTargets, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
</StackPanel>
<ToggleSwitch
Grid.Column="1"
IsOn="{x:Bind ViewModel.CutDebug.ApplyVoteRateCounterValues, Mode=TwoWay}"
OffContent="OFF"
OnContent="ON" />
</Grid>
<Grid ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Spacing="2">
<TextBlock
FontFamily="Bahnschrift SemiBold"
FontSize="14"
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
Text="정당 바/막대 색상" />
<TextBlock
Style="{StaticResource ConsoleLabelTextStyle}"
Text="{x:Bind ViewModel.CutDebugPartyBarColorTargets, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
</StackPanel>
<ToggleSwitch
Grid.Column="1"
IsOn="{x:Bind ViewModel.CutDebug.ApplyPartyBarStyleColors, Mode=TwoWay}"
OffContent="OFF"
OnContent="ON" />
</Grid>
<Grid ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Spacing="2">
<TextBlock
FontFamily="Bahnschrift SemiBold"
FontSize="14"
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
Text="정당 판/문자 색상" />
<TextBlock
Style="{StaticResource ConsoleLabelTextStyle}"
Text="{x:Bind ViewModel.CutDebugPartyPlateColorTargets, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
</StackPanel>
<ToggleSwitch
Grid.Column="1"
IsOn="{x:Bind ViewModel.CutDebug.ApplyPartyPlateStyleColors, Mode=TwoWay}"
OffContent="OFF"
OnContent="ON" />
</Grid>
<Grid ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Spacing="2">
<TextBlock
FontFamily="Bahnschrift SemiBold"
FontSize="14"
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
Text="득표율 색상" />
<TextBlock
Style="{StaticResource ConsoleLabelTextStyle}"
Text="{x:Bind ViewModel.CutDebugVoteRateColorTargets, Mode=OneWay}"
TextWrapping="WrapWholeWords" />
</StackPanel>
<ToggleSwitch
Grid.Column="1"
IsOn="{x:Bind ViewModel.CutDebug.ApplyVoteRateStyleColors, Mode=TwoWay}"
OffContent="OFF"
OnContent="ON" />
</Grid>
<Border
Padding="12"
Background="#0B1624"
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
BorderThickness="1"
CornerRadius="8">
<StackPanel Spacing="8">
<TextBlock
Style="{StaticResource ConsoleLabelTextStyle}"
Text="개별 항목 토글" />
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}">
<Run Text="항목 " />
<Run Text="{x:Bind ViewModel.CutDebugItemCount, Mode=OneWay}" />
<Run Text="개" />
</TextBlock>
<ListView
MaxHeight="300"
ItemsSource="{x:Bind ViewModel.CutDebugItems, Mode=OneWay}"
SelectionMode="None">
<ListView.ItemTemplate>
<DataTemplate x:DataType="domain:CutDebugItemState">
<Grid
Margin="0,0,0,6"
ColumnSpacing="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<CheckBox
VerticalAlignment="Center"
IsChecked="{x:Bind IsEnabled, Mode=TwoWay}" />
<Border
Grid.Column="1"
Padding="8,4"
Background="#18314B"
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
BorderThickness="1"
CornerRadius="6">
<TextBlock
Style="{StaticResource ConsoleLabelTextStyle}"
Text="{x:Bind KindLabel, Mode=OneWay}" />
</Border>
<StackPanel
Grid.Column="2"
Spacing="2">
<TextBlock
FontFamily="Consolas"
FontSize="13"
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
Text="{x:Bind Key, Mode=OneWay}" />
<TextBlock
Style="{StaticResource ConsoleLabelTextStyle}"
Text="{x:Bind GroupLabel, Mode=OneWay}" />
</StackPanel>
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</StackPanel>
</Border>
</StackPanel>
</Border>
</StackPanel> </StackPanel>
<Border <Border
@@ -272,14 +574,15 @@
Style="{StaticResource ConsoleLabelTextStyle}" Style="{StaticResource ConsoleLabelTextStyle}"
Text="선택된 컷 미리보기" /> Text="선택된 컷 미리보기" />
<Border <Border
Height="180" Width="{x:Bind ViewModel.SelectedFormatThumbnailWidth, Mode=OneWay}"
Height="{x:Bind ViewModel.SelectedFormatThumbnailHeight, Mode=OneWay}"
Background="#0B1624" Background="#0B1624"
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
BorderThickness="1" BorderThickness="1"
CornerRadius="6"> CornerRadius="6">
<Image <Image
Source="{x:Bind ViewModel.SelectedFormatThumbnailSource, Mode=OneWay}" Source="{x:Bind ViewModel.SelectedFormatThumbnailSource, Mode=OneWay}"
Stretch="UniformToFill" /> Stretch="Uniform" />
</Border> </Border>
<TextBlock <TextBlock
FontFamily="Bahnschrift SemiBold" FontFamily="Bahnschrift SemiBold"
@@ -373,7 +676,7 @@
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="8" /> <ColumnDefinition Width="8" />
<ColumnDefinition Width="140" /> <ColumnDefinition Width="140" />
<ColumnDefinition Width="168" /> <ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" /> <ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
@@ -401,15 +704,15 @@
Spacing="6" Spacing="6"
VerticalAlignment="Center"> VerticalAlignment="Center">
<Border <Border
Width="160" Width="{x:Bind ThumbnailWidth, Mode=OneWay}"
Height="90" Height="{x:Bind ThumbnailHeight, Mode=OneWay}"
Background="#0B1624" Background="#0B1624"
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
BorderThickness="1" BorderThickness="1"
CornerRadius="8"> CornerRadius="8">
<Image <Image
Source="{x:Bind ThumbnailSource, Mode=OneWay}" Source="{x:Bind ThumbnailSource, Mode=OneWay}"
Stretch="UniformToFill" /> Stretch="Uniform" />
</Border> </Border>
<TextBlock <TextBlock
Style="{StaticResource ConsoleLabelTextStyle}" Style="{StaticResource ConsoleLabelTextStyle}"

View File

@@ -11,4 +11,6 @@ public sealed class BroadcastStationProfile
public string LogoAssetPath { get; init; } = string.Empty; public string LogoAssetPath { get; init; } = string.Empty;
public required IReadOnlyList<string> RegionFilters { get; init; } public required IReadOnlyList<string> RegionFilters { get; init; }
public VideoWallLayoutPreset VideoWallLayoutPreset { get; init; } = VideoWallLayoutPreset.Auto;
} }

View File

@@ -16,6 +16,8 @@ public sealed class ChannelScheduleItem : ObservableObject
private string _currentRegionLabel = string.Empty; private string _currentRegionLabel = string.Empty;
private double _defaultCutDurationSeconds; private double _defaultCutDurationSeconds;
private int _totalCuts; private int _totalCuts;
private double _thumbnailWidth = 160;
private double _thumbnailHeight = 90;
private ImageSource? _thumbnailSource; private ImageSource? _thumbnailSource;
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
@@ -140,6 +142,20 @@ public sealed class ChannelScheduleItem : ObservableObject
[JsonIgnore] [JsonIgnore]
public bool HasThumbnail => CutThumbnailAssetCatalog.HasThumbnail(FormatId); public bool HasThumbnail => CutThumbnailAssetCatalog.HasThumbnail(FormatId);
[JsonIgnore]
public double ThumbnailWidth
{
get => _thumbnailWidth;
private set => SetProperty(ref _thumbnailWidth, value);
}
[JsonIgnore]
public double ThumbnailHeight
{
get => _thumbnailHeight;
private set => SetProperty(ref _thumbnailHeight, value);
}
[JsonIgnore] [JsonIgnore]
public string ThumbnailStatusLabel => HasThumbnail ? "등록 썸네일" : "기본 아이콘"; public string ThumbnailStatusLabel => HasThumbnail ? "등록 썸네일" : "기본 아이콘";
@@ -151,6 +167,12 @@ public sealed class ChannelScheduleItem : ObservableObject
OnPropertyChanged(nameof(ThumbnailStatusLabel)); OnPropertyChanged(nameof(ThumbnailStatusLabel));
} }
public void UpdateThumbnailLayout(ThumbnailDisplayMetrics metrics)
{
ThumbnailWidth = metrics.Width;
ThumbnailHeight = metrics.Height;
}
public static ChannelScheduleItem FromTemplate(FormatTemplateDefinition template, ScheduleRegionOption? regionOption = null) public static ChannelScheduleItem FromTemplate(FormatTemplateDefinition template, ScheduleRegionOption? regionOption = null)
{ {
var selectedRegion = regionOption ?? new ScheduleRegionOption var selectedRegion = regionOption ?? new ScheduleRegionOption

View File

@@ -0,0 +1,261 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using Tornado3_2026Election.Common;
namespace Tornado3_2026Election.Domain;
public enum CutDebugItemKind
{
TextValue,
ImageValue,
Counter,
StyleColor,
Visibility
}
public readonly record struct CutDebugItemDescriptor(
string Key,
CutDebugItemKind Kind,
string GroupLabel);
public sealed class CutDebugItemState : ObservableObject
{
private readonly Action<bool> _onIsEnabledChanged;
private bool _isEnabled;
public CutDebugItemState(
string key,
CutDebugItemKind kind,
string groupLabel,
bool isEnabled,
Action<bool> onIsEnabledChanged)
{
Key = key;
Kind = kind;
GroupLabel = groupLabel;
_isEnabled = isEnabled;
_onIsEnabledChanged = onIsEnabledChanged;
}
public string Key { get; }
public CutDebugItemKind Kind { get; }
public string GroupLabel { get; }
public string KindLabel => Kind switch
{
CutDebugItemKind.TextValue => "텍스트",
CutDebugItemKind.ImageValue => "이미지",
CutDebugItemKind.Counter => "카운터",
CutDebugItemKind.StyleColor => "색상",
CutDebugItemKind.Visibility => "표시",
_ => "기타"
};
public bool IsEnabled
{
get => _isEnabled;
set
{
if (SetProperty(ref _isEnabled, value))
{
_onIsEnabledChanged(value);
}
}
}
}
public sealed class CutDebugTemplateState
{
private readonly ConcurrentDictionary<string, bool> _enabledStates = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, CutDebugOverride> _overrides = new(StringComparer.OrdinalIgnoreCase);
public CutDebugTemplateState(string formatId, string displayName)
{
FormatId = formatId;
DisplayName = displayName;
Items = [];
}
public string FormatId { get; }
public string DisplayName { get; private set; }
public ObservableCollection<CutDebugItemState> Items { get; }
public void UpdateDisplayName(string displayName)
{
if (!string.IsNullOrWhiteSpace(displayName))
{
DisplayName = displayName;
}
}
public void SyncItems(IEnumerable<CutDebugItemDescriptor> descriptors)
{
var normalizedDescriptors = descriptors
.Where(descriptor => !string.IsNullOrWhiteSpace(descriptor.Key))
.Select(descriptor => descriptor with { Key = NormalizeKey(descriptor.Key) })
.GroupBy(descriptor => ComposeStateKey(descriptor.Kind, descriptor.Key), StringComparer.OrdinalIgnoreCase)
.Select(group => group.First())
.OrderBy(descriptor => (int)descriptor.Kind)
.ThenBy(descriptor => descriptor.Key, StringComparer.OrdinalIgnoreCase)
.ToArray();
var activeKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
Items.Clear();
foreach (var descriptor in normalizedDescriptors)
{
var stateKey = ComposeStateKey(descriptor.Kind, descriptor.Key);
activeKeys.Add(stateKey);
var initialState = _enabledStates.TryGetValue(stateKey, out var enabled) ? enabled : true;
_enabledStates[stateKey] = initialState;
Items.Add(new CutDebugItemState(
descriptor.Key,
descriptor.Kind,
descriptor.GroupLabel,
initialState,
isEnabled => _enabledStates[stateKey] = isEnabled));
}
foreach (var staleKey in _enabledStates.Keys.Where(key => !activeKeys.Contains(key)).ToArray())
{
_enabledStates.TryRemove(staleKey, out _);
}
foreach (var staleKey in _overrides.Keys.Where(key => !activeKeys.Contains(key)).ToArray())
{
_overrides.TryRemove(staleKey, out _);
}
}
public bool IsEnabled(string key, CutDebugItemKind kind)
{
var stateKey = ComposeStateKey(kind, NormalizeKey(key));
return !_enabledStates.TryGetValue(stateKey, out var enabled) || enabled;
}
public void SetOverride(string key, CutDebugItemKind kind, CutDebugOverride overrideValue)
{
var stateKey = ComposeStateKey(kind, NormalizeKey(key));
if (overrideValue.Mode == CutDebugOverrideMode.None)
{
_overrides.TryRemove(stateKey, out _);
return;
}
_overrides[stateKey] = overrideValue;
}
public bool TryGetOverride(string key, CutDebugItemKind kind, out CutDebugOverride overrideValue)
{
var stateKey = ComposeStateKey(kind, NormalizeKey(key));
if (_overrides.TryGetValue(stateKey, out overrideValue) &&
overrideValue.Mode != CutDebugOverrideMode.None)
{
return true;
}
overrideValue = CutDebugOverride.None;
return false;
}
public void ClearOverrides()
{
_overrides.Clear();
}
public static string NormalizeKey(string key)
{
if (string.IsNullOrWhiteSpace(key))
{
return string.Empty;
}
if (string.Equals(key, "유확당", StringComparison.Ordinal))
{
return "유확당01";
}
foreach (var prefix in IndexedPrefixes)
{
if (!key.StartsWith(prefix, StringComparison.Ordinal))
{
continue;
}
var suffix = key.Substring(prefix.Length);
if (suffix.Length == 0)
{
return BareSlotOnePrefixes.Contains(prefix, StringComparer.Ordinal)
? $"{prefix}01"
: key;
}
if (suffix.Length <= 2 && suffix.All(char.IsDigit) && int.TryParse(suffix, out var index))
{
return $"{prefix}{index:00}";
}
return key;
}
return key;
}
private static string ComposeStateKey(CutDebugItemKind kind, string key)
{
return $"{kind}|{key}";
}
private static readonly string[] IndexedPrefixes =
[
"순위",
"기호",
"기호텍스트",
"후보명",
"정당명",
"득표수",
"득표율",
"표차",
"득표차",
"선거구명",
"시도명",
"개표율",
"투표율",
"전국투표율",
"기준시",
"유권자수",
"투표자수",
"유확당",
"후보사진",
"득표수바",
"정당바",
"정당판",
"정당원",
"정당색",
"정당심볼",
"그룹",
"공약그룹",
"공약",
"바"
];
private static readonly string[] BareSlotOnePrefixes =
[
"선거구명",
"시도명",
"개표율",
"투표율",
"전국투표율",
"기준시",
"유권자수",
"투표자수"
];
}

View File

@@ -0,0 +1,40 @@
namespace Tornado3_2026Election.Domain;
public enum CutDebugOverrideMode
{
None,
Replace
}
public readonly record struct CutDebugOverride(
CutDebugOverrideMode Mode,
string? StringValue,
double? NumberValue,
bool? BooleanValue,
byte R,
byte G,
byte B,
byte A)
{
public static CutDebugOverride None => new(CutDebugOverrideMode.None, null, null, null, 0, 0, 0, byte.MaxValue);
public static CutDebugOverride ForString(string value)
{
return new(CutDebugOverrideMode.Replace, value, null, null, 0, 0, 0, byte.MaxValue);
}
public static CutDebugOverride ForNumber(double value)
{
return new(CutDebugOverrideMode.Replace, null, value, null, 0, 0, 0, byte.MaxValue);
}
public static CutDebugOverride ForVisibility(bool value)
{
return new(CutDebugOverrideMode.Replace, null, null, value, 0, 0, 0, byte.MaxValue);
}
public static CutDebugOverride ForColor(byte r, byte g, byte b, byte a = byte.MaxValue)
{
return new(CutDebugOverrideMode.Replace, null, null, null, r, g, b, a);
}
}

View File

@@ -0,0 +1,155 @@
using Tornado3_2026Election.Common;
namespace Tornado3_2026Election.Domain;
public sealed class CutDebugSettings : ObservableObject
{
private bool _isEnabled;
private bool _applyTextValues = true;
private bool _applyImageValues = true;
private bool _applyVisibilityValues = true;
private bool _applyVoteRateTextValues = true;
private bool _applyVoteRateCounterValues = true;
private bool _applyPartyBarStyleColors = true;
private bool _applyPartyPlateStyleColors = true;
private bool _applyVoteRateStyleColors = true;
public bool IsEnabled
{
get => _isEnabled;
set
{
if (SetProperty(ref _isEnabled, value))
{
OnPropertyChanged(nameof(Summary));
}
}
}
public bool ApplyTextValues
{
get => _applyTextValues;
set
{
if (SetProperty(ref _applyTextValues, value))
{
OnPropertyChanged(nameof(Summary));
}
}
}
public bool ApplyImageValues
{
get => _applyImageValues;
set
{
if (SetProperty(ref _applyImageValues, value))
{
OnPropertyChanged(nameof(Summary));
}
}
}
public bool ApplyVisibilityValues
{
get => _applyVisibilityValues;
set
{
if (SetProperty(ref _applyVisibilityValues, value))
{
OnPropertyChanged(nameof(Summary));
}
}
}
public bool ApplyVoteRateTextValues
{
get => _applyVoteRateTextValues;
set
{
if (SetProperty(ref _applyVoteRateTextValues, value))
{
OnPropertyChanged(nameof(Summary));
}
}
}
public bool ApplyVoteRateCounterValues
{
get => _applyVoteRateCounterValues;
set
{
if (SetProperty(ref _applyVoteRateCounterValues, value))
{
OnPropertyChanged(nameof(Summary));
}
}
}
public bool ApplyPartyBarStyleColors
{
get => _applyPartyBarStyleColors;
set
{
if (SetProperty(ref _applyPartyBarStyleColors, value))
{
OnPropertyChanged(nameof(Summary));
}
}
}
public bool ApplyPartyPlateStyleColors
{
get => _applyPartyPlateStyleColors;
set
{
if (SetProperty(ref _applyPartyPlateStyleColors, value))
{
OnPropertyChanged(nameof(Summary));
}
}
}
public bool ApplyVoteRateStyleColors
{
get => _applyVoteRateStyleColors;
set
{
if (SetProperty(ref _applyVoteRateStyleColors, value))
{
OnPropertyChanged(nameof(Summary));
}
}
}
public string Summary => !IsEnabled
? "디버그 OFF - 현재와 동일하게 전체 송출"
: $"텍스트 {ToOnOff(ApplyTextValues)}, 이미지 {ToOnOff(ApplyImageValues)}, 표시/숨김 {ToOnOff(ApplyVisibilityValues)}, 득표율 텍스트 {ToOnOff(ApplyTextValues && ApplyVoteRateTextValues)}, 득표율 카운터 {ToOnOff(ApplyVoteRateCounterValues)}, 정당 바/막대 색상 {ToOnOff(ApplyPartyBarStyleColors)}, 정당 판/문자 색상 {ToOnOff(ApplyPartyPlateStyleColors)}, 득표율 색상 {ToOnOff(ApplyVoteRateStyleColors)}";
public CutDebugSettingsSnapshot CreateSnapshot()
{
return new CutDebugSettingsSnapshot(
IsEnabled,
ApplyTextValues,
ApplyImageValues,
ApplyVisibilityValues,
ApplyVoteRateTextValues,
ApplyVoteRateCounterValues,
ApplyPartyBarStyleColors,
ApplyPartyPlateStyleColors,
ApplyVoteRateStyleColors);
}
private static string ToOnOff(bool value) => value ? "ON" : "OFF";
}
public readonly record struct CutDebugSettingsSnapshot(
bool IsEnabled,
bool ApplyTextValues,
bool ApplyImageValues,
bool ApplyVisibilityValues,
bool ApplyVoteRateTextValues,
bool ApplyVoteRateCounterValues,
bool ApplyPartyBarStyleColors,
bool ApplyPartyPlateStyleColors,
bool ApplyVoteRateStyleColors);

View File

@@ -0,0 +1,10 @@
namespace Tornado3_2026Election.Domain;
public enum CutListElectionCategory
{
MetropolitanHead,
MetropolitanCouncil,
Superintendent,
LocalHead,
LocalCouncil
}

View File

@@ -24,6 +24,10 @@ public sealed class FormatTemplateDefinition
public required IReadOnlyList<FormatCutDefinition> Cuts { get; init; } public required IReadOnlyList<FormatCutDefinition> Cuts { get; init; }
public int? SceneWidth { get; init; }
public int? SceneHeight { get; init; }
public bool IsAvailableInPhase(BroadcastPhase phase) public bool IsAvailableInPhase(BroadcastPhase phase)
{ {
return phase switch return phase switch

View File

@@ -0,0 +1,8 @@
namespace Tornado3_2026Election.Domain;
public enum VideoWallLayoutPreset
{
Auto,
Standard5760x1080,
UltraWide11520x1080
}

View File

@@ -470,9 +470,6 @@
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="10" VerticalAlignment="Bottom"> <StackPanel Grid.Column="2" 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}" /> <NumberBox Width="140" Header="주기(초)" Minimum="3" SpinButtonPlacementMode="Compact" Value="{x:Bind ViewModel.Data.PollingIntervalSeconds, Mode=TwoWay}" />
<ToggleSwitch Header="설정 권역만 보기"
IsEnabled="{x:Bind ViewModel.Data.HasConfiguredRegionFilter, Mode=OneWay}"
IsOn="{x:Bind ViewModel.Data.ShowOnlyConfiguredRegions, Mode=TwoWay}" />
<Button Command="{x:Bind ViewModel.Data.ManualRefreshCommand}" Content="수동 갱신" Style="{StaticResource ConsolePrimaryButtonStyle}" /> <Button Command="{x:Bind ViewModel.Data.ManualRefreshCommand}" Content="수동 갱신" Style="{StaticResource ConsolePrimaryButtonStyle}" />
</StackPanel> </StackPanel>
</Grid> </Grid>
@@ -512,7 +509,8 @@
Background="#132338" Background="#132338"
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
BorderThickness="1" BorderThickness="1"
CornerRadius="8"> CornerRadius="8"
Tapped="DistrictOverviewCard_Tapped">
<StackPanel Spacing="6"> <StackPanel Spacing="6">
<TextBlock Foreground="{StaticResource ControlRoomTextPrimaryBrush}" <TextBlock Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
Text="{x:Bind RegionName}" Text="{x:Bind RegionName}"
@@ -774,6 +772,7 @@
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="현재 송출 컷" /> <TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="현재 송출 컷" />
<Grid ColumnSpacing="12" RowSpacing="12"> <Grid ColumnSpacing="12" RowSpacing="12">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="220" />
<ColumnDefinition Width="220" /> <ColumnDefinition Width="220" />
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" /> <ColumnDefinition Width="Auto" />
@@ -782,7 +781,12 @@
DisplayMemberPath="Label" DisplayMemberPath="Label"
ItemsSource="{x:Bind ViewModel.CutListFilterOptions, Mode=OneWay}" ItemsSource="{x:Bind ViewModel.CutListFilterOptions, Mode=OneWay}"
SelectedItem="{x:Bind ViewModel.SelectedCutListFilterOption, Mode=TwoWay}" /> SelectedItem="{x:Bind ViewModel.SelectedCutListFilterOption, Mode=TwoWay}" />
<Border Grid.Column="1" <ComboBox Grid.Column="1"
Header="선거 분류"
DisplayMemberPath="Label"
ItemsSource="{x:Bind ViewModel.CutListCategoryOptions, Mode=OneWay}"
SelectedItem="{x:Bind ViewModel.SelectedCutListCategoryOption, Mode=TwoWay}" />
<Border Grid.Column="2"
Padding="14" Padding="14"
Background="#132338" Background="#132338"
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
@@ -797,7 +801,7 @@
TextWrapping="Wrap" /> TextWrapping="Wrap" />
</StackPanel> </StackPanel>
</Border> </Border>
<StackPanel Grid.Column="2" <StackPanel Grid.Column="3"
HorizontalAlignment="Right" HorizontalAlignment="Right"
Spacing="6"> Spacing="6">
<Button Command="{x:Bind ViewModel.GenerateCutThumbnailsCommand, Mode=OneWay}" <Button Command="{x:Bind ViewModel.GenerateCutThumbnailsCommand, Mode=OneWay}"
@@ -824,7 +828,7 @@
<StackPanel Spacing="14"> <StackPanel Spacing="14">
<Grid ColumnSpacing="12"> <Grid ColumnSpacing="12">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="168" /> <ColumnDefinition Width="Auto" />
<ColumnDefinition Width="110" /> <ColumnDefinition Width="110" />
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
<ColumnDefinition Width="140" /> <ColumnDefinition Width="140" />
@@ -848,20 +852,20 @@
CornerRadius="8"> CornerRadius="8">
<Grid ColumnSpacing="12"> <Grid ColumnSpacing="12">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="168" /> <ColumnDefinition Width="Auto" />
<ColumnDefinition Width="110" /> <ColumnDefinition Width="110" />
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
<ColumnDefinition Width="140" /> <ColumnDefinition Width="140" />
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<Border Width="160" <Border Width="{x:Bind ThumbnailWidth, Mode=OneWay}"
Height="90" Height="{x:Bind ThumbnailHeight, Mode=OneWay}"
Background="#0B1624" Background="#0B1624"
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
BorderThickness="1" BorderThickness="1"
CornerRadius="6"> CornerRadius="6">
<Image Source="{x:Bind ThumbnailSource, Mode=OneWay}" <Image Source="{x:Bind ThumbnailSource, Mode=OneWay}"
Stretch="UniformToFill" /> Stretch="Uniform" />
</Border> </Border>
<StackPanel Grid.Column="1" Spacing="4"> <StackPanel Grid.Column="1" Spacing="4">
@@ -947,6 +951,33 @@
Style="{StaticResource ConsoleGhostButtonStyle}" /> Style="{StaticResource ConsoleGhostButtonStyle}" />
</Grid> </Grid>
</Grid> </Grid>
<Grid ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="280" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<ComboBox DisplayMemberPath="Label"
Header="비디오월 화면"
ItemsSource="{x:Bind ViewModel.Settings.VideoWallLayoutOptions, Mode=OneWay}"
SelectedValue="{x:Bind ViewModel.Settings.SelectedStationVideoWallLayoutPreset, Mode=TwoWay}"
SelectedValuePath="Value" />
<Border Grid.Column="1"
Padding="14,10"
Background="#132338"
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
BorderThickness="1"
CornerRadius="16">
<StackPanel Spacing="4">
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="현재 비디오월 기준" />
<TextBlock FontFamily="Bahnschrift SemiBold"
FontSize="18"
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
Text="{x:Bind ViewModel.Settings.SelectedStationVideoWallLayoutSummary, Mode=OneWay}" />
</StackPanel>
</Border>
</Grid>
</StackPanel> </StackPanel>
</Border> </Border>
<Border Grid.Column="1" Padding="20" Background="{StaticResource ControlRoomPanelGradientBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="24"> <Border Grid.Column="1" Padding="20" Background="{StaticResource ControlRoomPanelGradientBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="24">

View File

@@ -1,6 +1,7 @@
using Microsoft.UI.Windowing; using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml; using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using System; using System;
using System.IO; using System.IO;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
@@ -432,6 +433,17 @@ public sealed partial class MainWindow : Window
} }
} }
private void DistrictOverviewCard_Tapped(object sender, TappedRoutedEventArgs e)
{
if (sender is not FrameworkElement element ||
element.DataContext is not DistrictOverviewCardViewModel card)
{
return;
}
ViewModel.Data.SelectDistrictOverviewCard(card.DistrictViewName);
}
private void EnsureNavigationSelection() private void EnsureNavigationSelection()
{ {
if (!ViewModel.IsPageAvailable(ViewModel.CurrentPage)) if (!ViewModel.IsPageAvailable(ViewModel.CurrentPage))

View File

@@ -52,4 +52,6 @@ public sealed class AppState
public Dictionary<string, double> CutDurations { get; set; } = []; public Dictionary<string, double> CutDurations { get; set; } = [];
public Dictionary<string, string> StationRegionFilters { get; set; } = []; public Dictionary<string, string> StationRegionFilters { get; set; } = [];
public Dictionary<string, string> StationVideoWallLayouts { get; set; } = [];
} }

View File

@@ -0,0 +1,33 @@
using System;
using System.Collections.Generic;
namespace Tornado3_2026Election.Services;
internal static class CutAppearancePolicyCatalog
{
private static readonly IReadOnlyDictionary<string, IReadOnlySet<string>> DefaultAppearanceSectionsByTemplate =
new Dictionary<string, IReadOnlySet<string>>(StringComparer.Ordinal)
{
["1-2위_ani_광역단체장"] = CreateSectionSet(
"정당판",
"정당바",
"득표수바",
"정당원",
"정당색",
"정당명",
"득표율")
};
public static bool UsesTemplateDefaultAppearance(string templateName, string sectionName)
{
return !string.IsNullOrWhiteSpace(templateName) &&
!string.IsNullOrWhiteSpace(sectionName) &&
DefaultAppearanceSectionsByTemplate.TryGetValue(templateName, out var sections) &&
sections.Contains(sectionName);
}
private static IReadOnlySet<string> CreateSectionSet(params string[] sections)
{
return new HashSet<string>(sections, StringComparer.Ordinal);
}
}

View File

@@ -0,0 +1,60 @@
using System;
using System.Collections.Generic;
using Tornado3_2026Election.Domain;
namespace Tornado3_2026Election.Services;
public sealed class CutDebugStateStore
{
private readonly object _syncRoot = new();
private readonly Dictionary<BroadcastChannel, CutDebugSettings> _settingsByChannel = new();
private readonly Dictionary<string, CutDebugTemplateState> _templateStates = new(StringComparer.Ordinal);
public CutDebugStateStore()
{
foreach (var channel in Enum.GetValues<BroadcastChannel>())
{
_settingsByChannel[channel] = new CutDebugSettings();
}
}
public CutDebugSettings Get(BroadcastChannel channel)
{
return _settingsByChannel[channel];
}
public CutDebugTemplateState GetTemplate(BroadcastChannel channel, string formatId, string displayName)
{
var templateKey = BuildTemplateKey(channel, formatId);
lock (_syncRoot)
{
if (!_templateStates.TryGetValue(templateKey, out var templateState))
{
templateState = new CutDebugTemplateState(formatId, displayName);
_templateStates[templateKey] = templateState;
}
else
{
templateState.UpdateDisplayName(displayName);
}
return templateState;
}
}
public CutDebugTemplateState? FindTemplate(BroadcastChannel channel, string formatId)
{
var templateKey = BuildTemplateKey(channel, formatId);
lock (_syncRoot)
{
return _templateStates.TryGetValue(templateKey, out var templateState)
? templateState
: null;
}
}
private static string BuildTemplateKey(BroadcastChannel channel, string formatId)
{
return $"{channel}|{formatId}";
}
}

View File

@@ -0,0 +1,52 @@
using System;
using Tornado3_2026Election.Domain;
namespace Tornado3_2026Election.Services;
public static class CutListElectionCategoryResolver
{
public static CutListElectionCategory Resolve(string? formatName)
{
var resolvedFormatName = formatName ?? string.Empty;
if (resolvedFormatName.Contains("\uAD50\uC721\uAC10", StringComparison.Ordinal))
{
return CutListElectionCategory.Superintendent;
}
if (resolvedFormatName.Contains("\uAE30\uCD08\uC758\uC6D0", StringComparison.Ordinal))
{
return CutListElectionCategory.LocalCouncil;
}
if (resolvedFormatName.Contains("\uAE30\uCD08\uB2E8\uCCB4\uC7A5", StringComparison.Ordinal))
{
return CutListElectionCategory.LocalHead;
}
if (resolvedFormatName.Contains("\uAD11\uC5ED\uC758\uC6D0", StringComparison.Ordinal))
{
return CutListElectionCategory.MetropolitanCouncil;
}
if (resolvedFormatName.Contains("\uAD11\uC5ED\uB2E8\uCCB4\uC7A5", StringComparison.Ordinal) ||
resolvedFormatName.Contains("\uBCF4\uAADC\uC120\uAC70", StringComparison.Ordinal))
{
return CutListElectionCategory.MetropolitanHead;
}
return CutListElectionCategory.MetropolitanHead;
}
public static string GetLabel(CutListElectionCategory category)
{
return category switch
{
CutListElectionCategory.MetropolitanCouncil => "\uAD11\uC5ED\uC758\uC6D0",
CutListElectionCategory.Superintendent => "\uAD50\uC721\uAC10",
CutListElectionCategory.LocalHead => "\uAE30\uCD08\uB2E8\uCCB4\uC7A5",
CutListElectionCategory.LocalCouncil => "\uAE30\uCD08\uC758\uC6D0",
_ => "\uAD11\uC5ED\uB2E8\uCCB4\uC7A5"
};
}
}

View File

@@ -9,7 +9,14 @@ namespace Tornado3_2026Election.Services;
public sealed class FormatCatalogService public sealed class FormatCatalogService
{ {
private static readonly IReadOnlyDictionary<string, string> LegacyFormatAliases = BuildLegacyFormatAliases(); private static readonly IReadOnlyDictionary<string, string> LegacyFormatAliases = BuildLegacyFormatAliases();
private readonly IReadOnlyList<FormatTemplateDefinition> _formats = BuildFormats(); private readonly string _t3CutPath;
private readonly IReadOnlyList<FormatTemplateDefinition> _formats;
public FormatCatalogService(string? configuredT3CutPath = null)
{
_t3CutPath = ResolveT3CutPath(configuredT3CutPath);
_formats = BuildFormats(_t3CutPath);
}
public IReadOnlyList<FormatTemplateDefinition> GetAll() => _formats; public IReadOnlyList<FormatTemplateDefinition> GetAll() => _formats;
@@ -33,7 +40,7 @@ public sealed class FormatCatalogService
return _formats.FirstOrDefault(format => string.Equals(format.Id, formatId, StringComparison.Ordinal)); return _formats.FirstOrDefault(format => string.Equals(format.Id, formatId, StringComparison.Ordinal));
} }
private static IReadOnlyList<FormatTemplateDefinition> BuildFormats() private static IReadOnlyList<FormatTemplateDefinition> BuildFormats(string t3CutPath)
{ {
List<FormatTemplateDefinition> formats = []; List<FormatTemplateDefinition> formats = [];
@@ -41,6 +48,7 @@ public sealed class FormatCatalogService
BroadcastChannel.Bottom, BroadcastChannel.Bottom,
"Elect2026_Bottom_민방", "Elect2026_Bottom_민방",
8, 8,
t3CutPath,
"1-2위_광역단체장", "1-2위_광역단체장",
"1-2위_기초단체장", "1-2위_기초단체장",
"1-3위_광역단체장", "1-3위_광역단체장",
@@ -61,6 +69,7 @@ public sealed class FormatCatalogService
BroadcastChannel.Normal, BroadcastChannel.Normal,
"Elect2026_Normal_민방", "Elect2026_Normal_민방",
10, 10,
t3CutPath,
"1-2위_ani_광역단체장", "1-2위_ani_광역단체장",
"1-2위_ani_기초단체장", "1-2위_ani_기초단체장",
"1-2위_ani_기초단체장_5760", "1-2위_ani_기초단체장_5760",
@@ -129,6 +138,7 @@ public sealed class FormatCatalogService
BroadcastChannel.TopLeft, BroadcastChannel.TopLeft,
"Elect2026_Top_민방", "Elect2026_Top_민방",
6, 6,
t3CutPath,
"광역단체장_2인", "광역단체장_2인",
"광역단체장_2인_텍스트", "광역단체장_2인_텍스트",
"기초단체장_2인", "기초단체장_2인",
@@ -148,24 +158,28 @@ public sealed class FormatCatalogService
BroadcastChannel channel, BroadcastChannel channel,
string relativeFolder, string relativeFolder,
double defaultCutDurationSeconds, double defaultCutDurationSeconds,
string t3CutPath,
params string[] baseNames) params string[] baseNames)
{ {
foreach (var baseName in baseNames) foreach (var baseName in baseNames)
{ {
var isAvailableInBothPhases = IsAvailableInBothPhases(baseName); var isAvailableInBothPhases = IsAvailableInBothPhases(baseName);
var isPreElectionOnlyFormat = !isAvailableInBothPhases && IsPreElectionOnlyFormat(baseName); var isPreElectionOnlyFormat = !isAvailableInBothPhases && IsPreElectionOnlyFormat(baseName);
var sceneResolution = TryReadSceneResolution(relativeFolder, baseName, t3CutPath);
yield return new FormatTemplateDefinition yield return new FormatTemplateDefinition
{ {
Id = Path.Combine(relativeFolder, baseName), Id = Path.Combine(relativeFolder, baseName),
Name = baseName, Name = baseName,
Description = $"{relativeFolder} 컷", Description = $"{relativeFolder} 컷",
RecommendedChannel = ResolveRecommendedChannel(channel, baseName), RecommendedChannel = ResolveRecommendedChannel(channel, baseName, sceneResolution),
RequiresImage = false, RequiresImage = false,
SupportsPreElection = isAvailableInBothPhases || isPreElectionOnlyFormat, SupportsPreElection = isAvailableInBothPhases || isPreElectionOnlyFormat,
SupportsCounting = isAvailableInBothPhases || !isPreElectionOnlyFormat, SupportsCounting = isAvailableInBothPhases || !isPreElectionOnlyFormat,
RequiresCandidateData = !isPreElectionOnlyFormat && !IsHistoricalPreElectionWinnerFormat(baseName), RequiresCandidateData = !isPreElectionOnlyFormat && !IsHistoricalPreElectionWinnerFormat(baseName),
LoopMode = LoopMode.None, LoopMode = LoopMode.None,
SceneWidth = sceneResolution?.Width,
SceneHeight = sceneResolution?.Height,
Cuts = Cuts =
[ [
new FormatCutDefinition new FormatCutDefinition
@@ -233,8 +247,21 @@ public sealed class FormatCatalogService
return baseName.StartsWith("사전_역대당선", StringComparison.Ordinal); return baseName.StartsWith("사전_역대당선", StringComparison.Ordinal);
} }
private static BroadcastChannel ResolveRecommendedChannel(BroadcastChannel fallbackChannel, string baseName) private static BroadcastChannel ResolveRecommendedChannel(
BroadcastChannel fallbackChannel,
string baseName,
KarismaSceneResolution? sceneResolution)
{ {
if (fallbackChannel != BroadcastChannel.Normal)
{
return fallbackChannel;
}
if (TryResolveRecommendedChannelFromSceneSize(sceneResolution, out var resolvedChannel))
{
return resolvedChannel;
}
return IsVideoWallFormat(baseName) return IsVideoWallFormat(baseName)
? BroadcastChannel.VideoWall ? BroadcastChannel.VideoWall
: fallbackChannel; : fallbackChannel;
@@ -245,4 +272,48 @@ public sealed class FormatCatalogService
return baseName.Contains("_5760", StringComparison.Ordinal) || return baseName.Contains("_5760", StringComparison.Ordinal) ||
baseName.Contains("_L", StringComparison.Ordinal); baseName.Contains("_L", StringComparison.Ordinal);
} }
private static KarismaSceneResolution? TryReadSceneResolution(string relativeFolder, string baseName, string t3CutPath)
{
if (string.IsNullOrWhiteSpace(t3CutPath))
{
return null;
}
var scenePath = Path.Combine(t3CutPath, relativeFolder, baseName + ".tscn");
return KarismaSceneResolutionReader.TryRead(scenePath, out var resolution)
? resolution
: null;
}
private static bool TryResolveRecommendedChannelFromSceneSize(
KarismaSceneResolution? sceneResolution,
out BroadcastChannel channel)
{
channel = BroadcastChannel.Normal;
if (sceneResolution is null)
{
return false;
}
channel = sceneResolution.Value is { Width: 1920, Height: 1080 }
? BroadcastChannel.Normal
: sceneResolution.Value.Width > 1920 && sceneResolution.Value.Height == 1080
? BroadcastChannel.VideoWall
: BroadcastChannel.Normal;
return true;
}
private static string ResolveT3CutPath(string? configuredT3CutPath)
{
var normalizedPath = TornadoPathResolver.NormalizeConfiguredPath(configuredT3CutPath);
if (!string.IsNullOrWhiteSpace(normalizedPath))
{
return normalizedPath;
}
return TornadoPathResolver.GetDefaultT3CutPath();
}
} }

View File

@@ -0,0 +1,65 @@
using System;
using System.Buffers.Binary;
using System.IO;
namespace Tornado3_2026Election.Services;
public static class KarismaSceneResolutionReader
{
private const int HeaderScanLength = 1024;
private const int Float50Bits = 1112014848;
private const int Float1000Bits = 1148846080;
public static bool TryRead(string scenePath, out KarismaSceneResolution resolution)
{
resolution = default;
if (string.IsNullOrWhiteSpace(scenePath) || !File.Exists(scenePath))
{
return false;
}
try
{
using var stream = File.OpenRead(scenePath);
Span<byte> header = stackalloc byte[HeaderScanLength];
var bytesRead = stream.Read(header);
if (bytesRead < 16)
{
return false;
}
var headerSlice = header[..bytesRead];
for (var offset = 0; offset <= headerSlice.Length - 16; offset += 4)
{
var width = BinaryPrimitives.ReadInt32LittleEndian(headerSlice.Slice(offset, 4));
var height = BinaryPrimitives.ReadInt32LittleEndian(headerSlice.Slice(offset + 4, 4));
var marker50 = BinaryPrimitives.ReadInt32LittleEndian(headerSlice.Slice(offset + 8, 4));
var marker1000 = BinaryPrimitives.ReadInt32LittleEndian(headerSlice.Slice(offset + 12, 4));
if (!IsPlausibleDimension(width, height) ||
marker50 != Float50Bits ||
marker1000 != Float1000Bits)
{
continue;
}
resolution = new KarismaSceneResolution(width, height);
return true;
}
}
catch
{
}
return false;
}
private static bool IsPlausibleDimension(int width, int height)
{
return width is >= 320 and <= 20000 &&
height is >= 180 and <= 12000;
}
}
public readonly record struct KarismaSceneResolution(int Width, int Height);

View File

@@ -10,8 +10,9 @@ public sealed class KarismaSceneVariableCatalog
{ {
private static readonly string[] PreferredReportNames = private static readonly string[] PreferredReportNames =
[ [
"TSCN_VARIABLE_DISCOVERY_ELECT2026_NORMAL.md",
"TSCN_VARIABLE_DISCOVERY_E_DRIVE.md", "TSCN_VARIABLE_DISCOVERY_E_DRIVE.md",
"TSCN_VARIABLE_DISCOVERY_ELECT2026_NORMAL.md",
"TSCN_VARIABLE_DISCOVERY_ONE.md",
"TSCN_VARIABLE_DISCOVERY.md" "TSCN_VARIABLE_DISCOVERY.md"
]; ];
@@ -28,8 +29,8 @@ public sealed class KarismaSceneVariableCatalog
public static KarismaSceneVariableCatalog Load(LogService logService) public static KarismaSceneVariableCatalog Load(LogService logService)
{ {
var reportPath = FindDiscoveryReportPath(); var reportPaths = FindDiscoveryReportPaths().ToArray();
if (string.IsNullOrWhiteSpace(reportPath) || !File.Exists(reportPath)) if (reportPaths.Length == 0)
{ {
logService.Warning("Karisma scene variable catalog report was not found. Falling back to runtime value heuristics."); logService.Warning("Karisma scene variable catalog report was not found. Falling back to runtime value heuristics.");
return new KarismaSceneVariableCatalog( return new KarismaSceneVariableCatalog(
@@ -38,8 +39,17 @@ public sealed class KarismaSceneVariableCatalog
try try
{ {
var scenes = ParseReport(reportPath); var mergedScenes = new Dictionary<string, Dictionary<string, KarismaSceneVariableDefinition>>(StringComparer.OrdinalIgnoreCase);
logService.Info($"Karisma scene variable catalog loaded: scenes={scenes.Count} source='{reportPath}'."); foreach (var reportPath in reportPaths)
{
MergeScenes(mergedScenes, ParseReport(reportPath));
}
var scenes = mergedScenes.ToDictionary(
pair => pair.Key,
pair => (IReadOnlyDictionary<string, KarismaSceneVariableDefinition>)pair.Value,
StringComparer.OrdinalIgnoreCase);
logService.Info($"Karisma scene variable catalog loaded: scenes={scenes.Count} sources={reportPaths.Length}.");
return new KarismaSceneVariableCatalog(scenes); return new KarismaSceneVariableCatalog(scenes);
} }
catch (Exception ex) catch (Exception ex)
@@ -60,22 +70,45 @@ public sealed class KarismaSceneVariableCatalog
} }
var relativePath = NormalizeRelativePath(Path.GetRelativePath(t3CutPath, scenePath)); var relativePath = NormalizeRelativePath(Path.GetRelativePath(t3CutPath, scenePath));
return _scenes.TryGetValue(relativePath, out var variables) if (_scenes.TryGetValue(relativePath, out var variables))
? variables {
: EmptySceneVariables; return variables;
}
var fileName = Path.GetFileName(relativePath);
if (!string.IsNullOrWhiteSpace(fileName))
{
var fileNameMatches = _scenes
.Where(pair => string.Equals(Path.GetFileName(pair.Key), fileName, StringComparison.OrdinalIgnoreCase))
.Take(2)
.ToArray();
if (fileNameMatches.Length == 1)
{
return fileNameMatches[0].Value;
}
}
return EmptySceneVariables;
} }
private static IReadOnlyDictionary<string, IReadOnlyDictionary<string, KarismaSceneVariableDefinition>> ParseReport(string reportPath) private static IReadOnlyDictionary<string, IReadOnlyDictionary<string, KarismaSceneVariableDefinition>> ParseReport(string reportPath)
{ {
var scenes = new Dictionary<string, Dictionary<string, KarismaSceneVariableDefinition>>(StringComparer.OrdinalIgnoreCase); var scenes = new Dictionary<string, Dictionary<string, KarismaSceneVariableDefinition>>(StringComparer.OrdinalIgnoreCase);
string? currentScene = null; string? currentScene = null;
string? reportRootRelativePath = null;
foreach (var rawLine in File.ReadLines(reportPath, Encoding.UTF8)) foreach (var rawLine in File.ReadLines(reportPath, Encoding.UTF8))
{ {
var line = rawLine.Trim(); var line = rawLine.Trim();
if (TryParseReportRoot(line, out var reportRoot))
{
reportRootRelativePath = NormalizeReportRootRelativePath(reportRoot);
continue;
}
if (TryParseSceneHeader(line, out var sceneRelativePath)) if (TryParseSceneHeader(line, out var sceneRelativePath))
{ {
currentScene = NormalizeRelativePath(sceneRelativePath); currentScene = NormalizeSceneKey(reportRootRelativePath, sceneRelativePath);
if (!scenes.ContainsKey(currentScene)) if (!scenes.ContainsKey(currentScene))
{ {
scenes[currentScene] = new Dictionary<string, KarismaSceneVariableDefinition>(StringComparer.OrdinalIgnoreCase); scenes[currentScene] = new Dictionary<string, KarismaSceneVariableDefinition>(StringComparer.OrdinalIgnoreCase);
@@ -124,6 +157,25 @@ public sealed class KarismaSceneVariableCatalog
StringComparer.OrdinalIgnoreCase); StringComparer.OrdinalIgnoreCase);
} }
private static void MergeScenes(
IDictionary<string, Dictionary<string, KarismaSceneVariableDefinition>> target,
IReadOnlyDictionary<string, IReadOnlyDictionary<string, KarismaSceneVariableDefinition>> source)
{
foreach (var (scenePath, variables) in source)
{
if (!target.TryGetValue(scenePath, out var mergedVariables))
{
mergedVariables = new Dictionary<string, KarismaSceneVariableDefinition>(StringComparer.OrdinalIgnoreCase);
target[scenePath] = mergedVariables;
}
foreach (var (variableName, definition) in variables)
{
mergedVariables[variableName] = definition;
}
}
}
private static bool TryParseSceneHeader(string line, out string sceneRelativePath) private static bool TryParseSceneHeader(string line, out string sceneRelativePath)
{ {
sceneRelativePath = string.Empty; sceneRelativePath = string.Empty;
@@ -136,6 +188,19 @@ public sealed class KarismaSceneVariableCatalog
return !string.IsNullOrWhiteSpace(sceneRelativePath); return !string.IsNullOrWhiteSpace(sceneRelativePath);
} }
private static bool TryParseReportRoot(string line, out string reportRoot)
{
reportRoot = string.Empty;
const string prefix = "- Root: `";
if (!line.StartsWith(prefix, StringComparison.Ordinal) || !line.EndsWith('`'))
{
return false;
}
reportRoot = line.Substring(prefix.Length, line.Length - prefix.Length - 1);
return !string.IsNullOrWhiteSpace(reportRoot);
}
private static List<string> SplitMarkdownRow(string line) private static List<string> SplitMarkdownRow(string line)
{ {
var cells = line.Split('|'); var cells = line.Split('|');
@@ -158,6 +223,11 @@ public sealed class KarismaSceneVariableCatalog
return KarismaSceneVariableKind.Counter; return KarismaSceneVariableKind.Counter;
} }
if (IsLikelyCounterVariableName(variableName))
{
return KarismaSceneVariableKind.Counter;
}
if (variableName.StartsWith("\uC720\uD655\uB2F9", StringComparison.OrdinalIgnoreCase)) if (variableName.StartsWith("\uC720\uD655\uB2F9", StringComparison.OrdinalIgnoreCase))
{ {
return KarismaSceneVariableKind.VideoResource; return KarismaSceneVariableKind.VideoResource;
@@ -179,8 +249,18 @@ public sealed class KarismaSceneVariableCatalog
return KarismaSceneVariableKind.Text; return KarismaSceneVariableKind.Text;
} }
private static string? FindDiscoveryReportPath() private static bool IsLikelyCounterVariableName(string variableName)
{ {
return variableName.StartsWith("득표율", StringComparison.Ordinal) ||
variableName.StartsWith("투표율", StringComparison.Ordinal) ||
variableName.StartsWith("전국투표율", StringComparison.Ordinal);
}
private static IEnumerable<string> FindDiscoveryReportPaths()
{
var seenPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var reportPaths = new List<string>();
foreach (var startPath in EnumerateSearchRoots()) foreach (var startPath in EnumerateSearchRoots())
{ {
var current = startPath; var current = startPath;
@@ -189,23 +269,25 @@ public sealed class KarismaSceneVariableCatalog
foreach (var reportName in PreferredReportNames) foreach (var reportName in PreferredReportNames)
{ {
var candidate = Path.Combine(current, reportName); var candidate = Path.Combine(current, reportName);
if (File.Exists(candidate)) if (File.Exists(candidate) && seenPaths.Add(candidate))
{ {
return candidate; reportPaths.Add(candidate);
} }
} }
var wildcardCandidate = TryFindLatestDiscoveryReport(current); foreach (var wildcardCandidate in TryFindDiscoveryReports(current))
if (!string.IsNullOrWhiteSpace(wildcardCandidate))
{ {
return wildcardCandidate; if (seenPaths.Add(wildcardCandidate))
{
reportPaths.Add(wildcardCandidate);
}
} }
current = Path.GetDirectoryName(current); current = Path.GetDirectoryName(current);
} }
} }
return null; return reportPaths;
} }
private static IEnumerable<string> EnumerateSearchRoots() private static IEnumerable<string> EnumerateSearchRoots()
@@ -222,7 +304,7 @@ public sealed class KarismaSceneVariableCatalog
return roots; return roots;
} }
private static string? TryFindLatestDiscoveryReport(string directoryPath) private static IEnumerable<string> TryFindDiscoveryReports(string directoryPath)
{ {
try try
{ {
@@ -230,12 +312,51 @@ public sealed class KarismaSceneVariableCatalog
.Where(path => !Path.GetFileName(path).Contains("SAMPLE", StringComparison.OrdinalIgnoreCase)) .Where(path => !Path.GetFileName(path).Contains("SAMPLE", StringComparison.OrdinalIgnoreCase))
.OrderByDescending(path => File.GetLastWriteTimeUtc(path)) .OrderByDescending(path => File.GetLastWriteTimeUtc(path))
.ThenBy(path => path, StringComparer.OrdinalIgnoreCase) .ThenBy(path => path, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(); .ToArray();
} }
catch catch
{
return [];
}
}
private static string NormalizeSceneKey(string? reportRootRelativePath, string sceneRelativePath)
{
var normalizedScenePath = NormalizeRelativePath(sceneRelativePath);
if (string.IsNullOrWhiteSpace(reportRootRelativePath) ||
string.IsNullOrWhiteSpace(normalizedScenePath) ||
normalizedScenePath.Contains('\\'))
{
return normalizedScenePath;
}
return NormalizeRelativePath(Path.Combine(reportRootRelativePath, normalizedScenePath));
}
private static string? NormalizeReportRootRelativePath(string reportRootPath)
{
if (string.IsNullOrWhiteSpace(reportRootPath))
{ {
return null; return null;
} }
var normalized = reportRootPath.Replace('/', '\\').Trim().TrimEnd('\\');
if (normalized.EndsWith("\\T3_Cut", StringComparison.OrdinalIgnoreCase))
{
return string.Empty;
}
const string marker = "\\T3_Cut\\";
var markerIndex = normalized.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
if (markerIndex >= 0)
{
return NormalizeRelativePath(normalized[(markerIndex + marker.Length)..]);
}
var leafFolder = Path.GetFileName(normalized);
return string.IsNullOrWhiteSpace(leafFolder)
? null
: NormalizeRelativePath(leafFolder);
} }
private static string NormalizeRelativePath(string relativePath) private static string NormalizeRelativePath(string relativePath)

View File

@@ -11,8 +11,6 @@ namespace Tornado3_2026Election.Services;
public sealed class KarismaThumbnailGeneratorService public sealed class KarismaThumbnailGeneratorService
{ {
private const int DefaultKarismaPort = 30001; private const int DefaultKarismaPort = 30001;
private const int ThumbnailWidth = 320;
private const int ThumbnailHeight = 180;
private const int ThumbnailFrame = -1; private const int ThumbnailFrame = -1;
private readonly LogService _logService; private readonly LogService _logService;
@@ -29,6 +27,7 @@ public sealed class KarismaThumbnailGeneratorService
public async Task<ThumbnailGenerationResult> GenerateAsync( public async Task<ThumbnailGenerationResult> GenerateAsync(
IReadOnlyList<FormatTemplateDefinition> templates, IReadOnlyList<FormatTemplateDefinition> templates,
string configuredT3CutPath, string configuredT3CutPath,
VideoWallLayoutPreset videoWallLayoutPreset,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var t3CutPath = string.IsNullOrWhiteSpace(configuredT3CutPath) var t3CutPath = string.IsNullOrWhiteSpace(configuredT3CutPath)
@@ -80,12 +79,13 @@ public sealed class KarismaThumbnailGeneratorService
Directory.CreateDirectory(targetDirectory); Directory.CreateDirectory(targetDirectory);
} }
var thumbnailSize = ThumbnailLayoutResolver.ResolveGenerationSize(template, videoWallLayoutPreset);
await manager.LoadSceneAsync(resolvedScene.Path, resolvedScene.Alias, cancellationToken).ConfigureAwait(false); await manager.LoadSceneAsync(resolvedScene.Path, resolvedScene.Alias, cancellationToken).ConfigureAwait(false);
await manager.SaveSceneImageAsync( await manager.SaveSceneImageAsync(
resolvedScene.Alias, resolvedScene.Alias,
targetPath, targetPath,
ThumbnailWidth, thumbnailSize.Width,
ThumbnailHeight, thumbnailSize.Height,
ThumbnailFrame, ThumbnailFrame,
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);

View File

@@ -62,6 +62,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
private readonly LogService _logService; private readonly LogService _logService;
private readonly Func<string> _t3CutPathProvider; private readonly Func<string> _t3CutPathProvider;
private readonly KarismaSceneVariableCatalog _sceneVariableCatalog; private readonly KarismaSceneVariableCatalog _sceneVariableCatalog;
private readonly CutDebugStateStore _cutDebugStateStore;
private readonly IReadOnlyDictionary<BroadcastChannel, KarismaChannelBinding> _bindings; private readonly IReadOnlyDictionary<BroadcastChannel, KarismaChannelBinding> _bindings;
private readonly string _connectionTarget; private readonly string _connectionTarget;
private readonly Dictionary<BroadcastChannel, string> _pendingScenes = new(); private readonly Dictionary<BroadcastChannel, string> _pendingScenes = new();
@@ -74,6 +75,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
LogService logService, LogService logService,
Func<string> t3CutPathProvider, Func<string> t3CutPathProvider,
KarismaSceneVariableCatalog sceneVariableCatalog, KarismaSceneVariableCatalog sceneVariableCatalog,
CutDebugStateStore cutDebugStateStore,
string connectionTarget, string connectionTarget,
IReadOnlyDictionary<BroadcastChannel, KarismaChannelBinding> bindings) IReadOnlyDictionary<BroadcastChannel, KarismaChannelBinding> bindings)
{ {
@@ -81,6 +83,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
_logService = logService; _logService = logService;
_t3CutPathProvider = t3CutPathProvider; _t3CutPathProvider = t3CutPathProvider;
_sceneVariableCatalog = sceneVariableCatalog; _sceneVariableCatalog = sceneVariableCatalog;
_cutDebugStateStore = cutDebugStateStore;
_connectionTarget = connectionTarget; _connectionTarget = connectionTarget;
_bindings = bindings; _bindings = bindings;
_manager.ConnectionChanged += (_, _) => ConnectionChanged?.Invoke(this, EventArgs.Empty); _manager.ConnectionChanged += (_, _) => ConnectionChanged?.Invoke(this, EventArgs.Empty);
@@ -113,14 +116,14 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
public event EventHandler? ConnectionChanged; public event EventHandler? ConnectionChanged;
public static ITornado3Adapter CreateOrFallback(LogService logService, Func<string> t3CutPathProvider) public static ITornado3Adapter CreateOrFallback(LogService logService, Func<string> t3CutPathProvider, CutDebugStateStore cutDebugStateStore)
{ {
return TryCreate(logService, t3CutPathProvider, out var adapter) return TryCreate(logService, t3CutPathProvider, cutDebugStateStore, out var adapter)
? adapter ? adapter
: new MockTornado3Adapter(logService); : new MockTornado3Adapter(logService);
} }
public static bool TryCreate(LogService logService, Func<string> t3CutPathProvider, out ITornado3Adapter adapter) public static bool TryCreate(LogService logService, Func<string> t3CutPathProvider, CutDebugStateStore cutDebugStateStore, out ITornado3Adapter adapter)
{ {
var host = Environment.GetEnvironmentVariable("TORNADO_KARISMA_HOST"); var host = Environment.GetEnvironmentVariable("TORNADO_KARISMA_HOST");
if (string.IsNullOrWhiteSpace(host)) if (string.IsNullOrWhiteSpace(host))
@@ -159,6 +162,7 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
logService, logService,
t3CutPathProvider, t3CutPathProvider,
sceneVariableCatalog, sceneVariableCatalog,
cutDebugStateStore,
$"{host}:{port}", $"{host}:{port}",
BuildBindings()); BuildBindings());
return true; return true;
@@ -198,23 +202,72 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
var judgementVisibilityUpdates = BuildJudgementVisibilityUpdates(template, cut, snapshot, t3CutPath, sceneVariables); var judgementVisibilityUpdates = BuildJudgementVisibilityUpdates(template, cut, snapshot, t3CutPath, sceneVariables);
var historicalWinnerVisibilityUpdates = BuildHistoricalWinnerVisibilityUpdates(template, cut, snapshot, sceneVariables); var historicalWinnerVisibilityUpdates = BuildHistoricalWinnerVisibilityUpdates(template, cut, snapshot, sceneVariables);
var careerPromiseVisibilityUpdates = BuildCareerPromiseVisibilityUpdates(template, cut, snapshot, sceneVariables); var careerPromiseVisibilityUpdates = BuildCareerPromiseVisibilityUpdates(template, cut, snapshot, sceneVariables);
var cutDebug = _cutDebugStateStore.Get(channel).CreateSnapshot();
var templateDebug = _cutDebugStateStore.FindTemplate(channel, template.Id);
var filteredValues = FilterObjectValues(values, sceneVariables, cutDebug, templateDebug);
var filteredCounterNumberKeys = FilterCounterNumberKeyUpdates(counterNumberKeys, cutDebug, templateDebug);
var filteredStyleColorUpdates = FilterStyleColorUpdates(styleColorUpdates, cutDebug, templateDebug);
var filteredJudgementVisibilityUpdates = FilterVisibilityUpdatePair(judgementVisibilityUpdates, cutDebug, templateDebug);
var filteredHistoricalWinnerVisibilityUpdates = FilterVisibilityUpdatePair(historicalWinnerVisibilityUpdates, cutDebug, templateDebug);
var filteredCareerPromiseVisibilityUpdates = FilterVisibilityUpdatePair(careerPromiseVisibilityUpdates, cutDebug, templateDebug);
var overriddenValues = ApplyObjectValueOverrides(filteredValues, sceneVariables, templateDebug);
var overriddenCounterNumberKeys = ApplyCounterNumberKeyOverrides(filteredCounterNumberKeys, sceneVariables, templateDebug);
var overriddenStyleColorUpdates = ApplyStyleColorOverrides(filteredStyleColorUpdates, templateDebug);
var overriddenJudgementVisibilityUpdates = ApplyVisibilityOverrides(filteredJudgementVisibilityUpdates, sceneVariables, templateDebug);
var overriddenHistoricalWinnerVisibilityUpdates = ApplyVisibilityOverrides(filteredHistoricalWinnerVisibilityUpdates, sceneVariables, templateDebug);
var overriddenCareerPromiseVisibilityUpdates = ApplyVisibilityOverrides(filteredCareerPromiseVisibilityUpdates, sceneVariables, templateDebug);
LogUnsupportedSceneVariables(channel, template, sceneVariables); LogUnsupportedSceneVariables(channel, template, sceneVariables);
LogCutDebugSummary(
channel,
template,
cut,
cutDebug,
values.Count,
overriddenValues.Count,
overriddenValues.Keys,
counterNumberKeys.Count,
overriddenCounterNumberKeys.Count,
overriddenCounterNumberKeys.Select(update => update.ObjectName),
styleColorUpdates.Count,
overriddenStyleColorUpdates.Count,
overriddenStyleColorUpdates.Select(update => update.ObjectName),
judgementVisibilityUpdates.HideBeforeValue.Count + judgementVisibilityUpdates.ShowAfterValue.Count +
historicalWinnerVisibilityUpdates.HideBeforeValue.Count + historicalWinnerVisibilityUpdates.ShowAfterValue.Count +
careerPromiseVisibilityUpdates.HideBeforeValue.Count + careerPromiseVisibilityUpdates.ShowAfterValue.Count,
overriddenJudgementVisibilityUpdates.HideBeforeValue.Count + overriddenJudgementVisibilityUpdates.ShowAfterValue.Count +
overriddenHistoricalWinnerVisibilityUpdates.HideBeforeValue.Count + overriddenHistoricalWinnerVisibilityUpdates.ShowAfterValue.Count +
overriddenCareerPromiseVisibilityUpdates.HideBeforeValue.Count + overriddenCareerPromiseVisibilityUpdates.ShowAfterValue.Count,
overriddenJudgementVisibilityUpdates.HideBeforeValue
.Concat(overriddenJudgementVisibilityUpdates.ShowAfterValue)
.Concat(overriddenHistoricalWinnerVisibilityUpdates.HideBeforeValue)
.Concat(overriddenHistoricalWinnerVisibilityUpdates.ShowAfterValue)
.Concat(overriddenCareerPromiseVisibilityUpdates.HideBeforeValue)
.Concat(overriddenCareerPromiseVisibilityUpdates.ShowAfterValue)
.Select(update => update.ObjectName));
LogCutDebugOverrides(
channel,
template,
cutDebug,
filteredValues,
overriddenValues,
filteredCounterNumberKeys,
overriddenCounterNumberKeys);
State = TornadoConnectionState.Sending; State = TornadoConnectionState.Sending;
await _manager.EnsureConnectedAsync(cancellationToken).ConfigureAwait(false); await _manager.EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
await _manager.LoadSceneAsync(resolvedScene.Path, resolvedScene.Alias, cancellationToken).ConfigureAwait(false); await _manager.LoadSceneAsync(resolvedScene.Path, resolvedScene.Alias, cancellationToken).ConfigureAwait(false);
await _manager.ApplyValuesAsync( await _manager.ApplyValuesAsync(
resolvedScene.Alias, resolvedScene.Alias,
judgementVisibilityUpdates.HideBeforeValue overriddenJudgementVisibilityUpdates.HideBeforeValue
.Concat(historicalWinnerVisibilityUpdates.HideBeforeValue) .Concat(overriddenHistoricalWinnerVisibilityUpdates.HideBeforeValue)
.Concat(careerPromiseVisibilityUpdates.HideBeforeValue) .Concat(overriddenCareerPromiseVisibilityUpdates.HideBeforeValue)
.ToArray(), .ToArray(),
values, overriddenValues,
counterNumberKeys, overriddenCounterNumberKeys,
styleColorUpdates, overriddenStyleColorUpdates,
judgementVisibilityUpdates.ShowAfterValue overriddenJudgementVisibilityUpdates.ShowAfterValue
.Concat(historicalWinnerVisibilityUpdates.ShowAfterValue) .Concat(overriddenHistoricalWinnerVisibilityUpdates.ShowAfterValue)
.Concat(careerPromiseVisibilityUpdates.ShowAfterValue) .Concat(overriddenCareerPromiseVisibilityUpdates.ShowAfterValue)
.ToArray(), .ToArray(),
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
@@ -715,6 +768,16 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
ElectionDataSnapshot snapshot, ElectionDataSnapshot snapshot,
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables) IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
{ {
if (IsHistoricalTurnoutTemplate(template.Name))
{
return BuildHistoricalTurnoutCounterNumberKeyUpdates(snapshot, sceneVariables);
}
if (IsTurnoutTemplate(template.Name))
{
return BuildTurnoutCounterNumberKeyUpdates(snapshot, sceneVariables);
}
if (!IsAnimatedTemplate(template)) if (!IsAnimatedTemplate(template))
{ {
return Array.Empty<KarismaCounterNumberKeyUpdate>(); return Array.Empty<KarismaCounterNumberKeyUpdate>();
@@ -747,6 +810,621 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
return updates; return updates;
} }
private static IReadOnlyList<KarismaCounterNumberKeyUpdate> BuildTurnoutCounterNumberKeyUpdates(
ElectionDataSnapshot snapshot,
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
{
var updates = new List<KarismaCounterNumberKeyUpdate>();
void AddOrUpdate(string variableName, double numberValue)
{
if (sceneVariables.Count > 0 && !sceneVariables.ContainsKey(variableName))
{
return;
}
var update = new KarismaCounterNumberKeyUpdate(
variableName,
1,
Math.Round(numberValue, 1, MidpointRounding.AwayFromZero));
var index = updates.FindIndex(existing => string.Equals(existing.ObjectName, variableName, StringComparison.OrdinalIgnoreCase));
if (index >= 0)
{
updates[index] = update;
}
else
{
updates.Add(update);
}
}
if (snapshot.TurnoutBoardSlots.Count > 0)
{
foreach (var slotEntry in snapshot.TurnoutBoardSlots.OrderBy(entry => entry.Slot))
{
AddOrUpdate($"투표율{slotEntry.Slot:00}", slotEntry.TurnoutRate);
}
}
else
{
AddOrUpdate("투표율01", snapshot.TurnoutRate);
}
AddOrUpdate("전국투표율01", snapshot.NationalTurnoutRate);
return updates;
}
private static IReadOnlyList<KarismaCounterNumberKeyUpdate> BuildHistoricalTurnoutCounterNumberKeyUpdates(
ElectionDataSnapshot snapshot,
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables)
{
var updates = new List<KarismaCounterNumberKeyUpdate>(6);
for (var slot = 1; slot <= 6; slot++)
{
var variableName = $"투표율{slot:00}";
if (sceneVariables.Count > 0 && !sceneVariables.ContainsKey(variableName))
{
continue;
}
updates.Add(new KarismaCounterNumberKeyUpdate(variableName, 1, 0));
}
var orderedTurnout = snapshot.HistoricalTurnoutHistory
.OrderBy(entry => entry.Year)
.ToArray();
foreach (var turnout in orderedTurnout)
{
var slot = turnout.ElectionOrder - 2;
if (slot is < 1 or > 6)
{
continue;
}
var variableName = $"투표율{slot:00}";
if (sceneVariables.Count > 0 && !sceneVariables.ContainsKey(variableName))
{
continue;
}
var index = updates.FindIndex(update => string.Equals(update.ObjectName, variableName, StringComparison.OrdinalIgnoreCase));
var update = new KarismaCounterNumberKeyUpdate(
variableName,
1,
Math.Round(turnout.TurnoutRate, 1, MidpointRounding.AwayFromZero));
if (index >= 0)
{
updates[index] = update;
}
else
{
updates.Add(update);
}
}
return updates;
}
private void LogCutDebugSummary(
BroadcastChannel channel,
FormatTemplateDefinition template,
FormatCutDefinition cut,
CutDebugSettingsSnapshot cutDebug,
int originalValueCount,
int filteredValueCount,
IEnumerable<string> filteredValueKeys,
int originalCounterCount,
int filteredCounterCount,
IEnumerable<string> filteredCounterKeys,
int originalStyleCount,
int filteredStyleCount,
IEnumerable<string> filteredStyleKeys,
int originalVisibilityCount,
int filteredVisibilityCount,
IEnumerable<string> filteredVisibilityKeys)
{
if (!cutDebug.IsEnabled)
{
return;
}
var valuePreview = BuildDebugKeyPreview(filteredValueKeys);
var counterPreview = BuildDebugKeyPreview(filteredCounterKeys);
var stylePreview = BuildDebugKeyPreview(filteredStyleKeys);
var visibilityPreview = BuildDebugKeyPreview(filteredVisibilityKeys);
_logService.Info(
$"[{channel}] Cut debug active {template.Name}/{cut.Name}: " +
$"text={(cutDebug.ApplyTextValues ? "ON" : "OFF")}, " +
$"image={(cutDebug.ApplyImageValues ? "ON" : "OFF")}, " +
$"visibility={(cutDebug.ApplyVisibilityValues ? "ON" : "OFF")}, " +
$"voteRateText={(cutDebug.ApplyTextValues && cutDebug.ApplyVoteRateTextValues ? "ON" : "OFF")}, " +
$"voteRateCounter={(cutDebug.ApplyVoteRateCounterValues ? "ON" : "OFF")}, " +
$"partyBarColor={(cutDebug.ApplyPartyBarStyleColors ? "ON" : "OFF")}, " +
$"partyPlateColor={(cutDebug.ApplyPartyPlateStyleColors ? "ON" : "OFF")}, " +
$"voteRateColor={(cutDebug.ApplyVoteRateStyleColors ? "ON" : "OFF")} | " +
$"values {filteredValueCount}/{originalValueCount}, " +
$"counters {filteredCounterCount}/{originalCounterCount}, " +
$"styles {filteredStyleCount}/{originalStyleCount}, " +
$"visibility {filteredVisibilityCount}/{originalVisibilityCount} | " +
$"valueKeys={valuePreview} | " +
$"counterKeys={counterPreview} | " +
$"styleKeys={stylePreview} | " +
$"visibilityKeys={visibilityPreview}");
}
private static string BuildDebugKeyPreview(IEnumerable<string> keys)
{
var orderedKeys = keys
.Where(key => !string.IsNullOrWhiteSpace(key))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(key => key, StringComparer.OrdinalIgnoreCase)
.ToArray();
if (orderedKeys.Length == 0)
{
return "(none)";
}
const int previewLimit = 8;
return orderedKeys.Length <= previewLimit
? string.Join(", ", orderedKeys)
: $"{string.Join(", ", orderedKeys.Take(previewLimit))}, ... (+{orderedKeys.Length - previewLimit})";
}
private void LogCutDebugOverrides(
BroadcastChannel channel,
FormatTemplateDefinition template,
CutDebugSettingsSnapshot cutDebug,
IReadOnlyDictionary<string, string> filteredValues,
IReadOnlyDictionary<string, string> overriddenValues,
IReadOnlyList<KarismaCounterNumberKeyUpdate> filteredCounterNumberKeys,
IReadOnlyList<KarismaCounterNumberKeyUpdate> overriddenCounterNumberKeys)
{
if (!cutDebug.IsEnabled)
{
return;
}
var valueChanges = overriddenValues
.Where(pair =>
!filteredValues.TryGetValue(pair.Key, out var originalValue) ||
!string.Equals(originalValue, pair.Value, StringComparison.Ordinal))
.Select(pair => $"{pair.Key}={TrimDebugValue(pair.Value)}")
.Take(8)
.ToArray();
var filteredCounterLookup = filteredCounterNumberKeys.ToDictionary(
update => $"{update.ObjectName}|{update.KeyIndex}",
update => update.Number,
StringComparer.OrdinalIgnoreCase);
var counterChanges = overriddenCounterNumberKeys
.Where(update =>
{
var lookupKey = $"{update.ObjectName}|{update.KeyIndex}";
return !filteredCounterLookup.TryGetValue(lookupKey, out var originalNumber) ||
Math.Abs(originalNumber - update.Number) > 0.0001d;
})
.Select(update => $"{update.ObjectName}#{update.KeyIndex}={update.Number:0.###}")
.Take(8)
.ToArray();
_logService.Info(
$"[{channel}] Cut debug overrides {template.Name}: " +
$"values={(valueChanges.Length == 0 ? "(none)" : string.Join(", ", valueChanges))} | " +
$"counters={(counterChanges.Length == 0 ? "(none)" : string.Join(", ", counterChanges))}");
}
private static string TrimDebugValue(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var fileName = Path.GetFileName(value);
if (!string.IsNullOrWhiteSpace(fileName))
{
value = fileName;
}
return value.Length <= 48
? value
: $"{value[..45]}...";
}
private static Dictionary<string, string> FilterObjectValues(
IReadOnlyDictionary<string, string> values,
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables,
CutDebugSettingsSnapshot cutDebug,
CutDebugTemplateState? templateDebug)
{
if (!cutDebug.IsEnabled)
{
return new Dictionary<string, string>(values, StringComparer.OrdinalIgnoreCase);
}
var filtered = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in values)
{
if (!ShouldApplyObjectValue(pair.Key, sceneVariables, cutDebug, templateDebug))
{
continue;
}
filtered[pair.Key] = pair.Value;
}
return filtered;
}
private static IReadOnlyList<KarismaCounterNumberKeyUpdate> FilterCounterNumberKeyUpdates(
IReadOnlyList<KarismaCounterNumberKeyUpdate> updates,
CutDebugSettingsSnapshot cutDebug,
CutDebugTemplateState? templateDebug)
{
if (!cutDebug.IsEnabled)
{
return updates;
}
return updates
.Where(update =>
{
if (cutDebug.IsEnabled && !cutDebug.ApplyVoteRateCounterValues && IsVoteRateVariable(update.ObjectName))
{
return false;
}
return templateDebug is null || templateDebug.IsEnabled(update.ObjectName, CutDebugItemKind.Counter);
})
.ToArray();
}
private static IReadOnlyList<KarismaStyleColorUpdate> FilterStyleColorUpdates(
IReadOnlyList<KarismaStyleColorUpdate> updates,
CutDebugSettingsSnapshot cutDebug,
CutDebugTemplateState? templateDebug)
{
if (!cutDebug.IsEnabled)
{
return updates;
}
return updates
.Where(update => ShouldApplyStyleColor(update.ObjectName, cutDebug, templateDebug))
.ToArray();
}
private static (IReadOnlyList<KarismaVisibilityUpdate> HideBeforeValue, IReadOnlyList<KarismaVisibilityUpdate> ShowAfterValue) FilterVisibilityUpdatePair(
(IReadOnlyList<KarismaVisibilityUpdate> HideBeforeValue, IReadOnlyList<KarismaVisibilityUpdate> ShowAfterValue) updates,
CutDebugSettingsSnapshot cutDebug,
CutDebugTemplateState? templateDebug)
{
if (!cutDebug.IsEnabled)
{
return updates;
}
if (cutDebug.IsEnabled && !cutDebug.ApplyVisibilityValues)
{
return (Array.Empty<KarismaVisibilityUpdate>(), Array.Empty<KarismaVisibilityUpdate>());
}
return (
updates.HideBeforeValue
.Where(update => templateDebug is null || templateDebug.IsEnabled(update.ObjectName, CutDebugItemKind.Visibility))
.ToArray(),
updates.ShowAfterValue
.Where(update => templateDebug is null || templateDebug.IsEnabled(update.ObjectName, CutDebugItemKind.Visibility))
.ToArray());
}
private static Dictionary<string, string> ApplyObjectValueOverrides(
IReadOnlyDictionary<string, string> values,
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables,
CutDebugTemplateState? templateDebug)
{
var result = new Dictionary<string, string>(values, StringComparer.OrdinalIgnoreCase);
if (templateDebug is null)
{
return result;
}
foreach (var item in templateDebug.Items)
{
if (item.Kind is not (CutDebugItemKind.TextValue or CutDebugItemKind.ImageValue) ||
!templateDebug.TryGetOverride(item.Key, item.Kind, out var overrideValue) ||
string.IsNullOrWhiteSpace(overrideValue.StringValue))
{
continue;
}
var targets = result.Keys
.Where(key => MatchesDebugItemKey(key, item.Key))
.ToArray();
if (targets.Length == 0)
{
targets = sceneVariables.Keys
.Where(key => MatchesDebugItemKey(key, item.Key))
.ToArray();
}
if (targets.Length == 0)
{
targets = [item.Key];
}
foreach (var target in targets)
{
result[target] = overrideValue.StringValue;
}
}
return result;
}
private static IReadOnlyList<KarismaCounterNumberKeyUpdate> ApplyCounterNumberKeyOverrides(
IReadOnlyList<KarismaCounterNumberKeyUpdate> updates,
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables,
CutDebugTemplateState? templateDebug)
{
if (templateDebug is null)
{
return updates;
}
var result = updates.ToList();
foreach (var item in templateDebug.Items)
{
if (item.Kind != CutDebugItemKind.Counter ||
!templateDebug.TryGetOverride(item.Key, item.Kind, out var overrideValue) ||
overrideValue.NumberValue is not double numberValue)
{
continue;
}
var matched = false;
for (var index = 0; index < result.Count; index++)
{
if (!MatchesDebugItemKey(result[index].ObjectName, item.Key))
{
continue;
}
result[index] = new KarismaCounterNumberKeyUpdate(
result[index].ObjectName,
result[index].KeyIndex,
numberValue);
matched = true;
}
if (matched)
{
continue;
}
var sceneTargets = sceneVariables.Keys
.Where(key => MatchesDebugItemKey(key, item.Key))
.ToArray();
foreach (var target in sceneTargets)
{
result.Add(new KarismaCounterNumberKeyUpdate(target, 1, numberValue));
matched = true;
}
if (!matched)
{
result.Add(new KarismaCounterNumberKeyUpdate(item.Key, 1, numberValue));
}
}
return result;
}
private static IReadOnlyList<KarismaStyleColorUpdate> ApplyStyleColorOverrides(
IReadOnlyList<KarismaStyleColorUpdate> updates,
CutDebugTemplateState? templateDebug)
{
if (templateDebug is null)
{
return updates;
}
var result = updates.ToList();
foreach (var item in templateDebug.Items)
{
if (item.Kind != CutDebugItemKind.StyleColor ||
!templateDebug.TryGetOverride(item.Key, item.Kind, out var overrideValue))
{
continue;
}
for (var index = 0; index < result.Count; index++)
{
if (!MatchesDebugItemKey(result[index].ObjectName, item.Key))
{
continue;
}
result[index] = result[index] with
{
R = overrideValue.R,
G = overrideValue.G,
B = overrideValue.B,
A = overrideValue.A
};
}
}
return result;
}
private static (IReadOnlyList<KarismaVisibilityUpdate> HideBeforeValue, IReadOnlyList<KarismaVisibilityUpdate> ShowAfterValue) ApplyVisibilityOverrides(
(IReadOnlyList<KarismaVisibilityUpdate> HideBeforeValue, IReadOnlyList<KarismaVisibilityUpdate> ShowAfterValue) updates,
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables,
CutDebugTemplateState? templateDebug)
{
if (templateDebug is null)
{
return updates;
}
var hide = updates.HideBeforeValue.ToList();
var show = updates.ShowAfterValue.ToList();
foreach (var item in templateDebug.Items)
{
if (item.Kind != CutDebugItemKind.Visibility ||
!templateDebug.TryGetOverride(item.Key, item.Kind, out var overrideValue) ||
overrideValue.BooleanValue is not bool isVisible)
{
continue;
}
var targets = hide.Select(update => update.ObjectName)
.Concat(show.Select(update => update.ObjectName))
.Concat(sceneVariables.Keys)
.Where(key => MatchesDebugItemKey(key, item.Key))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
hide.RemoveAll(update => MatchesDebugItemKey(update.ObjectName, item.Key));
show.RemoveAll(update => MatchesDebugItemKey(update.ObjectName, item.Key));
if (targets.Length == 0)
{
targets = [item.Key];
}
var targetList = isVisible ? show : hide;
foreach (var target in targets)
{
targetList.Add(new KarismaVisibilityUpdate(target, isVisible));
}
}
return (hide, show);
}
private static bool MatchesDebugItemKey(string candidateKey, string debugKey)
{
return string.Equals(
CutDebugTemplateState.NormalizeKey(candidateKey),
CutDebugTemplateState.NormalizeKey(debugKey),
StringComparison.OrdinalIgnoreCase);
}
private static bool ShouldApplyObjectValue(
string variableName,
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables,
CutDebugSettingsSnapshot cutDebug,
CutDebugTemplateState? templateDebug)
{
if (!cutDebug.IsEnabled)
{
return true;
}
var hasDefinition = sceneVariables.TryGetValue(variableName, out var variableDefinition);
var isImageValue =
(hasDefinition && variableDefinition is not null &&
variableDefinition.Kind is KarismaSceneVariableKind.Image or KarismaSceneVariableKind.VideoResource) ||
(!hasDefinition && IsImageValueVariable(variableName));
if (isImageValue)
{
return (!cutDebug.IsEnabled || cutDebug.ApplyImageValues) &&
(templateDebug is null || templateDebug.IsEnabled(variableName, CutDebugItemKind.ImageValue));
}
if (cutDebug.IsEnabled && !cutDebug.ApplyTextValues)
{
return false;
}
if (cutDebug.IsEnabled && IsVoteRateVariable(variableName) && !cutDebug.ApplyVoteRateTextValues)
{
return false;
}
return templateDebug is null || templateDebug.IsEnabled(variableName, CutDebugItemKind.TextValue);
}
private static bool ShouldApplyStyleColor(
string objectName,
CutDebugSettingsSnapshot cutDebug,
CutDebugTemplateState? templateDebug)
{
if (!cutDebug.IsEnabled)
{
return true;
}
if (templateDebug is not null && !templateDebug.IsEnabled(objectName, CutDebugItemKind.StyleColor))
{
return false;
}
if (cutDebug.IsEnabled && IsVoteRateVariable(objectName))
{
return cutDebug.ApplyVoteRateStyleColors;
}
if (cutDebug.IsEnabled && IsPartyBarStyleObjectName(objectName))
{
return cutDebug.ApplyPartyBarStyleColors;
}
if (cutDebug.IsEnabled && IsPartyPlateStyleObjectName(objectName))
{
return cutDebug.ApplyPartyPlateStyleColors;
}
return true;
}
private static bool IsVoteRateVariable(string variableName)
{
return string.Equals(variableName, "득표율", StringComparison.Ordinal) ||
string.Equals(variableName, "투표율", StringComparison.Ordinal) ||
string.Equals(variableName, "전국투표율", StringComparison.Ordinal) ||
MatchesIndexedVariable(variableName, "득표율") ||
MatchesIndexedVariable(variableName, "투표율") ||
MatchesIndexedVariable(variableName, "전국투표율");
}
private static bool IsImageValueVariable(string variableName)
{
return IsJudgementVariableName(variableName) ||
MatchesIndexedVariable(variableName, "후보사진") ||
MatchesIndexedVariable(variableName, "득표수바") ||
MatchesIndexedVariable(variableName, "정당바") ||
MatchesIndexedVariable(variableName, "정당판") ||
MatchesIndexedVariable(variableName, "정당원") ||
MatchesIndexedVariable(variableName, "정당색") ||
MatchesIndexedVariable(variableName, "정당심볼") ||
MatchesIndexedVariable(variableName, "공약그룹") ||
MatchesIndexedVariable(variableName, "그룹") ||
MatchesIndexedVariable(variableName, "바");
}
private static bool IsPartyBarStyleObjectName(string variableName)
{
return MatchesIndexedVariable(variableName, "득표수바") ||
MatchesIndexedVariable(variableName, "정당바");
}
private static bool IsPartyPlateStyleObjectName(string variableName)
{
return MatchesIndexedVariable(variableName, "기호") ||
MatchesIndexedVariable(variableName, "기호텍스트") ||
MatchesIndexedVariable(variableName, "정당판") ||
MatchesIndexedVariable(variableName, "정당원") ||
MatchesIndexedVariable(variableName, "정당색") ||
MatchesIndexedVariable(variableName, "정당명");
}
private static CandidateEntry[] GetOrderedCandidates( private static CandidateEntry[] GetOrderedCandidates(
FormatTemplateDefinition template, FormatTemplateDefinition template,
FormatCutDefinition cut, FormatCutDefinition cut,
@@ -1379,6 +2057,16 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
string? value, string? value,
params string[] keys) params string[] keys)
{ {
if (ShouldUseTemplateDefaultAppearance(templateName, sectionName))
{
foreach (var key in keys)
{
values.Remove(key);
}
return;
}
if (PartyColorCatalog.HasStyleColorBinding(templateFolderPath, templateName, sectionName) && if (PartyColorCatalog.HasStyleColorBinding(templateFolderPath, templateName, sectionName) &&
!ShouldPreferAssetAliasForStyleBoundSection(templateName, sectionName)) !ShouldPreferAssetAliasForStyleBoundSection(templateName, sectionName))
{ {
@@ -1441,6 +2129,11 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
string sectionName, string sectionName,
params string[] objectNames) params string[] objectNames)
{ {
if (ShouldUseTemplateDefaultAppearance(templateName, sectionName))
{
return;
}
if (ShouldPreferAssetAliasForStyleBoundSection(templateName, sectionName)) if (ShouldPreferAssetAliasForStyleBoundSection(templateName, sectionName))
{ {
return; return;
@@ -1488,8 +2181,12 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
return false; return false;
} }
return string.Equals(templateName, "1-2위_ani_광역단체장", StringComparison.Ordinal) && return false;
string.Equals(sectionName, "정당판", StringComparison.Ordinal); }
private static bool ShouldUseTemplateDefaultAppearance(string templateName, string sectionName)
{
return CutAppearancePolicyCatalog.UsesTemplateDefaultAppearance(templateName, sectionName);
} }
private static void SetRankAliases( private static void SetRankAliases(

View File

@@ -0,0 +1,137 @@
using System;
using Tornado3_2026Election.Domain;
namespace Tornado3_2026Election.Services;
public enum ThumbnailDisplayContext
{
CutList,
Queue,
Preview
}
public readonly record struct ThumbnailDisplayMetrics(double Width, double Height);
public static class ThumbnailLayoutResolver
{
private const double HdAspectRatio = 1920d / 1080d;
private const double StandardVideoWallAspectRatio = 5760d / 1080d;
private const double UltraWideVideoWallAspectRatio = 11520d / 1080d;
public static ThumbnailDisplayMetrics ResolveDisplayMetrics(
FormatTemplateDefinition template,
VideoWallLayoutPreset videoWallLayoutPreset,
ThumbnailDisplayContext context)
{
return ResolveDisplayMetrics(template.RecommendedChannel, template.SceneWidth, template.SceneHeight, videoWallLayoutPreset, context);
}
public static ThumbnailDisplayMetrics ResolveDisplayMetrics(
BroadcastChannel channel,
VideoWallLayoutPreset videoWallLayoutPreset,
ThumbnailDisplayContext context)
{
return ResolveDisplayMetrics(channel, null, null, videoWallLayoutPreset, context);
}
public static (int Width, int Height) ResolveGenerationSize(
FormatTemplateDefinition template,
VideoWallLayoutPreset videoWallLayoutPreset)
{
const int thumbnailHeight = 180;
var aspectRatio = ResolveAspectRatio(template.RecommendedChannel, template.SceneWidth, template.SceneHeight, videoWallLayoutPreset);
var thumbnailWidth = Math.Max(1, (int)Math.Round(thumbnailHeight * aspectRatio, MidpointRounding.AwayFromZero));
return (thumbnailWidth, thumbnailHeight);
}
private static ThumbnailDisplayMetrics ResolveDisplayMetrics(
BroadcastChannel channel,
int? sceneWidth,
int? sceneHeight,
VideoWallLayoutPreset videoWallLayoutPreset,
ThumbnailDisplayContext context)
{
var aspectRatio = ResolveAspectRatio(channel, sceneWidth, sceneHeight, videoWallLayoutPreset);
var (maxWidth, maxHeight) = context switch
{
ThumbnailDisplayContext.Preview => (480d, 180d),
ThumbnailDisplayContext.CutList => (320d, 90d),
ThumbnailDisplayContext.Queue => (320d, 90d),
_ => (320d, 180d)
};
return FitWithin(aspectRatio, maxWidth, maxHeight);
}
private static double ResolveAspectRatio(
BroadcastChannel channel,
int? sceneWidth,
int? sceneHeight,
VideoWallLayoutPreset videoWallLayoutPreset)
{
if (channel == BroadcastChannel.VideoWall)
{
if (TryGetPresetAspectRatio(videoWallLayoutPreset, out var presetAspectRatio))
{
return presetAspectRatio;
}
if (TryGetSceneAspectRatio(sceneWidth, sceneHeight, out var sceneAspectRatio))
{
return sceneAspectRatio;
}
return StandardVideoWallAspectRatio;
}
if (TryGetSceneAspectRatio(sceneWidth, sceneHeight, out var resolvedSceneAspectRatio))
{
return resolvedSceneAspectRatio;
}
return HdAspectRatio;
}
private static bool TryGetPresetAspectRatio(VideoWallLayoutPreset videoWallLayoutPreset, out double aspectRatio)
{
switch (videoWallLayoutPreset)
{
case VideoWallLayoutPreset.Standard5760x1080:
aspectRatio = StandardVideoWallAspectRatio;
return true;
case VideoWallLayoutPreset.UltraWide11520x1080:
aspectRatio = UltraWideVideoWallAspectRatio;
return true;
default:
aspectRatio = 0;
return false;
}
}
private static bool TryGetSceneAspectRatio(int? sceneWidth, int? sceneHeight, out double aspectRatio)
{
if (sceneWidth is > 0 && sceneHeight is > 0)
{
aspectRatio = (double)sceneWidth.Value / sceneHeight.Value;
return true;
}
aspectRatio = 0;
return false;
}
private static ThumbnailDisplayMetrics FitWithin(double aspectRatio, double maxWidth, double maxHeight)
{
var width = maxWidth;
var height = width / aspectRatio;
if (height > maxHeight)
{
height = maxHeight;
width = height * aspectRatio;
}
return new ThumbnailDisplayMetrics(
Math.Max(1, Math.Round(width, 0, MidpointRounding.AwayFromZero)),
Math.Max(1, Math.Round(height, 0, MidpointRounding.AwayFromZero)));
}
}

View File

@@ -37,21 +37,33 @@
<Content Include="Assets\LockScreenLogo.png"> <Content Include="Assets\LockScreenLogo.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content> </Content>
<Content Include="Assets\SplashScreen.scale-200.png" /> <Content Include="Assets\SplashScreen.scale-200.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Assets\SplashScreen.png"> <Content Include="Assets\SplashScreen.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content> </Content>
<Content Include="Assets\LockScreenLogo.scale-200.png" /> <Content Include="Assets\LockScreenLogo.scale-200.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Assets\Square150x150Logo.png"> <Content Include="Assets\Square150x150Logo.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content> </Content>
<Content Include="Assets\Square150x150Logo.scale-200.png" /> <Content Include="Assets\Square150x150Logo.scale-200.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Assets\Square44x44Logo.png"> <Content Include="Assets\Square44x44Logo.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content> </Content>
<Content Include="Assets\Square44x44Logo.scale-200.png" /> <Content Include="Assets\Square44x44Logo.scale-200.png">
<Content Include="Assets\Square44x44Logo.targetsize-24_altform-unplated.png" /> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Content Include="Assets\StoreLogo.png" /> </Content>
<Content Include="Assets\Square44x44Logo.targetsize-24_altform-unplated.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Assets\StoreLogo.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Assets\Stations\**\*.*"> <Content Include="Assets\Stations\**\*.*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content> </Content>
@@ -64,7 +76,9 @@
<Content Include="Assets\Wide310x150Logo.png"> <Content Include="Assets\Wide310x150Logo.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content> </Content>
<Content Include="Assets\Wide310x150Logo.scale-200.png" /> <Content Include="Assets\Wide310x150Logo.scale-200.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Data\LocationCatalog.seed.json"> <Content Include="Data\LocationCatalog.seed.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content> </Content>

View File

@@ -1,7 +1,10 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel; using System.ComponentModel;
using System.Linq; using System.Linq;
using System.Text.RegularExpressions;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.UI.Xaml.Media; using Microsoft.UI.Xaml.Media;
@@ -13,17 +16,25 @@ namespace Tornado3_2026Election.ViewModels;
public sealed class ChannelScheduleViewModel : ObservableObject public sealed class ChannelScheduleViewModel : ObservableObject
{ {
private static readonly Regex TopRankSlotCountPattern = new(@"1-(\d+)위", RegexOptions.Compiled);
private static readonly Regex PeopleSlotCountPattern = new(@"(\d+)인", RegexOptions.Compiled);
private readonly ChannelScheduleEngine _engine; private readonly ChannelScheduleEngine _engine;
private readonly ITornado3Adapter _adapter; private readonly ITornado3Adapter _adapter;
private readonly CutDebugStateStore _cutDebugStateStore;
private readonly DataViewModel _data; private readonly DataViewModel _data;
private readonly LogService _logService; private readonly LogService _logService;
private readonly IReadOnlyList<FormatTemplateDefinition> _allFormats; private readonly ObservableCollection<CutDebugItemState> _emptyCutDebugItems = [];
private IReadOnlyList<FormatTemplateDefinition> _allFormats;
private FormatTemplateDefinition? _selectedFormat; private FormatTemplateDefinition? _selectedFormat;
private CutDebugTemplateState? _selectedCutDebugTemplate;
private ScheduleRegionOption? _selectedRegionOption; private ScheduleRegionOption? _selectedRegionOption;
private SelectionOption<EmptyScheduleBehavior>? _selectedEmptyBehaviorOption; private SelectionOption<EmptyScheduleBehavior>? _selectedEmptyBehaviorOption;
private bool _loopEnabled; private bool _loopEnabled;
private EmptyScheduleBehavior _emptyScheduleBehavior = EmptyScheduleBehavior.ImmediateOut; private EmptyScheduleBehavior _emptyScheduleBehavior = EmptyScheduleBehavior.ImmediateOut;
private int _regionOptionsRevision; private int _regionOptionsRevision;
private VideoWallLayoutPreset _videoWallLayoutPreset = VideoWallLayoutPreset.Auto;
private double _selectedFormatThumbnailWidth = 320;
private double _selectedFormatThumbnailHeight = 180;
public ChannelScheduleViewModel( public ChannelScheduleViewModel(
BroadcastChannel channel, BroadcastChannel channel,
@@ -31,6 +42,8 @@ public sealed class ChannelScheduleViewModel : ObservableObject
IReadOnlyList<FormatTemplateDefinition> formats, IReadOnlyList<FormatTemplateDefinition> formats,
DataViewModel data, DataViewModel data,
ITornado3Adapter adapter, ITornado3Adapter adapter,
CutDebugSettings cutDebug,
CutDebugStateStore cutDebugStateStore,
ChannelScheduleEngine engine, ChannelScheduleEngine engine,
LogService logService) LogService logService)
{ {
@@ -38,6 +51,8 @@ public sealed class ChannelScheduleViewModel : ObservableObject
Title = title; Title = title;
_data = data; _data = data;
_adapter = adapter; _adapter = adapter;
CutDebug = cutDebug;
_cutDebugStateStore = cutDebugStateStore;
_engine = engine; _engine = engine;
_logService = logService; _logService = logService;
_allFormats = formats.ToArray(); _allFormats = formats.ToArray();
@@ -65,10 +80,14 @@ public sealed class ChannelScheduleViewModel : ObservableObject
_engine.QueueChanged += (_, _) => RefreshSummary(); _engine.QueueChanged += (_, _) => RefreshSummary();
_adapter.StateChanged += (_, _) => RefreshSummary(); _adapter.StateChanged += (_, _) => RefreshSummary();
_adapter.ConnectionChanged += (_, _) => RefreshSummary(); _adapter.ConnectionChanged += (_, _) => RefreshSummary();
CutDebug.PropertyChanged += (_, _) => OnPropertyChanged(nameof(CutDebugSummary));
_data.PropertyChanged += Data_PropertyChanged; _data.PropertyChanged += Data_PropertyChanged;
Queue.CollectionChanged += Queue_CollectionChanged;
RebuildAvailableFormats(); RebuildAvailableFormats();
_ = RebuildRegionOptionsAsync(); _ = RebuildRegionOptionsAsync();
UpdateSelectedFormatThumbnailMetrics();
ApplyQueueThumbnailLayouts();
RefreshSummary(); RefreshSummary();
} }
@@ -104,6 +123,8 @@ public sealed class ChannelScheduleViewModel : ObservableObject
public AsyncRelayCommand ForceQueueNextCommand { get; } public AsyncRelayCommand ForceQueueNextCommand { get; }
public CutDebugSettings CutDebug { get; }
public RelayCommand AddFormatCommand { get; } public RelayCommand AddFormatCommand { get; }
public RelayCommand ResetQueueCommand { get; } public RelayCommand ResetQueueCommand { get; }
@@ -124,6 +145,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
if (SetProperty(ref _selectedFormat, value)) if (SetProperty(ref _selectedFormat, value))
{ {
NotifySelectedFormatPreviewChanged(); NotifySelectedFormatPreviewChanged();
SyncSelectedCutDebugTemplate();
_ = RebuildRegionOptionsAsync(); _ = RebuildRegionOptionsAsync();
AddFormatCommand.NotifyCanExecuteChanged(); AddFormatCommand.NotifyCanExecuteChanged();
} }
@@ -239,10 +261,44 @@ public sealed class ChannelScheduleViewModel : ObservableObject
? "등록된 썸네일을 표시 중" ? "등록된 썸네일을 표시 중"
: "썸네일이 없어 기본 아이콘을 표시 중"; : "썸네일이 없어 기본 아이콘을 표시 중";
public string CutDebugSummary => CutDebug.Summary;
public ObservableCollection<CutDebugItemState> CutDebugItems => _selectedCutDebugTemplate?.Items ?? _emptyCutDebugItems;
public int CutDebugItemCount => CutDebugItems.Count;
public string CutDebugTextTargets => BuildCutDebugTextTargets(SelectedFormat);
public string CutDebugImageTargets => BuildCutDebugImageTargets(SelectedFormat);
public string CutDebugVisibilityTargets => BuildCutDebugVisibilityTargets(SelectedFormat);
public string CutDebugVoteRateTextTargets => BuildIndexedTargetRange("득표율", ResolveCutDebugSlotCount(SelectedFormat));
public string CutDebugVoteRateCounterTargets => $"{BuildIndexedTargetRange("", ResolveCutDebugSlotCount(SelectedFormat))} key#1";
public string CutDebugPartyBarColorTargets => JoinTargets(
BuildIndexedTargetRange("기호", ResolveCutDebugSlotCount(SelectedFormat)),
BuildIndexedTargetRange("기호텍스트", ResolveCutDebugSlotCount(SelectedFormat)),
BuildIndexedTargetRange("득표수바", ResolveCutDebugSlotCount(SelectedFormat)),
BuildIndexedTargetRange("정당바", ResolveCutDebugSlotCount(SelectedFormat)));
public string CutDebugPartyPlateColorTargets => JoinTargets(
BuildIndexedTargetRange("정당판", ResolveCutDebugSlotCount(SelectedFormat)),
BuildIndexedTargetRange("정당원", ResolveCutDebugSlotCount(SelectedFormat)),
BuildIndexedTargetRange("정당색", ResolveCutDebugSlotCount(SelectedFormat)),
BuildIndexedTargetRange("정당명", ResolveCutDebugSlotCount(SelectedFormat)));
public string CutDebugVoteRateColorTargets => $"{BuildIndexedTargetRange("", ResolveCutDebugSlotCount(SelectedFormat))} edge/face";
public ImageSource? SelectedFormatThumbnailSource => SelectedFormat is null public ImageSource? SelectedFormatThumbnailSource => SelectedFormat is null
? null ? null
: CutThumbnailAssetCatalog.CreateImageSource(SelectedFormat); : CutThumbnailAssetCatalog.CreateImageSource(SelectedFormat);
public double SelectedFormatThumbnailWidth => _selectedFormatThumbnailWidth;
public double SelectedFormatThumbnailHeight => _selectedFormatThumbnailHeight;
public async Task RefreshRegionOptionsAsync() public async Task RefreshRegionOptionsAsync()
{ {
await RebuildRegionOptionsAsync(); await RebuildRegionOptionsAsync();
@@ -261,6 +317,27 @@ public sealed class ChannelScheduleViewModel : ObservableObject
} }
} }
public void UpdateFormats(IReadOnlyList<FormatTemplateDefinition> formats)
{
_allFormats = formats.ToArray();
RebuildAvailableFormats();
_ = RebuildRegionOptionsAsync();
ApplyQueueThumbnailLayouts();
RefreshSummary();
}
public void UpdateVideoWallLayoutPreset(VideoWallLayoutPreset videoWallLayoutPreset)
{
if (_videoWallLayoutPreset == videoWallLayoutPreset)
{
return;
}
_videoWallLayoutPreset = videoWallLayoutPreset;
UpdateSelectedFormatThumbnailMetrics();
ApplyQueueThumbnailLayouts();
}
private async Task StartAsync() private async Task StartAsync()
{ {
await _engine.StartAsync().ConfigureAwait(false); await _engine.StartAsync().ConfigureAwait(false);
@@ -309,7 +386,9 @@ public sealed class ChannelScheduleViewModel : ObservableObject
return; return;
} }
_engine.Enqueue(ChannelScheduleItem.FromTemplate(SelectedFormat, regionOption)); var item = ChannelScheduleItem.FromTemplate(SelectedFormat, regionOption);
item.UpdateThumbnailLayout(ThumbnailLayoutResolver.ResolveDisplayMetrics(SelectedFormat, _videoWallLayoutPreset, ThumbnailDisplayContext.Queue));
_engine.Enqueue(item);
RefreshSummary(); RefreshSummary();
} }
@@ -388,6 +467,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
private void RebuildAvailableFormats() private void RebuildAvailableFormats()
{ {
var selectedFormatId = SelectedFormat?.Id;
var filteredFormats = _allFormats var filteredFormats = _allFormats
.Where(format => format.IsAvailableInPhase(_data.BroadcastPhase)) .Where(format => format.IsAvailableInPhase(_data.BroadcastPhase))
.ToArray(); .ToArray();
@@ -398,11 +478,17 @@ public sealed class ChannelScheduleViewModel : ObservableObject
AvailableFormats.Add(format); AvailableFormats.Add(format);
} }
if (SelectedFormat is null || !AvailableFormats.Contains(SelectedFormat)) var nextSelectedFormat = !string.IsNullOrWhiteSpace(selectedFormatId)
? AvailableFormats.FirstOrDefault(format => string.Equals(format.Id, selectedFormatId, StringComparison.Ordinal))
: null;
nextSelectedFormat ??= AvailableFormats.FirstOrDefault();
if (!ReferenceEquals(SelectedFormat, nextSelectedFormat))
{ {
SelectedFormat = AvailableFormats.FirstOrDefault(); SelectedFormat = nextSelectedFormat;
} }
UpdateSelectedFormatThumbnailMetrics();
SyncSelectedCutDebugTemplate();
AddFormatCommand.NotifyCanExecuteChanged(); AddFormatCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(QueueFootnote)); OnPropertyChanged(nameof(QueueFootnote));
} }
@@ -439,12 +525,28 @@ public sealed class ChannelScheduleViewModel : ObservableObject
private void NotifySelectedFormatPreviewChanged() private void NotifySelectedFormatPreviewChanged()
{ {
UpdateSelectedFormatThumbnailMetrics();
OnPropertyChanged( OnPropertyChanged(
nameof(CutDebugTextTargets),
nameof(CutDebugImageTargets),
nameof(CutDebugVisibilityTargets),
nameof(CutDebugVoteRateTextTargets),
nameof(CutDebugVoteRateCounterTargets),
nameof(CutDebugPartyBarColorTargets),
nameof(CutDebugPartyPlateColorTargets),
nameof(CutDebugVoteRateColorTargets),
nameof(SelectedFormatName), nameof(SelectedFormatName),
nameof(SelectedFormatDescription), nameof(SelectedFormatDescription),
nameof(SelectedFormatPath), nameof(SelectedFormatPath),
nameof(SelectedFormatThumbnailStatus), nameof(SelectedFormatThumbnailStatus),
nameof(SelectedFormatThumbnailSource)); nameof(SelectedFormatThumbnailSource),
nameof(SelectedFormatThumbnailWidth),
nameof(SelectedFormatThumbnailHeight));
}
private void Queue_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
ApplyQueueThumbnailLayouts();
} }
private static ScheduleRegionOption? ResolvePreferredRegionOption( private static ScheduleRegionOption? ResolvePreferredRegionOption(
@@ -479,4 +581,222 @@ public sealed class ChannelScheduleViewModel : ObservableObject
{ {
return EmptyBehaviorOptions.FirstOrDefault(option => option.Value == behavior); return EmptyBehaviorOptions.FirstOrDefault(option => option.Value == behavior);
} }
private void UpdateSelectedFormatThumbnailMetrics()
{
var metrics = SelectedFormat is null
? ThumbnailLayoutResolver.ResolveDisplayMetrics(Channel, _videoWallLayoutPreset, ThumbnailDisplayContext.Preview)
: ThumbnailLayoutResolver.ResolveDisplayMetrics(SelectedFormat, _videoWallLayoutPreset, ThumbnailDisplayContext.Preview);
SetProperty(ref _selectedFormatThumbnailWidth, metrics.Width, nameof(SelectedFormatThumbnailWidth));
SetProperty(ref _selectedFormatThumbnailHeight, metrics.Height, nameof(SelectedFormatThumbnailHeight));
}
private void ApplyQueueThumbnailLayouts()
{
foreach (var item in Queue)
{
var template = _allFormats.FirstOrDefault(format => string.Equals(format.Id, item.FormatId, StringComparison.Ordinal));
var metrics = template is null
? ThumbnailLayoutResolver.ResolveDisplayMetrics(item.Channel, _videoWallLayoutPreset, ThumbnailDisplayContext.Queue)
: ThumbnailLayoutResolver.ResolveDisplayMetrics(template, _videoWallLayoutPreset, ThumbnailDisplayContext.Queue);
item.UpdateThumbnailLayout(metrics);
}
}
private void SyncSelectedCutDebugTemplate()
{
if (SelectedFormat is null)
{
_selectedCutDebugTemplate = null;
OnPropertyChanged(nameof(CutDebugItems), nameof(CutDebugItemCount));
return;
}
var templateState = _cutDebugStateStore.GetTemplate(Channel, SelectedFormat.Id, SelectedFormat.Name);
templateState.SyncItems(BuildCutDebugItemDescriptors(SelectedFormat));
_selectedCutDebugTemplate = templateState;
OnPropertyChanged(nameof(CutDebugItems), nameof(CutDebugItemCount));
}
private static string BuildCutDebugTextTargets(FormatTemplateDefinition? format)
{
var slotCount = ResolveCutDebugSlotCount(format);
return JoinTargets(
"시도명01",
"개표율01",
BuildIndexedTargetRange("순위", slotCount),
BuildIndexedTargetRange("후보명", slotCount),
BuildIndexedTargetRange("정당명", slotCount),
BuildIndexedTargetRange("득표수", slotCount),
BuildIndexedTargetRange("득표율", slotCount));
}
private static string BuildCutDebugImageTargets(FormatTemplateDefinition? format)
{
var slotCount = ResolveCutDebugSlotCount(format);
var chartTarget = ShouldExcludeHistoricalTurnoutGraph(format) ? string.Empty : "차트01";
return JoinTargets(
chartTarget,
BuildIndexedTargetRange("유확당", slotCount),
BuildIndexedTargetRange("점선", slotCount),
BuildIndexedTargetRange("후보사진", slotCount),
BuildIndexedTargetRange("득표수바", slotCount),
BuildIndexedTargetRange("정당바", slotCount),
BuildIndexedTargetRange("정당판", slotCount),
BuildIndexedTargetRange("정당원", slotCount),
BuildIndexedTargetRange("정당색", slotCount),
BuildIndexedTargetRange("정당심볼", slotCount));
}
private static string BuildCutDebugVisibilityTargets(FormatTemplateDefinition? format)
{
var slotCount = ResolveCutDebugSlotCount(format);
var chartTarget = ShouldExcludeHistoricalTurnoutGraph(format) ? string.Empty : "차트01";
return JoinTargets(
chartTarget,
BuildIndexedTargetRange("점선", slotCount),
BuildIndexedTargetRange("유확당", slotCount),
BuildIndexedTargetRange("그룹", slotCount),
BuildIndexedTargetRange("공약그룹", 3));
}
private static int ResolveCutDebugSlotCount(FormatTemplateDefinition? format)
{
if (format is null)
{
return 2;
}
var source = $"{format.Name} {format.Id}";
var topRankMatch = TopRankSlotCountPattern.Match(source);
if (topRankMatch.Success && int.TryParse(topRankMatch.Groups[1].Value, out var topRankSlotCount))
{
return Math.Max(1, topRankSlotCount);
}
var peopleMatch = PeopleSlotCountPattern.Match(source);
if (peopleMatch.Success && int.TryParse(peopleMatch.Groups[1].Value, out var peopleSlotCount))
{
return Math.Max(1, peopleSlotCount);
}
return 2;
}
private static string BuildIndexedTargetRange(string prefix, int slotCount)
{
slotCount = Math.Max(1, slotCount);
return slotCount == 1
? $"{prefix}01"
: $"{prefix}01~{slotCount:00}";
}
private static string JoinTargets(params string[] targets)
{
return string.Join(", ", targets.Where(target => !string.IsNullOrWhiteSpace(target)));
}
private static IReadOnlyList<CutDebugItemDescriptor> BuildCutDebugItemDescriptors(FormatTemplateDefinition? format)
{
if (format is null)
{
return Array.Empty<CutDebugItemDescriptor>();
}
var slotCount = ResolveCutDebugSlotCount(format);
var descriptors = new List<CutDebugItemDescriptor>();
AddDescriptor(descriptors, "선거구명01", CutDebugItemKind.TextValue, "공통 텍스트");
AddDescriptor(descriptors, "시도명01", CutDebugItemKind.TextValue, "공통 텍스트");
AddDescriptor(descriptors, "개표율01", CutDebugItemKind.TextValue, "공통 텍스트");
AddDescriptor(descriptors, "투표율01", CutDebugItemKind.TextValue, "공통 텍스트");
AddDescriptor(descriptors, "전국투표율01", CutDebugItemKind.TextValue, "공통 텍스트");
AddDescriptor(descriptors, "기준시01", CutDebugItemKind.TextValue, "공통 텍스트");
AddDescriptor(descriptors, "기준시02", CutDebugItemKind.TextValue, "공통 텍스트");
AddDescriptor(descriptors, "유권자수01", CutDebugItemKind.TextValue, "공통 텍스트");
AddDescriptor(descriptors, "투표자수01", CutDebugItemKind.TextValue, "공통 텍스트");
AddDescriptor(descriptors, "전국투표율01", CutDebugItemKind.Counter, "카운터");
if (!ShouldExcludeHistoricalTurnoutGraph(format))
{
AddDescriptor(descriptors, "차트01", CutDebugItemKind.ImageValue, "이미지/리소스");
AddDescriptor(descriptors, "차트01", CutDebugItemKind.Visibility, "표시/숨김");
}
AddIndexedDescriptors(descriptors, "순위", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
AddIndexedDescriptors(descriptors, "기호", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
AddIndexedDescriptors(descriptors, "기호텍스트", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
AddIndexedDescriptors(descriptors, "후보명", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
AddIndexedDescriptors(descriptors, "정당명", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
AddIndexedDescriptors(descriptors, "득표수", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
AddIndexedDescriptors(descriptors, "득표율", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
AddIndexedDescriptors(descriptors, "표차", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
AddIndexedDescriptors(descriptors, "득표차", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
AddIndexedDescriptors(descriptors, "선거구명", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
AddIndexedDescriptors(descriptors, "시도명", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
AddIndexedDescriptors(descriptors, "개표율", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
AddIndexedDescriptors(descriptors, "투표율", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
AddIndexedDescriptors(descriptors, "공약", 3, CutDebugItemKind.TextValue, "공약 텍스트");
AddIndexedDescriptors(descriptors, "유확당", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
AddIndexedDescriptors(descriptors, "후보사진", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
AddIndexedDescriptors(descriptors, "득표수바", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
AddIndexedDescriptors(descriptors, "정당바", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
AddIndexedDescriptors(descriptors, "정당판", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
AddIndexedDescriptors(descriptors, "정당원", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
AddIndexedDescriptors(descriptors, "정당색", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
AddIndexedDescriptors(descriptors, "정당심볼", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
AddIndexedDescriptors(descriptors, "그룹", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
AddIndexedDescriptors(descriptors, "공약그룹", 3, CutDebugItemKind.ImageValue, "이미지/리소스");
AddIndexedDescriptors(descriptors, "바", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
AddIndexedDescriptors(descriptors, "점선", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
AddIndexedDescriptors(descriptors, "득표율", slotCount, CutDebugItemKind.Counter, "카운터");
AddIndexedDescriptors(descriptors, "투표율", slotCount, CutDebugItemKind.Counter, "카운터");
AddIndexedDescriptors(descriptors, "기호", slotCount, CutDebugItemKind.StyleColor, "색상");
AddIndexedDescriptors(descriptors, "기호텍스트", slotCount, CutDebugItemKind.StyleColor, "색상");
AddIndexedDescriptors(descriptors, "득표수바", slotCount, CutDebugItemKind.StyleColor, "색상");
AddIndexedDescriptors(descriptors, "정당바", slotCount, CutDebugItemKind.StyleColor, "색상");
AddIndexedDescriptors(descriptors, "정당판", slotCount, CutDebugItemKind.StyleColor, "색상");
AddIndexedDescriptors(descriptors, "정당원", slotCount, CutDebugItemKind.StyleColor, "색상");
AddIndexedDescriptors(descriptors, "정당색", slotCount, CutDebugItemKind.StyleColor, "색상");
AddIndexedDescriptors(descriptors, "정당명", slotCount, CutDebugItemKind.StyleColor, "색상");
AddIndexedDescriptors(descriptors, "득표율", slotCount, CutDebugItemKind.StyleColor, "색상");
AddIndexedDescriptors(descriptors, "유확당", slotCount, CutDebugItemKind.Visibility, "표시/숨김");
AddIndexedDescriptors(descriptors, "그룹", slotCount, CutDebugItemKind.Visibility, "표시/숨김");
AddIndexedDescriptors(descriptors, "공약그룹", 3, CutDebugItemKind.Visibility, "표시/숨김");
AddIndexedDescriptors(descriptors, "점선", slotCount, CutDebugItemKind.Visibility, "표시/숨김");
return descriptors;
}
private static void AddIndexedDescriptors(
ICollection<CutDebugItemDescriptor> descriptors,
string prefix,
int slotCount,
CutDebugItemKind kind,
string groupLabel)
{
for (var slot = 1; slot <= Math.Max(1, slotCount); slot++)
{
AddDescriptor(descriptors, $"{prefix}{slot:00}", kind, groupLabel);
}
}
private static void AddDescriptor(
ICollection<CutDebugItemDescriptor> descriptors,
string key,
CutDebugItemKind kind,
string groupLabel)
{
descriptors.Add(new CutDebugItemDescriptor(key, kind, groupLabel));
}
private static bool ShouldExcludeHistoricalTurnoutGraph(FormatTemplateDefinition? format)
{
return format is not null &&
format.Name.StartsWith("사전_역대투표율", StringComparison.Ordinal);
}
} }

View File

@@ -12,19 +12,25 @@ public sealed class CutListEntryViewModel : ObservableObject
private readonly FormatTemplateDefinition _template; private readonly FormatTemplateDefinition _template;
private readonly FormatCutDefinition _cut; private readonly FormatCutDefinition _cut;
private readonly Action<FormatTemplateDefinition> _durationChanged; private readonly Action<FormatTemplateDefinition> _durationChanged;
private VideoWallLayoutPreset _videoWallLayoutPreset;
private double _durationSeconds; private double _durationSeconds;
private double _thumbnailWidth;
private double _thumbnailHeight;
private ImageSource? _thumbnailSource; private ImageSource? _thumbnailSource;
public CutListEntryViewModel( public CutListEntryViewModel(
FormatTemplateDefinition template, FormatTemplateDefinition template,
FormatCutDefinition cut, FormatCutDefinition cut,
Action<FormatTemplateDefinition> durationChanged) Action<FormatTemplateDefinition> durationChanged,
VideoWallLayoutPreset videoWallLayoutPreset)
{ {
_template = template; _template = template;
_cut = cut; _cut = cut;
_durationChanged = durationChanged; _durationChanged = durationChanged;
_videoWallLayoutPreset = videoWallLayoutPreset;
_durationSeconds = cut.DurationSeconds; _durationSeconds = cut.DurationSeconds;
_thumbnailSource = CutThumbnailAssetCatalog.CreateImageSource(template); _thumbnailSource = CutThumbnailAssetCatalog.CreateImageSource(template);
ApplyThumbnailLayout();
} }
public string FormatId => _template.Id; public string FormatId => _template.Id;
@@ -46,10 +52,18 @@ public sealed class CutListEntryViewModel : ObservableObject
public string Description => _template.Description; public string Description => _template.Description;
public CutListElectionCategory ElectionCategory => CutListElectionCategoryResolver.Resolve(_template.Name);
public string ElectionCategoryLabel => CutListElectionCategoryResolver.GetLabel(ElectionCategory);
public ImageSource? ThumbnailSource => _thumbnailSource; public ImageSource? ThumbnailSource => _thumbnailSource;
public bool HasThumbnail => CutThumbnailAssetCatalog.HasThumbnail(_template); public bool HasThumbnail => CutThumbnailAssetCatalog.HasThumbnail(_template);
public double ThumbnailWidth => _thumbnailWidth;
public double ThumbnailHeight => _thumbnailHeight;
public double DurationSeconds public double DurationSeconds
{ {
get => _durationSeconds; get => _durationSeconds;
@@ -90,4 +104,22 @@ public sealed class CutListEntryViewModel : ObservableObject
_thumbnailSource = CutThumbnailAssetCatalog.CreateImageSource(_template); _thumbnailSource = CutThumbnailAssetCatalog.CreateImageSource(_template);
OnPropertyChanged(nameof(ThumbnailSource), nameof(HasThumbnail)); OnPropertyChanged(nameof(ThumbnailSource), nameof(HasThumbnail));
} }
public void UpdateVideoWallLayoutPreset(VideoWallLayoutPreset videoWallLayoutPreset)
{
if (_videoWallLayoutPreset == videoWallLayoutPreset)
{
return;
}
_videoWallLayoutPreset = videoWallLayoutPreset;
ApplyThumbnailLayout();
}
private void ApplyThumbnailLayout()
{
var metrics = ThumbnailLayoutResolver.ResolveDisplayMetrics(_template, _videoWallLayoutPreset, ThumbnailDisplayContext.CutList);
SetProperty(ref _thumbnailWidth, metrics.Width, nameof(ThumbnailWidth));
SetProperty(ref _thumbnailHeight, metrics.Height, nameof(ThumbnailHeight));
}
} }

View File

@@ -154,7 +154,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
private IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption> _districtSelectionSource = DefaultDistrictOptions; private IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption> _districtSelectionSource = DefaultDistrictOptions;
private HashSet<string> _configuredRegions = new(StringComparer.OrdinalIgnoreCase); private HashSet<string> _configuredRegions = new(StringComparer.OrdinalIgnoreCase);
private bool _showOnlyConfiguredRegions; private bool _showOnlyConfiguredRegions;
private string _selectedDistrictViewName = string.Empty; private string _selectedDistrictViewName = StationRegionOverviewOptionValue;
private bool _isRefreshingDistrictOverview; private bool _isRefreshingDistrictOverview;
private string _districtOverviewStatusText = "전체보기를 선택하면 지역별 개표율을 확인할 수 있습니다."; private string _districtOverviewStatusText = "전체보기를 선택하면 지역별 개표율을 확인할 수 있습니다.";
private string _selectedStationId = "KNN"; private string _selectedStationId = "KNN";
@@ -265,6 +265,16 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
public AsyncRelayCommand SavePreElectionHistoryCommand { get; } public AsyncRelayCommand SavePreElectionHistoryCommand { get; }
public void SelectDistrictOverviewCard(string? districtViewName)
{
if (!IsDistrictOverviewMode || string.IsNullOrWhiteSpace(districtViewName))
{
return;
}
SelectedDistrictViewName = districtViewName.Trim();
}
public BroadcastPhase BroadcastPhase public BroadcastPhase BroadcastPhase
{ {
get => _broadcastPhase; get => _broadcastPhase;
@@ -592,7 +602,9 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
return; return;
} }
var normalizedValue = string.IsNullOrWhiteSpace(value) ? DistrictName : value; var normalizedValue = string.IsNullOrWhiteSpace(value)
? StationRegionOverviewOptionValue
: value;
if (!SetProperty(ref _selectedDistrictViewName, normalizedValue)) if (!SetProperty(ref _selectedDistrictViewName, normalizedValue))
{ {
return; return;
@@ -2269,6 +2281,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
ReplaceDistrictOverviewCards( ReplaceDistrictOverviewCards(
overviewItems.Select(item => new DistrictOverviewCardViewModel overviewItems.Select(item => new DistrictOverviewCardViewModel
{ {
DistrictViewName = item.DisplayName,
RegionName = item.DisplayName, RegionName = item.DisplayName,
CountedRateDisplay = $"{item.CountedRate:0.0}%", CountedRateDisplay = $"{item.CountedRate:0.0}%",
DetailText = $"개표 {item.CountedVotes:N0} / 남은표 {item.UncountedVotes:N0}" DetailText = $"개표 {item.CountedVotes:N0} / 남은표 {item.UncountedVotes:N0}"

View File

@@ -2,6 +2,8 @@ namespace Tornado3_2026Election.ViewModels;
public sealed class DistrictOverviewCardViewModel public sealed class DistrictOverviewCardViewModel
{ {
public required string DistrictViewName { get; init; }
public required string RegionName { get; init; } public required string RegionName { get; init; }
public required string CountedRateDisplay { get; init; } public required string CountedRateDisplay { get; init; }

View File

@@ -22,10 +22,11 @@ public sealed class MainViewModel : ObservableObject
private static readonly Brush ConnectedStatusBrush = new SolidColorBrush(Colors.LimeGreen); private static readonly Brush ConnectedStatusBrush = new SolidColorBrush(Colors.LimeGreen);
private static readonly Brush DisconnectedStatusBrush = new SolidColorBrush(Colors.OrangeRed); private static readonly Brush DisconnectedStatusBrush = new SolidColorBrush(Colors.OrangeRed);
private static readonly TimeSpan AutomaticSaveDelay = TimeSpan.FromMilliseconds(500); private static readonly TimeSpan AutomaticSaveDelay = TimeSpan.FromMilliseconds(500);
private readonly FormatCatalogService _formatCatalogService; private FormatCatalogService _formatCatalogService;
private readonly AppStateStore _stateStore; private readonly AppStateStore _stateStore;
private readonly LogService _logService; private readonly LogService _logService;
private readonly KarismaThumbnailGeneratorService _thumbnailGeneratorService; private readonly KarismaThumbnailGeneratorService _thumbnailGeneratorService;
private readonly CutDebugStateStore _cutDebugStateStore;
private readonly ITornado3Adapter _sharedTornadoAdapter; private readonly ITornado3Adapter _sharedTornadoAdapter;
private readonly SemaphoreSlim _stateSaveLock = new(1, 1); private readonly SemaphoreSlim _stateSaveLock = new(1, 1);
private AppPage _currentPage = AppPage.Normal; private AppPage _currentPage = AppPage.Normal;
@@ -41,17 +42,18 @@ public sealed class MainViewModel : ObservableObject
private SelectionOption<LogLevel?>? _selectedLogFilterOption; private SelectionOption<LogLevel?>? _selectedLogFilterOption;
private readonly List<(BroadcastChannel Channel, CutListEntryViewModel Entry)> _allCutListEntries = []; private readonly List<(BroadcastChannel Channel, CutListEntryViewModel Entry)> _allCutListEntries = [];
private SelectionOption<BroadcastChannel?>? _selectedCutListFilterOption; private SelectionOption<BroadcastChannel?>? _selectedCutListFilterOption;
private SelectionOption<CutListElectionCategory?>? _selectedCutListCategoryOption;
private string _thumbnailGenerationStatus = string.Empty; private string _thumbnailGenerationStatus = string.Empty;
public MainViewModel() public MainViewModel()
{ {
_formatCatalogService = new FormatCatalogService();
_stateStore = new AppStateStore(); _stateStore = new AppStateStore();
_logService = new LogService(); _logService = new LogService();
Settings = new SettingsViewModel(new StationCatalogService().GetAll());
_formatCatalogService = new FormatCatalogService(Settings.ImageRootPath);
_thumbnailGeneratorService = new KarismaThumbnailGeneratorService(_logService); _thumbnailGeneratorService = new KarismaThumbnailGeneratorService(_logService);
Data = new DataViewModel(_logService); Data = new DataViewModel(_logService);
Settings = new SettingsViewModel(new StationCatalogService().GetAll());
var selectedStationProfile = Settings.BuildSelectedStationProfile(); var selectedStationProfile = Settings.BuildSelectedStationProfile();
Data.SetConfiguredRegions(selectedStationProfile.RegionFilters); Data.SetConfiguredRegions(selectedStationProfile.RegionFilters);
Data.SetSelectedStationContext(selectedStationProfile.Id, selectedStationProfile.Name); Data.SetSelectedStationContext(selectedStationProfile.Id, selectedStationProfile.Name);
@@ -71,13 +73,24 @@ public sealed class MainViewModel : ObservableObject
new SelectionOption<BroadcastChannel?>(BroadcastChannel.Bottom, "하단"), new SelectionOption<BroadcastChannel?>(BroadcastChannel.Bottom, "하단"),
new SelectionOption<BroadcastChannel?>(BroadcastChannel.VideoWall, "비디오월") new SelectionOption<BroadcastChannel?>(BroadcastChannel.VideoWall, "비디오월")
]; ];
CutListCategoryOptions =
[
new SelectionOption<CutListElectionCategory?>(null, "\uC804\uCCB4"),
new SelectionOption<CutListElectionCategory?>(CutListElectionCategory.MetropolitanHead, "\uAD11\uC5ED\uB2E8\uCCB4\uC7A5"),
new SelectionOption<CutListElectionCategory?>(CutListElectionCategory.MetropolitanCouncil, "\uAD11\uC5ED\uC758\uC6D0"),
new SelectionOption<CutListElectionCategory?>(CutListElectionCategory.Superintendent, "\uAD50\uC721\uAC10"),
new SelectionOption<CutListElectionCategory?>(CutListElectionCategory.LocalHead, "\uAE30\uCD08\uB2E8\uCCB4\uC7A5"),
new SelectionOption<CutListElectionCategory?>(CutListElectionCategory.LocalCouncil, "\uAE30\uCD08\uC758\uC6D0")
];
FilteredLogs = []; FilteredLogs = [];
CutListItems = []; CutListItems = [];
_selectedCutListFilterOption = CutListFilterOptions[0]; _selectedCutListFilterOption = CutListFilterOptions[0];
_selectedCutListCategoryOption = CutListCategoryOptions[0];
Settings.PropertyChanged += Settings_PropertyChanged; Settings.PropertyChanged += Settings_PropertyChanged;
Data.PropertyChanged += Data_PropertyChanged; Data.PropertyChanged += Data_PropertyChanged;
_sharedTornadoAdapter = KarismaTornado3Adapter.CreateOrFallback(_logService, () => Settings.ImageRootPath); _cutDebugStateStore = new CutDebugStateStore();
_sharedTornadoAdapter = KarismaTornado3Adapter.CreateOrFallback(_logService, () => Settings.ImageRootPath, _cutDebugStateStore);
NormalChannel = CreateChannelViewModel(BroadcastChannel.Normal, "노멀", _sharedTornadoAdapter); NormalChannel = CreateChannelViewModel(BroadcastChannel.Normal, "노멀", _sharedTornadoAdapter);
TopLeftChannel = CreateChannelViewModel(BroadcastChannel.TopLeft, "좌상단", _sharedTornadoAdapter); TopLeftChannel = CreateChannelViewModel(BroadcastChannel.TopLeft, "좌상단", _sharedTornadoAdapter);
@@ -85,6 +98,7 @@ public sealed class MainViewModel : ObservableObject
VideoWallChannel = CreateChannelViewModel(BroadcastChannel.VideoWall, "비디오월", _sharedTornadoAdapter); VideoWallChannel = CreateChannelViewModel(BroadcastChannel.VideoWall, "비디오월", _sharedTornadoAdapter);
Channels = [NormalChannel, TopLeftChannel, BottomChannel, VideoWallChannel]; Channels = [NormalChannel, TopLeftChannel, BottomChannel, VideoWallChannel];
UpdateChannelThumbnailLayouts();
BuildCutListEntries(); BuildCutListEntries();
foreach (var channel in Channels) foreach (var channel in Channels)
{ {
@@ -157,6 +171,8 @@ public sealed class MainViewModel : ObservableObject
public IReadOnlyList<SelectionOption<BroadcastChannel?>> CutListFilterOptions { get; } public IReadOnlyList<SelectionOption<BroadcastChannel?>> CutListFilterOptions { get; }
public IReadOnlyList<SelectionOption<CutListElectionCategory?>> CutListCategoryOptions { get; }
public ChannelOperationMode OperationMode public ChannelOperationMode OperationMode
{ {
get => _operationMode; get => _operationMode;
@@ -278,12 +294,23 @@ public sealed class MainViewModel : ObservableObject
{ {
var totalCount = _allCutListEntries.Count; var totalCount = _allCutListEntries.Count;
var filteredCount = CutListItems.Count; var filteredCount = CutListItems.Count;
if (SelectedCutListFilterOption?.Value is null) var selectedFilters = new List<string>();
if (SelectedCutListFilterOption?.Value is not null)
{
selectedFilters.Add(SelectedCutListFilterOption.Label);
}
if (SelectedCutListCategoryOption?.Value is not null)
{
selectedFilters.Add(SelectedCutListCategoryOption.Label);
}
if (selectedFilters.Count == 0)
{ {
return $"등록 컷 {totalCount}개"; return $"등록 컷 {totalCount}개";
} }
return $"{SelectedCutListFilterOption.Label} 컷 {filteredCount}개 / 전체 {totalCount}개"; return $"{string.Join(" / ", selectedFilters)} 컷 {filteredCount}개 / 전체 {totalCount}개";
} }
} }
@@ -359,6 +386,23 @@ public sealed class MainViewModel : ObservableObject
} }
} }
public SelectionOption<CutListElectionCategory?>? SelectedCutListCategoryOption
{
get => _selectedCutListCategoryOption;
set
{
if (value is null)
{
return;
}
if (SetProperty(ref _selectedCutListCategoryOption, value))
{
ApplyCutListFilter();
}
}
}
public string LogFilterSummary => $"표시 {FilteredLogs.Count}건 / 전체 {Logs.Count}건"; public string LogFilterSummary => $"표시 {FilteredLogs.Count}건 / 전체 {Logs.Count}건";
public string CgIntegrationSummary => IsCgConnected ? "Connected" : "Disconnected"; public string CgIntegrationSummary => IsCgConnected ? "Connected" : "Disconnected";
@@ -624,7 +668,11 @@ public sealed class MainViewModel : ObservableObject
try try
{ {
var result = await _thumbnailGeneratorService var result = await _thumbnailGeneratorService
.GenerateAsync(_formatCatalogService.GetAll(), Settings.ImageRootPath, CancellationToken.None); .GenerateAsync(
_formatCatalogService.GetAll(),
Settings.ImageRootPath,
Settings.SelectedStationVideoWallLayoutPreset,
CancellationToken.None);
RefreshCutListThumbnails(); RefreshCutListThumbnails();
foreach (var channel in Channels) foreach (var channel in Channels)
@@ -667,8 +715,20 @@ public sealed class MainViewModel : ObservableObject
QueueAutomaticSave(); QueueAutomaticSave();
} }
if (args.PropertyName is nameof(SettingsViewModel.SelectedStationId) or nameof(SettingsViewModel.ImageRootPath)) if (args.PropertyName is nameof(SettingsViewModel.SelectedStationId)
or nameof(SettingsViewModel.SelectedStationVideoWallLayoutPreset)
or nameof(SettingsViewModel.ImageRootPath))
{ {
if (args.PropertyName == nameof(SettingsViewModel.ImageRootPath))
{
ReloadFormatCatalog();
}
else
{
UpdateChannelThumbnailLayouts();
UpdateCutListThumbnailLayouts();
}
_ = WarmupSharedCgConnectionAsync(); _ = WarmupSharedCgConnectionAsync();
GenerateCutThumbnailsCommand.NotifyCanExecuteChanged(); GenerateCutThumbnailsCommand.NotifyCanExecuteChanged();
} }
@@ -751,9 +811,18 @@ public sealed class MainViewModel : ObservableObject
{ {
station.RegionFiltersText = filters; station.RegionFiltersText = filters;
} }
if (state.StationVideoWallLayouts.TryGetValue(station.Id, out var videoWallLayoutValue) &&
Enum.TryParse<VideoWallLayoutPreset>(videoWallLayoutValue, ignoreCase: true, out var videoWallLayoutPreset))
{
station.VideoWallLayoutPreset = videoWallLayoutPreset;
} }
} }
UpdateChannelThumbnailLayouts();
UpdateCutListThumbnailLayouts();
}
if (RestoreSelection.RestoreStatusValues) if (RestoreSelection.RestoreStatusValues)
{ {
if (!Enum.TryParse<ChannelOperationMode>(state.OperationMode, ignoreCase: true, out var operationMode)) if (!Enum.TryParse<ChannelOperationMode>(state.OperationMode, ignoreCase: true, out var operationMode))
@@ -901,7 +970,10 @@ public sealed class MainViewModel : ObservableObject
}).ToList(), }).ToList(),
Channels = BuildChannelStateMap(), Channels = BuildChannelStateMap(),
CutDurations = BuildCutDurationMap(), CutDurations = BuildCutDurationMap(),
StationRegionFilters = Settings.Stations.ToDictionary(station => station.Id, station => station.RegionFiltersText) StationRegionFilters = Settings.Stations.ToDictionary(station => station.Id, station => station.RegionFiltersText),
StationVideoWallLayouts = Settings.Stations.ToDictionary(
station => station.Id,
station => station.VideoWallLayoutPreset.ToString())
}; };
await _stateStore.SaveAsync(state); await _stateStore.SaveAsync(state);
@@ -946,16 +1018,35 @@ public sealed class MainViewModel : ObservableObject
formatId => _formatCatalogService.FindById(formatId), formatId => _formatCatalogService.FindById(formatId),
_logService); _logService);
var cutDebug = _cutDebugStateStore.Get(channel);
return new ChannelScheduleViewModel( return new ChannelScheduleViewModel(
channel, channel,
title, title,
_formatCatalogService.GetByChannel(channel), _formatCatalogService.GetByChannel(channel),
Data, Data,
adapter, adapter,
cutDebug,
_cutDebugStateStore,
engine, engine,
_logService); _logService);
} }
private void ReloadFormatCatalog()
{
var cutDurations = BuildCutDurationMap();
_formatCatalogService = new FormatCatalogService(Settings.ImageRootPath);
NormalChannel.UpdateFormats(_formatCatalogService.GetByChannel(BroadcastChannel.Normal));
TopLeftChannel.UpdateFormats(_formatCatalogService.GetByChannel(BroadcastChannel.TopLeft));
BottomChannel.UpdateFormats(_formatCatalogService.GetByChannel(BroadcastChannel.Bottom));
VideoWallChannel.UpdateFormats(_formatCatalogService.GetByChannel(BroadcastChannel.VideoWall));
UpdateChannelThumbnailLayouts();
BuildCutListEntries();
ApplyCutDurations(cutDurations);
SyncAllQueuedCutDurations();
}
private Dictionary<string, ChannelState> BuildChannelStateMap() private Dictionary<string, ChannelState> BuildChannelStateMap()
{ {
return Channels.ToDictionary( return Channels.ToDictionary(
@@ -1061,9 +1152,16 @@ public sealed class MainViewModel : ObservableObject
{ {
var entries = _formatCatalogService var entries = _formatCatalogService
.GetAll() .GetAll()
.OrderBy(template => template.RecommendedChannel) .OrderBy(template => CutListElectionCategoryResolver.Resolve(template.Name))
.ThenBy(template => template.RecommendedChannel)
.ThenBy(template => template.Name, StringComparer.Ordinal) .ThenBy(template => template.Name, StringComparer.Ordinal)
.SelectMany(template => template.Cuts.Select(cut => (template.RecommendedChannel, Entry: new CutListEntryViewModel(template, cut, OnCutDurationChanged)))) .SelectMany(template => template.Cuts.Select(cut => (
template.RecommendedChannel,
Entry: new CutListEntryViewModel(
template,
cut,
OnCutDurationChanged,
Settings.SelectedStationVideoWallLayoutPreset))))
.ToArray(); .ToArray();
_allCutListEntries.Clear(); _allCutListEntries.Clear();
@@ -1114,8 +1212,11 @@ public sealed class MainViewModel : ObservableObject
private void ApplyCutListFilter() private void ApplyCutListFilter()
{ {
var selectedChannel = SelectedCutListFilterOption?.Value; var selectedChannel = SelectedCutListFilterOption?.Value;
var selectedCategory = SelectedCutListCategoryOption?.Value;
var filteredEntries = _allCutListEntries var filteredEntries = _allCutListEntries
.Where(item => selectedChannel is null || item.Channel == selectedChannel.Value) .Where(item =>
(selectedChannel is null || item.Channel == selectedChannel.Value) &&
(selectedCategory is null || item.Entry.ElectionCategory == selectedCategory.Value))
.Select(item => item.Entry) .Select(item => item.Entry)
.ToArray(); .ToArray();
@@ -1138,6 +1239,24 @@ public sealed class MainViewModel : ObservableObject
OnPropertyChanged(nameof(CutThumbnailSummary)); OnPropertyChanged(nameof(CutThumbnailSummary));
} }
private void UpdateChannelThumbnailLayouts()
{
var videoWallLayoutPreset = Settings.SelectedStationVideoWallLayoutPreset;
foreach (var channel in Channels)
{
channel.UpdateVideoWallLayoutPreset(videoWallLayoutPreset);
}
}
private void UpdateCutListThumbnailLayouts()
{
var videoWallLayoutPreset = Settings.SelectedStationVideoWallLayoutPreset;
foreach (var item in _allCutListEntries)
{
item.Entry.UpdateVideoWallLayoutPreset(videoWallLayoutPreset);
}
}
private string BuildInitialThumbnailGenerationStatus() private string BuildInitialThumbnailGenerationStatus()
{ {
return KarismaThumbnailGeneratorService.IsGenerationAvailable() return KarismaThumbnailGeneratorService.IsGenerationAvailable()

View File

@@ -11,6 +11,12 @@ public sealed class SettingsViewModel : ObservableObject
{ {
private string _selectedStationId = "KNN"; private string _selectedStationId = "KNN";
private string _imageRootPath = TornadoPathResolver.GetDefaultT3CutPath(); private string _imageRootPath = TornadoPathResolver.GetDefaultT3CutPath();
private readonly IReadOnlyList<SelectionOption<VideoWallLayoutPreset>> _videoWallLayoutOptions =
[
new SelectionOption<VideoWallLayoutPreset>(VideoWallLayoutPreset.Auto, "자동"),
new SelectionOption<VideoWallLayoutPreset>(VideoWallLayoutPreset.Standard5760x1080, "5760 x 1080"),
new SelectionOption<VideoWallLayoutPreset>(VideoWallLayoutPreset.UltraWide11520x1080, "11520 x 1080")
];
public SettingsViewModel(IEnumerable<BroadcastStationProfile> stations) public SettingsViewModel(IEnumerable<BroadcastStationProfile> stations)
{ {
@@ -25,6 +31,11 @@ public sealed class SettingsViewModel : ObservableObject
{ {
OnPropertyChanged(nameof(SelectedStationRegionSummary)); OnPropertyChanged(nameof(SelectedStationRegionSummary));
} }
if (station == SelectedStation && args.PropertyName is nameof(StationFilterItemViewModel.VideoWallLayoutPreset) or nameof(StationFilterItemViewModel.VideoWallLayoutSummary))
{
OnPropertyChanged(nameof(SelectedStationVideoWallLayoutPreset), nameof(SelectedStationVideoWallLayoutSummary));
}
}; };
} }
@@ -36,6 +47,8 @@ public sealed class SettingsViewModel : ObservableObject
public ObservableCollection<StationFilterItemViewModel> Stations { get; } public ObservableCollection<StationFilterItemViewModel> Stations { get; }
public IReadOnlyList<SelectionOption<VideoWallLayoutPreset>> VideoWallLayoutOptions => _videoWallLayoutOptions;
public string SelectedStationId public string SelectedStationId
{ {
get => _selectedStationId; get => _selectedStationId;
@@ -43,7 +56,13 @@ public sealed class SettingsViewModel : ObservableObject
{ {
if (SetProperty(ref _selectedStationId, value)) if (SetProperty(ref _selectedStationId, value))
{ {
OnPropertyChanged(nameof(SelectedStation), nameof(SelectedStationLogoAssetPath), nameof(SelectedStationRegions), nameof(SelectedStationRegionSummary)); OnPropertyChanged(
nameof(SelectedStation),
nameof(SelectedStationLogoAssetPath),
nameof(SelectedStationRegions),
nameof(SelectedStationRegionSummary),
nameof(SelectedStationVideoWallLayoutPreset),
nameof(SelectedStationVideoWallLayoutSummary));
} }
} }
} }
@@ -63,6 +82,23 @@ public sealed class SettingsViewModel : ObservableObject
public string SelectedStationRegionSummary => SelectedStation.RegionSelectionSummary; public string SelectedStationRegionSummary => SelectedStation.RegionSelectionSummary;
public VideoWallLayoutPreset SelectedStationVideoWallLayoutPreset
{
get => SelectedStation.VideoWallLayoutPreset;
set
{
if (SelectedStation.VideoWallLayoutPreset == value)
{
return;
}
SelectedStation.VideoWallLayoutPreset = value;
OnPropertyChanged(nameof(SelectedStationVideoWallLayoutPreset), nameof(SelectedStationVideoWallLayoutSummary));
}
}
public string SelectedStationVideoWallLayoutSummary => SelectedStation.VideoWallLayoutSummary;
public BroadcastStationProfile BuildSelectedStationProfile() public BroadcastStationProfile BuildSelectedStationProfile()
{ {
return SelectedStation.ToProfile(); return SelectedStation.ToProfile();

View File

@@ -71,11 +71,14 @@ public sealed class StationFilterItemViewModel : ObservableObject
["제주특별자치도"] = "제주" ["제주특별자치도"] = "제주"
}; };
private VideoWallLayoutPreset _videoWallLayoutPreset;
public StationFilterItemViewModel(BroadcastStationProfile station) public StationFilterItemViewModel(BroadcastStationProfile station)
{ {
Id = station.Id; Id = station.Id;
Name = station.Name; Name = station.Name;
LogoAssetPath = station.LogoAssetPath; LogoAssetPath = station.LogoAssetPath;
_videoWallLayoutPreset = station.VideoWallLayoutPreset;
var selectedRegions = ParseRegions(station.RegionFilters); var selectedRegions = ParseRegions(station.RegionFilters);
Regions = new ObservableCollection<RegionOptionViewModel>( Regions = new ObservableCollection<RegionOptionViewModel>(
@@ -90,6 +93,18 @@ public sealed class StationFilterItemViewModel : ObservableObject
public ObservableCollection<RegionOptionViewModel> Regions { get; } public ObservableCollection<RegionOptionViewModel> Regions { get; }
public VideoWallLayoutPreset VideoWallLayoutPreset
{
get => _videoWallLayoutPreset;
set
{
if (SetProperty(ref _videoWallLayoutPreset, value))
{
OnPropertyChanged(nameof(VideoWallLayoutSummary));
}
}
}
public string RegionFiltersText public string RegionFiltersText
{ {
get => string.Join(", ", Regions.Where(region => region.IsSelected).Select(region => region.Name)); get => string.Join(", ", Regions.Where(region => region.IsSelected).Select(region => region.Name));
@@ -101,6 +116,13 @@ public sealed class StationFilterItemViewModel : ObservableObject
public string RegionSelectionSummary public string RegionSelectionSummary
=> SelectedRegionCount == 0 ? "선택된 시도가 없습니다." : $"선택된 시도 {SelectedRegionCount}개"; => SelectedRegionCount == 0 ? "선택된 시도가 없습니다." : $"선택된 시도 {SelectedRegionCount}개";
public string VideoWallLayoutSummary => VideoWallLayoutPreset switch
{
VideoWallLayoutPreset.Standard5760x1080 => "5760 x 1080 비디오월",
VideoWallLayoutPreset.UltraWide11520x1080 => "11520 x 1080 비디오월",
_ => "씬 기준 자동 감지"
};
public BroadcastStationProfile ToProfile() public BroadcastStationProfile ToProfile()
{ {
return new BroadcastStationProfile return new BroadcastStationProfile
@@ -108,6 +130,7 @@ public sealed class StationFilterItemViewModel : ObservableObject
Id = Id, Id = Id,
Name = Name, Name = Name,
LogoAssetPath = LogoAssetPath, LogoAssetPath = LogoAssetPath,
VideoWallLayoutPreset = VideoWallLayoutPreset,
RegionFilters = Regions RegionFilters = Regions
.Where(region => region.IsSelected) .Where(region => region.IsSelected)
.Select(region => region.Name) .Select(region => region.Name)

View File

@@ -0,0 +1,29 @@
{
"name": "cut-design-debugger",
"version": "0.1.0",
"description": "Repository-local tools for changing and validating Tornado3/Karisma election cuts.",
"keywords": [
"tornado3",
"karisma",
"election-cuts"
],
"skills": "./skills/",
"interface": {
"displayName": "Cut Design Debugger",
"shortDescription": "Modify, inspect, and validate Tornado3 election cuts.",
"longDescription": "Use repo-local skills and validation wrappers to change cut layouts, debug scene mappings, and verify Karisma output.",
"developerName": "Repo Local",
"category": "Productivity",
"capabilities": [
"Interactive",
"Write",
"Debug"
],
"defaultPrompt": [
"Inspect this cut and make the smallest safe design change.",
"Find why this cut is missing scene values or assets.",
"Run validation for this cut and summarize what failed."
],
"brandColor": "#2563EB"
}
}

View File

@@ -0,0 +1,46 @@
---
name: cut-design-debugger
description: Modify and validate Tornado3/Karisma election cut designs in this repository. Use when Codex needs to change a cut layout or asset mapping, troubleshoot scene-variable visibility or style issues, inspect T3_Cut scene behavior, or run repo-local validation after a cut change.
---
# Cut Design Debugger
Use this skill to make the smallest safe change to a cut-related workflow, then validate it with the repo's existing Karisma tools.
## Workflow
1. Identify the target before editing.
- Confirm the requested template, cut, channel, and visible symptom.
- Search the repo spec first: `SYSTEM_SPEC.md`, `RGB_SPEC_CUT_MAPPING.md`, and `CURRENT_IMPLEMENTATION_STATUS_2026-04-17.md`.
- Map the request to the owning code before changing anything. Read [repo-map.md](references/repo-map.md) when the affected area is not obvious.
2. Edit the smallest surface that can explain the behavior.
- Catalog or cut-list problems usually live in `FormatCatalogService`, `MainViewModel`, or thumbnail helpers.
- Scene resolution, path, and asset lookup issues usually live in `KarismaSceneResolver`, `KarismaSceneVariableCatalog`, `TornadoPathResolver`, or `KarismaTornado3Adapter`.
- Runtime value, visibility, color, and candidate-slot logic usually lives in `KarismaTornado3Adapter`.
- If the problem exists only inside external `T3_Cut` assets, call that out explicitly before assuming a repo-side fix exists.
3. Validate in layers after every meaningful change.
- Always run `dotnet build Tornado3_2026Election.slnx` when code changed.
- Use [validation-workflow.md](references/validation-workflow.md) for command selection.
- For a scoped live pass, prefer `scripts/validate-cut.ps1`.
- For scene-level snapshots or raw object checks, use `tools/KarismaTcpProbe` directly.
- If live Karisma or `T3_Cut` is unavailable, still run the build and document the missing external dependency.
4. Report the result in operational terms.
- Name the files changed.
- List the commands run.
- Say what was verified, what remains unverified, and whether external Karisma or `T3_Cut` access blocked anything.
## Repo Notes
- Treat the repo as the source of truth for cut metadata and validation helpers.
- Treat `T3_Cut` as an external dependency that may contain the real scene or asset causing the issue.
- Prefer targeted validation with a template or cut filter instead of sweeping the whole catalog unless the user asks for a broad audit.
- Reuse existing `tools/KarismaTcpProbe/scene-ops/*.json` fixtures when they match the symptom instead of inventing a new validation format.
## Resources
- `scripts/validate-cut.ps1`: run scoped live validation against the existing `KarismaTcpProbe` tool.
- [repo-map.md](references/repo-map.md): load when you need to find the owning file quickly.
- [validation-workflow.md](references/validation-workflow.md): load when you need the exact validation command for the current symptom.

View File

@@ -0,0 +1,4 @@
interface:
display_name: "Cut Design Debugger"
short_description: "Modify and validate Tornado3 election cut designs."
default_prompt: "Inspect the requested cut, make the smallest safe design or mapping change, then run the relevant validation steps and summarize the result."

View File

@@ -0,0 +1,35 @@
# Repo Map
Use this reference to decide where a cut-related change belongs.
## Specs and current behavior
- `SYSTEM_SPEC.md`: high-level broadcast, catalog, thumbnail, and `T3_Cut` rules.
- `RGB_SPEC_CUT_MAPPING.md`: color and cut mapping notes.
- `CURRENT_IMPLEMENTATION_STATUS_2026-04-17.md`: implemented behavior and current constraints.
## UI and cut-list behavior
- `Tornado3_2026Election/MainWindow.xaml`: cut-list and control UI.
- `Tornado3_2026Election/ViewModels/MainViewModel.cs`: cut-list items, thumbnail generation command, filter state.
- `Tornado3_2026Election/ViewModels/CutListEntryViewModel.cs`: per-cut thumbnail and duration state.
## Catalog, scene lookup, and paths
- `Tornado3_2026Election/Services/FormatCatalogService.cs`: template and cut catalog.
- `Tornado3_2026Election/Services/KarismaSceneResolver.cs`: resolve the actual `.tscn` or `_loop.tscn` path.
- `Tornado3_2026Election/Services/KarismaSceneVariableCatalog.cs`: scene-variable discovery and lookup cache.
- `Tornado3_2026Election/Services/TornadoPathResolver.cs`: default and normalized `T3_Cut` path handling.
- `Tornado3_2026Election/Services/CutThumbnailAssetCatalog.cs`: project thumbnail asset locations.
## Runtime apply logic
- `Tornado3_2026Election/Services/KarismaTornado3Adapter.cs`: object values, style colors, visibility, candidate slots, asset lookup, live apply flow.
- `Tornado3_2026Election/Services/KarismaThumbnailGeneratorService.cs`: generate thumbnail PNGs from Karisma.
- `Tornado3_2026Election/Services/MockTornado3Adapter.cs`: fallback behavior when live Karisma is unavailable.
## Validation tools
- `tools/KarismaTcpProbe/Program.cs`: raw commands for scene validation, folder inspection, scene image capture, and live-cut validation.
- `tools/KarismaTcpProbe/LiveCutValidation.cs`: A/B live validation runner and report generation.
- `tools/KarismaTcpProbe/scene-ops/*.json`: reusable scene operation fixtures for targeted checks.

View File

@@ -0,0 +1,57 @@
# Validation Workflow
Choose the lightest command that still proves the change.
## 1. Always build after code changes
```powershell
dotnet build Tornado3_2026Election.slnx
```
## 2. Run scoped live validation for a cut or template
Use the local wrapper when Karisma and `T3_Cut` are available.
```powershell
powershell -ExecutionPolicy Bypass -File plugins/cut-design-debugger/skills/cut-design-debugger/scripts/validate-cut.ps1 `
-ImageRootPath 'C:\Path\To\T3_Cut' `
-Filter '1-2위_ani_광역단체장'
```
Useful flags:
- `-Limit 1`: validate only the first matching item.
- `-IncludeVideoWall`: include VideoWall templates.
- `-OutputPath <dir>`: write reports to a custom artifact directory.
## 3. Capture a scene image without running the full live pass
Use this when the problem is visual and you already know the exact `.tscn` scene path.
```powershell
dotnet run --project tools/KarismaTcpProbe/KarismaTcpProbe.csproj -- `
--save-scene-image `
--scene 'C:\Path\To\T3_Cut\SomeScene.tscn' `
--output artifacts\scene-captures\some-scene.png
```
## 4. Validate scene operations against a fixture
Use this when the issue is about values, visibility, or style updates for known scene objects.
```powershell
dotnet run --project tools/KarismaTcpProbe/KarismaTcpProbe.csproj -- `
--validate-scene-values `
--scene 'C:\Path\To\T3_Cut\SomeScene.tscn' `
--operations tools/KarismaTcpProbe/scene-ops/1-2위_ani_광역단체장_style.json `
--output artifacts\scene-validation\style.md
```
## 5. Inspect a whole folder when the object names are unclear
```powershell
dotnet run --project tools/KarismaTcpProbe/KarismaTcpProbe.csproj -- `
--inspect-tscn-folder `
--root 'C:\Path\To\T3_Cut' `
--output artifacts\scene-inspection\inspection.md
```

View File

@@ -0,0 +1,65 @@
param(
[string]$ImageRootPath = "",
[string]$Filter = "",
[string]$OutputPath = "",
[int]$Limit = 0,
[int]$OnAirDelayMs = 900,
[int]$BetweenDelayMs = 250,
[switch]$IncludeVideoWall
)
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..\..\..")).Path
$probeProject = Join-Path $repoRoot "tools\KarismaTcpProbe\KarismaTcpProbe.csproj"
if ([string]::IsNullOrWhiteSpace($OutputPath))
{
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
$OutputPath = Join-Path $repoRoot "artifacts\cut-design-debugger\live-cut-validation\$timestamp"
}
$commandArgs = @(
"run",
"--project", $probeProject,
"--",
"--validate-live-cuts",
"--output", $OutputPath,
"--onair-delay-ms", $OnAirDelayMs.ToString(),
"--between-delay-ms", $BetweenDelayMs.ToString()
)
if (-not [string]::IsNullOrWhiteSpace($ImageRootPath))
{
$commandArgs += @("--image-root", $ImageRootPath)
}
if (-not [string]::IsNullOrWhiteSpace($Filter))
{
$commandArgs += @("--filter", $Filter)
}
if ($Limit -gt 0)
{
$commandArgs += @("--limit", $Limit.ToString())
}
if ($IncludeVideoWall)
{
$commandArgs += "--include-videowall"
}
Write-Host "Running live-cut validation..."
Write-Host "Repo Root : $repoRoot"
Write-Host "Output : $OutputPath"
if (-not [string]::IsNullOrWhiteSpace($Filter))
{
Write-Host "Filter : $Filter"
}
& dotnet @commandArgs
if ($LASTEXITCODE -ne 0)
{
exit $LASTEXITCODE
}
$summaryPath = Join-Path $OutputPath "summary.md"
Write-Host "Validation report: $summaryPath"

View File

@@ -0,0 +1,96 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using Tornado3_2026Election.Domain;
internal readonly record struct CutDebugRecommendation(string Key, CutDebugItemKind Kind);
internal static class CutDebugRecommendationCatalog
{
private static readonly Lazy<IReadOnlyDictionary<string, CutDebugRecommendation>> Recommendations =
new(LoadRecommendations);
public static bool TryGetRecommendation(string templateId, out CutDebugRecommendation recommendation)
{
return Recommendations.Value.TryGetValue(templateId, out recommendation);
}
public static int Count => Recommendations.Value.Count;
private static IReadOnlyDictionary<string, CutDebugRecommendation> LoadRecommendations()
{
var path = FindRecommendationPath();
if (path is null || !File.Exists(path))
{
return new Dictionary<string, CutDebugRecommendation>(StringComparer.OrdinalIgnoreCase);
}
var recommendations = new Dictionary<string, CutDebugRecommendation>(StringComparer.OrdinalIgnoreCase);
foreach (var rawLine in File.ReadLines(path, Encoding.UTF8).Skip(1))
{
if (string.IsNullOrWhiteSpace(rawLine))
{
continue;
}
var columns = rawLine.Split('\t');
if (columns.Length < 3)
{
continue;
}
var templateId = columns[0].Trim();
var key = columns[1].Trim();
if (!Enum.TryParse<CutDebugItemKind>(columns[2].Trim(), ignoreCase: true, out var kind) ||
string.IsNullOrWhiteSpace(templateId) ||
string.IsNullOrWhiteSpace(key))
{
continue;
}
recommendations[templateId] = new CutDebugRecommendation(key, kind);
}
return recommendations;
}
private static string? FindRecommendationPath()
{
foreach (var root in EnumerateSearchRoots())
{
var candidate = Path.Combine(root, "tools", "KarismaTcpProbe", "cut-debug-recommendations.tsv");
if (File.Exists(candidate))
{
return candidate;
}
}
return null;
}
private static IEnumerable<string> EnumerateSearchRoots()
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var start in new[] { AppContext.BaseDirectory, Environment.CurrentDirectory })
{
if (string.IsNullOrWhiteSpace(start))
{
continue;
}
var directory = new DirectoryInfo(Path.GetFullPath(start));
while (directory is not null)
{
if (seen.Add(directory.FullName))
{
yield return directory.FullName;
}
directory = directory.Parent;
}
}
}
}

View File

@@ -25,6 +25,9 @@
<Compile Include="..\..\Tornado3_2026Election\Domain\BroadcastStationProfile.cs" Link="AppSource\Domain\BroadcastStationProfile.cs" /> <Compile Include="..\..\Tornado3_2026Election\Domain\BroadcastStationProfile.cs" Link="AppSource\Domain\BroadcastStationProfile.cs" />
<Compile Include="..\..\Tornado3_2026Election\Domain\CandidateEntry.cs" Link="AppSource\Domain\CandidateEntry.cs" /> <Compile Include="..\..\Tornado3_2026Election\Domain\CandidateEntry.cs" Link="AppSource\Domain\CandidateEntry.cs" />
<Compile Include="..\..\Tornado3_2026Election\Domain\CandidateJudgement.cs" Link="AppSource\Domain\CandidateJudgement.cs" /> <Compile Include="..\..\Tornado3_2026Election\Domain\CandidateJudgement.cs" Link="AppSource\Domain\CandidateJudgement.cs" />
<Compile Include="..\..\Tornado3_2026Election\Domain\CutDebugItemState.cs" Link="AppSource\Domain\CutDebugItemState.cs" />
<Compile Include="..\..\Tornado3_2026Election\Domain\CutDebugOverride.cs" Link="AppSource\Domain\CutDebugOverride.cs" />
<Compile Include="..\..\Tornado3_2026Election\Domain\CutDebugSettings.cs" Link="AppSource\Domain\CutDebugSettings.cs" />
<Compile Include="..\..\Tornado3_2026Election\Domain\ElectionDataSnapshot.cs" Link="AppSource\Domain\ElectionDataSnapshot.cs" /> <Compile Include="..\..\Tornado3_2026Election\Domain\ElectionDataSnapshot.cs" Link="AppSource\Domain\ElectionDataSnapshot.cs" />
<Compile Include="..\..\Tornado3_2026Election\Domain\FormatCutDefinition.cs" Link="AppSource\Domain\FormatCutDefinition.cs" /> <Compile Include="..\..\Tornado3_2026Election\Domain\FormatCutDefinition.cs" Link="AppSource\Domain\FormatCutDefinition.cs" />
<Compile Include="..\..\Tornado3_2026Election\Domain\FormatTemplateDefinition.cs" Link="AppSource\Domain\FormatTemplateDefinition.cs" /> <Compile Include="..\..\Tornado3_2026Election\Domain\FormatTemplateDefinition.cs" Link="AppSource\Domain\FormatTemplateDefinition.cs" />
@@ -33,10 +36,14 @@
<Compile Include="..\..\Tornado3_2026Election\Domain\LoopMode.cs" Link="AppSource\Domain\LoopMode.cs" /> <Compile Include="..\..\Tornado3_2026Election\Domain\LoopMode.cs" Link="AppSource\Domain\LoopMode.cs" />
<Compile Include="..\..\Tornado3_2026Election\Domain\PreElectionHistoryModels.cs" Link="AppSource\Domain\PreElectionHistoryModels.cs" /> <Compile Include="..\..\Tornado3_2026Election\Domain\PreElectionHistoryModels.cs" Link="AppSource\Domain\PreElectionHistoryModels.cs" />
<Compile Include="..\..\Tornado3_2026Election\Domain\TornadoConnectionState.cs" Link="AppSource\Domain\TornadoConnectionState.cs" /> <Compile Include="..\..\Tornado3_2026Election\Domain\TornadoConnectionState.cs" Link="AppSource\Domain\TornadoConnectionState.cs" />
<Compile Include="..\..\Tornado3_2026Election\Domain\VideoWallLayoutPreset.cs" Link="AppSource\Domain\VideoWallLayoutPreset.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\CutDebugStateStore.cs" Link="AppSource\Services\CutDebugStateStore.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\CutAppearancePolicyCatalog.cs" Link="AppSource\Services\CutAppearancePolicyCatalog.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\FormatCatalogService.cs" Link="AppSource\Services\FormatCatalogService.cs" /> <Compile Include="..\..\Tornado3_2026Election\Services\FormatCatalogService.cs" Link="AppSource\Services\FormatCatalogService.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\ITornado3Adapter.cs" Link="AppSource\Services\ITornado3Adapter.cs" /> <Compile Include="..\..\Tornado3_2026Election\Services\ITornado3Adapter.cs" Link="AppSource\Services\ITornado3Adapter.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaCounterNumberKeyUpdate.cs" Link="AppSource\Services\KarismaCounterNumberKeyUpdate.cs" /> <Compile Include="..\..\Tornado3_2026Election\Services\KarismaCounterNumberKeyUpdate.cs" Link="AppSource\Services\KarismaCounterNumberKeyUpdate.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaEventHandler.cs" Link="AppSource\Services\KarismaEventHandler.cs" /> <Compile Include="..\..\Tornado3_2026Election\Services\KarismaEventHandler.cs" Link="AppSource\Services\KarismaEventHandler.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaSceneResolutionReader.cs" Link="AppSource\Services\KarismaSceneResolutionReader.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaSceneResolver.cs" Link="AppSource\Services\KarismaSceneResolver.cs" /> <Compile Include="..\..\Tornado3_2026Election\Services\KarismaSceneResolver.cs" Link="AppSource\Services\KarismaSceneResolver.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaSceneVariableCatalog.cs" Link="AppSource\Services\KarismaSceneVariableCatalog.cs" /> <Compile Include="..\..\Tornado3_2026Election\Services\KarismaSceneVariableCatalog.cs" Link="AppSource\Services\KarismaSceneVariableCatalog.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaStyleColorUpdate.cs" Link="AppSource\Services\KarismaStyleColorUpdate.cs" /> <Compile Include="..\..\Tornado3_2026Election\Services\KarismaStyleColorUpdate.cs" Link="AppSource\Services\KarismaStyleColorUpdate.cs" />

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,68 @@
TemplateId Key Kind
Elect2026_Bottom_민방\1-2위_광역단체장 후보사진01 ImageValue
Elect2026_Bottom_민방\1-2위_기초단체장 후보사진01 ImageValue
Elect2026_Bottom_민방\1-3위_광역단체장 후보사진01 ImageValue
Elect2026_Bottom_민방\1-3위_기초단체장 후보사진01 ImageValue
Elect2026_Bottom_민방\1위_광역단체장 후보사진01 ImageValue
Elect2026_Bottom_민방\1위_기초단체장 후보사진01 ImageValue
Elect2026_Bottom_민방\당선_광역단체장 후보사진01 ImageValue
Elect2026_Bottom_민방\당선_광역의원 후보사진01 ImageValue
Elect2026_Bottom_민방\당선_기초단체장 후보사진01 ImageValue
Elect2026_Bottom_민방\당선_기초의원 후보사진01 ImageValue
Elect2026_Bottom_민방\사전투표율 투표율01 Counter
Elect2026_Bottom_민방\전후보_광역단체장 후보사진01 ImageValue
Elect2026_Bottom_민방\전후보_교육감 후보사진01 ImageValue
Elect2026_Bottom_민방\전후보_기초단체장 후보사진01 ImageValue
Elect2026_Bottom_민방\투표율 투표율01 Counter
Elect2026_Normal_민방\1-2위_ani_광역단체장 후보사진01 ImageValue
Elect2026_Normal_민방\1-2위_ani_기초단체장 후보명01 TextValue
Elect2026_Normal_민방\1-2위_광역단체장 후보사진01 ImageValue
Elect2026_Normal_민방\1-2위_광역단체장_시도별영상 후보명01 TextValue
Elect2026_Normal_민방\1-2위_교육감 후보사진01 ImageValue
Elect2026_Normal_민방\1-2위_기초단체장 후보명01 TextValue
Elect2026_Normal_민방\1-2위_기초단체장_시도별영상 후보명01 TextValue
Elect2026_Normal_민방\1-2위_보궐선거 후보명01 TextValue
Elect2026_Normal_민방\1-3위_ani_광역단체장 후보명01 TextValue
Elect2026_Normal_민방\1-3위_ani_기초단체장 후보사진01 ImageValue
Elect2026_Normal_민방\1-3위_보궐선거 후보명01 TextValue
Elect2026_Normal_민방\경력_광역단체장_in 후보명01 TextValue
Elect2026_Normal_민방\경력_기초단체장_in 후보사진01 ImageValue
Elect2026_Normal_민방\광역의원표_HD 개표율01 TextValue
Elect2026_Normal_민방\기초의원표_HD 개표율01 TextValue
Elect2026_Normal_민방\당선_광역단체장_HD 후보사진01 ImageValue
Elect2026_Normal_민방\당선_광역의원_HD 후보사진01 ImageValue
Elect2026_Normal_민방\당선_교육감_HD 후보사진01 ImageValue
Elect2026_Normal_민방\당선_기초단체장_HD 후보사진01 ImageValue
Elect2026_Normal_민방\당선_기초의원_HD 후보사진01 ImageValue
Elect2026_Normal_민방\모든후보_광역단체장 후보사진01 ImageValue
Elect2026_Normal_민방\모든후보_교육감 후보사진01 ImageValue
Elect2026_Normal_민방\모든후보_기초단체장 후보사진01 ImageValue
Elect2026_Normal_민방\민방_타이틀_1920 후보사진01 ImageValue
Elect2026_Normal_민방\민방_타이틀_1920_notext 후보사진02 ImageValue
Elect2026_Normal_민방\사전_역대당선자 후보사진07 ImageValue
Elect2026_Normal_민방\사전_역대당선자_교육감 후보사진05 ImageValue
Elect2026_Normal_민방\사전_역대당선자_기초단체장 후보사진05 ImageValue
Elect2026_Normal_민방\역대시도판세_광역단체장 득표율02 Counter
Elect2026_Normal_민방\역대시도판세_기초단체장 후보명02 TextValue
Elect2026_Normal_민방\이시각1위_광역단체장_HD 후보사진01 ImageValue
Elect2026_Normal_민방\이시각1위_기초단체장_HD 후보사진01 ImageValue
Elect2026_Normal_민방\접전_광역단체장 후보사진01 ImageValue
Elect2026_Normal_민방\접전_기초단체장 후보사진01 ImageValue
Elect2026_Normal_민방\초접전_광역단체장 후보사진01 ImageValue
Elect2026_Normal_민방\초접전_기초단체장 후보사진01 ImageValue
Elect2026_Normal_민방\투표율_사진 투표율01 Counter
Elect2026_Normal_민방\투표율_선거구별 사전 투표율01 Counter
Elect2026_Normal_민방\투표율_영상 투표율01 Counter
Elect2026_Normal_민방\판세_광역단체장 개표율01 TextValue
Elect2026_Normal_민방\판세_기초단체장 득표율01 Counter
Elect2026_Top_민방\광역단체장_2인 후보사진01 ImageValue
Elect2026_Top_민방\광역단체장_2인_텍스트 후보명01 TextValue
Elect2026_Top_민방\기초단체장_2인 후보사진01 ImageValue
Elect2026_Top_민방\기초단체장_2인_텍스트 득표율01 Counter
Elect2026_Top_민방\투표율 전국투표율01 Counter
Elect2026_Top_민방\투표율_선거구별 투표율01 Counter
Elect2026_Top_민방\판세_광역단체장 정당명01 TextValue
Elect2026_Top_민방\판세_광역의원 정당명01 TextValue
Elect2026_Top_민방\판세_교육감 정당명01 TextValue
Elect2026_Top_민방\판세_기초단체장 정당명01 TextValue
Elect2026_Top_민방\판세_기초의원 정당명01 TextValue
1 TemplateId Key Kind
2 Elect2026_Bottom_민방\1-2위_광역단체장 후보사진01 ImageValue
3 Elect2026_Bottom_민방\1-2위_기초단체장 후보사진01 ImageValue
4 Elect2026_Bottom_민방\1-3위_광역단체장 후보사진01 ImageValue
5 Elect2026_Bottom_민방\1-3위_기초단체장 후보사진01 ImageValue
6 Elect2026_Bottom_민방\1위_광역단체장 후보사진01 ImageValue
7 Elect2026_Bottom_민방\1위_기초단체장 후보사진01 ImageValue
8 Elect2026_Bottom_민방\당선_광역단체장 후보사진01 ImageValue
9 Elect2026_Bottom_민방\당선_광역의원 후보사진01 ImageValue
10 Elect2026_Bottom_민방\당선_기초단체장 후보사진01 ImageValue
11 Elect2026_Bottom_민방\당선_기초의원 후보사진01 ImageValue
12 Elect2026_Bottom_민방\사전투표율 투표율01 Counter
13 Elect2026_Bottom_민방\전후보_광역단체장 후보사진01 ImageValue
14 Elect2026_Bottom_민방\전후보_교육감 후보사진01 ImageValue
15 Elect2026_Bottom_민방\전후보_기초단체장 후보사진01 ImageValue
16 Elect2026_Bottom_민방\투표율 투표율01 Counter
17 Elect2026_Normal_민방\1-2위_ani_광역단체장 후보사진01 ImageValue
18 Elect2026_Normal_민방\1-2위_ani_기초단체장 후보명01 TextValue
19 Elect2026_Normal_민방\1-2위_광역단체장 후보사진01 ImageValue
20 Elect2026_Normal_민방\1-2위_광역단체장_시도별영상 후보명01 TextValue
21 Elect2026_Normal_민방\1-2위_교육감 후보사진01 ImageValue
22 Elect2026_Normal_민방\1-2위_기초단체장 후보명01 TextValue
23 Elect2026_Normal_민방\1-2위_기초단체장_시도별영상 후보명01 TextValue
24 Elect2026_Normal_민방\1-2위_보궐선거 후보명01 TextValue
25 Elect2026_Normal_민방\1-3위_ani_광역단체장 후보명01 TextValue
26 Elect2026_Normal_민방\1-3위_ani_기초단체장 후보사진01 ImageValue
27 Elect2026_Normal_민방\1-3위_보궐선거 후보명01 TextValue
28 Elect2026_Normal_민방\경력_광역단체장_in 후보명01 TextValue
29 Elect2026_Normal_민방\경력_기초단체장_in 후보사진01 ImageValue
30 Elect2026_Normal_민방\광역의원표_HD 개표율01 TextValue
31 Elect2026_Normal_민방\기초의원표_HD 개표율01 TextValue
32 Elect2026_Normal_민방\당선_광역단체장_HD 후보사진01 ImageValue
33 Elect2026_Normal_민방\당선_광역의원_HD 후보사진01 ImageValue
34 Elect2026_Normal_민방\당선_교육감_HD 후보사진01 ImageValue
35 Elect2026_Normal_민방\당선_기초단체장_HD 후보사진01 ImageValue
36 Elect2026_Normal_민방\당선_기초의원_HD 후보사진01 ImageValue
37 Elect2026_Normal_민방\모든후보_광역단체장 후보사진01 ImageValue
38 Elect2026_Normal_민방\모든후보_교육감 후보사진01 ImageValue
39 Elect2026_Normal_민방\모든후보_기초단체장 후보사진01 ImageValue
40 Elect2026_Normal_민방\민방_타이틀_1920 후보사진01 ImageValue
41 Elect2026_Normal_민방\민방_타이틀_1920_notext 후보사진02 ImageValue
42 Elect2026_Normal_민방\사전_역대당선자 후보사진07 ImageValue
43 Elect2026_Normal_민방\사전_역대당선자_교육감 후보사진05 ImageValue
44 Elect2026_Normal_민방\사전_역대당선자_기초단체장 후보사진05 ImageValue
45 Elect2026_Normal_민방\역대시도판세_광역단체장 득표율02 Counter
46 Elect2026_Normal_민방\역대시도판세_기초단체장 후보명02 TextValue
47 Elect2026_Normal_민방\이시각1위_광역단체장_HD 후보사진01 ImageValue
48 Elect2026_Normal_민방\이시각1위_기초단체장_HD 후보사진01 ImageValue
49 Elect2026_Normal_민방\접전_광역단체장 후보사진01 ImageValue
50 Elect2026_Normal_민방\접전_기초단체장 후보사진01 ImageValue
51 Elect2026_Normal_민방\초접전_광역단체장 후보사진01 ImageValue
52 Elect2026_Normal_민방\초접전_기초단체장 후보사진01 ImageValue
53 Elect2026_Normal_민방\투표율_사진 투표율01 Counter
54 Elect2026_Normal_민방\투표율_선거구별 사전 투표율01 Counter
55 Elect2026_Normal_민방\투표율_영상 투표율01 Counter
56 Elect2026_Normal_민방\판세_광역단체장 개표율01 TextValue
57 Elect2026_Normal_민방\판세_기초단체장 득표율01 Counter
58 Elect2026_Top_민방\광역단체장_2인 후보사진01 ImageValue
59 Elect2026_Top_민방\광역단체장_2인_텍스트 후보명01 TextValue
60 Elect2026_Top_민방\기초단체장_2인 후보사진01 ImageValue
61 Elect2026_Top_민방\기초단체장_2인_텍스트 득표율01 Counter
62 Elect2026_Top_민방\투표율 전국투표율01 Counter
63 Elect2026_Top_민방\투표율_선거구별 투표율01 Counter
64 Elect2026_Top_민방\판세_광역단체장 정당명01 TextValue
65 Elect2026_Top_민방\판세_광역의원 정당명01 TextValue
66 Elect2026_Top_민방\판세_교육감 정당명01 TextValue
67 Elect2026_Top_민방\판세_기초단체장 정당명01 TextValue
68 Elect2026_Top_민방\판세_기초의원 정당명01 TextValue