중간 저장
20
.agents/plugins/marketplace.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 242 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 271 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 153 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 241 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 315 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 311 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 273 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 222 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 233 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 253 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 312 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 79 KiB |
@@ -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}"
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
261
Tornado3_2026Election/Domain/CutDebugItemState.cs
Normal 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 =
|
||||||
|
[
|
||||||
|
"선거구명",
|
||||||
|
"시도명",
|
||||||
|
"개표율",
|
||||||
|
"투표율",
|
||||||
|
"전국투표율",
|
||||||
|
"기준시",
|
||||||
|
"유권자수",
|
||||||
|
"투표자수"
|
||||||
|
];
|
||||||
|
}
|
||||||
40
Tornado3_2026Election/Domain/CutDebugOverride.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
155
Tornado3_2026Election/Domain/CutDebugSettings.cs
Normal 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);
|
||||||
10
Tornado3_2026Election/Domain/CutListElectionCategory.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
namespace Tornado3_2026Election.Domain;
|
||||||
|
|
||||||
|
public enum CutListElectionCategory
|
||||||
|
{
|
||||||
|
MetropolitanHead,
|
||||||
|
MetropolitanCouncil,
|
||||||
|
Superintendent,
|
||||||
|
LocalHead,
|
||||||
|
LocalCouncil
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
8
Tornado3_2026Election/Domain/VideoWallLayoutPreset.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Tornado3_2026Election.Domain;
|
||||||
|
|
||||||
|
public enum VideoWallLayoutPreset
|
||||||
|
{
|
||||||
|
Auto,
|
||||||
|
Standard5760x1080,
|
||||||
|
UltraWide11520x1080
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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; } = [];
|
||||||
}
|
}
|
||||||
|
|||||||
33
Tornado3_2026Election/Services/CutAppearancePolicyCatalog.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
Tornado3_2026Election/Services/CutDebugStateStore.cs
Normal 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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
137
Tornado3_2026Election/Services/ThumbnailLayoutResolver.cs
Normal 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
29
plugins/cut-design-debugger/.codex-plugin/plugin.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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."
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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"
|
||||||
96
tools/KarismaTcpProbe/CutDebugRecommendationCatalog.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" />
|
||||||
|
|||||||
68
tools/KarismaTcpProbe/cut-debug-recommendations.tsv
Normal 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
|
||||||
|