5.14 시작전

This commit is contained in:
2026-05-14 09:38:45 +09:00
parent 8b5c92194f
commit e76c37ef56
24 changed files with 3638 additions and 717 deletions

View File

@@ -326,15 +326,9 @@
Text="초" />
</StackPanel>
<ToggleSwitch
Grid.Row="1"
Grid.Column="0"
Header="반복"
IsOn="{x:Bind ViewModel.LoopEnabled, Mode=TwoWay}" />
<ComboBox
Grid.Row="1"
Grid.Column="1"
Grid.Column="0"
Width="150"
Header="빈 스케줄"
DisplayMemberPath="Label"
@@ -343,7 +337,7 @@
<Button
Grid.Row="1"
Grid.Column="2"
Grid.Column="1"
Width="22"
Height="22"
MinWidth="22"
@@ -364,18 +358,34 @@
</Button>
</Grid>
<StackPanel
Orientation="Horizontal"
Spacing="10">
<Button
Command="{x:Bind ViewModel.DirectStartCommand}"
Content="시작"
Style="{StaticResource ConsolePrimaryButtonStyle}" />
<Button
Command="{x:Bind ViewModel.DirectStopCommand}"
Content="정지"
Style="{StaticResource ConsoleGhostButtonStyle}" />
</StackPanel>
<Border
Padding="12"
Background="#101C2E"
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
BorderThickness="1"
CornerRadius="8">
<StackPanel Spacing="10">
<TextBlock
Style="{StaticResource ConsoleLabelTextStyle}"
Text="선택컷 송출 제어" />
<StackPanel
Orientation="Horizontal"
Spacing="10">
<Button
Command="{x:Bind ViewModel.DirectPrepareCommand}"
Content="준비"
Style="{StaticResource ConsoleGhostButtonStyle}" />
<Button
Command="{x:Bind ViewModel.DirectStartCommand}"
Content="시작"
Style="{StaticResource ConsolePrimaryButtonStyle}" />
<Button
Command="{x:Bind ViewModel.DirectStopCommand}"
Content="정지"
Style="{StaticResource ConsoleGhostButtonStyle}" />
</StackPanel>
</StackPanel>
</Border>
<Border
Padding="12"
@@ -770,6 +780,13 @@
HorizontalAlignment="Right"
Orientation="Horizontal"
Spacing="8">
<ToggleSwitch
Header="반복"
IsOn="{x:Bind ViewModel.LoopEnabled, Mode=TwoWay}" />
<Button
Command="{x:Bind ViewModel.SchedulePrepareCommand}"
Content="준비"
Style="{StaticResource PanelCommandButtonStyle}" />
<Button
Command="{x:Bind ViewModel.StartCommand}"
Content="스케줄 시작"

View File

@@ -230,7 +230,9 @@ public sealed class ChannelScheduleItem : ObservableObject
[JsonIgnore]
public string PreviewStatusLabel => HasRenderedPreview
? _renderedPreviewStatusLabel
: "실데이터 프리뷰 준비 중";
: string.IsNullOrWhiteSpace(_renderedPreviewStatusLabel)
? "실데이터 프리뷰 준비 중"
: _renderedPreviewStatusLabel;
[JsonIgnore]
public ImageSource? InternalNextPreviewSource => _internalNextPreviewSource;
@@ -281,6 +283,12 @@ public sealed class ChannelScheduleItem : ObservableObject
OnPreviewChanged();
}
public void UpdateRenderedPreviewStatus(string statusLabel)
{
_renderedPreviewStatusLabel = statusLabel;
OnPreviewChanged();
}
public void UpdateInternalNextPreview(string previewPath, string displayName, string statusLabel)
{
_internalNextPreviewPath = previewPath;

View File

@@ -74,4 +74,5 @@ public sealed record TurnoutBoardSlotEntry(
string Label,
double TurnoutRate,
bool IsNational = false,
string RegionLabel = "");
string RegionLabel = "",
bool HasTurnoutData = true);

View File

@@ -49,9 +49,9 @@
<NavigationViewItem Content="하단" Tag="bottom" Visibility="{x:Bind ViewModel.BottomMenuVisibility, Mode=OneWay}"><NavigationViewItem.Icon><SymbolIcon Foreground="{x:Bind ViewModel.BottomChannel.PlaybackIconBrush, Mode=OneWay}" Symbol="Download" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="비디오월" Tag="videowall" Visibility="{x:Bind ViewModel.VideoWallMenuVisibility, Mode=OneWay}"><NavigationViewItem.Icon><SymbolIcon Foreground="{x:Bind ViewModel.VideoWallChannel.PlaybackIconBrush, Mode=OneWay}" Symbol="Video" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="사전데이터" Tag="pre-election-data"><NavigationViewItem.Icon><SymbolIcon Symbol="Library" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="투표데이터" Tag="turnout-data"><NavigationViewItem.Icon><SymbolIcon Foreground="{x:Bind ViewModel.DataNavigationIconBrush, Mode=OneWay}" Symbol="Edit" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="개표데이터" Tag="counting-data"><NavigationViewItem.Icon><SymbolIcon Foreground="{x:Bind ViewModel.DataNavigationIconBrush, Mode=OneWay}" Symbol="Edit" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="공약데이터" Tag="career-promises"><NavigationViewItem.Icon><SymbolIcon Symbol="Contact" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="투표데이터" Tag="turnout-data"><NavigationViewItem.Icon><PathIcon Foreground="{x:Bind ViewModel.DataNavigationIconBrush, Mode=OneWay}" Data="M4,2 H13 V3 H4 Z M4,2 H5 V22 H4 Z M4,21 H18 V22 H4 Z M17,7 H18 V22 H17 Z M13,2 L18,7 H13 Z M6,8 H8 V10 H6 Z M10,8 H15 V9 H10 Z M6,12 H8 V14 H6 Z M10,12 H15 V13 H10 Z M6,16 H8 V18 H6 Z M10,16 H14 V17 H10 Z" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="개표데이터" Tag="counting-data"><NavigationViewItem.Icon><PathIcon Foreground="{x:Bind ViewModel.DataNavigationIconBrush, Mode=OneWay}" Data="M10,2 H15 V8 H10 Z M11,4 H14 V5 H11 Z M4,9 H20 V11 H4 Z M6,7 L10,5 L11,6 L7,8 Z M18,7 L14,5 L13,6 L17,8 Z M5,12 H19 L17,21 H7 Z M7,14 L8,19 H16 L17,14 Z" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="컷리스트" Tag="cut-list"><NavigationViewItem.Icon><SymbolIcon Symbol="Bullets" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="설정" Tag="settings"><NavigationViewItem.Icon><SymbolIcon Symbol="Setting" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="로그" Tag="log"><NavigationViewItem.Icon><SymbolIcon Symbol="Document" /></NavigationViewItem.Icon></NavigationViewItem>

View File

