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

View File

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

View File

@@ -74,4 +74,5 @@ public sealed record TurnoutBoardSlotEntry(
string Label, string Label,
double TurnoutRate, double TurnoutRate,
bool IsNational = false, 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="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="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="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="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="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="settings"><NavigationViewItem.Icon><SymbolIcon Symbol="Setting" /></NavigationViewItem.Icon></NavigationViewItem>
<NavigationViewItem Content="로그" Tag="log"><NavigationViewItem.Icon><SymbolIcon Symbol="Document" /></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 public sealed class ChannelScheduleEngine
{ {
private const int PreviewFrame = -1; private const int PreviewFrame = -1;
private static readonly TimeSpan MinimumNextPreviewWindow = TimeSpan.FromSeconds(2.5);
private readonly ITornado3Adapter _adapter; private readonly ITornado3Adapter _adapter;
private readonly IDataRefreshGate _dataRefreshGate; private readonly IDataRefreshGate _dataRefreshGate;
private readonly Func<BroadcastStationProfile> _stationProvider; private readonly Func<BroadcastStationProfile> _stationProvider;
@@ -26,6 +27,7 @@ public sealed class ChannelScheduleEngine
private Guid? _preferredNextItemId; private Guid? _preferredNextItemId;
private Guid? _skipCurrentItemId; private Guid? _skipCurrentItemId;
private ChannelScheduleItem? _directPlaybackItem; private ChannelScheduleItem? _directPlaybackItem;
private PreparedCutFrame? _preparedCutFrame;
public ChannelScheduleEngine( public ChannelScheduleEngine(
BroadcastChannel channel, BroadcastChannel channel,
@@ -66,6 +68,11 @@ public sealed class ChannelScheduleEngine
public event EventHandler? QueueChanged; public event EventHandler? QueueChanged;
public bool IsPreparedItem(ChannelScheduleItem item)
{
return _preparedCutFrame?.Item.Id == item.Id;
}
public async Task StartAsync() public async Task StartAsync()
{ {
if (IsRunning) if (IsRunning)
@@ -74,6 +81,11 @@ public sealed class ChannelScheduleEngine
return; return;
} }
if (_preparedCutFrame is { Item: var preparedItem } && !Queue.Contains(preparedItem))
{
ClearPreparedFrame(resetState: true);
}
_playbackCts = new CancellationTokenSource(); _playbackCts = new CancellationTokenSource();
IsRunning = true; IsRunning = true;
RefreshQueueMarkers(); RefreshQueueMarkers();
@@ -85,6 +97,21 @@ public sealed class ChannelScheduleEngine
{ {
if (!IsRunning) 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; return;
} }
@@ -105,11 +132,97 @@ public sealed class ChannelScheduleEngine
_preferredNextItemId = null; _preferredNextItemId = null;
_skipCurrentItemId = null; _skipCurrentItemId = null;
ClearPreparedFrame(resetState: false);
IsRunning = false; IsRunning = false;
RefreshQueueMarkers(); RefreshQueueMarkers();
QueueChanged?.Invoke(this, EventArgs.Empty); 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( public async Task PlayDirectAsync(
ChannelScheduleItem item, ChannelScheduleItem item,
FormatTemplateDefinition template, FormatTemplateDefinition template,
@@ -139,6 +252,7 @@ public sealed class ChannelScheduleEngine
public void Reset() public void Reset()
{ {
_preferredNextItemId = null; _preferredNextItemId = null;
ClearPreparedFrame(resetState: false);
foreach (var item in Queue) foreach (var item in Queue)
{ {
item.State = ScheduleQueueItemState.Queued; item.State = ScheduleQueueItemState.Queued;
@@ -311,12 +425,94 @@ public sealed class ChannelScheduleEngine
IsRunning = false; IsRunning = false;
RefreshQueueMarkers(); 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 finally
{ {
_executionLock.Release(); _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) private async Task PlayItemAsync(ChannelScheduleItem queueItem, FormatTemplateDefinition template, CancellationToken cancellationToken)
{ {
var station = _stationProvider(); var station = _stationProvider();
@@ -521,9 +717,22 @@ public sealed class ChannelScheduleEngine
RefreshQueueMarkers(); RefreshQueueMarkers();
QueueChanged?.Invoke(this, EventArgs.Empty); QueueChanged?.Invoke(this, EventArgs.Empty);
await _adapter.EnsureConnectedAsync(cancellationToken).ConfigureAwait(false); var preparedFrame = TryConsumePreparedFrame(queueItem, template, cut);
await _adapter.ApplyCutAsync(Channel, template, cut, snapshot, station, imageRootPath, cancellationToken).ConfigureAwait(false); var playbackCut = preparedFrame?.Cut ?? cut;
await _adapter.PrepareAsync(Channel, cancellationToken).ConfigureAwait(false); 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); await _adapter.TakeAsync(Channel, cancellationToken).ConfigureAwait(false);
var onAirAt = DateTimeOffset.Now; var onAirAt = DateTimeOffset.Now;
@@ -539,36 +748,39 @@ public sealed class ChannelScheduleEngine
signal.TrySetResult(true); signal.TrySetResult(true);
} }
await CaptureCurrentPreviewAsync( var durationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(cut.DurationSeconds, template);
queueItem, var playbackDuration = TimeSpan.FromSeconds(durationSeconds);
template, await CaptureCurrentPreviewAsync(queueItem, template, cancellationToken).ConfigureAwait(false);
cut,
snapshot,
station,
imageRootPath,
cancellationToken).ConfigureAwait(false);
QueueChanged?.Invoke(this, EventArgs.Empty); QueueChanged?.Invoke(this, EventArgs.Empty);
if (!ShouldSkipCurrentItem(queueItem)) if (!ShouldSkipCurrentItem(queueItem))
{ {
CutPreviewFrame? nextInternalPreviewFrame = null; var remainingForPreview = playbackDuration - (DateTimeOffset.Now - onAirAt);
if (nextInternalPreviewFrameFactory is not null) 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); await CaptureNextPreviewAsync(queueItem, template, nextInternalPreviewFrame, station, imageRootPath, cancellationToken).ConfigureAwait(false);
QueueChanged?.Invoke(this, EventArgs.Empty); QueueChanged?.Invoke(this, EventArgs.Empty);
}
else
{
MarkQueueNextPreviewStatus(queueItem, $"빠른 송출 중 프리뷰 생략 {DateTimeOffset.Now:HH:mm:ss}");
}
} }
if (ShouldSkipCurrentItem(queueItem)) if (ShouldSkipCurrentItem(queueItem))
@@ -576,8 +788,7 @@ public sealed class ChannelScheduleEngine
return; return;
} }
var durationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(cut.DurationSeconds, template); var remainingDuration = playbackDuration - (DateTimeOffset.Now - onAirAt);
var remainingDuration = TimeSpan.FromSeconds(durationSeconds) - (DateTimeOffset.Now - onAirAt);
if (remainingDuration <= TimeSpan.Zero) if (remainingDuration <= TimeSpan.Zero)
{ {
return; return;
@@ -590,10 +801,6 @@ public sealed class ChannelScheduleEngine
private async Task CaptureCurrentPreviewAsync( private async Task CaptureCurrentPreviewAsync(
ChannelScheduleItem queueItem, ChannelScheduleItem queueItem,
FormatTemplateDefinition template, FormatTemplateDefinition template,
FormatCutDefinition cut,
ElectionDataSnapshot snapshot,
BroadcastStationProfile station,
string imageRootPath,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (!_adapter.IsLiveCg) if (!_adapter.IsLiveCg)
@@ -603,13 +810,8 @@ public sealed class ChannelScheduleEngine
var size = ThumbnailLayoutResolver.ResolveGenerationSize(template, _videoWallLayoutPresetProvider()); var size = ThumbnailLayoutResolver.ResolveGenerationSize(template, _videoWallLayoutPresetProvider());
var previewPath = CutPreviewAssetCatalog.CreateCapturePath(Channel, queueItem.Id, "current"); var previewPath = CutPreviewAssetCatalog.CreateCapturePath(Channel, queueItem.Id, "current");
var captured = await _adapter.TryCaptureCutPreviewAsync( var captured = await _adapter.TryCapturePendingCutPreviewAsync(
Channel, Channel,
template,
cut,
snapshot,
station,
imageRootPath,
previewPath, previewPath,
size.Width, size.Width,
size.Height, size.Height,
@@ -618,6 +820,8 @@ public sealed class ChannelScheduleEngine
if (!captured) if (!captured)
{ {
await UiDispatcher.EnqueueAsync(() =>
queueItem.UpdateRenderedPreviewStatus($"현재 화면 캡처 지연 {DateTimeOffset.Now:HH:mm:ss}")).ConfigureAwait(false);
return; return;
} }
@@ -701,6 +905,8 @@ public sealed class ChannelScheduleEngine
if (!captured) if (!captured)
{ {
await UiDispatcher.EnqueueAsync(() =>
nextItem.UpdateRenderedPreviewStatus($"다음 프리뷰 캡처 지연 {DateTimeOffset.Now:HH:mm:ss}")).ConfigureAwait(false);
return; return;
} }
@@ -708,6 +914,17 @@ public sealed class ChannelScheduleEngine
nextItem.UpdateRenderedPreview(previewPath, $"다음 변수 적용 캡처 {DateTimeOffset.Now:HH:mm:ss}")).ConfigureAwait(false); 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( private async Task CaptureInternalNextPreviewAsync(
ChannelScheduleItem activeItem, ChannelScheduleItem activeItem,
FormatTemplateDefinition template, FormatTemplateDefinition template,
@@ -904,6 +1121,59 @@ public sealed class ChannelScheduleEngine
return _skipCurrentItemId == queueItem.Id; 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) private void ClearSkipCurrentItem(ChannelScheduleItem queueItem)
{ {
if (_skipCurrentItemId == queueItem.Id) if (_skipCurrentItemId == queueItem.Id)
@@ -944,8 +1214,7 @@ public sealed class ChannelScheduleEngine
return IsBottomTurnoutBoardTemplate(template); return IsBottomTurnoutBoardTemplate(template);
} }
return (template.RecommendedChannel == BroadcastChannel.Normal && return IsNormalPreElectionTurnoutDistrictBoardTemplate(template) ||
string.Equals(template.Name, "투표율_선거구별 사전", StringComparison.Ordinal)) ||
IsTopTurnoutDistrictBoardTemplate(template); IsTopTurnoutDistrictBoardTemplate(template);
} }
@@ -976,6 +1245,14 @@ public sealed class ChannelScheduleEngine
.ToArray(); .ToArray();
} }
if (IsNormalPreElectionTurnoutDistrictBoardTemplate(template))
{
return regionTargets
.GroupBy(ResolveCouncilSeatTableRegionKey, StringComparer.OrdinalIgnoreCase)
.SelectMany(group => ChunkRegionTargets(group.ToArray(), 7))
.ToArray();
}
if (!IsBasicCouncilWinnerTemplate(template) && if (!IsBasicCouncilWinnerTemplate(template) &&
!ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template.Name)) !ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template.Name))
{ {
@@ -1048,6 +1325,12 @@ public sealed class ChannelScheduleEngine
string.Equals(template.Name, "투표율_선거구별", StringComparison.Ordinal); 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) private static bool IsBottomTurnoutBoardTemplate(FormatTemplateDefinition template)
{ {
return template.RecommendedChannel == BroadcastChannel.Bottom && return template.RecommendedChannel == BroadcastChannel.Bottom &&
@@ -1327,6 +1610,11 @@ public sealed class ChannelScheduleEngine
private ChannelScheduleItem? GetNextPlayableItem() private ChannelScheduleItem? GetNextPlayableItem()
{ {
if (_preparedCutFrame is { Item: var preparedItem } && Queue.Contains(preparedItem))
{
return preparedItem;
}
return Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Next) return Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Next)
?? Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Queued); ?? 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); 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 = private static readonly IReadOnlyList<CutCategory> OrderedCategories =
[ [
CutCategory.Title,
CutCategory.MetropolitanHead, CutCategory.MetropolitanHead,
CutCategory.LocalHead, CutCategory.LocalHead,
CutCategory.Superintendent, CutCategory.Superintendent,
@@ -25,8 +26,7 @@ public static class CutCategoryResolver
CutCategory.BottomElectionDayTurnout, CutCategory.BottomElectionDayTurnout,
CutCategory.PreElection, CutCategory.PreElection,
CutCategory.Historical, CutCategory.Historical,
CutCategory.Turnout, CutCategory.Turnout
CutCategory.Title
]; ];
public static IReadOnlyList<CutCategory> GetOrderedCategories() => OrderedCategories; public static IReadOnlyList<CutCategory> GetOrderedCategories() => OrderedCategories;
@@ -37,7 +37,7 @@ public static class CutCategoryResolver
return category switch return category switch
{ {
CutCategory.MetropolitanHead => Contains(formatName, "광역단체장"), CutCategory.MetropolitanHead => IsMetropolitanHeadFormat(formatName),
CutCategory.LocalHead => Contains(formatName, "기초단체장"), CutCategory.LocalHead => Contains(formatName, "기초단체장"),
CutCategory.Superintendent => Contains(formatName, "교육감"), CutCategory.Superintendent => Contains(formatName, "교육감"),
CutCategory.MetropolitanCouncil => Contains(formatName, "광역의원"), CutCategory.MetropolitanCouncil => Contains(formatName, "광역의원"),
@@ -94,6 +94,12 @@ public static class CutCategoryResolver
return value.Contains(token, StringComparison.Ordinal); 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) private static bool IsBottomCountingTemplate(FormatTemplateDefinition template, string prefix)
{ {
return template.RecommendedChannel == BroadcastChannel.Bottom && return template.RecommendedChannel == BroadcastChannel.Bottom &&

View File

@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using Microsoft.UI.Xaml.Media; using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Imaging; using Microsoft.UI.Xaml.Media.Imaging;
@@ -70,18 +71,15 @@ public static class CutThumbnailAssetCatalog
public static bool HasThumbnail(string templateId) 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); return false;
if (string.Equals(templateId, fallbackTemplateId, StringComparison.Ordinal))
{
return false;
}
return HasThumbnailPath(fallbackTemplateId);
} }
private static bool HasThumbnailPath(string templateId) private static bool HasThumbnailPath(string templateId)
@@ -115,37 +113,64 @@ public static class CutThumbnailAssetCatalog
public static string ResolvePreferredDisplayPath(string templateId) public static string ResolvePreferredDisplayPath(string templateId)
{ {
var projectPath = TryGetProjectAssetPath(templateId); foreach (var candidateTemplateId in EnumerateThumbnailTemplateIds(templateId))
if (!string.IsNullOrWhiteSpace(projectPath) && File.Exists(projectPath))
{ {
return projectPath; var projectPath = TryGetProjectAssetPath(candidateTemplateId);
} if (!string.IsNullOrWhiteSpace(projectPath) && File.Exists(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))
{ {
return fallbackProjectPath; return projectPath;
} }
var fallbackBundledPath = GetBundledAssetPath(fallbackTemplateId); var bundledPath = GetBundledAssetPath(candidateTemplateId);
if (File.Exists(fallbackBundledPath)) if (File.Exists(bundledPath))
{ {
return fallbackBundledPath; return bundledPath;
} }
} }
return Path.Combine(AppContext.BaseDirectory, FallbackAssetPath); 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) private static string ResolveThumbnailTemplateId(string templateId)
{ {
if (string.IsNullOrWhiteSpace(templateId)) if (string.IsNullOrWhiteSpace(templateId))

View File

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

View File

@@ -55,7 +55,11 @@ public interface ITornado3Adapter
Task PrepareAsync(BroadcastChannel channel, CancellationToken cancellationToken); Task PrepareAsync(BroadcastChannel channel, CancellationToken cancellationToken);
Task ShowPreparedFirstFrameAsync(BroadcastChannel channel, CancellationToken cancellationToken);
Task TakeAsync(BroadcastChannel channel, CancellationToken cancellationToken); Task TakeAsync(BroadcastChannel channel, CancellationToken cancellationToken);
Task ClearOutputAsync(BroadcastChannel channel, CancellationToken cancellationToken);
Task OutAsync(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); completion.TrySetException(error);
} }
public void OnLoadScene(eKResult Result, string SceneName) public void OnLoadScene(eKResult Result, string SceneName)
{ {
LogResult(nameof(OnLoadScene), Result, $"scene={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 OnSetCylinderAngleKey(eKResult Result, string SceneName, string ObjectName) { }
virtual public void OnSetSphereAngleKey(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 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 OnSetCountDown(eKResult Result, string SceneName, string ObjectName) { }
virtual public void OnSetPosition(eKResult Result, string SceneName, string ObjectName) { } virtual public void OnSetPosition(eKResult Result, string SceneName, string ObjectName) { }
virtual public void OnSetRotation(eKResult Result, string SceneName, string ObjectName) { } virtual public void OnSetRotation(eKResult Result, string SceneName, string ObjectName) { }
@@ -674,6 +675,65 @@ public class KarismaEventHandler : KAEventHandler
completion?.TrySetResult(errorCode); 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) private static void CompleteOrCancel<TResult>(TaskCompletionSource<TResult>? completion, Exception? error)
{ {
if (completion is null) if (completion is null)

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
using System; using System;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.IO;
using Tornado3_2026Election.Domain; using Tornado3_2026Election.Domain;
namespace Tornado3_2026Election.Services; namespace Tornado3_2026Election.Services;
@@ -7,6 +8,12 @@ namespace Tornado3_2026Election.Services;
public sealed class LogService public sealed class LogService
{ {
private const int MaxEntries = 400; 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; } = []; public ObservableCollection<LogEntry> Entries { get; } = [];
@@ -20,6 +27,8 @@ public sealed class LogService
private void Add(LogLevel level, string message) private void Add(LogLevel level, string message)
{ {
WriteDebugLog(level, message);
Common.UiDispatcher.Enqueue(() => Common.UiDispatcher.Enqueue(() =>
{ {
Entries.Insert(0, new LogEntry 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); }, 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) public async Task TakeAsync(BroadcastChannel channel, CancellationToken cancellationToken)
{ {
await ExecuteWithTimeoutAsync(async () => await ExecuteWithTimeoutAsync(async () =>
@@ -121,6 +131,16 @@ public sealed class MockTornado3Adapter : ITornado3Adapter
}, cancellationToken).ConfigureAwait(false); }, 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) public async Task OutAsync(BroadcastChannel channel, CancellationToken cancellationToken)
{ {
await ExecuteWithTimeoutAsync(async () => 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)); var folderName = Path.GetFileName(Path.GetFullPath(templateFolderPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
if (TryGetExplicitRgbSpecBaseName(folderName, templateName, out var explicitSpecBaseName) && 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"); var explicitSpecPath = Path.Combine(rgbDirectoryPath, explicitSpecBaseName + ".txt");
if (File.Exists(explicitSpecPath)) if (File.Exists(explicitSpecPath))
@@ -265,6 +270,11 @@ internal static class PartyColorCatalog
if (line.StartsWith("(", StringComparison.Ordinal)) if (line.StartsWith("(", StringComparison.Ordinal))
{ {
if (inHeader)
{
currentSectionHeaders = ExtractSectionHeaders(headerBuilder.ToString());
}
headerBuilder.Clear(); headerBuilder.Clear();
headerBuilder.AppendLine(line); headerBuilder.AppendLine(line);
inHeader = !line.Contains(')'); inHeader = !line.Contains(')');
@@ -278,14 +288,20 @@ internal static class PartyColorCatalog
if (inHeader) if (inHeader)
{ {
headerBuilder.AppendLine(line); if (IsHeaderContinuationLine(line))
if (line.Contains(')'))
{ {
inHeader = false; headerBuilder.AppendLine(line);
currentSectionHeaders = ExtractSectionHeaders(headerBuilder.ToString()); 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)) if (currentSectionHeaders is null || currentSectionHeaders.Count == 0 || line.StartsWith("R", StringComparison.OrdinalIgnoreCase))
@@ -328,6 +344,17 @@ internal static class PartyColorCatalog
StringComparer.OrdinalIgnoreCase)); 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) private static List<SectionHeaderEntry> ExtractSectionHeaders(string header)
{ {
var entries = new List<SectionHeaderEntry>(); var entries = new List<SectionHeaderEntry>();
@@ -871,7 +898,12 @@ internal static class PartyColorCatalog
"Elect2026_Normal_민방", "Elect2026_Normal_민방",
"이시각1위_광역단체장", "이시각1위_광역단체장",
"이시각1위_광역단체장", "이시각1위_광역단체장",
"이시각1위_광역단체장_HD", "이시각1위_광역단체장_HD");
Add(
mappings,
"Elect2026_Normal_민방",
"이시각1위_광역단체장_5760",
"이시각1위_광역단체장_5760",
"이시각1위_광역단체장_L"); "이시각1위_광역단체장_L");
Add( Add(
mappings, mappings,
@@ -894,8 +926,15 @@ internal static class PartyColorCatalog
"판세_광역단체장", "판세_광역단체장",
"판세_광역단체장", "판세_광역단체장",
"판세_기초단체장", "판세_기초단체장",
"역대시도판세_광역단체장",
"역대시도판세_기초단체장",
"판세_기초단체장_5760", "판세_기초단체장_5760",
"판세_기초단체장_7680"); "판세_기초단체장_7680");
Add(
mappings,
"Elect2026_Normal_민방",
string.Empty,
"사전_역대투표율");
Add( Add(
mappings, 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 ScheduleRegionOption? _selectedRegionOption;
private SelectionOption<EmptyScheduleBehavior>? _selectedEmptyBehaviorOption; private SelectionOption<EmptyScheduleBehavior>? _selectedEmptyBehaviorOption;
private CancellationTokenSource? _directPlaybackCts; private CancellationTokenSource? _directPlaybackCts;
private ChannelScheduleItem? _preparedDirectItem;
private string _preparedDirectFormatId = string.Empty;
private string _preparedDirectRegionKey = string.Empty;
private bool _loopEnabled; private bool _loopEnabled;
private EmptyScheduleBehavior _emptyScheduleBehavior = EmptyScheduleBehavior.ImmediateOut; private EmptyScheduleBehavior _emptyScheduleBehavior = EmptyScheduleBehavior.ImmediateOut;
private int _regionOptionsRevision; private int _regionOptionsRevision;
@@ -81,8 +84,10 @@ public sealed class ChannelScheduleViewModel : ObservableObject
]; ];
Queue = engine.Queue; Queue = engine.Queue;
SchedulePrepareCommand = new AsyncRelayCommand(PrepareScheduleAsync);
StartCommand = new AsyncRelayCommand(StartAsync, allowConcurrentExecutions: true); StartCommand = new AsyncRelayCommand(StartAsync, allowConcurrentExecutions: true);
StopCommand = new AsyncRelayCommand(StopAsync); StopCommand = new AsyncRelayCommand(StopAsync);
DirectPrepareCommand = new AsyncRelayCommand(DirectPrepareAsync, CanDirectStart);
DirectStartCommand = new AsyncRelayCommand(DirectStartAsync, CanDirectStart, allowConcurrentExecutions: true); DirectStartCommand = new AsyncRelayCommand(DirectStartAsync, CanDirectStart, allowConcurrentExecutions: true);
DirectStopCommand = new AsyncRelayCommand(DirectStopAsync); DirectStopCommand = new AsyncRelayCommand(DirectStopAsync);
ForceNextCommand = new AsyncRelayCommand(ForceNextAsync); ForceNextCommand = new AsyncRelayCommand(ForceNextAsync);
@@ -141,10 +146,14 @@ public sealed class ChannelScheduleViewModel : ObservableObject
public ObservableCollection<ChannelScheduleItem> Queue { get; } public ObservableCollection<ChannelScheduleItem> Queue { get; }
public AsyncRelayCommand SchedulePrepareCommand { get; }
public AsyncRelayCommand StartCommand { get; } public AsyncRelayCommand StartCommand { get; }
public AsyncRelayCommand StopCommand { get; } public AsyncRelayCommand StopCommand { get; }
public AsyncRelayCommand DirectPrepareCommand { get; }
public AsyncRelayCommand DirectStartCommand { get; } public AsyncRelayCommand DirectStartCommand { get; }
public AsyncRelayCommand DirectStopCommand { get; } public AsyncRelayCommand DirectStopCommand { get; }
@@ -230,6 +239,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
SyncSelectedCutDebugTemplate(); SyncSelectedCutDebugTemplate();
_ = RebuildRegionOptionsAsync(); _ = RebuildRegionOptionsAsync();
AddFormatCommand.NotifyCanExecuteChanged(); AddFormatCommand.NotifyCanExecuteChanged();
DirectPrepareCommand.NotifyCanExecuteChanged();
DirectStartCommand.NotifyCanExecuteChanged(); DirectStartCommand.NotifyCanExecuteChanged();
} }
} }
@@ -243,6 +253,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
if (SetProperty(ref _selectedRegionOption, value)) if (SetProperty(ref _selectedRegionOption, value))
{ {
AddFormatCommand.NotifyCanExecuteChanged(); AddFormatCommand.NotifyCanExecuteChanged();
DirectPrepareCommand.NotifyCanExecuteChanged();
DirectStartCommand.NotifyCanExecuteChanged(); DirectStartCommand.NotifyCanExecuteChanged();
} }
} }
@@ -332,7 +343,9 @@ public sealed class ChannelScheduleViewModel : ObservableObject
public Brush PlaybackIconBrush => IsPlaying ? PlaybackActiveIconBrush : PlaybackIdleIconBrush; 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}] 큐를 시작"); _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() private async Task StopAsync()
{ {
await _engine.StopAsync().ConfigureAwait(false); await _engine.StopAsync().ConfigureAwait(false);
@@ -526,6 +554,57 @@ public sealed class ChannelScheduleViewModel : ObservableObject
_logService.Info($"[{Title}] 큐를 종료"); _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() private async Task DirectStartAsync()
{ {
var selectedFormat = SelectedFormat; var selectedFormat = SelectedFormat;
@@ -536,22 +615,24 @@ public sealed class ChannelScheduleViewModel : ObservableObject
return; return;
} }
if (!selectedFormat.IsAvailableInPhase(_data.BroadcastPhase)) var preparedItem = ResolvePreparedDirectItem(selectedFormat, regionOption);
if (preparedItem is null)
{ {
_logService.Warning($"[{Title}] 현재 단계에서는 '{selectedFormat.Name}' 컷을 바로 송출할 수 없습니다."); await _engine.StopAsync(takeOutputOff: false).ConfigureAwait(false);
return; _directPlaybackCts?.Cancel();
_directPlaybackCts?.Dispose();
} }
await _engine.StopAsync(takeOutputOff: false).ConfigureAwait(false);
_directPlaybackCts?.Cancel();
var playbackCts = new CancellationTokenSource(); var playbackCts = new CancellationTokenSource();
_directPlaybackCts = playbackCts; _directPlaybackCts = playbackCts;
var item = ChannelScheduleItem.FromTemplate(selectedFormat, regionOption); var item = preparedItem ?? ChannelScheduleItem.FromTemplate(selectedFormat, regionOption);
item.UpdateThumbnailLayout(ThumbnailLayoutResolver.ResolveDisplayMetrics( if (preparedItem is null)
selectedFormat, {
_videoWallLayoutPreset, item.UpdateThumbnailLayout(ThumbnailLayoutResolver.ResolveDisplayMetrics(
ThumbnailDisplayContext.Queue)); selectedFormat,
_videoWallLayoutPreset,
ThumbnailDisplayContext.Queue));
}
try try
{ {
@@ -568,6 +649,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
} }
finally finally
{ {
ClearPreparedDirectState(item);
if (ReferenceEquals(_directPlaybackCts, playbackCts)) if (ReferenceEquals(_directPlaybackCts, playbackCts))
{ {
_directPlaybackCts = null; _directPlaybackCts = null;
@@ -582,10 +664,49 @@ public sealed class ChannelScheduleViewModel : ObservableObject
{ {
_directPlaybackCts?.Cancel(); _directPlaybackCts?.Cancel();
await _adapter.OutAsync(Channel, CancellationToken.None).ConfigureAwait(false); await _adapter.OutAsync(Channel, CancellationToken.None).ConfigureAwait(false);
ClearPreparedDirectState(_preparedDirectItem);
_engine.ClearDirectPlayback();
RefreshSummary(); RefreshSummary();
_logService.Info($"[{Title}] 선택 컷 송출 정지"); _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() private async Task ForceNextAsync()
{ {
await _engine.ForceNextAsync().ConfigureAwait(false); await _engine.ForceNextAsync().ConfigureAwait(false);
@@ -607,12 +728,6 @@ public sealed class ChannelScheduleViewModel : ObservableObject
return; return;
} }
if (!SelectedFormat.IsAvailableInPhase(_data.BroadcastPhase))
{
_logService.Warning($"[{Title}] 현재 단계에서는 '{SelectedFormat.Name}' 컷을 추가할 수 없습니다.");
return;
}
var regionOption = SelectedRegionOption ?? RegionOptions.FirstOrDefault(); var regionOption = SelectedRegionOption ?? RegionOptions.FirstOrDefault();
if (regionOption is null) if (regionOption is null)
{ {
@@ -745,7 +860,6 @@ public sealed class ChannelScheduleViewModel : ObservableObject
private bool CanAddFormat() private bool CanAddFormat()
{ {
return SelectedFormat is not null && return SelectedFormat is not null &&
SelectedFormat.IsAvailableInPhase(_data.BroadcastPhase) &&
SelectedRegionOption is not null; SelectedRegionOption is not null;
} }
@@ -770,7 +884,6 @@ public sealed class ChannelScheduleViewModel : ObservableObject
var selectedFormatId = SelectedFormat?.Id; var selectedFormatId = SelectedFormat?.Id;
var selectedCategory = SelectedFormatCategoryOption?.Value; var selectedCategory = SelectedFormatCategoryOption?.Value;
var filteredFormats = _allFormats var filteredFormats = _allFormats
.Where(format => format.IsAvailableInPhase(_data.BroadcastPhase))
.Where(format => selectedCategory is null || CutCategoryResolver.IsMatch(format, selectedCategory.Value)) .Where(format => selectedCategory is null || CutCategoryResolver.IsMatch(format, selectedCategory.Value))
.ToArray(); .ToArray();
@@ -792,6 +905,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
UpdateSelectedFormatThumbnailMetrics(); UpdateSelectedFormatThumbnailMetrics();
SyncSelectedCutDebugTemplate(); SyncSelectedCutDebugTemplate();
AddFormatCommand.NotifyCanExecuteChanged(); AddFormatCommand.NotifyCanExecuteChanged();
DirectPrepareCommand.NotifyCanExecuteChanged();
DirectStartCommand.NotifyCanExecuteChanged(); DirectStartCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(QueueFootnote)); OnPropertyChanged(nameof(QueueFootnote));
} }
@@ -799,10 +913,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
private void RebuildFormatCategoryOptions() private void RebuildFormatCategoryOptions()
{ {
var selectedCategory = SelectedFormatCategoryOption?.Value; var selectedCategory = SelectedFormatCategoryOption?.Value;
var formatsInCurrentPhase = _allFormats var options = CreateFormatCategoryOptions(_allFormats);
.Where(format => format.IsAvailableInPhase(_data.BroadcastPhase))
.ToArray();
var options = CreateFormatCategoryOptions(formatsInCurrentPhase);
FormatCategoryOptions.Clear(); FormatCategoryOptions.Clear();
foreach (var option in options) foreach (var option in options)
@@ -871,6 +982,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
SelectedRegionOption = null; SelectedRegionOption = null;
_lastRegionOptionFormatId = string.Empty; _lastRegionOptionFormatId = string.Empty;
AddFormatCommand.NotifyCanExecuteChanged(); AddFormatCommand.NotifyCanExecuteChanged();
DirectPrepareCommand.NotifyCanExecuteChanged();
DirectStartCommand.NotifyCanExecuteChanged(); DirectStartCommand.NotifyCanExecuteChanged();
return; return;
} }
@@ -898,6 +1010,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
SelectedRegionOption = ResolvePreferredRegionOption(options, previousSelection, selectedFormat, shouldUseDefaultSelection); SelectedRegionOption = ResolvePreferredRegionOption(options, previousSelection, selectedFormat, shouldUseDefaultSelection);
_lastRegionOptionFormatId = selectedFormat.Id; _lastRegionOptionFormatId = selectedFormat.Id;
AddFormatCommand.NotifyCanExecuteChanged(); AddFormatCommand.NotifyCanExecuteChanged();
DirectPrepareCommand.NotifyCanExecuteChanged();
DirectStartCommand.NotifyCanExecuteChanged(); DirectStartCommand.NotifyCanExecuteChanged();
} }

View File

@@ -1141,7 +1141,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
return await GetTurnoutPhotoRegionLevelOptionsAsync(turnoutPhotoMode, cancellationToken).ConfigureAwait(false); 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> 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)); regionOptions.AddRange(CreateScheduleRegionGroupOptions(options, electionType));
} }
@@ -1522,7 +1524,12 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
private BroadcastPhase ResolveScheduleRefreshPhase(FormatTemplateDefinition template) 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.Counting
: BroadcastPhase; : BroadcastPhase;
} }
@@ -1541,9 +1548,9 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
private bool ShouldUseTurnoutPhotoRegionLevelOptions(FormatTemplateDefinition? template) private bool ShouldUseTurnoutPhotoRegionLevelOptions(FormatTemplateDefinition? template)
{ {
return BroadcastPhase == BroadcastPhase.PreElection && return template is not null &&
template is not null && (string.Equals(template.Name, "투표율", StringComparison.Ordinal) ||
string.Equals(template.Name, "투표율_사진", StringComparison.Ordinal); string.Equals(template.Name, "투표율_사진", StringComparison.Ordinal));
} }
private static int ResolveRequiredCandidateCount(FormatTemplateDefinition template) private static int ResolveRequiredCandidateCount(FormatTemplateDefinition template)
@@ -1616,7 +1623,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
return [CreateSingleScheduleRegionTarget(electionType)]; return [CreateSingleScheduleRegionTarget(electionType)];
} }
var options = await GetScheduleDistrictOptionsAsync(electionType, cancellationToken).ConfigureAwait(false); var options = await GetScheduleDistrictOptionsAsync(electionType, template, cancellationToken).ConfigureAwait(false);
if (options.Count == 0) if (options.Count == 0)
{ {
return Array.Empty<ScheduleRegionTarget>(); return Array.Empty<ScheduleRegionTarget>();
@@ -1629,6 +1636,26 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
.ToArray(); .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 return item.RegionScope switch
{ {
ScheduleRegionScope.StationRegions => ResolveStationRegionTargets(options, electionType, station), ScheduleRegionScope.StationRegions => ResolveStationRegionTargets(options, electionType, station),
@@ -1669,16 +1696,18 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
[target], [target],
includeNationalSlot: false, includeNationalSlot: false,
includeRegionalBoardSlots: false, includeRegionalBoardSlots: false,
includeEmptyRegionalBoardSlots: false,
maxRegionalSlots: 0, maxRegionalSlots: 0,
cancellationToken) cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
} }
var refreshPhase = ResolveScheduleRefreshPhase(template);
var refreshResult = await _apiClient var refreshResult = await _apiClient
.RefreshAsync(ResolveScheduleRefreshPhase(template), electionType, target.DisplayName, target.DistrictCode, cancellationToken) .RefreshAsync(refreshPhase, electionType, target.DisplayName, target.DistrictCode, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
return CreateSnapshotFromRefreshResult(electionType, refreshResult); return CreateSnapshotFromRefreshResult(electionType, refreshResult, refreshPhase);
} }
public Task<ElectionDataSnapshot> GetAggregateScheduleSnapshotAsync( public Task<ElectionDataSnapshot> GetAggregateScheduleSnapshotAsync(
@@ -1714,6 +1743,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
regionTargets, regionTargets,
includeNationalSlot: true, includeNationalSlot: true,
includeRegionalBoardSlots: true, includeRegionalBoardSlots: true,
includeEmptyRegionalBoardSlots: false,
maxRegionalSlots: 4, maxRegionalSlots: 4,
cancellationToken); cancellationToken);
} }
@@ -1725,6 +1755,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
regionTargets, regionTargets,
includeNationalSlot: false, includeNationalSlot: false,
includeRegionalBoardSlots: true, includeRegionalBoardSlots: true,
includeEmptyRegionalBoardSlots: IsNormalPreElectionTurnoutDistrictBoardTemplate(template),
maxRegionalSlots: ResolveRegionalTurnoutBoardMaxSlots(template), maxRegionalSlots: ResolveRegionalTurnoutBoardMaxSlots(template),
cancellationToken); 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( private async Task<IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption>> GetScheduleDistrictOptionsAsync(
string electionType, string electionType,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -3696,6 +3798,21 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
return "기초단체장"; 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); return ResolveScheduleElectionType(template?.Name, preferredElectionType);
} }
@@ -3707,6 +3824,11 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
return historicalWinnerElectionType; return historicalWinnerElectionType;
} }
if (ScheduleTemplatePolicy.IsHistoricalTurnoutFormat(resolvedFormatName))
{
return "광역단체장";
}
if (IsBottomTurnoutDistrictTemplateName(resolvedFormatName)) if (IsBottomTurnoutDistrictTemplateName(resolvedFormatName))
{ {
return "기초단체장"; return "기초단체장";
@@ -3749,11 +3871,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
if (BroadcastPhase == BroadcastPhase.PreElection) if (BroadcastPhase == BroadcastPhase.PreElection)
{ {
return SupportsPreElectionTurnout(electionType: preferredElectionType) return ResolvePreElectionTurnoutElectionType(preferredElectionType);
? preferredElectionType!
: SupportsPreElectionTurnout(ElectionType)
? ElectionType
: "광역단체장";
} }
return string.IsNullOrWhiteSpace(preferredElectionType) return string.IsNullOrWhiteSpace(preferredElectionType)
@@ -3761,6 +3879,15 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
: preferredElectionType; : preferredElectionType;
} }
private string ResolvePreElectionTurnoutElectionType(string? preferredElectionType = null)
{
return SupportsPreElectionTurnout(electionType: preferredElectionType)
? preferredElectionType!
: SupportsPreElectionTurnout(ElectionType)
? ElectionType
: "광역단체장";
}
private static bool TryResolveHistoricalWinnerElectionType(string formatName, out string electionType) private static bool TryResolveHistoricalWinnerElectionType(string formatName, out string electionType)
{ {
var sourceName = System.IO.Path.GetFileNameWithoutExtension( var sourceName = System.IO.Path.GetFileNameWithoutExtension(
@@ -3839,6 +3966,38 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
.ToArray(); .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( private static bool MatchesScheduleRegionGroup(
SbsElectionApiClient.DistrictSelectionOption option, SbsElectionApiClient.DistrictSelectionOption option,
string regionCode, string regionCode,
@@ -3873,7 +4032,8 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
private ElectionDataSnapshot CreateSnapshotFromRefreshResult( private ElectionDataSnapshot CreateSnapshotFromRefreshResult(
string electionType, string electionType,
SbsElectionApiClient.SbsElectionRefreshResult refreshResult) SbsElectionApiClient.SbsElectionRefreshResult refreshResult,
BroadcastPhase broadcastPhase)
{ {
var districtName = string.IsNullOrWhiteSpace(refreshResult.DistrictName) var districtName = string.IsNullOrWhiteSpace(refreshResult.DistrictName)
? refreshResult.ElectionDistrictName ? refreshResult.ElectionDistrictName
@@ -3888,7 +4048,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
return new ElectionDataSnapshot return new ElectionDataSnapshot
{ {
BroadcastPhase = BroadcastPhase, BroadcastPhase = broadcastPhase,
ElectionType = electionType, ElectionType = electionType,
DistrictName = districtName ?? string.Empty, DistrictName = districtName ?? string.Empty,
DistrictCode = refreshResult.DistrictCode ?? string.Empty, DistrictCode = refreshResult.DistrictCode ?? string.Empty,
@@ -4682,6 +4842,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
IReadOnlyList<ScheduleRegionTarget> regionTargets, IReadOnlyList<ScheduleRegionTarget> regionTargets,
bool includeNationalSlot, bool includeNationalSlot,
bool includeRegionalBoardSlots, bool includeRegionalBoardSlots,
bool includeEmptyRegionalBoardSlots,
int maxRegionalSlots, int maxRegionalSlots,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
@@ -4722,17 +4883,18 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
foreach (var target in selectedTargets.Take(Math.Max(maxRegionalSlots, 0))) foreach (var target in selectedTargets.Take(Math.Max(maxRegionalSlots, 0)))
{ {
var slotItem = FindTurnoutOverviewItem(turnoutOverview.Items, target); var slotItem = FindTurnoutOverviewItem(turnoutOverview.Items, target);
if (!HasPositiveTurnoutOverview(slotItem)) var hasTurnoutData = HasPositiveTurnoutOverview(slotItem);
if (!includeEmptyRegionalBoardSlots && !hasTurnoutData)
{ {
continue; continue;
} }
var resolvedSlotItem = slotItem!;
turnoutBoardSlots.Add(new TurnoutBoardSlotEntry( turnoutBoardSlots.Add(new TurnoutBoardSlotEntry(
nextSlot++, nextSlot++,
ResolveTurnoutBoardDistrictLabel(electionType, resolvedSlotItem, target), ResolveTurnoutBoardDistrictLabel(electionType, slotItem, target),
resolvedSlotItem.TurnoutRate, hasTurnoutData ? slotItem!.TurnoutRate : 0d,
RegionLabel: ResolveTurnoutBoardRegionLabel(resolvedSlotItem, target))); RegionLabel: ResolveTurnoutBoardRegionLabel(slotItem, target),
HasTurnoutData: hasTurnoutData));
} }
} }
@@ -4757,7 +4919,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
return new ElectionDataSnapshot return new ElectionDataSnapshot
{ {
BroadcastPhase = BroadcastPhase, BroadcastPhase = BroadcastPhase.PreElection,
ElectionType = electionType, ElectionType = electionType,
DistrictName = string.IsNullOrWhiteSpace(districtName) ? regionName : districtName, DistrictName = string.IsNullOrWhiteSpace(districtName) ? regionName : districtName,
DistrictCode = primaryItem?.DistrictCode ?? primaryTarget?.DistrictCode ?? string.Empty, DistrictCode = primaryItem?.DistrictCode ?? primaryTarget?.DistrictCode ?? string.Empty,
@@ -4806,17 +4968,17 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
private static string ResolveTurnoutBoardDistrictLabel( private static string ResolveTurnoutBoardDistrictLabel(
string electionType, string electionType,
SbsElectionApiClient.TurnoutOverviewItem item, SbsElectionApiClient.TurnoutOverviewItem? item,
ScheduleRegionTarget target) ScheduleRegionTarget target)
{ {
if (string.Equals(electionType, "기초단체장", StringComparison.Ordinal)) if (string.Equals(electionType, "기초단체장", StringComparison.Ordinal))
{ {
return FirstNonWhiteSpace( return FirstNonWhiteSpace(
item.DistrictName, item?.DistrictName,
target.DistrictName, target.DistrictName,
item.DisplayName, item?.DisplayName,
target.DisplayName, target.DisplayName,
item.RegionName, item?.RegionName,
target.RegionName); target.RegionName);
} }
@@ -4824,13 +4986,13 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
} }
private static string ResolveTurnoutBoardRegionLabel( private static string ResolveTurnoutBoardRegionLabel(
SbsElectionApiClient.TurnoutOverviewItem item, SbsElectionApiClient.TurnoutOverviewItem? item,
ScheduleRegionTarget target) ScheduleRegionTarget target)
{ {
return FirstNonWhiteSpace( return FirstNonWhiteSpace(
item.RegionName, item?.RegionName,
target.RegionName, target.RegionName,
item.DisplayName, item?.DisplayName,
target.DisplayName); target.DisplayName);
} }
@@ -4942,6 +5104,12 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
IsTopTurnoutDistrictBoardTemplate(template); 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) private static bool IsTopTurnoutDistrictBoardTemplate(FormatTemplateDefinition template)
{ {
return template.RecommendedChannel == BroadcastChannel.TopLeft && return template.RecommendedChannel == BroadcastChannel.TopLeft &&

Binary file not shown.

View File

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

View File

@@ -274,6 +274,7 @@ internal static class CutFileAudit
payload.CounterNumberKeys, payload.CounterNumberKeys,
Array.Empty<KarismaChartCellUpdate>(), Array.Empty<KarismaChartCellUpdate>(),
Array.Empty<KarismaPositionUpdate>(), Array.Empty<KarismaPositionUpdate>(),
Array.Empty<KarismaCropKeyUpdate>(),
payload.StyleColorUpdates, payload.StyleColorUpdates,
payload.VisibilityUpdates, payload.VisibilityUpdates,
CancellationToken.None) CancellationToken.None)
@@ -524,6 +525,10 @@ internal static class CutFileAudit
string scenario, string scenario,
string frameLabel) 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 fileName = $"{result.Index:000}_{SanitizeFileName(result.FolderName)}_{SanitizeFileName(result.BaseName)}_{scenario.ToLowerInvariant()}_{frameLabel}.png";
var outputPath = Path.Combine(options.CapturePath, fileName); var outputPath = Path.Combine(options.CapturePath, fileName);
Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!); Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
@@ -961,6 +966,12 @@ internal static class CutFileAudit
{ {
var normalizedBaseName = NormalizeVariantName(result.BaseName); var normalizedBaseName = NormalizeVariantName(result.BaseName);
var explicitBaseName = TryResolveExplicitRgbSpec(result.FolderName, normalizedBaseName); var explicitBaseName = TryResolveExplicitRgbSpec(result.FolderName, normalizedBaseName);
if (explicitBaseName is not null && string.IsNullOrWhiteSpace(explicitBaseName))
{
mappingKind = "explicit-none";
return null;
}
if (!string.IsNullOrWhiteSpace(explicitBaseName) && if (!string.IsNullOrWhiteSpace(explicitBaseName) &&
rgbCatalog.TryGetValue(BuildRgbCatalogKey(result.FolderName, explicitBaseName), out var explicitSpec)) 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_민방", "사전_역대당선", "사전_역대당선자", "사전_역대당선자_기초단체장");
Add("Elect2026_Normal_민방", "사전_역대당선_교육감", "사전_역대당선자_교육감"); Add("Elect2026_Normal_민방", "사전_역대당선_교육감", "사전_역대당선자_교육감");
Add("Elect2026_Normal_민방", "이시각1위_광역단체장", "이시각1위_광역단체장"); Add("Elect2026_Normal_민방", string.Empty, "사전_역대투표율");
Add("Elect2026_Normal_민방", "이시각1위_광역단체장_5760", "이시각1위_광역단체장_5760"); Add("Elect2026_Normal_민방", "이시각1위_광역단체장", "이시각1위_광역단체장", "이시각1위_광역단체장_HD");
Add("Elect2026_Normal_민방", "이시각1위_기초단체장(5760동일)", "이시각1위_기초단체장"); 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_Normal_민방", "판세_광역단체장", "판세_광역단체장", "판세_기초단체장", "역대시도판세_광역단체장", "역대시도판세_기초단체장");
Add("Elect2026_Bottom_민방", "1-2위, 1-3위, 이시각1위", "1-2위_광역단체장", "1-2위_기초단체장", "1-3위_광역단체장", "1-3위_기초단체장", "1위_광역단체장", "1위_기초단체장"); Add("Elect2026_Bottom_민방", "1-2위, 1-3위, 이시각1위", "1-2위_광역단체장", "1-2위_기초단체장", "1-3위_광역단체장", "1-3위_기초단체장", "1위_광역단체장", "1위_기초단체장");
Add("Elect2026_Bottom_민방", "당선", "당선_광역단체장", "당선_광역의원", "당선_기초단체장", "당선_기초의원"); Add("Elect2026_Bottom_민방", "당선", "당선_광역단체장", "당선_광역의원", "당선_기초단체장", "당선_기초의원");
Add("Elect2026_Bottom_민방", "모든후보", "전후보_광역단체장", "전후보_기초단체장"); Add("Elect2026_Bottom_민방", "모든후보", "전후보_광역단체장", "전후보_기초단체장");
@@ -1296,29 +1308,62 @@ internal static class CutFileAudit
private static PgmWindow? TryFindPgmWindow() private static PgmWindow? TryFindPgmWindow()
{ {
var process = Process.GetProcessesByName("Tornado3") var handle = IntPtr.Zero;
.FirstOrDefault(candidate => string.Equals(candidate.MainWindowTitle, "PGM", StringComparison.Ordinal)); var tornadoProcessIds = Process.GetProcessesByName("Tornado3")
if (process is null || process.MainWindowHandle == IntPtr.Zero) .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; 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 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) private static bool TryGetDwmExtendedFrameBounds(IntPtr handle, out Rect bounds)
@@ -1544,6 +1589,27 @@ internal static class CutFileAudit
return value.Replace("|", "\\|", StringComparison.Ordinal); 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")] [DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)] [return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetWindowRect(IntPtr hWnd, out NativeRect lpRect); 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 static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, out NativeRect pvAttribute, int cbAttribute);
private const int DwmwaExtendedFrameBounds = 9; 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 AuditScene(string ScenePath, string RelativePath, string FolderName, string BaseName, BroadcastChannel Channel);
private readonly record struct AuditChannelBinding(int OutputChannelIndex, int LayerNo); 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\ITornado3Adapter.cs" Link="AppSource\Services\ITornado3Adapter.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaChartCellUpdate.cs" Link="AppSource\Services\KarismaChartCellUpdate.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\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\KarismaEventHandler.cs" Link="AppSource\Services\KarismaEventHandler.cs" />
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaPositionUpdate.cs" Link="AppSource\Services\KarismaPositionUpdate.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" /> <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;
using System.Globalization;
using System.Net.Sockets; using System.Net.Sockets;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text.Json; using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using KAsyncEngineLib; using KAsyncEngineLib;
@@ -319,6 +321,12 @@ if (args.Length > 0 && string.Equals(args[0], "--validate-live-cuts", StringComp
return; 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)) if (args.Length > 0 && string.Equals(args[0], "--validate-current-api-cuts", StringComparison.OrdinalIgnoreCase))
{ {
Environment.ExitCode = await CurrentApiCutDiagnostics.RunAsync(args[1..]).ConfigureAwait(false); 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); var operationResult = ApplySceneOperation(handler, scene!, operation, options.Connection.Timeout);
if (!string.Equals(operationResult.Result, eKResult.RESULT_SUCCESS.ToString(), StringComparison.Ordinal)) 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( completion.TrySetResult(
new SaveSceneImageProbeResult( new SaveSceneImageProbeResult(
true, true,
@@ -1001,31 +1017,38 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
} }
} }
var positionKeyUpdates = new List<PositionKeyUpdate>();
if (options.PositionKey is not null) if (options.PositionKey is not null)
{
positionKeyUpdates.Add(options.PositionKey);
}
positionKeyUpdates.AddRange(options.PositionKeys);
foreach (var positionKeyUpdate in positionKeyUpdates)
{ {
Console.WriteLine( Console.WriteLine(
$"[SAVE-IMAGE] Setting position key object={options.PositionKey.ObjectName} index={options.PositionKey.KeyIndex} " + $"[SAVE-IMAGE] Setting position key object={positionKeyUpdate.ObjectName} index={positionKeyUpdate.KeyIndex} " +
$"value=({options.PositionKey.X},{options.PositionKey.Y},{options.PositionKey.Z}) vector={options.PositionKey.VectorType}..."); $"value=({positionKeyUpdate.X},{positionKeyUpdate.Y},{positionKeyUpdate.Z}) vector={positionKeyUpdate.VectorType}...");
var sceneObject = scene.GetObject(options.PositionKey.ObjectName); var sceneObject = scene.GetObject(positionKeyUpdate.ObjectName);
if (sceneObject is null) if (sceneObject is null)
{ {
completion.TrySetResult( 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; return;
} }
handler.ResetPositionKeyTask(); handler.ResetPositionKeyTask();
sceneObject.SetPositionKey( sceneObject.SetPositionKey(
options.PositionKey.KeyIndex, positionKeyUpdate.KeyIndex,
options.PositionKey.X, positionKeyUpdate.X,
options.PositionKey.Y, positionKeyUpdate.Y,
options.PositionKey.Z, positionKeyUpdate.Z,
options.PositionKey.VectorType); positionKeyUpdate.VectorType);
if (!WaitForTaskWithMessagePump(handler.PositionKeyTask, options.Connection.Timeout)) if (!WaitForTaskWithMessagePump(handler.PositionKeyTask, options.Connection.Timeout))
{ {
completion.TrySetResult( 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; return;
} }
@@ -1033,7 +1056,7 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
if (positionKeyResult != eKResult.RESULT_SUCCESS) if (positionKeyResult != eKResult.RESULT_SUCCESS)
{ {
completion.TrySetResult( 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; return;
} }
} }
@@ -1179,53 +1202,161 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
} }
} }
var outputDirectory = Path.GetDirectoryName(options.OutputPath); foreach (var positionUpdate in options.PostPositions)
if (!string.IsNullOrWhiteSpace(outputDirectory))
{ {
Directory.CreateDirectory(outputDirectory); Console.WriteLine(
} $"[SAVE-IMAGE] Setting post-chart position object={positionUpdate.ObjectName} " +
$"value=({positionUpdate.X},{positionUpdate.Y},{positionUpdate.Z}) vector={positionUpdate.VectorType}...");
if (File.Exists(options.OutputPath)) var sceneObject = scene.GetObject(positionUpdate.ObjectName);
{ if (sceneObject is null)
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))
{ {
var info = new FileInfo(options.OutputPath); completion.TrySetResult(
if (info.Length > 0) new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{positionUpdate.ObjectName}' was not found."));
{ return;
completion.TrySetResult(
new SaveSceneImageProbeResult(true, "SUCCESS", "SUCCESS", options.OutputPath, $"Saved {info.Length} bytes."));
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) catch (Exception ex)
{ {
@@ -3430,6 +3561,9 @@ internal sealed record SaveSceneImageOptions(
int Width, int Width,
int Height, int Height,
int Frame, int Frame,
IReadOnlyList<int> Frames,
string? OutputDirectory,
string OutputPattern,
string? SetObjectName, string? SetObjectName,
string? SetObjectValue, string? SetObjectValue,
string? VisibleObjectName, string? VisibleObjectName,
@@ -3442,6 +3576,9 @@ internal sealed record SaveSceneImageOptions(
PositionUpdate? Position, PositionUpdate? Position,
IReadOnlyList<PositionUpdate> Positions, IReadOnlyList<PositionUpdate> Positions,
PositionKeyUpdate? PositionKey, PositionKeyUpdate? PositionKey,
IReadOnlyList<PositionKeyUpdate> PositionKeys,
IReadOnlyList<PositionUpdate> PostPositions,
IReadOnlyList<PositionKeyUpdate> PostPositionKeys,
string? ChartObjectName, string? ChartObjectName,
string? ChartCsvPath, string? ChartCsvPath,
IReadOnlyList<ChartCellUpdate> ChartCells, IReadOnlyList<ChartCellUpdate> ChartCells,
@@ -3455,6 +3592,9 @@ internal sealed record SaveSceneImageOptions(
string? scenePath = null; string? scenePath = null;
string? sceneAlias = null; string? sceneAlias = null;
string? outputPath = null; string? outputPath = null;
string? outputDirectory = null;
string outputPattern = "frame_{0:D4}.png";
IReadOnlyList<int> frames = Array.Empty<int>();
string? setObjectName = null; string? setObjectName = null;
string? setObjectValue = null; string? setObjectValue = null;
string? visibleObjectName = null; string? visibleObjectName = null;
@@ -3474,6 +3614,9 @@ internal sealed record SaveSceneImageOptions(
string? positionKeyObjectName = null; string? positionKeyObjectName = null;
int positionKeyIndex = 1; int positionKeyIndex = 1;
string? positionKeyRaw = null; string? positionKeyRaw = null;
string? positionKeysRaw = null;
string? postPositionsRaw = null;
string? postPositionKeysRaw = null;
string? chartObjectName = null; string? chartObjectName = null;
string? chartCsvPath = null; string? chartCsvPath = null;
string? chartCellsRaw = null; string? chartCellsRaw = null;
@@ -3497,6 +3640,12 @@ internal sealed record SaveSceneImageOptions(
case "--output" when index + 1 < args.Length: case "--output" when index + 1 < args.Length:
outputPath = args[++index]; outputPath = args[++index];
break; 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: case "--set-object" when index + 1 < args.Length:
setObjectName = args[++index]; setObjectName = args[++index];
break; break;
@@ -3561,6 +3710,15 @@ internal sealed record SaveSceneImageOptions(
case "--position-key" when index + 1 < args.Length: case "--position-key" when index + 1 < args.Length:
positionKeyRaw = args[++index]; positionKeyRaw = args[++index];
break; 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: case "--chart-object" when index + 1 < args.Length:
chartObjectName = args[++index]; chartObjectName = args[++index];
break; break;
@@ -3591,6 +3749,9 @@ internal sealed record SaveSceneImageOptions(
frame = parsedFrame; frame = parsedFrame;
index++; index++;
break; 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."); 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."); throw new ArgumentException("--output is required.");
} }
@@ -3618,6 +3790,9 @@ internal sealed record SaveSceneImageOptions(
width, width,
height, height,
frame, frame,
frames,
outputDirectory,
outputPattern,
setObjectName, setObjectName,
setObjectValue, setObjectValue,
visibleObjectName, visibleObjectName,
@@ -3630,6 +3805,9 @@ internal sealed record SaveSceneImageOptions(
ParsePosition(positionObjectName, positionRaw), ParsePosition(positionObjectName, positionRaw),
ParsePositions(positionsRaw), ParsePositions(positionsRaw),
ParsePositionKey(positionKeyObjectName, positionKeyIndex, positionKeyRaw), ParsePositionKey(positionKeyObjectName, positionKeyIndex, positionKeyRaw),
ParsePositionKeys(positionKeysRaw),
ParsePositions(postPositionsRaw),
ParsePositionKeys(postPositionKeysRaw),
chartObjectName, chartObjectName,
chartCsvPath, chartCsvPath,
ParseChartCells(chartCellsRaw), ParseChartCells(chartCellsRaw),
@@ -3638,6 +3816,53 @@ internal sealed record SaveSceneImageOptions(
ParsePathModifications(modifyPathRaw)); 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) private static CloneObjectUpdate? ParseCloneObject(string? sourceObjectName, string? variableName)
{ {
if (string.IsNullOrWhiteSpace(sourceObjectName) || string.IsNullOrWhiteSpace(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); 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) private static IReadOnlyList<ChartCellUpdate> ParseChartCells(string? raw)
{ {
if (string.IsNullOrWhiteSpace(raw)) if (string.IsNullOrWhiteSpace(raw))
@@ -4410,6 +4667,8 @@ internal sealed class SceneValidationOperation
public int A { get; set; } = 255; public int A { get; set; } = 255;
public bool Visible { get; set; } public bool Visible { get; set; }
public bool ContinueOnFailure { get; set; }
} }
internal sealed record SceneOperationValidationResult(string ObjectName, string Method, string Payload, string Result, string Detail); internal sealed record SceneOperationValidationResult(string ObjectName, string Method, string Payload, string Result, string Detail);