@@ -13,6 +13,7 @@ namespace Tornado3_2026Election.Services;
public sealed class ChannelScheduleEngine
{
private const int PreviewFrame = -1;
private static readonly TimeSpan MinimumNextPreviewWindow = TimeSpan.FromSeconds(2.5);
private readonly ITornado3Adapter _adapter;
private readonly IDataRefreshGate _dataRefreshGate;
private readonly Func<BroadcastStationProfile> _stationProvider;
@@ -26,6 +27,7 @@ public sealed class ChannelScheduleEngine
private Guid? _preferredNextItemId;
private Guid? _skipCurrentItemId;
private ChannelScheduleItem? _directPlaybackItem;
private PreparedCutFrame? _preparedCutFrame;
public ChannelScheduleEngine(
BroadcastChannel channel,
@@ -66,6 +68,11 @@ public sealed class ChannelScheduleEngine
public event EventHandler? QueueChanged;
public bool IsPreparedItem(ChannelScheduleItem item)
{
return _preparedCutFrame?.Item.Id == item.Id;
}
public async Task StartAsync()
{
if (IsRunning)
@@ -74,6 +81,11 @@ public sealed class ChannelScheduleEngine
return;
}
if (_preparedCutFrame is { Item: var preparedItem } && !Queue.Contains(preparedItem))
{
ClearPreparedFrame(resetState: true);
}
_playbackCts = new CancellationTokenSource();
IsRunning = true;
RefreshQueueMarkers();
@@ -85,6 +97,21 @@ public sealed class ChannelScheduleEngine
{
if (!IsRunning)
{
if (_preparedCutFrame is null)
{
return;
}
if (takeOutputOff)
{
await _adapter.OutAsync(Channel, CancellationToken.None).ConfigureAwait(false);
}
ClearPreparedFrame(resetState: true);
_preferredNextItemId = null;
_skipCurrentItemId = null;
RefreshQueueMarkers();
QueueChanged?.Invoke(this, EventArgs.Empty);
return;
}
@@ -105,11 +132,97 @@ public sealed class ChannelScheduleEngine
_preferredNextItemId = null;
_skipCurrentItemId = null;
ClearPreparedFrame(resetState: false);
IsRunning = false;
RefreshQueueMarkers();
QueueChanged?.Invoke(this, EventArgs.Empty);
}
public async Task PrepareNextAsync(CancellationToken cancellationToken)
{
if (IsRunning)
{
return;
}
await _executionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
ClearPreparedFrame(resetState: true);
RefreshQueueMarkers();
var next = GetNextPlayableItem();
if (next is null)
{
_logService.Warning($"[{Channel}] 준비할 스케줄 컷이 없습니다.");
return;
}
var template = _templateResolver(next.FormatId);
if (template is null)
{
next.State = ScheduleQueueItemState.Error;
next.LastError = "포맷을 찾을 수 없습니다.";
_logService.Error($"[{Channel}] Missing template: {next.FormatId}");
RefreshQueueMarkers();
return;
}
await PrepareFirstCutAsync(next, template, cancellationToken).ConfigureAwait(false);
}
finally
{
_executionLock.Release();
QueueChanged?.Invoke(this, EventArgs.Empty);
}
}
public async Task PrepareDirectAsync(
ChannelScheduleItem item,
FormatTemplateDefinition template,
CancellationToken cancellationToken)
{
await _executionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
ClearPreparedFrame(resetState: true);
_directPlaybackItem = item;
QueueChanged?.Invoke(this, EventArgs.Empty);
await PrepareFirstCutAsync(item, template, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
if (_directPlaybackItem == item)
{
_directPlaybackItem = null;
}
item.State = ScheduleQueueItemState.Queued;
item.CurrentRegionLabel = string.Empty;
throw;
}
finally
{
_executionLock.Release();
QueueChanged?.Invoke(this, EventArgs.Empty);
}
}
public void ClearDirectPlayback()
{
ClearPreparedFrame(resetState: true);
if (_directPlaybackItem is not null)
{
_directPlaybackItem.State = ScheduleQueueItemState.Queued;
_directPlaybackItem.CurrentRegionLabel = string.Empty;
_directPlaybackItem.ClearRenderedPreview();
_directPlaybackItem.ClearInternalNextPreview();
_directPlaybackItem = null;
}
QueueChanged?.Invoke(this, EventArgs.Empty);
}
public async Task PlayDirectAsync(
ChannelScheduleItem item,
FormatTemplateDefinition template,
@@ -139,6 +252,7 @@ public sealed class ChannelScheduleEngine
public void Reset()
{
_preferredNextItemId = null;
ClearPreparedFrame(resetState: false);
foreach (var item in Queue)
{
item.State = ScheduleQueueItemState.Queued;
@@ -311,12 +425,94 @@ public sealed class ChannelScheduleEngine
IsRunning = false;
RefreshQueueMarkers();
}
catch (Exception ex)
{
IsRunning = false;
var sendingItem = Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Sending);
if (sendingItem is not null)
{
sendingItem.State = ScheduleQueueItemState.Error;
sendingItem.LastError = ex.Message;
sendingItem.CurrentRegionLabel = string.Empty;
sendingItem.ClearInternalNextPreview();
ClearSkipCurrentItem(sendingItem);
}
_logService.Error($"[{Channel}] Schedule playback stopped: {ex.Message}");
RefreshQueueMarkers();
QueueChanged?.Invoke(this, EventArgs.Empty);
}
finally
{
_executionLock.Release();
}
}
private async Task PrepareFirstCutAsync(
ChannelScheduleItem queueItem,
FormatTemplateDefinition template,
CancellationToken cancellationToken)
{
queueItem.State = ScheduleQueueItemState.Sending;
queueItem.LastError = string.Empty;
queueItem.ClearInternalNextPreview();
RefreshQueueMarkers();
QueueChanged?.Invoke(this, EventArgs.Empty);
var station = _stationProvider();
var imageRootPath = _imageRootProvider();
CutPreviewFrame? previewFrame;
try
{
previewFrame = await TryBuildPreviewFrameAsync(queueItem, template, station, imageRootPath, cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
queueItem.State = ScheduleQueueItemState.Error;
queueItem.LastError = ex.Message;
queueItem.CurrentRegionLabel = string.Empty;
RefreshQueueMarkers();
_logService.Warning($"[{Channel}] 준비 컷 데이터 구성 실패: {queueItem.DisplayName} / {ex.Message}");
return;
}
if (previewFrame is null)
{
queueItem.State = ScheduleQueueItemState.Error;
queueItem.LastError = "송출 가능한 지역 데이터가 없습니다.";
queueItem.CurrentRegionLabel = string.Empty;
RefreshQueueMarkers();
_logService.Warning($"[{Channel}] 준비할 수 있는 컷 데이터가 없습니다: {queueItem.DisplayName}");
return;
}
queueItem.CurrentRegionLabel = previewFrame.RegionLabel;
await _adapter.EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
await _adapter
.ApplyCutAsync(Channel, template, previewFrame.Cut, previewFrame.Snapshot, station, imageRootPath, cancellationToken)
.ConfigureAwait(false);
await _adapter.PrepareAsync(Channel, cancellationToken).ConfigureAwait(false);
_preparedCutFrame = new PreparedCutFrame(
queueItem,
template,
previewFrame.Cut,
previewFrame.Snapshot,
station,
imageRootPath,
previewFrame.RegionLabel);
await CaptureCurrentPreviewAsync(queueItem, template, cancellationToken).ConfigureAwait(false);
RefreshQueueMarkers();
}
private async Task PlayItemAsync(ChannelScheduleItem queueItem, FormatTemplateDefinition template, CancellationToken cancellationToken)
{
var station = _stationProvider();
@@ -521,9 +717,22 @@ public sealed class ChannelScheduleEngine
RefreshQueueMarkers();
QueueChanged?.Invoke(this, EventArgs.Empty);
await _adapter.EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
await _adapter.ApplyCutAsync(Channel, template, cut, snapshot, station, imageRootPath, cancellationToken).ConfigureAwait(false);
await _adapter.PrepareAsync(Channel, cancellationToken).ConfigureAwait(false);
var preparedFrame = TryConsumePreparedFrame(queueItem, template, cut);
var playbackCut = preparedFrame?.Cut ?? cut;
var playbackSnapshot = preparedFrame?.Snapshot ?? snapshot;
var playbackStation = preparedFrame?.Station ?? station;
var playbackImageRootPath = preparedFrame?.ImageRootPath ?? imageRootPath;
if (preparedFrame is not null)
{
queueItem.CurrentRegionLabel = preparedFrame.RegionLabel;
}
else
{
await _adapter.EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
await _adapter.ApplyCutAsync(Channel, template, cut, snapshot, station, imageRootPath, cancellationToken).ConfigureAwait(false);
await _adapter.PrepareAsync(Channel, cancellationToken).ConfigureAwait(false);
}
await _adapter.TakeAsync(Channel, cancellationToken).ConfigureAwait(false);
var onAirAt = DateTimeOffset.Now;
@@ -539,36 +748,39 @@ public sealed class ChannelScheduleEngine
signal.TrySetResult(true);
}
await CaptureCurrentPreviewAsync(
queueItem,
template,
cut,
snapshot,
station,
imageRootPath,
cancellationToken).ConfigureAwait(false);
var durationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(cut.DurationSeconds, template);
var playbackDuration = TimeSpan.FromSeconds(durationSeconds);
await CaptureCurrentPreviewAsync(queueItem, template, cancellationToken).ConfigureAwait(false);
QueueChanged?.Invoke(this, EventArgs.Empty);
if (!ShouldSkipCurrentItem(queueItem))
{
CutPreviewFrame? nextInternalPreviewFrame = null;
if (nextInternalPreviewFrameFactory is not null)
var remainingForPreview = playbackDuration - (DateTimeOffset.Now - onAirAt);
if (remainingForPreview > MinimumNextPreviewWindow)
{
try
CutPreviewFrame? nextInternalPreviewFrame = null;
if (nextInternalPreviewFrameFactory is not null)
{
nextInternalPreviewFrame = await nextInternalPreviewFrameFactory(cancellationToken).ConfigureAwait(false);
try
{
nextInternalPreviewFrame = await nextInternalPreviewFrameFactory(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logService.Warning($"[{Channel}] 다음 지역 프리뷰 데이터 준비 실패: {queueItem.DisplayName} / {ex.Message}");
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logService.Warning($"[{Channel}] 다음 지역 프리뷰 데이터 준비 실패: {queueItem.DisplayName} / {ex.Message}");
}
}
await CaptureNextPreviewAsync(queueItem, template, nextInternalPreviewFrame, station, imageRootPath, cancellationToken).ConfigureAwait(false);
QueueChanged?.Invoke(this, EventArgs.Empty);
await CaptureNextPreviewAsync(queueItem, template, nextInternalPreviewFrame, station, imageRootPath, cancellationToken).ConfigureAwait(false);
QueueChanged?.Invoke(this, EventArgs.Empty);
}
else
{
MarkQueueNextPreviewStatus(queueItem, $"빠른 송출 중 프리뷰 생략 {DateTimeOffset.Now:HH:mm:ss}");
}
}
if (ShouldSkipCurrentItem(queueItem))
@@ -576,8 +788,7 @@ public sealed class ChannelScheduleEngine
return;
}
var durationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(cut.DurationSeconds, template);
var remainingDuration = TimeSpan.FromSeconds(durationSeconds) - (DateTimeOffset.Now - onAirAt);
var remainingDuration = playbackDuration - (DateTimeOffset.Now - onAirAt);
if (remainingDuration <= TimeSpan.Zero)
{
return;
@@ -590,10 +801,6 @@ public sealed class ChannelScheduleEngine
private async Task CaptureCurrentPreviewAsync(
ChannelScheduleItem queueItem,
FormatTemplateDefinition template,
FormatCutDefinition cut,
ElectionDataSnapshot snapshot,
BroadcastStationProfile station,
string imageRootPath,
CancellationToken cancellationToken)
{
if (!_adapter.IsLiveCg)
@@ -603,13 +810,8 @@ public sealed class ChannelScheduleEngine
var size = ThumbnailLayoutResolver.ResolveGenerationSize(template, _videoWallLayoutPresetProvider());
var previewPath = CutPreviewAssetCatalog.CreateCapturePath(Channel, queueItem.Id, "current");
var captured = await _adapter.TryCaptureCutPreviewAsync(
var captured = await _adapter.TryCapturePendingCutPreviewAsync(
Channel,
template,
cut,
snapshot,
station,
imageRootPath,
previewPath,
size.Width,
size.Height,
@@ -618,6 +820,8 @@ public sealed class ChannelScheduleEngine
if (!captured)
{
await UiDispatcher.EnqueueAsync(() =>
queueItem.UpdateRenderedPreviewStatus($"현재 화면 캡처 지연 {DateTimeOffset.Now:HH:mm:ss}")).ConfigureAwait(false);
return;
}
@@ -701,6 +905,8 @@ public sealed class ChannelScheduleEngine
if (!captured)
{
await UiDispatcher.EnqueueAsync(() =>
nextItem.UpdateRenderedPreviewStatus($"다음 프리뷰 캡처 지연 {DateTimeOffset.Now:HH:mm:ss}")).ConfigureAwait(false);
return;
}
@@ -708,6 +914,17 @@ public sealed class ChannelScheduleEngine
nextItem.UpdateRenderedPreview(previewPath, $"다음 변수 적용 캡처 {DateTimeOffset.Now:HH:mm:ss}")).ConfigureAwait(false);
}
private void MarkQueueNextPreviewStatus(ChannelScheduleItem activeItem, string statusLabel)
{
var nextItem = GetPreviewNextItem(activeItem);
if (nextItem is null)
{
return;
}
UiDispatcher.Enqueue(() => nextItem.UpdateRenderedPreviewStatus(statusLabel));
}
private async Task CaptureInternalNextPreviewAsync(
ChannelScheduleItem activeItem,
FormatTemplateDefinition template,
@@ -904,6 +1121,59 @@ public sealed class ChannelScheduleEngine
return _skipCurrentItemId == queueItem.Id;
}
private PreparedCutFrame? TryConsumePreparedFrame(
ChannelScheduleItem queueItem,
FormatTemplateDefinition template,
FormatCutDefinition cut)
{
if (_preparedCutFrame is not { } preparedFrame)
{
return null;
}
if (preparedFrame.Item.Id != queueItem.Id ||
!string.Equals(preparedFrame.Template.Id, template.Id, StringComparison.Ordinal) ||
!CutsMatch(preparedFrame.Cut, cut))
{
if (preparedFrame.Item.Id == queueItem.Id)
{
ClearPreparedFrame(resetState: false);
}
return null;
}
_preparedCutFrame = null;
return preparedFrame;
}
private static bool CutsMatch(FormatCutDefinition left, FormatCutDefinition right)
{
return string.Equals(left.Name, right.Name, StringComparison.Ordinal) &&
left.CandidateStartIndex == right.CandidateStartIndex &&
left.UseEndScene == right.UseEndScene &&
string.Equals(left.SceneIdOverride, right.SceneIdOverride, StringComparison.Ordinal);
}
private void ClearPreparedFrame(bool resetState)
{
if (_preparedCutFrame is not { } preparedFrame)
{
return;
}
_preparedCutFrame = null;
if (!resetState || preparedFrame.Item.State != ScheduleQueueItemState.Sending)
{
return;
}
preparedFrame.Item.State = ScheduleQueueItemState.Queued;
preparedFrame.Item.CurrentRegionLabel = string.Empty;
preparedFrame.Item.ClearRenderedPreview();
preparedFrame.Item.ClearInternalNextPreview();
}
private void ClearSkipCurrentItem(ChannelScheduleItem queueItem)
{
if (_skipCurrentItemId == queueItem.Id)
@@ -944,8 +1214,7 @@ public sealed class ChannelScheduleEngine
return IsBottomTurnoutBoardTemplate(template);
}
return (template.RecommendedChannel == BroadcastChannel.Normal &&
string.Equals(template.Name, "투표율_선거구별 사전", StringComparison.Ordinal)) ||
return IsNormalPreElectionTurnoutDistrictBoardTemplate(template) ||
IsTopTurnoutDistrictBoardTemplate(template);
}
@@ -976,6 +1245,14 @@ public sealed class ChannelScheduleEngine
.ToArray();
}
if (IsNormalPreElectionTurnoutDistrictBoardTemplate(template))
{
return regionTargets
.GroupBy(ResolveCouncilSeatTableRegionKey, StringComparer.OrdinalIgnoreCase)
.SelectMany(group => ChunkRegionTargets(group.ToArray(), 7))
.ToArray();
}
if (!IsBasicCouncilWinnerTemplate(template) &&
!ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template.Name))
{
@@ -1048,6 +1325,12 @@ public sealed class ChannelScheduleEngine
string.Equals(template.Name, "투표율_선거구별", StringComparison.Ordinal);
}
private static bool IsNormalPreElectionTurnoutDistrictBoardTemplate(FormatTemplateDefinition template)
{
return template.RecommendedChannel == BroadcastChannel.Normal &&
string.Equals(template.Name, "투표율_선거구별 사전", StringComparison.Ordinal);
}
private static bool IsBottomTurnoutBoardTemplate(FormatTemplateDefinition template)
{
return template.RecommendedChannel == BroadcastChannel.Bottom &&
@@ -1327,6 +1610,11 @@ public sealed class ChannelScheduleEngine
private ChannelScheduleItem? GetNextPlayableItem()
{
if (_preparedCutFrame is { Item: var preparedItem } && Queue.Contains(preparedItem))
{
return preparedItem;
}
return Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Next)
?? Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Queued);
}
@@ -1361,5 +1649,14 @@ public sealed class ChannelScheduleEngine
}
}
private sealed record PreparedCutFrame(
ChannelScheduleItem Item,
FormatTemplateDefinition Template,
FormatCutDefinition Cut,
ElectionDataSnapshot Snapshot,
BroadcastStationProfile Station,
string ImageRootPath,
string RegionLabel);
private sealed record CutPreviewFrame(FormatCutDefinition Cut, ElectionDataSnapshot Snapshot, string RegionLabel);
}

View File

@@ -8,6 +8,7 @@ public static class CutCategoryResolver
{
private static readonly IReadOnlyList<CutCategory> OrderedCategories =
[
CutCategory.Title,
CutCategory.MetropolitanHead,
CutCategory.LocalHead,
CutCategory.Superintendent,
@@ -25,8 +26,7 @@ public static class CutCategoryResolver
CutCategory.BottomElectionDayTurnout,
CutCategory.PreElection,
CutCategory.Historical,
CutCategory.Turnout,
CutCategory.Title
CutCategory.Turnout
];
public static IReadOnlyList<CutCategory> GetOrderedCategories() => OrderedCategories;
@@ -37,7 +37,7 @@ public static class CutCategoryResolver
return category switch
{
CutCategory.MetropolitanHead => Contains(formatName, "광역단체장"),
CutCategory.MetropolitanHead => IsMetropolitanHeadFormat(formatName),
CutCategory.LocalHead => Contains(formatName, "기초단체장"),
CutCategory.Superintendent => Contains(formatName, "교육감"),
CutCategory.MetropolitanCouncil => Contains(formatName, "광역의원"),
@@ -94,6 +94,12 @@ public static class CutCategoryResolver
return value.Contains(token, StringComparison.Ordinal);
}
private static bool IsMetropolitanHeadFormat(string formatName)
{
return Contains(formatName, "광역단체장") ||
string.Equals(formatName, "사전_역대당선자", StringComparison.Ordinal);
}
private static bool IsBottomCountingTemplate(FormatTemplateDefinition template, string prefix)
{
return template.RecommendedChannel == BroadcastChannel.Bottom &&

View File

@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.IO;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Imaging;
@@ -70,18 +71,15 @@ public static class CutThumbnailAssetCatalog
public static bool HasThumbnail(string templateId)
{
if (HasThumbnailPath(templateId))
foreach (var candidateTemplateId in EnumerateThumbnailTemplateIds(templateId))
{
return true;
if (HasThumbnailPath(candidateTemplateId))
{
return true;
}
}
var fallbackTemplateId = ResolveThumbnailTemplateId(templateId);
if (string.Equals(templateId, fallbackTemplateId, StringComparison.Ordinal))
{
return false;
}
return HasThumbnailPath(fallbackTemplateId);
return false;
}
private static bool HasThumbnailPath(string templateId)
@@ -115,37 +113,64 @@ public static class CutThumbnailAssetCatalog
public static string ResolvePreferredDisplayPath(string templateId)
{
var projectPath = TryGetProjectAssetPath(templateId);
if (!string.IsNullOrWhiteSpace(projectPath) && File.Exists(projectPath))
foreach (var candidateTemplateId in EnumerateThumbnailTemplateIds(templateId))
{
return projectPath;
}
var bundledPath = GetBundledAssetPath(templateId);
if (File.Exists(bundledPath))
{
return bundledPath;
}
var fallbackTemplateId = ResolveThumbnailTemplateId(templateId);
if (!string.Equals(templateId, fallbackTemplateId, StringComparison.Ordinal))
{
var fallbackProjectPath = TryGetProjectAssetPath(fallbackTemplateId);
if (!string.IsNullOrWhiteSpace(fallbackProjectPath) && File.Exists(fallbackProjectPath))
var projectPath = TryGetProjectAssetPath(candidateTemplateId);
if (!string.IsNullOrWhiteSpace(projectPath) && File.Exists(projectPath))
{
return fallbackProjectPath;
return projectPath;
}
var fallbackBundledPath = GetBundledAssetPath(fallbackTemplateId);
if (File.Exists(fallbackBundledPath))
var bundledPath = GetBundledAssetPath(candidateTemplateId);
if (File.Exists(bundledPath))
{
return fallbackBundledPath;
return bundledPath;
}
}
return Path.Combine(AppContext.BaseDirectory, FallbackAssetPath);
}
private static IEnumerable<string> EnumerateThumbnailTemplateIds(string templateId)
{
var preferredTemplateId = ResolvePreferredThumbnailTemplateId(templateId);
if (!string.Equals(preferredTemplateId, templateId, StringComparison.Ordinal))
{
yield return preferredTemplateId;
yield return templateId;
yield break;
}
yield return templateId;
var fallbackTemplateId = ResolveThumbnailTemplateId(templateId);
if (!string.Equals(templateId, fallbackTemplateId, StringComparison.Ordinal))
{
yield return fallbackTemplateId;
}
}
private static string ResolvePreferredThumbnailTemplateId(string templateId)
{
if (string.IsNullOrWhiteSpace(templateId))
{
return templateId;
}
var normalizedId = templateId.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar);
var folder = Path.GetDirectoryName(normalizedId);
var fileName = Path.GetFileName(normalizedId);
if (folder is not null &&
string.Equals(folder, "Elect2026_Normal_민방", StringComparison.Ordinal) &&
string.Equals(fileName, "투표율", StringComparison.Ordinal))
{
return Path.Combine(folder, "투표율_사진");
}
return templateId;
}
private static string ResolveThumbnailTemplateId(string templateId)
{
if (string.IsNullOrWhiteSpace(templateId))

View File

@@ -86,6 +86,12 @@ public sealed class FormatCatalogService
"1-3위_ani_기초단체장",
"1-3위_기초단체장_5760",
"1-3위_보궐선거",
"2880_광역의원표",
"2880_기초의원표",
"810_광역의원표",
"810_기초의원표",
"8316_광역의원표",
"8316_기초의원표",
"경력_광역단체장_in",
"경력_기초단체장_in",
"광역의원표",
@@ -129,10 +135,8 @@ public sealed class FormatCatalogService
"접전_기초단체장",
"초접전_광역단체장",
"초접전_기초단체장",
"투표율_사진",
"투표율",
"투표율_선거구별 사전",
"투표율_시도별",
"투표율_영상",
"판세_광역단체장",
"판세_기초단체장",
"판세_기초단체장_5760"));
@@ -208,6 +212,12 @@ public sealed class FormatCatalogService
private static string? ResolveSceneIdOverride(string relativeFolder, string baseName)
{
if (string.Equals(relativeFolder, "Elect2026_Normal_민방", StringComparison.Ordinal) &&
string.Equals(baseName, "투표율", StringComparison.Ordinal))
{
return Path.Combine(relativeFolder, "투표율_사진");
}
return baseName switch
{
"사전투표율_시도" or "사전투표율_시군구" => Path.Combine(relativeFolder, "사전투표율"),
@@ -288,9 +298,12 @@ public sealed class FormatCatalogService
[Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_L")] = Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_5760"),
[Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_L_1")] = Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_5760"),
[Path.Combine("Elect2026_Normal_민방", "투표율_선거구별 사전_loop")] = Path.Combine("Elect2026_Normal_민방", "투표율_선거구별 사전"),
[Path.Combine("Elect2026_Normal_민방", "투표율_시도별_loop")] = Path.Combine("Elect2026_Normal_민방", "투표율_시도별"),
[Path.Combine("Elect2026_Normal_민방", "투표율_시도별_L")] = Path.Combine("Elect2026_Normal_민방", "투표율_시도별"),
[Path.Combine("Elect2026_Normal_민방", "투표율_시도별_L_loop")] = Path.Combine("Elect2026_Normal_민방", "투표율_시도별"),
[Path.Combine("Elect2026_Normal_민방", "투표율_사진")] = Path.Combine("Elect2026_Normal_민방", "투표율"),
[Path.Combine("Elect2026_Normal_민방", "투표율_시도별")] = Path.Combine("Elect2026_Normal_민방", "투표율"),
[Path.Combine("Elect2026_Normal_민방", "투표율_시도별_loop")] = Path.Combine("Elect2026_Normal_민방", "투표율"),
[Path.Combine("Elect2026_Normal_민방", "투표율_시도별_L")] = Path.Combine("Elect2026_Normal_민방", "투표율"),
[Path.Combine("Elect2026_Normal_민방", "투표율_시도별_L_loop")] = Path.Combine("Elect2026_Normal_민방", "투표율"),
[Path.Combine("Elect2026_Normal_민방", "투표율_영상")] = Path.Combine("Elect2026_Normal_민방", "투표율"),
[Path.Combine("Elect2026_Normal_민방", "판세_기초단체장_7680")] = Path.Combine("Elect2026_Normal_민방", "판세_기초단체장_5760"),
[Path.Combine("Elect2026_Top_민방", "투표율_loop")] = Path.Combine("Elect2026_Top_민방", "투표율"),
[Path.Combine("Elect2026_Top_민방", "투표율_선거구별_loop")] = Path.Combine("Elect2026_Top_민방", "투표율_선거구별")
@@ -299,7 +312,8 @@ public sealed class FormatCatalogService
private static bool IsAvailableInBothPhases(string baseName)
{
return baseName.StartsWith("사전_역대당선", StringComparison.Ordinal) ||
return ScheduleTemplatePolicy.IsTitleFormat(baseName) ||
baseName.StartsWith("사전_역대당선", StringComparison.Ordinal) ||
baseName.StartsWith("경력_", StringComparison.Ordinal);
}

View File

@@ -55,7 +55,11 @@ public interface ITornado3Adapter
Task PrepareAsync(BroadcastChannel channel, CancellationToken cancellationToken);
Task ShowPreparedFirstFrameAsync(BroadcastChannel channel, CancellationToken cancellationToken);
Task TakeAsync(BroadcastChannel channel, CancellationToken cancellationToken);
Task ClearOutputAsync(BroadcastChannel channel, CancellationToken cancellationToken);
Task OutAsync(BroadcastChannel channel, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,12 @@
using KAsyncEngineLib;
namespace Tornado3_2026Election.Services;
public readonly record struct KarismaCropKeyUpdate(
string ObjectName,
int KeyIndex,
float Left,
float Top,
float Right,
float Bottom,
eKCropKey CropKey);

View File

@@ -332,6 +332,7 @@ public class KarismaEventHandler : KAEventHandler
completion.TrySetException(error);
}
public void OnLoadScene(eKResult Result, string SceneName)
{
LogResult(nameof(OnLoadScene), Result, $"scene={SceneName}");
@@ -510,7 +511,7 @@ public class KarismaEventHandler : KAEventHandler
virtual public void OnSetCylinderAngleKey(eKResult Result, string SceneName, string ObjectName) { }
virtual public void OnSetSphereAngleKey(eKResult Result, string SceneName, string ObjectName) { }
virtual public void OnSetCircleAngleKey(eKResult Result, string SceneName, string ObjectName) { }
virtual public void OnSetCropKey(eKResult Result, string SceneName, string ObjectName) { }
public void OnSetCropKey(eKResult Result, string SceneName, string ObjectName) => LogResult(nameof(OnSetCropKey), Result, $"scene={SceneName} object={ObjectName}");
virtual public void OnSetCountDown(eKResult Result, string SceneName, string ObjectName) { }
virtual public void OnSetPosition(eKResult Result, string SceneName, string ObjectName) { }
virtual public void OnSetRotation(eKResult Result, string SceneName, string ObjectName) { }
@@ -674,6 +675,65 @@ public class KarismaEventHandler : KAEventHandler
completion?.TrySetResult(errorCode);
}
private static void CancelPendingSceneOperation(
object syncRoot,
Dictionary<string, TaskCompletionSource<eKResult>> pendingOperations,
string sceneName,
Exception? error)
{
if (string.IsNullOrWhiteSpace(sceneName))
{
return;
}
TaskCompletionSource<eKResult>? completion;
lock (syncRoot)
{
if (!pendingOperations.TryGetValue(sceneName, out completion))
{
return;
}
pendingOperations.Remove(sceneName);
}
CompleteOrCancel(completion, error);
}
private static void CompletePendingSceneOperation(
object syncRoot,
Dictionary<string, TaskCompletionSource<eKResult>> pendingOperations,
string sceneName,
eKResult result)
{
TaskCompletionSource<eKResult>? completion = null;
lock (syncRoot)
{
if (!string.IsNullOrWhiteSpace(sceneName) &&
pendingOperations.TryGetValue(sceneName, out completion))
{
pendingOperations.Remove(sceneName);
}
else if (pendingOperations.Count == 1)
{
string? keyToRemove = null;
foreach (var pair in pendingOperations)
{
keyToRemove = pair.Key;
completion = pair.Value;
break;
}
if (!string.IsNullOrWhiteSpace(keyToRemove))
{
pendingOperations.Remove(keyToRemove);
}
}
}
completion?.TrySetResult(result);
}
private static void CompleteOrCancel<TResult>(TaskCompletionSource<TResult>? completion, Exception? error)
{
if (completion is null)

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.ObjectModel;
using System.IO;
using Tornado3_2026Election.Domain;
namespace Tornado3_2026Election.Services;
@@ -7,6 +8,12 @@ namespace Tornado3_2026Election.Services;
public sealed class LogService
{
private const int MaxEntries = 400;
private const long MaxDebugLogBytes = 2 * 1024 * 1024;
private static readonly object FileSync = new();
private static readonly string DebugLogPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Tornado3_2026Election",
"debug.log");
public ObservableCollection<LogEntry> Entries { get; } = [];
@@ -20,6 +27,8 @@ public sealed class LogService
private void Add(LogLevel level, string message)
{
WriteDebugLog(level, message);
Common.UiDispatcher.Enqueue(() =>
{
Entries.Insert(0, new LogEntry
@@ -35,4 +44,32 @@ public sealed class LogService
}
});
}
private static void WriteDebugLog(LogLevel level, string message)
{
try
{
lock (FileSync)
{
var directory = Path.GetDirectoryName(DebugLogPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
if (File.Exists(DebugLogPath) && new FileInfo(DebugLogPath).Length > MaxDebugLogBytes)
{
File.Delete(DebugLogPath);
}
File.AppendAllText(
DebugLogPath,
$"{DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss.fff zzz} [{level}] {message}{Environment.NewLine}");
}
}
catch
{
// Logging must not affect live CG control.
}
}
}

View File

@@ -111,6 +111,16 @@ public sealed class MockTornado3Adapter : ITornado3Adapter
}, cancellationToken).ConfigureAwait(false);
}
public async Task ShowPreparedFirstFrameAsync(BroadcastChannel channel, CancellationToken cancellationToken)
{
await ExecuteWithTimeoutAsync(async () =>
{
State = TornadoConnectionState.Ready;
await Task.Delay(40, cancellationToken).ConfigureAwait(false);
_logService.Info($"[{channel}] Show prepared first frame on PGM");
}, cancellationToken).ConfigureAwait(false);
}
public async Task TakeAsync(BroadcastChannel channel, CancellationToken cancellationToken)
{
await ExecuteWithTimeoutAsync(async () =>
@@ -121,6 +131,16 @@ public sealed class MockTornado3Adapter : ITornado3Adapter
}, cancellationToken).ConfigureAwait(false);
}
public async Task ClearOutputAsync(BroadcastChannel channel, CancellationToken cancellationToken)
{
await ExecuteWithTimeoutAsync(async () =>
{
State = TornadoConnectionState.Idle;
await Task.Delay(30, cancellationToken).ConfigureAwait(false);
_logService.Info($"[{channel}] Clear output layer");
}, cancellationToken).ConfigureAwait(false);
}
public async Task OutAsync(BroadcastChannel channel, CancellationToken cancellationToken)
{
await ExecuteWithTimeoutAsync(async () =>

View File

@@ -156,7 +156,12 @@ internal static class PartyColorCatalog
var folderName = Path.GetFileName(Path.GetFullPath(templateFolderPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
if (TryGetExplicitRgbSpecBaseName(folderName, templateName, out var explicitSpecBaseName) &&
!string.IsNullOrWhiteSpace(explicitSpecBaseName))
string.IsNullOrWhiteSpace(explicitSpecBaseName))
{
return null;
}
if (!string.IsNullOrWhiteSpace(explicitSpecBaseName))
{
var explicitSpecPath = Path.Combine(rgbDirectoryPath, explicitSpecBaseName + ".txt");
if (File.Exists(explicitSpecPath))
@@ -265,6 +270,11 @@ internal static class PartyColorCatalog
if (line.StartsWith("(", StringComparison.Ordinal))
{
if (inHeader)
{
currentSectionHeaders = ExtractSectionHeaders(headerBuilder.ToString());
}
headerBuilder.Clear();
headerBuilder.AppendLine(line);
inHeader = !line.Contains(')');
@@ -278,14 +288,20 @@ internal static class PartyColorCatalog
if (inHeader)
{
headerBuilder.AppendLine(line);
if (line.Contains(')'))
if (IsHeaderContinuationLine(line))
{
inHeader = false;
currentSectionHeaders = ExtractSectionHeaders(headerBuilder.ToString());
headerBuilder.AppendLine(line);
if (line.Contains(')'))
{
inHeader = false;
currentSectionHeaders = ExtractSectionHeaders(headerBuilder.ToString());
}
continue;
}
continue;
inHeader = false;
currentSectionHeaders = ExtractSectionHeaders(headerBuilder.ToString());
}
if (currentSectionHeaders is null || currentSectionHeaders.Count == 0 || line.StartsWith("R", StringComparison.OrdinalIgnoreCase))
@@ -328,6 +344,17 @@ internal static class PartyColorCatalog
StringComparer.OrdinalIgnoreCase));
}
private static bool IsHeaderContinuationLine(string line)
{
if (line.Contains(')'))
{
return true;
}
var normalizedLine = line.Trim().Trim('(', ')').Trim();
return TryParseSectionHeaderLine(normalizedLine, out _, out _);
}
private static List<SectionHeaderEntry> ExtractSectionHeaders(string header)
{
var entries = new List<SectionHeaderEntry>();
@@ -871,7 +898,12 @@ internal static class PartyColorCatalog
"Elect2026_Normal_민방",
"이시각1위_광역단체장",
"이시각1위_광역단체장",
"이시각1위_광역단체장_HD",
"이시각1위_광역단체장_HD");
Add(
mappings,
"Elect2026_Normal_민방",
"이시각1위_광역단체장_5760",
"이시각1위_광역단체장_5760",
"이시각1위_광역단체장_L");
Add(
mappings,
@@ -894,8 +926,15 @@ internal static class PartyColorCatalog
"판세_광역단체장",
"판세_광역단체장",
"판세_기초단체장",
"역대시도판세_광역단체장",
"역대시도판세_기초단체장",
"판세_기초단체장_5760",
"판세_기초단체장_7680");
Add(
mappings,
"Elect2026_Normal_민방",
string.Empty,
"사전_역대투표율");
Add(
mappings,

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,9 @@ public sealed class ChannelScheduleViewModel : ObservableObject
private ScheduleRegionOption? _selectedRegionOption;
private SelectionOption<EmptyScheduleBehavior>? _selectedEmptyBehaviorOption;
private CancellationTokenSource? _directPlaybackCts;
private ChannelScheduleItem? _preparedDirectItem;
private string _preparedDirectFormatId = string.Empty;
private string _preparedDirectRegionKey = string.Empty;
private bool _loopEnabled;
private EmptyScheduleBehavior _emptyScheduleBehavior = EmptyScheduleBehavior.ImmediateOut;
private int _regionOptionsRevision;
@@ -81,8 +84,10 @@ public sealed class ChannelScheduleViewModel : ObservableObject
];
Queue = engine.Queue;
SchedulePrepareCommand = new AsyncRelayCommand(PrepareScheduleAsync);
StartCommand = new AsyncRelayCommand(StartAsync, allowConcurrentExecutions: true);
StopCommand = new AsyncRelayCommand(StopAsync);
DirectPrepareCommand = new AsyncRelayCommand(DirectPrepareAsync, CanDirectStart);
DirectStartCommand = new AsyncRelayCommand(DirectStartAsync, CanDirectStart, allowConcurrentExecutions: true);
DirectStopCommand = new AsyncRelayCommand(DirectStopAsync);
ForceNextCommand = new AsyncRelayCommand(ForceNextAsync);
@@ -141,10 +146,14 @@ public sealed class ChannelScheduleViewModel : ObservableObject
public ObservableCollection<ChannelScheduleItem> Queue { get; }
public AsyncRelayCommand SchedulePrepareCommand { get; }
public AsyncRelayCommand StartCommand { get; }
public AsyncRelayCommand StopCommand { get; }
public AsyncRelayCommand DirectPrepareCommand { get; }
public AsyncRelayCommand DirectStartCommand { get; }
public AsyncRelayCommand DirectStopCommand { get; }
@@ -230,6 +239,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
SyncSelectedCutDebugTemplate();
_ = RebuildRegionOptionsAsync();
AddFormatCommand.NotifyCanExecuteChanged();
DirectPrepareCommand.NotifyCanExecuteChanged();
DirectStartCommand.NotifyCanExecuteChanged();
}
}
@@ -243,6 +253,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
if (SetProperty(ref _selectedRegionOption, value))
{
AddFormatCommand.NotifyCanExecuteChanged();
DirectPrepareCommand.NotifyCanExecuteChanged();
DirectStartCommand.NotifyCanExecuteChanged();
}
}
@@ -332,7 +343,9 @@ public sealed class ChannelScheduleViewModel : ObservableObject
public Brush PlaybackIconBrush => IsPlaying ? PlaybackActiveIconBrush : PlaybackIdleIconBrush;
public string TransmissionLabel => IsPlaying
public string TransmissionLabel => _engine.ActivePlaybackItem?.State == ScheduleQueueItemState.Sending
? "준비"
: IsPlaying
? "송출 중"
: "대기";
@@ -519,6 +532,21 @@ public sealed class ChannelScheduleViewModel : ObservableObject
_logService.Info($"[{Title}] 큐를 시작");
}
private async Task PrepareScheduleAsync()
{
try
{
await _engine.PrepareNextAsync(CancellationToken.None).ConfigureAwait(false);
RefreshSummary();
_logService.Info($"[{Title}] 스케줄 다음 컷 준비");
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
RefreshSummary();
_logService.Error($"[{Title}] Schedule prepare failed: {ex.Message}");
}
}
private async Task StopAsync()
{
await _engine.StopAsync().ConfigureAwait(false);
@@ -526,6 +554,57 @@ public sealed class ChannelScheduleViewModel : ObservableObject
_logService.Info($"[{Title}] 큐를 종료");
}
private async Task DirectPrepareAsync()
{
var selectedFormat = SelectedFormat;
var regionOption = SelectedRegionOption ?? RegionOptions.FirstOrDefault();
if (selectedFormat is null || regionOption is null)
{
_logService.Warning($"[{Title}] 바로 송출 준비할 컷과 지역을 먼저 선택해 주세요.");
return;
}
await _engine.StopAsync(takeOutputOff: false).ConfigureAwait(false);
_directPlaybackCts?.Cancel();
_directPlaybackCts?.Dispose();
var prepareCts = new CancellationTokenSource();
_directPlaybackCts = prepareCts;
var item = ChannelScheduleItem.FromTemplate(selectedFormat, regionOption);
item.UpdateThumbnailLayout(ThumbnailLayoutResolver.ResolveDisplayMetrics(
selectedFormat,
_videoWallLayoutPreset,
ThumbnailDisplayContext.Queue));
_preparedDirectItem = item;
_preparedDirectFormatId = selectedFormat.Id;
_preparedDirectRegionKey = BuildRegionOptionKey(regionOption);
try
{
_logService.Info($"[{Title}] 선택 컷 준비: {selectedFormat.Name} / {regionOption.Label}");
await _engine.PrepareDirectAsync(item, selectedFormat, prepareCts.Token).ConfigureAwait(false);
if (!prepareCts.IsCancellationRequested)
{
_logService.Info($"[{Title}] 선택 컷 준비 완료: {selectedFormat.Name}");
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
ClearPreparedDirectState(item);
_logService.Error($"[{Title}] 선택 컷 준비 실패: {ex.Message}");
}
finally
{
if (ReferenceEquals(_directPlaybackCts, prepareCts))
{
_directPlaybackCts = null;
}
prepareCts.Dispose();
RefreshSummary();
}
}
private async Task DirectStartAsync()
{
var selectedFormat = SelectedFormat;
@@ -536,22 +615,24 @@ public sealed class ChannelScheduleViewModel : ObservableObject
return;
}
if (!selectedFormat.IsAvailableInPhase(_data.BroadcastPhase))
var preparedItem = ResolvePreparedDirectItem(selectedFormat, regionOption);
if (preparedItem is null)
{
_logService.Warning($"[{Title}] 현재 단계에서는 '{selectedFormat.Name}' 컷을 바로 송출할 수 없습니다.");
return;
await _engine.StopAsync(takeOutputOff: false).ConfigureAwait(false);
_directPlaybackCts?.Cancel();
_directPlaybackCts?.Dispose();
}
await _engine.StopAsync(takeOutputOff: false).ConfigureAwait(false);
_directPlaybackCts?.Cancel();
var playbackCts = new CancellationTokenSource();
_directPlaybackCts = playbackCts;
var item = ChannelScheduleItem.FromTemplate(selectedFormat, regionOption);
item.UpdateThumbnailLayout(ThumbnailLayoutResolver.ResolveDisplayMetrics(
selectedFormat,
_videoWallLayoutPreset,
ThumbnailDisplayContext.Queue));
var item = preparedItem ?? ChannelScheduleItem.FromTemplate(selectedFormat, regionOption);
if (preparedItem is null)
{
item.UpdateThumbnailLayout(ThumbnailLayoutResolver.ResolveDisplayMetrics(
selectedFormat,
_videoWallLayoutPreset,
ThumbnailDisplayContext.Queue));
}
try
{
@@ -568,6 +649,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
}
finally
{
ClearPreparedDirectState(item);
if (ReferenceEquals(_directPlaybackCts, playbackCts))
{
_directPlaybackCts = null;
@@ -582,10 +664,49 @@ public sealed class ChannelScheduleViewModel : ObservableObject
{
_directPlaybackCts?.Cancel();
await _adapter.OutAsync(Channel, CancellationToken.None).ConfigureAwait(false);
ClearPreparedDirectState(_preparedDirectItem);
_engine.ClearDirectPlayback();
RefreshSummary();
_logService.Info($"[{Title}] 선택 컷 송출 정지");
}
private ChannelScheduleItem? ResolvePreparedDirectItem(
FormatTemplateDefinition selectedFormat,
ScheduleRegionOption regionOption)
{
if (_preparedDirectItem is null ||
!_engine.IsPreparedItem(_preparedDirectItem) ||
!string.Equals(_preparedDirectFormatId, selectedFormat.Id, StringComparison.Ordinal) ||
!string.Equals(_preparedDirectRegionKey, BuildRegionOptionKey(regionOption), StringComparison.Ordinal))
{
return null;
}
return _preparedDirectItem;
}
private void ClearPreparedDirectState(ChannelScheduleItem? item)
{
if (item is not null && !ReferenceEquals(_preparedDirectItem, item))
{
return;
}
_preparedDirectItem = null;
_preparedDirectFormatId = string.Empty;
_preparedDirectRegionKey = string.Empty;
}
private static string BuildRegionOptionKey(ScheduleRegionOption regionOption)
{
return string.Join(
"\u001F",
regionOption.Scope,
regionOption.ElectionType ?? string.Empty,
regionOption.Label ?? string.Empty,
regionOption.DistrictCode ?? string.Empty);
}
private async Task ForceNextAsync()
{
await _engine.ForceNextAsync().ConfigureAwait(false);
@@ -607,12 +728,6 @@ public sealed class ChannelScheduleViewModel : ObservableObject
return;
}
if (!SelectedFormat.IsAvailableInPhase(_data.BroadcastPhase))
{
_logService.Warning($"[{Title}] 현재 단계에서는 '{SelectedFormat.Name}' 컷을 추가할 수 없습니다.");
return;
}
var regionOption = SelectedRegionOption ?? RegionOptions.FirstOrDefault();
if (regionOption is null)
{
@@ -745,7 +860,6 @@ public sealed class ChannelScheduleViewModel : ObservableObject
private bool CanAddFormat()
{
return SelectedFormat is not null &&
SelectedFormat.IsAvailableInPhase(_data.BroadcastPhase) &&
SelectedRegionOption is not null;
}
@@ -770,7 +884,6 @@ public sealed class ChannelScheduleViewModel : ObservableObject
var selectedFormatId = SelectedFormat?.Id;
var selectedCategory = SelectedFormatCategoryOption?.Value;
var filteredFormats = _allFormats
.Where(format => format.IsAvailableInPhase(_data.BroadcastPhase))
.Where(format => selectedCategory is null || CutCategoryResolver.IsMatch(format, selectedCategory.Value))
.ToArray();
@@ -792,6 +905,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
UpdateSelectedFormatThumbnailMetrics();
SyncSelectedCutDebugTemplate();
AddFormatCommand.NotifyCanExecuteChanged();
DirectPrepareCommand.NotifyCanExecuteChanged();
DirectStartCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(QueueFootnote));
}
@@ -799,10 +913,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
private void RebuildFormatCategoryOptions()
{
var selectedCategory = SelectedFormatCategoryOption?.Value;
var formatsInCurrentPhase = _allFormats
.Where(format => format.IsAvailableInPhase(_data.BroadcastPhase))
.ToArray();
var options = CreateFormatCategoryOptions(formatsInCurrentPhase);
var options = CreateFormatCategoryOptions(_allFormats);
FormatCategoryOptions.Clear();
foreach (var option in options)
@@ -871,6 +982,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
SelectedRegionOption = null;
_lastRegionOptionFormatId = string.Empty;
AddFormatCommand.NotifyCanExecuteChanged();
DirectPrepareCommand.NotifyCanExecuteChanged();
DirectStartCommand.NotifyCanExecuteChanged();
return;
}
@@ -898,6 +1010,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
SelectedRegionOption = ResolvePreferredRegionOption(options, previousSelection, selectedFormat, shouldUseDefaultSelection);
_lastRegionOptionFormatId = selectedFormat.Id;
AddFormatCommand.NotifyCanExecuteChanged();
DirectPrepareCommand.NotifyCanExecuteChanged();
DirectStartCommand.NotifyCanExecuteChanged();
}

View File

@@ -1141,7 +1141,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
return await GetTurnoutPhotoRegionLevelOptionsAsync(turnoutPhotoMode, cancellationToken).ConfigureAwait(false);
}
var options = await GetScheduleDistrictOptionsAsync(electionType, cancellationToken).ConfigureAwait(false);
var options = await GetScheduleDistrictOptionsAsync(electionType, template, cancellationToken).ConfigureAwait(false);
var regionOptions = new List<ScheduleRegionOption>
{
@@ -1159,7 +1159,9 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
}
};
if (IsByElectionTemplate(template) || ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template?.Name))
if (IsByElectionTemplate(template) ||
IsNormalPreElectionTurnoutDistrictBoardTemplate(template) ||
ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template?.Name))
{
regionOptions.AddRange(CreateScheduleRegionGroupOptions(options, electionType));
}
@@ -1522,7 +1524,12 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
private BroadcastPhase ResolveScheduleRefreshPhase(FormatTemplateDefinition template)
{
return BroadcastPhase == BroadcastPhase.PreElection && IsCareerTemplate(template)
if (IsTurnoutTemplate(template))
{
return BroadcastPhase.PreElection;
}
return template.RequiresCandidateData || IsCareerTemplate(template)
? BroadcastPhase.Counting
: BroadcastPhase;
}
@@ -1541,9 +1548,9 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
private bool ShouldUseTurnoutPhotoRegionLevelOptions(FormatTemplateDefinition? template)
{
return BroadcastPhase == BroadcastPhase.PreElection &&
template is not null &&
string.Equals(template.Name, "투표율_사진", StringComparison.Ordinal);
return template is not null &&
(string.Equals(template.Name, "투표율", StringComparison.Ordinal) ||
string.Equals(template.Name, "투표율_사진", StringComparison.Ordinal));
}
private static int ResolveRequiredCandidateCount(FormatTemplateDefinition template)
@@ -1616,7 +1623,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
return [CreateSingleScheduleRegionTarget(electionType)];
}
var options = await GetScheduleDistrictOptionsAsync(electionType, cancellationToken).ConfigureAwait(false);
var options = await GetScheduleDistrictOptionsAsync(electionType, template, cancellationToken).ConfigureAwait(false);
if (options.Count == 0)
{
return Array.Empty<ScheduleRegionTarget>();
@@ -1629,6 +1636,26 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
.ToArray();
}
if (IsNormalPreElectionTurnoutDistrictBoardTemplate(template) &&
item.RegionScope == ScheduleRegionScope.Single)
{
var regionGroupTargets = ResolveRegionGroupTargets(item, options, electionType);
if (regionGroupTargets.Count > 1)
{
return regionGroupTargets;
}
}
if (UsesHistoricalScheduleOptions(template) &&
item.RegionScope is ScheduleRegionScope.Single or ScheduleRegionScope.RegionGroup)
{
var splitHistoricalTargets = ResolveCombinedHistoricalRegionTargets(item, options, electionType);
if (splitHistoricalTargets.Count > 0)
{
return splitHistoricalTargets;
}
}
return item.RegionScope switch
{
ScheduleRegionScope.StationRegions => ResolveStationRegionTargets(options, electionType, station),
@@ -1669,16 +1696,18 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
[target],
includeNationalSlot: false,
includeRegionalBoardSlots: false,
includeEmptyRegionalBoardSlots: false,
maxRegionalSlots: 0,
cancellationToken)
.ConfigureAwait(false);
}
var refreshPhase = ResolveScheduleRefreshPhase(template);
var refreshResult = await _apiClient
.RefreshAsync(ResolveScheduleRefreshPhase(template), electionType, target.DisplayName, target.DistrictCode, cancellationToken)
.RefreshAsync(refreshPhase, electionType, target.DisplayName, target.DistrictCode, cancellationToken)
.ConfigureAwait(false);
return CreateSnapshotFromRefreshResult(electionType, refreshResult);
return CreateSnapshotFromRefreshResult(electionType, refreshResult, refreshPhase);
}
public Task<ElectionDataSnapshot> GetAggregateScheduleSnapshotAsync(
@@ -1714,6 +1743,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
regionTargets,
includeNationalSlot: true,
includeRegionalBoardSlots: true,
includeEmptyRegionalBoardSlots: false,
maxRegionalSlots: 4,
cancellationToken);
}
@@ -1725,6 +1755,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
regionTargets,
includeNationalSlot: false,
includeRegionalBoardSlots: true,
includeEmptyRegionalBoardSlots: IsNormalPreElectionTurnoutDistrictBoardTemplate(template),
maxRegionalSlots: ResolveRegionalTurnoutBoardMaxSlots(template),
cancellationToken);
}
@@ -3640,6 +3671,77 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
}
}
private async Task<IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption>> GetScheduleDistrictOptionsAsync(
string electionType,
FormatTemplateDefinition? template,
CancellationToken cancellationToken)
{
if (UsesHistoricalScheduleOptions(template))
{
var historicalOptions = GetHistoricalScheduleDistrictOptions(electionType);
if (historicalOptions.Count > 0)
{
return historicalOptions;
}
}
return await GetScheduleDistrictOptionsAsync(electionType, cancellationToken).ConfigureAwait(false);
}
private IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption> GetHistoricalScheduleDistrictOptions(string electionType)
{
var records = _preElectionHistoryService.GetSelectionRecords(electionType);
if (records.Count == 0)
{
return Array.Empty<SbsElectionApiClient.DistrictSelectionOption>();
}
return records
.Where(record => !string.IsNullOrWhiteSpace(ResolveHistoricalScheduleOptionDisplayName(record)))
.OrderBy(record => ResolveDefaultRegionOrder(record.RegionName))
.ThenBy(record => ResolveHistoricalScheduleOptionDisplayName(record), StringComparer.Ordinal)
.Select(CreateHistoricalScheduleDistrictOption)
.ToArray();
}
private static SbsElectionApiClient.DistrictSelectionOption CreateHistoricalScheduleDistrictOption(
PreElectionHistoryRecord record)
{
var regionName = string.IsNullOrWhiteSpace(record.RegionName)
? record.DisplayName
: record.RegionName;
var districtName = string.IsNullOrWhiteSpace(record.DistrictName)
? ResolveHistoricalScheduleOptionDisplayName(record)
: record.DistrictName;
var parentRegionCode = SbsElectionApiClient.ResolveBasicApiSidoCode(regionName);
var canonicalElectionType = PreElectionHistoryService.NormalizeElectionType(record.ElectionType);
var districtCode = string.Equals(canonicalElectionType, "기초단체장", StringComparison.Ordinal)
? record.Key
: parentRegionCode;
return new SbsElectionApiClient.DistrictSelectionOption(
DisplayName: ResolveHistoricalScheduleOptionDisplayName(record),
DistrictCode: districtCode,
RegionName: regionName,
DistrictName: districtName,
ParentRegionCode: parentRegionCode);
}
private static string ResolveHistoricalScheduleOptionDisplayName(PreElectionHistoryRecord record)
{
if (!string.IsNullOrWhiteSpace(record.DisplayName))
{
return record.DisplayName;
}
if (!string.IsNullOrWhiteSpace(record.DistrictName))
{
return record.DistrictName;
}
return record.RegionName ?? string.Empty;
}
private async Task<IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption>> GetScheduleDistrictOptionsAsync(
string electionType,
CancellationToken cancellationToken)
@@ -3696,6 +3798,21 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
return "기초단체장";
}
if (IsNormalPreElectionTurnoutDistrictBoardTemplate(template))
{
return "기초단체장";
}
if (template is not null && ScheduleTemplatePolicy.IsHistoricalTurnoutFormat(template.Name))
{
return "광역단체장";
}
if (template is not null && IsTurnoutTemplate(template))
{
return ResolvePreElectionTurnoutElectionType(preferredElectionType);
}
return ResolveScheduleElectionType(template?.Name, preferredElectionType);
}
@@ -3707,6 +3824,11 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
return historicalWinnerElectionType;
}
if (ScheduleTemplatePolicy.IsHistoricalTurnoutFormat(resolvedFormatName))
{
return "광역단체장";
}
if (IsBottomTurnoutDistrictTemplateName(resolvedFormatName))
{
return "기초단체장";
@@ -3749,11 +3871,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
if (BroadcastPhase == BroadcastPhase.PreElection)
{
return SupportsPreElectionTurnout(electionType: preferredElectionType)
? preferredElectionType!
: SupportsPreElectionTurnout(ElectionType)
? ElectionType
: "광역단체장";
return ResolvePreElectionTurnoutElectionType(preferredElectionType);
}
return string.IsNullOrWhiteSpace(preferredElectionType)
@@ -3761,6 +3879,15 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
: preferredElectionType;
}
private string ResolvePreElectionTurnoutElectionType(string? preferredElectionType = null)
{
return SupportsPreElectionTurnout(electionType: preferredElectionType)
? preferredElectionType!
: SupportsPreElectionTurnout(ElectionType)
? ElectionType
: "광역단체장";
}
private static bool TryResolveHistoricalWinnerElectionType(string formatName, out string electionType)
{
var sourceName = System.IO.Path.GetFileNameWithoutExtension(
@@ -3839,6 +3966,38 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
.ToArray();
}
private static IReadOnlyList<ScheduleRegionTarget> ResolveCombinedHistoricalRegionTargets(
ChannelScheduleItem item,
IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption> options,
string electionType)
{
if (!IsGwangjuJeonnamCombinedLabel(item.RegionLabel))
{
return Array.Empty<ScheduleRegionTarget>();
}
return options
.Where(option => GetNormalizedRegionKeys(option.RegionName)
.Any(key => string.Equals(key, "광주", StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, "전남", StringComparison.OrdinalIgnoreCase)))
.OrderBy(option => ResolveDefaultRegionOrder(option.RegionName))
.Select(option => CreateScheduleRegionTarget(option, electionType))
.ToArray();
}
private static bool UsesHistoricalScheduleOptions(FormatTemplateDefinition? template)
{
return ScheduleTemplatePolicy.IsHistoricalTurnoutFormat(template?.Name) ||
ScheduleTemplatePolicy.IsHistoricalWinnerFormat(template?.Name);
}
private static bool IsGwangjuJeonnamCombinedLabel(string? value)
{
var normalized = NormalizeConfiguredRegion(value);
return string.Equals(normalized, "전남광주", StringComparison.OrdinalIgnoreCase) ||
string.Equals(normalized, "광주전남", StringComparison.OrdinalIgnoreCase);
}
private static bool MatchesScheduleRegionGroup(
SbsElectionApiClient.DistrictSelectionOption option,
string regionCode,
@@ -3873,7 +4032,8 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
private ElectionDataSnapshot CreateSnapshotFromRefreshResult(
string electionType,
SbsElectionApiClient.SbsElectionRefreshResult refreshResult)
SbsElectionApiClient.SbsElectionRefreshResult refreshResult,
BroadcastPhase broadcastPhase)
{
var districtName = string.IsNullOrWhiteSpace(refreshResult.DistrictName)
? refreshResult.ElectionDistrictName
@@ -3888,7 +4048,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
return new ElectionDataSnapshot
{
BroadcastPhase = BroadcastPhase,
BroadcastPhase = broadcastPhase,
ElectionType = electionType,
DistrictName = districtName ?? string.Empty,
DistrictCode = refreshResult.DistrictCode ?? string.Empty,
@@ -4682,6 +4842,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
IReadOnlyList<ScheduleRegionTarget> regionTargets,
bool includeNationalSlot,
bool includeRegionalBoardSlots,
bool includeEmptyRegionalBoardSlots,
int maxRegionalSlots,
CancellationToken cancellationToken)
{
@@ -4722,17 +4883,18 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
foreach (var target in selectedTargets.Take(Math.Max(maxRegionalSlots, 0)))
{
var slotItem = FindTurnoutOverviewItem(turnoutOverview.Items, target);
if (!HasPositiveTurnoutOverview(slotItem))
var hasTurnoutData = HasPositiveTurnoutOverview(slotItem);
if (!includeEmptyRegionalBoardSlots && !hasTurnoutData)
{
continue;
}
var resolvedSlotItem = slotItem!;
turnoutBoardSlots.Add(new TurnoutBoardSlotEntry(
nextSlot++,
ResolveTurnoutBoardDistrictLabel(electionType, resolvedSlotItem, target),
resolvedSlotItem.TurnoutRate,
RegionLabel: ResolveTurnoutBoardRegionLabel(resolvedSlotItem, target)));
ResolveTurnoutBoardDistrictLabel(electionType, slotItem, target),
hasTurnoutData ? slotItem!.TurnoutRate : 0d,
RegionLabel: ResolveTurnoutBoardRegionLabel(slotItem, target),
HasTurnoutData: hasTurnoutData));
}
}
@@ -4757,7 +4919,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
return new ElectionDataSnapshot
{
BroadcastPhase = BroadcastPhase,
BroadcastPhase = BroadcastPhase.PreElection,
ElectionType = electionType,
DistrictName = string.IsNullOrWhiteSpace(districtName) ? regionName : districtName,
DistrictCode = primaryItem?.DistrictCode ?? primaryTarget?.DistrictCode ?? string.Empty,
@@ -4806,17 +4968,17 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
private static string ResolveTurnoutBoardDistrictLabel(
string electionType,
SbsElectionApiClient.TurnoutOverviewItem item,
SbsElectionApiClient.TurnoutOverviewItem? item,
ScheduleRegionTarget target)
{
if (string.Equals(electionType, "기초단체장", StringComparison.Ordinal))
{
return FirstNonWhiteSpace(
item.DistrictName,
item?.DistrictName,
target.DistrictName,
item.DisplayName,
item?.DisplayName,
target.DisplayName,
item.RegionName,
item?.RegionName,
target.RegionName);
}
@@ -4824,13 +4986,13 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
}
private static string ResolveTurnoutBoardRegionLabel(
SbsElectionApiClient.TurnoutOverviewItem item,
SbsElectionApiClient.TurnoutOverviewItem? item,
ScheduleRegionTarget target)
{
return FirstNonWhiteSpace(
item.RegionName,
item?.RegionName,
target.RegionName,
item.DisplayName,
item?.DisplayName,
target.DisplayName);
}
@@ -4942,6 +5104,12 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
IsTopTurnoutDistrictBoardTemplate(template);
}
private static bool IsNormalPreElectionTurnoutDistrictBoardTemplate(FormatTemplateDefinition? template)
{
return template?.RecommendedChannel == BroadcastChannel.Normal &&
string.Equals(template.Name, "투표율_선거구별 사전", StringComparison.Ordinal);
}
private static bool IsTopTurnoutDistrictBoardTemplate(FormatTemplateDefinition template)
{
return template.RecommendedChannel == BroadcastChannel.TopLeft &&

Binary file not shown.

View File

@@ -155,7 +155,9 @@ internal static class CurrentApiCutDiagnostics
districtCache,
electionType,
station,
options.RegionScope == "all" || IsNormalPanseMapTemplate(template))
options.RegionScope == "all" || IsNormalPanseMapTemplate(template),
template,
preElectionHistoryService)
.ConfigureAwait(false);
}
catch (Exception ex)
@@ -620,6 +622,10 @@ internal static class CurrentApiCutDiagnostics
? overview.TurnoutVotes
: primaryItem?.TurnoutVotes ?? 0;
var snapshotReferenceTimeLabel = includeNationalSlot
? overview.ReferenceTimeLabel
: FirstNonWhiteSpace(primaryItem?.ReferenceTimeLabel, overview.ReferenceTimeLabel);
return new ElectionDataSnapshot
{
BroadcastPhase = BroadcastPhase.PreElection,
@@ -634,7 +640,8 @@ internal static class CurrentApiCutDiagnostics
CountedVotesFromApi = null,
RemainingVotesFromApi = null,
CountedRateFromApi = null,
ReceivedAt = DateTimeOffset.Now,
ReceivedAt = overview.ReceivedAt == default ? DateTimeOffset.Now : overview.ReceivedAt,
ReferenceTimeLabel = snapshotReferenceTimeLabel,
TurnoutBoardSlots = turnoutBoardSlots,
NationalTurnoutRateOverride = overview.NationalTurnoutRate
};
@@ -1104,6 +1111,7 @@ internal static class CurrentApiCutDiagnostics
RemainingVotesFromApi = null,
CountedRateFromApi = null,
ReceivedAt = overview.ReceivedAt == default ? DateTimeOffset.Now : overview.ReceivedAt,
ReferenceTimeLabel = FirstNonWhiteSpace(item.ReferenceTimeLabel, overview.ReferenceTimeLabel),
NationalTurnoutRateOverride = overview.NationalTurnoutRate
};
}
@@ -1318,8 +1326,15 @@ internal static class CurrentApiCutDiagnostics
IDictionary<string, IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption>> districtCache,
string electionType,
BroadcastStationProfile station,
bool useAllRegions = false)
bool useAllRegions,
FormatTemplateDefinition template,
PreElectionHistoryService preElectionHistoryService)
{
if (UsesHistoricalStoredOptions(template))
{
return GetStoredHistoryDistricts(electionType, preElectionHistoryService);
}
var regionFilters = useAllRegions ? Array.Empty<string>() : station.RegionFilters;
var cacheKey = $"{electionType}|{string.Join(",", regionFilters)}";
if (!districtCache.TryGetValue(cacheKey, out var districts))
@@ -1333,6 +1348,59 @@ internal static class CurrentApiCutDiagnostics
return districts;
}
private static IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption> GetStoredHistoryDistricts(
string electionType,
PreElectionHistoryService preElectionHistoryService)
{
return preElectionHistoryService
.GetSelectionRecords(electionType)
.Where(record => !string.IsNullOrWhiteSpace(ResolveStoredHistoryDisplayName(record)))
.OrderBy(record => SbsElectionApiClient.ResolveBasicApiSidoCode(record.RegionName), StringComparer.Ordinal)
.ThenBy(record => ResolveStoredHistoryDisplayName(record), StringComparer.Ordinal)
.Select(CreateStoredHistoryDistrict)
.ToArray();
}
private static SbsElectionApiClient.DistrictSelectionOption CreateStoredHistoryDistrict(
PreElectionHistoryRecord record)
{
var regionName = string.IsNullOrWhiteSpace(record.RegionName)
? record.DisplayName
: record.RegionName;
var districtName = string.IsNullOrWhiteSpace(record.DistrictName)
? ResolveStoredHistoryDisplayName(record)
: record.DistrictName;
var parentRegionCode = SbsElectionApiClient.ResolveBasicApiSidoCode(regionName);
var districtCode = string.Equals(
PreElectionHistoryService.NormalizeElectionType(record.ElectionType),
"기초단체장",
StringComparison.Ordinal)
? record.Key
: parentRegionCode;
return new SbsElectionApiClient.DistrictSelectionOption(
ResolveStoredHistoryDisplayName(record),
districtCode,
regionName,
districtName,
parentRegionCode);
}
private static string ResolveStoredHistoryDisplayName(PreElectionHistoryRecord record)
{
if (!string.IsNullOrWhiteSpace(record.DisplayName))
{
return record.DisplayName;
}
if (!string.IsNullOrWhiteSpace(record.DistrictName))
{
return record.DistrictName;
}
return record.RegionName ?? string.Empty;
}
private static IEnumerable<SbsElectionApiClient.DistrictSelectionOption> ResolveTargets(
IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption> districts,
BroadcastStationProfile station,
@@ -1398,7 +1466,8 @@ internal static class CurrentApiCutDiagnostics
CountedVotesFromApi = refreshResult.CountedVotes,
RemainingVotesFromApi = refreshResult.RemainingVotes,
CountedRateFromApi = refreshResult.CountedRate,
ReceivedAt = refreshResult.ReceivedAt == default ? DateTimeOffset.Now : refreshResult.ReceivedAt
ReceivedAt = refreshResult.ReceivedAt == default ? DateTimeOffset.Now : refreshResult.ReceivedAt,
ReferenceTimeLabel = refreshResult.ReferenceTimeLabel
};
}
@@ -2297,12 +2366,22 @@ internal static class CurrentApiCutDiagnostics
return "기초단체장";
}
if (ScheduleTemplatePolicy.IsHistoricalTurnoutFormat(template.Name))
{
return "광역단체장";
}
return ResolveScheduleElectionType(template.Name, phase, defaultElectionType);
}
private static string ResolveScheduleElectionType(string? formatName, BroadcastPhase phase, string defaultElectionType)
{
var resolvedFormatName = formatName ?? string.Empty;
if (ScheduleTemplatePolicy.IsHistoricalTurnoutFormat(resolvedFormatName))
{
return "광역단체장";
}
if (resolvedFormatName.Contains("교육감", StringComparison.Ordinal))
{
return "교육감";
@@ -2343,6 +2422,12 @@ internal static class CurrentApiCutDiagnostics
return defaultElectionType;
}
private static bool UsesHistoricalStoredOptions(FormatTemplateDefinition template)
{
return ScheduleTemplatePolicy.IsHistoricalTurnoutFormat(template.Name) ||
ScheduleTemplatePolicy.IsHistoricalWinnerFormat(template.Name);
}
private static bool SupportsPreElectionTurnout(string? electionType)
{
return string.Equals(electionType, "광역단체장", StringComparison.Ordinal) ||

View File

@@ -274,6 +274,7 @@ internal static class CutFileAudit
payload.CounterNumberKeys,
Array.Empty<KarismaChartCellUpdate>(),
Array.Empty<KarismaPositionUpdate>(),
Array.Empty<KarismaCropKeyUpdate>(),
payload.StyleColorUpdates,
payload.VisibilityUpdates,
CancellationToken.None)
@@ -524,6 +525,10 @@ internal static class CutFileAudit
string scenario,
string frameLabel)
{
_ = ShowWindow(pgmWindow.Handle, ShowWindowRestore);
_ = SetForegroundWindow(pgmWindow.Handle);
Thread.Sleep(80);
var fileName = $"{result.Index:000}_{SanitizeFileName(result.FolderName)}_{SanitizeFileName(result.BaseName)}_{scenario.ToLowerInvariant()}_{frameLabel}.png";
var outputPath = Path.Combine(options.CapturePath, fileName);
Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
@@ -961,6 +966,12 @@ internal static class CutFileAudit
{
var normalizedBaseName = NormalizeVariantName(result.BaseName);
var explicitBaseName = TryResolveExplicitRgbSpec(result.FolderName, normalizedBaseName);
if (explicitBaseName is not null && string.IsNullOrWhiteSpace(explicitBaseName))
{
mappingKind = "explicit-none";
return null;
}
if (!string.IsNullOrWhiteSpace(explicitBaseName) &&
rgbCatalog.TryGetValue(BuildRgbCatalogKey(result.FolderName, explicitBaseName), out var explicitSpec))
{
@@ -1027,11 +1038,12 @@ internal static class CutFileAudit
Add("Elect2026_Normal_민방", "모든후보_교육감", "모든후보_교육감");
Add("Elect2026_Normal_민방", "사전_역대당선", "사전_역대당선자", "사전_역대당선자_기초단체장");
Add("Elect2026_Normal_민방", "사전_역대당선_교육감", "사전_역대당선자_교육감");
Add("Elect2026_Normal_민방", "이시각1위_광역단체장", "이시각1위_광역단체장");
Add("Elect2026_Normal_민방", "이시각1위_광역단체장_5760", "이시각1위_광역단체장_5760");
Add("Elect2026_Normal_민방", "이시각1위_기초단체장(5760동일)", "이시각1위_기초단체장");
Add("Elect2026_Normal_민방", string.Empty, "사전_역대투표율");
Add("Elect2026_Normal_민방", "이시각1위_광역단체장", "이시각1위_광역단체장", "이시각1위_광역단체장_HD");
Add("Elect2026_Normal_민방", "이시각1위_광역단체장_5760", "이시각1위_광역단체장_5760", "이시각1위_광역단체장_L");
Add("Elect2026_Normal_민방", "이시각1위_기초단체장(5760동일)", "이시각1위_기초단체장", "이시각1위_기초단체장_HD", "이시각1위_기초단체장_L");
Add("Elect2026_Normal_민방", "접전,초접전", "접전_광역단체장", "접전_기초단체장", "초접전_광역단체장", "초접전_기초단체장");
Add("Elect2026_Normal_민방", "판세_광역단체장", "판세_광역단체장", "판세_기초단체장");
Add("Elect2026_Normal_민방", "판세_광역단체장", "판세_광역단체장", "판세_기초단체장", "역대시도판세_광역단체장", "역대시도판세_기초단체장");
Add("Elect2026_Bottom_민방", "1-2위, 1-3위, 이시각1위", "1-2위_광역단체장", "1-2위_기초단체장", "1-3위_광역단체장", "1-3위_기초단체장", "1위_광역단체장", "1위_기초단체장");
Add("Elect2026_Bottom_민방", "당선", "당선_광역단체장", "당선_광역의원", "당선_기초단체장", "당선_기초의원");
Add("Elect2026_Bottom_민방", "모든후보", "전후보_광역단체장", "전후보_기초단체장");
@@ -1296,29 +1308,62 @@ internal static class CutFileAudit
private static PgmWindow? TryFindPgmWindow()
{
var process = Process.GetProcessesByName("Tornado3")
.FirstOrDefault(candidate => string.Equals(candidate.MainWindowTitle, "PGM", StringComparison.Ordinal));
if (process is null || process.MainWindowHandle == IntPtr.Zero)
var handle = IntPtr.Zero;
var tornadoProcessIds = Process.GetProcessesByName("Tornado3")
.Select(process => process.Id)
.ToHashSet();
EnumWindows((candidateHandle, lParam) =>
{
if (handle != IntPtr.Zero)
{
return false;
}
_ = GetWindowThreadProcessId(candidateHandle, out var processId);
if (!tornadoProcessIds.Contains((int)processId))
{
return true;
}
var titleLength = GetWindowTextLength(candidateHandle);
if (titleLength <= 0)
{
return true;
}
var title = new StringBuilder(titleLength + 1);
_ = GetWindowText(candidateHandle, title, title.Capacity);
if (string.Equals(title.ToString(), "PGM", StringComparison.Ordinal))
{
handle = candidateHandle;
return false;
}
return true;
}, IntPtr.Zero);
if (handle == IntPtr.Zero)
{
return null;
}
if (TryGetDwmExtendedFrameBounds(process.MainWindowHandle, out var dwmBounds))
if (TryGetDwmExtendedFrameBounds(handle, out var dwmBounds))
{
return new PgmWindow(process.MainWindowHandle, dwmBounds);
return new PgmWindow(handle, dwmBounds);
}
if (TryGetClientBounds(process.MainWindowHandle, out var clientBounds))
if (TryGetClientBounds(handle, out var clientBounds))
{
return new PgmWindow(process.MainWindowHandle, clientBounds);
return new PgmWindow(handle, clientBounds);
}
if (!GetWindowRect(process.MainWindowHandle, out var rect))
if (!GetWindowRect(handle, out var rect))
{
return null;
}
return new PgmWindow(process.MainWindowHandle, new Rect(rect.Left, rect.Top, rect.Right - rect.Left, rect.Bottom - rect.Top));
return new PgmWindow(handle, new Rect(rect.Left, rect.Top, rect.Right - rect.Left, rect.Bottom - rect.Top));
}
private static bool TryGetDwmExtendedFrameBounds(IntPtr handle, out Rect bounds)
@@ -1544,6 +1589,27 @@ internal static class CutFileAudit
return value.Replace("|", "\\|", StringComparison.Ordinal);
}
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
[DllImport("user32.dll")]
private static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount);
[DllImport("user32.dll")]
private static extern int GetWindowTextLength(IntPtr hWnd);
[DllImport("user32.dll")]
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetWindowRect(IntPtr hWnd, out NativeRect lpRect);
@@ -1560,6 +1626,8 @@ internal static class CutFileAudit
private static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, out NativeRect pvAttribute, int cbAttribute);
private const int DwmwaExtendedFrameBounds = 9;
private const int ShowWindowRestore = 9;
private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
private readonly record struct AuditScene(string ScenePath, string RelativePath, string FolderName, string BaseName, BroadcastChannel Channel);
private readonly record struct AuditChannelBinding(int OutputChannelIndex, int LayerNo);

View File

@@ -43,6 +43,7 @@
<Compile Include="..\..\Tornado3_2026Election\Services\ITornado3Adapter.cs" Link="AppSource\Services\ITornado3Adapter.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaChartCellUpdate.cs" Link="AppSource\Services\KarismaChartCellUpdate.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaCounterNumberKeyUpdate.cs" Link="AppSource\Services\KarismaCounterNumberKeyUpdate.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaCropKeyUpdate.cs" Link="AppSource\Services\KarismaCropKeyUpdate.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaEventHandler.cs" Link="AppSource\Services\KarismaEventHandler.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaPositionUpdate.cs" Link="AppSource\Services\KarismaPositionUpdate.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaSceneResolutionReader.cs" Link="AppSource\Services\KarismaSceneResolutionReader.cs" />

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,9 @@
using System;
using System.Globalization;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using KAsyncEngineLib;
@@ -319,6 +321,12 @@ if (args.Length > 0 && string.Equals(args[0], "--validate-live-cuts", StringComp
return;
}
if (args.Length > 0 && string.Equals(args[0], "--audit-party-colors-live", StringComparison.OrdinalIgnoreCase))
{
Environment.ExitCode = await LiveCutValidation.RunPartyColorAuditAsync(args[1..]).ConfigureAwait(false);
return;
}
if (args.Length > 0 && string.Equals(args[0], "--validate-current-api-cuts", StringComparison.OrdinalIgnoreCase))
{
Environment.ExitCode = await CurrentApiCutDiagnostics.RunAsync(args[1..]).ConfigureAwait(false);
@@ -876,6 +884,14 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
var operationResult = ApplySceneOperation(handler, scene!, operation, options.Connection.Timeout);
if (!string.Equals(operationResult.Result, eKResult.RESULT_SUCCESS.ToString(), StringComparison.Ordinal))
{
if (operation.ContinueOnFailure)
{
Console.WriteLine(
$"[SAVE-IMAGE] Optional operation {operationResult.Method} failed for '{operationResult.ObjectName}': " +
$"{operationResult.Result} {operationResult.Detail}");
continue;
}
completion.TrySetResult(
new SaveSceneImageProbeResult(
true,
@@ -1001,31 +1017,38 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
}
}
var positionKeyUpdates = new List<PositionKeyUpdate>();
if (options.PositionKey is not null)
{
positionKeyUpdates.Add(options.PositionKey);
}
positionKeyUpdates.AddRange(options.PositionKeys);
foreach (var positionKeyUpdate in positionKeyUpdates)
{
Console.WriteLine(
$"[SAVE-IMAGE] Setting position key object={options.PositionKey.ObjectName} index={options.PositionKey.KeyIndex} " +
$"value=({options.PositionKey.X},{options.PositionKey.Y},{options.PositionKey.Z}) vector={options.PositionKey.VectorType}...");
var sceneObject = scene.GetObject(options.PositionKey.ObjectName);
$"[SAVE-IMAGE] Setting position key object={positionKeyUpdate.ObjectName} index={positionKeyUpdate.KeyIndex} " +
$"value=({positionKeyUpdate.X},{positionKeyUpdate.Y},{positionKeyUpdate.Z}) vector={positionKeyUpdate.VectorType}...");
var sceneObject = scene.GetObject(positionKeyUpdate.ObjectName);
if (sceneObject is null)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{options.PositionKey.ObjectName}' was not found."));
new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{positionKeyUpdate.ObjectName}' was not found."));
return;
}
handler.ResetPositionKeyTask();
sceneObject.SetPositionKey(
options.PositionKey.KeyIndex,
options.PositionKey.X,
options.PositionKey.Y,
options.PositionKey.Z,
options.PositionKey.VectorType);
positionKeyUpdate.KeyIndex,
positionKeyUpdate.X,
positionKeyUpdate.Y,
positionKeyUpdate.Z,
positionKeyUpdate.VectorType);
if (!WaitForTaskWithMessagePump(handler.PositionKeyTask, options.Connection.Timeout))
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnSetPositionKey timed out for '{options.PositionKey.ObjectName}'." ));
new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnSetPositionKey timed out for '{positionKeyUpdate.ObjectName}'." ));
return;
}
@@ -1033,7 +1056,7 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
if (positionKeyResult != eKResult.RESULT_SUCCESS)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", positionKeyResult.ToString(), options.OutputPath, $"OnSetPositionKey result={positionKeyResult} object={options.PositionKey.ObjectName}"));
new SaveSceneImageProbeResult(true, "SUCCESS", positionKeyResult.ToString(), options.OutputPath, $"OnSetPositionKey result={positionKeyResult} object={positionKeyUpdate.ObjectName}"));
return;
}
}
@@ -1179,53 +1202,161 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
}
}
var outputDirectory = Path.GetDirectoryName(options.OutputPath);
if (!string.IsNullOrWhiteSpace(outputDirectory))
foreach (var positionUpdate in options.PostPositions)
{
Directory.CreateDirectory(outputDirectory);
}
if (File.Exists(options.OutputPath))
{
File.Delete(options.OutputPath);
}
Console.WriteLine("[SAVE-IMAGE] Calling SaveSceneImage()...");
handler.ResetSaveSceneImageTask();
scene.SaveSceneImage(options.OutputPath, options.Width, options.Height, options.Frame);
if (!WaitForTaskWithMessagePump(handler.SaveSceneImageTask, options.Connection.Timeout))
{
completion.TrySetResult(new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, "OnSaveSceneImage timed out."));
return;
}
var saveResult = handler.SaveSceneImageTask.Result;
if (saveResult != eKResult.RESULT_SUCCESS)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", saveResult.ToString(), options.OutputPath, $"OnSaveSceneImage result={saveResult}"));
return;
}
var fileWaitDeadline = DateTime.UtcNow + options.Connection.Timeout;
while (DateTime.UtcNow < fileWaitDeadline)
{
if (File.Exists(options.OutputPath))
Console.WriteLine(
$"[SAVE-IMAGE] Setting post-chart position object={positionUpdate.ObjectName} " +
$"value=({positionUpdate.X},{positionUpdate.Y},{positionUpdate.Z}) vector={positionUpdate.VectorType}...");
var sceneObject = scene.GetObject(positionUpdate.ObjectName);
if (sceneObject is null)
{
var info = new FileInfo(options.OutputPath);
if (info.Length > 0)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", "SUCCESS", options.OutputPath, $"Saved {info.Length} bytes."));
return;
}
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{positionUpdate.ObjectName}' was not found."));
return;
}
Thread.Sleep(50);
handler.ResetPositionTask();
sceneObject.SetPosition(
positionUpdate.X,
positionUpdate.Y,
positionUpdate.Z,
positionUpdate.VectorType);
if (!WaitForTaskWithMessagePump(handler.PositionTask, options.Connection.Timeout))
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnSetPosition timed out for '{positionUpdate.ObjectName}'." ));
return;
}
var positionResult = handler.PositionTask.Result;
if (positionResult != eKResult.RESULT_SUCCESS)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", positionResult.ToString(), options.OutputPath, $"OnSetPosition result={positionResult} object={positionUpdate.ObjectName}"));
return;
}
}
completion.TrySetResult(new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, "Image file was not created."));
foreach (var positionKeyUpdate in options.PostPositionKeys)
{
Console.WriteLine(
$"[SAVE-IMAGE] Setting post-chart position key object={positionKeyUpdate.ObjectName} index={positionKeyUpdate.KeyIndex} " +
$"value=({positionKeyUpdate.X},{positionKeyUpdate.Y},{positionKeyUpdate.Z}) vector={positionKeyUpdate.VectorType}...");
var sceneObject = scene.GetObject(positionKeyUpdate.ObjectName);
if (sceneObject is null)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{positionKeyUpdate.ObjectName}' was not found."));
return;
}
handler.ResetPositionKeyTask();
sceneObject.SetPositionKey(
positionKeyUpdate.KeyIndex,
positionKeyUpdate.X,
positionKeyUpdate.Y,
positionKeyUpdate.Z,
positionKeyUpdate.VectorType);
if (!WaitForTaskWithMessagePump(handler.PositionKeyTask, options.Connection.Timeout))
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnSetPositionKey timed out for '{positionKeyUpdate.ObjectName}'." ));
return;
}
var positionKeyResult = handler.PositionKeyTask.Result;
if (positionKeyResult != eKResult.RESULT_SUCCESS)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", positionKeyResult.ToString(), options.OutputPath, $"OnSetPositionKey result={positionKeyResult} object={positionKeyUpdate.ObjectName}"));
return;
}
}
var captures = new List<(string OutputPath, int Frame)>();
if (options.Frames.Count > 0)
{
var captureDirectory = options.OutputDirectory ?? options.OutputPath;
foreach (var captureFrame in options.Frames)
{
captures.Add((
Path.GetFullPath(Path.Combine(
captureDirectory,
string.Format(CultureInfo.InvariantCulture, options.OutputPattern, captureFrame))),
captureFrame));
}
}
else
{
captures.Add((options.OutputPath, options.Frame));
}
long totalBytes = 0;
foreach (var capture in captures)
{
var outputDirectory = Path.GetDirectoryName(capture.OutputPath);
if (!string.IsNullOrWhiteSpace(outputDirectory))
{
Directory.CreateDirectory(outputDirectory);
}
if (File.Exists(capture.OutputPath))
{
File.Delete(capture.OutputPath);
}
Console.WriteLine($"[SAVE-IMAGE] Calling SaveSceneImage() frame={capture.Frame} output={capture.OutputPath}...");
handler.ResetSaveSceneImageTask();
scene.SaveSceneImage(capture.OutputPath, options.Width, options.Height, capture.Frame);
if (!WaitForTaskWithMessagePump(handler.SaveSceneImageTask, options.Connection.Timeout))
{
completion.TrySetResult(new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", capture.OutputPath, "OnSaveSceneImage timed out."));
return;
}
var saveResult = handler.SaveSceneImageTask.Result;
if (saveResult != eKResult.RESULT_SUCCESS)
{
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", saveResult.ToString(), capture.OutputPath, $"OnSaveSceneImage result={saveResult}"));
return;
}
var savedThisFrame = false;
var fileWaitDeadline = DateTime.UtcNow + options.Connection.Timeout;
while (DateTime.UtcNow < fileWaitDeadline)
{
if (File.Exists(capture.OutputPath))
{
var info = new FileInfo(capture.OutputPath);
if (info.Length > 0)
{
totalBytes += info.Length;
savedThisFrame = true;
break;
}
}
Thread.Sleep(50);
}
if (!savedThisFrame)
{
completion.TrySetResult(new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", capture.OutputPath, "Image file was not created."));
return;
}
}
var resultOutput = options.Frames.Count > 0
? options.OutputDirectory ?? options.OutputPath
: options.OutputPath;
var detail = captures.Count == 1
? $"Saved {totalBytes} bytes."
: $"Saved {captures.Count} frames ({totalBytes} bytes).";
completion.TrySetResult(new SaveSceneImageProbeResult(true, "SUCCESS", "SUCCESS", resultOutput, detail));
}
catch (Exception ex)
{
@@ -3430,6 +3561,9 @@ internal sealed record SaveSceneImageOptions(
int Width,
int Height,
int Frame,
IReadOnlyList<int> Frames,
string? OutputDirectory,
string OutputPattern,
string? SetObjectName,
string? SetObjectValue,
string? VisibleObjectName,
@@ -3442,6 +3576,9 @@ internal sealed record SaveSceneImageOptions(
PositionUpdate? Position,
IReadOnlyList<PositionUpdate> Positions,
PositionKeyUpdate? PositionKey,
IReadOnlyList<PositionKeyUpdate> PositionKeys,
IReadOnlyList<PositionUpdate> PostPositions,
IReadOnlyList<PositionKeyUpdate> PostPositionKeys,
string? ChartObjectName,
string? ChartCsvPath,
IReadOnlyList<ChartCellUpdate> ChartCells,
@@ -3455,6 +3592,9 @@ internal sealed record SaveSceneImageOptions(
string? scenePath = null;
string? sceneAlias = null;
string? outputPath = null;
string? outputDirectory = null;
string outputPattern = "frame_{0:D4}.png";
IReadOnlyList<int> frames = Array.Empty<int>();
string? setObjectName = null;
string? setObjectValue = null;
string? visibleObjectName = null;
@@ -3474,6 +3614,9 @@ internal sealed record SaveSceneImageOptions(
string? positionKeyObjectName = null;
int positionKeyIndex = 1;
string? positionKeyRaw = null;
string? positionKeysRaw = null;
string? postPositionsRaw = null;
string? postPositionKeysRaw = null;
string? chartObjectName = null;
string? chartCsvPath = null;
string? chartCellsRaw = null;
@@ -3497,6 +3640,12 @@ internal sealed record SaveSceneImageOptions(
case "--output" when index + 1 < args.Length:
outputPath = args[++index];
break;
case "--output-dir" when index + 1 < args.Length:
outputDirectory = args[++index];
break;
case "--output-pattern" when index + 1 < args.Length:
outputPattern = args[++index];
break;
case "--set-object" when index + 1 < args.Length:
setObjectName = args[++index];
break;
@@ -3561,6 +3710,15 @@ internal sealed record SaveSceneImageOptions(
case "--position-key" when index + 1 < args.Length:
positionKeyRaw = args[++index];
break;
case "--position-keys" when index + 1 < args.Length:
positionKeysRaw = args[++index];
break;
case "--post-positions" when index + 1 < args.Length:
postPositionsRaw = args[++index];
break;
case "--post-position-keys" when index + 1 < args.Length:
postPositionKeysRaw = args[++index];
break;
case "--chart-object" when index + 1 < args.Length:
chartObjectName = args[++index];
break;
@@ -3591,6 +3749,9 @@ internal sealed record SaveSceneImageOptions(
frame = parsedFrame;
index++;
break;
case "--frames" when index + 1 < args.Length:
frames = ParseFrameSequence(args[++index]);
break;
}
}
@@ -3599,7 +3760,18 @@ internal sealed record SaveSceneImageOptions(
throw new ArgumentException("--scene is required.");
}
if (string.IsNullOrWhiteSpace(outputPath))
if (frames.Count > 0)
{
outputDirectory ??= outputPath;
if (string.IsNullOrWhiteSpace(outputDirectory))
{
throw new ArgumentException("--output-dir is required when --frames is provided.");
}
outputDirectory = Path.GetFullPath(outputDirectory);
outputPath ??= outputDirectory;
}
else if (string.IsNullOrWhiteSpace(outputPath))
{
throw new ArgumentException("--output is required.");
}
@@ -3618,6 +3790,9 @@ internal sealed record SaveSceneImageOptions(
width,
height,
frame,
frames,
outputDirectory,
outputPattern,
setObjectName,
setObjectValue,
visibleObjectName,
@@ -3630,6 +3805,9 @@ internal sealed record SaveSceneImageOptions(
ParsePosition(positionObjectName, positionRaw),
ParsePositions(positionsRaw),
ParsePositionKey(positionKeyObjectName, positionKeyIndex, positionKeyRaw),
ParsePositionKeys(positionKeysRaw),
ParsePositions(postPositionsRaw),
ParsePositionKeys(postPositionKeysRaw),
chartObjectName,
chartCsvPath,
ParseChartCells(chartCellsRaw),
@@ -3638,6 +3816,53 @@ internal sealed record SaveSceneImageOptions(
ParsePathModifications(modifyPathRaw));
}
private static IReadOnlyList<int> ParseFrameSequence(string value)
{
var frames = new List<int>();
foreach (var token in value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var rangeMatch = Regex.Match(token, @"^(?<start>-?\d+)-(?<end>-?\d+)(?::(?<step>\d+))?$", RegexOptions.CultureInvariant);
if (rangeMatch.Success)
{
var start = int.Parse(rangeMatch.Groups["start"].Value, CultureInfo.InvariantCulture);
var end = int.Parse(rangeMatch.Groups["end"].Value, CultureInfo.InvariantCulture);
var step = rangeMatch.Groups["step"].Success
? int.Parse(rangeMatch.Groups["step"].Value, CultureInfo.InvariantCulture)
: 1;
if (step <= 0)
{
throw new ArgumentException("--frames range step must be greater than zero.");
}
if (start <= end)
{
for (var frame = start; frame <= end; frame += step)
{
frames.Add(frame);
}
}
else
{
for (var frame = start; frame >= end; frame -= step)
{
frames.Add(frame);
}
}
continue;
}
if (!int.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out var singleFrame))
{
throw new ArgumentException($"Invalid frame token: {token}");
}
frames.Add(singleFrame);
}
return frames.Distinct().ToArray();
}
private static CloneObjectUpdate? ParseCloneObject(string? sourceObjectName, string? variableName)
{
if (string.IsNullOrWhiteSpace(sourceObjectName) || string.IsNullOrWhiteSpace(variableName))
@@ -3776,6 +4001,38 @@ internal sealed record SaveSceneImageOptions(
return new PositionKeyUpdate(objectName, keyIndex, x, y, z, vectorType);
}
private static IReadOnlyList<PositionKeyUpdate> ParsePositionKeys(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
{
return Array.Empty<PositionKeyUpdate>();
}
var updates = new List<PositionKeyUpdate>();
foreach (var token in raw.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var nameParts = token.Split('=', 2, StringSplitOptions.TrimEntries);
if (nameParts.Length != 2)
{
throw new ArgumentException($"Invalid position key update: {token}");
}
var objectAndKey = nameParts[0].Split('#', 2, StringSplitOptions.TrimEntries);
if (objectAndKey.Length != 2 || !int.TryParse(objectAndKey[1], out var keyIndex))
{
throw new ArgumentException($"Invalid position key object/index: {nameParts[0]}");
}
var update = ParsePositionKey(objectAndKey[0], keyIndex, nameParts[1]);
if (update is not null)
{
updates.Add(update);
}
}
return updates;
}
private static IReadOnlyList<ChartCellUpdate> ParseChartCells(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
@@ -4410,6 +4667,8 @@ internal sealed class SceneValidationOperation
public int A { get; set; } = 255;
public bool Visible { get; set; }
public bool ContinueOnFailure { get; set; }
}
internal sealed record SceneOperationValidationResult(string ObjectName, string Method, string Payload, string Result, string Detail);